From 9a33eb00a508394b917a153f7618c49202db8983 Mon Sep 17 00:00:00 2001
From: zzh <1368735060@qq.com>
Date: Tue, 4 Aug 2026 13:54:10 +0800
Subject: [PATCH] feat: add knowledge base M1 APIs and workplace spend trend
---
sql/20260804_kb_knowledge.sql | 144 +++++++
sql/20260804_kb_menu_notes.sql | 8 +
src/Oms.Application/Dtos/Kb/KbDtos.cs | 138 +++++++
.../Orders/OrderWorkplaceSpendTrendOutput.cs | 58 +++
.../Services/Kb/KbArticleService.cs | 359 ++++++++++++++++++
.../Services/Kb/KbCategoryService.cs | 104 +++++
.../Services/Orders/OrderService.Workplace.cs | 65 ++++
.../KbAggregate/Entities/KbArticle.cs | 51 +++
.../KbAggregate/Entities/KbCategory.cs | 21 +
.../Repositories/IKbArticleRepository.cs | 9 +
.../Repositories/IKbCategoryRepository.cs | 11 +
.../Repositories/IOrderRepository.cs | 9 +
.../Kb/KbArticleRepository.cs | 13 +
.../Kb/KbCategoryRepository.cs | 26 ++
.../Orders/OrderRepository.cs | 15 +
.../Auth/XhsRolePermissionSeedContributor.cs | 11 +-
16 files changed, 1041 insertions(+), 1 deletion(-)
create mode 100644 sql/20260804_kb_knowledge.sql
create mode 100644 sql/20260804_kb_menu_notes.sql
create mode 100644 src/Oms.Application/Dtos/Kb/KbDtos.cs
create mode 100644 src/Oms.Application/Dtos/Orders/OrderWorkplaceSpendTrendOutput.cs
create mode 100644 src/Oms.Application/Services/Kb/KbArticleService.cs
create mode 100644 src/Oms.Application/Services/Kb/KbCategoryService.cs
create mode 100644 src/Oms.Domain/KbAggregate/Entities/KbArticle.cs
create mode 100644 src/Oms.Domain/KbAggregate/Entities/KbCategory.cs
create mode 100644 src/Oms.Domain/KbAggregate/Repositories/IKbArticleRepository.cs
create mode 100644 src/Oms.Domain/KbAggregate/Repositories/IKbCategoryRepository.cs
create mode 100644 src/Oms.SqlSugarCore/Kb/KbArticleRepository.cs
create mode 100644 src/Oms.SqlSugarCore/Kb/KbCategoryRepository.cs
diff --git a/sql/20260804_kb_knowledge.sql b/sql/20260804_kb_knowledge.sql
new file mode 100644
index 0000000..2077eca
--- /dev/null
+++ b/sql/20260804_kb_knowledge.sql
@@ -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
+ );
+
diff --git a/sql/20260804_kb_menu_notes.sql b/sql/20260804_kb_menu_notes.sql
new file mode 100644
index 0000000..64e6747
--- /dev/null
+++ b/sql/20260804_kb_menu_notes.sql
@@ -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)
diff --git a/src/Oms.Application/Dtos/Kb/KbDtos.cs b/src/Oms.Application/Dtos/Kb/KbDtos.cs
new file mode 100644
index 0000000..3c801d6
--- /dev/null
+++ b/src/Oms.Application/Dtos/Kb/KbDtos.cs
@@ -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;
+}
diff --git a/src/Oms.Application/Dtos/Orders/OrderWorkplaceSpendTrendOutput.cs b/src/Oms.Application/Dtos/Orders/OrderWorkplaceSpendTrendOutput.cs
new file mode 100644
index 0000000..e85de5b
--- /dev/null
+++ b/src/Oms.Application/Dtos/Orders/OrderWorkplaceSpendTrendOutput.cs
@@ -0,0 +1,58 @@
+namespace Oms.Application.Dtos.Orders;
+
+///
+/// 工作台费用趋势(按日/周/月)。
+///
+public class OrderWorkplaceSpendTrendOutput
+{
+ ///
+ /// 维度:day / week / month。
+ ///
+ public string Dimension { get; set; } = "day";
+
+ ///
+ /// 区间合计:运费支出(客户应付)。
+ ///
+ public decimal TotalSpend { get; set; }
+
+ ///
+ /// 区间合计:订单数。
+ ///
+ public int TotalOrders { get; set; }
+
+ ///
+ /// 区间合计:单票均价。
+ ///
+ public decimal AvgAmount { get; set; }
+
+ ///
+ /// 趋势点(按时间正序)。
+ ///
+ public List Points { get; set; } = [];
+}
+
+///
+/// 单个时间桶的费用汇总。
+///
+public class OrderWorkplaceSpendTrendPointOutput
+{
+ ///
+ /// 展示标签。
+ ///
+ public string Label { get; set; } = string.Empty;
+
+ ///
+ /// 订单数(创建时间)。
+ ///
+ public int OrderCount { get; set; }
+
+ ///
+ /// 运费支出合计(Order.PayableAmount)。
+ ///
+ public decimal SpendAmount { get; set; }
+
+ ///
+ /// 单票均价。
+ ///
+ public decimal AvgAmount { get; set; }
+}
diff --git a/src/Oms.Application/Services/Kb/KbArticleService.cs b/src/Oms.Application/Services/Kb/KbArticleService.cs
new file mode 100644
index 0000000..8c6cd17
--- /dev/null
+++ b/src/Oms.Application/Services/Kb/KbArticleService.cs
@@ -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;
+
+///
+/// 知识库文章:管理端 + 客户门户。
+///
+[ApiExplorerSettings(GroupName = "OMS-Kb")]
+public class KbArticleService : ApplicationService
+{
+ private IKbArticleRepository ArticleRepository =>
+ LazyServiceProvider.GetRequiredService();
+
+ private IKbCategoryRepository CategoryRepository =>
+ LazyServiceProvider.GetRequiredService();
+
+ [HttpPost("/kb/admin/article/page")]
+ public async Task> 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
+ {
+ [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
+ {
+ Total = total,
+ Items = entities.Select(x => Map(x, categoryMap, input.IncludeContent)).ToList()
+ };
+ }
+
+ [HttpGet("/kb/admin/article/{id:int}")]
+ public async Task 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 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);
+ }
+
+ /// 门户:启用分类列表(需客户登录)。
+ [HttpGet("/kb/portal/category/list")]
+ public async Task> 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
+ {
+ Items = ordered.Select(x => new KbCategoryOutput
+ {
+ Id = x.Id,
+ Code = x.Code,
+ Name = x.Name,
+ SortOrder = x.SortOrder,
+ Enabled = x.Enabled
+ }).ToList()
+ };
+ }
+
+ /// 门户:文章分页(需客户登录)。
+ [HttpPost("/kb/portal/article/page")]
+ public async Task> 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
+ {
+ [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
+ {
+ Total = total,
+ Items = entities.Select(x => Map(x, categoryMap, includeContent: false)).ToList()
+ };
+ }
+
+ /// 门户:热门(需客户登录)。
+ [HttpGet("/kb/portal/article/hot")]
+ public async Task> 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
+ {
+ Items = ordered.Select(x => Map(x, categoryMap, includeContent: false)).ToList()
+ };
+ }
+
+ /// 门户:详情(需客户登录;浏览量 +1)。
+ [HttpGet("/kb/portal/article/{id:int}")]
+ public async Task 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> 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> 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 GetArticleOrThrowAsync(int id)
+ {
+ var entity = await ArticleRepository.FirstOrDefaultAsync(x => x.Id == id);
+ if (entity == null)
+ {
+ throw new BusinessException("文章不存在");
+ }
+
+ return entity;
+ }
+
+ private async Task> LoadCategoryNameMapAsync(IEnumerable ids)
+ {
+ var idList = ids.Where(x => x > 0).Distinct().ToList();
+ if (idList.Count == 0)
+ {
+ return new Dictionary();
+ }
+
+ 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 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;
+ }
+}
diff --git a/src/Oms.Application/Services/Kb/KbCategoryService.cs b/src/Oms.Application/Services/Kb/KbCategoryService.cs
new file mode 100644
index 0000000..d045c1c
--- /dev/null
+++ b/src/Oms.Application/Services/Kb/KbCategoryService.cs
@@ -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;
+
+///
+/// 知识库分类管理。
+///
+[ApiExplorerSettings(GroupName = "OMS-Kb")]
+public class KbCategoryService : ApplicationService
+{
+ private IKbCategoryRepository CategoryRepository =>
+ LazyServiceProvider.GetRequiredService();
+
+ private IKbArticleRepository ArticleRepository =>
+ LazyServiceProvider.GetRequiredService();
+
+ [HttpGet("/kb/admin/category/list")]
+ public async Task> 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
+ {
+ Items = ordered.Select(Map).ToList()
+ };
+ }
+
+ [HttpPost("/kb/admin/category/save")]
+ public async Task 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
+ };
+}
diff --git a/src/Oms.Application/Services/Orders/OrderService.Workplace.cs b/src/Oms.Application/Services/Orders/OrderService.Workplace.cs
index dbfa3b8..191ba62 100644
--- a/src/Oms.Application/Services/Orders/OrderService.Workplace.cs
+++ b/src/Oms.Application/Services/Orders/OrderService.Workplace.cs
@@ -132,6 +132,71 @@ public partial class OrderService
};
}
+ ///
+ /// 工作台费用趋势(运费支出 vs 单票均价)。
+ ///
+ [HttpGet("/order/workplace-spend-trend")]
+ public async Task 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(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 BuildScopedListFilterAsync()
{
var customerId = await OmsSessionHelper.ResolveListCustomerIdAsync(
diff --git a/src/Oms.Domain/KbAggregate/Entities/KbArticle.cs b/src/Oms.Domain/KbAggregate/Entities/KbArticle.cs
new file mode 100644
index 0000000..db35c04
--- /dev/null
+++ b/src/Oms.Domain/KbAggregate/Entities/KbArticle.cs
@@ -0,0 +1,51 @@
+using Oms.Domain.Shared;
+using SqlSugar;
+
+namespace Oms.Domain.KbAggregate.Entities;
+
+///
+/// 知识库文章。
+///
+[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;
+
+ /// customer / internal
+ [SugarColumn(Length = 16)]
+ public string Audience { get; set; } = "customer";
+
+ /// draft / published / offline
+ [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; }
+}
diff --git a/src/Oms.Domain/KbAggregate/Entities/KbCategory.cs b/src/Oms.Domain/KbAggregate/Entities/KbCategory.cs
new file mode 100644
index 0000000..d3b70cf
--- /dev/null
+++ b/src/Oms.Domain/KbAggregate/Entities/KbCategory.cs
@@ -0,0 +1,21 @@
+using Oms.Domain.Shared;
+using SqlSugar;
+
+namespace Oms.Domain.KbAggregate.Entities;
+
+///
+/// 知识库分类。
+///
+[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;
+}
diff --git a/src/Oms.Domain/KbAggregate/Repositories/IKbArticleRepository.cs b/src/Oms.Domain/KbAggregate/Repositories/IKbArticleRepository.cs
new file mode 100644
index 0000000..e579079
--- /dev/null
+++ b/src/Oms.Domain/KbAggregate/Repositories/IKbArticleRepository.cs
@@ -0,0 +1,9 @@
+using Fake.Domain.Repositories;
+using Oms.Domain.KbAggregate.Entities;
+
+namespace Oms.Domain.KbAggregate.Repositories;
+
+public interface IKbArticleRepository : IRepository
+{
+ Task CountByCategoryIdAsync(int categoryId, CancellationToken cancellationToken = default);
+}
diff --git a/src/Oms.Domain/KbAggregate/Repositories/IKbCategoryRepository.cs b/src/Oms.Domain/KbAggregate/Repositories/IKbCategoryRepository.cs
new file mode 100644
index 0000000..4fe86d9
--- /dev/null
+++ b/src/Oms.Domain/KbAggregate/Repositories/IKbCategoryRepository.cs
@@ -0,0 +1,11 @@
+using Fake.Domain.Repositories;
+using Oms.Domain.KbAggregate.Entities;
+
+namespace Oms.Domain.KbAggregate.Repositories;
+
+public interface IKbCategoryRepository : IRepository
+{
+ Task GetByCodeAsync(string code, CancellationToken cancellationToken = default);
+
+ Task ExistsCodeAsync(string code, int? excludeId = null, CancellationToken cancellationToken = default);
+}
diff --git a/src/Oms.Domain/OrdersAggregate/Repositories/IOrderRepository.cs b/src/Oms.Domain/OrdersAggregate/Repositories/IOrderRepository.cs
index f720371..ceb1c11 100644
--- a/src/Oms.Domain/OrdersAggregate/Repositories/IOrderRepository.cs
+++ b/src/Oms.Domain/OrdersAggregate/Repositories/IOrderRepository.cs
@@ -98,6 +98,15 @@ public interface IOrderRepository : IRepository
DateTime endExclusive,
CancellationToken cancellationToken = default);
+ ///
+ /// 按创建时间汇总客户应付金额(Order.PayableAmount,营销后净额)。
+ ///
+ Task SumPayableAmountByCreateTimeRangeAsync(
+ OrderListFilter filter,
+ DateTime startInclusive,
+ DateTime endExclusive,
+ CancellationToken cancellationToken = default);
+
Task SumFeeAmountByPayTimeRangeAsync(
OrderListFilter filter,
string feeType,
diff --git a/src/Oms.SqlSugarCore/Kb/KbArticleRepository.cs b/src/Oms.SqlSugarCore/Kb/KbArticleRepository.cs
new file mode 100644
index 0000000..3cd9697
--- /dev/null
+++ b/src/Oms.SqlSugarCore/Kb/KbArticleRepository.cs
@@ -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, IKbArticleRepository, IScopedDependency
+{
+ public Task CountByCategoryIdAsync(int categoryId, CancellationToken cancellationToken = default) =>
+ CountAsync(x => x.CategoryId == categoryId, cancellationToken);
+}
diff --git a/src/Oms.SqlSugarCore/Kb/KbCategoryRepository.cs b/src/Oms.SqlSugarCore/Kb/KbCategoryRepository.cs
new file mode 100644
index 0000000..f6fdc90
--- /dev/null
+++ b/src/Oms.SqlSugarCore/Kb/KbCategoryRepository.cs
@@ -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, IKbCategoryRepository, IScopedDependency
+{
+ public Task GetByCodeAsync(string code, CancellationToken cancellationToken = default) =>
+ FirstOrDefaultAsync(x => x.Code == code, cancellationToken);
+
+ public async Task 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);
+ }
+}
diff --git a/src/Oms.SqlSugarCore/Orders/OrderRepository.cs b/src/Oms.SqlSugarCore/Orders/OrderRepository.cs
index d0279ae..8c7388a 100644
--- a/src/Oms.SqlSugarCore/Orders/OrderRepository.cs
+++ b/src/Oms.SqlSugarCore/Orders/OrderRepository.cs
@@ -1074,6 +1074,21 @@ public class OrderRepository : OmsSqlSugarRepository, IOrderRepository, I
return await query.CountAsync(cancellationToken);
}
+ public async Task SumPayableAmountByCreateTimeRangeAsync(
+ OrderListFilter filter,
+ DateTime startInclusive,
+ DateTime endExclusive,
+ CancellationToken cancellationToken = default)
+ {
+ var db = await GetDbContextAsync(cancellationToken);
+ var query = ApplyListFilter(db.Queryable(), 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 SumFeeAmountByPayTimeRangeAsync(
OrderListFilter filter,
string feeType,
diff --git a/src/SimpleAdmin.Api/Auth/XhsRolePermissionSeedContributor.cs b/src/SimpleAdmin.Api/Auth/XhsRolePermissionSeedContributor.cs
index 8050ec1..10abc06 100644
--- a/src/SimpleAdmin.Api/Auth/XhsRolePermissionSeedContributor.cs
+++ b/src/SimpleAdmin.Api/Auth/XhsRolePermissionSeedContributor.cs
@@ -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));
}
}