diff --git a/src/Oms.Application/Dtos/Orders/OrderChangePostShipStatusInput.cs b/src/Oms.Application/Dtos/Orders/OrderChangePostShipStatusInput.cs new file mode 100644 index 0000000..3babb24 --- /dev/null +++ b/src/Oms.Application/Dtos/Orders/OrderChangePostShipStatusInput.cs @@ -0,0 +1,29 @@ +using System.ComponentModel.DataAnnotations; + +namespace Oms.Application.Dtos.Orders; + +/// +/// 出库后状态校正入参:仅允许在待上网 / 运输中 / 派送成功 / 派送失败之间变更;派送成功只进不出。 +/// +public class OrderChangePostShipStatusInput +{ + /// + /// 订单主键。 + /// + [Required] + public int Id { get; set; } + + /// + /// 目标订单状态(pendingOnline / shipping / deliverySuccess / deliveryFailed)。 + /// + [Required] + [StringLength(32)] + public string TargetStatus { get; set; } = string.Empty; + + /// + /// 变更备注(必填,落操作日志)。 + /// + [Required] + [StringLength(512)] + public string Remark { get; set; } = string.Empty; +} diff --git a/src/Oms.Application/Dtos/Orders/OrderPriceCheckFormOptionsOutput.cs b/src/Oms.Application/Dtos/Orders/OrderPriceCheckFormOptionsOutput.cs new file mode 100644 index 0000000..488a32d --- /dev/null +++ b/src/Oms.Application/Dtos/Orders/OrderPriceCheckFormOptionsOutput.cs @@ -0,0 +1,49 @@ +namespace Oms.Application.Dtos.Orders; + +/// +/// 查价表单下拉选项(国家 / 发货仓 / 货物属性),可供访客匿名加载。 +/// +public class OrderPriceCheckFormOptionsOutput +{ + /// 目的国家列表。 + public List Countries { get; set; } = []; + + /// 发货仓列表(仅 id + 名称,不含地址电话)。 + public List Warehouses { get; set; } = []; + + /// 货物属性(字典 cargo_attribute)。 + public List CargoAttributes { get; set; } = []; +} + +/// 查价目的国家选项。 +public class OrderPriceCheckCountryOptionOutput +{ + /// ISO 二字码。 + public string CountryCode { get; set; } = string.Empty; + + /// 中文名。 + public string ChineseName { get; set; } = string.Empty; + + /// 英文名。 + public string EnglishName { get; set; } = string.Empty; +} + +/// 查价发货仓选项。 +public class OrderPriceCheckWarehouseOptionOutput +{ + /// 仓库主键。 + public int Id { get; set; } + + /// 仓库名称。 + public string WarehouseName { get; set; } = string.Empty; +} + +/// 查价货物属性选项。 +public class OrderPriceCheckCargoOptionOutput +{ + /// 字典编码。 + public string DictCode { get; set; } = string.Empty; + + /// 字典名称。 + public string DictName { get; set; } = string.Empty; +} diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs index 7ef6839..929f1db 100644 --- a/src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs +++ b/src/Oms.Application/Services/Orders/OrderService.ParcelQuote.cs @@ -19,6 +19,60 @@ public partial class OrderService private sealed record PriceCheckCargoTarget(string Code, string DisplayName, string CargoAttribute); + private const string CargoAttributeDictType = "cargo_attribute"; + + /// + /// 组装查价表单下拉:国家、发货仓名称、货物属性字典。 + /// + private async Task BuildPriceCheckFormOptionsAsync() + { + var countries = (await CountryRepository.GetListAsync()) + .OrderBy(x => x.CountryCode) + .ThenBy(x => x.EnglishName) + .Select(x => new OrderPriceCheckCountryOptionOutput + { + CountryCode = (x.CountryCode ?? string.Empty).Trim().ToUpperInvariant(), + ChineseName = (x.ChineseName ?? string.Empty).Trim(), + EnglishName = (x.EnglishName ?? string.Empty).Trim(), + }) + .Where(x => !string.IsNullOrWhiteSpace(x.CountryCode)) + .ToList(); + + var warehouses = (await WarehouseAddressRepository.GetListAsync()) + .OrderBy(x => x.Sort == null) + .ThenBy(x => x.Sort) + .ThenBy(x => x.Id) + .Select(x => new OrderPriceCheckWarehouseOptionOutput + { + Id = x.Id, + WarehouseName = string.IsNullOrWhiteSpace(x.WarehouseName) + ? x.Id.ToString() + : x.WarehouseName.Trim(), + }) + .Where(x => x.Id > 0) + .ToList(); + + var cargoAttributes = (await DictRepository.GetListAsync(x => x.DictType == CargoAttributeDictType)) + .OrderBy(x => x.DictSort) + .ThenBy(x => x.DictCode) + .Select(x => new OrderPriceCheckCargoOptionOutput + { + DictCode = (x.DictCode ?? string.Empty).Trim(), + DictName = string.IsNullOrWhiteSpace(x.DictName) + ? (x.DictCode ?? string.Empty).Trim() + : x.DictName.Trim(), + }) + .Where(x => !string.IsNullOrWhiteSpace(x.DictCode)) + .ToList(); + + return new OrderPriceCheckFormOptionsOutput + { + Countries = countries, + Warehouses = warehouses, + CargoAttributes = cargoAttributes, + }; + } + private async Task BuildPriceCheckShippingOptionsAsync() { var products = await SalesProductRepository.GetListAsync(x => x.IsActive == EnableStatus.Enabled); diff --git a/src/Oms.Application/Services/Orders/OrderService.cs b/src/Oms.Application/Services/Orders/OrderService.cs index 068e567..9f6dd5a 100644 --- a/src/Oms.Application/Services/Orders/OrderService.cs +++ b/src/Oms.Application/Services/Orders/OrderService.cs @@ -4,6 +4,7 @@ using Fake.Application; using Fake.Domain.Exceptions; using Fake.ObjectStorage; using Fake.UnitOfWork; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc; using MiniExcelLibs; @@ -236,7 +237,101 @@ public partial class OrderService : ApplicationService order.OrderStatus = OrderStatusCodes.DeliverySuccess; order.SignTime = DateTime.Now; await UpdateOrderAsync(order); + await ApplyDeliverySuccessMarketingAsync(order); + } + /// + /// 出库后状态校正:仅待上网 / 运输中 / 派送成功 / 派送失败互改;派送成功只进不出。 + /// 目标为派送成功时同步 SignTime 并触发引荐分成 / 首签券(与 mark-delivery-success 一致)。 + /// + /// 订单 Id、目标状态、必填备注。 + [HttpPost("/order/change-post-ship-status")] + [UnitOfWork(IsTransactional = true, IsolationLevel = IsolationLevel.ReadCommitted)] + public async Task ChangePostShipStatusAsync([FromBody] OrderChangePostShipStatusInput input) + { + var target = NormalizePostShipStatus(input.TargetStatus); + if (target == null) + { + throw new BusinessException("目标状态仅支持:待上网、运输中、派送成功、派送失败"); + } + + var remark = input.Remark?.Trim(); + if (string.IsNullOrWhiteSpace(remark)) + { + throw new BusinessException("请填写变更备注"); + } + + var order = await GetOrderOrThrowAsync(input.Id); + var fromStatus = order.OrderStatus?.Trim() ?? string.Empty; + if (!IsPostShipChangeableStatus(fromStatus)) + { + throw new BusinessException( + $"订单状态为「{OmsDocHelper.GetOrderStatusName(fromStatus)}」,仅出库后状态可校正"); + } + + if (string.Equals(fromStatus, OrderStatusCodes.DeliverySuccess, StringComparison.OrdinalIgnoreCase)) + { + throw new BusinessException("派送成功不可回退"); + } + + if (string.Equals(fromStatus, target, StringComparison.OrdinalIgnoreCase)) + { + throw new BusinessException("目标状态与当前状态相同"); + } + + var beforeSnap = await CaptureOrderAuditSnapshotAsync(order); + order.OrderStatus = target; + if (string.Equals(target, OrderStatusCodes.DeliverySuccess, StringComparison.OrdinalIgnoreCase)) + { + order.SignTime = DateTime.Now; + } + + order.UpdateTime = DateTime.Now; + await UpdateOrderAsync(order); + + if (string.Equals(target, OrderStatusCodes.DeliverySuccess, StringComparison.OrdinalIgnoreCase)) + { + order = await GetOrderOrThrowAsync(order.Id); + await ApplyDeliverySuccessMarketingAsync(order); + } + + order = await GetOrderOrThrowAsync(order.Id); + var afterSnap = await CaptureOrderAuditSnapshotAsync(order); + var fromName = OmsDocHelper.GetOrderStatusName(fromStatus) ?? fromStatus; + var toName = OmsDocHelper.GetOrderStatusName(target) ?? target; + await OperationLogWriter.TryWriteAsync( + order, + OrderOperationActionCodes.ChangePostShipStatus, + beforeSnap, + afterSnap, + $"{fromName} → {toName};{remark}"); + } + + private static readonly HashSet PostShipChangeableStatuses = new(StringComparer.OrdinalIgnoreCase) + { + OrderStatusCodes.PendingOnline, + OrderStatusCodes.Shipping, + OrderStatusCodes.DeliverySuccess, + OrderStatusCodes.DeliveryFailed + }; + + private static bool IsPostShipChangeableStatus(string? status) => + !string.IsNullOrWhiteSpace(status) && PostShipChangeableStatuses.Contains(status.Trim()); + + private static string? NormalizePostShipStatus(string? raw) + { + var value = raw?.Trim(); + if (string.IsNullOrWhiteSpace(value) || !PostShipChangeableStatuses.Contains(value)) + { + return null; + } + + return PostShipChangeableStatuses.First(x => + string.Equals(x, value, StringComparison.OrdinalIgnoreCase)); + } + + private async Task ApplyDeliverySuccessMarketingAsync(Order order) + { var commissionManager = LazyServiceProvider.GetRequiredService(); await commissionManager.OnOrderDeliverySuccessAsync(order.Id); @@ -573,6 +668,14 @@ public partial class OrderService : ApplicationService public Task GetPriceCheckShippingOptionsAsync() => BuildPriceCheckShippingOptionsAsync(); + /// + /// 查价表单下拉(国家 / 发货仓 / 货物属性)。匿名可读,供 H5 访客先填条件再登录查价。 + /// + [AllowAnonymous] + [HttpGet("/order/price-check/form-options")] + public Task GetPriceCheckFormOptionsAsync() => + BuildPriceCheckFormOptionsAsync(); + /// /// 执行订单运费试算。 /// diff --git a/src/Oms.Application/Services/Warehouse/WarehouseService.Inbound.cs b/src/Oms.Application/Services/Warehouse/WarehouseService.Inbound.cs index 4c58871..75e8fbb 100644 --- a/src/Oms.Application/Services/Warehouse/WarehouseService.Inbound.cs +++ b/src/Oms.Application/Services/Warehouse/WarehouseService.Inbound.cs @@ -128,7 +128,7 @@ public partial class WarehouseService var resolvedCustomerId = await ResolveListCustomerIdAsync(customerId); var notice = await GetInboundNoticeOrThrowAsync(resolvedCustomerId, id); var boxes = await InboundBoxRepository.GetListAsync(x => x.InboundNoticeId == notice.Id); - boxes = boxes.OrderBy(x => x.BoxSeqNo).ThenBy(x => x.Id).ToList(); + boxes = OrderInboundBoxes(boxes); var boxIds = boxes.Select(x => x.Id).ToArray(); var lines = boxIds.Length == 0 ? [] @@ -367,7 +367,8 @@ public partial class WarehouseService var customerId = await ResolveListCustomerIdAsync(input.CustomerId); var notice = await GetInboundNoticeOrThrowAsync(customerId, input.Id); var boxes = await InboundBoxRepository.GetListAsync(x => x.InboundNoticeId == notice.Id); - boxes = boxes.OrderBy(x => x.BoxSeqNo).ThenBy(x => x.Id).ToList(); + // BoxSeqNo 为字符串,必须按数值排,否则 10 会排在 2 前面导致箱唛乱序 + boxes = OrderInboundBoxes(boxes); if (boxes.Count == 0) { throw new BusinessException("该预报没有箱子,无法打印箱唛"); @@ -549,4 +550,23 @@ public partial class WarehouseService BinLocationId = line.BinLocationId }).ToList() }; + + /// + /// 按箱序号数值升序;无法解析的序号排最后,同序号再按 Id。 + /// + private static List OrderInboundBoxes(IEnumerable boxes) => + boxes + .OrderBy(x => ParseBoxSeqNo(x.BoxSeqNo)) + .ThenBy(x => x.Id) + .ToList(); + + private static int ParseBoxSeqNo(string? boxSeqNo) + { + if (string.IsNullOrWhiteSpace(boxSeqNo)) + { + return int.MaxValue; + } + + return int.TryParse(boxSeqNo.Trim(), out var n) && n > 0 ? n : int.MaxValue; + } } diff --git a/src/Oms.Domain/OrdersAggregate/Consts/OrderOperationActionCodes.cs b/src/Oms.Domain/OrdersAggregate/Consts/OrderOperationActionCodes.cs index b164f5d..71173e7 100644 --- a/src/Oms.Domain/OrdersAggregate/Consts/OrderOperationActionCodes.cs +++ b/src/Oms.Domain/OrdersAggregate/Consts/OrderOperationActionCodes.cs @@ -17,6 +17,7 @@ public static class OrderOperationActionCodes public const string Print = "print"; public const string Inbound = "inbound"; public const string Outbound = "outbound"; + public const string ChangePostShipStatus = "changePostShipStatus"; public static string GetActionName(string action) => action switch { @@ -35,6 +36,7 @@ public static class OrderOperationActionCodes Print => "打印面单", Inbound => "入库", Outbound => "出库", + ChangePostShipStatus => "修改出库后状态", _ => action }; }