From d2cff49b9eca34a09f36ddce4afd37ea77e359b2 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 11 Sep 2026 11:51:26 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/sql/头程数据库统一增量-MySQL8.sql | 13 +++- firstleg/AGENTS.md | 2 +- .../client/WarehouseMasterDataClient.java | 9 +++ ...atformDropshipPalletizationController.java | 18 +++++- .../PlatformLoadingPdaController.java | 14 ++++- .../mapper/DropshipPalletizationMapper.java | 6 +- .../mapper/LoadingPickTaskMapper.java | 2 + .../repository/LoadingPickRepository.java | 4 +- .../service/DropshipPalletizationService.java | 12 +++- .../InboundCartonLabelPrintService.java | 3 +- .../firstleg/service/LoadingPickService.java | 4 +- .../service/PdaWarehouseCatalogService.java | 37 ++++++++++++ .../openapi/dropship-palletization-v1.yaml | 23 +++++-- .../openapi/phase3-pda-convergence-v1.yaml | 19 ++++++ .../DropshipPalletizationSqlContractTest.java | 13 ++++ .../LoadingPickPdaTaskSqlContractTest.java | 3 +- .../DropshipPalletizationServiceTest.java | 38 ++++++------ .../InboundCartonLabelPrintServiceTest.java | 28 +++++++++ .../service/LoadingPickServiceTest.java | 4 +- .../PdaWarehouseCatalogServiceTest.java | 39 ++++++++++++ iam/AGENTS.md | 2 +- .../java/com/baiyue/iam/dto/TenantDtos.java | 2 +- .../repository/CustomerCodeRepository.java | 51 ++++++++++++++++ .../service/MerchantOnboardingService.java | 4 +- .../com/baiyue/iam/service/TenantService.java | 5 +- iam/src/main/resources/db/iam.sql | 11 +++- iam/src/main/resources/openapi/iam-v1.yaml | 6 +- .../CustomerCodeRepositoryTest.java | 23 +++++++ .../CustomerCodeSchemaContractTest.java | 34 +++++++++++ ...CustomerListExportProviderServiceTest.java | 2 +- .../MerchantOnboardingServiceTest.java | 10 ++++ .../iam/service/TenantCustomerExportTest.java | 4 +- .../baiyue/iam/service/TenantServiceTest.java | 18 +++--- .../DomesticWarehouseDirectoryClient.java | 3 +- .../service/DispatchBolPrintService.java | 41 ++++++++++++- ...cWarehouseDirectoryClientContractTest.java | 3 +- .../service/DispatchBolPrintServiceTest.java | 60 +++++++++++++++++++ .../service/FbaWarehouseValidatorTest.java | 3 +- .../print/renderer/DispatchBolRenderer.java | 18 +++++- ...ance-reconciliation-full-contract.test.mjs | 2 +- warehouse/AGENTS.md | 2 +- .../InternalWarehouseController.java | 8 +++ .../dto/DomesticWarehouseOptionResponse.java | 11 ++++ .../service/WarehouseApplicationService.java | 9 +++ .../InternalWarehouseControllerTest.java | 12 ++++ 45 files changed, 569 insertions(+), 66 deletions(-) create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/service/PdaWarehouseCatalogService.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/service/PdaWarehouseCatalogServiceTest.java create mode 100644 iam/src/main/java/com/baiyue/iam/repository/CustomerCodeRepository.java create mode 100644 iam/src/test/java/com/baiyue/iam/repository/CustomerCodeRepositoryTest.java create mode 100644 iam/src/test/java/com/baiyue/iam/repository/CustomerCodeSchemaContractTest.java create mode 100644 warehouse/src/main/java/com/baiyue/warehouse/dto/DomesticWarehouseOptionResponse.java diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 5cf2de42d..ada7ec286 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -329,7 +329,7 @@ USE baiyue_iam; CREATE TABLE IF NOT EXISTS iam_tenant ( id BIGINT NOT NULL COMMENT '客户主档ID(雪花ID);0保留为平台技术作用域', - tenant_code VARCHAR(64) NOT NULL COMMENT '全局唯一客户编码', + tenant_code VARCHAR(8) NOT NULL COMMENT '客户编号:企业客户固定五位数字,平台技术主体为PLATFORM', tenant_name VARCHAR(128) NOT NULL COMMENT '客户企业名称', tenant_type VARCHAR(20) NOT NULL DEFAULT 'MERCHANT' COMMENT '主体类型:MERCHANT企业客户', status VARCHAR(20) NOT NULL DEFAULT 'PROFILE_REQUIRED' COMMENT '客户状态:PROFILE_REQUIRED待完善资料、PENDING待审核、ACTIVE正常、REJECTED已驳回、DISABLED已停用', @@ -364,6 +364,15 @@ CREATE TABLE IF NOT EXISTS iam_tenant ( KEY idx_iam_tenant_status (status, created_at) ) ENGINE=InnoDB COMMENT='客户企业主档表:保存企业资料、审核结果和启停状态;ID同时作为客户数据隔离键'; +CREATE TABLE IF NOT EXISTS iam_number_sequence ( + sequence_key VARCHAR(64) NOT NULL COMMENT '业务编号类型', + next_value BIGINT UNSIGNED NOT NULL COMMENT '下一待分配数字', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY (sequence_key) +) ENGINE=InnoDB COMMENT='IAM业务编号序列表'; + +INSERT IGNORE INTO iam_number_sequence(sequence_key, next_value) VALUES ('CUSTOMER_CODE', 1); + CREATE TABLE IF NOT EXISTS iam_tenant_review ( id BIGINT NOT NULL COMMENT '审核记录ID', tenant_id BIGINT NOT NULL COMMENT '被审核客户ID和数据隔离键', @@ -15186,7 +15195,7 @@ CALL queue_column_comment('baiyue_iam', 'iam_tenant', 'reject_reason', '客户 CALL queue_column_comment('baiyue_iam', 'iam_tenant', 'sales_owner_name', '销售负责人姓名'); CALL queue_column_comment('baiyue_iam', 'iam_tenant', 'service_package', '客户服务套餐'); CALL queue_column_comment('baiyue_iam', 'iam_tenant', 'status', '客户状态:PROFILE_REQUIRED待完善资料、PENDING待审核、ACTIVE正常、REJECTED已驳回、DISABLED已停用'); -CALL queue_column_comment('baiyue_iam', 'iam_tenant', 'tenant_code', '全局唯一客户编码'); +CALL queue_column_comment('baiyue_iam', 'iam_tenant', 'tenant_code', '客户编号:企业客户固定五位数字,平台技术主体为PLATFORM'); CALL queue_column_comment('baiyue_iam', 'iam_tenant', 'tenant_name', '客户企业名称'); CALL queue_column_comment('baiyue_iam', 'iam_tenant', 'tenant_type', '主体类型:MERCHANT企业客户'); CALL queue_column_comment('baiyue_iam', 'iam_tenant', 'unified_social_credit_code', '统一社会信用代码'); diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index 841f6439b..e3a0f9bc4 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -88,7 +88,7 @@ - 新增模拟柜只保存智能装柜的 9 类计划渠道 channel_group_code;未关联已放舱订舱时 channel_code/channel_name 为空,关联后才冻结订舱的实际渠道。智能装柜全局工作台和订舱关联候选均按计划渠道组匹配;服务端把业务渠道分组映射为实际渠道,并分别分页查询候选与 `WAIT_LOADING/EDITING/LOADING` 柜次;候选支持按创建日期和预计到货日期闭区间筛选,状态筛选使用最多四项的 `orderStatuses`,未传表示 `PENDING_REVIEW/WAIT_MEASURE/WAIT_SPLIT/LOADING` 全部未完成状态。状态只用于搜索和展示,能否加入以响应 `canAssign` 为准,由有效柜次占用、剩余箱数、容量、组锁和版本等事实决定;人工加入允许订单国内仓及渠道与柜次不同,智能推荐允许跨国内仓但仍只选择柜次渠道。容积和重量均允许达到柜型额定值的 150%,超过即拒绝。待审核事实必须经 `PendingReviewOrderClient` 重新核验 Order 权威 `SUBMITTED` 订单,待审核或待测量可先占位,但确认预装和启动 PDA 前仍必须进入 `WAIT_SPLIT`。计划明细通过 `totalPackageCount/remainingPackageCount` 返回可调总量和余量:测量前取 Order 原始申报箱数,进入 `WAIT_SPLIT/LOADING` 后取最新已审核测量箱数;只读补充失败时保守回退当前已分配数,调整写入仍重新查询并校验权威数量。同柜不可拆组只有全部成员命中状态集合时才返回,且必须成员完整、满箱并作为单个分页与分配单元,不能跨页或拆分。一次性移除未拣明细时,普通未拣项直接移除;同柜组仅全组未拣时整组移除,任一成员已拣则整组保留,单事务只递增一次计划版本。推荐顺序按入库时间先进先出。装柜计划状态为 `EDITING -> LOADING -> COMPLETED`,PDA 全部明细处理完成后自动完成。 - 预装进入 `CONFIRMING/CONFIRMED` 只固化确认快照,不得锁死尚未完成柜次的智能装柜分配;装柜计划仍处于 `EDITING/LOADING` 时可继续新增,未产生实物拣货的明细可调整或移除,已拣明细保持锁定。 - 客户入库费用只在登录客户范围、测量审核通过、`feeStatus=SENT` 且存在已发布明细时可见;确认必须具备 `merchant:order:edit`,携带当前 `expectedFeeVersion` 与幂等键。成功后记录确认成员和时间;客户未确认或该票尚未装柜完成时,平台仍可修改箱规规则、生成新费用版本并重新发送,新版本必须清空旧版客户确认快照。仅当“客户已确认且该票已装柜完成”同时成立时最终冻结;装柜完成不得代替客户自动确认。旧版本、未发送或重复异参确认均返回冲突,不做兼容覆盖。 -- PDA 拣货以 `fl_inbound_carton_label.box_code` 对应的稳定箱唛箱码为扫描身份;审核建单、重复打印、手工测量和墨家测量复用同一映射,版本字段只用于并发控制,不进入扫码文本。一个有效且未重复的箱码扫描表示拣出一箱,不得以 SKU、商品件数或页面展示字段替代箱码。装柜允许跨国内仓,仓库与渠道限制统一由上游装柜编排负责;PDA 只按已生成任务读取各入仓单自身的 `CONFIRMED` 库位资源,不得再用柜次仓库过滤任务明细、库存快照或拣货写入。任务下发与提交契约见 `src/main/resources/openapi/phase3-pda-convergence-v1.yaml`。 +- PDA 拣货以 `fl_inbound_carton_label.box_code` 对应的稳定箱唛箱码为扫描身份;审核建单、重复打印、手工测量和墨家测量复用同一映射,版本字段只用于并发控制,不进入扫码文本。一个有效且未重复的箱码扫描表示拣出一箱,不得以 SKU、商品件数或页面展示字段替代箱码。PDA 首页从 Warehouse 主档选择启用国内仓,一件代发与装柜待办列表必须按所选 `warehouseId` 服务端过滤;装柜任务内部仍允许跨国内仓,仓库与渠道限制统一由上游装柜编排负责,PDA 只按已生成任务读取各入仓单自身的 `CONFIRMED` 库位资源,不得再用柜次仓库过滤任务明细、库存快照或拣货写入。任务下发与提交契约见 `src/main/resources/openapi/phase3-pda-convergence-v1.yaml`。 - PDA 部分拣货生成二级子入库单时,数量必须守恒:`已拣数量 + 子单数量 = 拣货前主单可用数量`。子单只允许一层,不能继续拆分。 - 首张称重图片确认时按 `max(已确认称重图片数, ceil(有效体积 / 默认托盘容量))` 尽力预留并确认整票库位块;有效体积先取入库单已落地的正数实测体积,否则对最新测量批次的正数 `totalVolumeCbm/originalVolumeCbm` 取较大值,再无有效值才查询 Order 最新客户申报体积,全部无效则拒绝。后续确认绑定最小空槽,所需槽数增长时通过 Warehouse `/blocks/expand` 原子追加,既有 `blockId/slotNo/reservationId/locationId/sharedOverflow` 不得变化。库位指引失败只记录警告,不阻断称重确认;测量提交后以最新已确认图片调用 `ensureForSubmission` 再次补齐和校验,失败同样不回滚已提交的测量主流程。Firstleg 必须显式保存 `weightResultId -> blockId/slotNo/reservationId/locationId/locationCode`,按 `confirmedAt + weightResultId` 稳定顺序绑定,禁止按列表下标推断;普通连续位不足的新增槽由 Warehouse 放入共享 A0,不阻断确认。 - 删除称重图片只解除图片和托盘快照绑定,将槽位恢复为 `RESERVED_EMPTY`;槽位和整票块继续归当前入仓码。解绑失败时不得调用文件删除,文件删除失败时本地解绑和重量结果删除必须回滚。 diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/WarehouseMasterDataClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/WarehouseMasterDataClient.java index 3985ff209..28ae9d820 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/client/WarehouseMasterDataClient.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/WarehouseMasterDataClient.java @@ -6,6 +6,8 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestHeader; +import java.util.List; + /** 获取平台仓库主数据,供虚拟柜校验国内中转仓使用。 */ @FeignClient(name = "warehouse-service", contextId = "firstlegWarehouseMasterDataClient", url = "${baiyue.clients.warehouse.url:}", path = "/internal/warehouses") @@ -15,10 +17,17 @@ public interface WarehouseMasterDataClient { @PathVariable long tenantId, @PathVariable long warehouseId); + @GetMapping("/domestic") + ApiResponse> domestic( + @RequestHeader("X-Internal-Service-Token") String token); + record Detail(Warehouse warehouse) { } record Warehouse(Long id, Long tenantId, String warehouseCode, String internalCode, String warehouseName, String warehouseType, String status) { } + + record WarehouseOption(Long id, String warehouseCode, String warehouseName) { + } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformDropshipPalletizationController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformDropshipPalletizationController.java index bbb40a3a7..399f218b6 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformDropshipPalletizationController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformDropshipPalletizationController.java @@ -39,9 +39,10 @@ public class PlatformDropshipPalletizationController { @PreAuthorize("hasAuthority('platform:firstleg:pda:view')") public ApiResponse> pending( @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam @Positive long warehouseId, @RequestParam(required = false) @Size(max = 64) String keyword) { FirstlegScopeGuard.platform(user); - return ApiResponse.success(service.pending(user.userId(), keyword)); + return ApiResponse.success(service.pending(user.userId(), warehouseId, keyword)); } @GetMapping("/tasks/{taskId}") @@ -121,6 +122,21 @@ public class PlatformDropshipPalletizationController { return ApiResponse.success(service.seal(taskId, palletId, request, user.userId(), user.username())); } + @GetMapping("/tasks/{taskId}/pallets/{palletId}/print") + @PreAuthorize("hasAuthority('platform:firstleg:pda:view')") + public ResponseEntity printTaskPallet(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long taskId, + @PathVariable @Positive long palletId, + @RequestParam @Positive long warehouseId, + @RequestHeader(value = "X-Request-Id", required = false) String requestId) { + FirstlegScopeGuard.platform(user); + Pallet pallet = service.task(taskId, warehouseId, user.userId()).pallets().stream() + .filter(item -> item.palletId() == palletId).findFirst() + .orElseThrow(() -> new com.baiyue.common.core.exception.BizException( + com.baiyue.common.core.api.ResultCode.NOT_FOUND, "托盘不存在")); + return printService.generateDropshipPallet(pallet, requestId); + } + @GetMapping("/inbound-orders/{inboundId}/pallets") @PreAuthorize("hasAuthority('firstleg:inbound:list')") public ApiResponse> inboundPallets(@AuthenticationPrincipal AuthenticatedUser user, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingPdaController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingPdaController.java index 81cedd891..0344746e7 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingPdaController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingPdaController.java @@ -20,6 +20,8 @@ import java.util.List; import com.baiyue.firstleg.dto.LoadingPickDtos.Task; import com.baiyue.firstleg.service.FirstlegScopeGuard; import com.baiyue.firstleg.service.LoadingPickService; +import com.baiyue.firstleg.service.PdaWarehouseCatalogService; +import com.baiyue.firstleg.client.WarehouseMasterDataClient.WarehouseOption; import com.baiyue.firstleg.loading.service.CabinetLoadingEvidenceService; import org.springframework.http.ResponseEntity; import org.springframework.http.MediaType; @@ -51,6 +53,8 @@ public class PlatformLoadingPdaController { @Resource private LoadingPickService service; @Resource + private PdaWarehouseCatalogService warehouseCatalog; + @Resource private CabinetLoadingEvidenceService evidenceService; @GetMapping("/tasks") @@ -66,10 +70,18 @@ public class PlatformLoadingPdaController { @GetMapping("/tasks/pending") @PreAuthorize("hasAuthority('platform:firstleg:pda:view')") public ApiResponse> pendingTasks(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam @Positive long warehouseId, @RequestParam(required = false) @Size(max = 64) String keyword) { FirstlegScopeGuard.platform(user); - return ApiResponse.success(service.pendingTasks(user.userId(), keyword)); + return ApiResponse.success(service.pendingTasks(user.userId(), warehouseId, keyword)); + } + + @GetMapping("/warehouses") + @PreAuthorize("hasAuthority('platform:firstleg:pda:view')") + public ApiResponse> warehouses(@AuthenticationPrincipal AuthenticatedUser user) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(warehouseCatalog.activeDomesticWarehouses()); } @GetMapping("/tasks/{taskId}") diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/DropshipPalletizationMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/DropshipPalletizationMapper.java index 4e08fb76b..200815abf 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/DropshipPalletizationMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/DropshipPalletizationMapper.java @@ -30,10 +30,12 @@ public interface DropshipPalletizationMapper { o.warehouse_name,t.total_cartons,t.counted_cartons,t.allocated_cartons bound_cartons, t.counted_cartons-t.allocated_cartons unbound_cartons,t.discrepancy_cartons,t.task_status,t.created_at FROM fl_dropship_pallet_task t JOIN fl_inbound_order o ON o.id=t.inbound_id AND o.tenant_id=t.tenant_id - WHERE t.task_status IN ('PENDING','CLAIMED') AND (t.assignee_id IS NULL OR t.assignee_id=#{operatorId}) + WHERE t.task_status IN ('PENDING','CLAIMED') AND t.warehouse_id=#{warehouseId} + AND (t.assignee_id IS NULL OR t.assignee_id=#{operatorId}) AND (#{keyword} IS NULL OR o.inbound_code LIKE CONCAT('%',#{keyword},'%') OR o.customer_name LIKE CONCAT('%',#{keyword},'%')) ORDER BY t.created_at,t.id""") - List pending(@Param("operatorId") long operatorId,@Param("keyword") String keyword); + List pending(@Param("operatorId") long operatorId,@Param("warehouseId") long warehouseId, + @Param("keyword") String keyword); @Select(""" SELECT t.*,o.inbound_code,o.customer_name,o.warehouse_name,o.process_status,o.fulfillment_type diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickTaskMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickTaskMapper.java index 9fa4e1c78..7ab029bc1 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickTaskMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickTaskMapper.java @@ -100,6 +100,7 @@ public interface LoadingPickTaskMapper extends BaseMapper JOIN fl_loading_pick_item pick_item ON pick_item.pick_task_id = task.id LEFT JOIN fl_loading_pick_assignment assignment ON assignment.pick_task_id = task.id WHERE task.task_status IN ('LOADING','COMPLETING','COMPLETION_FAILED') AND booking.record_status = 'ACTIVE' + AND booking.warehouse_id = #{warehouseId} AND (assignment.pick_task_id IS NULL OR assignment.assignee_id = #{operatorId}) AND (booking.container_no LIKE CONCAT('%', #{keyword}, '%') @@ -115,6 +116,7 @@ public interface LoadingPickTaskMapper extends BaseMapper """) List selectPendingForOperator(@Param("operatorId") long operatorId, + @Param("warehouseId") long warehouseId, @Param("keyword") String keyword); class PendingTaskRow { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingPickRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingPickRepository.java index f3b1a1390..51260d8c9 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingPickRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingPickRepository.java @@ -90,8 +90,8 @@ public class LoadingPickRepository { return tasks.selectLoadingByPlan(loadingPlanId, warehouseId); } - public List pendingTasks(long operatorId, String keyword) { - return tasks.selectPendingForOperator(operatorId, keyword); + public List pendingTasks(long operatorId, long warehouseId, String keyword) { + return tasks.selectPendingForOperator(operatorId, warehouseId, keyword); } public ContainerBookingEntity activeBookingByTask(long taskId) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/DropshipPalletizationService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/DropshipPalletizationService.java index f8efa803c..c8ca1ce2c 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/DropshipPalletizationService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/DropshipPalletizationService.java @@ -36,6 +36,7 @@ public class DropshipPalletizationService { return view(task); } if(!"CLAIMED".equals(task.getTaskStatus())||!"WAIT_PALLETIZE".equals(task.getProcessStatus())) throw conflict("当前任务不允许创建托盘"); + if(!Objects.equals(task.getCountedCartons(),task.getTotalCartons())) throw conflict("请先完成全部箱码清点,再生成托盘码"); int expected=mapper.unboundCount(taskId); if(expected==0) throw conflict("当前没有待绑托箱,请先清点箱码"); long palletId=IdWorker.getId(); @@ -43,6 +44,10 @@ public class DropshipPalletizationService { mapper.insertPallet(palletId,taskId,task.getTenantId(),task.getInboundId(),task.getWarehouseId(),palletNo,operation,operatorId,operatorName); int bound=mapper.bindPending(taskId,palletId,operation,operatorId,operatorName); if(bound!=expected||mapper.adjustBound(taskId,operatorId,bound)!=1) throw conflict("待绑托箱已变化,请刷新后重试"); + if(mapper.seal(taskId,palletId,1,request.warehouseId(),operation,operatorId,operatorName)!=1) + throw conflict("托盘完成状态已变化,请刷新后重试"); + if(mapper.completeTask(taskId)!=1 || mapper.completeInbound(taskId,task.getTenantId(),task.getInboundId(),operatorId)!=1) + throw conflict("一件代发入库完成状态已变化,请刷新后重试"); return view(requireTask(taskId,request.warehouseId())); } @@ -52,7 +57,7 @@ public class DropshipPalletizationService { mapper.insertTask(IdWorker.getId(),inbound.getTenantId(),inbound.getId(),inbound.getWarehouseId(),inbound.getTotalPackageCount()); } - public List pending(long operatorId,String keyword){ return mapper.pending(operatorId,normalize(keyword)); } + public List pending(long operatorId,long warehouseId,String keyword){ return mapper.pending(operatorId,warehouseId,normalize(keyword)); } @Transactional public Task claim(long taskId,long warehouseId,long operatorId,String operatorName){ @@ -99,8 +104,8 @@ public class DropshipPalletizationService { if(!mismatch && request.discrepancyAction()!=DiscrepancyAction.MATCH) throw invalid("实收数量一致时差异动作必须为 MATCH"); if(mismatch && request.discrepancyAction()!=DiscrepancyAction.ACCEPT_VARIANCE) throw conflict("实收数量存在差异,请确认差异处理结果"); if(mismatch && !StringUtils.hasText(remark)) throw invalid("接受数量差异时必须填写处理说明"); - PalletRow pallet=StringUtils.hasText(request.palletNo()) - ? getOrCreatePallet(task,request.palletNo().trim(),operation,operatorId,operatorName):null; + if(StringUtils.hasText(request.palletNo())) throw invalid("逐箱清点阶段不能提前绑定托盘码"); + PalletRow pallet=null; long cartonId=IdWorker.getId(); mapper.insertCarton(cartonId,taskId,pallet==null?null:pallet.getId(),task.getTenantId(),task.getInboundId(), label.getInboundDetailId(),label.getCartonLabelId(),label.getSkuCode(),label.getCargoName(),label.getBoxCode(), @@ -113,6 +118,7 @@ public class DropshipPalletizationService { @Transactional public Task bindPending(long taskId,BindPendingRequest request,long operatorId,String operatorName){ TaskRow task=requireWorkingTask(taskId,request.warehouseId(),operatorId); + if(!Objects.equals(task.getCountedCartons(),task.getTotalCartons())) throw conflict("请先完成全部箱码清点,再绑定托盘码"); String operation=operation(request.operationId()); if(mapper.bindOperationCount(taskId,operation)>0) return view(requireTask(taskId,request.warehouseId())); PalletRow pallet=getOrCreatePallet(task,request.palletNo().trim(),operation,operatorId,operatorName); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundCartonLabelPrintService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundCartonLabelPrintService.java index 065ebf1a1..9114087e4 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundCartonLabelPrintService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundCartonLabelPrintService.java @@ -115,7 +115,8 @@ public class InboundCartonLabelPrintService { /** 一件代发托盘标签:托盘号、入仓码、SKU箱数和托盘二维码。 */ public ResponseEntity generateDropshipPallet(Pallet pallet, String requestId) { - if (pallet == null || !StringUtils.hasText(pallet.palletNo()) || pallet.items().isEmpty()) { + if (pallet == null || !"SEALED".equals(pallet.status()) + || !StringUtils.hasText(pallet.palletNo()) || pallet.items().isEmpty()) { throw new BizException(ResultCode.CONFLICT, "只有已完成且包含货物的一件代发托盘可以打印"); } String inboundCodes = pallet.items().stream().map(item -> item.inboundCode()) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java index 383710d1b..a0a478702 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java @@ -233,9 +233,9 @@ public class LoadingPickService { return view(requiredTask(taskId), warehouseId); } - public List pendingTasks(long operatorId, String keyword) { + public List pendingTasks(long operatorId, long warehouseId, String keyword) { String normalizedKeyword = StringUtils.hasText(keyword) ? keyword.trim() : null; - return repository.pendingTasks(operatorId, normalizedKeyword).stream() + return repository.pendingTasks(operatorId, warehouseId, normalizedKeyword).stream() .map(row -> new PendingTask(row.getTaskId(), row.getLoadingPlanId(), row.getContainerBookingId(), row.getCabinetNo(), row.getContainerNo(), row.getSourceType(), row.getRecordStatus(), row.getMergedIntoContainerBookingId(), diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/PdaWarehouseCatalogService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/PdaWarehouseCatalogService.java new file mode 100644 index 000000000..4dd10242e --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/PdaWarehouseCatalogService.java @@ -0,0 +1,37 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.client.WarehouseMasterDataClient; +import com.baiyue.firstleg.client.WarehouseMasterDataClient.WarehouseOption; +import jakarta.annotation.Resource; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.util.List; + +/** 为国内 PDA 提供启用中的国内仓选择项,主档仍由 Warehouse Service 唯一维护。 */ +@Service +public class PdaWarehouseCatalogService { + @Resource private WarehouseMasterDataClient warehouses; + @Value("${baiyue.security.internal-service-token:}") private String internalToken; + + public List activeDomesticWarehouses() { + try { + ApiResponse> response = warehouses.domestic(internalToken); + if (response == null || !response.successful() || response.data() == null) { + throw unavailable(); + } + return response.data(); + } catch (BizException exception) { + throw exception; + } catch (RuntimeException exception) { + throw unavailable(); + } + } + + private static BizException unavailable() { + return new BizException(ResultCode.SERVICE_UNAVAILABLE, "国内仓目录暂时不可用,请稍后重试"); + } +} diff --git a/firstleg/src/main/resources/openapi/dropship-palletization-v1.yaml b/firstleg/src/main/resources/openapi/dropship-palletization-v1.yaml index bd26d6110..83a769ee2 100644 --- a/firstleg/src/main/resources/openapi/dropship-palletization-v1.yaml +++ b/firstleg/src/main/resources/openapi/dropship-palletization-v1.yaml @@ -2,12 +2,13 @@ openapi: 3.0.3 info: title: 一件代发国内清点组托 API version: 2.0.0 - description: 测量、计费后独立下发。箱码逐箱确认 SKU 实收数量;托盘码可先扫或后扫,封托后作为不可拆分柜单元。 + description: 测量、计费后独立下发。先逐箱确认 SKU 实收数量;全部箱码清点完成后才原子生成、绑定并封存正式 DP 托盘码,随后可打印托盘标签。 paths: /api/platform/v1/first-leg/pda-dropship/tasks/pending: get: summary: 查询待领取或本人已领取任务 x-required-authority: platform:firstleg:pda:view + parameters: [{$ref: '#/components/parameters/WarehouseId'}, {name: keyword, in: query, required: false, schema: {type: string, maxLength: 64}}] responses: {'200': {description: OK}} /api/platform/v1/first-leg/pda-dropship/tasks/{taskId}: get: @@ -33,8 +34,8 @@ paths: responses: {'200': {description: OK}} /api/platform/v1/first-leg/pda-dropship/tasks/{taskId}/cartons/count: post: - summary: 逐箱确认 SKU 实收数量,可同时绑定当前托盘 - description: palletNo 为空时进入 COUNTED_UNBOUND;不为空时幂等获取或创建 OPEN 托盘并绑定。数量不一致必须 ACCEPT_VARIANCE 且填写说明。 + summary: 逐箱确认 SKU 实收数量 + description: 清点阶段只记录箱级实收并进入 COUNTED_UNBOUND,不得提前生成或绑定托盘码。数量不一致必须 ACCEPT_VARIANCE 且填写说明。 x-required-authority: platform:firstleg:pda:work parameters: [{$ref: '#/components/parameters/TaskId'}] requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/CountCartonRequest'}}}} @@ -48,8 +49,8 @@ paths: responses: {'200': {description: OK}} /api/platform/v1/first-leg/pda-dropship/tasks/{taskId}/pallets/create-and-bind: post: - summary: 系统生成 DP 托盘码并原子绑定全部待绑箱 - description: 仅当前仓库本人已领取的待组托任务可执行。没有待绑箱时不发码、不建空托。operationId 在同任务和仓库重放不重复创建;不同任务使用同幂等号拒绝。返回当前 Task。 + summary: 完成一件代发入库并生成正式 DP 托盘码 + description: 仅全部箱码已经清点的本人任务可执行;同一事务内生成 DP 托盘码、绑定全部箱、封托并把入仓单推进到待分柜。未完成清点或没有待绑箱时不发码、不建空托。operationId 重放不重复创建;返回已完成 Task。 x-required-authority: platform:firstleg:pda:work parameters: [{$ref: '#/components/parameters/TaskId'}] requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/CreateAndBindRequest'}}}} @@ -72,6 +73,17 @@ paths: - {name: palletId, in: path, required: true, schema: {type: integer, format: int64}} requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/SealPalletRequest'}}}} responses: {'200': {description: OK}} + /api/platform/v1/first-leg/pda-dropship/tasks/{taskId}/pallets/{palletId}/print: + get: + summary: PDA 打印已封托的一件代发托盘码 + description: 仅当前仓库、当前操作员可访问;未封托托盘拒绝打印。返回 100mm × 100mm PDF。 + x-required-authority: platform:firstleg:pda:view + parameters: + - {$ref: '#/components/parameters/TaskId'} + - {name: palletId, in: path, required: true, schema: {type: integer, format: int64}} + - {$ref: '#/components/parameters/WarehouseId'} + responses: + '200': {description: 托盘码 PDF, content: {application/pdf: {schema: {type: string, format: binary}}}} /api/platform/v1/first-leg/pda-dropship/inbound-orders/{inboundId}/pallets: get: summary: Admin 查询入仓单权威托盘结果 @@ -134,7 +146,6 @@ components: actualSkuQuantity: {type: integer, minimum: 1} discrepancyAction: {type: string, enum: [MATCH, ACCEPT_VARIANCE]} discrepancyRemark: {type: string, maxLength: 500, nullable: true} - palletNo: {$ref: '#/components/schemas/NullablePalletNo'} operationId: {type: string, maxLength: 128} BindPendingRequest: type: object diff --git a/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml b/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml index 2678da454..be0584e55 100644 --- a/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml +++ b/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml @@ -3,6 +3,17 @@ info: title: Phase 3 Container PDA Picking API version: 1.0.0 paths: + /api/platform/v1/first-leg/pda-loading/warehouses: + get: + summary: List active domestic warehouses available to the PDA operator + responses: + '200': + description: Warehouse master-data options used to scope PDA pending-task lists + content: + application/json: + schema: + type: array + items: {$ref: '#/components/schemas/WarehouseOption'} /api/platform/v1/first-leg/pda-loading/tasks: get: summary: Find the active container picking task in one platform warehouse @@ -18,6 +29,7 @@ paths: get: summary: List active tasks available to the authenticated PDA operator parameters: + - {name: warehouseId, in: query, required: true, schema: {type: integer, format: int64, minimum: 1}} - {name: keyword, in: query, required: false, schema: {type: string, maxLength: 64}} responses: '200': @@ -175,6 +187,13 @@ components: TaskId: {name: taskId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}} ItemId: {name: itemId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}} schemas: + WarehouseOption: + type: object + required: [id, warehouseCode, warehouseName] + properties: + id: {type: integer, format: int64} + warehouseCode: {type: string} + warehouseName: {type: string} Task: type: object required: [id, loadingPlanId, containerBookingId, inboundId, inboundVersion, loadingVersion, cabinetNo, sourceType, recordStatus, taskStatus, workflowStatus, version, items] diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/DropshipPalletizationSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/DropshipPalletizationSqlContractTest.java index e247cc0aa..d44557cc2 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/DropshipPalletizationSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/DropshipPalletizationSqlContractTest.java @@ -1,6 +1,8 @@ package com.baiyue.firstleg.repository; import org.junit.jupiter.api.Test; +import org.apache.ibatis.annotations.Select; +import com.baiyue.firstleg.mapper.DropshipPalletizationMapper; import org.springframework.core.io.ClassPathResource; import java.nio.charset.StandardCharsets; @@ -10,6 +12,17 @@ import java.nio.file.Path; import static org.assertj.core.api.Assertions.assertThat; class DropshipPalletizationSqlContractTest { + @Test + void pendingPdaTasksAreScopedByTheSelectedWarehouse() throws Exception { + String sql = DropshipPalletizationMapper.class + .getDeclaredMethod("pending", long.class, long.class, String.class) + .getAnnotation(Select.class).value()[0]; + + assertThat(sql) + .contains("t.warehouse_id=#{warehouseId}") + .contains("t.assignee_id IS NULL OR t.assignee_id=#{operatorId}"); + } + @Test void moduleAndUnifiedSchemasExposeCountingPalletAndWholePalletAssignmentFacts() throws Exception { String inbound = new ClassPathResource("db/phase2-inbound-schema.sql") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickPdaTaskSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickPdaTaskSqlContractTest.java index ae16cb39a..20799bb0b 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickPdaTaskSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickPdaTaskSqlContractTest.java @@ -37,12 +37,13 @@ class LoadingPickPdaTaskSqlContractTest { @Test void pendingTasksAreLimitedToUnassignedOrCurrentOperatorWithoutCustomerInput() throws Exception { String sql = LoadingPickTaskMapper.class - .getDeclaredMethod("selectPendingForOperator", long.class, String.class) + .getDeclaredMethod("selectPendingForOperator", long.class, long.class, String.class) .getAnnotation(Select.class).value()[0]; assertThat(sql) .contains("task.task_status IN ('LOADING','COMPLETING','COMPLETION_FAILED')") .contains("booking.record_status = 'ACTIVE'") + .contains("booking.warehouse_id = #{warehouseId}") .contains("booking.source_type AS sourceType") .contains("booking.record_status AS recordStatus") .contains("booking.merged_into_container_booking_id AS mergedIntoContainerBookingId") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/DropshipPalletizationServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/DropshipPalletizationServiceTest.java index 248544add..8613f5263 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/DropshipPalletizationServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/DropshipPalletizationServiceTest.java @@ -22,18 +22,27 @@ class DropshipPalletizationServiceTest { DropshipPalletizationServiceTest(){ ReflectionTestUtils.setField(service,"mapper",mapper); ReflectionTestUtils.setField(service,"documentNumbers",numbers); } - @Test void generatesAndBindsOnlyWhenPendingCartonsExist(){ - when(mapper.lockTask(10L,100L)).thenReturn(10L); when(mapper.task(10L,100L)).thenReturn(task()); + @Test void generatesBindsSealsAndCompletesOnlyAfterEveryCartonIsCounted(){ + when(mapper.lockTask(10L,100L)).thenReturn(10L); when(mapper.task(10L,100L)).thenReturn(readyToComplete()); when(mapper.unboundCount(10L)).thenReturn(2); when(numbers.next("DP")).thenReturn("DP-260909-0001"); when(mapper.bindPending(eq(10L),anyLong(),eq("create-1"),eq(7L),eq("张三"))).thenReturn(2); when(mapper.adjustBound(10L,7L,2)).thenReturn(1); + when(mapper.seal(eq(10L),anyLong(),eq(1),eq(100L),eq("create-1"),eq(7L),eq("张三"))).thenReturn(1); + when(mapper.completeTask(10L)).thenReturn(1); when(mapper.completeInbound(10L,1L,30L,7L)).thenReturn(1); service.createAndBind(10L,new com.baiyue.firstleg.dto.DropshipPalletizationDtos.CreateAndBindRequest(100L,"create-1"),7L,"张三"); verify(mapper).insertPallet(anyLong(),eq(10L),eq(1L),eq(30L),eq(100L),eq("DP-260909-0001"),eq("create-1"),eq(7L),eq("张三")); verify(numbers,times(1)).next("DP"); } - @Test void noPendingCartonsNeverAllocatesNumberOrEmptyPallet(){ + @Test void incompleteCountingNeverAllocatesAPalletNumber(){ when(mapper.lockTask(10L,100L)).thenReturn(10L); when(mapper.task(10L,100L)).thenReturn(task()); + assertThatThrownBy(()->service.createAndBind(10L,new com.baiyue.firstleg.dto.DropshipPalletizationDtos.CreateAndBindRequest(100L,"early"),7L,"张三")) + .hasMessageContaining("完成全部箱码清点"); + verifyNoInteractions(numbers); + } + + @Test void noPendingCartonsNeverAllocatesNumberOrEmptyPallet(){ + when(mapper.lockTask(10L,100L)).thenReturn(10L); when(mapper.task(10L,100L)).thenReturn(readyToComplete()); assertThatThrownBy(()->service.createAndBind(10L,new com.baiyue.firstleg.dto.DropshipPalletizationDtos.CreateAndBindRequest(100L,"empty"),7L,"张三")).hasMessageContaining("没有待绑托箱"); verifyNoInteractions(numbers); verify(mapper,never()).insertPallet(anyLong(),anyLong(),anyLong(),anyLong(),anyLong(),anyString(),anyString(),anyLong(),anyString()); @@ -48,7 +57,7 @@ class DropshipPalletizationServiceTest { } @Test void rejectsOtherOperatorAndCrossTaskReplay(){ - when(mapper.lockTask(10L,100L)).thenReturn(10L); when(mapper.task(10L,100L)).thenReturn(task()); + when(mapper.lockTask(10L,100L)).thenReturn(10L); when(mapper.task(10L,100L)).thenReturn(readyToComplete()); var request=new com.baiyue.firstleg.dto.DropshipPalletizationDtos.CreateAndBindRequest(100L,"replay"); assertThatThrownBy(()->service.createAndBind(10L,request,8L,"李四")).hasMessageContaining("当前操作员"); PalletRow other=pallet(); other.setTaskId(11L); when(mapper.palletByOpenOperation("replay")).thenReturn(other); @@ -59,7 +68,7 @@ class DropshipPalletizationServiceTest { @Test void rejectsUnknownWarehouseAndChangedBindCount(){ var request=new com.baiyue.firstleg.dto.DropshipPalletizationDtos.CreateAndBindRequest(100L,"new"); assertThatThrownBy(()->service.createAndBind(10L,request,7L,"张三")).hasMessageContaining("当前仓库"); - when(mapper.lockTask(10L,100L)).thenReturn(10L); when(mapper.task(10L,100L)).thenReturn(task()); + when(mapper.lockTask(10L,100L)).thenReturn(10L); when(mapper.task(10L,100L)).thenReturn(readyToComplete()); when(mapper.unboundCount(10L)).thenReturn(2); when(numbers.next("DP")).thenReturn("DP-260909-0002"); assertThatThrownBy(()->service.createAndBind(10L,request,7L,"张三")).hasMessageContaining("待绑托箱已变化"); verify(mapper,never()).adjustBound(anyLong(),anyLong(),anyInt()); @@ -87,23 +96,16 @@ class DropshipPalletizationServiceTest { verify(mapper,never()).insertCarton(anyLong(),anyLong(),any(),anyLong(),anyLong(),anyLong(),anyLong(),any(),any(),any(),anyInt(),anyInt(),any(),any(),any(),anyLong(),any()); } - @Test void countsOneCartonDirectlyIntoScannedPallet(){ - TaskRow before=task(),after=task(); after.setCountedCartons(1); after.setAllocatedCartons(1); - LabelRow label=label(); PalletRow pallet=pallet(); CartonRow carton=carton(); - when(mapper.task(10L,100L)).thenReturn(before,after); - when(mapper.label(10L,"BOX-1")).thenReturn(label); when(mapper.palletByNo("DP-260908-0001")).thenReturn(pallet); - when(mapper.addCounted(10L,7L,1,0)).thenReturn(1); when(mapper.carton(eq(10L),anyLong())).thenReturn(carton); - when(mapper.pallet(10L,20L,100L)).thenReturn(pallet); when(mapper.cartons(20L)).thenReturn(java.util.List.of(carton)); - when(mapper.metrics(20L)).thenReturn(new DropshipPalletizationMapper.PalletMetrics(java.math.BigDecimal.ONE,java.math.BigDecimal.TEN)); + @Test void countingCannotExposeOrBindAPalletNumberEarly(){ + when(mapper.task(10L,100L)).thenReturn(task()); when(mapper.label(10L,"BOX-1")).thenReturn(label()); var request=new CountCartonRequest(100L,"BOX-1",12,DiscrepancyAction.MATCH,null,"DP-260908-0001","op-1"); - var result=service.countCarton(10L,request,7L,"张三"); - assertThat(result.carton().countStatus()).isEqualTo("BOUND"); - assertThat(result.pallet().status()).isEqualTo("OPEN"); - verify(mapper).insertCarton(anyLong(),eq(10L),eq(20L),eq(1L),eq(30L),eq(40L),eq(50L),eq("SKU-1"), - eq("货物"),eq("BOX-1"),eq(12),eq(12),eq("MATCH"),isNull(),eq("op-1"),eq(7L),eq("张三")); + assertThatThrownBy(()->service.countCarton(10L,request,7L,"张三")) + .hasMessageContaining("不能提前绑定托盘码"); + verify(mapper,never()).insertCarton(anyLong(),anyLong(),any(),anyLong(),anyLong(),anyLong(),anyLong(),any(),any(),any(),anyInt(),anyInt(),any(),any(),any(),anyLong(),any()); } private static TaskRow task(){ TaskRow t=new TaskRow();t.setId(10L);t.setTenantId(1L);t.setInboundId(30L);t.setWarehouseId(100L);t.setInboundCode("IN-1");t.setTaskStatus("CLAIMED");t.setAssigneeId(7L);t.setProcessStatus("WAIT_PALLETIZE");t.setTotalCartons(2);t.setCountedCartons(0);t.setAllocatedCartons(0);t.setDiscrepancyCartons(0);t.setVersion(1);return t; } + private static TaskRow readyToComplete(){ TaskRow t=task();t.setCountedCartons(2);return t; } private static LabelRow label(){LabelRow l=new LabelRow();l.setCartonLabelId(50L);l.setInboundDetailId(40L);l.setSkuCode("SKU-1");l.setCargoName("货物");l.setUnitsPerCarton(12);l.setInboundCode("IN-1");l.setBoxCode("BOX-1");return l;} private static PalletRow pallet(){PalletRow p=new PalletRow();p.setId(20L);p.setTaskId(10L);p.setTenantId(1L);p.setInboundId(30L);p.setWarehouseId(100L);p.setPalletNo("DP-260908-0001");p.setPalletStatus("OPEN");p.setVersion(1);return p;} private static CartonRow carton(){CartonRow c=new CartonRow();c.setId(60L);c.setTaskId(10L);c.setPalletId(20L);c.setPalletNo("DP-260908-0001");c.setInboundId(30L);c.setInboundDetailId(40L);c.setSkuCode("SKU-1");c.setCargoName("货物");c.setBoxCode("BOX-1");c.setExpectedSkuQuantity(12);c.setActualSkuQuantity(12);c.setDiscrepancyStatus("MATCH");return c;} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCartonLabelPrintServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCartonLabelPrintServiceTest.java index 50df8f106..86c5fff26 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCartonLabelPrintServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCartonLabelPrintServiceTest.java @@ -4,6 +4,8 @@ import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.client.PrintPdfClient; import com.baiyue.firstleg.dto.InboundCartonLabels; +import com.baiyue.firstleg.dto.DropshipPalletizationDtos.Pallet; +import com.baiyue.firstleg.dto.DropshipPalletizationDtos.SkuItem; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -35,6 +37,25 @@ class InboundCartonLabelPrintServiceTest { ReflectionTestUtils.setField(service, "internalToken", "internal-token"); } + @Test + void dropshipPalletLabelIsAvailableOnlyAfterSeal() { + Pallet open = dropshipPallet("OPEN"); + assertThatThrownBy(() -> service.generateDropshipPallet(open, "request-open")) + .isInstanceOf(BizException.class) + .hasMessageContaining("已完成"); + verifyNoInteractions(print); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_PDF); + when(print.generateInboundMarks(org.mockito.ArgumentMatchers.anyString(), + org.mockito.ArgumentMatchers.eq("request-sealed"), org.mockito.ArgumentMatchers.any())) + .thenReturn(new ResponseEntity<>(new byte[] {7, 8, 9}, headers, + org.springframework.http.HttpStatus.OK)); + + ResponseEntity response = service.generateDropshipPallet(dropshipPallet("SEALED"), "request-sealed"); + assertThat(response.getBody()).containsExactly(7, 8, 9); + } + @Test void forwardsStableLabelsAndOnlySafePdfHeaders() { when(labels.list(10001L, 70001L, List.of(1))).thenReturn(batch()); @@ -159,4 +180,11 @@ class InboundCartonLabelPrintServiceTest { "PCHK2608040174", "MCC1", "CTNS:19-1", "洛杉矶普船海卡"); return new InboundCartonLabels.Batch(70001L, 80001L, "PCHK2608040174", 19, List.of(label)); } + + private Pallet dropshipPallet(String status) { + SkuItem item = new SkuItem(70001L, "PCHK2608040174", 2L, + "SKU-1", "音箱", 1, 19, List.of("PCHK2608040174#19-1")); + return new Pallet(90001L, "DP-260911-0001", status, 1, 1, 0, + BigDecimal.ONE, BigDecimal.ONE, 1, List.of(item), List.of(), null); + } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java index 2f93adf23..10c5cfe43 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java @@ -337,9 +337,9 @@ class LoadingPickServiceTest { row.setAssignmentVersion(0); row.setItemCount(1); row.setPendingItemCount(0); - when(repository.pendingTasks(9L, null)).thenReturn(List.of(row)); + when(repository.pendingTasks(9L, 200001L, null)).thenReturn(List.of(row)); - var result = service.pendingTasks(9L, null).get(0); + var result = service.pendingTasks(9L, 200001L, null).get(0); assertThat(result.sourceType()).isEqualTo("SIMULATION"); assertThat(result.recordStatus()).isEqualTo("ACTIVE"); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/PdaWarehouseCatalogServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/PdaWarehouseCatalogServiceTest.java new file mode 100644 index 000000000..521bb9f12 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/PdaWarehouseCatalogServiceTest.java @@ -0,0 +1,39 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.client.WarehouseMasterDataClient; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class PdaWarehouseCatalogServiceTest { + @Test + void returnsDomesticWarehouseMasterDataWithoutHardcodedOptions() { + WarehouseMasterDataClient client = mock(WarehouseMasterDataClient.class); + PdaWarehouseCatalogService service = new PdaWarehouseCatalogService(); + ReflectionTestUtils.setField(service, "warehouses", client); + ReflectionTestUtils.setField(service, "internalToken", "token"); + var option = new WarehouseMasterDataClient.WarehouseOption(200001L, "CN-DONGGUAN-01", "东莞仓"); + when(client.domestic("token")).thenReturn(ApiResponse.success(List.of(option))); + + assertThat(service.activeDomesticWarehouses()).containsExactly(option); + } + + @Test + void failsClosedWhenWarehouseDirectoryIsUnavailable() { + WarehouseMasterDataClient client = mock(WarehouseMasterDataClient.class); + PdaWarehouseCatalogService service = new PdaWarehouseCatalogService(); + ReflectionTestUtils.setField(service, "warehouses", client); + ReflectionTestUtils.setField(service, "internalToken", "token"); + when(client.domestic("token")).thenThrow(new IllegalStateException("down")); + + assertThatThrownBy(service::activeDomesticWarehouses).isInstanceOf(BizException.class); + } +} diff --git a/iam/AGENTS.md b/iam/AGENTS.md index ce4dddaca..e8e8b4c46 100644 --- a/iam/AGENTS.md +++ b/iam/AGENTS.md @@ -12,7 +12,7 @@ | 目标 | 入口 | |---|---| -| 客户资料/审核与企业联系信息 | `controller/PlatformTenantController.java`、`service/TenantService.java`;公司联系电话/邮箱属于企业资料,不作为登录标识且不要求跨企业唯一;审核必填资质仅营业执照与统一社会信用代码,法人身份证选填 | +| 客户资料/审核与企业联系信息 | `controller/PlatformTenantController.java`、`service/TenantService.java`;企业客户编号由 `CustomerCodeRepository` 在创建事务内从 `00001` 起分配固定五位数字,平台技术主体仍为 `PLATFORM`;公司联系电话/邮箱属于企业资料,不作为登录标识且不要求跨企业唯一;审核必填资质仅营业执照与统一社会信用代码,法人身份证选填 | | 客户默认箱规与权重系数初始化、重试及补偿 | `service/CustomerDimensionRuleInitializationService.java`、`CustomerDimensionInitializationRelay.java`、`repository/CustomerDimensionInitializationRepository.java`、`client/CustomerDimensionRuleClient.java`、`model/CustomerDimensionInitializationTask.java`;持久任务异步调用 Firstleg `/internal/firstleg/v1/customer-dimension-configs/defaults`,尺寸配置和默认权重系数规则均返回成功后才标记 `READY` | | 商户成员、登录手机号与个人资料 | `controller/MerchantTenantController.java`、`service/MemberService.java`、`service/PersonalProfileService.java`、`client/AuthAccountClient.java`;成员手机号是可选登录标识,变更时同步 Auth、撤销该账号全部会话并审计,不与公司联系电话联动 | | 平台用户与海外 PDA 仓绑定 | `controller/PlatformUserManagementController.java`、`service/PlatformUserManagementService.java`、`dto/PlatformUserManagementDtos.java`、`entity/IamMembership.java`;`OVERSEAS_WAREHOUSE_PDA_OPERATOR` 必须且只能绑定一个 `overseasWarehouseId` | diff --git a/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java b/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java index 875d001a9..aca36a421 100644 --- a/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java +++ b/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java @@ -110,7 +110,7 @@ public final class TenantDtos { @Schema(description = "Review type / 审核类型", example = "REGISTER") String reviewType, @Schema(description = "Review status / 审核状态", example = "PENDING") String status, @Schema(description = "Customer name / 客户名称", example = "深圳虫虫出海科技有限公司") String tenantName, - @Schema(description = "Customer code / 客户编号", example = "TEN190000000000000002") String tenantCode, + @Schema(description = "Customer code / 五位客户编号", example = "00001") String tenantCode, @Schema(description = "Primary contact / 联系人", example = "张三") String contactName, @Schema(description = "Contact phone / 联系电话", example = "13800138000") String contactPhone, @Schema(description = "Contact email / 联系邮箱", example = "ops@example.com") String contactEmail, diff --git a/iam/src/main/java/com/baiyue/iam/repository/CustomerCodeRepository.java b/iam/src/main/java/com/baiyue/iam/repository/CustomerCodeRepository.java new file mode 100644 index 000000000..8cca4e4d9 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/repository/CustomerCodeRepository.java @@ -0,0 +1,51 @@ +package com.baiyue.iam.repository; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import jakarta.annotation.Resource; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Locale; + +/** 在客户创建事务内串行分配固定五位客户编号。 */ +@Repository +public class CustomerCodeRepository { + private static final String SEQUENCE_KEY = "CUSTOMER_CODE"; + private static final long MAX_CUSTOMER_CODE = 99_999L; + + @Resource + private JdbcTemplate jdbc; + + @Transactional(propagation = Propagation.MANDATORY) + public String nextCode() { + jdbc.update("INSERT IGNORE INTO iam_number_sequence(sequence_key,next_value) VALUES (?,1)", + SEQUENCE_KEY); + Long value = jdbc.queryForObject( + "SELECT next_value FROM iam_number_sequence WHERE sequence_key=? FOR UPDATE", + Long.class, SEQUENCE_KEY); + if (value == null) { + throw new BizException(ResultCode.INTERNAL_ERROR, "客户编号序列状态异常"); + } + String code = format(value); + int updated = jdbc.update( + "UPDATE iam_number_sequence SET next_value=next_value+1 WHERE sequence_key=? AND next_value=?", + SEQUENCE_KEY, value); + if (updated != 1) { + throw new BizException(ResultCode.CONFLICT, "客户编号分配冲突,请重试"); + } + return code; + } + + static String format(long value) { + if (value < 1) { + throw new BizException(ResultCode.INTERNAL_ERROR, "客户编号序列状态异常"); + } + if (value > MAX_CUSTOMER_CODE) { + throw new BizException(ResultCode.CONFLICT, "五位客户编号已用完"); + } + return String.format(Locale.ROOT, "%05d", value); + } +} diff --git a/iam/src/main/java/com/baiyue/iam/service/MerchantOnboardingService.java b/iam/src/main/java/com/baiyue/iam/service/MerchantOnboardingService.java index a38f0b668..7073f3fd9 100644 --- a/iam/src/main/java/com/baiyue/iam/service/MerchantOnboardingService.java +++ b/iam/src/main/java/com/baiyue/iam/service/MerchantOnboardingService.java @@ -20,6 +20,7 @@ import com.baiyue.iam.mapper.IamTenantMapper; import com.baiyue.iam.mapper.IamTenantQualificationMapper; import com.baiyue.iam.mapper.IamTenantReviewMapper; import com.baiyue.iam.mapper.IamUserMapper; +import com.baiyue.iam.repository.CustomerCodeRepository; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.toolkit.IdWorker; import jakarta.annotation.Resource; @@ -43,6 +44,7 @@ public class MerchantOnboardingService { @Resource private CustomerDimensionRuleInitializationService dimensionRuleInitialization; @Resource private OverseasStorageFeeInitializationService overseasStorageFeeInitialization; @Resource private SecurityEventOutboxService securityEvents; + @Resource private CustomerCodeRepository customerCodes; @Transactional public MerchantRegistrationResponse register(MerchantRegistrationRequest request) { @@ -66,7 +68,7 @@ public class MerchantOnboardingService { IamTenant tenant = new IamTenant(); tenant.setId(tenantId); - tenant.setTenantCode("TEN" + tenantId); + tenant.setTenantCode(customerCodes.nextCode()); tenant.setTenantName(request.companyName()); tenant.setTenantType("MERCHANT"); tenant.setStatus("PROFILE_REQUIRED"); diff --git a/iam/src/main/java/com/baiyue/iam/service/TenantService.java b/iam/src/main/java/com/baiyue/iam/service/TenantService.java index 50221060e..b46d9f380 100644 --- a/iam/src/main/java/com/baiyue/iam/service/TenantService.java +++ b/iam/src/main/java/com/baiyue/iam/service/TenantService.java @@ -11,6 +11,7 @@ import com.baiyue.iam.entity.IamTenant; import com.baiyue.iam.entity.IamTenantQualification; import com.baiyue.iam.entity.IamTenantReview; import com.baiyue.iam.repository.TenantRepository; +import com.baiyue.iam.repository.CustomerCodeRepository; import com.baiyue.iam.repository.ProfileResourceRepository; import com.baiyue.iam.client.AuthAccountClient; import com.baiyue.iam.repository.MemberRepository; @@ -56,6 +57,8 @@ public class TenantService { @Resource private TenantRepository repository; @Resource + private CustomerCodeRepository customerCodes; + @Resource private ProfileResourceRepository profileRepository; @Resource private AuthAccountClient authClient; @@ -165,7 +168,7 @@ public class TenantService { ensureAdministratorPhoneAvailable(contactPhone); IamTenant tenant = new IamTenant(); tenant.setId(IdWorker.getId()); - tenant.setTenantCode("TEN" + tenant.getId()); + tenant.setTenantCode(customerCodes.nextCode()); tenant.setTenantName(request.tenantName()); tenant.setTenantType("MERCHANT"); tenant.setStatus("PROFILE_REQUIRED"); diff --git a/iam/src/main/resources/db/iam.sql b/iam/src/main/resources/db/iam.sql index 4b4dd4f59..1916a76b4 100644 --- a/iam/src/main/resources/db/iam.sql +++ b/iam/src/main/resources/db/iam.sql @@ -7,7 +7,7 @@ USE baiyue_iam; CREATE TABLE IF NOT EXISTS iam_tenant ( id BIGINT NOT NULL COMMENT '客户主档ID(雪花ID);0保留为平台技术作用域', - tenant_code VARCHAR(64) NOT NULL COMMENT '全局唯一客户编码', + tenant_code VARCHAR(8) NOT NULL COMMENT '客户编号:企业客户固定五位数字,平台技术主体为PLATFORM', tenant_name VARCHAR(128) NOT NULL COMMENT '客户企业名称', tenant_type VARCHAR(20) NOT NULL DEFAULT 'MERCHANT' COMMENT '主体类型:MERCHANT企业客户', status VARCHAR(20) NOT NULL DEFAULT 'PROFILE_REQUIRED' COMMENT '客户状态:PROFILE_REQUIRED待完善资料、PENDING待审核、ACTIVE正常、REJECTED已驳回、DISABLED已停用、ARCHIVED已归档', @@ -43,6 +43,15 @@ CREATE TABLE IF NOT EXISTS iam_tenant ( KEY idx_iam_tenant_status (status, created_at) ) ENGINE=InnoDB COMMENT='客户企业主档表:保存企业资料、审核结果和启停状态;ID同时作为客户数据隔离键'; +CREATE TABLE IF NOT EXISTS iam_number_sequence ( + sequence_key VARCHAR(64) NOT NULL COMMENT '业务编号类型', + next_value BIGINT UNSIGNED NOT NULL COMMENT '下一待分配数字', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY (sequence_key) +) ENGINE=InnoDB COMMENT='IAM业务编号序列表'; + +INSERT IGNORE INTO iam_number_sequence(sequence_key, next_value) VALUES ('CUSTOMER_CODE', 1); + CREATE TABLE IF NOT EXISTS iam_customer_dimension_initialization_task ( tenant_id BIGINT NOT NULL COMMENT '客户ID及任务聚合ID', operator_id BIGINT NOT NULL COMMENT '触发初始化的操作人ID;系统补偿为0', diff --git a/iam/src/main/resources/openapi/iam-v1.yaml b/iam/src/main/resources/openapi/iam-v1.yaml index 9d5da3f64..8abc9a16e 100644 --- a/iam/src/main/resources/openapi/iam-v1.yaml +++ b/iam/src/main/resources/openapi/iam-v1.yaml @@ -1113,7 +1113,7 @@ components: reviewType: {type: string, example: REGISTER} status: {type: string, enum: [PENDING]} tenantName: {type: string, description: 客户名称} - tenantCode: {type: string, description: 客户编号} + tenantCode: {type: string, pattern: '^\d{5}$', example: '00001', description: 五位客户编号} contactName: {type: string, description: 联系人} contactPhone: {type: string, nullable: true, description: 联系电话} contactEmail: {type: string, format: email, nullable: true, description: 联系邮箱} @@ -1199,7 +1199,7 @@ components: required: [id, tenantCode, tenantName, status, dimensionInitializationStatus, servicePackage] properties: id: {type: string, pattern: '^\d+$', example: '190000000000000001'} - tenantCode: {type: string} + tenantCode: {type: string, pattern: '^\d{5}$', example: '00001'} tenantName: {type: string} status: {type: string, enum: [PROFILE_REQUIRED, PENDING, ACTIVE, REJECTED, DISABLED, ARCHIVED]} dimensionInitializationStatus: {type: string, enum: [PENDING, READY, FAILED], description: 客户默认尺寸初始化状态;未 READY 时依赖该配置的业务失败关闭} @@ -1237,7 +1237,7 @@ components: required: [id, tenantCode, tenantName, status] properties: id: {type: string, pattern: '^\d+$'} - tenantCode: {type: string} + tenantCode: {type: string, pattern: '^\d{5}$', example: '00001'} tenantName: {type: string} status: {type: string, enum: [ACTIVE, DISABLED]} contactName: {type: string, nullable: true} diff --git a/iam/src/test/java/com/baiyue/iam/repository/CustomerCodeRepositoryTest.java b/iam/src/test/java/com/baiyue/iam/repository/CustomerCodeRepositoryTest.java new file mode 100644 index 000000000..9b7f0ab7d --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/repository/CustomerCodeRepositoryTest.java @@ -0,0 +1,23 @@ +package com.baiyue.iam.repository; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class CustomerCodeRepositoryTest { + @Test + void allocatesFiveDigitCodeFromOne() { + assertEquals("00001", CustomerCodeRepository.format(1)); + assertEquals("99999", CustomerCodeRepository.format(99_999)); + } + + @Test + void rejectsAllocationAfterFiveDigitCapacityIsExhausted() { + BizException failure = assertThrows(BizException.class, + () -> CustomerCodeRepository.format(100_000)); + + assertEquals(ResultCode.CONFLICT, failure.getResultCode()); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/repository/CustomerCodeSchemaContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/CustomerCodeSchemaContractTest.java new file mode 100644 index 000000000..27e88c17d --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/repository/CustomerCodeSchemaContractTest.java @@ -0,0 +1,34 @@ +package com.baiyue.iam.repository; + +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class CustomerCodeSchemaContractTest { + @Test + void baselineAndUnifiedSqlInitializeCustomerCodeSequenceAtOne() throws Exception { + String baseline = new ClassPathResource("db/iam.sql") + .getContentAsString(StandardCharsets.UTF_8); + Path unifiedPath = Path.of("deploy", "sql", "头程数据库统一增量-MySQL8.sql"); + if (!Files.exists(unifiedPath)) { + unifiedPath = Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"); + } + String unified = Files.readString(unifiedPath, StandardCharsets.UTF_8); + + assertSequenceDefinition(baseline); + assertSequenceDefinition(unified); + } + + private static void assertSequenceDefinition(String sql) { + assertThat(sql) + .contains("tenant_code VARCHAR(8) NOT NULL") + .contains("CREATE TABLE IF NOT EXISTS iam_number_sequence") + .contains("PRIMARY KEY (sequence_key)") + .contains("INSERT IGNORE INTO iam_number_sequence(sequence_key, next_value) VALUES ('CUSTOMER_CODE', 1)"); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/service/CustomerListExportProviderServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/CustomerListExportProviderServiceTest.java index 92f5639c6..68a289288 100644 --- a/iam/src/test/java/com/baiyue/iam/service/CustomerListExportProviderServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/CustomerListExportProviderServiceTest.java @@ -140,7 +140,7 @@ class CustomerListExportProviderServiceTest { private IamTenant tenant(long id) { IamTenant tenant = new IamTenant(); tenant.setId(id); - tenant.setTenantCode("TEN" + id); + tenant.setTenantCode(String.format("%05d", id)); tenant.setTenantName("柏悦物流" + id); tenant.setLegalName("柏悦物流有限公司"); tenant.setUnifiedSocialCreditCode("91440000123456789X"); diff --git a/iam/src/test/java/com/baiyue/iam/service/MerchantOnboardingServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/MerchantOnboardingServiceTest.java index 2b48686ef..7e4501eb1 100644 --- a/iam/src/test/java/com/baiyue/iam/service/MerchantOnboardingServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/MerchantOnboardingServiceTest.java @@ -14,7 +14,9 @@ import com.baiyue.iam.mapper.IamTenantMapper; import com.baiyue.iam.mapper.IamTenantQualificationMapper; import com.baiyue.iam.mapper.IamTenantReviewMapper; import com.baiyue.iam.mapper.IamUserMapper; +import com.baiyue.iam.repository.CustomerCodeRepository; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.BeforeEach; import org.mockito.ArgumentCaptor; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; @@ -42,7 +44,14 @@ class MerchantOnboardingServiceTest { @MockBean private CustomerDimensionRuleInitializationService dimensionRuleInitialization; @MockBean private OverseasStorageFeeInitializationService overseasStorageFeeInitialization; @MockBean private SecurityEventOutboxService securityEvents; + @MockBean private CustomerCodeRepository customerCodes; @Resource private MerchantOnboardingService service; + + @BeforeEach + void allocateCustomerCode() { + when(customerCodes.nextCode()).thenReturn("00001"); + } + @Test void clientRegistrationCreatesOwnerAndAssignsGlobalMerchantOwnerRole() { @@ -76,6 +85,7 @@ class MerchantOnboardingServiceTest { assertEquals("PROFILE_REQUIRED", result.tenantStatus()); assertEquals("PENDING", result.dimensionInitializationStatus()); assertEquals("PENDING", tenant.getValue().getDimensionInitializationStatus()); + assertEquals("00001", tenant.getValue().getTenantCode()); assertEquals(result.tenantId(), tenant.getValue().getId()); assertEquals("测试企业", tenant.getValue().getLegalName()); assertEquals("张三", tenant.getValue().getLegalRepresentative()); diff --git a/iam/src/test/java/com/baiyue/iam/service/TenantCustomerExportTest.java b/iam/src/test/java/com/baiyue/iam/service/TenantCustomerExportTest.java index 6ff844faa..aa1f23fd3 100644 --- a/iam/src/test/java/com/baiyue/iam/service/TenantCustomerExportTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/TenantCustomerExportTest.java @@ -5,6 +5,7 @@ import com.baiyue.iam.entity.IamTenant; import com.baiyue.iam.repository.ProfileResourceRepository; import com.baiyue.iam.repository.MemberRepository; import com.baiyue.iam.repository.TenantRepository; +import com.baiyue.iam.repository.CustomerCodeRepository; import org.junit.jupiter.api.Test; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; @@ -21,6 +22,7 @@ import static org.mockito.Mockito.when; @SpringJUnitConfig(TenantService.class) class TenantCustomerExportTest { @MockBean private TenantRepository repository; + @MockBean private CustomerCodeRepository customerCodes; @MockBean private ProfileResourceRepository profileRepository; @MockBean private MemberRepository memberRepository; @MockBean private AuthAccountClient authAccountClient; @@ -34,7 +36,7 @@ class TenantCustomerExportTest { void returnsUnmaskedCustomerRowsAndAdvancingCursor() { IamTenant tenant = new IamTenant(); tenant.setId(101L); - tenant.setTenantCode("TEN101"); + tenant.setTenantCode("00101"); tenant.setTenantName("柏悦物流"); tenant.setContactPhone("13800138000"); tenant.setContactEmail("ops@example.com"); diff --git a/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java index e25b98c87..ce6928eb8 100644 --- a/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java @@ -14,6 +14,7 @@ import com.baiyue.iam.dto.TenantDtos.ResetCustomerOwnerPasswordRequest; import com.baiyue.iam.dto.TenantDtos.CustomerImpersonationRequest; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.iam.repository.TenantRepository; +import com.baiyue.iam.repository.CustomerCodeRepository; import com.baiyue.iam.repository.ProfileResourceRepository; import com.baiyue.iam.repository.MemberRepository; import com.baiyue.iam.mapper.IamRoleMapper; @@ -51,6 +52,7 @@ import com.baiyue.common.core.api.ResultCode; @TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token") class TenantServiceTest { @MockBean private TenantRepository repository; + @MockBean private CustomerCodeRepository customerCodes; @MockBean private ProfileResourceRepository profileRepository; @MockBean private MemberRepository members; @MockBean private IamRoleMapper roles; @@ -63,6 +65,7 @@ class TenantServiceTest { @BeforeEach void allowUnusedAdministratorPhone() { + when(customerCodes.nextCode()).thenReturn("00001"); when(authClient.availability(eq("internal-token"), any())) .thenReturn(com.baiyue.common.core.api.ApiResponse.success(true)); } @@ -72,7 +75,7 @@ class TenantServiceTest { IamTenant tenant = new IamTenant(); tenant.setId(10001L); - tenant.setTenantCode("TEN10001"); + tenant.setTenantCode("10001"); tenant.setTenantName("Example Merchant"); tenant.setStatus("ACTIVE"); tenant.setLegalName("Example Merchant Ltd"); @@ -91,7 +94,7 @@ class TenantServiceTest { var result = service.pageMerchants(1, 10, "Example", "ACTIVE"); assertEquals(1, result.total()); - assertEquals("TEN10001", result.records().get(0).tenantCode()); + assertEquals("10001", result.records().get(0).tenantCode()); assertEquals("Alice", result.records().get(0).contactName()); assertEquals("Sales Zhang", result.records().get(0).salesOwnerName()); assertEquals(30, result.records().get(0).paymentTermDays()); @@ -108,7 +111,7 @@ class TenantServiceTest { review.setReviewType("REGISTER"); review.setCreatedAt(LocalDateTime.of(2026, 8, 17, 10, 27)); IamTenant tenant = merchant(10001L, "PENDING"); - tenant.setTenantCode("TEN10001"); + tenant.setTenantCode("10001"); tenant.setTenantName("Example Merchant"); tenant.setContactName("Alice"); tenant.setContactPhone("13800138000"); @@ -124,7 +127,7 @@ class TenantServiceTest { assertEquals("REGISTER", result.get(0).reviewType()); assertEquals("PENDING", result.get(0).status()); assertEquals("Example Merchant", result.get(0).tenantName()); - assertEquals("TEN10001", result.get(0).tenantCode()); + assertEquals("10001", result.get(0).tenantCode()); assertEquals("Alice", result.get(0).contactName()); assertEquals("13800138000", result.get(0).contactPhone()); assertEquals("alice@example.com", result.get(0).contactEmail()); @@ -136,7 +139,7 @@ class TenantServiceTest { @Test void pageFinanceCustomersKeepsFullContactSummaryForInternalFinance() { IamTenant tenant = merchant(10001L, "ACTIVE"); - tenant.setTenantCode("TEN10001"); + tenant.setTenantCode("10001"); tenant.setTenantName("Example Merchant"); tenant.setContactName("Alice"); tenant.setContactPhone("13800138000"); @@ -145,7 +148,7 @@ class TenantServiceTest { var result = service.pageFinanceCustomers(1, 10, "Alice", "ACTIVE", null); - assertEquals("TEN10001", result.records().get(0).tenantCode()); + assertEquals("10001", result.records().get(0).tenantCode()); assertEquals("Alice", result.records().get(0).contactName()); assertEquals("13800138000", result.records().get(0).contactPhone()); verify(repository).pageFinanceCustomers(1, 10, "Alice", "ACTIVE", null); @@ -320,6 +323,7 @@ class TenantServiceTest { 9001L); assertEquals("PROFILE_REQUIRED", created.tenant().status()); + assertEquals("00001", created.tenant().tenantCode()); assertEquals("PENDING", created.tenant().dimensionInitializationStatus()); assertEquals("Bob", created.tenant().legalRepresentative()); assertEquals("基础版", created.tenant().servicePackage()); @@ -515,7 +519,7 @@ class TenantServiceTest { private IamTenant merchant(long id, String status) { IamTenant tenant = new IamTenant(); tenant.setId(id); - tenant.setTenantCode("TEN" + id); + tenant.setTenantCode(String.format("%05d", id)); tenant.setTenantType("MERCHANT"); tenant.setStatus(status); tenant.setDimensionInitializationStatus("READY"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/client/DomesticWarehouseDirectoryClient.java b/lastleg-service/src/main/java/com/baiyue/lastleg/client/DomesticWarehouseDirectoryClient.java index 2f3b48a3c..048206462 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/client/DomesticWarehouseDirectoryClient.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/client/DomesticWarehouseDirectoryClient.java @@ -22,5 +22,6 @@ public interface DomesticWarehouseDirectoryClient { record Warehouse(Long id, Long tenantId, String warehouseCode, String warehouseName, String warehouseType, String countryCode, String status) { } - record Address(String stateProvince) { } + record Address(String addressType, String countryCode, String stateProvince, String city, + String district, String postalCode, String addressLine1, String addressLine2) { } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintService.java index e3c92791e..537bb0c66 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintService.java @@ -2,6 +2,7 @@ package com.baiyue.lastleg.dispatch.service; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; +import com.baiyue.lastleg.client.DomesticWarehouseDirectoryClient; import com.baiyue.lastleg.client.PrintPdfClient; import com.baiyue.lastleg.dispatch.dto.DispatchResponse; import feign.FeignException; @@ -13,6 +14,7 @@ import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.Objects; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; @@ -27,6 +29,7 @@ public class DispatchBolPrintService { @Resource private DispatchService dispatches; @Resource private PrintPdfClient print; + @Resource private DomesticWarehouseDirectoryClient warehouses; @Value("${baiyue.security.internal-service-token:}") private String internalToken; public GeneratedPdf generate(String dispatchNo, String requestId) { @@ -52,6 +55,7 @@ public class DispatchBolPrintService { } private List documents(DispatchResponse detail) { + String shipFromAddress = warehouseAddress(detail.overseasWarehouseId()); Map> groups = new LinkedHashMap<>(); for (DispatchResponse.BolLine line : detail.bolLines()) groups.computeIfAbsent(text(line.fbaWarehouseCode()), ignored -> new ArrayList<>()).add(line); List documents = new ArrayList<>(); @@ -67,7 +71,7 @@ public class DispatchBolPrintService { List rows = lines.subList(offset, end).stream() .map(line -> new PrintPdfClient.CargoRow(text(line.containerNo()), text(line.palletNo()), text(line.fbaWarehouseCode()), text(line.warehouseNo()), text(line.fbaShipmentId()), text(line.poReferenceNo()), line.packageCount())).toList(); - documents.add(new PrintPdfClient.Document(text(detail.overseasWarehouseName()), "—", text(detail.carrierName()), + documents.add(new PrintPdfClient.Document(text(detail.overseasWarehouseName()), shipFromAddress, text(detail.carrierName()), text(detail.fbaAppointmentNo()), dateTime(detail.estimatedArrivalAt()), text(detail.vehiclePlate()), "—", group.getKey(), destination, "—", text(detail.dispatchNo()), unique(lines, true), unique(lines, false), total, page + 1, pages, rows)); @@ -77,6 +81,41 @@ public class DispatchBolPrintService { return List.copyOf(documents); } + private String warehouseAddress(String warehouseId) { + long id; + try { + id = Long.parseLong(warehouseId == null ? "" : warehouseId.strip()); + } catch (NumberFormatException exception) { + throw new BizException(ResultCode.VALIDATION_FAILED, "排车单未关联有效的海外仓"); + } + try { + var response = warehouses.activeWarehouse(id, internalToken); + if (response == null || !response.successful() || response.data() == null) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "仓库主数据暂不可用,无法生成BOL"); + } + List addresses = response.data().addresses(); + if (addresses == null || addresses.isEmpty()) return "—"; + DomesticWarehouseDirectoryClient.Address address = addresses.stream().filter(Objects::nonNull) + .filter(value -> "OPERATING".equals(value.addressType())).findFirst() + .orElseGet(() -> addresses.stream().filter(Objects::nonNull).findFirst().orElse(null)); + if (address == null) return "—"; + List parts = new ArrayList<>(); + add(parts, address.addressLine1()); + add(parts, address.addressLine2()); + add(parts, address.city()); + add(parts, address.stateProvince()); + add(parts, address.postalCode()); + add(parts, address.countryCode()); + return parts.isEmpty() ? "—" : String.join(", ", parts); + } catch (FeignException exception) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "仓库主数据暂不可用,无法生成BOL"); + } + } + + private void add(List parts, String value) { + if (value != null && !value.isBlank()) parts.add(value.strip()); + } + static int pageCount(int rowCount) { if (rowCount <= FIRST_PAGE_ROWS) return 1; return 1 + (rowCount - FIRST_PAGE_ROWS + CONTINUATION_PAGE_ROWS - 1) / CONTINUATION_PAGE_ROWS; diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/client/DomesticWarehouseDirectoryClientContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/client/DomesticWarehouseDirectoryClientContractTest.java index cf96c781e..f9573422f 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/client/DomesticWarehouseDirectoryClientContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/client/DomesticWarehouseDirectoryClientContractTest.java @@ -33,6 +33,7 @@ class DomesticWarehouseDirectoryClientContractTest { "warehouseType:String", "countryCode:String", "status:String"); assertThat(Arrays.stream(DomesticWarehouseDirectoryClient.Address.class.getRecordComponents()) .map(value -> value.getName() + ":" + value.getType().getSimpleName())) - .containsExactly("stateProvince:String"); + .containsExactly("addressType:String", "countryCode:String", "stateProvince:String", "city:String", + "district:String", "postalCode:String", "addressLine1:String", "addressLine2:String"); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintServiceTest.java index ff6bad8f5..0a297cbc0 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintServiceTest.java @@ -1,10 +1,41 @@ package com.baiyue.lastleg.dispatch.service; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.lastleg.client.DomesticWarehouseDirectoryClient; +import com.baiyue.lastleg.client.PrintPdfClient; +import com.baiyue.lastleg.dispatch.dto.DispatchResponse; +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.test.util.ReflectionTestUtils; +@ExtendWith(MockitoExtension.class) class DispatchBolPrintServiceTest { + @Mock private DispatchService dispatches; + @Mock private PrintPdfClient print; + @Mock private DomesticWarehouseDirectoryClient warehouses; + @InjectMocks private DispatchBolPrintService service; + + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(service, "internalToken", "internal-test-token"); + } + @Test void usesDenseFirstAndContinuationPageCapacities() { assertThat(DispatchBolPrintService.pageCount(1)).isEqualTo(1); @@ -15,4 +46,33 @@ class DispatchBolPrintServiceTest { assertThat(DispatchBolPrintService.pageCount(70)).isEqualTo(3); assertThat(DispatchBolPrintService.pageCount(71)).isEqualTo(4); } + + @Test + void resolvesTheOperatingWarehouseAddressIntoTheBolRequest() { + String warehouseId = "193000000000200003"; + DispatchResponse detail = new DispatchResponse(1L, "DS-WH-20260911-624449", "DEV-23653906", + null, null, "五月开发验收车队", null, null, warehouseId, "洛杉矶仓", null, + null, null, null, LocalDateTime.of(2026, 9, 12, 2, 40, 20), "PENDING_DEPARTURE", + 1, 1, 1, 0, 0, 1, null, LocalDateTime.of(2026, 9, 11, 10, 0), List.of(), + List.of(new DispatchResponse.BolLine("TSTU3539519", "DP-260911-0027", "ABE2", "ABE2", + "NYEXX2609110214", null, null, 1))); + var address = new DomesticWarehouseDirectoryClient.Address("OPERATING", "US", "CA", "Los Angeles", + null, "90001", "Los Angeles warehouse generic address pending maintenance", null); + var warehouse = new DomesticWarehouseDirectoryClient.Warehouse(Long.parseLong(warehouseId), 0L, + "US-LAX", "洛杉矶仓", "OVERSEAS", "US", "ACTIVE"); + when(dispatches.platformDetail(detail.dispatchNo())).thenReturn(detail); + when(warehouses.activeWarehouse(Long.parseLong(warehouseId), "internal-test-token")) + .thenReturn(ApiResponse.success(new DomesticWarehouseDirectoryClient.Detail(warehouse, List.of(address)))); + when(print.generateDispatchBol(eq("internal-test-token"), eq("request-1"), any())) + .thenReturn(ResponseEntity.ok().contentType(MediaType.APPLICATION_PDF) + .header("X-Print-Page-Count", "1").body("%PDF-".getBytes(StandardCharsets.US_ASCII))); + + service.generate(detail.dispatchNo(), "request-1"); + + ArgumentCaptor request = ArgumentCaptor.forClass(PrintPdfClient.Request.class); + verify(print).generateDispatchBol(eq("internal-test-token"), eq("request-1"), request.capture()); + assertThat(request.getValue().documents()).singleElement().satisfies(document -> + assertThat(document.shipFromAddress()).isEqualTo( + "Los Angeles warehouse generic address pending maintenance, Los Angeles, CA, 90001, US")); + } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/FbaWarehouseValidatorTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/FbaWarehouseValidatorTest.java index ed50bf734..1d27139c8 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/FbaWarehouseValidatorTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/FbaWarehouseValidatorTest.java @@ -91,7 +91,8 @@ class FbaWarehouseValidatorTest { return new DomesticWarehouseDirectoryClient.Detail( new DomesticWarehouseDirectoryClient.Warehouse(193000000000000001L, 0L, code, name, type, "US", status), - List.of(new DomesticWarehouseDirectoryClient.Address("CA"))); + List.of(new DomesticWarehouseDirectoryClient.Address("OPERATING", "US", "CA", "Los Angeles", + null, "90001", "123 Logistics Ave", null))); } private void assertInvalid(org.assertj.core.api.ThrowableAssert.ThrowingCallable action) { diff --git a/print/src/main/java/com/baiyue/print/renderer/DispatchBolRenderer.java b/print/src/main/java/com/baiyue/print/renderer/DispatchBolRenderer.java index 772fff41f..6067b1a4b 100644 --- a/print/src/main/java/com/baiyue/print/renderer/DispatchBolRenderer.java +++ b/print/src/main/java/com/baiyue/print/renderer/DispatchBolRenderer.java @@ -61,7 +61,8 @@ public class DispatchBolRenderer { line(s,u,105,top,105,bottom); line(s,u,left,50,right,50); blackHeader(s,u,f,"SHIP FROM",left,top,105,27); blackHeader(s,u,f,"CARRIER",105,top,right,27); blackHeader(s,u,f,"SHIP TO",left,50,105,56); blackHeader(s,u,f,"REFERENCE",105,50,right,56); - text(s,u,f,d.shipFromName(),9.5f,left+2,33); text(s,u,f,"Address: " + d.shipFromAddress(),7.5f,left+2,40); + text(s,u,f,d.shipFromName(),9.5f,left+2,33); + wrappedText(s,u,f,"Address: " + d.shipFromAddress(),7.2f,left+2,40,91,2,4.5); text(s,u,f,"Carrier Name: " + d.carrierName(),7.5f,107,32); text(s,u,f,"ISA #: " + d.isaNumber(),7.5f,107,36.5); text(s,u,f,"Appointment time: " + d.appointmentTime(),7.5f,107,41); text(s,u,f,"Trailer #: " + d.trailerNumber(),7.5f,107,45.5); text(s,u,f,"Seal #: " + d.sealNumber(),7.5f,107,49); @@ -132,6 +133,21 @@ public class DispatchBolRenderer { private void fill(PDPageContentStream s,PdfUnits u,Color color,double x1,double y1,double x2,double y2)throws IOException{s.setNonStrokingColor(color);s.addRect(PdfUnits.mmToPt(x1),u.topDownMmToPdfY(y2),PdfUnits.mmToPt(x2-x1),PdfUnits.mmToPt(y2-y1));s.fill();} private void signature(PDPageContentStream s,PdfUnits u,PDFont f,double x,double y,String label)throws IOException{line(s,u,x,y,x+52,y);centeredCell(s,u,f,label,6.2f,x,x+52,y+8);} private void centered(PDPageContentStream s,PdfUnits u,PDFont f,String value,float size,double centerX,double y)throws IOException{float w=f.getStringWidth(value)/1000*size;text(s,u,f,value,size,centerX-w/PdfUnits.mmToPt(1)/2,y);} + private void wrappedText(PDPageContentStream s,PdfUnits u,PDFont f,String value,float size,double x,double y,double width,int maxLines,double lineHeight)throws IOException{ + String remaining=value==null?"—":value.strip(); + for(int lineIndex=0;lineIndex0&&f.getStringWidth(value.substring(0,end))/1000*size>max)end--; + int space=value.lastIndexOf(' ',end);return value.substring(0,space>0?space:end).stripTrailing(); + } private void centeredLines(PDPageContentStream s,PdfUnits u,PDFont f,String[] values,float size,double x1,double x2,double y)throws IOException{for(int i=0;i> domesticWarehouses( + @RequestHeader(value="X-Internal-Service-Token", required=false) String token) { + verifyToken(token); + return ApiResponse.success(service.activeDomesticWarehouses()); + } + @GetMapping("/shared-overseas") public ApiResponse> sharedOverseasWarehouses( @RequestHeader(value="X-Internal-Service-Token", required=false) String token) { diff --git a/warehouse/src/main/java/com/baiyue/warehouse/dto/DomesticWarehouseOptionResponse.java b/warehouse/src/main/java/com/baiyue/warehouse/dto/DomesticWarehouseOptionResponse.java new file mode 100644 index 000000000..88aa7b2dd --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/dto/DomesticWarehouseOptionResponse.java @@ -0,0 +1,11 @@ +package com.baiyue.warehouse.dto; + +import com.baiyue.warehouse.entity.WarehouseEntity; + +/** 国内 PDA 可选择的启用仓库主档。 */ +public record DomesticWarehouseOptionResponse(Long id, String warehouseCode, String warehouseName) { + public static DomesticWarehouseOptionResponse of(WarehouseEntity warehouse) { + return new DomesticWarehouseOptionResponse( + warehouse.getId(), warehouse.getWarehouseCode(), warehouse.getWarehouseName()); + } +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseApplicationService.java b/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseApplicationService.java index d6abc3986..0a3dd7d29 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseApplicationService.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseApplicationService.java @@ -6,6 +6,7 @@ import com.baiyue.common.core.api.PageResult; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.warehouse.dto.WarehouseDetailResponse; +import com.baiyue.warehouse.dto.DomesticWarehouseOptionResponse; import com.baiyue.warehouse.dto.WarehouseRequests; import com.baiyue.warehouse.dto.OverseasPartnerWarehouseResponse; import com.baiyue.warehouse.dto.WarehouseSummaryResponse; @@ -96,6 +97,14 @@ public class WarehouseApplicationService { .toList(); } + public List activeDomesticWarehouses() { + return repository.findActiveByType(WarehouseSaveNormalizer.PUBLIC_TENANT_ID, + WarehouseSaveNormalizer.TYPE_DOMESTIC) + .stream() + .map(DomesticWarehouseOptionResponse::of) + .toList(); + } + public List activeSharedOverseasWarehouses() { return repository.findActiveByType(WarehouseSaveNormalizer.PUBLIC_TENANT_ID, WarehouseSaveNormalizer.TYPE_OVERSEAS) diff --git a/warehouse/src/test/java/com/baiyue/warehouse/controller/InternalWarehouseControllerTest.java b/warehouse/src/test/java/com/baiyue/warehouse/controller/InternalWarehouseControllerTest.java index f246fcd69..5aa71a2b7 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/controller/InternalWarehouseControllerTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/controller/InternalWarehouseControllerTest.java @@ -3,6 +3,7 @@ package com.baiyue.warehouse.controller; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.warehouse.dto.WarehouseDetailResponse; +import com.baiyue.warehouse.dto.DomesticWarehouseOptionResponse; import com.baiyue.warehouse.dto.OverseasPartnerWarehouseResponse; import com.baiyue.warehouse.dto.SharedOverseasWarehouseResponse; import com.baiyue.warehouse.entity.WarehouseEntity; @@ -85,6 +86,17 @@ class InternalWarehouseControllerTest { verify(service).activeOverseasPartners(); } + @Test + void listsActiveDomesticWarehouseOptionsForInternalCallers() { + var option = new DomesticWarehouseOptionResponse(200001L, "CN-DONGGUAN-01", "东莞仓"); + when(service.activeDomesticWarehouses()).thenReturn(List.of(option)); + + var response = controller.domesticWarehouses(INTERNAL_TOKEN); + + assertThat(response.data()).containsExactly(option); + verify(service).activeDomesticWarehouses(); + } + @Test void exposesSharedOverseasReceivingDirectoryOnlyWithInternalToken() { var warehouse = new SharedOverseasWarehouseResponse(200003L, "US-LAX-01",