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
};
}