diff --git a/db.sql b/db.sql
index 3f1111e..2661036 100644
--- a/db.sql
+++ b/db.sql
@@ -1220,7 +1220,7 @@ CREATE TABLE `t_notice` (
`Content` TEXT NOT NULL COMMENT '通知内容',
`IsPopup` TINYINT NOT NULL DEFAULT 0 COMMENT '是否弹窗:0-否,1-是',
`ReceiverType` VARCHAR(16) NOT NULL COMMENT '接收对象:user/customer',
- `ReceiverIds` JSON NOT NULL COMMENT '接收对象ID列表(JSON数组)',
+ `ReceiverIds` TEXT NOT NULL COMMENT '接收对象ID列表(JSON数组字符串)',
`IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是',
`ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT '并发戳',
`CreateUserId` INT NOT NULL DEFAULT 0 COMMENT '创建人',
diff --git a/sql/migrate_notice_receiver_ids_text.sql b/sql/migrate_notice_receiver_ids_text.sql
new file mode 100644
index 0000000..d4bcd97
--- /dev/null
+++ b/sql/migrate_notice_receiver_ids_text.sql
@@ -0,0 +1,25 @@
+-- =============================================================================
+-- migrate_notice_receiver_ids_text.sql
+-- t_notice.ReceiverIds:JSON → TEXT,保证广播([])与定向名单可用字符串条件查询
+-- 可重复执行
+-- =============================================================================
+SET @db := DATABASE();
+
+SET @col_type := (
+ SELECT DATA_TYPE
+ FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = @db
+ AND TABLE_NAME = 't_notice'
+ AND COLUMN_NAME = 'ReceiverIds'
+ LIMIT 1
+);
+
+SET @sql := IF(
+ @col_type IS NOT NULL AND LOWER(IFNULL(@col_type, '')) = 'json',
+ 'ALTER TABLE `t_notice`
+ MODIFY COLUMN `ReceiverIds` TEXT NOT NULL COMMENT ''接收对象ID列表(JSON数组字符串)''',
+ 'SELECT ''t_notice.ReceiverIds already text or missing'' AS info'
+);
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
diff --git a/src/Oms.Application/Dtos/Monitor/MonitorOverviewOutput.cs b/src/Oms.Application/Dtos/Monitor/MonitorOverviewOutput.cs
index a8a9138..6f03ec8 100644
--- a/src/Oms.Application/Dtos/Monitor/MonitorOverviewOutput.cs
+++ b/src/Oms.Application/Dtos/Monitor/MonitorOverviewOutput.cs
@@ -10,12 +10,32 @@ public class MonitorOverviewOutput
public MonitorCustomerStatsOutput Customer { get; set; } = new();
///
- /// 近 7 日新增订单趋势。
+ /// 经营指标(订单量/营收/毛利)按日周月维度。
+ ///
+ public MonitorOpsStatsOutput Ops { get; set; } = new();
+
+ ///
+ /// 实时积压队列(不随维度变化)。
+ ///
+ public MonitorQueueStatsOutput Queue { get; set; } = new();
+
+ ///
+ /// 订单趋势(随 dimension 变化;兼容字段名 OrderTrend7d 在 day 时等同本字段)。
+ ///
+ public List OrderTrend { get; set; } = [];
+
+ ///
+ /// 近 7 日新增订单趋势(兼容旧前端;dimension=day 时与 OrderTrend 相同)。
///
public List OrderTrend7d { get; set; } = [];
///
- /// 近 7 日营收/毛利趋势。
+ /// 营收/毛利趋势(随 dimension 变化)。
+ ///
+ public List RevenueTrend { get; set; } = [];
+
+ ///
+ /// 近 7 日营收/毛利趋势(兼容旧前端;dimension=day 时与 RevenueTrend 相同)。
///
public List RevenueTrend7d { get; set; } = [];
@@ -40,6 +60,47 @@ public class MonitorOverviewOutput
public MonitorTrackRatesOutput TrackRates { get; set; } = new();
}
+///
+/// 经营 KPI:订单量 / 营收 / 毛利(随日周月)。
+///
+public class MonitorOpsStatsOutput
+{
+ /// day / week / month
+ public string Dimension { get; set; } = "day";
+
+ /// 当前周期新建订单数。
+ public int OrderCount { get; set; }
+
+ /// 订单量环比增长百分比。
+ public decimal? OrderCountGrowthPercent { get; set; }
+
+ /// 当前周期应收(已付)营收。
+ public decimal Revenue { get; set; }
+
+ /// 营收环比增长百分比。
+ public decimal? RevenueGrowthPercent { get; set; }
+
+ /// 当前周期毛利 = 应收 - 应付。
+ public decimal GrossProfit { get; set; }
+
+ /// 毛利环比增长百分比。
+ public decimal? GrossProfitGrowthPercent { get; set; }
+}
+
+///
+/// 实时积压快照(不受日周月影响)。
+///
+public class MonitorQueueStatsOutput
+{
+ public int PendingPayment { get; set; }
+
+ public int PendingOnline { get; set; }
+
+ public int PendingSign { get; set; }
+
+ public int Problematic { get; set; }
+}
+
public class MonitorOrderStatsOutput
{
public int TodayCreated { get; set; }
diff --git a/src/Oms.Application/Dtos/System/NoticeListInput.cs b/src/Oms.Application/Dtos/System/NoticeListInput.cs
index 4707ffa..51f5af1 100644
--- a/src/Oms.Application/Dtos/System/NoticeListInput.cs
+++ b/src/Oms.Application/Dtos/System/NoticeListInput.cs
@@ -34,4 +34,9 @@ public class NoticeListInput
/// 仅未读(相对当前登录主体)。
///
public bool? OnlyUnread { get; set; }
+
+ ///
+ /// 管理端查看全部通知(仅非客户登录生效)。默认按当前主体可见范围过滤。
+ ///
+ public bool? Manage { get; set; }
}
diff --git a/src/Oms.Application/Services/Orders/OrderService.Monitor.cs b/src/Oms.Application/Services/Orders/OrderService.Monitor.cs
index ac443f2..f5a04fc 100644
--- a/src/Oms.Application/Services/Orders/OrderService.Monitor.cs
+++ b/src/Oms.Application/Services/Orders/OrderService.Monitor.cs
@@ -12,9 +12,12 @@ public partial class OrderService
///
/// 实时监控页概览(订单/客户统计与趋势)。
///
+ /// 经营指标维度:day / week / month,默认 day。
[HttpGet("/monitor/overview")]
- public async Task GetMonitorOverviewAsync()
+ public async Task GetMonitorOverviewAsync(
+ [FromQuery] string? dimension = "day")
{
+ var dim = NormalizeMonitorDimension(dimension);
var filter = await BuildScopedListFilterAsync();
var today = DateTime.Today;
var tomorrow = today.AddDays(1);
@@ -28,21 +31,60 @@ public partial class OrderService
int GetCount(string status) =>
counts.TryGetValue(status, out var value) ? value : 0;
- var todayCreated = await OrderRepository.CountByFilterAndCreateTimeRangeAsync(filter, today, tomorrow);
- var yesterdayCreated = await OrderRepository.CountByFilterAndCreateTimeRangeAsync(filter, yesterday, today);
+ var pendingPayment = GetCount(OrderStatusCodes.PendingPayment);
+ var pendingOnline = GetCount(OrderStatusCodes.PendingOnline);
+ var pendingSign = GetCount(OrderStatusCodes.Shipping);
+ var problematic = GetCount(OrderStatusCodes.Problematic);
+
+ var (currentStart, currentEndExclusive, previousStart, previousEndExclusive) =
+ ResolveOpsPeriodRanges(dim, today, tomorrow);
+
+ var orderCount = await OrderRepository.CountByFilterAndCreateTimeRangeAsync(
+ filter, currentStart, currentEndExclusive);
+ var previousOrderCount = await OrderRepository.CountByFilterAndCreateTimeRangeAsync(
+ filter, previousStart, previousEndExclusive);
+
+ var revenue = await OrderRepository.SumFeeAmountByPayTimeRangeAsync(
+ filter, OrderFeeTypeCodes.Receivable, currentStart, currentEndExclusive);
+ var previousRevenue = await OrderRepository.SumFeeAmountByPayTimeRangeAsync(
+ filter, OrderFeeTypeCodes.Receivable, previousStart, previousEndExclusive);
+
+ var payable = await OrderRepository.SumFeeAmountByPayTimeRangeAsync(
+ filter, OrderFeeTypeCodes.Payable, currentStart, currentEndExclusive);
+ var previousPayable = await OrderRepository.SumFeeAmountByPayTimeRangeAsync(
+ filter, OrderFeeTypeCodes.Payable, previousStart, previousEndExclusive);
+ var grossProfit = revenue - payable;
+ var previousGrossProfit = previousRevenue - previousPayable;
+
+ // 兼容旧字段:始终提供「今日」口径
+ var todayCreated = dim == "day"
+ ? orderCount
+ : await OrderRepository.CountByFilterAndCreateTimeRangeAsync(filter, today, tomorrow);
+ var yesterdayCreated = dim == "day"
+ ? previousOrderCount
+ : await OrderRepository.CountByFilterAndCreateTimeRangeAsync(filter, yesterday, today);
+ var todayRevenue = dim == "day"
+ ? revenue
+ : await OrderRepository.SumFeeAmountByPayTimeRangeAsync(
+ filter, OrderFeeTypeCodes.Receivable, today, tomorrow);
+ var yesterdayRevenue = dim == "day"
+ ? previousRevenue
+ : await OrderRepository.SumFeeAmountByPayTimeRangeAsync(
+ filter, OrderFeeTypeCodes.Receivable, yesterday, today);
var monthStart = new DateTime(today.Year, today.Month, 1);
var lastMonthStart = monthStart.AddMonths(-1);
var thisMonthCreated = await OrderRepository.CountByFilterAndCreateTimeRangeAsync(filter, monthStart, tomorrow);
var lastMonthCreated = await OrderRepository.CountByFilterAndCreateTimeRangeAsync(filter, lastMonthStart, monthStart);
- var todayRevenue = await OrderRepository.SumFeeAmountByPayTimeRangeAsync(
- filter, OrderFeeTypeCodes.Receivable, today, tomorrow);
- var yesterdayRevenue = await OrderRepository.SumFeeAmountByPayTimeRangeAsync(
- filter, OrderFeeTypeCodes.Receivable, yesterday, today);
-
- var orderTrend7d = await BuildMonitorOrderTrendAsync(filter, 7);
- var revenueTrend7d = await BuildMonitorRevenueTrendAsync(filter, 7);
+ var trendCount = dim switch
+ {
+ "week" => 8,
+ "month" => 6,
+ _ => 7,
+ };
+ var orderTrend = await BuildMonitorOrderTrendAsync(filter, dim, trendCount);
+ var revenueTrend = await BuildMonitorRevenueTrendAsync(filter, dim, trendCount);
var recentFilter = CloneFilterWithCreateTimeStart(filter, today.AddDays(-90));
var recentOrders = await OrderRepository.GetFilteredListAsync(recentFilter);
@@ -83,13 +125,30 @@ public partial class OrderService
TodayCreatedGrowthPercent = CalcGrowthPercent(todayCreated, yesterdayCreated),
Total = counts.Values.Sum(),
TotalMonthGrowthPercent = CalcGrowthPercent(thisMonthCreated, lastMonthCreated),
- PendingPayment = GetCount(OrderStatusCodes.PendingPayment),
- PendingOnline = GetCount(OrderStatusCodes.PendingOnline),
- PendingSign = GetCount(OrderStatusCodes.Shipping),
- Problematic = GetCount(OrderStatusCodes.Problematic),
+ PendingPayment = pendingPayment,
+ PendingOnline = pendingOnline,
+ PendingSign = pendingSign,
+ Problematic = problematic,
TodayRevenue = todayRevenue,
TodayRevenueGrowthPercent = CalcGrowthPercentDecimal(todayRevenue, yesterdayRevenue),
},
+ Ops = new MonitorOpsStatsOutput
+ {
+ Dimension = dim,
+ OrderCount = orderCount,
+ OrderCountGrowthPercent = CalcGrowthPercent(orderCount, previousOrderCount),
+ Revenue = revenue,
+ RevenueGrowthPercent = CalcGrowthPercentDecimal(revenue, previousRevenue),
+ GrossProfit = grossProfit,
+ GrossProfitGrowthPercent = CalcGrowthPercentDecimal(grossProfit, previousGrossProfit),
+ },
+ Queue = new MonitorQueueStatsOutput
+ {
+ PendingPayment = pendingPayment,
+ PendingOnline = pendingOnline,
+ PendingSign = pendingSign,
+ Problematic = problematic,
+ },
Customer = new MonitorCustomerStatsOutput
{
TotalCustomers = totalCustomers,
@@ -100,8 +159,10 @@ public partial class OrderService
InactiveLogin7d = inactiveLogin7d,
InactiveLogin7dDelta = inactiveLogin7d - inactiveLogin14d,
},
- OrderTrend7d = orderTrend7d,
- RevenueTrend7d = revenueTrend7d,
+ OrderTrend = orderTrend,
+ OrderTrend7d = dim == "day" ? orderTrend : await BuildMonitorOrderTrendAsync(filter, "day", 7),
+ RevenueTrend = revenueTrend,
+ RevenueTrend7d = dim == "day" ? revenueTrend : await BuildMonitorRevenueTrendAsync(filter, "day", 7),
RegionOrderTrend = new MonitorRegionTrendBundleOutput
{
Week = new MonitorRegionTrendPeriodOutput { Labels = weekOrderLabels, Series = weekOrderSeries },
@@ -117,11 +178,42 @@ public partial class OrderService
};
}
+ private static string NormalizeMonitorDimension(string? dimension)
+ {
+ var dim = (dimension ?? "day").Trim().ToLowerInvariant();
+ return dim is "day" or "week" or "month" ? dim : "day";
+ }
+
+ ///
+ /// 当前周期(统计到今天结束)与上一完整同尺度周期。
+ ///
+ private static (DateTime CurrentStart, DateTime CurrentEndExclusive, DateTime PreviousStart, DateTime PreviousEndExclusive)
+ ResolveOpsPeriodRanges(string dimension, DateTime today, DateTime tomorrow)
+ {
+ if (dimension == "week")
+ {
+ var weekStart = GetWeekStart(today);
+ var previousWeekStart = weekStart.AddDays(-7);
+ return (weekStart, tomorrow, previousWeekStart, weekStart);
+ }
+
+ if (dimension == "month")
+ {
+ var monthStart = new DateTime(today.Year, today.Month, 1);
+ var previousMonthStart = monthStart.AddMonths(-1);
+ return (monthStart, tomorrow, previousMonthStart, monthStart);
+ }
+
+ var yesterday = today.AddDays(-1);
+ return (today, tomorrow, yesterday, today);
+ }
+
private async Task> BuildMonitorOrderTrendAsync(
OrderListFilter filter,
+ string dimension,
int count)
{
- var buckets = BuildTrendBuckets("day", count);
+ var buckets = BuildTrendBuckets(dimension, count);
var points = new List(buckets.Count);
foreach (var bucket in buckets)
{
@@ -135,9 +227,10 @@ public partial class OrderService
private async Task> BuildMonitorRevenueTrendAsync(
OrderListFilter filter,
+ string dimension,
int count)
{
- var buckets = BuildTrendBuckets("day", count);
+ var buckets = BuildTrendBuckets(dimension, count);
var points = new List(buckets.Count);
foreach (var bucket in buckets)
{
diff --git a/src/Oms.Application/Services/System/NoticeService.cs b/src/Oms.Application/Services/System/NoticeService.cs
index ca82fe3..e3238b6 100644
--- a/src/Oms.Application/Services/System/NoticeService.cs
+++ b/src/Oms.Application/Services/System/NoticeService.cs
@@ -49,35 +49,41 @@ public class NoticeService : ApplicationService
[HttpGet("/system/notice/list")]
public async Task> GetListAsync([FromQuery] NoticeListInput input)
{
- var receiverUserId = CurrentUser.GetId();
+ var receiverUserId = OmsSessionHelper.ResolveMapUserId(CurrentUser);
if (receiverUserId <= 0)
{
return new OmsPageResult { Items = [], Total = 0 };
}
- var expectedReceiverType = OmsSessionHelper.IsCustomerLogin(CurrentUser)
- ? ReceiverTypeCustomer
- : ReceiverTypeUser;
+ var isCustomer = OmsSessionHelper.IsCustomerLogin(CurrentUser);
+ var expectedReceiverType = isCustomer ? ReceiverTypeCustomer : ReceiverTypeUser;
+ // 管理端公告页:管理员可看全部;工作台/H5/未读仍按接收范围过滤
+ var manageAll = input.Manage == true && !isCustomer;
var myReadNoticeIds = (await NoticeReadRepository.GetListAsync(x =>
x.ReceiverType == expectedReceiverType && x.ReceiverId == receiverUserId))
.Select(x => x.NoticeId)
.Distinct()
.ToList();
+ var myReadIdSet = myReadNoticeIds.ToHashSet();
- var predicate = BuildListPredicate(
- input,
- receiverUserId,
- expectedReceiverType,
- input.OnlyUnread == true ? myReadNoticeIds : null);
+ // 先按类型/关键词查,再在内存按 ReceiverIds 判可见性。
+ // MySQL JSON 列与字符串 "[]" 比较会失败,导致广播对客户/收件箱不可见。
+ var basePredicate = BuildManageListPredicate(input);
+ var candidates = await NoticeRepository.GetListAsync(basePredicate);
+ var visible = candidates
+ .Where(x => manageAll || IsVisibleToReceiver(x, receiverUserId, expectedReceiverType))
+ .Where(x => input.OnlyUnread != true || !myReadIdSet.Contains(x.Id))
+ .OrderByDescending(x => x.CreateTime)
+ .ThenByDescending(x => x.Id)
+ .ToList();
var pageIndex = OmsDocHelper.NormalizePage(input.Page);
var pageSize = OmsDocHelper.NormalizePageSize(input.PageSize);
- var notices = await NoticeRepository.GetPagedListAsync(
- predicate,
- pageIndex,
- pageSize,
- new Dictionary { [nameof(Notice.CreateTime)] = false, [nameof(Notice.Id)] = false });
- var total = await NoticeRepository.CountAsync(predicate);
+ var total = visible.Count;
+ var notices = visible
+ .Skip(Math.Max(pageIndex - 1, 0) * pageSize)
+ .Take(pageSize)
+ .ToList();
var noticeIds = notices.Select(x => x.Id).Distinct().ToArray();
var reads = noticeIds.Length == 0
@@ -88,8 +94,6 @@ public class NoticeService : ApplicationService
.ToDictionary(
x => x.Key,
x => (int)x.Select(item => $"{item.ReceiverType}:{item.ReceiverId}").Distinct(StringComparer.Ordinal).Count());
- var myReadIdSet = myReadNoticeIds.ToHashSet();
-
var selectionIdMap = await BuildReceiverSelectionIdMapAsync(notices);
return new OmsPageResult
@@ -197,7 +201,7 @@ public class NoticeService : ApplicationService
var currentReceiverType = OmsSessionHelper.IsCustomerLogin(CurrentUser)
? ReceiverTypeCustomer
: ReceiverTypeUser;
- var currentReceiverId = CurrentUser.GetId();
+ var currentReceiverId = OmsSessionHelper.ResolveMapUserId(CurrentUser);
if (currentReceiverId <= 0)
{
throw new BusinessException("当前登录主体无效");
@@ -349,33 +353,32 @@ public class NoticeService : ApplicationService
return result;
}
- private static Expression> BuildListPredicate(
- NoticeListInput input,
- int receiverUserId,
- string expectedReceiverType,
- IReadOnlyCollection? excludeNoticeIds)
+ private static Expression> BuildManageListPredicate(NoticeListInput input)
{
var noticeType = input.NoticeType?.Trim();
var receiverType = input.ReceiverType?.Trim();
var keyword = input.Keyword?.Trim();
- var receiverIdToken = receiverUserId.ToString();
- var excluded = excludeNoticeIds?.Where(x => x > 0).Distinct().ToList() ?? [];
return x =>
(string.IsNullOrWhiteSpace(noticeType) || x.NoticeType == noticeType) &&
(string.IsNullOrWhiteSpace(receiverType) || x.ReceiverType == receiverType) &&
(string.IsNullOrWhiteSpace(keyword) ||
x.Title.Contains(keyword) ||
- x.Content.Contains(keyword)) &&
- (
- // 空接收人列表 = 全体广播(员工/客户均可见)
- x.ReceiverIds == "[]" ||
- (x.ReceiverType == expectedReceiverType &&
- (x.ReceiverIds == $"[{receiverIdToken}]" ||
- x.ReceiverIds.StartsWith($"[{receiverIdToken},") ||
- x.ReceiverIds.Contains($",{receiverIdToken},") ||
- x.ReceiverIds.EndsWith($",{receiverIdToken}]")))
- ) &&
- (excluded.Count == 0 || !excluded.Contains(x.Id));
+ x.Content.Contains(keyword));
+ }
+
+ ///
+ /// 空接收人 = 全体广播(员工/客户均可见);否则须类型匹配且 mapId 在名单内。
+ ///
+ private static bool IsVisibleToReceiver(Notice notice, int receiverUserId, string expectedReceiverType)
+ {
+ var receiverIds = OmsDocHelper.DeserializeJson>(notice.ReceiverIds) ?? [];
+ if (receiverIds.Count == 0)
+ {
+ return true;
+ }
+
+ return string.Equals(notice.ReceiverType, expectedReceiverType, StringComparison.OrdinalIgnoreCase)
+ && receiverIds.Contains(receiverUserId);
}
private async Task GetNoticeOrThrowAsync(int id)
diff --git a/src/Oms.Application/Shared/OmsSessionHelper.cs b/src/Oms.Application/Shared/OmsSessionHelper.cs
index 3b603fd..c5ff2b4 100644
--- a/src/Oms.Application/Shared/OmsSessionHelper.cs
+++ b/src/Oms.Application/Shared/OmsSessionHelper.cs
@@ -43,6 +43,27 @@ public static class OmsSessionHelper
return string.Equals(role, CustomerRoleName, StringComparison.OrdinalIgnoreCase);
}
+ ///
+ /// 解析当前会话的 RBAC mapId(user.userid)。
+ /// JWT nameidentifier 多为 Guid,GetId<int> 常为 0,需回退读 mapId claim。
+ ///
+ public static int ResolveMapUserId(ICurrentUser currentUser)
+ {
+ var userId = currentUser.GetId();
+ if (userId > 0)
+ {
+ return userId;
+ }
+
+ var mapIdRaw = currentUser.FindClaimOrNull(MapIdClaim)?.Value;
+ if (int.TryParse(mapIdRaw, out var mapId) && mapId > 0)
+ {
+ return mapId;
+ }
+
+ return 0;
+ }
+
///
/// 客户登录时返回 mapId(对应 t_customer.UserId / 订单 CreateUserId);管理员返回 null。
///
@@ -53,20 +74,8 @@ public static class OmsSessionHelper
return null;
}
- var userId = currentUser.GetId();
- if (userId > 0)
- {
- return userId;
- }
-
- // GetId 未绑到 mapId 时,直接读 claim,避免客户会话被当成管理员(不过滤)
- var mapIdRaw = currentUser.FindClaimOrNull(MapIdClaim)?.Value;
- if (int.TryParse(mapIdRaw, out var mapId) && mapId > 0)
- {
- return mapId;
- }
-
- return null;
+ var mapId = ResolveMapUserId(currentUser);
+ return mapId > 0 ? mapId : null;
}
///
diff --git a/src/Oms.Domain/SystemAggregate/Entities/Notice.cs b/src/Oms.Domain/SystemAggregate/Entities/Notice.cs
index e8fb134..3b2afa6 100644
--- a/src/Oms.Domain/SystemAggregate/Entities/Notice.cs
+++ b/src/Oms.Domain/SystemAggregate/Entities/Notice.cs
@@ -16,5 +16,9 @@ public class Notice : OmsAuditedAggregateRoot
public string ReceiverType { get; set; } = string.Empty;
+ ///
+ /// 接收人 mapId 列表 JSON 数组字符串,如 [] / [1,2]。用 text 存储,避免 MySQL JSON 列与字符串比较失败导致广播不可见。
+ ///
+ [SugarColumn(ColumnDataType = "text")]
public string ReceiverIds { get; set; } = "[]";
}