feat: add knowledge base M1 APIs and workplace spend trend
This commit is contained in:
parent
b2355104cf
commit
9a33eb00a5
144
sql/20260804_kb_knowledge.sql
Normal file
144
sql/20260804_kb_knowledge.sql
Normal file
@ -0,0 +1,144 @@
|
||||
-- 物流业务知识库 M1:分类 + 文章
|
||||
-- 执行库:OMS 业务库
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `t_kb_category` (
|
||||
`Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||
`Code` VARCHAR(64) NOT NULL COMMENT '稳定键,如 pricing',
|
||||
`Name` VARCHAR(128) NOT NULL COMMENT '展示名(中文)',
|
||||
`SortOrder` INT NOT NULL DEFAULT 0 COMMENT '排序,越小越前',
|
||||
`Enabled` TINYINT NOT NULL DEFAULT 1 COMMENT '是否启用:0-否,1-是',
|
||||
`IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是',
|
||||
`ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT '并发戳',
|
||||
`CreateUserId` INT NOT NULL DEFAULT 0 COMMENT '创建人',
|
||||
`CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`UpdateUserId` INT NULL COMMENT '更新人',
|
||||
`UpdateTime` DATETIME NULL COMMENT '更新时间',
|
||||
PRIMARY KEY (`Id`),
|
||||
UNIQUE KEY `uk_kb_category_code` (`Code`),
|
||||
KEY `idx_kb_category_enabled_sort` (`Enabled`, `SortOrder`)
|
||||
) COMMENT='知识库分类';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `t_kb_article` (
|
||||
`Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||
`CategoryId` INT NOT NULL COMMENT '分类 Id',
|
||||
`Title` VARCHAR(200) NOT NULL COMMENT '标题(中文)',
|
||||
`Summary` VARCHAR(500) NULL COMMENT '列表摘要',
|
||||
`Content` LONGTEXT NOT NULL COMMENT 'Markdown 正文(中文)',
|
||||
`Audience` VARCHAR(16) NOT NULL DEFAULT 'customer' COMMENT '受众 customer/internal',
|
||||
`Status` VARCHAR(16) NOT NULL DEFAULT 'draft' COMMENT 'draft/published/offline',
|
||||
`Tags` VARCHAR(256) NULL COMMENT '逗号分隔标签',
|
||||
`RelatedPath` VARCHAR(256) NULL COMMENT '站内关联 path',
|
||||
`RelatedLabel` VARCHAR(64) NULL COMMENT '关联按钮文案',
|
||||
`IsPinned` TINYINT NOT NULL DEFAULT 0 COMMENT '置顶:0-否,1-是',
|
||||
`SortOrder` INT NOT NULL DEFAULT 0 COMMENT '同类排序',
|
||||
`PublishTime` DATETIME NULL COMMENT '发布时间',
|
||||
`ExpireTime` DATETIME NULL COMMENT '过期时间',
|
||||
`ViewCount` INT NOT NULL DEFAULT 0 COMMENT '浏览次数',
|
||||
`IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是',
|
||||
`ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT '并发戳',
|
||||
`CreateUserId` INT NOT NULL DEFAULT 0 COMMENT '创建人',
|
||||
`CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`UpdateUserId` INT NULL COMMENT '更新人',
|
||||
`UpdateTime` DATETIME NULL COMMENT '更新时间',
|
||||
PRIMARY KEY (`Id`),
|
||||
KEY `idx_kb_article_list` (`Status`, `Audience`, `CategoryId`, `IsPinned`, `SortOrder`),
|
||||
KEY `idx_kb_article_publish` (`Status`, `Audience`, `PublishTime`),
|
||||
KEY `idx_kb_article_hot` (`Status`, `Audience`, `IsPinned`, `ViewCount`)
|
||||
) COMMENT='知识库文章';
|
||||
|
||||
-- 种子分类(幂等:Code 唯一)
|
||||
INSERT INTO `t_kb_category` (`Code`, `Name`, `SortOrder`, `Enabled`)
|
||||
SELECT 'pricing', '查价与计费', 10, 1 FROM DUAL
|
||||
WHERE NOT EXISTS (SELECT 1 FROM `t_kb_category` WHERE `Code` = 'pricing' AND `IsDeleted` = 0);
|
||||
|
||||
INSERT INTO `t_kb_category` (`Code`, `Name`, `SortOrder`, `Enabled`)
|
||||
SELECT 'order', '下单与轨迹', 20, 1 FROM DUAL
|
||||
WHERE NOT EXISTS (SELECT 1 FROM `t_kb_category` WHERE `Code` = 'order' AND `IsDeleted` = 0);
|
||||
|
||||
INSERT INTO `t_kb_category` (`Code`, `Name`, `SortOrder`, `Enabled`)
|
||||
SELECT 'exception', '问题件与售后', 30, 1 FROM DUAL
|
||||
WHERE NOT EXISTS (SELECT 1 FROM `t_kb_category` WHERE `Code` = 'exception' AND `IsDeleted` = 0);
|
||||
|
||||
INSERT INTO `t_kb_category` (`Code`, `Name`, `SortOrder`, `Enabled`)
|
||||
SELECT 'customs', '禁限运与清关', 40, 1 FROM DUAL
|
||||
WHERE NOT EXISTS (SELECT 1 FROM `t_kb_category` WHERE `Code` = 'customs' AND `IsDeleted` = 0);
|
||||
|
||||
INSERT INTO `t_kb_category` (`Code`, `Name`, `SortOrder`, `Enabled`)
|
||||
SELECT 'account', '账户与充值', 50, 1 FROM DUAL
|
||||
WHERE NOT EXISTS (SELECT 1 FROM `t_kb_category` WHERE `Code` = 'account' AND `IsDeleted` = 0);
|
||||
|
||||
INSERT INTO `t_kb_category` (`Code`, `Name`, `SortOrder`, `Enabled`)
|
||||
SELECT 'dropship', '代发与店铺', 60, 1 FROM DUAL
|
||||
WHERE NOT EXISTS (SELECT 1 FROM `t_kb_category` WHERE `Code` = 'dropship' AND `IsDeleted` = 0);
|
||||
|
||||
-- 种子 FAQ(幂等:按 Title + Category Code)
|
||||
INSERT INTO `t_kb_article`
|
||||
(`CategoryId`, `Title`, `Summary`, `Content`, `Audience`, `Status`, `Tags`, `RelatedPath`, `RelatedLabel`, `IsPinned`, `SortOrder`, `PublishTime`, `ViewCount`)
|
||||
SELECT c.`Id`,
|
||||
'如何查价?计费重量怎么算?',
|
||||
'说明计费重、体积重与卖价取整规则。',
|
||||
'## 查价入口\n\n工作台或菜单进入「查价」,填写目的国、重量与尺寸即可预估运费。\n\n## 计费重量\n\n系统按 **实重** 与 **体积重** 取较大者作为计费重。体积重公式以销售产品配置为准。\n\n## 提示\n\n查价结果为预估,实际以出库计费为准。',
|
||||
'customer', 'published', '查价,计费', '/order/priceCheck', '去查价', 1, 10, NOW(), 0
|
||||
FROM `t_kb_category` c
|
||||
WHERE c.`Code` = 'pricing' AND c.`IsDeleted` = 0
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM `t_kb_article` a
|
||||
WHERE a.`Title` = '如何查价?计费重量怎么算?' AND a.`CategoryId` = c.`Id` AND a.`IsDeleted` = 0
|
||||
);
|
||||
|
||||
INSERT INTO `t_kb_article`
|
||||
(`CategoryId`, `Title`, `Summary`, `Content`, `Audience`, `Status`, `Tags`, `RelatedPath`, `RelatedLabel`, `IsPinned`, `SortOrder`, `PublishTime`, `ViewCount`)
|
||||
SELECT c.`Id`,
|
||||
'下单后如何查看轨迹?',
|
||||
'订单列表与轨迹查询入口说明。',
|
||||
'## 查看轨迹\n\n1. 进入「我的订单」,找到目标运单。\n2. 点击轨迹图标或进入详情查看最新节点。\n\n上网后才会出现承运商轨迹;未上网前可能仅有内部操作节点。',
|
||||
'customer', 'published', '轨迹,订单', NULL, NULL, 1, 10, NOW(), 0
|
||||
FROM `t_kb_category` c
|
||||
WHERE c.`Code` = 'order' AND c.`IsDeleted` = 0
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM `t_kb_article` a
|
||||
WHERE a.`Title` = '下单后如何查看轨迹?' AND a.`CategoryId` = c.`Id` AND a.`IsDeleted` = 0
|
||||
);
|
||||
|
||||
INSERT INTO `t_kb_article`
|
||||
(`CategoryId`, `Title`, `Summary`, `Content`, `Audience`, `Status`, `Tags`, `RelatedPath`, `RelatedLabel`, `IsPinned`, `SortOrder`, `PublishTime`, `ViewCount`)
|
||||
SELECT c.`Id`,
|
||||
'什么是问题件?如何处理?',
|
||||
'问题件常见原因与处理路径。',
|
||||
'## 什么是问题件\n\n因地址不全、禁限运、欠费、清关资料缺失等原因,包裹无法继续流转时会标记为问题件。\n\n## 怎么处理\n\n在订单列表筛选「问题件」,按提示补全资料或联系客服。处理完成后会重新放行。',
|
||||
'customer', 'published', '问题件,售后', NULL, NULL, 0, 20, NOW(), 0
|
||||
FROM `t_kb_category` c
|
||||
WHERE c.`Code` = 'exception' AND c.`IsDeleted` = 0
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM `t_kb_article` a
|
||||
WHERE a.`Title` = '什么是问题件?如何处理?' AND a.`CategoryId` = c.`Id` AND a.`IsDeleted` = 0
|
||||
);
|
||||
|
||||
INSERT INTO `t_kb_article`
|
||||
(`CategoryId`, `Title`, `Summary`, `Content`, `Audience`, `Status`, `Tags`, `RelatedPath`, `RelatedLabel`, `IsPinned`, `SortOrder`, `PublishTime`, `ViewCount`)
|
||||
SELECT c.`Id`,
|
||||
'账户余额不足怎么办?',
|
||||
'充值入口与余额扣费说明。',
|
||||
'## 充值\n\n工作台余额卡片或财务菜单可发起充值。到账后余额可用于下单扣费。\n\n## 注意\n\n子账号余额归属主客户;扣费失败时订单可能无法提交或出库。',
|
||||
'customer', 'published', '充值,余额', NULL, NULL, 0, 10, NOW(), 0
|
||||
FROM `t_kb_category` c
|
||||
WHERE c.`Code` = 'account' AND c.`IsDeleted` = 0
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM `t_kb_article` a
|
||||
WHERE a.`Title` = '账户余额不足怎么办?' AND a.`CategoryId` = c.`Id` AND a.`IsDeleted` = 0
|
||||
);
|
||||
|
||||
INSERT INTO `t_kb_article`
|
||||
(`CategoryId`, `Title`, `Summary`, `Content`, `Audience`, `Status`, `Tags`, `RelatedPath`, `RelatedLabel`, `IsPinned`, `SortOrder`, `PublishTime`, `ViewCount`)
|
||||
SELECT c.`Id`,
|
||||
'禁限运物品有哪些?',
|
||||
'常见禁限运与申报注意点。',
|
||||
'## 常见禁限运\n\n电池、液体、粉末、管制刀具、仿牌等通常受限或禁运,以承运商与目的国法规为准。\n\n## 申报建议\n\n如实填写品名与货值,避免笼统描述(如 gifts)。不确定时可先咨询客服。',
|
||||
'customer', 'published', '禁运,清关', NULL, NULL, 0, 10, NOW(), 0
|
||||
FROM `t_kb_category` c
|
||||
WHERE c.`Code` = 'customs' AND c.`IsDeleted` = 0
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM `t_kb_article` a
|
||||
WHERE a.`Title` = '禁限运物品有哪些?' AND a.`CategoryId` = c.`Id` AND a.`IsDeleted` = 0
|
||||
);
|
||||
|
||||
8
sql/20260804_kb_menu_notes.sql
Normal file
8
sql/20260804_kb_menu_notes.sql
Normal file
@ -0,0 +1,8 @@
|
||||
-- 知识库 M1 菜单挂载说明
|
||||
-- 一级:知识库 route=/kb permissionCode=Knowledge
|
||||
-- 在菜单管理对该一级点「同步子菜单及按钮」,将自动创建:
|
||||
-- 分类管理 /kb/category permissionCode=Knowledge.Category
|
||||
-- 三级:Knowledge.Category.Query / Knowledge.Category.Edit
|
||||
-- 文章管理 /kb/article permissionCode=Knowledge.Article
|
||||
-- 三级:Knowledge.Article.Query / Knowledge.Article.Edit / Knowledge.Article.Publish
|
||||
-- Admin 角色启动时可自动合并上述权限(见 XhsRolePermissionSeedContributor.AdminKbPermissions)
|
||||
138
src/Oms.Application/Dtos/Kb/KbDtos.cs
Normal file
138
src/Oms.Application/Dtos/Kb/KbDtos.cs
Normal file
@ -0,0 +1,138 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Oms.Application.Dtos.Kb;
|
||||
|
||||
public static class KbAudienceCodes
|
||||
{
|
||||
public const string Customer = "customer";
|
||||
public const string Internal = "internal";
|
||||
}
|
||||
|
||||
public static class KbArticleStatusCodes
|
||||
{
|
||||
public const string Draft = "draft";
|
||||
public const string Published = "published";
|
||||
public const string Offline = "offline";
|
||||
}
|
||||
|
||||
public class KbCategoryOutput
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Code { get; set; } = string.Empty;
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public int SortOrder { get; set; }
|
||||
public bool Enabled { get; set; }
|
||||
}
|
||||
|
||||
public class KbCategorySaveInput
|
||||
{
|
||||
public int? Id { get; set; }
|
||||
|
||||
[Required]
|
||||
public string Code { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
public int SortOrder { get; set; }
|
||||
|
||||
public bool Enabled { get; set; } = true;
|
||||
}
|
||||
|
||||
public class KbCategoryDeleteInput
|
||||
{
|
||||
[Required]
|
||||
public int Id { get; set; }
|
||||
}
|
||||
|
||||
public class KbArticleOutput
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int CategoryId { get; set; }
|
||||
public string? CategoryName { get; set; }
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public string? Summary { get; set; }
|
||||
public string? Content { get; set; }
|
||||
public string Audience { get; set; } = KbAudienceCodes.Customer;
|
||||
public string Status { get; set; } = KbArticleStatusCodes.Draft;
|
||||
public string? Tags { get; set; }
|
||||
public string? RelatedPath { get; set; }
|
||||
public string? RelatedLabel { get; set; }
|
||||
public bool IsPinned { get; set; }
|
||||
public int SortOrder { get; set; }
|
||||
public DateTime? PublishTime { get; set; }
|
||||
public DateTime? ExpireTime { get; set; }
|
||||
public int ViewCount { get; set; }
|
||||
public DateTime? UpdateTime { get; set; }
|
||||
}
|
||||
|
||||
public class KbArticleListInput
|
||||
{
|
||||
public int Page { get; set; } = 1;
|
||||
public int PageSize { get; set; } = 30;
|
||||
public string? Keyword { get; set; }
|
||||
public int? CategoryId { get; set; }
|
||||
public string? Status { get; set; }
|
||||
public string? Audience { get; set; }
|
||||
public bool? IsPinned { get; set; }
|
||||
public bool IncludeContent { get; set; }
|
||||
}
|
||||
|
||||
public class KbArticleSaveInput
|
||||
{
|
||||
public int? Id { get; set; }
|
||||
|
||||
[Required]
|
||||
public int CategoryId { get; set; }
|
||||
|
||||
[Required]
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
public string? Summary { get; set; }
|
||||
|
||||
[Required]
|
||||
public string Content { get; set; } = string.Empty;
|
||||
|
||||
public string Audience { get; set; } = KbAudienceCodes.Customer;
|
||||
|
||||
public string Status { get; set; } = KbArticleStatusCodes.Draft;
|
||||
|
||||
public string? Tags { get; set; }
|
||||
|
||||
public string? RelatedPath { get; set; }
|
||||
|
||||
public string? RelatedLabel { get; set; }
|
||||
|
||||
public bool IsPinned { get; set; }
|
||||
|
||||
public int SortOrder { get; set; }
|
||||
|
||||
public DateTime? ExpireTime { get; set; }
|
||||
}
|
||||
|
||||
public class KbArticleIdInput
|
||||
{
|
||||
[Required]
|
||||
public int Id { get; set; }
|
||||
}
|
||||
|
||||
public class KbArticlePinInput
|
||||
{
|
||||
[Required]
|
||||
public int Id { get; set; }
|
||||
|
||||
public bool IsPinned { get; set; }
|
||||
}
|
||||
|
||||
public class KbPortalArticleListInput
|
||||
{
|
||||
public int Page { get; set; } = 1;
|
||||
public int PageSize { get; set; } = 20;
|
||||
public string? Keyword { get; set; }
|
||||
public int? CategoryId { get; set; }
|
||||
}
|
||||
|
||||
public class KbPortalHotInput
|
||||
{
|
||||
public int Top { get; set; } = 10;
|
||||
}
|
||||
@ -0,0 +1,58 @@
|
||||
namespace Oms.Application.Dtos.Orders;
|
||||
|
||||
/// <summary>
|
||||
/// 工作台费用趋势(按日/周/月)。
|
||||
/// </summary>
|
||||
public class OrderWorkplaceSpendTrendOutput
|
||||
{
|
||||
/// <summary>
|
||||
/// 维度:day / week / month。
|
||||
/// </summary>
|
||||
public string Dimension { get; set; } = "day";
|
||||
|
||||
/// <summary>
|
||||
/// 区间合计:运费支出(客户应付)。
|
||||
/// </summary>
|
||||
public decimal TotalSpend { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 区间合计:订单数。
|
||||
/// </summary>
|
||||
public int TotalOrders { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 区间合计:单票均价。
|
||||
/// </summary>
|
||||
public decimal AvgAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 趋势点(按时间正序)。
|
||||
/// </summary>
|
||||
public List<OrderWorkplaceSpendTrendPointOutput> Points { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 单个时间桶的费用汇总。
|
||||
/// </summary>
|
||||
public class OrderWorkplaceSpendTrendPointOutput
|
||||
{
|
||||
/// <summary>
|
||||
/// 展示标签。
|
||||
/// </summary>
|
||||
public string Label { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 订单数(创建时间)。
|
||||
/// </summary>
|
||||
public int OrderCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 运费支出合计(Order.PayableAmount)。
|
||||
/// </summary>
|
||||
public decimal SpendAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 单票均价。
|
||||
/// </summary>
|
||||
public decimal AvgAmount { get; set; }
|
||||
}
|
||||
359
src/Oms.Application/Services/Kb/KbArticleService.cs
Normal file
359
src/Oms.Application/Services/Kb/KbArticleService.cs
Normal file
@ -0,0 +1,359 @@
|
||||
using System.Linq.Expressions;
|
||||
using Fake.Application;
|
||||
using Fake.Domain.Exceptions;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Oms.Application.Dtos.Kb;
|
||||
using Oms.Application.Dtos.Shared;
|
||||
using Oms.Application.Shared;
|
||||
using Oms.Domain.KbAggregate.Entities;
|
||||
using Oms.Domain.KbAggregate.Repositories;
|
||||
|
||||
namespace Oms.Application.Services.Kb;
|
||||
|
||||
/// <summary>
|
||||
/// 知识库文章:管理端 + 客户门户。
|
||||
/// </summary>
|
||||
[ApiExplorerSettings(GroupName = "OMS-Kb")]
|
||||
public class KbArticleService : ApplicationService
|
||||
{
|
||||
private IKbArticleRepository ArticleRepository =>
|
||||
LazyServiceProvider.GetRequiredService<IKbArticleRepository>();
|
||||
|
||||
private IKbCategoryRepository CategoryRepository =>
|
||||
LazyServiceProvider.GetRequiredService<IKbCategoryRepository>();
|
||||
|
||||
[HttpPost("/kb/admin/article/page")]
|
||||
public async Task<OmsPageResult<KbArticleOutput>> AdminPageAsync(KbArticleListInput input)
|
||||
{
|
||||
var predicate = BuildAdminPredicate(input);
|
||||
var pageIndex = OmsDocHelper.NormalizePage(input.Page);
|
||||
var pageSize = OmsDocHelper.NormalizePageSize(input.PageSize);
|
||||
var entities = await ArticleRepository.GetPagedListAsync(
|
||||
predicate,
|
||||
pageIndex,
|
||||
pageSize,
|
||||
new Dictionary<string, bool>
|
||||
{
|
||||
[nameof(KbArticle.IsPinned)] = false,
|
||||
[nameof(KbArticle.SortOrder)] = true,
|
||||
[nameof(KbArticle.Id)] = false
|
||||
});
|
||||
var total = await ArticleRepository.CountAsync(predicate);
|
||||
var categoryMap = await LoadCategoryNameMapAsync(entities.Select(x => x.CategoryId).Distinct());
|
||||
return new OmsPageResult<KbArticleOutput>
|
||||
{
|
||||
Total = total,
|
||||
Items = entities.Select(x => Map(x, categoryMap, input.IncludeContent)).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
[HttpGet("/kb/admin/article/{id:int}")]
|
||||
public async Task<KbArticleOutput> AdminGetAsync(int id)
|
||||
{
|
||||
var entity = await GetArticleOrThrowAsync(id);
|
||||
var categoryMap = await LoadCategoryNameMapAsync([entity.CategoryId]);
|
||||
return Map(entity, categoryMap, includeContent: true);
|
||||
}
|
||||
|
||||
[HttpPost("/kb/admin/article/save")]
|
||||
public async Task<KbArticleOutput> SaveAsync(KbArticleSaveInput input)
|
||||
{
|
||||
ValidateSave(input);
|
||||
await EnsureCategoryExistsAsync(input.CategoryId);
|
||||
|
||||
KbArticle? entity = null;
|
||||
if (input.Id is > 0)
|
||||
{
|
||||
entity = await GetArticleOrThrowAsync(input.Id.Value);
|
||||
}
|
||||
|
||||
entity ??= new KbArticle();
|
||||
ApplySave(input, entity);
|
||||
|
||||
if (string.Equals(entity.Status, KbArticleStatusCodes.Published, StringComparison.OrdinalIgnoreCase)
|
||||
&& entity.PublishTime == null)
|
||||
{
|
||||
entity.PublishTime = DateTime.Now;
|
||||
}
|
||||
|
||||
if (entity.Id > 0)
|
||||
{
|
||||
await ArticleRepository.UpdateAsync(entity);
|
||||
}
|
||||
else
|
||||
{
|
||||
await ArticleRepository.InsertAsync(entity);
|
||||
}
|
||||
|
||||
var categoryMap = await LoadCategoryNameMapAsync([entity.CategoryId]);
|
||||
return Map(entity, categoryMap, includeContent: true);
|
||||
}
|
||||
|
||||
[HttpPost("/kb/admin/article/publish")]
|
||||
public async Task PublishAsync(KbArticleIdInput input)
|
||||
{
|
||||
var entity = await GetArticleOrThrowAsync(input.Id);
|
||||
entity.Status = KbArticleStatusCodes.Published;
|
||||
entity.PublishTime ??= DateTime.Now;
|
||||
await ArticleRepository.UpdateAsync(entity);
|
||||
}
|
||||
|
||||
[HttpPost("/kb/admin/article/offline")]
|
||||
public async Task OfflineAsync(KbArticleIdInput input)
|
||||
{
|
||||
var entity = await GetArticleOrThrowAsync(input.Id);
|
||||
entity.Status = KbArticleStatusCodes.Offline;
|
||||
await ArticleRepository.UpdateAsync(entity);
|
||||
}
|
||||
|
||||
[HttpPost("/kb/admin/article/pin")]
|
||||
public async Task PinAsync(KbArticlePinInput input)
|
||||
{
|
||||
var entity = await GetArticleOrThrowAsync(input.Id);
|
||||
entity.IsPinned = input.IsPinned;
|
||||
await ArticleRepository.UpdateAsync(entity);
|
||||
}
|
||||
|
||||
[HttpPost("/kb/admin/article/delete")]
|
||||
public async Task DeleteAsync(KbArticleIdInput input)
|
||||
{
|
||||
var entity = await GetArticleOrThrowAsync(input.Id);
|
||||
await ArticleRepository.DeleteAsync(entity);
|
||||
}
|
||||
|
||||
/// <summary>门户:启用分类列表(需客户登录)。</summary>
|
||||
[HttpGet("/kb/portal/category/list")]
|
||||
public async Task<OmsItemsResult<KbCategoryOutput>> PortalCategoryListAsync()
|
||||
{
|
||||
MarketingPortalSessionHelper.EnsureCustomerLogin(CurrentUser);
|
||||
var items = await CategoryRepository.GetListAsync(x => x.Enabled);
|
||||
var ordered = items.OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToList();
|
||||
return new OmsItemsResult<KbCategoryOutput>
|
||||
{
|
||||
Items = ordered.Select(x => new KbCategoryOutput
|
||||
{
|
||||
Id = x.Id,
|
||||
Code = x.Code,
|
||||
Name = x.Name,
|
||||
SortOrder = x.SortOrder,
|
||||
Enabled = x.Enabled
|
||||
}).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>门户:文章分页(需客户登录)。</summary>
|
||||
[HttpPost("/kb/portal/article/page")]
|
||||
public async Task<OmsPageResult<KbArticleOutput>> PortalPageAsync(KbPortalArticleListInput input)
|
||||
{
|
||||
MarketingPortalSessionHelper.EnsureCustomerLogin(CurrentUser);
|
||||
var predicate = BuildPortalPredicate(input.Keyword, input.CategoryId);
|
||||
var pageIndex = OmsDocHelper.NormalizePage(input.Page);
|
||||
var pageSize = OmsDocHelper.NormalizePageSize(input.PageSize);
|
||||
var entities = await ArticleRepository.GetPagedListAsync(
|
||||
predicate,
|
||||
pageIndex,
|
||||
pageSize,
|
||||
new Dictionary<string, bool>
|
||||
{
|
||||
[nameof(KbArticle.IsPinned)] = false,
|
||||
[nameof(KbArticle.SortOrder)] = true,
|
||||
[nameof(KbArticle.PublishTime)] = false,
|
||||
[nameof(KbArticle.Id)] = false
|
||||
});
|
||||
var total = await ArticleRepository.CountAsync(predicate);
|
||||
var categoryMap = await LoadCategoryNameMapAsync(entities.Select(x => x.CategoryId).Distinct());
|
||||
return new OmsPageResult<KbArticleOutput>
|
||||
{
|
||||
Total = total,
|
||||
Items = entities.Select(x => Map(x, categoryMap, includeContent: false)).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>门户:热门(需客户登录)。</summary>
|
||||
[HttpGet("/kb/portal/article/hot")]
|
||||
public async Task<OmsItemsResult<KbArticleOutput>> PortalHotAsync([FromQuery] int top = 10)
|
||||
{
|
||||
MarketingPortalSessionHelper.EnsureCustomerLogin(CurrentUser);
|
||||
top = Math.Clamp(top <= 0 ? 10 : top, 1, 50);
|
||||
var now = DateTime.Now;
|
||||
var all = await ArticleRepository.GetListAsync(x =>
|
||||
x.Status == KbArticleStatusCodes.Published
|
||||
&& x.Audience == KbAudienceCodes.Customer
|
||||
&& (x.ExpireTime == null || x.ExpireTime > now));
|
||||
var ordered = all
|
||||
.OrderByDescending(x => x.IsPinned)
|
||||
.ThenByDescending(x => x.ViewCount)
|
||||
.ThenByDescending(x => x.PublishTime)
|
||||
.ThenByDescending(x => x.Id)
|
||||
.Take(top)
|
||||
.ToList();
|
||||
var categoryMap = await LoadCategoryNameMapAsync(ordered.Select(x => x.CategoryId).Distinct());
|
||||
return new OmsItemsResult<KbArticleOutput>
|
||||
{
|
||||
Items = ordered.Select(x => Map(x, categoryMap, includeContent: false)).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>门户:详情(需客户登录;浏览量 +1)。</summary>
|
||||
[HttpGet("/kb/portal/article/{id:int}")]
|
||||
public async Task<KbArticleOutput> PortalGetAsync(int id)
|
||||
{
|
||||
MarketingPortalSessionHelper.EnsureCustomerLogin(CurrentUser);
|
||||
var entity = await GetArticleOrThrowAsync(id);
|
||||
EnsurePortalVisible(entity);
|
||||
entity.ViewCount += 1;
|
||||
await ArticleRepository.UpdateAsync(entity);
|
||||
var categoryMap = await LoadCategoryNameMapAsync([entity.CategoryId]);
|
||||
return Map(entity, categoryMap, includeContent: true);
|
||||
}
|
||||
|
||||
private static Expression<Func<KbArticle, bool>> BuildAdminPredicate(KbArticleListInput input)
|
||||
{
|
||||
var keyword = input.Keyword?.Trim();
|
||||
var status = NormalizeOptional(input.Status);
|
||||
var audience = NormalizeOptional(input.Audience);
|
||||
return x =>
|
||||
(!input.CategoryId.HasValue || x.CategoryId == input.CategoryId.Value)
|
||||
&& (status == null || x.Status == status)
|
||||
&& (audience == null || x.Audience == audience)
|
||||
&& (!input.IsPinned.HasValue || x.IsPinned == input.IsPinned.Value)
|
||||
&& (keyword == null
|
||||
|| x.Title.Contains(keyword)
|
||||
|| (x.Summary != null && x.Summary.Contains(keyword))
|
||||
|| (x.Tags != null && x.Tags.Contains(keyword)));
|
||||
}
|
||||
|
||||
private static Expression<Func<KbArticle, bool>> BuildPortalPredicate(string? keyword, int? categoryId)
|
||||
{
|
||||
var kw = NormalizeOptional(keyword);
|
||||
var now = DateTime.Now;
|
||||
return x =>
|
||||
x.Status == KbArticleStatusCodes.Published
|
||||
&& x.Audience == KbAudienceCodes.Customer
|
||||
&& (x.ExpireTime == null || x.ExpireTime > now)
|
||||
&& (!categoryId.HasValue || x.CategoryId == categoryId.Value)
|
||||
&& (kw == null
|
||||
|| x.Title.Contains(kw)
|
||||
|| (x.Summary != null && x.Summary.Contains(kw))
|
||||
|| (x.Tags != null && x.Tags.Contains(kw)));
|
||||
}
|
||||
|
||||
private static void EnsurePortalVisible(KbArticle entity)
|
||||
{
|
||||
if (!string.Equals(entity.Audience, KbAudienceCodes.Customer, StringComparison.OrdinalIgnoreCase)
|
||||
|| !string.Equals(entity.Status, KbArticleStatusCodes.Published, StringComparison.OrdinalIgnoreCase)
|
||||
|| (entity.ExpireTime != null && entity.ExpireTime <= DateTime.Now))
|
||||
{
|
||||
throw new BusinessException("文章不存在或未发布");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task EnsureCategoryExistsAsync(int categoryId)
|
||||
{
|
||||
var exists = await CategoryRepository.AnyAsync(x => x.Id == categoryId && x.Enabled);
|
||||
if (!exists)
|
||||
{
|
||||
throw new BusinessException("分类不存在或已停用");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<KbArticle> GetArticleOrThrowAsync(int id)
|
||||
{
|
||||
var entity = await ArticleRepository.FirstOrDefaultAsync(x => x.Id == id);
|
||||
if (entity == null)
|
||||
{
|
||||
throw new BusinessException("文章不存在");
|
||||
}
|
||||
|
||||
return entity;
|
||||
}
|
||||
|
||||
private async Task<Dictionary<int, string>> LoadCategoryNameMapAsync(IEnumerable<int> ids)
|
||||
{
|
||||
var idList = ids.Where(x => x > 0).Distinct().ToList();
|
||||
if (idList.Count == 0)
|
||||
{
|
||||
return new Dictionary<int, string>();
|
||||
}
|
||||
|
||||
var categories = await CategoryRepository.GetListAsync(x => idList.Contains(x.Id));
|
||||
return categories.ToDictionary(x => x.Id, x => x.Name);
|
||||
}
|
||||
|
||||
private static void ValidateSave(KbArticleSaveInput input)
|
||||
{
|
||||
if (input.CategoryId <= 0)
|
||||
{
|
||||
throw new BusinessException("请选择分类");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(input.Title))
|
||||
{
|
||||
throw new BusinessException("标题不能为空");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(input.Content))
|
||||
{
|
||||
throw new BusinessException("正文不能为空");
|
||||
}
|
||||
|
||||
var audience = (input.Audience ?? string.Empty).Trim().ToLowerInvariant();
|
||||
if (audience is not (KbAudienceCodes.Customer or KbAudienceCodes.Internal))
|
||||
{
|
||||
throw new BusinessException("受众仅支持 customer / internal");
|
||||
}
|
||||
|
||||
var status = (input.Status ?? string.Empty).Trim().ToLowerInvariant();
|
||||
if (status is not (KbArticleStatusCodes.Draft or KbArticleStatusCodes.Published or KbArticleStatusCodes.Offline))
|
||||
{
|
||||
throw new BusinessException("状态仅支持 draft / published / offline");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplySave(KbArticleSaveInput input, KbArticle entity)
|
||||
{
|
||||
entity.CategoryId = input.CategoryId;
|
||||
entity.Title = input.Title.Trim();
|
||||
entity.Summary = NormalizeOptional(input.Summary);
|
||||
entity.Content = input.Content ?? string.Empty;
|
||||
entity.Audience = input.Audience.Trim().ToLowerInvariant();
|
||||
entity.Status = input.Status.Trim().ToLowerInvariant();
|
||||
entity.Tags = NormalizeOptional(input.Tags);
|
||||
entity.RelatedPath = NormalizeOptional(input.RelatedPath);
|
||||
entity.RelatedLabel = NormalizeOptional(input.RelatedLabel);
|
||||
entity.IsPinned = input.IsPinned;
|
||||
entity.SortOrder = input.SortOrder;
|
||||
entity.ExpireTime = input.ExpireTime;
|
||||
}
|
||||
|
||||
private static KbArticleOutput Map(
|
||||
KbArticle x,
|
||||
IReadOnlyDictionary<int, string> categoryMap,
|
||||
bool includeContent) =>
|
||||
new()
|
||||
{
|
||||
Id = x.Id,
|
||||
CategoryId = x.CategoryId,
|
||||
CategoryName = categoryMap.TryGetValue(x.CategoryId, out var name) ? name : null,
|
||||
Title = x.Title,
|
||||
Summary = x.Summary,
|
||||
Content = includeContent ? x.Content : null,
|
||||
Audience = x.Audience,
|
||||
Status = x.Status,
|
||||
Tags = x.Tags,
|
||||
RelatedPath = x.RelatedPath,
|
||||
RelatedLabel = x.RelatedLabel,
|
||||
IsPinned = x.IsPinned,
|
||||
SortOrder = x.SortOrder,
|
||||
PublishTime = x.PublishTime,
|
||||
ExpireTime = x.ExpireTime,
|
||||
ViewCount = x.ViewCount,
|
||||
UpdateTime = x.UpdateTime
|
||||
};
|
||||
|
||||
private static string? NormalizeOptional(string? value)
|
||||
{
|
||||
var trimmed = value?.Trim();
|
||||
return string.IsNullOrWhiteSpace(trimmed) ? null : trimmed;
|
||||
}
|
||||
}
|
||||
104
src/Oms.Application/Services/Kb/KbCategoryService.cs
Normal file
104
src/Oms.Application/Services/Kb/KbCategoryService.cs
Normal file
@ -0,0 +1,104 @@
|
||||
using Fake.Application;
|
||||
using Fake.Domain.Exceptions;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Oms.Application.Dtos.Kb;
|
||||
using Oms.Application.Dtos.Shared;
|
||||
using Oms.Domain.KbAggregate.Entities;
|
||||
using Oms.Domain.KbAggregate.Repositories;
|
||||
|
||||
namespace Oms.Application.Services.Kb;
|
||||
|
||||
/// <summary>
|
||||
/// 知识库分类管理。
|
||||
/// </summary>
|
||||
[ApiExplorerSettings(GroupName = "OMS-Kb")]
|
||||
public class KbCategoryService : ApplicationService
|
||||
{
|
||||
private IKbCategoryRepository CategoryRepository =>
|
||||
LazyServiceProvider.GetRequiredService<IKbCategoryRepository>();
|
||||
|
||||
private IKbArticleRepository ArticleRepository =>
|
||||
LazyServiceProvider.GetRequiredService<IKbArticleRepository>();
|
||||
|
||||
[HttpGet("/kb/admin/category/list")]
|
||||
public async Task<OmsItemsResult<KbCategoryOutput>> AdminListAsync([FromQuery] bool? enabledOnly = null)
|
||||
{
|
||||
var items = await CategoryRepository.GetListAsync(x =>
|
||||
!enabledOnly.HasValue || x.Enabled == enabledOnly.Value);
|
||||
var ordered = items.OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToList();
|
||||
return new OmsItemsResult<KbCategoryOutput>
|
||||
{
|
||||
Items = ordered.Select(Map).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
[HttpPost("/kb/admin/category/save")]
|
||||
public async Task<KbCategoryOutput> SaveAsync(KbCategorySaveInput input)
|
||||
{
|
||||
var code = input.Code.Trim();
|
||||
var name = input.Name.Trim();
|
||||
if (string.IsNullOrWhiteSpace(code) || string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
throw new BusinessException("分类编码 / 名称不能为空");
|
||||
}
|
||||
|
||||
KbCategory? entity = null;
|
||||
if (input.Id is > 0)
|
||||
{
|
||||
entity = await CategoryRepository.FirstOrDefaultAsync(x => x.Id == input.Id.Value);
|
||||
if (entity == null)
|
||||
{
|
||||
throw new BusinessException("分类不存在");
|
||||
}
|
||||
}
|
||||
|
||||
if (await CategoryRepository.ExistsCodeAsync(code, entity?.Id))
|
||||
{
|
||||
throw new BusinessException($"分类编码已存在:{code}");
|
||||
}
|
||||
|
||||
entity ??= new KbCategory();
|
||||
entity.Code = code;
|
||||
entity.Name = name;
|
||||
entity.SortOrder = input.SortOrder;
|
||||
entity.Enabled = input.Enabled;
|
||||
|
||||
if (entity.Id > 0)
|
||||
{
|
||||
await CategoryRepository.UpdateAsync(entity);
|
||||
}
|
||||
else
|
||||
{
|
||||
await CategoryRepository.InsertAsync(entity);
|
||||
}
|
||||
|
||||
return Map(entity);
|
||||
}
|
||||
|
||||
[HttpPost("/kb/admin/category/delete")]
|
||||
public async Task DeleteAsync(KbCategoryDeleteInput input)
|
||||
{
|
||||
var entity = await CategoryRepository.FirstOrDefaultAsync(x => x.Id == input.Id);
|
||||
if (entity == null)
|
||||
{
|
||||
throw new BusinessException("分类不存在");
|
||||
}
|
||||
|
||||
var articleCount = await ArticleRepository.CountByCategoryIdAsync(entity.Id);
|
||||
if (articleCount > 0)
|
||||
{
|
||||
throw new BusinessException($"该分类下仍有 {articleCount} 篇文章,无法删除");
|
||||
}
|
||||
|
||||
await CategoryRepository.DeleteAsync(entity);
|
||||
}
|
||||
|
||||
private static KbCategoryOutput Map(KbCategory x) => new()
|
||||
{
|
||||
Id = x.Id,
|
||||
Code = x.Code,
|
||||
Name = x.Name,
|
||||
SortOrder = x.SortOrder,
|
||||
Enabled = x.Enabled
|
||||
};
|
||||
}
|
||||
@ -132,6 +132,71 @@ public partial class OrderService
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 工作台费用趋势(运费支出 vs 单票均价)。
|
||||
/// </summary>
|
||||
[HttpGet("/order/workplace-spend-trend")]
|
||||
public async Task<OrderWorkplaceSpendTrendOutput> GetWorkplaceSpendTrendAsync(
|
||||
[FromQuery] string? dimension = "day",
|
||||
[FromQuery] int count = 7)
|
||||
{
|
||||
var dim = (dimension ?? "day").Trim().ToLowerInvariant();
|
||||
if (dim is not ("day" or "week" or "month"))
|
||||
{
|
||||
dim = "day";
|
||||
}
|
||||
|
||||
count = dim switch
|
||||
{
|
||||
"week" => Math.Clamp(count, 1, 12),
|
||||
"month" => Math.Clamp(count, 1, 12),
|
||||
_ => Math.Clamp(count, 1, 31),
|
||||
};
|
||||
|
||||
var filter = await BuildScopedListFilterAsync();
|
||||
var buckets = BuildTrendBuckets(dim, count);
|
||||
var points = new List<OrderWorkplaceSpendTrendPointOutput>(buckets.Count);
|
||||
|
||||
foreach (var bucket in buckets)
|
||||
{
|
||||
var orderCount = await OrderRepository.CountByFilterAndCreateTimeRangeAsync(
|
||||
filter,
|
||||
bucket.Start,
|
||||
bucket.EndExclusive);
|
||||
var spendAmount = await OrderRepository.SumPayableAmountByCreateTimeRangeAsync(
|
||||
filter,
|
||||
bucket.Start,
|
||||
bucket.EndExclusive);
|
||||
spendAmount = Math.Round(spendAmount, 2);
|
||||
var avg = orderCount > 0
|
||||
? Math.Round(spendAmount / orderCount, 2)
|
||||
: 0m;
|
||||
|
||||
points.Add(new OrderWorkplaceSpendTrendPointOutput
|
||||
{
|
||||
Label = bucket.Label,
|
||||
OrderCount = orderCount,
|
||||
SpendAmount = spendAmount,
|
||||
AvgAmount = avg,
|
||||
});
|
||||
}
|
||||
|
||||
var totalOrders = points.Sum(x => x.OrderCount);
|
||||
var totalSpend = Math.Round(points.Sum(x => x.SpendAmount), 2);
|
||||
var totalAvg = totalOrders > 0
|
||||
? Math.Round(totalSpend / totalOrders, 2)
|
||||
: 0m;
|
||||
|
||||
return new OrderWorkplaceSpendTrendOutput
|
||||
{
|
||||
Dimension = dim,
|
||||
TotalSpend = totalSpend,
|
||||
TotalOrders = totalOrders,
|
||||
AvgAmount = totalAvg,
|
||||
Points = points,
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<OrderListFilter> BuildScopedListFilterAsync()
|
||||
{
|
||||
var customerId = await OmsSessionHelper.ResolveListCustomerIdAsync(
|
||||
|
||||
51
src/Oms.Domain/KbAggregate/Entities/KbArticle.cs
Normal file
51
src/Oms.Domain/KbAggregate/Entities/KbArticle.cs
Normal file
@ -0,0 +1,51 @@
|
||||
using Oms.Domain.Shared;
|
||||
using SqlSugar;
|
||||
|
||||
namespace Oms.Domain.KbAggregate.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// 知识库文章。
|
||||
/// </summary>
|
||||
[SugarTable("t_kb_article")]
|
||||
public class KbArticle : OmsAuditedAggregateRoot
|
||||
{
|
||||
public int CategoryId { get; set; }
|
||||
|
||||
[SugarColumn(Length = 200)]
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
[SugarColumn(IsNullable = true, Length = 500)]
|
||||
public string? Summary { get; set; }
|
||||
|
||||
[SugarColumn(ColumnDataType = "longtext")]
|
||||
public string Content { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>customer / internal</summary>
|
||||
[SugarColumn(Length = 16)]
|
||||
public string Audience { get; set; } = "customer";
|
||||
|
||||
/// <summary>draft / published / offline</summary>
|
||||
[SugarColumn(Length = 16)]
|
||||
public string Status { get; set; } = "draft";
|
||||
|
||||
[SugarColumn(IsNullable = true, Length = 256)]
|
||||
public string? Tags { get; set; }
|
||||
|
||||
[SugarColumn(IsNullable = true, Length = 256)]
|
||||
public string? RelatedPath { get; set; }
|
||||
|
||||
[SugarColumn(IsNullable = true, Length = 64)]
|
||||
public string? RelatedLabel { get; set; }
|
||||
|
||||
public bool IsPinned { get; set; }
|
||||
|
||||
public int SortOrder { get; set; }
|
||||
|
||||
[SugarColumn(IsNullable = true)]
|
||||
public DateTime? PublishTime { get; set; }
|
||||
|
||||
[SugarColumn(IsNullable = true)]
|
||||
public DateTime? ExpireTime { get; set; }
|
||||
|
||||
public int ViewCount { get; set; }
|
||||
}
|
||||
21
src/Oms.Domain/KbAggregate/Entities/KbCategory.cs
Normal file
21
src/Oms.Domain/KbAggregate/Entities/KbCategory.cs
Normal file
@ -0,0 +1,21 @@
|
||||
using Oms.Domain.Shared;
|
||||
using SqlSugar;
|
||||
|
||||
namespace Oms.Domain.KbAggregate.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// 知识库分类。
|
||||
/// </summary>
|
||||
[SugarTable("t_kb_category")]
|
||||
public class KbCategory : OmsAuditedAggregateRoot
|
||||
{
|
||||
[SugarColumn(Length = 64)]
|
||||
public string Code { get; set; } = string.Empty;
|
||||
|
||||
[SugarColumn(Length = 128)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
public int SortOrder { get; set; }
|
||||
|
||||
public bool Enabled { get; set; } = true;
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
using Fake.Domain.Repositories;
|
||||
using Oms.Domain.KbAggregate.Entities;
|
||||
|
||||
namespace Oms.Domain.KbAggregate.Repositories;
|
||||
|
||||
public interface IKbArticleRepository : IRepository<KbArticle>
|
||||
{
|
||||
Task<int> CountByCategoryIdAsync(int categoryId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
using Fake.Domain.Repositories;
|
||||
using Oms.Domain.KbAggregate.Entities;
|
||||
|
||||
namespace Oms.Domain.KbAggregate.Repositories;
|
||||
|
||||
public interface IKbCategoryRepository : IRepository<KbCategory>
|
||||
{
|
||||
Task<KbCategory?> GetByCodeAsync(string code, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<bool> ExistsCodeAsync(string code, int? excludeId = null, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@ -98,6 +98,15 @@ public interface IOrderRepository : IRepository<Order>
|
||||
DateTime endExclusive,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 按创建时间汇总客户应付金额(Order.PayableAmount,营销后净额)。
|
||||
/// </summary>
|
||||
Task<decimal> SumPayableAmountByCreateTimeRangeAsync(
|
||||
OrderListFilter filter,
|
||||
DateTime startInclusive,
|
||||
DateTime endExclusive,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<decimal> SumFeeAmountByPayTimeRangeAsync(
|
||||
OrderListFilter filter,
|
||||
string feeType,
|
||||
|
||||
13
src/Oms.SqlSugarCore/Kb/KbArticleRepository.cs
Normal file
13
src/Oms.SqlSugarCore/Kb/KbArticleRepository.cs
Normal file
@ -0,0 +1,13 @@
|
||||
using Fake.DependencyInjection;
|
||||
using Oms.Domain.KbAggregate.Entities;
|
||||
using Oms.Domain.KbAggregate.Repositories;
|
||||
using Oms.SqlSugarCore.Repositories;
|
||||
|
||||
namespace Oms.SqlSugarCore.Kb;
|
||||
|
||||
public class KbArticleRepository
|
||||
: OmsSqlSugarRepository<KbArticle>, IKbArticleRepository, IScopedDependency
|
||||
{
|
||||
public Task<int> CountByCategoryIdAsync(int categoryId, CancellationToken cancellationToken = default) =>
|
||||
CountAsync(x => x.CategoryId == categoryId, cancellationToken);
|
||||
}
|
||||
26
src/Oms.SqlSugarCore/Kb/KbCategoryRepository.cs
Normal file
26
src/Oms.SqlSugarCore/Kb/KbCategoryRepository.cs
Normal file
@ -0,0 +1,26 @@
|
||||
using Fake.DependencyInjection;
|
||||
using Oms.Domain.KbAggregate.Entities;
|
||||
using Oms.Domain.KbAggregate.Repositories;
|
||||
using Oms.SqlSugarCore.Repositories;
|
||||
|
||||
namespace Oms.SqlSugarCore.Kb;
|
||||
|
||||
public class KbCategoryRepository
|
||||
: OmsSqlSugarRepository<KbCategory>, IKbCategoryRepository, IScopedDependency
|
||||
{
|
||||
public Task<KbCategory?> GetByCodeAsync(string code, CancellationToken cancellationToken = default) =>
|
||||
FirstOrDefaultAsync(x => x.Code == code, cancellationToken);
|
||||
|
||||
public async Task<bool> ExistsCodeAsync(
|
||||
string code,
|
||||
int? excludeId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (excludeId is > 0)
|
||||
{
|
||||
return await AnyAsync(x => x.Code == code && x.Id != excludeId.Value, cancellationToken);
|
||||
}
|
||||
|
||||
return await AnyAsync(x => x.Code == code, cancellationToken);
|
||||
}
|
||||
}
|
||||
@ -1074,6 +1074,21 @@ public class OrderRepository : OmsSqlSugarRepository<Order>, IOrderRepository, I
|
||||
return await query.CountAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<decimal> SumPayableAmountByCreateTimeRangeAsync(
|
||||
OrderListFilter filter,
|
||||
DateTime startInclusive,
|
||||
DateTime endExclusive,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var db = await GetDbContextAsync(cancellationToken);
|
||||
var query = ApplyListFilter(db.Queryable<Order>(), filter)
|
||||
.Where(x => x.CreateTime >= startInclusive && x.CreateTime < endExclusive)
|
||||
.Where(x => x.OrderStatus != OrderStatusCodes.Deleted)
|
||||
.Where(x => x.PayableAmount != null);
|
||||
|
||||
return await query.SumAsync(x => x.PayableAmount!.Value);
|
||||
}
|
||||
|
||||
public async Task<decimal> SumFeeAmountByPayTimeRangeAsync(
|
||||
OrderListFilter filter,
|
||||
string feeType,
|
||||
|
||||
@ -28,6 +28,15 @@ public class XhsRolePermissionSeedContributor(RoleService roleService) : IDataSe
|
||||
"System.AiSkill.Save"
|
||||
];
|
||||
|
||||
public static readonly string[] AdminKbPermissions =
|
||||
[
|
||||
"Knowledge.Category.Query",
|
||||
"Knowledge.Category.Edit",
|
||||
"Knowledge.Article.Query",
|
||||
"Knowledge.Article.Edit",
|
||||
"Knowledge.Article.Publish"
|
||||
];
|
||||
|
||||
public async Task SeedAsync()
|
||||
{
|
||||
var roles = await roleService.GetAllRolesAsync();
|
||||
@ -42,7 +51,7 @@ public class XhsRolePermissionSeedContributor(RoleService roleService) : IDataSe
|
||||
string.Equals(x.Code, AdminRoleCode, StringComparison.OrdinalIgnoreCase));
|
||||
if (admin != null)
|
||||
{
|
||||
await MergePermissionsAsync(admin.Id, CustomerPermissions);
|
||||
await MergePermissionsAsync(admin.Id, CustomerPermissions.Concat(AdminKbPermissions));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user