diff --git a/sql/migrate_warehouse_module.sql b/sql/migrate_warehouse_module.sql new file mode 100644 index 0000000..b59332b --- /dev/null +++ b/sql/migrate_warehouse_module.sql @@ -0,0 +1,263 @@ +-- 仓储模块 + 店铺订单表结构(v0.3) +-- 设计文档:E:\oms0414\docs\仓储模块设计方案.md +-- 仓储表前缀:C_ ;店铺表前缀:t_sales_ +-- 幂等:CREATE TABLE IF NOT EXISTS + +-- --------------------------------------------------------------------------- +-- 仓储域(C_*) +-- --------------------------------------------------------------------------- + +CREATE TABLE IF NOT EXISTS `C_sku` ( + `Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', + `CustomerId` INT NOT NULL COMMENT '客户ID → t_customer.Id', + `SkuCode` VARCHAR(64) NOT NULL COMMENT '库内SKU编码', + `SkuName` VARCHAR(256) NOT NULL COMMENT 'SKU名称', + `SkuImageUrl` VARCHAR(512) NULL COMMENT '图片URL', + `WeightG` DECIMAL(10,2) NULL COMMENT '重量(g)', + `LengthCm` DECIMAL(10,2) NULL COMMENT '长(cm)', + `WidthCm` DECIMAL(10,2) NULL COMMENT '宽(cm)', + `HeightCm` DECIMAL(10,2) NULL COMMENT '高(cm)', + `Remark` VARCHAR(512) NULL COMMENT '备注', + `IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是', + `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_c_sku_code` (`CustomerId`, `SkuCode`), + KEY `idx_c_sku_customer` (`CustomerId`, `IsDeleted`) +) COMMENT='仓储-SKU主数据'; + +CREATE TABLE IF NOT EXISTS `C_bin_location` ( + `Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', + `WarehouseId` INT NOT NULL COMMENT '仓库ID → t_warehouse_address.Id', + `BinCode` VARCHAR(32) NOT NULL COMMENT '库位编码', + `BinName` VARCHAR(64) NULL COMMENT '库位展示名', + `ZoneCode` VARCHAR(32) NULL COMMENT '库区编码', + `IsEnabled` TINYINT NOT NULL DEFAULT 1 COMMENT '是否启用:0-否,1-是', + `Remark` VARCHAR(512) NULL COMMENT '备注', + `IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是', + `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_c_bin_wh_code` (`WarehouseId`, `BinCode`), + KEY `idx_c_bin_warehouse` (`WarehouseId`, `IsEnabled`) +) COMMENT='仓储-库位主数据'; + +CREATE TABLE IF NOT EXISTS `C_inventory` ( + `Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', + `CustomerId` INT NOT NULL COMMENT '客户ID', + `WarehouseId` INT NOT NULL COMMENT '仓库ID', + `SkuId` INT NOT NULL COMMENT 'SKU ID → C_sku.Id', + `BinLocationId` INT NOT NULL COMMENT '库位ID → C_bin_location.Id', + `Qty` INT NOT NULL DEFAULT 0 COMMENT '可用库存数量', + `IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是', + `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_c_inventory` (`CustomerId`, `WarehouseId`, `SkuId`, `BinLocationId`), + KEY `idx_c_inv_sku_wh` (`CustomerId`, `SkuId`, `WarehouseId`) +) COMMENT='仓储-库存余额(按库位)'; + +CREATE TABLE IF NOT EXISTS `C_inbound_notice` ( + `Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', + `CustomerId` INT NOT NULL COMMENT '客户ID', + `WarehouseId` INT NOT NULL COMMENT '仓库ID', + `NoticeNo` VARCHAR(32) NOT NULL COMMENT '入库单号 IN+雪花', + `MarkReference` VARCHAR(64) NULL COMMENT '唛头参考号', + `InboundType` VARCHAR(16) NOT NULL DEFAULT 'normal' COMMENT 'normal/return', + `ReceiptStatus` VARCHAR(16) NOT NULL DEFAULT 'forecasted' COMMENT 'forecasted/received/completed', + `ExpectedArrivalTime` DATETIME NULL COMMENT '预计到货时间', + `ActualArrivalTime` DATETIME NULL COMMENT '实际到货时间', + `Remark` VARCHAR(512) NULL COMMENT '备注', + `IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是', + `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_c_inbound_notice_no` (`NoticeNo`), + KEY `idx_c_inbound_customer_status` (`CustomerId`, `ReceiptStatus`, `CreateTime`) +) COMMENT='仓储-入库预报主表'; + +CREATE TABLE IF NOT EXISTS `C_inbound_box` ( + `Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', + `InboundNoticeId` INT NOT NULL COMMENT '入库预报ID → C_inbound_notice.Id', + `BoxSeqNo` VARCHAR(16) NOT NULL COMMENT '箱序号', + `MarkBoxNo` VARCHAR(64) NULL COMMENT '箱唛号/箱号', + `WeightG` DECIMAL(10,2) NULL COMMENT '箱重(g)', + `LengthCm` DECIMAL(10,2) NULL COMMENT '长(cm)', + `WidthCm` DECIMAL(10,2) NULL COMMENT '宽(cm)', + `HeightCm` DECIMAL(10,2) NULL COMMENT '高(cm)', + `IsReceived` TINYINT NOT NULL DEFAULT 0 COMMENT '是否已收货:0-否,1-是', + `IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是', + `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_c_inbound_box_notice` (`InboundNoticeId`, `IsDeleted`) +) COMMENT='仓储-入库预报箱'; + +CREATE TABLE IF NOT EXISTS `C_inbound_box_line` ( + `Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', + `InboundBoxId` INT NOT NULL COMMENT '入库箱ID → C_inbound_box.Id', + `SkuId` INT NOT NULL COMMENT 'SKU ID → C_sku.Id', + `SkuCode` VARCHAR(64) NOT NULL COMMENT '冗余SKU编码', + `PlannedQty` INT NOT NULL DEFAULT 0 COMMENT '计划入库数量', + `ReceivedQty` INT NULL COMMENT '实收数量', + `BinLocationId` INT NULL COMMENT '上架库位ID → C_bin_location.Id', + `IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是', + `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_c_inbound_box_line_box` (`InboundBoxId`, `IsDeleted`), + KEY `idx_c_inbound_box_line_sku` (`SkuId`) +) COMMENT='仓储-入库箱内SKU行'; + +CREATE TABLE IF NOT EXISTS `C_parcel` ( + `Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', + `CustomerId` INT NOT NULL COMMENT '客户ID', + `WarehouseId` INT NOT NULL COMMENT '仓库ID', + `TrackingNo` VARCHAR(64) NOT NULL COMMENT '快递单号', + `CarrierName` VARCHAR(64) NULL COMMENT '快递公司', + `LifecycleStatus` VARCHAR(16) NOT NULL DEFAULT 'inTransit' COMMENT 'inTransit/arrived/stored', + `ArrivalTime` DATETIME NULL COMMENT '到仓时间', + `Remark` VARCHAR(512) NULL COMMENT '备注', + `IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是', + `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_c_parcel_tracking` (`CustomerId`, `TrackingNo`, `WarehouseId`), + KEY `idx_c_parcel_status` (`CustomerId`, `LifecycleStatus`) +) COMMENT='仓储-客户包裹'; + +CREATE TABLE IF NOT EXISTS `C_parcel_item` ( + `Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', + `ParcelId` INT NOT NULL COMMENT '包裹ID → C_parcel.Id', + `ProductName` VARCHAR(512) NULL COMMENT '商品名称', + `Quantity` INT NOT NULL DEFAULT 0 COMMENT '数量', + `SkuId` INT NULL COMMENT '库内SKU ID → C_sku.Id', + `SkuCode` VARCHAR(64) NULL COMMENT '冗余SKU编码', + `BinLocationId` INT NULL COMMENT '上架库位ID', + `Remark` VARCHAR(512) NULL COMMENT '备注', + `IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是', + `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_c_parcel_item_parcel` (`ParcelId`, `IsDeleted`) +) COMMENT='仓储-包裹明细'; + +CREATE TABLE IF NOT EXISTS `C_sku_platform_map` ( + `Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', + `CustomerId` INT NOT NULL COMMENT '客户ID', + `StoreId` INT NOT NULL COMMENT '店铺ID → t_sales_store.Id', + `PlatformSku` VARCHAR(128) NOT NULL COMMENT '平台listing SKU', + `SkuId` INT NOT NULL COMMENT '库内SKU ID → C_sku.Id', + `SkuCode` VARCHAR(64) NOT NULL COMMENT '冗余SKU编码', + `IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是', + `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_c_sku_map` (`StoreId`, `PlatformSku`), + KEY `idx_c_sku_map_customer` (`CustomerId`, `SkuId`) +) COMMENT='仓储-平台SKU映射'; + +-- --------------------------------------------------------------------------- +-- 订单域 — 店铺(t_sales_*,接口阶段使用) +-- --------------------------------------------------------------------------- + +CREATE TABLE IF NOT EXISTS `t_sales_store` ( + `Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', + `CustomerId` INT NOT NULL COMMENT '客户ID', + `PlatformCode` VARCHAR(32) NOT NULL COMMENT 'lazada/shopee/tiktok/temu/manual', + `StoreNickname` VARCHAR(128) NOT NULL COMMENT '店铺昵称', + `PlatformStoreId` VARCHAR(64) NULL COMMENT '平台侧店铺ID', + `PlatformAccount` VARCHAR(128) NULL COMMENT '平台账号', + `SiteCountry` VARCHAR(8) NULL COMMENT '开户站点国家码', + `SellerId` VARCHAR(64) NULL COMMENT '平台卖家ID', + `AuthStatus` VARCHAR(16) NOT NULL DEFAULT 'active' COMMENT 'active/expired/revoked', + `AuthTime` DATETIME NULL COMMENT '授权时间', + `TokenExpireTime` DATETIME NULL COMMENT 'AccessToken过期时间', + `RefreshExpireTime` DATETIME NULL COMMENT 'RefreshToken过期时间', + `AccessToken` VARCHAR(2048) NULL COMMENT 'AccessToken(加密存储)', + `RefreshToken` VARCHAR(2048) NULL COMMENT 'RefreshToken(加密存储)', + `TokenExtraJson` JSON NULL COMMENT '平台扩展Token信息', + `IsAutoFulfill` TINYINT NOT NULL DEFAULT 0 COMMENT '是否自动发货:0-否,1-是', + `Remark` VARCHAR(512) NULL COMMENT '备注', + `IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是', + `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_sales_store_customer` (`CustomerId`, `IsDeleted`), + KEY `idx_sales_store_platform` (`CustomerId`, `PlatformCode`, `AuthStatus`) +) COMMENT='销售店铺授权'; + +CREATE TABLE IF NOT EXISTS `t_sales_store_order` ( + `Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', + `CustomerId` INT NOT NULL COMMENT '客户ID', + `StoreId` INT NOT NULL COMMENT '店铺ID → t_sales_store.Id', + `PlatformCode` VARCHAR(32) NOT NULL COMMENT '平台编码', + `PlatformOrderNo` VARCHAR(64) NOT NULL COMMENT '平台订单号', + `OrderCountry` VARCHAR(8) NULL COMMENT '订单国家', + `ItemCount` INT NULL COMMENT '商品行数', + `TotalAmount` DECIMAL(18,4) NULL COMMENT '订单总额', + `Currency` VARCHAR(8) NULL COMMENT '币种', + `PlatformOrderTime` DATETIME NULL COMMENT '平台下单时间', + `FulfillStatus` VARCHAR(32) NOT NULL DEFAULT 'pending' COMMENT '履约状态', + `PlatformStatus` VARCHAR(64) NULL COMMENT '平台原始状态码', + `PlatformStatusLabel` VARCHAR(128) NULL COMMENT '平台状态展示文案', + `ShippingLabelNo` VARCHAR(64) NULL COMMENT '面单号', + `ShippingLabelUrl` VARCHAR(512) NULL COMMENT '面单URL', + `IsMultiLabel` TINYINT NOT NULL DEFAULT 0 COMMENT '是否多面单', + `CustomTags` VARCHAR(512) NULL COMMENT '自定义标签', + `IsSuspended` TINYINT NOT NULL DEFAULT 0 COMMENT '是否搁置', + `WarehouseId` INT NULL COMMENT '发货仓库ID', + `LinkedOrderId` INT NULL COMMENT '关联小包订单 t_order.Id', + `LastSyncTime` DATETIME NULL COMMENT '最近同步时间', + `PlatformExtraJson` JSON NULL COMMENT '平台扩展字段', + `IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是', + `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_store_order_no` (`StoreId`, `PlatformOrderNo`), + KEY `idx_store_order_customer_status` (`CustomerId`, `FulfillStatus`, `PlatformOrderTime`) +) COMMENT='销售店铺订单'; + +CREATE TABLE IF NOT EXISTS `t_sales_store_order_line` ( + `Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', + `StoreOrderId` INT NOT NULL COMMENT '店铺订单ID → t_sales_store_order.Id', + `PlatformLineId` VARCHAR(64) NULL COMMENT '平台行ID', + `PlatformSku` VARCHAR(128) NULL COMMENT '平台SKU', + `ProductName` VARCHAR(512) NULL COMMENT '商品名称', + `ProductImageUrl` VARCHAR(512) NULL COMMENT '商品图片', + `VariantText` VARCHAR(256) NULL COMMENT '规格属性', + `UnitPrice` DECIMAL(18,4) NULL COMMENT '单价', + `Quantity` INT NOT NULL DEFAULT 0 COMMENT '数量', + `Currency` VARCHAR(8) NULL COMMENT '币种', + `IsShipExcluded` TINYINT NOT NULL DEFAULT 0 COMMENT '是否拒绝发货', + `MappedSkuId` INT NULL COMMENT '映射库内SKU → C_sku.Id', + `IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是', + `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_store_order_line_order` (`StoreOrderId`, `IsDeleted`) +) COMMENT='销售店铺订单明细'; diff --git a/sql/seed_warehouse_module_permissions.sql b/sql/seed_warehouse_module_permissions.sql new file mode 100644 index 0000000..b50ce94 --- /dev/null +++ b/sql/seed_warehouse_module_permissions.sql @@ -0,0 +1,42 @@ +-- 仓储模块 + 店铺授权/订单 RBAC 权限(幂等) +-- RBAC 库执行;执行后用户需重新登录或刷新权限缓存 + +SET @ADMIN_USER_ID = ( + SELECT `Id` FROM `user` + WHERE `Account` = 'admin' AND (`IsDeleted` = 0 OR `IsDeleted` IS NULL) + LIMIT 1 +); +SET @ADMIN_USER_ID = IFNULL( + @ADMIN_USER_ID, + '08de4226-3fd4-4a5c-83e5-70e164b15f2f' +); + +INSERT INTO `role_permission` (`Id`, `RoleId`, `PermissionCode`, `CreateUserId`, `CreateTime`) +SELECT UUID(), r.`Id`, p.`Code`, @ADMIN_USER_ID, NOW() +FROM `role` r +CROSS JOIN ( + SELECT 'Warehouse.Sku.Query' AS `Code` UNION ALL + SELECT 'Warehouse.Sku.Save' UNION ALL + SELECT 'Warehouse.Sku.Delete' UNION ALL + SELECT 'Warehouse.Inventory.Query' UNION ALL + SELECT 'Warehouse.Inventory.Relocate' UNION ALL + SELECT 'Warehouse.Parcel.Query' UNION ALL + SELECT 'Warehouse.Parcel.Save' UNION ALL + SELECT 'Warehouse.Parcel.Delete' UNION ALL + SELECT 'Warehouse.InboundNotice.Query' UNION ALL + SELECT 'Warehouse.InboundNotice.Save' UNION ALL + SELECT 'Warehouse.InboundNotice.Delete' UNION ALL + SELECT 'Order.PlatformStore.Query' UNION ALL + SELECT 'Order.PlatformStore.Save' UNION ALL + SELECT 'Order.PlatformStore.Delete' UNION ALL + SELECT 'Order.PlatformStore.Auth' UNION ALL + SELECT 'Order.PlatformStoreOrder.Query' UNION ALL + SELECT 'Order.PlatformStoreOrder.MatchInventory' UNION ALL + SELECT 'Order.PlatformStoreOrder.ToggleSuspend' UNION ALL + SELECT 'Order.PlatformStoreOrder.Delete' +) p +WHERE r.`Code` IN ('Admin', 'Customer') + AND NOT EXISTS ( + SELECT 1 FROM `role_permission` rp + WHERE rp.`RoleId` = r.`Id` AND rp.`PermissionCode` = p.`Code` + ); diff --git a/src/Oms.Application/Dtos/PlatformStore/PlatformStoreDtos.cs b/src/Oms.Application/Dtos/PlatformStore/PlatformStoreDtos.cs new file mode 100644 index 0000000..0681549 --- /dev/null +++ b/src/Oms.Application/Dtos/PlatformStore/PlatformStoreDtos.cs @@ -0,0 +1,293 @@ +using System.ComponentModel.DataAnnotations; +using Oms.Application.Dtos.Orders; + +namespace Oms.Application.Dtos.PlatformStore; + +public class PlatformStorePageInput +{ + public int Page { get; set; } = 1; + + public int PageSize { get; set; } = 30; + + public int? CustomerId { get; set; } + + public string? PlatformCode { get; set; } + + public string? StoreNickname { get; set; } + + public string? ExpirationFilter { get; set; } +} + +public class PlatformStoreOutput +{ + public int Id { get; set; } + + public int CustomerId { get; set; } + + public string PlatformCode { get; set; } = string.Empty; + + public string PlatformName { get; set; } = string.Empty; + + public string StoreNickname { get; set; } = string.Empty; + + public string? PlatformAccount { get; set; } + + public string? SiteCountry { get; set; } + + public string AuthStatus { get; set; } = string.Empty; + + public DateTime? AuthTime { get; set; } + + public DateTime? TokenExpireTime { get; set; } + + public bool IsAutoFulfill { get; set; } + + public string? Remark { get; set; } + + public DateTime CreateTime { get; set; } +} + +public class PlatformStoreSaveInput +{ + public int Id { get; set; } + + public int? CustomerId { get; set; } + + public string PlatformCode { get; set; } = "manual"; + + [Required] + public string StoreNickname { get; set; } = string.Empty; + + public string? PlatformAccount { get; set; } + + public string? SiteCountry { get; set; } + + public bool IsAutoFulfill { get; set; } + + public string? Remark { get; set; } +} + +public class PlatformStoreAuthUrlInput +{ + [Required] + public string PlatformCode { get; set; } = string.Empty; + + public int? CustomerId { get; set; } + + public string? RedirectUri { get; set; } +} + +public class PlatformStoreAuthUrlOutput +{ + public string AuthUrl { get; set; } = string.Empty; + + public string Message { get; set; } = string.Empty; +} + +public class PlatformStoreIdsInput +{ + [Required] + public List Ids { get; set; } = []; +} + +// --- Store Order --- + +public class PlatformStoreOrderListInput : PlatformStorePageInput +{ + public int? StoreId { get; set; } + + public string? FulfillStatus { get; set; } + + public string? PlatformOrderNo { get; set; } + + public bool? IsSuspended { get; set; } +} + +public class PlatformStoreOrderLineOutput +{ + public int Id { get; set; } + + public string? PlatformLineId { get; set; } + + public string? PlatformSku { get; set; } + + public string? ProductName { get; set; } + + public string? ProductImageUrl { get; set; } + + public string? VariantText { get; set; } + + public decimal? UnitPrice { get; set; } + + public int Quantity { get; set; } + + public string? Currency { get; set; } + + public bool IsShipExcluded { get; set; } + + public int? MappedSkuId { get; set; } + + public string? MappedSkuCode { get; set; } +} + +public class PlatformStoreOrderOutput +{ + public int Id { get; set; } + + public int CustomerId { get; set; } + + public int StoreId { get; set; } + + public string StoreNickname { get; set; } = string.Empty; + + public string PlatformCode { get; set; } = string.Empty; + + public string PlatformOrderNo { get; set; } = string.Empty; + + public string? OrderCountry { get; set; } + + public int? ItemCount { get; set; } + + public decimal? TotalAmount { get; set; } + + public string? Currency { get; set; } + + public DateTime? PlatformOrderTime { get; set; } + + public string FulfillStatus { get; set; } = string.Empty; + + public string? PlatformStatusLabel { get; set; } + + public string? ShippingLabelNo { get; set; } + + public string? ShippingLabelUrl { get; set; } + + public bool IsMultiLabel { get; set; } + + public string? CustomTags { get; set; } + + public bool IsSuspended { get; set; } + + public int? WarehouseId { get; set; } + + public int? LinkedOrderId { get; set; } + + public string? LinkedServiceNo { get; set; } + + public DateTime? LastSyncTime { get; set; } + + public List Lines { get; set; } = []; +} + +public class PlatformStoreOrderSummaryOutput +{ + public int Pending { get; set; } + + public int ToShip { get; set; } + + public int Shipped { get; set; } + + public int Canceled { get; set; } + + public int Suspended { get; set; } + + public int Completed { get; set; } +} + +public class PlatformStoreOrderIdInput +{ + [Required] + public int Id { get; set; } + + public int? CustomerId { get; set; } +} + +public class PlatformStoreOrderIdsInput +{ + [Required] + public List Ids { get; set; } = []; + + public int? CustomerId { get; set; } +} + +public class PlatformStoreOrderMatchInventoryInput +{ + [Required] + public int Id { get; set; } + + public int? CustomerId { get; set; } + + public int? WarehouseId { get; set; } + + public string? ProductCode { get; set; } + + public string? TrackNo { get; set; } + + public OrderParcelReceiverInput? Receiver { get; set; } +} + +public class PlatformStoreOrderBatchMatchInventoryInput +{ + [Required] + public List Ids { get; set; } = []; + + public int? CustomerId { get; set; } + + public int? WarehouseId { get; set; } + + public string? ProductCode { get; set; } +} + +public class PlatformStoreOrderBatchMatchItemOutput +{ + public int Id { get; set; } + + public string PlatformOrderNo { get; set; } = string.Empty; + + public bool Success { get; set; } + + public string Message { get; set; } = string.Empty; + + public int? LinkedOrderId { get; set; } + + public string? ServiceNo { get; set; } +} + +public class PlatformStoreOrderBatchMatchInventoryOutput +{ + public int SuccessCount { get; set; } + + public int FailCount { get; set; } + + public List Items { get; set; } = []; +} + +public class PlatformStoreOrderMatchInventoryOutput +{ + public bool Success { get; set; } + + public string Message { get; set; } = string.Empty; + + public int? LinkedOrderId { get; set; } + + public string? ServiceNo { get; set; } + + public string? ClientNo { get; set; } +} + +public class PlatformStoreOrderToggleSuspendInput +{ + [Required] + public int Id { get; set; } + + public int? CustomerId { get; set; } + + public bool IsSuspended { get; set; } +} + +public class PlatformStoreOrderActionOutput +{ + public bool Success { get; set; } = true; + + public string Message { get; set; } = string.Empty; +} diff --git a/src/Oms.Application/Dtos/Warehouse/WarehouseDtos.cs b/src/Oms.Application/Dtos/Warehouse/WarehouseDtos.cs new file mode 100644 index 0000000..edf6583 --- /dev/null +++ b/src/Oms.Application/Dtos/Warehouse/WarehouseDtos.cs @@ -0,0 +1,468 @@ +using System.ComponentModel.DataAnnotations; + +namespace Oms.Application.Dtos.Warehouse; + +public class WarehousePageInput +{ + public int Page { get; set; } = 1; + + public int PageSize { get; set; } = 30; + + public int? CustomerId { get; set; } +} + +// --- SKU --- + +public class WarehouseSkuListInput : WarehousePageInput +{ + public string? Keyword { get; set; } +} + +public class WarehouseSkuOutput +{ + public int Id { get; set; } + + public int CustomerId { get; set; } + + public string SkuCode { get; set; } = string.Empty; + + public string SkuName { get; set; } = string.Empty; + + public string? SkuImageUrl { get; set; } + + public decimal? WeightG { get; set; } + + public decimal? LengthCm { get; set; } + + public decimal? WidthCm { get; set; } + + public decimal? HeightCm { get; set; } + + public string? Remark { get; set; } + + public int TotalQty { get; set; } + + public DateTime CreateTime { get; set; } + + public DateTime? UpdateTime { get; set; } +} + +public class WarehouseSkuSaveInput +{ + public int Id { get; set; } + + public int? CustomerId { get; set; } + + [Required] + public string SkuCode { get; set; } = string.Empty; + + [Required] + public string SkuName { get; set; } = string.Empty; + + public string? SkuImageUrl { get; set; } + + public decimal? WeightG { get; set; } + + public decimal? LengthCm { get; set; } + + public decimal? WidthCm { get; set; } + + public decimal? HeightCm { get; set; } + + public string? Remark { get; set; } +} + +public class WarehouseSkuBindMappingInput +{ + public int? CustomerId { get; set; } + + [Required] + public int StoreId { get; set; } + + [Required] + public string PlatformSku { get; set; } = string.Empty; + + [Required] + public int SkuId { get; set; } +} + +public class WarehouseSkuMappingOutput +{ + public int Id { get; set; } + + public int StoreId { get; set; } + + public string PlatformSku { get; set; } = string.Empty; + + public int SkuId { get; set; } + + public string SkuCode { get; set; } = string.Empty; +} + +// --- Bin Location --- + +public class WarehouseBinLocationListInput +{ + public int WarehouseId { get; set; } + + public string? Keyword { get; set; } + + public bool? IsEnabled { get; set; } +} + +public class WarehouseBinLocationOutput +{ + public int Id { get; set; } + + public int WarehouseId { get; set; } + + public string BinCode { get; set; } = string.Empty; + + public string? BinName { get; set; } + + public string? ZoneCode { get; set; } + + public bool IsEnabled { get; set; } + + public string? Remark { get; set; } +} + +public class WarehouseBinLocationSaveInput +{ + public int Id { get; set; } + + [Required] + public int WarehouseId { get; set; } + + [Required] + public string BinCode { get; set; } = string.Empty; + + public string? BinName { get; set; } + + public string? ZoneCode { get; set; } + + public bool IsEnabled { get; set; } = true; + + public string? Remark { get; set; } +} + +// --- Inbound --- + +public class WarehouseInboundListInput : WarehousePageInput +{ + public int? WarehouseId { get; set; } + + public string? ReceiptStatus { get; set; } + + public string? Keyword { get; set; } +} + +public class WarehouseInboundOutput +{ + public int Id { get; set; } + + public int CustomerId { get; set; } + + public int WarehouseId { get; set; } + + public string NoticeNo { get; set; } = string.Empty; + + public string? MarkReference { get; set; } + + public string InboundType { get; set; } = string.Empty; + + public string ReceiptStatus { get; set; } = string.Empty; + + public DateTime? ExpectedArrivalTime { get; set; } + + public DateTime? ActualArrivalTime { get; set; } + + public string? Remark { get; set; } + + public int BoxCount { get; set; } + + public int PlannedQty { get; set; } + + public DateTime CreateTime { get; set; } +} + +public class WarehouseInboundLineSaveInput +{ + public int SkuId { get; set; } + + public string SkuCode { get; set; } = string.Empty; + + public int PlannedQty { get; set; } +} + +public class WarehouseInboundBoxSaveInput +{ + public string BoxSeqNo { get; set; } = string.Empty; + + public string? MarkBoxNo { get; set; } + + public decimal? WeightG { get; set; } + + public decimal? LengthCm { get; set; } + + public decimal? WidthCm { get; set; } + + public decimal? HeightCm { get; set; } + + public List Lines { get; set; } = []; +} + +public class WarehouseInboundSaveInput +{ + public int Id { get; set; } + + public int? CustomerId { get; set; } + + [Required] + public int WarehouseId { get; set; } + + public string? MarkReference { get; set; } + + public string InboundType { get; set; } = "normal"; + + public DateTime? ExpectedArrivalTime { get; set; } + + public string? Remark { get; set; } + + public List Boxes { get; set; } = []; +} + +public class WarehouseInboundLineOutput +{ + public int Id { get; set; } + + public int SkuId { get; set; } + + public string SkuCode { get; set; } = string.Empty; + + public int PlannedQty { get; set; } + + public int? ReceivedQty { get; set; } + + public int? BinLocationId { get; set; } +} + +public class WarehouseInboundBoxOutput +{ + public int Id { get; set; } + + public string BoxSeqNo { get; set; } = string.Empty; + + public string? MarkBoxNo { get; set; } + + public decimal? WeightG { get; set; } + + public decimal? LengthCm { get; set; } + + public decimal? WidthCm { get; set; } + + public decimal? HeightCm { get; set; } + + public bool IsReceived { get; set; } + + public List Lines { get; set; } = []; +} + +public class WarehouseInboundDetailOutput : WarehouseInboundOutput +{ + public List Boxes { get; set; } = []; +} + +public class WarehouseInboundConfirmLineInput +{ + public int LineId { get; set; } + + public int ReceivedQty { get; set; } + + public int BinLocationId { get; set; } +} + +public class WarehouseInboundConfirmBoxInput +{ + public int BoxId { get; set; } + + public List Lines { get; set; } = []; +} + +public class WarehouseInboundConfirmReceiveInput +{ + [Required] + public int InboundNoticeId { get; set; } + + public int? CustomerId { get; set; } + + public List Boxes { get; set; } = []; +} + +public class WarehouseInboundSummaryOutput +{ + public int Forecasted { get; set; } + + public int Received { get; set; } + + public int Completed { get; set; } +} + +// --- Parcel --- + +public class WarehouseParcelListInput : WarehousePageInput +{ + public int? WarehouseId { get; set; } + + public string? LifecycleStatus { get; set; } + + public string? Keyword { get; set; } +} + +public class WarehouseParcelItemSaveInput +{ + public int Id { get; set; } + + public string? ProductName { get; set; } + + public int Quantity { get; set; } + + public string? Remark { get; set; } +} + +public class WarehouseParcelItemOutput +{ + public int Id { get; set; } + + public string? ProductName { get; set; } + + public int Quantity { get; set; } + + public int? SkuId { get; set; } + + public string? SkuCode { get; set; } + + public int? BinLocationId { get; set; } + + public string? Remark { get; set; } +} + +public class WarehouseParcelOutput +{ + public int Id { get; set; } + + public int CustomerId { get; set; } + + public int WarehouseId { get; set; } + + public string TrackingNo { get; set; } = string.Empty; + + public string? CarrierName { get; set; } + + public string LifecycleStatus { get; set; } = string.Empty; + + public DateTime? ArrivalTime { get; set; } + + public string? Remark { get; set; } + + public List Items { get; set; } = []; + + public DateTime CreateTime { get; set; } +} + +public class WarehouseParcelSaveInput +{ + public int Id { get; set; } + + public int? CustomerId { get; set; } + + [Required] + public int WarehouseId { get; set; } + + [Required] + public string TrackingNo { get; set; } = string.Empty; + + public string? CarrierName { get; set; } + + public string LifecycleStatus { get; set; } = "inTransit"; + + public string? Remark { get; set; } + + public List Items { get; set; } = []; +} + +public class WarehouseParcelPutawayItemInput +{ + public int ItemId { get; set; } + + public int SkuId { get; set; } + + public int BinLocationId { get; set; } +} + +public class WarehouseParcelPutawayInput +{ + [Required] + public int ParcelId { get; set; } + + public int? CustomerId { get; set; } + + public List Items { get; set; } = []; +} + +// --- Inventory --- + +public class WarehouseInventoryListInput : WarehousePageInput +{ + public int? WarehouseId { get; set; } + + public string? Keyword { get; set; } +} + +public class WarehouseInventoryOutput +{ + public int SkuId { get; set; } + + public string SkuCode { get; set; } = string.Empty; + + public string SkuName { get; set; } = string.Empty; + + public int WarehouseId { get; set; } + + public int TotalQty { get; set; } + + public int BinCount { get; set; } +} + +public class WarehouseInventoryBinDetailOutput +{ + public int Id { get; set; } + + public int BinLocationId { get; set; } + + public string BinCode { get; set; } = string.Empty; + + public string? BinName { get; set; } + + public int Qty { get; set; } +} + +public class WarehouseInventoryRelocateInput +{ + public int? CustomerId { get; set; } + + [Required] + public int WarehouseId { get; set; } + + [Required] + public int SkuId { get; set; } + + [Required] + public int SourceBinLocationId { get; set; } + + [Required] + public int TargetBinLocationId { get; set; } + + [Range(1, int.MaxValue)] + public int Qty { get; set; } +} diff --git a/src/Oms.Application/Services/Orders/PlatformStoreOrderParcelBuilder.cs b/src/Oms.Application/Services/Orders/PlatformStoreOrderParcelBuilder.cs new file mode 100644 index 0000000..e912d37 --- /dev/null +++ b/src/Oms.Application/Services/Orders/PlatformStoreOrderParcelBuilder.cs @@ -0,0 +1,263 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using Fake.Application; +using Oms.Application.Dtos.Orders; +using Oms.Domain.OrdersAggregate.Consts; +using Oms.Domain.OrdersAggregate.Entities; +using Oms.Domain.SystemAggregate.Entities; +using Oms.Domain.WarehouseAggregate.Entities; + +namespace Oms.Application.Services.Orders; + +/// +/// 将店铺订单转换为小包创建入参。 +/// +public static class PlatformStoreOrderParcelBuilder +{ + private const decimal DefaultLengthCm = 10m; + private const decimal DefaultWidthCm = 10m; + private const decimal DefaultHeightCm = 10m; + private const decimal MinActualWeightKg = 0.01m; + + public static OrderParcelCreateInput Build( + SalesStoreOrder storeOrder, + IReadOnlyCollection lines, + IReadOnlyDictionary skuMap, + WarehouseAddress warehouse, + string productCode, + int warehouseId, + int customerId, + OrderParcelReceiverInput? receiverOverride = null, + string? trackNoOverride = null) + { + var receiver = receiverOverride ?? ParseReceiver(storeOrder); + ValidateReceiver(receiver); + + var declarationLines = BuildDeclarationLines(lines, skuMap); + var (length, width, height, actualWeightKg) = ResolveParcelMetrics(declarationLines, lines, skuMap); + + return new OrderParcelCreateInput + { + RequestId = $"platform-store-order:{storeOrder.Id}", + ClientNo = storeOrder.PlatformOrderNo, + TrackNo = string.IsNullOrWhiteSpace(trackNoOverride) ? storeOrder.ShippingLabelNo : trackNoOverride.Trim(), + ProductCode = productCode, + WarehouseId = warehouseId, + CustomerId = customerId, + Length = length, + Width = width, + Height = height, + ActualWeight = actualWeightKg, + ChargeableWeight = actualWeightKg, + CargoAttribute = CargoAttributeCodes.General, + DeclaredCurrency = string.IsNullOrWhiteSpace(storeOrder.Currency) ? "USD" : storeOrder.Currency.Trim(), + DeclaredSum = storeOrder.TotalAmount, + Quantity = 1, + Receiver = receiver, + Sender = BuildSender(warehouse), + Items = declarationLines + }; + } + + private static List BuildDeclarationLines( + IReadOnlyCollection lines, + IReadOnlyDictionary skuMap) + { + var items = new List(); + foreach (var line in lines.Where(x => !x.IsShipExcluded)) + { + if (!line.MappedSkuId.HasValue || !skuMap.TryGetValue(line.MappedSkuId.Value, out var sku)) + { + throw new BusinessException($"申报行「{line.PlatformSku}」缺少库内 SKU 映射"); + } + + var unitWeightKg = sku.WeightG is > 0 ? Math.Max(sku.WeightG.Value / 1000m, MinActualWeightKg) : MinActualWeightKg; + var unitPrice = line.UnitPrice is > 0 ? line.UnitPrice.Value : 1m; + var cnName = string.IsNullOrWhiteSpace(line.ProductName) ? sku.SkuName : line.ProductName.Trim(); + var enName = string.IsNullOrWhiteSpace(sku.SkuName) ? cnName : sku.SkuName; + + items.Add(new OrderParcelItemInput + { + CnName = cnName, + EnName = enName, + Sku = sku.SkuCode, + Unit = "PCS", + UnitPrice = unitPrice, + UnitWeight = unitWeightKg, + Quantity = line.Quantity, + Amount = unitPrice * line.Quantity, + Weight = unitWeightKg * line.Quantity + }); + } + + if (items.Count == 0) + { + throw new BusinessException("没有可申报的商品行"); + } + + return items; + } + + private static (decimal Length, decimal Width, decimal Height, decimal ActualWeightKg) ResolveParcelMetrics( + IReadOnlyCollection items, + IReadOnlyCollection lines, + IReadOnlyDictionary skuMap) + { + decimal maxLength = DefaultLengthCm; + decimal maxWidth = DefaultWidthCm; + decimal maxHeight = DefaultHeightCm; + decimal totalWeightKg = 0m; + + foreach (var line in lines.Where(x => !x.IsShipExcluded && x.MappedSkuId.HasValue)) + { + if (!skuMap.TryGetValue(line.MappedSkuId!.Value, out var sku)) + { + continue; + } + + if (sku.LengthCm is > 0) + { + maxLength = Math.Max(maxLength, sku.LengthCm.Value); + } + + if (sku.WidthCm is > 0) + { + maxWidth = Math.Max(maxWidth, sku.WidthCm.Value); + } + + if (sku.HeightCm is > 0) + { + maxHeight = Math.Max(maxHeight, sku.HeightCm.Value); + } + + var unitWeightKg = sku.WeightG is > 0 ? sku.WeightG.Value / 1000m : MinActualWeightKg; + totalWeightKg += unitWeightKg * line.Quantity; + } + + if (totalWeightKg <= 0) + { + totalWeightKg = Math.Max(items.Sum(x => x.Weight ?? 0m), MinActualWeightKg); + } + + return (maxLength, maxWidth, maxHeight, Math.Max(totalWeightKg, MinActualWeightKg)); + } + + private static OrderParcelSenderInput BuildSender(WarehouseAddress warehouse) + { + var senderName = string.IsNullOrWhiteSpace(warehouse.Recipient) ? warehouse.WarehouseName : warehouse.Recipient.Trim(); + return new OrderParcelSenderInput + { + SenderName = senderName, + Phone = warehouse.ContactPhone, + StreetAddress1 = string.IsNullOrWhiteSpace(warehouse.ShippingAddress) ? warehouse.WarehouseName : warehouse.ShippingAddress.Trim() + }; + } + + private static OrderParcelReceiverInput? ParseReceiver(SalesStoreOrder storeOrder) + { + if (!string.IsNullOrWhiteSpace(storeOrder.PlatformExtraJson)) + { + try + { + var node = JsonNode.Parse(storeOrder.PlatformExtraJson); + if (node != null) + { + var parsed = ParseReceiverNode(node); + if (parsed != null) + { + return parsed; + } + } + } + catch (JsonException) + { + // 忽略 JSON 解析错误,走兜底 + } + } + + if (string.IsNullOrWhiteSpace(storeOrder.OrderCountry)) + { + return null; + } + + return new OrderParcelReceiverInput + { + RecipientsName = "Platform Recipient", + CountryCode = storeOrder.OrderCountry.Trim(), + ZipCode = "00000", + StreetAddress1 = "TBD" + }; + } + + private static OrderParcelReceiverInput? ParseReceiverNode(JsonNode node) + { + var candidates = new List { node }; + foreach (var key in new[] { "recipient", "receiver", "shippingAddress", "address" }) + { + if (node[key] != null) + { + candidates.Add(node[key]); + } + } + + foreach (var candidate in candidates) + { + if (candidate == null) + { + continue; + } + + var name = ReadText(candidate, "recipientsName", "recipientName", "receiverName", "name", "fullName"); + var country = ReadText(candidate, "countryCode", "country", "region", "orderCountry") ?? ReadText(node, "orderCountry"); + var zip = ReadText(candidate, "zipCode", "zip", "postalCode", "postcode"); + var address = ReadText(candidate, "streetAddress1", "address", "address1", "fullAddress", "detailAddress"); + if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(country) || string.IsNullOrWhiteSpace(zip) || string.IsNullOrWhiteSpace(address)) + { + continue; + } + + return new OrderParcelReceiverInput + { + RecipientsName = name, + CompanyName = ReadText(candidate, "companyName", "company"), + Phone = ReadText(candidate, "phone", "mobile", "phoneNumber"), + TelePhone = ReadText(candidate, "telePhone", "tel"), + Email = ReadText(candidate, "email"), + CountryCode = country, + Province = ReadText(candidate, "province", "state", "regionState"), + City = ReadText(candidate, "city"), + StreetAddress1 = address, + StreetAddress2 = ReadText(candidate, "streetAddress2", "address2"), + ZipCode = zip + }; + } + + return null; + } + + private static string? ReadText(JsonNode node, params string[] keys) + { + foreach (var key in keys) + { + var value = node[key]?.ToString()?.Trim(); + if (!string.IsNullOrWhiteSpace(value)) + { + return value; + } + } + + return null; + } + + private static void ValidateReceiver(OrderParcelReceiverInput? receiver) + { + if (receiver == null + || string.IsNullOrWhiteSpace(receiver.RecipientsName) + || string.IsNullOrWhiteSpace(receiver.CountryCode) + || string.IsNullOrWhiteSpace(receiver.ZipCode) + || string.IsNullOrWhiteSpace(receiver.StreetAddress1)) + { + throw new BusinessException("收件人信息不完整,请同步平台订单或在匹配时补充收件人"); + } + } +} diff --git a/src/Oms.Application/Services/Orders/PlatformStoreOrderService.cs b/src/Oms.Application/Services/Orders/PlatformStoreOrderService.cs new file mode 100644 index 0000000..74d55ee --- /dev/null +++ b/src/Oms.Application/Services/Orders/PlatformStoreOrderService.cs @@ -0,0 +1,612 @@ +using System.Linq.Expressions; +using Fake.Application; +using Microsoft.AspNetCore.Mvc; +using Oms.Application.Dtos.Orders; +using Oms.Application.Dtos.PlatformStore; +using Oms.Application.Dtos.Shared; +using Oms.Application.Shared; +using Oms.Domain.ChannelsAggregate.Repositories; +using Oms.Domain.CustomersAggregate.Repositories; +using Oms.Domain.OrdersAggregate.Consts; +using Oms.Domain.OrdersAggregate.Entities; +using Oms.Domain.OrdersAggregate.Repositories; +using Oms.Domain.Shared.Enums; +using Oms.Domain.SystemAggregate.Repositories; +using Oms.Domain.WarehouseAggregate.Consts; +using Oms.Domain.WarehouseAggregate.Repositories; + +namespace Oms.Application.Services.Orders; + +/// +/// 店铺订单 API 服务。 +/// +[ApiExplorerSettings(GroupName = "OMS-Order")] +public class PlatformStoreOrderService : ApplicationService +{ + private ICustomerRepository CustomerRepository => LazyServiceProvider.GetRequiredService(); + private ISalesStoreRepository StoreRepository => LazyServiceProvider.GetRequiredService(); + private ISalesStoreOrderRepository StoreOrderRepository => LazyServiceProvider.GetRequiredService(); + private ISalesStoreOrderLineRepository StoreOrderLineRepository => LazyServiceProvider.GetRequiredService(); + private ICSkuRepository SkuRepository => LazyServiceProvider.GetRequiredService(); + private ICSkuPlatformMapRepository SkuPlatformMapRepository => LazyServiceProvider.GetRequiredService(); + private ICInventoryRepository InventoryRepository => LazyServiceProvider.GetRequiredService(); + private IOrderRepository OrderRepository => LazyServiceProvider.GetRequiredService(); + private IWarehouseAddressRepository WarehouseAddressRepository => LazyServiceProvider.GetRequiredService(); + private ISalesProductRepository SalesProductRepository => LazyServiceProvider.GetRequiredService(); + private OrderService OrderService => LazyServiceProvider.GetRequiredService(); + + [HttpPost("/order/platform-store-order/list")] + public async Task> GetListAsync(PlatformStoreOrderListInput input) + { + var customerId = await WarehouseCustomerHelper.ResolveRequiredCustomerIdAsync( + CustomerRepository, + CurrentUser, + input.CustomerId); + var predicate = BuildListPredicate(customerId, input); + var pageIndex = OmsDocHelper.NormalizePage(input.Page); + var pageSize = OmsDocHelper.NormalizePageSize(input.PageSize); + var entities = await StoreOrderRepository.GetPagedListAsync( + predicate, + pageIndex, + pageSize, + new Dictionary { [nameof(SalesStoreOrder.UpdateTime)] = false, [nameof(SalesStoreOrder.Id)] = false }); + var total = await StoreOrderRepository.CountAsync(predicate); + + var storeIds = entities.Select(x => x.StoreId).Distinct().ToArray(); + var stores = storeIds.Length == 0 + ? [] + : await StoreRepository.GetListAsync(x => storeIds.Contains(x.Id)); + var storeMap = stores.ToDictionary(x => x.Id); + + var linkedOrderIds = entities.Where(x => x.LinkedOrderId is > 0).Select(x => x.LinkedOrderId!.Value).Distinct().ToArray(); + var linkedOrders = linkedOrderIds.Length == 0 + ? [] + : await OrderRepository.GetListAsync(x => linkedOrderIds.Contains(x.Id)); + var linkedOrderMap = linkedOrders.ToDictionary(x => x.Id); + + return new OmsPageResult + { + Items = entities.Select(x => + { + storeMap.TryGetValue(x.StoreId, out var store); + string? linkedServiceNo = null; + if (x.LinkedOrderId is > 0 && linkedOrderMap.TryGetValue(x.LinkedOrderId.Value, out var linkedOrder)) + { + linkedServiceNo = linkedOrder.ServiceNo; + } + + return MapOrderOutput(x, store?.StoreNickname ?? string.Empty, [], linkedServiceNo: linkedServiceNo); + }).ToList(), + Total = total + }; + } + + [HttpGet("/order/platform-store-order/detail")] + public async Task GetDetailAsync([FromQuery] int id, [FromQuery] int? customerId = null) + { + var resolvedCustomerId = await WarehouseCustomerHelper.ResolveRequiredCustomerIdAsync( + CustomerRepository, + CurrentUser, + customerId); + var order = await GetOrderOrThrowAsync(resolvedCustomerId, id); + var store = await StoreRepository.FirstOrDefaultAsync(x => x.Id == order.StoreId); + var lines = await StoreOrderLineRepository.GetListAsync(x => x.StoreOrderId == order.Id); + var skuIds = lines.Where(x => x.MappedSkuId.HasValue).Select(x => x.MappedSkuId!.Value).Distinct().ToArray(); + var skus = skuIds.Length == 0 + ? [] + : await SkuRepository.GetListAsync(x => skuIds.Contains(x.Id)); + var skuMap = skus.ToDictionary(x => x.Id); + var linkedServiceNo = await ResolveLinkedServiceNoAsync(order.LinkedOrderId); + + return MapOrderOutput(order, store?.StoreNickname ?? string.Empty, lines, skuMap, linkedServiceNo); + } + + [HttpGet("/order/platform-store-order/summary")] + public async Task GetSummaryAsync([FromQuery] int? customerId = null, [FromQuery] int? storeId = null) + { + var resolvedCustomerId = await WarehouseCustomerHelper.ResolveRequiredCustomerIdAsync( + CustomerRepository, + CurrentUser, + customerId); + var orders = await StoreOrderRepository.GetListAsync(x => + x.CustomerId == resolvedCustomerId + && (!storeId.HasValue || x.StoreId == storeId.Value)); + + return new PlatformStoreOrderSummaryOutput + { + Pending = orders.Count(x => x.FulfillStatus == SalesStoreFulfillStatusCodes.Pending), + ToShip = orders.Count(x => x.FulfillStatus == SalesStoreFulfillStatusCodes.ToShip), + Shipped = orders.Count(x => x.FulfillStatus == SalesStoreFulfillStatusCodes.Shipped), + Canceled = orders.Count(x => x.FulfillStatus == SalesStoreFulfillStatusCodes.Canceled), + Suspended = orders.Count(x => x.IsSuspended), + Completed = orders.Count(x => x.FulfillStatus == SalesStoreFulfillStatusCodes.Completed) + }; + } + + [HttpPost("/order/platform-store-order/sync-status")] + public async Task SyncStatusAsync(PlatformStoreOrderIdsInput input) + { + await WarehouseCustomerHelper.ResolveRequiredCustomerIdAsync( + CustomerRepository, + CurrentUser, + input.CustomerId); + + return new PlatformStoreOrderActionOutput + { + Message = "平台订单同步待 SDK 接入,当前为占位实现" + }; + } + + [HttpPost("/order/platform-store-order/ready-to-ship")] + public async Task ReadyToShipAsync(PlatformStoreOrderIdsInput input) + { + await WarehouseCustomerHelper.ResolveRequiredCustomerIdAsync( + CustomerRepository, + CurrentUser, + input.CustomerId); + + return new PlatformStoreOrderActionOutput + { + Message = "RTS 待平台 SDK 接入,当前为占位实现" + }; + } + + [HttpPost("/order/platform-store-order/fetch-label")] + public async Task FetchLabelAsync(PlatformStoreOrderIdInput input) + { + await WarehouseCustomerHelper.ResolveRequiredCustomerIdAsync( + CustomerRepository, + CurrentUser, + input.CustomerId); + + return new PlatformStoreOrderActionOutput + { + Message = "面单拉取待平台 SDK 接入,当前为占位实现" + }; + } + + [HttpPost("/order/platform-store-order/toggle-suspend")] + public async Task ToggleSuspendAsync(PlatformStoreOrderToggleSuspendInput input) + { + var customerId = await WarehouseCustomerHelper.ResolveRequiredCustomerIdAsync( + CustomerRepository, + CurrentUser, + input.CustomerId); + var order = await GetOrderOrThrowAsync(customerId, input.Id); + order.IsSuspended = input.IsSuspended; + order.FulfillStatus = input.IsSuspended + ? SalesStoreFulfillStatusCodes.Suspended + : SalesStoreFulfillStatusCodes.Pending; + await StoreOrderRepository.UpdateAsync(order); + } + + [HttpPost("/order/platform-store-order/delete")] + public async Task DeleteAsync(PlatformStoreOrderIdsInput input) + { + if (input.Ids.Count == 0) + { + throw new BusinessException("请选择要删除的订单"); + } + + var customerId = await WarehouseCustomerHelper.ResolveRequiredCustomerIdAsync( + CustomerRepository, + CurrentUser, + input.CustomerId); + var orders = await StoreOrderRepository.GetListAsync(x => x.CustomerId == customerId && input.Ids.Contains(x.Id)); + foreach (var order in orders) + { + var lines = await StoreOrderLineRepository.GetListAsync(x => x.StoreOrderId == order.Id); + foreach (var line in lines) + { + await StoreOrderLineRepository.DeleteAsync(line); + } + + await StoreOrderRepository.DeleteAsync(order); + } + } + + [HttpPost("/order/platform-store-order/match-inventory")] + public async Task MatchInventoryAsync(PlatformStoreOrderMatchInventoryInput input) + { + var customerId = await WarehouseCustomerHelper.ResolveRequiredCustomerIdAsync( + CustomerRepository, + CurrentUser, + input.CustomerId); + var result = await MatchInventoryCoreAsync( + customerId, + input.Id, + input.WarehouseId, + input.ProductCode, + input.TrackNo, + input.Receiver); + + return new PlatformStoreOrderMatchInventoryOutput + { + Success = true, + Message = $"库存匹配成功,已创建小包订单 {result.ServiceNo}", + LinkedOrderId = result.OrderId, + ServiceNo = result.ServiceNo, + ClientNo = result.ClientNo + }; + } + + [HttpPost("/order/platform-store-order/batch-match-inventory")] + public async Task BatchMatchInventoryAsync( + PlatformStoreOrderBatchMatchInventoryInput input) + { + if (input.Ids.Count == 0) + { + throw new BusinessException("请选择要匹配的订单"); + } + + var customerId = await WarehouseCustomerHelper.ResolveRequiredCustomerIdAsync( + CustomerRepository, + CurrentUser, + input.CustomerId); + var output = new PlatformStoreOrderBatchMatchInventoryOutput(); + foreach (var id in input.Ids.Distinct()) + { + var storeOrder = await StoreOrderRepository.FirstOrDefaultAsync(x => x.Id == id && x.CustomerId == customerId); + var item = new PlatformStoreOrderBatchMatchItemOutput + { + Id = id, + PlatformOrderNo = storeOrder?.PlatformOrderNo ?? id.ToString() + }; + + try + { + var result = await MatchInventoryCoreAsync( + customerId, + id, + input.WarehouseId, + input.ProductCode, + null, + null); + item.Success = true; + item.Message = $"已创建小包订单 {result.ServiceNo}"; + item.LinkedOrderId = result.OrderId; + item.ServiceNo = result.ServiceNo; + output.SuccessCount++; + } + catch (BusinessException ex) + { + item.Success = false; + item.Message = ex.Message; + output.FailCount++; + } + catch (Exception ex) + { + item.Success = false; + item.Message = ex.Message; + output.FailCount++; + } + + output.Items.Add(item); + } + + return output; + } + + private async Task MatchInventoryCoreAsync( + int customerId, + int storeOrderId, + int? warehouseId, + string? productCode, + string? trackNo, + OrderParcelReceiverInput? receiver) + { + var order = await GetOrderOrThrowAsync(customerId, storeOrderId); + await EnsureMatchableAsync(order); + + var resolvedWarehouseId = warehouseId ?? order.WarehouseId; + if (!resolvedWarehouseId.HasValue || resolvedWarehouseId.Value <= 0) + { + throw new BusinessException("请指定发货仓库"); + } + + var warehouse = await WarehouseAddressRepository.FirstOrDefaultAsync(x => x.Id == resolvedWarehouseId.Value) + ?? throw new BusinessException("仓库不存在"); + + var lines = await StoreOrderLineRepository.GetListAsync(x => x.StoreOrderId == order.Id && !x.IsShipExcluded); + if (lines.Count == 0) + { + throw new BusinessException("没有可发货的商品行"); + } + + await ResolveMappedSkuLinesAsync(customerId, order, lines); + var requiredQty = lines + .Where(x => x.MappedSkuId.HasValue) + .GroupBy(x => x.MappedSkuId!.Value) + .ToDictionary(g => g.Key, g => g.Sum(x => x.Quantity)); + + await EnsureInventoryAvailableAsync(customerId, resolvedWarehouseId.Value, requiredQty); + + var skuIds = requiredQty.Keys.ToArray(); + var skus = await SkuRepository.GetListAsync(x => x.CustomerId == customerId && skuIds.Contains(x.Id)); + var skuMap = skus.ToDictionary(x => x.Id); + if (skuMap.Count != skuIds.Length) + { + throw new BusinessException("部分库内 SKU 不存在"); + } + + var resolvedProductCode = await ResolveProductCodeAsync(productCode); + var parcelInput = PlatformStoreOrderParcelBuilder.Build( + order, + lines, + skuMap, + warehouse, + resolvedProductCode, + resolvedWarehouseId.Value, + customerId, + receiver, + trackNo); + + List deductions = []; + try + { + foreach (var (skuId, qty) in requiredQty) + { + deductions.AddRange(await DeductInventoryAsync(customerId, resolvedWarehouseId.Value, skuId, qty)); + } + + var parcelOutput = await OrderService.CreateParcelForBatchAsync(parcelInput, CurrentUser.GetId()); + + order.WarehouseId = resolvedWarehouseId; + order.LinkedOrderId = parcelOutput.OrderId; + order.FulfillStatus = SalesStoreFulfillStatusCodes.ToShip; + order.LastSyncTime = DateTime.Now; + if (!string.IsNullOrWhiteSpace(parcelOutput.TrackNo)) + { + order.ShippingLabelNo = parcelOutput.TrackNo; + } + + await StoreOrderRepository.UpdateAsync(order); + return parcelOutput; + } + catch + { + await RestoreInventoryAsync(customerId, resolvedWarehouseId.Value, deductions); + throw; + } + } + + private async Task EnsureMatchableAsync(SalesStoreOrder order) + { + if (order.IsSuspended) + { + throw new BusinessException("订单已搁置,不能匹配库存"); + } + + if (order.FulfillStatus is SalesStoreFulfillStatusCodes.Shipped or SalesStoreFulfillStatusCodes.Completed) + { + throw new BusinessException("订单已发货或完成,不能重复匹配"); + } + + if (order.LinkedOrderId is > 0) + { + var linked = await OrderRepository.FirstOrDefaultAsync(x => x.Id == order.LinkedOrderId.Value); + if (linked != null && linked.OrderStatus != OrderStatusCodes.Deleted) + { + throw new BusinessException($"已关联小包订单 {linked.ServiceNo},不能重复匹配"); + } + } + } + + private async Task ResolveMappedSkuLinesAsync(int customerId, SalesStoreOrder order, List lines) + { + var mappings = await SkuPlatformMapRepository.GetListAsync(x => x.CustomerId == customerId && x.StoreId == order.StoreId); + var mappingDict = mappings.ToDictionary(x => x.PlatformSku, x => x, StringComparer.OrdinalIgnoreCase); + + foreach (var line in lines) + { + if (line.MappedSkuId.HasValue) + { + continue; + } + + if (!string.IsNullOrWhiteSpace(line.PlatformSku) && mappingDict.TryGetValue(line.PlatformSku, out var map)) + { + line.MappedSkuId = map.SkuId; + await StoreOrderLineRepository.UpdateAsync(line); + continue; + } + + throw new BusinessException($"平台 SKU「{line.PlatformSku}」未映射库内 SKU"); + } + } + + private async Task EnsureInventoryAvailableAsync(int customerId, int warehouseId, Dictionary requiredQty) + { + foreach (var (skuId, qty) in requiredQty) + { + var total = await InventoryRepository.GetListAsync(x => + x.CustomerId == customerId + && x.WarehouseId == warehouseId + && x.SkuId == skuId + && x.Qty > 0); + var available = total.Sum(x => x.Qty); + if (available < qty) + { + throw new BusinessException($"SKU {skuId} 库存不足,需要 {qty},可用 {available}"); + } + } + } + + private async Task ResolveProductCodeAsync(string? productCode) + { + var normalized = productCode?.Trim(); + if (!string.IsNullOrWhiteSpace(normalized)) + { + var product = await SalesProductRepository.GetByProductCodeAsync(normalized); + if (product == null || product.IsActive != EnableStatus.Enabled) + { + throw new BusinessException("销售产品不存在或未启用"); + } + + return normalized; + } + + var products = await SalesProductRepository.GetListAsync(x => x.IsActive == EnableStatus.Enabled); + var first = products.OrderBy(x => x.ProductCode).FirstOrDefault(); + if (first == null || string.IsNullOrWhiteSpace(first.ProductCode)) + { + throw new BusinessException("请指定销售产品编码"); + } + + return first.ProductCode; + } + + private async Task> DeductInventoryAsync(int customerId, int warehouseId, int skuId, int requiredQty) + { + var rows = await InventoryRepository.GetListAsync(x => + x.CustomerId == customerId + && x.WarehouseId == warehouseId + && x.SkuId == skuId + && x.Qty > 0); + rows = rows.OrderBy(x => x.BinLocationId).ToList(); + + var deductions = new List(); + var remaining = requiredQty; + foreach (var row in rows) + { + if (remaining <= 0) + { + break; + } + + var deduct = Math.Min(row.Qty, remaining); + row.Qty -= deduct; + remaining -= deduct; + await InventoryRepository.UpdateAsync(row); + deductions.Add(new InventoryDeduction(skuId, row.BinLocationId, deduct)); + } + + if (remaining > 0) + { + throw new BusinessException($"SKU {skuId} 库存扣减失败"); + } + + return deductions; + } + + private async Task RestoreInventoryAsync(int customerId, int warehouseId, IReadOnlyCollection deductions) + { + foreach (var item in deductions) + { + var inventory = await InventoryRepository.FirstOrDefaultAsync(x => + x.CustomerId == customerId + && x.WarehouseId == warehouseId + && x.SkuId == item.SkuId + && x.BinLocationId == item.BinLocationId); + if (inventory == null) + { + inventory = new Domain.WarehouseAggregate.Entities.CInventory + { + CustomerId = customerId, + WarehouseId = warehouseId, + SkuId = item.SkuId, + BinLocationId = item.BinLocationId, + Qty = item.Qty + }; + await InventoryRepository.InsertAsync(inventory); + continue; + } + + inventory.Qty += item.Qty; + await InventoryRepository.UpdateAsync(inventory); + } + } + + private async Task ResolveLinkedServiceNoAsync(int? linkedOrderId) + { + if (linkedOrderId is not > 0) + { + return null; + } + + var linked = await OrderRepository.FirstOrDefaultAsync(x => x.Id == linkedOrderId.Value); + return linked?.ServiceNo; + } + + private async Task GetOrderOrThrowAsync(int customerId, int id) + { + var entity = await StoreOrderRepository.FirstOrDefaultAsync(x => x.Id == id && x.CustomerId == customerId); + if (entity == null) + { + throw new BusinessException("店铺订单不存在"); + } + + return entity; + } + + private static Expression> BuildListPredicate(int customerId, PlatformStoreOrderListInput input) + { + var fulfillStatus = input.FulfillStatus?.Trim(); + var platformOrderNo = input.PlatformOrderNo?.Trim(); + + return x => + x.CustomerId == customerId + && (!input.StoreId.HasValue || x.StoreId == input.StoreId.Value) + && (string.IsNullOrWhiteSpace(fulfillStatus) || x.FulfillStatus == fulfillStatus) + && (!input.IsSuspended.HasValue || x.IsSuspended == input.IsSuspended.Value) + && (string.IsNullOrWhiteSpace(platformOrderNo) || x.PlatformOrderNo.Contains(platformOrderNo)); + } + + private static PlatformStoreOrderOutput MapOrderOutput( + SalesStoreOrder order, + string storeNickname, + IReadOnlyCollection lines, + IReadOnlyDictionary? skuMap = null, + string? linkedServiceNo = null) => + new() + { + Id = order.Id, + CustomerId = order.CustomerId, + StoreId = order.StoreId, + StoreNickname = storeNickname, + PlatformCode = order.PlatformCode, + PlatformOrderNo = order.PlatformOrderNo, + OrderCountry = order.OrderCountry, + ItemCount = order.ItemCount, + TotalAmount = order.TotalAmount, + Currency = order.Currency, + PlatformOrderTime = order.PlatformOrderTime, + FulfillStatus = order.FulfillStatus, + PlatformStatusLabel = order.PlatformStatusLabel, + ShippingLabelNo = order.ShippingLabelNo, + ShippingLabelUrl = order.ShippingLabelUrl, + IsMultiLabel = order.IsMultiLabel, + CustomTags = order.CustomTags, + IsSuspended = order.IsSuspended, + WarehouseId = order.WarehouseId, + LinkedOrderId = order.LinkedOrderId, + LinkedServiceNo = linkedServiceNo, + LastSyncTime = order.LastSyncTime, + Lines = lines.Select(line => + { + string? mappedSkuCode = null; + if (line.MappedSkuId.HasValue && skuMap != null && skuMap.TryGetValue(line.MappedSkuId.Value, out var sku)) + { + mappedSkuCode = sku.SkuCode; + } + + return new PlatformStoreOrderLineOutput + { + Id = line.Id, + PlatformLineId = line.PlatformLineId, + PlatformSku = line.PlatformSku, + ProductName = line.ProductName, + ProductImageUrl = line.ProductImageUrl, + VariantText = line.VariantText, + UnitPrice = line.UnitPrice, + Quantity = line.Quantity, + Currency = line.Currency, + IsShipExcluded = line.IsShipExcluded, + MappedSkuId = line.MappedSkuId, + MappedSkuCode = mappedSkuCode + }; + }).ToList() + }; + + private sealed record InventoryDeduction(int SkuId, int BinLocationId, int Qty); +} diff --git a/src/Oms.Application/Services/Orders/PlatformStoreService.cs b/src/Oms.Application/Services/Orders/PlatformStoreService.cs new file mode 100644 index 0000000..a3a6e29 --- /dev/null +++ b/src/Oms.Application/Services/Orders/PlatformStoreService.cs @@ -0,0 +1,192 @@ +using System.Linq.Expressions; +using Fake.Application; +using Microsoft.AspNetCore.Mvc; +using Oms.Application.Dtos.PlatformStore; +using Oms.Application.Dtos.Shared; +using Oms.Application.Shared; +using Oms.Domain.CustomersAggregate.Repositories; +using Oms.Domain.OrdersAggregate.Entities; +using Oms.Domain.OrdersAggregate.Repositories; +using Oms.Domain.WarehouseAggregate.Consts; + +namespace Oms.Application.Services.Orders; + +/// +/// 店铺授权 API 服务。 +/// +[ApiExplorerSettings(GroupName = "OMS-Order")] +public class PlatformStoreService : ApplicationService +{ + private ICustomerRepository CustomerRepository => LazyServiceProvider.GetRequiredService(); + private ISalesStoreRepository StoreRepository => LazyServiceProvider.GetRequiredService(); + + [HttpPost("/order/platform-store/list")] + public async Task> GetListAsync(PlatformStorePageInput input) + { + var customerId = await WarehouseCustomerHelper.ResolveRequiredCustomerIdAsync( + CustomerRepository, + CurrentUser, + input.CustomerId); + var predicate = BuildListPredicate(customerId, input); + var pageIndex = OmsDocHelper.NormalizePage(input.Page); + var pageSize = OmsDocHelper.NormalizePageSize(input.PageSize); + var entities = await StoreRepository.GetPagedListAsync( + predicate, + pageIndex, + pageSize, + new Dictionary { [nameof(SalesStore.UpdateTime)] = false, [nameof(SalesStore.Id)] = false }); + var total = await StoreRepository.CountAsync(predicate); + + return new OmsPageResult + { + Items = entities.Select(MapOutput).ToList(), + Total = total + }; + } + + [HttpPost("/order/platform-store/auth-url")] + public async Task GetAuthUrlAsync(PlatformStoreAuthUrlInput input) + { + await WarehouseCustomerHelper.ResolveRequiredCustomerIdAsync( + CustomerRepository, + CurrentUser, + input.CustomerId); + + var platform = input.PlatformCode.Trim().ToLowerInvariant(); + var redirect = string.IsNullOrWhiteSpace(input.RedirectUri) + ? "https://localhost/oauth/callback" + : input.RedirectUri.Trim(); + + return new PlatformStoreAuthUrlOutput + { + AuthUrl = $"https://oauth.example.com/{platform}?redirect={Uri.EscapeDataString(redirect)}", + Message = "OAuth 对接待平台 SDK 接入,当前返回占位地址" + }; + } + + [HttpPost("/order/platform-store/save")] + public async Task SaveAsync(PlatformStoreSaveInput input) + { + var customerId = await WarehouseCustomerHelper.ResolveRequiredCustomerIdAsync( + CustomerRepository, + CurrentUser, + input.CustomerId); + var nickname = input.StoreNickname.Trim(); + if (string.IsNullOrWhiteSpace(nickname)) + { + throw new BusinessException("店铺昵称不能为空"); + } + + SalesStore entity; + if (input.Id > 0) + { + entity = await GetStoreOrThrowAsync(customerId, input.Id); + } + else + { + entity = new SalesStore + { + CustomerId = customerId, + PlatformCode = string.IsNullOrWhiteSpace(input.PlatformCode) ? "manual" : input.PlatformCode.Trim(), + AuthStatus = SalesStoreAuthStatusCodes.Active, + AuthTime = DateTime.Now + }; + } + + entity.StoreNickname = nickname; + entity.PlatformAccount = string.IsNullOrWhiteSpace(input.PlatformAccount) ? null : input.PlatformAccount.Trim(); + entity.SiteCountry = string.IsNullOrWhiteSpace(input.SiteCountry) ? null : input.SiteCountry.Trim(); + entity.IsAutoFulfill = input.IsAutoFulfill; + entity.Remark = string.IsNullOrWhiteSpace(input.Remark) ? null : input.Remark.Trim(); + + if (input.Id > 0) + { + await StoreRepository.UpdateAsync(entity); + } + else + { + await StoreRepository.InsertAsync(entity); + } + + return MapOutput(entity); + } + + [HttpPost("/order/platform-store/delete")] + public async Task DeleteAsync(PlatformStoreIdsInput input) + { + if (input.Ids.Count == 0) + { + throw new BusinessException("请选择要删除的店铺"); + } + + var customerId = await WarehouseCustomerHelper.ResolveRequiredCustomerIdAsync( + CustomerRepository, + CurrentUser, + null); + var entities = await StoreRepository.GetListAsync(x => x.CustomerId == customerId && input.Ids.Contains(x.Id)); + foreach (var entity in entities) + { + await StoreRepository.DeleteAsync(entity); + } + } + + private async Task GetStoreOrThrowAsync(int customerId, int id) + { + var entity = await StoreRepository.FirstOrDefaultAsync(x => x.Id == id && x.CustomerId == customerId); + if (entity == null) + { + throw new BusinessException("店铺不存在"); + } + + return entity; + } + + private static Expression> BuildListPredicate(int customerId, PlatformStorePageInput input) + { + var platformCode = input.PlatformCode?.Trim(); + var nickname = input.StoreNickname?.Trim(); + var filter = input.ExpirationFilter?.Trim().ToLowerInvariant(); + var now = DateTime.Now; + var within7Days = now.AddDays(7); + + return x => + x.CustomerId == customerId + && (string.IsNullOrWhiteSpace(platformCode) || x.PlatformCode == platformCode) + && (string.IsNullOrWhiteSpace(nickname) || x.StoreNickname.Contains(nickname)) + && (string.IsNullOrWhiteSpace(filter) || filter == "normal" + ? string.IsNullOrWhiteSpace(filter) || !x.TokenExpireTime.HasValue || x.TokenExpireTime > within7Days + : filter == "expired" + ? x.TokenExpireTime.HasValue && x.TokenExpireTime < now + : filter == "within7days" + ? x.TokenExpireTime.HasValue && x.TokenExpireTime >= now && x.TokenExpireTime <= within7Days + : true); + } + + private static PlatformStoreOutput MapOutput(SalesStore entity) => + new() + { + Id = entity.Id, + CustomerId = entity.CustomerId, + PlatformCode = entity.PlatformCode, + PlatformName = ResolvePlatformName(entity.PlatformCode), + StoreNickname = entity.StoreNickname, + PlatformAccount = entity.PlatformAccount, + SiteCountry = entity.SiteCountry, + AuthStatus = entity.AuthStatus, + AuthTime = entity.AuthTime, + TokenExpireTime = entity.TokenExpireTime, + IsAutoFulfill = entity.IsAutoFulfill, + Remark = entity.Remark, + CreateTime = entity.CreateTime + }; + + private static string ResolvePlatformName(string platformCode) => platformCode.ToLowerInvariant() switch + { + "lazada" => "Lazada", + "shopee" => "Shopee", + "tiktok" => "TikTok", + "temu" => "Temu", + "manual" => "手工店铺", + _ => platformCode + }; +} diff --git a/src/Oms.Application/Services/Warehouse/WarehouseService.BinLocation.cs b/src/Oms.Application/Services/Warehouse/WarehouseService.BinLocation.cs new file mode 100644 index 0000000..3697e9e --- /dev/null +++ b/src/Oms.Application/Services/Warehouse/WarehouseService.BinLocation.cs @@ -0,0 +1,93 @@ +using Fake.Application; +using Microsoft.AspNetCore.Mvc; +using Oms.Application.Dtos.Shared; +using Oms.Application.Dtos.Warehouse; +using Oms.Domain.WarehouseAggregate.Entities; + +namespace Oms.Application.Services.Warehouse; + +public partial class WarehouseService +{ + [HttpPost("/warehouse/bin-location/list")] + public async Task> GetBinLocationListAsync(WarehouseBinLocationListInput input) + { + if (input.WarehouseId <= 0) + { + throw new BusinessException("仓库ID不能为空"); + } + + var keyword = input.Keyword?.Trim(); + var hasKeyword = !string.IsNullOrWhiteSpace(keyword); + var items = await BinLocationRepository.GetListAsync(x => + x.WarehouseId == input.WarehouseId + && (!input.IsEnabled.HasValue || x.IsEnabled == input.IsEnabled.Value) + && (!hasKeyword || x.BinCode.Contains(keyword!) || (x.BinName != null && x.BinName.Contains(keyword!)))); + items = items.OrderBy(x => x.BinCode).ThenBy(x => x.Id).ToList(); + + return new OmsItemsResult + { + Items = items.Select(MapBinLocation).ToList() + }; + } + + [HttpPost("/warehouse/bin-location/save")] + public async Task SaveBinLocationAsync(WarehouseBinLocationSaveInput input) + { + var binCode = input.BinCode.Trim(); + if (string.IsNullOrWhiteSpace(binCode)) + { + throw new BusinessException("库位编码不能为空"); + } + + CBinLocation entity; + if (input.Id > 0) + { + entity = await BinLocationRepository.FirstOrDefaultAsync(x => x.Id == input.Id) + ?? throw new BusinessException("库位不存在"); + if (entity.WarehouseId != input.WarehouseId) + { + throw new BusinessException("库位仓库不匹配"); + } + } + else + { + entity = new CBinLocation { WarehouseId = input.WarehouseId }; + } + + var duplicate = await BinLocationRepository.FirstOrDefaultAsync(x => + x.WarehouseId == input.WarehouseId && x.BinCode == binCode && x.Id != entity.Id); + if (duplicate != null) + { + throw new BusinessException("库位编码已存在"); + } + + entity.BinCode = binCode; + entity.BinName = string.IsNullOrWhiteSpace(input.BinName) ? null : input.BinName.Trim(); + entity.ZoneCode = string.IsNullOrWhiteSpace(input.ZoneCode) ? null : input.ZoneCode.Trim(); + entity.IsEnabled = input.IsEnabled; + entity.Remark = string.IsNullOrWhiteSpace(input.Remark) ? null : input.Remark.Trim(); + + if (input.Id > 0) + { + await BinLocationRepository.UpdateAsync(entity); + } + else + { + await BinLocationRepository.InsertAsync(entity); + } + + return MapBinLocation(entity); + } + + private static WarehouseBinLocationOutput MapBinLocation(CBinLocation entity) => + new() + { + Id = entity.Id, + WarehouseId = entity.WarehouseId, + BinCode = entity.BinCode, + BinName = entity.BinName, + ZoneCode = entity.ZoneCode, + IsEnabled = entity.IsEnabled, + Remark = entity.Remark + }; +} diff --git a/src/Oms.Application/Services/Warehouse/WarehouseService.Helpers.cs b/src/Oms.Application/Services/Warehouse/WarehouseService.Helpers.cs new file mode 100644 index 0000000..8520bbd --- /dev/null +++ b/src/Oms.Application/Services/Warehouse/WarehouseService.Helpers.cs @@ -0,0 +1,109 @@ +using Fake.Application; +using Oms.Application.Shared; +using Oms.Domain.WarehouseAggregate.Entities; + +namespace Oms.Application.Services.Warehouse; + +public partial class WarehouseService +{ + private async Task ResolveCustomerIdAsync(int? requestedCustomerId) => + await WarehouseCustomerHelper.ResolveRequiredCustomerIdAsync( + CustomerRepository, + CurrentUser, + requestedCustomerId); + + private async Task GetSkuOrThrowAsync(int customerId, int skuId) + { + var entity = await SkuRepository.FirstOrDefaultAsync(x => x.Id == skuId && x.CustomerId == customerId); + if (entity == null) + { + throw new BusinessException("SKU 不存在"); + } + + return entity; + } + + private async Task UpsertInventoryQtyAsync( + int customerId, + int warehouseId, + int skuId, + int binLocationId, + int deltaQty) + { + if (deltaQty == 0) + { + return; + } + + var inventory = await InventoryRepository.FirstOrDefaultAsync(x => + x.CustomerId == customerId + && x.WarehouseId == warehouseId + && x.SkuId == skuId + && x.BinLocationId == binLocationId); + if (inventory == null) + { + if (deltaQty < 0) + { + throw new BusinessException("库存不足"); + } + + inventory = new CInventory + { + CustomerId = customerId, + WarehouseId = warehouseId, + SkuId = skuId, + BinLocationId = binLocationId, + Qty = deltaQty + }; + await InventoryRepository.InsertAsync(inventory); + return; + } + + var newQty = inventory.Qty + deltaQty; + if (newQty < 0) + { + throw new BusinessException("库存不足"); + } + + inventory.Qty = newQty; + await InventoryRepository.UpdateAsync(inventory); + } + + private async Task DeductInventoryAsync( + int customerId, + int warehouseId, + int skuId, + int requiredQty) + { + if (requiredQty <= 0) + { + return; + } + + var rows = await InventoryRepository.GetListAsync(x => + x.CustomerId == customerId + && x.WarehouseId == warehouseId + && x.SkuId == skuId + && x.Qty > 0); + rows = rows.OrderBy(x => x.BinLocationId).ToList(); + + var remaining = requiredQty; + foreach (var row in rows) + { + if (remaining <= 0) + { + break; + } + + var deduct = Math.Min(row.Qty, remaining); + row.Qty -= deduct; + remaining -= deduct; + await InventoryRepository.UpdateAsync(row); + } + + if (remaining > 0) + { + throw new BusinessException($"SKU {skuId} 库存不足,缺少 {remaining} 件"); + } + } +} diff --git a/src/Oms.Application/Services/Warehouse/WarehouseService.Inbound.cs b/src/Oms.Application/Services/Warehouse/WarehouseService.Inbound.cs new file mode 100644 index 0000000..267f0fe --- /dev/null +++ b/src/Oms.Application/Services/Warehouse/WarehouseService.Inbound.cs @@ -0,0 +1,365 @@ +using System.Linq.Expressions; +using Fake.Application; +using Microsoft.AspNetCore.Mvc; +using Oms.Application.Dtos.Shared; +using Oms.Application.Dtos.Warehouse; +using Oms.Application.Shared; +using Oms.Domain.WarehouseAggregate.Consts; +using Oms.Domain.WarehouseAggregate.Entities; + +namespace Oms.Application.Services.Warehouse; + +public partial class WarehouseService +{ + [HttpPost("/warehouse/inbound/list")] + public async Task> GetInboundListAsync(WarehouseInboundListInput input) + { + var customerId = await ResolveCustomerIdAsync(input.CustomerId); + var keyword = input.Keyword?.Trim(); + var hasKeyword = !string.IsNullOrWhiteSpace(keyword); + var receiptStatus = input.ReceiptStatus?.Trim(); + + Expression> predicate = x => + x.CustomerId == customerId + && (!input.WarehouseId.HasValue || x.WarehouseId == input.WarehouseId.Value) + && (string.IsNullOrWhiteSpace(receiptStatus) || x.ReceiptStatus == receiptStatus) + && (!hasKeyword || x.NoticeNo.Contains(keyword!) || (x.MarkReference != null && x.MarkReference.Contains(keyword!))); + + var pageIndex = OmsDocHelper.NormalizePage(input.Page); + var pageSize = OmsDocHelper.NormalizePageSize(input.PageSize); + var entities = await InboundNoticeRepository.GetPagedListAsync( + predicate, + pageIndex, + pageSize, + new Dictionary { [nameof(CInboundNotice.UpdateTime)] = false, [nameof(CInboundNotice.Id)] = false }); + var total = await InboundNoticeRepository.CountAsync(predicate); + + var noticeIds = entities.Select(x => x.Id).ToArray(); + var boxes = noticeIds.Length == 0 + ? [] + : await InboundBoxRepository.GetListAsync(x => noticeIds.Contains(x.InboundNoticeId)); + var boxIds = boxes.Select(x => x.Id).ToArray(); + var lines = boxIds.Length == 0 + ? [] + : await InboundBoxLineRepository.GetListAsync(x => boxIds.Contains(x.InboundBoxId)); + + return new OmsPageResult + { + Items = entities.Select(x => MapInboundOutput(x, boxes, lines)).ToList(), + Total = total + }; + } + + [HttpGet("/warehouse/inbound/detail")] + public async Task GetInboundDetailAsync( + [FromQuery] int id, + [FromQuery] int? customerId = null) + { + var resolvedCustomerId = await ResolveCustomerIdAsync(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(); + var boxIds = boxes.Select(x => x.Id).ToArray(); + var lines = boxIds.Length == 0 + ? [] + : await InboundBoxLineRepository.GetListAsync(x => boxIds.Contains(x.InboundBoxId)); + + var output = MapInboundOutput(notice, boxes, lines); + return new WarehouseInboundDetailOutput + { + Id = output.Id, + CustomerId = output.CustomerId, + WarehouseId = output.WarehouseId, + NoticeNo = output.NoticeNo, + MarkReference = output.MarkReference, + InboundType = output.InboundType, + ReceiptStatus = output.ReceiptStatus, + ExpectedArrivalTime = output.ExpectedArrivalTime, + ActualArrivalTime = output.ActualArrivalTime, + Remark = output.Remark, + BoxCount = output.BoxCount, + PlannedQty = output.PlannedQty, + CreateTime = output.CreateTime, + Boxes = boxes.Select(box => MapInboundBox(box, lines.Where(l => l.InboundBoxId == box.Id))).ToList() + }; + } + + [HttpPost("/warehouse/inbound/save")] + public async Task SaveInboundAsync(WarehouseInboundSaveInput input) + { + var customerId = await ResolveCustomerIdAsync(input.CustomerId); + if (input.WarehouseId <= 0) + { + throw new BusinessException("仓库ID不能为空"); + } + + if (input.Boxes.Count == 0) + { + throw new BusinessException("请至少添加一个箱子"); + } + + CInboundNotice notice; + if (input.Id > 0) + { + notice = await GetInboundNoticeOrThrowAsync(customerId, input.Id); + if (notice.ReceiptStatus != WarehouseReceiptStatusCodes.Forecasted) + { + throw new BusinessException("仅预报状态可编辑"); + } + } + else + { + notice = new CInboundNotice + { + CustomerId = customerId, + NoticeNo = WarehouseCustomerHelper.GenerateInboundNoticeNo(), + ReceiptStatus = WarehouseReceiptStatusCodes.Forecasted + }; + } + + notice.WarehouseId = input.WarehouseId; + notice.MarkReference = string.IsNullOrWhiteSpace(input.MarkReference) ? null : input.MarkReference.Trim(); + notice.InboundType = string.IsNullOrWhiteSpace(input.InboundType) + ? WarehouseInboundTypeCodes.Normal + : input.InboundType.Trim(); + notice.ExpectedArrivalTime = input.ExpectedArrivalTime; + notice.Remark = string.IsNullOrWhiteSpace(input.Remark) ? null : input.Remark.Trim(); + + if (input.Id > 0) + { + await InboundNoticeRepository.UpdateAsync(notice); + await ClearInboundBoxesAsync(notice.Id); + } + else + { + await InboundNoticeRepository.InsertAsync(notice); + } + + foreach (var boxInput in input.Boxes) + { + var box = new CInboundBox + { + InboundNoticeId = notice.Id, + BoxSeqNo = string.IsNullOrWhiteSpace(boxInput.BoxSeqNo) ? "1" : boxInput.BoxSeqNo.Trim(), + MarkBoxNo = string.IsNullOrWhiteSpace(boxInput.MarkBoxNo) ? null : boxInput.MarkBoxNo.Trim(), + WeightG = boxInput.WeightG, + LengthCm = boxInput.LengthCm, + WidthCm = boxInput.WidthCm, + HeightCm = boxInput.HeightCm + }; + await InboundBoxRepository.InsertAsync(box); + + foreach (var lineInput in boxInput.Lines) + { + if (lineInput.SkuId <= 0 || lineInput.PlannedQty <= 0) + { + throw new BusinessException("箱内 SKU 行数据不完整"); + } + + var sku = await GetSkuOrThrowAsync(customerId, lineInput.SkuId); + var line = new CInboundBoxLine + { + InboundBoxId = box.Id, + SkuId = sku.Id, + SkuCode = string.IsNullOrWhiteSpace(lineInput.SkuCode) ? sku.SkuCode : lineInput.SkuCode.Trim(), + PlannedQty = lineInput.PlannedQty + }; + await InboundBoxLineRepository.InsertAsync(line); + } + } + + return await GetInboundDetailAsync(notice.Id, customerId); + } + + [HttpPost("/warehouse/inbound/delete")] + public async Task DeleteInboundAsync(IdInput input) + { + var customerId = await ResolveCustomerIdAsync(null); + var notice = await GetInboundNoticeOrThrowAsync(customerId, input.Id); + if (notice.ReceiptStatus != WarehouseReceiptStatusCodes.Forecasted) + { + throw new BusinessException("仅预报状态可删除"); + } + + await ClearInboundBoxesAsync(notice.Id); + await InboundNoticeRepository.DeleteAsync(notice); + } + + [HttpPost("/warehouse/inbound/confirm-receive")] + public async Task ConfirmInboundReceiveAsync(WarehouseInboundConfirmReceiveInput input) + { + var customerId = await ResolveCustomerIdAsync(input.CustomerId); + var notice = await GetInboundNoticeOrThrowAsync(customerId, input.InboundNoticeId); + if (notice.ReceiptStatus == WarehouseReceiptStatusCodes.Completed) + { + throw new BusinessException("入库单已完成收货"); + } + + if (input.Boxes.Count == 0) + { + throw new BusinessException("请提交收货箱数据"); + } + + var boxes = await InboundBoxRepository.GetListAsync(x => x.InboundNoticeId == notice.Id); + var boxMap = boxes.ToDictionary(x => x.Id); + var boxIds = boxes.Select(x => x.Id).ToArray(); + var lines = boxIds.Length == 0 + ? [] + : await InboundBoxLineRepository.GetListAsync(x => boxIds.Contains(x.InboundBoxId)); + var lineMap = lines.ToDictionary(x => x.Id); + + foreach (var boxInput in input.Boxes) + { + if (!boxMap.TryGetValue(boxInput.BoxId, out var box)) + { + throw new BusinessException($"箱 {boxInput.BoxId} 不存在"); + } + + foreach (var lineInput in boxInput.Lines) + { + if (!lineMap.TryGetValue(lineInput.LineId, out var line) || line.InboundBoxId != box.Id) + { + throw new BusinessException($"箱内行 {lineInput.LineId} 不存在"); + } + + if (lineInput.ReceivedQty < 0) + { + throw new BusinessException("实收数量不能为负"); + } + + if (lineInput.ReceivedQty > 0 && lineInput.BinLocationId <= 0) + { + throw new BusinessException("上架库位不能为空"); + } + + var previousQty = line.ReceivedQty ?? 0; + var deltaQty = lineInput.ReceivedQty - previousQty; + line.ReceivedQty = lineInput.ReceivedQty; + line.BinLocationId = lineInput.ReceivedQty > 0 ? lineInput.BinLocationId : null; + await InboundBoxLineRepository.UpdateAsync(line); + + if (deltaQty > 0) + { + await UpsertInventoryQtyAsync( + customerId, + notice.WarehouseId, + line.SkuId, + lineInput.BinLocationId, + deltaQty); + } + } + + box.IsReceived = boxInput.Lines.All(x => + { + if (!lineMap.TryGetValue(x.LineId, out var line)) + { + return false; + } + + return x.ReceivedQty >= line.PlannedQty; + }); + await InboundBoxRepository.UpdateAsync(box); + } + + notice.ReceiptStatus = WarehouseReceiptStatusCodes.Completed; + notice.ActualArrivalTime = DateTime.Now; + await InboundNoticeRepository.UpdateAsync(notice); + } + + [HttpGet("/warehouse/inbound/summary")] + public async Task GetInboundSummaryAsync([FromQuery] int? customerId = null, [FromQuery] int? warehouseId = null) + { + var resolvedCustomerId = await ResolveCustomerIdAsync(customerId); + var notices = await InboundNoticeRepository.GetListAsync(x => + x.CustomerId == resolvedCustomerId + && (!warehouseId.HasValue || x.WarehouseId == warehouseId.Value)); + + return new WarehouseInboundSummaryOutput + { + Forecasted = notices.Count(x => x.ReceiptStatus == WarehouseReceiptStatusCodes.Forecasted), + Received = notices.Count(x => x.ReceiptStatus == WarehouseReceiptStatusCodes.Received), + Completed = notices.Count(x => x.ReceiptStatus == WarehouseReceiptStatusCodes.Completed) + }; + } + + private async Task GetInboundNoticeOrThrowAsync(int customerId, int id) + { + var entity = await InboundNoticeRepository.FirstOrDefaultAsync(x => x.Id == id && x.CustomerId == customerId); + if (entity == null) + { + throw new BusinessException("入库预报不存在"); + } + + return entity; + } + + private async Task ClearInboundBoxesAsync(int noticeId) + { + var boxes = await InboundBoxRepository.GetListAsync(x => x.InboundNoticeId == noticeId); + if (boxes.Count == 0) + { + return; + } + + var boxIds = boxes.Select(x => x.Id).ToArray(); + var lines = await InboundBoxLineRepository.GetListAsync(x => boxIds.Contains(x.InboundBoxId)); + foreach (var line in lines) + { + await InboundBoxLineRepository.DeleteAsync(line); + } + + foreach (var box in boxes) + { + await InboundBoxRepository.DeleteAsync(box); + } + } + + private static WarehouseInboundOutput MapInboundOutput( + CInboundNotice notice, + IReadOnlyCollection boxes, + IReadOnlyCollection lines) + { + var noticeBoxes = boxes.Where(x => x.InboundNoticeId == notice.Id).ToList(); + var noticeBoxIds = noticeBoxes.Select(x => x.Id).ToHashSet(); + var noticeLines = lines.Where(x => noticeBoxIds.Contains(x.InboundBoxId)).ToList(); + + return new WarehouseInboundOutput + { + Id = notice.Id, + CustomerId = notice.CustomerId, + WarehouseId = notice.WarehouseId, + NoticeNo = notice.NoticeNo, + MarkReference = notice.MarkReference, + InboundType = notice.InboundType, + ReceiptStatus = notice.ReceiptStatus, + ExpectedArrivalTime = notice.ExpectedArrivalTime, + ActualArrivalTime = notice.ActualArrivalTime, + Remark = notice.Remark, + BoxCount = noticeBoxes.Count, + PlannedQty = noticeLines.Sum(x => x.PlannedQty), + CreateTime = notice.CreateTime + }; + } + + private static WarehouseInboundBoxOutput MapInboundBox(CInboundBox box, IEnumerable lines) => + new() + { + Id = box.Id, + BoxSeqNo = box.BoxSeqNo, + MarkBoxNo = box.MarkBoxNo, + WeightG = box.WeightG, + LengthCm = box.LengthCm, + WidthCm = box.WidthCm, + HeightCm = box.HeightCm, + IsReceived = box.IsReceived, + Lines = lines.Select(line => new WarehouseInboundLineOutput + { + Id = line.Id, + SkuId = line.SkuId, + SkuCode = line.SkuCode, + PlannedQty = line.PlannedQty, + ReceivedQty = line.ReceivedQty, + BinLocationId = line.BinLocationId + }).ToList() + }; +} diff --git a/src/Oms.Application/Services/Warehouse/WarehouseService.Inventory.cs b/src/Oms.Application/Services/Warehouse/WarehouseService.Inventory.cs new file mode 100644 index 0000000..d2ab769 --- /dev/null +++ b/src/Oms.Application/Services/Warehouse/WarehouseService.Inventory.cs @@ -0,0 +1,130 @@ +using Fake.Application; +using Microsoft.AspNetCore.Mvc; +using Oms.Application.Dtos.Shared; +using Oms.Application.Dtos.Warehouse; +using Oms.Application.Shared; + +namespace Oms.Application.Services.Warehouse; + +public partial class WarehouseService +{ + [HttpPost("/warehouse/inventory/list")] + public async Task> GetInventoryListAsync(WarehouseInventoryListInput input) + { + var customerId = await ResolveCustomerIdAsync(input.CustomerId); + var inventories = await InventoryRepository.GetListAsync(x => + x.CustomerId == customerId + && x.Qty > 0 + && (!input.WarehouseId.HasValue || x.WarehouseId == input.WarehouseId.Value)); + + var skuIds = inventories.Select(x => x.SkuId).Distinct().ToArray(); + var skus = skuIds.Length == 0 + ? [] + : await SkuRepository.GetListAsync(x => x.CustomerId == customerId && skuIds.Contains(x.Id)); + var skuMap = skus.ToDictionary(x => x.Id); + + var keyword = input.Keyword?.Trim(); + var grouped = inventories + .GroupBy(x => new { x.SkuId, x.WarehouseId }) + .Select(g => + { + skuMap.TryGetValue(g.Key.SkuId, out var sku); + return new WarehouseInventoryOutput + { + SkuId = g.Key.SkuId, + SkuCode = sku?.SkuCode ?? string.Empty, + SkuName = sku?.SkuName ?? string.Empty, + WarehouseId = g.Key.WarehouseId, + TotalQty = g.Sum(x => x.Qty), + BinCount = g.Count() + }; + }) + .Where(x => + string.IsNullOrWhiteSpace(keyword) + || x.SkuCode.Contains(keyword!, StringComparison.OrdinalIgnoreCase) + || x.SkuName.Contains(keyword!, StringComparison.OrdinalIgnoreCase)) + .OrderBy(x => x.SkuCode) + .ThenBy(x => x.WarehouseId) + .ToList(); + + var pageIndex = OmsDocHelper.NormalizePage(input.Page); + var pageSize = OmsDocHelper.NormalizePageSize(input.PageSize); + var skip = (pageIndex - 1) * pageSize; + + return new OmsPageResult + { + Items = grouped.Skip(skip).Take(pageSize).ToList(), + Total = grouped.Count + }; + } + + [HttpGet("/warehouse/inventory/bin-detail")] + public async Task> GetInventoryBinDetailAsync( + [FromQuery] int skuId, + [FromQuery] int warehouseId, + [FromQuery] int? customerId = null) + { + if (skuId <= 0 || warehouseId <= 0) + { + throw new BusinessException("SKU 和仓库不能为空"); + } + + var resolvedCustomerId = await ResolveCustomerIdAsync(customerId); + await GetSkuOrThrowAsync(resolvedCustomerId, skuId); + + var inventories = await InventoryRepository.GetListAsync(x => + x.CustomerId == resolvedCustomerId + && x.WarehouseId == warehouseId + && x.SkuId == skuId + && x.Qty > 0); + var binIds = inventories.Select(x => x.BinLocationId).Distinct().ToArray(); + var bins = binIds.Length == 0 + ? [] + : await BinLocationRepository.GetListAsync(x => binIds.Contains(x.Id)); + var binMap = bins.ToDictionary(x => x.Id); + + return new OmsItemsResult + { + Items = inventories + .OrderBy(x => x.BinLocationId) + .Select(x => + { + binMap.TryGetValue(x.BinLocationId, out var bin); + return new WarehouseInventoryBinDetailOutput + { + Id = x.Id, + BinLocationId = x.BinLocationId, + BinCode = bin?.BinCode ?? string.Empty, + BinName = bin?.BinName, + Qty = x.Qty + }; + }) + .ToList() + }; + } + + [HttpPost("/warehouse/inventory/relocate")] + public async Task RelocateInventoryAsync(WarehouseInventoryRelocateInput input) + { + var customerId = await ResolveCustomerIdAsync(input.CustomerId); + if (input.SourceBinLocationId == input.TargetBinLocationId) + { + throw new BusinessException("源库位与目标库位不能相同"); + } + + await GetSkuOrThrowAsync(customerId, input.SkuId); + + var source = await InventoryRepository.FirstOrDefaultAsync(x => + x.CustomerId == customerId + && x.WarehouseId == input.WarehouseId + && x.SkuId == input.SkuId + && x.BinLocationId == input.SourceBinLocationId); + if (source == null || source.Qty < input.Qty) + { + throw new BusinessException("源库位库存不足"); + } + + await UpsertInventoryQtyAsync(customerId, input.WarehouseId, input.SkuId, input.SourceBinLocationId, -input.Qty); + await UpsertInventoryQtyAsync(customerId, input.WarehouseId, input.SkuId, input.TargetBinLocationId, input.Qty); + } +} diff --git a/src/Oms.Application/Services/Warehouse/WarehouseService.Parcel.cs b/src/Oms.Application/Services/Warehouse/WarehouseService.Parcel.cs new file mode 100644 index 0000000..272bc1f --- /dev/null +++ b/src/Oms.Application/Services/Warehouse/WarehouseService.Parcel.cs @@ -0,0 +1,233 @@ +using System.Linq.Expressions; +using Fake.Application; +using Microsoft.AspNetCore.Mvc; +using Oms.Application.Dtos.Shared; +using Oms.Application.Dtos.Warehouse; +using Oms.Application.Shared; +using Oms.Domain.WarehouseAggregate.Consts; +using Oms.Domain.WarehouseAggregate.Entities; + +namespace Oms.Application.Services.Warehouse; + +public partial class WarehouseService +{ + [HttpPost("/warehouse/parcel/list")] + public async Task> GetParcelListAsync(WarehouseParcelListInput input) + { + var customerId = await ResolveCustomerIdAsync(input.CustomerId); + var keyword = input.Keyword?.Trim(); + var hasKeyword = !string.IsNullOrWhiteSpace(keyword); + var lifecycleStatus = input.LifecycleStatus?.Trim(); + + Expression> predicate = x => + x.CustomerId == customerId + && (!input.WarehouseId.HasValue || x.WarehouseId == input.WarehouseId.Value) + && (string.IsNullOrWhiteSpace(lifecycleStatus) || x.LifecycleStatus == lifecycleStatus) + && (!hasKeyword || x.TrackingNo.Contains(keyword!) || (x.CarrierName != null && x.CarrierName.Contains(keyword!))); + + var pageIndex = OmsDocHelper.NormalizePage(input.Page); + var pageSize = OmsDocHelper.NormalizePageSize(input.PageSize); + var entities = await ParcelRepository.GetPagedListAsync( + predicate, + pageIndex, + pageSize, + new Dictionary { [nameof(CParcel.UpdateTime)] = false, [nameof(CParcel.Id)] = false }); + var total = await ParcelRepository.CountAsync(predicate); + + var parcelIds = entities.Select(x => x.Id).ToArray(); + var items = parcelIds.Length == 0 + ? [] + : await ParcelItemRepository.GetListAsync(x => parcelIds.Contains(x.ParcelId)); + + return new OmsPageResult + { + Items = entities.Select(x => MapParcelOutput(x, items.Where(i => i.ParcelId == x.Id))).ToList(), + Total = total + }; + } + + [HttpPost("/warehouse/parcel/save")] + public async Task SaveParcelAsync(WarehouseParcelSaveInput input) + { + var customerId = await ResolveCustomerIdAsync(input.CustomerId); + if (input.WarehouseId <= 0) + { + throw new BusinessException("仓库ID不能为空"); + } + + var trackingNo = input.TrackingNo.Trim(); + if (string.IsNullOrWhiteSpace(trackingNo)) + { + throw new BusinessException("快递单号不能为空"); + } + + CParcel parcel; + if (input.Id > 0) + { + parcel = await GetParcelOrThrowAsync(customerId, input.Id); + if (parcel.LifecycleStatus == WarehouseParcelLifecycleCodes.Stored) + { + throw new BusinessException("已上架包裹不可编辑"); + } + } + else + { + parcel = new CParcel { CustomerId = customerId }; + } + + var duplicate = await ParcelRepository.FirstOrDefaultAsync(x => + x.CustomerId == customerId + && x.WarehouseId == input.WarehouseId + && x.TrackingNo == trackingNo + && x.Id != parcel.Id); + if (duplicate != null) + { + throw new BusinessException("快递单号已存在"); + } + + parcel.WarehouseId = input.WarehouseId; + parcel.TrackingNo = trackingNo; + parcel.CarrierName = string.IsNullOrWhiteSpace(input.CarrierName) ? null : input.CarrierName.Trim(); + parcel.LifecycleStatus = string.IsNullOrWhiteSpace(input.LifecycleStatus) + ? WarehouseParcelLifecycleCodes.InTransit + : input.LifecycleStatus.Trim(); + parcel.Remark = string.IsNullOrWhiteSpace(input.Remark) ? null : input.Remark.Trim(); + + if (input.Id > 0) + { + await ParcelRepository.UpdateAsync(parcel); + await ClearParcelItemsAsync(parcel.Id); + } + else + { + await ParcelRepository.InsertAsync(parcel); + } + + foreach (var itemInput in input.Items) + { + if (itemInput.Quantity <= 0) + { + throw new BusinessException("商品数量必须大于 0"); + } + + var item = new CParcelItem + { + ParcelId = parcel.Id, + ProductName = string.IsNullOrWhiteSpace(itemInput.ProductName) ? null : itemInput.ProductName.Trim(), + Quantity = itemInput.Quantity, + Remark = string.IsNullOrWhiteSpace(itemInput.Remark) ? null : itemInput.Remark.Trim() + }; + await ParcelItemRepository.InsertAsync(item); + } + + var savedItems = await ParcelItemRepository.GetListAsync(x => x.ParcelId == parcel.Id); + return MapParcelOutput(parcel, savedItems); + } + + [HttpPost("/warehouse/parcel/delete")] + public async Task DeleteParcelAsync(IdInput input) + { + var customerId = await ResolveCustomerIdAsync(null); + var parcel = await GetParcelOrThrowAsync(customerId, input.Id); + if (parcel.LifecycleStatus == WarehouseParcelLifecycleCodes.Stored) + { + throw new BusinessException("已上架包裹不可删除"); + } + + await ClearParcelItemsAsync(parcel.Id); + await ParcelRepository.DeleteAsync(parcel); + } + + [HttpPost("/warehouse/parcel/putaway")] + public async Task PutawayParcelAsync(WarehouseParcelPutawayInput input) + { + var customerId = await ResolveCustomerIdAsync(input.CustomerId); + var parcel = await GetParcelOrThrowAsync(customerId, input.ParcelId); + if (parcel.LifecycleStatus == WarehouseParcelLifecycleCodes.Stored) + { + throw new BusinessException("包裹已上架"); + } + + if (input.Items.Count == 0) + { + throw new BusinessException("请指定上架明细"); + } + + var items = await ParcelItemRepository.GetListAsync(x => x.ParcelId == parcel.Id); + var itemMap = items.ToDictionary(x => x.Id); + + foreach (var putawayItem in input.Items) + { + if (!itemMap.TryGetValue(putawayItem.ItemId, out var item)) + { + throw new BusinessException($"包裹明细 {putawayItem.ItemId} 不存在"); + } + + if (putawayItem.SkuId <= 0 || putawayItem.BinLocationId <= 0) + { + throw new BusinessException("SKU 和库位不能为空"); + } + + var sku = await GetSkuOrThrowAsync(customerId, putawayItem.SkuId); + item.SkuId = sku.Id; + item.SkuCode = sku.SkuCode; + item.BinLocationId = putawayItem.BinLocationId; + await ParcelItemRepository.UpdateAsync(item); + + await UpsertInventoryQtyAsync( + customerId, + parcel.WarehouseId, + sku.Id, + putawayItem.BinLocationId, + item.Quantity); + } + + parcel.LifecycleStatus = WarehouseParcelLifecycleCodes.Stored; + parcel.ArrivalTime = DateTime.Now; + await ParcelRepository.UpdateAsync(parcel); + } + + private async Task GetParcelOrThrowAsync(int customerId, int id) + { + var entity = await ParcelRepository.FirstOrDefaultAsync(x => x.Id == id && x.CustomerId == customerId); + if (entity == null) + { + throw new BusinessException("包裹不存在"); + } + + return entity; + } + + private async Task ClearParcelItemsAsync(int parcelId) + { + var items = await ParcelItemRepository.GetListAsync(x => x.ParcelId == parcelId); + foreach (var item in items) + { + await ParcelItemRepository.DeleteAsync(item); + } + } + + private static WarehouseParcelOutput MapParcelOutput(CParcel parcel, IEnumerable items) => + new() + { + Id = parcel.Id, + CustomerId = parcel.CustomerId, + WarehouseId = parcel.WarehouseId, + TrackingNo = parcel.TrackingNo, + CarrierName = parcel.CarrierName, + LifecycleStatus = parcel.LifecycleStatus, + ArrivalTime = parcel.ArrivalTime, + Remark = parcel.Remark, + CreateTime = parcel.CreateTime, + Items = items.Select(item => new WarehouseParcelItemOutput + { + Id = item.Id, + ProductName = item.ProductName, + Quantity = item.Quantity, + SkuId = item.SkuId, + SkuCode = item.SkuCode, + BinLocationId = item.BinLocationId, + Remark = item.Remark + }).ToList() + }; +} diff --git a/src/Oms.Application/Services/Warehouse/WarehouseService.Sku.cs b/src/Oms.Application/Services/Warehouse/WarehouseService.Sku.cs new file mode 100644 index 0000000..bae190c --- /dev/null +++ b/src/Oms.Application/Services/Warehouse/WarehouseService.Sku.cs @@ -0,0 +1,179 @@ +using System.Linq.Expressions; +using Fake.Application; +using Microsoft.AspNetCore.Mvc; +using Oms.Application.Dtos.Shared; +using Oms.Application.Dtos.Warehouse; +using Oms.Application.Shared; + +namespace Oms.Application.Services.Warehouse; + +public partial class WarehouseService +{ + [HttpPost("/warehouse/sku/list")] + public async Task> GetSkuListAsync(WarehouseSkuListInput input) + { + var customerId = await ResolveCustomerIdAsync(input.CustomerId); + var keyword = input.Keyword?.Trim(); + var hasKeyword = !string.IsNullOrWhiteSpace(keyword); + + Expression> predicate = x => + x.CustomerId == customerId + && (!hasKeyword || x.SkuCode.Contains(keyword!) || x.SkuName.Contains(keyword!)); + + var pageIndex = OmsDocHelper.NormalizePage(input.Page); + var pageSize = OmsDocHelper.NormalizePageSize(input.PageSize); + var entities = await SkuRepository.GetPagedListAsync( + predicate, + pageIndex, + pageSize, + new Dictionary { [nameof(Domain.WarehouseAggregate.Entities.CSku.UpdateTime)] = false, [nameof(Domain.WarehouseAggregate.Entities.CSku.Id)] = false }); + var total = await SkuRepository.CountAsync(predicate); + + var skuIds = entities.Select(x => x.Id).ToArray(); + var inventories = skuIds.Length == 0 + ? [] + : await InventoryRepository.GetListAsync(x => x.CustomerId == customerId && skuIds.Contains(x.SkuId)); + var qtyMap = inventories + .GroupBy(x => x.SkuId) + .ToDictionary(g => g.Key, g => g.Sum(x => x.Qty)); + + return new OmsPageResult + { + Items = entities.Select(x => MapSkuOutput(x, qtyMap.GetValueOrDefault(x.Id))).ToList(), + Total = total + }; + } + + [HttpGet("/warehouse/sku/detail")] + public async Task GetSkuDetailAsync([FromQuery] int id, [FromQuery] int? customerId = null) + { + var resolvedCustomerId = await ResolveCustomerIdAsync(customerId); + var entity = await GetSkuOrThrowAsync(resolvedCustomerId, id); + var inventories = await InventoryRepository.GetListAsync(x => x.CustomerId == resolvedCustomerId && x.SkuId == id); + return MapSkuOutput(entity, inventories.Sum(x => x.Qty)); + } + + [HttpPost("/warehouse/sku/save")] + public async Task SaveSkuAsync(WarehouseSkuSaveInput input) + { + var customerId = await ResolveCustomerIdAsync(input.CustomerId); + var skuCode = input.SkuCode.Trim(); + var skuName = input.SkuName.Trim(); + if (string.IsNullOrWhiteSpace(skuCode) || string.IsNullOrWhiteSpace(skuName)) + { + throw new BusinessException("SKU 编码和名称不能为空"); + } + + Domain.WarehouseAggregate.Entities.CSku entity; + if (input.Id > 0) + { + entity = await GetSkuOrThrowAsync(customerId, input.Id); + } + else + { + entity = new Domain.WarehouseAggregate.Entities.CSku { CustomerId = customerId }; + } + + var duplicate = await SkuRepository.FirstOrDefaultAsync(x => + x.CustomerId == customerId && x.SkuCode == skuCode && x.Id != entity.Id); + if (duplicate != null) + { + throw new BusinessException("SKU 编码已存在"); + } + + entity.SkuCode = skuCode; + entity.SkuName = skuName; + entity.SkuImageUrl = string.IsNullOrWhiteSpace(input.SkuImageUrl) ? null : input.SkuImageUrl.Trim(); + entity.WeightG = input.WeightG; + entity.LengthCm = input.LengthCm; + entity.WidthCm = input.WidthCm; + entity.HeightCm = input.HeightCm; + entity.Remark = string.IsNullOrWhiteSpace(input.Remark) ? null : input.Remark.Trim(); + + if (input.Id > 0) + { + await SkuRepository.UpdateAsync(entity); + } + else + { + await SkuRepository.InsertAsync(entity); + } + + return MapSkuOutput(entity, 0); + } + + [HttpPost("/warehouse/sku/delete")] + public async Task DeleteSkuAsync(IdInput input) + { + var customerId = await ResolveCustomerIdAsync(null); + var entity = await GetSkuOrThrowAsync(customerId, input.Id); + var hasInventory = await InventoryRepository.CountAsync(x => + x.CustomerId == customerId && x.SkuId == entity.Id && x.Qty > 0); + if (hasInventory > 0) + { + throw new BusinessException("SKU 仍有库存,不能删除"); + } + + await SkuRepository.DeleteAsync(entity); + } + + [HttpPost("/warehouse/sku/bind-mapping")] + public async Task BindSkuMappingAsync(WarehouseSkuBindMappingInput input) + { + var customerId = await ResolveCustomerIdAsync(input.CustomerId); + var platformSku = input.PlatformSku.Trim(); + if (string.IsNullOrWhiteSpace(platformSku)) + { + throw new BusinessException("平台 SKU 不能为空"); + } + + var sku = await GetSkuOrThrowAsync(customerId, input.SkuId); + var existing = await SkuPlatformMapRepository.FirstOrDefaultAsync(x => + x.CustomerId == customerId && x.StoreId == input.StoreId && x.PlatformSku == platformSku); + if (existing == null) + { + existing = new Domain.WarehouseAggregate.Entities.CSkuPlatformMap + { + CustomerId = customerId, + StoreId = input.StoreId, + PlatformSku = platformSku, + SkuId = sku.Id, + SkuCode = sku.SkuCode + }; + await SkuPlatformMapRepository.InsertAsync(existing); + } + else + { + existing.SkuId = sku.Id; + existing.SkuCode = sku.SkuCode; + await SkuPlatformMapRepository.UpdateAsync(existing); + } + + return new WarehouseSkuMappingOutput + { + Id = existing.Id, + StoreId = existing.StoreId, + PlatformSku = existing.PlatformSku, + SkuId = existing.SkuId, + SkuCode = existing.SkuCode + }; + } + + private static WarehouseSkuOutput MapSkuOutput(Domain.WarehouseAggregate.Entities.CSku entity, int totalQty) => + new() + { + Id = entity.Id, + CustomerId = entity.CustomerId, + SkuCode = entity.SkuCode, + SkuName = entity.SkuName, + SkuImageUrl = entity.SkuImageUrl, + WeightG = entity.WeightG, + LengthCm = entity.LengthCm, + WidthCm = entity.WidthCm, + HeightCm = entity.HeightCm, + Remark = entity.Remark, + TotalQty = totalQty, + CreateTime = entity.CreateTime, + UpdateTime = OmsDocHelper.NullIfDefault(entity.UpdateTime) + }; +} diff --git a/src/Oms.Application/Services/Warehouse/WarehouseService.cs b/src/Oms.Application/Services/Warehouse/WarehouseService.cs new file mode 100644 index 0000000..58096c7 --- /dev/null +++ b/src/Oms.Application/Services/Warehouse/WarehouseService.cs @@ -0,0 +1,24 @@ +using Fake.Application; +using Microsoft.AspNetCore.Mvc; +using Oms.Domain.CustomersAggregate.Repositories; +using Oms.Domain.WarehouseAggregate.Repositories; + +namespace Oms.Application.Services.Warehouse; + +/// +/// 仓储模块 API 服务(我的包裹 / 库存 / SKU / 入库预报)。 +/// +[ApiExplorerSettings(GroupName = "OMS-Warehouse")] +public partial class WarehouseService : ApplicationService +{ + private ICustomerRepository CustomerRepository => LazyServiceProvider.GetRequiredService(); + private ICSkuRepository SkuRepository => LazyServiceProvider.GetRequiredService(); + private ICBinLocationRepository BinLocationRepository => LazyServiceProvider.GetRequiredService(); + private ICInventoryRepository InventoryRepository => LazyServiceProvider.GetRequiredService(); + private ICInboundNoticeRepository InboundNoticeRepository => LazyServiceProvider.GetRequiredService(); + private ICInboundBoxRepository InboundBoxRepository => LazyServiceProvider.GetRequiredService(); + private ICInboundBoxLineRepository InboundBoxLineRepository => LazyServiceProvider.GetRequiredService(); + private ICParcelRepository ParcelRepository => LazyServiceProvider.GetRequiredService(); + private ICParcelItemRepository ParcelItemRepository => LazyServiceProvider.GetRequiredService(); + private ICSkuPlatformMapRepository SkuPlatformMapRepository => LazyServiceProvider.GetRequiredService(); +} diff --git a/src/Oms.Application/Shared/WarehouseCustomerHelper.cs b/src/Oms.Application/Shared/WarehouseCustomerHelper.cs new file mode 100644 index 0000000..caf0ce1 --- /dev/null +++ b/src/Oms.Application/Shared/WarehouseCustomerHelper.cs @@ -0,0 +1,31 @@ +using Fake.Application; +using Fake.Users; +using Oms.Domain.CustomersAggregate.Repositories; + +namespace Oms.Application.Shared; + +/// +/// 仓储模块客户上下文解析。 +/// +public static class WarehouseCustomerHelper +{ + public static async Task ResolveRequiredCustomerIdAsync( + ICustomerRepository customerRepository, + ICurrentUser currentUser, + int? requestedCustomerId = null) + { + var customerId = await OmsSessionHelper.ResolveListCustomerIdAsync( + customerRepository, + currentUser, + requestedCustomerId); + if (!customerId.HasValue) + { + throw new BusinessException("客户ID不能为空"); + } + + return customerId.Value; + } + + public static string GenerateInboundNoticeNo() => + $"IN{DateTime.Now:yyyyMMddHHmmss}{Random.Shared.Next(1000, 9999)}"; +} diff --git a/src/Oms.Domain/OrdersAggregate/Entities/SalesStore.cs b/src/Oms.Domain/OrdersAggregate/Entities/SalesStore.cs new file mode 100644 index 0000000..7d679f6 --- /dev/null +++ b/src/Oms.Domain/OrdersAggregate/Entities/SalesStore.cs @@ -0,0 +1,41 @@ +using Oms.Domain.Shared; +using SqlSugar; + +namespace Oms.Domain.OrdersAggregate.Entities; + +[SugarTable("t_sales_store")] +public class SalesStore : OmsAuditedAggregateRoot +{ + public int CustomerId { get; set; } + + public string PlatformCode { get; set; } = string.Empty; + + public string StoreNickname { get; set; } = string.Empty; + + public string? PlatformStoreId { get; set; } + + public string? PlatformAccount { get; set; } + + public string? SiteCountry { get; set; } + + public string? SellerId { get; set; } + + public string AuthStatus { get; set; } = "active"; + + public DateTime? AuthTime { get; set; } + + public DateTime? TokenExpireTime { get; set; } + + public DateTime? RefreshExpireTime { get; set; } + + public string? AccessToken { get; set; } + + public string? RefreshToken { get; set; } + + [SugarColumn(IsJson = true, ColumnDataType = "json", IsNullable = true)] + public string? TokenExtraJson { get; set; } + + public bool IsAutoFulfill { get; set; } + + public string? Remark { get; set; } +} diff --git a/src/Oms.Domain/OrdersAggregate/Entities/SalesStoreOrder.cs b/src/Oms.Domain/OrdersAggregate/Entities/SalesStoreOrder.cs new file mode 100644 index 0000000..cbc2f9d --- /dev/null +++ b/src/Oms.Domain/OrdersAggregate/Entities/SalesStoreOrder.cs @@ -0,0 +1,51 @@ +using Oms.Domain.Shared; +using SqlSugar; + +namespace Oms.Domain.OrdersAggregate.Entities; + +[SugarTable("t_sales_store_order")] +public class SalesStoreOrder : OmsAuditedAggregateRoot +{ + public int CustomerId { get; set; } + + public int StoreId { get; set; } + + public string PlatformCode { get; set; } = string.Empty; + + public string PlatformOrderNo { get; set; } = string.Empty; + + public string? OrderCountry { get; set; } + + public int? ItemCount { get; set; } + + public decimal? TotalAmount { get; set; } + + public string? Currency { get; set; } + + public DateTime? PlatformOrderTime { get; set; } + + public string FulfillStatus { get; set; } = "pending"; + + public string? PlatformStatus { get; set; } + + public string? PlatformStatusLabel { get; set; } + + public string? ShippingLabelNo { get; set; } + + public string? ShippingLabelUrl { get; set; } + + public bool IsMultiLabel { get; set; } + + public string? CustomTags { get; set; } + + public bool IsSuspended { get; set; } + + public int? WarehouseId { get; set; } + + public int? LinkedOrderId { get; set; } + + public DateTime? LastSyncTime { get; set; } + + [SugarColumn(IsJson = true, ColumnDataType = "json", IsNullable = true)] + public string? PlatformExtraJson { get; set; } +} diff --git a/src/Oms.Domain/OrdersAggregate/Entities/SalesStoreOrderLine.cs b/src/Oms.Domain/OrdersAggregate/Entities/SalesStoreOrderLine.cs new file mode 100644 index 0000000..dab91b5 --- /dev/null +++ b/src/Oms.Domain/OrdersAggregate/Entities/SalesStoreOrderLine.cs @@ -0,0 +1,30 @@ +using Oms.Domain.Shared; +using SqlSugar; + +namespace Oms.Domain.OrdersAggregate.Entities; + +[SugarTable("t_sales_store_order_line")] +public class SalesStoreOrderLine : OmsAuditedAggregateRoot +{ + public int StoreOrderId { get; set; } + + public string? PlatformLineId { get; set; } + + public string? PlatformSku { get; set; } + + public string? ProductName { get; set; } + + public string? ProductImageUrl { get; set; } + + public string? VariantText { get; set; } + + public decimal? UnitPrice { get; set; } + + public int Quantity { get; set; } + + public string? Currency { get; set; } + + public bool IsShipExcluded { get; set; } + + public int? MappedSkuId { get; set; } +} diff --git a/src/Oms.Domain/OrdersAggregate/Repositories/ISalesStoreRepositories.cs b/src/Oms.Domain/OrdersAggregate/Repositories/ISalesStoreRepositories.cs new file mode 100644 index 0000000..bff5be5 --- /dev/null +++ b/src/Oms.Domain/OrdersAggregate/Repositories/ISalesStoreRepositories.cs @@ -0,0 +1,10 @@ +using Fake.Domain.Repositories; +using Oms.Domain.OrdersAggregate.Entities; + +namespace Oms.Domain.OrdersAggregate.Repositories; + +public interface ISalesStoreRepository : IRepository; + +public interface ISalesStoreOrderRepository : IRepository; + +public interface ISalesStoreOrderLineRepository : IRepository; diff --git a/src/Oms.Domain/WarehouseAggregate/Consts/WarehouseCodes.cs b/src/Oms.Domain/WarehouseAggregate/Consts/WarehouseCodes.cs new file mode 100644 index 0000000..f73fd85 --- /dev/null +++ b/src/Oms.Domain/WarehouseAggregate/Consts/WarehouseCodes.cs @@ -0,0 +1,38 @@ +namespace Oms.Domain.WarehouseAggregate.Consts; + +public static class WarehouseInboundTypeCodes +{ + public const string Normal = "normal"; + public const string Return = "return"; +} + +public static class WarehouseReceiptStatusCodes +{ + public const string Forecasted = "forecasted"; + public const string Received = "received"; + public const string Completed = "completed"; +} + +public static class WarehouseParcelLifecycleCodes +{ + public const string InTransit = "inTransit"; + public const string Arrived = "arrived"; + public const string Stored = "stored"; +} + +public static class SalesStoreAuthStatusCodes +{ + public const string Active = "active"; + public const string Expired = "expired"; + public const string Revoked = "revoked"; +} + +public static class SalesStoreFulfillStatusCodes +{ + public const string Pending = "pending"; + public const string ToShip = "toShip"; + public const string Canceled = "canceled"; + public const string Suspended = "suspended"; + public const string Shipped = "shipped"; + public const string Completed = "completed"; +} diff --git a/src/Oms.Domain/WarehouseAggregate/Entities/CBinLocation.cs b/src/Oms.Domain/WarehouseAggregate/Entities/CBinLocation.cs new file mode 100644 index 0000000..6288eb8 --- /dev/null +++ b/src/Oms.Domain/WarehouseAggregate/Entities/CBinLocation.cs @@ -0,0 +1,20 @@ +using Oms.Domain.Shared; +using SqlSugar; + +namespace Oms.Domain.WarehouseAggregate.Entities; + +[SugarTable("C_bin_location")] +public class CBinLocation : OmsAuditedAggregateRoot +{ + public int WarehouseId { get; set; } + + public string BinCode { get; set; } = string.Empty; + + public string? BinName { get; set; } + + public string? ZoneCode { get; set; } + + public bool IsEnabled { get; set; } = true; + + public string? Remark { get; set; } +} diff --git a/src/Oms.Domain/WarehouseAggregate/Entities/CInboundBox.cs b/src/Oms.Domain/WarehouseAggregate/Entities/CInboundBox.cs new file mode 100644 index 0000000..49be713 --- /dev/null +++ b/src/Oms.Domain/WarehouseAggregate/Entities/CInboundBox.cs @@ -0,0 +1,24 @@ +using Oms.Domain.Shared; +using SqlSugar; + +namespace Oms.Domain.WarehouseAggregate.Entities; + +[SugarTable("C_inbound_box")] +public class CInboundBox : OmsAuditedAggregateRoot +{ + public int InboundNoticeId { get; set; } + + public string BoxSeqNo { get; set; } = string.Empty; + + public string? MarkBoxNo { get; set; } + + public decimal? WeightG { get; set; } + + public decimal? LengthCm { get; set; } + + public decimal? WidthCm { get; set; } + + public decimal? HeightCm { get; set; } + + public bool IsReceived { get; set; } +} diff --git a/src/Oms.Domain/WarehouseAggregate/Entities/CInboundBoxLine.cs b/src/Oms.Domain/WarehouseAggregate/Entities/CInboundBoxLine.cs new file mode 100644 index 0000000..248d099 --- /dev/null +++ b/src/Oms.Domain/WarehouseAggregate/Entities/CInboundBoxLine.cs @@ -0,0 +1,20 @@ +using Oms.Domain.Shared; +using SqlSugar; + +namespace Oms.Domain.WarehouseAggregate.Entities; + +[SugarTable("C_inbound_box_line")] +public class CInboundBoxLine : OmsAuditedAggregateRoot +{ + public int InboundBoxId { get; set; } + + public int SkuId { get; set; } + + public string SkuCode { get; set; } = string.Empty; + + public int PlannedQty { get; set; } + + public int? ReceivedQty { get; set; } + + public int? BinLocationId { get; set; } +} diff --git a/src/Oms.Domain/WarehouseAggregate/Entities/CInboundNotice.cs b/src/Oms.Domain/WarehouseAggregate/Entities/CInboundNotice.cs new file mode 100644 index 0000000..b8d1b2b --- /dev/null +++ b/src/Oms.Domain/WarehouseAggregate/Entities/CInboundNotice.cs @@ -0,0 +1,26 @@ +using Oms.Domain.Shared; +using SqlSugar; + +namespace Oms.Domain.WarehouseAggregate.Entities; + +[SugarTable("C_inbound_notice")] +public class CInboundNotice : OmsAuditedAggregateRoot +{ + public int CustomerId { get; set; } + + public int WarehouseId { get; set; } + + public string NoticeNo { get; set; } = string.Empty; + + public string? MarkReference { get; set; } + + public string InboundType { get; set; } = "normal"; + + public string ReceiptStatus { get; set; } = "forecasted"; + + public DateTime? ExpectedArrivalTime { get; set; } + + public DateTime? ActualArrivalTime { get; set; } + + public string? Remark { get; set; } +} diff --git a/src/Oms.Domain/WarehouseAggregate/Entities/CInventory.cs b/src/Oms.Domain/WarehouseAggregate/Entities/CInventory.cs new file mode 100644 index 0000000..7bdd3de --- /dev/null +++ b/src/Oms.Domain/WarehouseAggregate/Entities/CInventory.cs @@ -0,0 +1,18 @@ +using Oms.Domain.Shared; +using SqlSugar; + +namespace Oms.Domain.WarehouseAggregate.Entities; + +[SugarTable("C_inventory")] +public class CInventory : OmsAuditedAggregateRoot +{ + public int CustomerId { get; set; } + + public int WarehouseId { get; set; } + + public int SkuId { get; set; } + + public int BinLocationId { get; set; } + + public int Qty { get; set; } +} diff --git a/src/Oms.Domain/WarehouseAggregate/Entities/CParcel.cs b/src/Oms.Domain/WarehouseAggregate/Entities/CParcel.cs new file mode 100644 index 0000000..efae6c5 --- /dev/null +++ b/src/Oms.Domain/WarehouseAggregate/Entities/CParcel.cs @@ -0,0 +1,22 @@ +using Oms.Domain.Shared; +using SqlSugar; + +namespace Oms.Domain.WarehouseAggregate.Entities; + +[SugarTable("C_parcel")] +public class CParcel : OmsAuditedAggregateRoot +{ + public int CustomerId { get; set; } + + public int WarehouseId { get; set; } + + public string TrackingNo { get; set; } = string.Empty; + + public string? CarrierName { get; set; } + + public string LifecycleStatus { get; set; } = "inTransit"; + + public DateTime? ArrivalTime { get; set; } + + public string? Remark { get; set; } +} diff --git a/src/Oms.Domain/WarehouseAggregate/Entities/CParcelItem.cs b/src/Oms.Domain/WarehouseAggregate/Entities/CParcelItem.cs new file mode 100644 index 0000000..98b7484 --- /dev/null +++ b/src/Oms.Domain/WarehouseAggregate/Entities/CParcelItem.cs @@ -0,0 +1,22 @@ +using Oms.Domain.Shared; +using SqlSugar; + +namespace Oms.Domain.WarehouseAggregate.Entities; + +[SugarTable("C_parcel_item")] +public class CParcelItem : OmsAuditedAggregateRoot +{ + public int ParcelId { get; set; } + + public string? ProductName { get; set; } + + public int Quantity { get; set; } + + public int? SkuId { get; set; } + + public string? SkuCode { get; set; } + + public int? BinLocationId { get; set; } + + public string? Remark { get; set; } +} diff --git a/src/Oms.Domain/WarehouseAggregate/Entities/CSku.cs b/src/Oms.Domain/WarehouseAggregate/Entities/CSku.cs new file mode 100644 index 0000000..1466853 --- /dev/null +++ b/src/Oms.Domain/WarehouseAggregate/Entities/CSku.cs @@ -0,0 +1,26 @@ +using Oms.Domain.Shared; +using SqlSugar; + +namespace Oms.Domain.WarehouseAggregate.Entities; + +[SugarTable("C_sku")] +public class CSku : OmsAuditedAggregateRoot +{ + public int CustomerId { get; set; } + + public string SkuCode { get; set; } = string.Empty; + + public string SkuName { get; set; } = string.Empty; + + public string? SkuImageUrl { get; set; } + + public decimal? WeightG { get; set; } + + public decimal? LengthCm { get; set; } + + public decimal? WidthCm { get; set; } + + public decimal? HeightCm { get; set; } + + public string? Remark { get; set; } +} diff --git a/src/Oms.Domain/WarehouseAggregate/Entities/CSkuPlatformMap.cs b/src/Oms.Domain/WarehouseAggregate/Entities/CSkuPlatformMap.cs new file mode 100644 index 0000000..bacdbee --- /dev/null +++ b/src/Oms.Domain/WarehouseAggregate/Entities/CSkuPlatformMap.cs @@ -0,0 +1,18 @@ +using Oms.Domain.Shared; +using SqlSugar; + +namespace Oms.Domain.WarehouseAggregate.Entities; + +[SugarTable("C_sku_platform_map")] +public class CSkuPlatformMap : OmsAuditedAggregateRoot +{ + public int CustomerId { get; set; } + + public int StoreId { get; set; } + + public string PlatformSku { get; set; } = string.Empty; + + public int SkuId { get; set; } + + public string SkuCode { get; set; } = string.Empty; +} diff --git a/src/Oms.Domain/WarehouseAggregate/Repositories/IWarehouseRepositories.cs b/src/Oms.Domain/WarehouseAggregate/Repositories/IWarehouseRepositories.cs new file mode 100644 index 0000000..527e50d --- /dev/null +++ b/src/Oms.Domain/WarehouseAggregate/Repositories/IWarehouseRepositories.cs @@ -0,0 +1,22 @@ +using Fake.Domain.Repositories; +using Oms.Domain.WarehouseAggregate.Entities; + +namespace Oms.Domain.WarehouseAggregate.Repositories; + +public interface ICSkuRepository : IRepository; + +public interface ICBinLocationRepository : IRepository; + +public interface ICInventoryRepository : IRepository; + +public interface ICInboundNoticeRepository : IRepository; + +public interface ICInboundBoxRepository : IRepository; + +public interface ICInboundBoxLineRepository : IRepository; + +public interface ICParcelRepository : IRepository; + +public interface ICParcelItemRepository : IRepository; + +public interface ICSkuPlatformMapRepository : IRepository; diff --git a/src/Oms.SqlSugarCore/Orders/SalesStoreRepositories.cs b/src/Oms.SqlSugarCore/Orders/SalesStoreRepositories.cs new file mode 100644 index 0000000..1946a17 --- /dev/null +++ b/src/Oms.SqlSugarCore/Orders/SalesStoreRepositories.cs @@ -0,0 +1,12 @@ +using Fake.DependencyInjection; +using Oms.Domain.OrdersAggregate.Entities; +using Oms.Domain.OrdersAggregate.Repositories; +using Oms.SqlSugarCore.Repositories; + +namespace Oms.SqlSugarCore.Orders; + +public class SalesStoreRepository : OmsSqlSugarRepository, ISalesStoreRepository, IScopedDependency; + +public class SalesStoreOrderRepository : OmsSqlSugarRepository, ISalesStoreOrderRepository, IScopedDependency; + +public class SalesStoreOrderLineRepository : OmsSqlSugarRepository, ISalesStoreOrderLineRepository, IScopedDependency; diff --git a/src/Oms.SqlSugarCore/Warehouse/WarehouseRepositories.cs b/src/Oms.SqlSugarCore/Warehouse/WarehouseRepositories.cs new file mode 100644 index 0000000..1e5688f --- /dev/null +++ b/src/Oms.SqlSugarCore/Warehouse/WarehouseRepositories.cs @@ -0,0 +1,24 @@ +using Fake.DependencyInjection; +using Oms.Domain.WarehouseAggregate.Entities; +using Oms.Domain.WarehouseAggregate.Repositories; +using Oms.SqlSugarCore.Repositories; + +namespace Oms.SqlSugarCore.Warehouse; + +public class CSkuRepository : OmsSqlSugarRepository, ICSkuRepository, IScopedDependency; + +public class CBinLocationRepository : OmsSqlSugarRepository, ICBinLocationRepository, IScopedDependency; + +public class CInventoryRepository : OmsSqlSugarRepository, ICInventoryRepository, IScopedDependency; + +public class CInboundNoticeRepository : OmsSqlSugarRepository, ICInboundNoticeRepository, IScopedDependency; + +public class CInboundBoxRepository : OmsSqlSugarRepository, ICInboundBoxRepository, IScopedDependency; + +public class CInboundBoxLineRepository : OmsSqlSugarRepository, ICInboundBoxLineRepository, IScopedDependency; + +public class CParcelRepository : OmsSqlSugarRepository, ICParcelRepository, IScopedDependency; + +public class CParcelItemRepository : OmsSqlSugarRepository, ICParcelItemRepository, IScopedDependency; + +public class CSkuPlatformMapRepository : OmsSqlSugarRepository, ICSkuPlatformMapRepository, IScopedDependency;