Merge remote-tracking branch 'origin/test' into dev

# Conflicts:
#	AGENTS.md
#	firstleg/AGENTS.md
#	scripts/agents-index-contract.ps1
This commit is contained in:
2026-09-03 18:22:05 +08:00
9 changed files with 367 additions and 83 deletions
+6 -5
View File
@@ -22,17 +22,18 @@
| 客户档案、成员、审核、RBAC、授权快照 | `iam/` | `iam/AGENTS.md` |
| 客户建档默认尺寸初始化与补偿 | `iam/`、`firstleg/` | IAM `CustomerDimensionRuleInitializationService` 持久化意图、`CustomerDimensionInitializationRelay` 后台投递;Firstleg 内部入口 `/internal/firstleg/v1/customer-dimension-configs/defaults`,未 `READY` 不得审核通过 |
| 消息中心、站内通知、消息模板、收件箱 | `iam/` | `iam/AGENTS.md`,再按 `src/main/java/com/baiyue/iam/notification/` 定位;业务事件生产者回到所属服务 |
| 订单、货物、快照、状态、幂等、Outbox | `order/` | `order/AGENTS.md`;统一列表仍固定显示 `PENDING_REVIEW` 并提供审核动作:Order 为 `SUBMITTED` 时必须优先返回 `PENDING_REVIEW/APPROVE_ORDER`,不得被 Firstleg 准备中状态覆盖 |
| 订单、货物、快照、状态、幂等、Outbox | `order/` | `order/AGENTS.md`;统一列表由 `UnifiedOrderQueryService` 组合交易与履约投影,仍固定显示 `PENDING_REVIEW` 并提供审核动作:Order 为 `SUBMITTED` 时必须优先返回 `PENDING_REVIEW/APPROVE_ORDER`,不得被 Firstleg 准备中状态覆盖 |
| 客户头程订单中文批量导入 | `order/` | `MerchantBatchImportService.java`、`MerchantWarehouseCatalogClient.java`、`MerchantPricingCatalogClient.java`、`order-v1.yaml`;模板和导入都按当前客户读取动态地址、全部有效仓库与渠道,最多 50 行,只创建草稿,支持 `PARTIAL/ATOMIC` |
| 平台修改客户原始申报、履约渠道变更与跨服务同步 | `order/`、`firstleg/` | Order `OrderDeclarationUpdateService.java`、`OrderInboundOutboxPublisher.java`、`order-v1.yaml`;Firstleg `OrderDeclarationSyncService.java`、`OrderDeclarationSyncMapper.java`、`firstleg-v1.yaml`。只更新申报快照;渠道变化先失败关闭校验履约可逆性与重新计价,成功后由版本化 `ORDER_DECLARATION_UPDATED_V1` 幂等同步,不覆盖实测、箱码或已确认费用 |
| 履约分配、入库、测量、审核、FBA 计费 | `firstleg/` | `firstleg/AGENTS.md` |
| 履约分配、入库、测量、箱规算法、审核、FBA 计费 | `firstleg/` | `firstleg/AGENTS.md`;客户四档箱规与 `ADD/MULTIPLY` 默认算法从 `CustomerDimensionConfigService` 进入,单箱测量和最终计价分别由 `CustomerDimensionWeightingService`、`FbaPricingService` 保存并消费服务端快照 |
| Firstleg 内部模块化与依赖基线 | `firstleg/` | `firstleg/docs/firstleg-internal-modularization-baseline.md`、`firstleg/scripts/analyze-firstleg-dependencies.mjs`;价格簿查询和客户统计按端口/适配器切片,不改变服务与数据库边界 |
| Mapper 新增、移动、Bean 缺失与扫描遗漏 | 目标持久化服务 | 所有 Mapper 显式 `@Mapper`,启动类按服务根包注解过滤扫描;先运行 `node scripts/mapper-scan-contract.mjs`,再按目标模块执行 `test-compile/test`,不得等部署启动发现 |
| 脚本管理、全量验证、测试造数与诊断 | 仓库根 `scripts/`;服务专属脚本留在 `<service>/scripts/` | 先读 `scripts/AGENTS.md` 和 `scripts/README.md`;Admin HTTP 内部模块继续读 `scripts/admin-test-env-modules/README.md`。新增入口必须登记用途、目标环境、数据副作用和报告位置,并通过 `scripts/script-documentation-contract.test.mjs` |
| 分柜、柜次、智能排柜、预装/装柜清单、真实柜号后补、PDA | `firstleg/` | `firstleg/AGENTS.md`,再按 `split/`、`loading/`、`outbound/` 定位;创建柜次时原子生成唯一预装计划,加入入仓码时同步建立关联,不设推送步骤;预装/正式详情由 `/loading-plans/{planId}/lifecycle` 解析。真实集装箱号不阻塞订舱关联、计划确认或 PDA task 创建,可在 `EDITING/LOADING` 首次补录,但必须在 PDA 最终完成前存在。上传以幂等键绑定包含文件内容的请求哈希,完全重放不得再次调用 File |
| 合柜报关 / 同柜不可拆组、跨客户合柜、整组分配/移除 | `firstleg/` | `loading/coload/controller/PlatformColoadGroupController.java`、`service/ColoadGroupService.java`、`persistence/ColoadGroupMapper.java`、`src/main/resources/openapi/coload-groups-v1.yaml`;按订单建组并冻结全部可用入仓片段,仅要求同国内仓、同渠道 |
| 智能装柜入仓码候选与箱数分配 | `firstleg/` | `controller/PlatformSmartLoadingController.java` 的 `/inbound-candidates`、`/plans/{planId}/inbound-assignments`,业务编排 `service/SmartLoadingService.java` |
| 全局智能装柜候选、柜次与箱数分配 | `firstleg/` | `controller/PlatformSmartLoadingController.java` 的 `/inbound-candidates`、`/containers`、`/plans/{planId}/inbound-assignments`、`/group-assignments`,业务编排 `service/SmartLoadingService.java`;渠道分组由服务端映射,同柜不可拆组作为一个分页与分配单元 |
| 订舱资料/打印、船期、订舱级拖车、报关 | `firstleg/` | `oceanbooking/`、`portschedule/`、`TruckAppointmentService.java`、`outbound/dispatch/`、`customs/`;拖车只绑定 `RELEASED` 订舱,不携带客户、柜次、装柜计划或真实柜号,每订舱最多一张非终态预约,订舱文件联查 `file/AGENTS.md` |
| 客户账期、费用池、账单、收款、成本、核销 | `finance/` | `finance/AGENTS.md`;费用来源和柜次成本快照再联查 `firstleg/AGENTS.md`,凭证再联查 `file/AGENTS.md` |
| 客户账期、费用池、账单、入仓码联查、收款、成本、核销 | `finance/` | `finance/AGENTS.md`;客户账单行 `inboundCode` 由 `FinanceQueryService` 从费用来源快照提供,Client 复用头程入仓关系详情;费用来源和柜次成本快照再联查 `firstleg/AGENTS.md`,凭证再联查 `file/AGENTS.md` |
| 柜级公共费用跨客户分摊 | `firstleg/`、`finance/` | Firstleg `PlatformContainerFeeDetailController.java`、`ContainerFeeDetailService.java`、`container-fees-v1.yaml` 按柜内真实客户与订单原子创建 `allocation-groups`;金额合计必须等于总额,分组审核、驳回、调整、作废均整组处理,Finance 只消费各客户不可变来源快照 |
| 海外业务费用来源跨区入池 | `lastleg-service/`、`common-event-contract/`、`event-bridge-service/`、`finance/` | Lastleg `financefee/` 审核五类海外费用并发布 `BUSINESS_FEE_SOURCE_CHANGED` 完整快照;Event Bridge 对 `targetRegion=CN` 定向投递 Finance `/internal/finance/v1/business-fee-source-events`;Finance 按事件 ID、Payload 哈希、来源系统/费用 ID/版本幂等入池,`PAYABLE + DIRECT_PAID` 单独形成已确认成本且不进入 AP 待结算 |
| 入库/柜次应付、直接已付、付款流水与凭证 | `firstleg/`、`finance/`、`file/` | 业务费用与付款快照见 `firstleg/AGENTS.md` 的 `FeePaymentSnapshotValidator`;`/api/platform/finance/v1/vouchers` 上传凭证,File 按 `FINANCE_VOUCHER` 和客户范围校验 |
@@ -40,7 +41,7 @@
| 供应商、国内整柜库存与出入库 | `warehouse/` | `warehouse/AGENTS.md`;OpenAPI `warehouse/src/main/resources/openapi/supplier-v1.yaml`、`domestic-container-v1.yaml` |
| 客户运营统计、在途柜量 | `firstleg/` | `firstleg/AGENTS.md`;OpenAPI `firstleg/src/main/resources/openapi/customer-statistics-v1.yaml` |
| 平台操作日志与审计查询 | `audit-service/` | `audit-service/AGENTS.md`;平台查询接口 `/api/platform/v1/audit/logs` |
| 资质、导出、测量图片、文件下载 | `file/` | `file/AGENTS.md`;法人身份证选填,可在已有营业执照后单独补传或替换 |
| 资质、导出、导出退避重试、测量图片、文件下载 | `file/` | `file/AGENTS.md`;法人身份证选填,可在已有营业执照后单独补传或替换;`PENDING` 且已有失败尝试的退避任务可人工立即到期并复用当前投递,不创建并发任务 |
| 路由、CORS、JWT 入口、访问域 | `gateway/` | `gateway/AGENTS.md` |
| 百度/Amazon/OCR/第三方适配 | `integration-service/` | `integration-service/AGENTS.md` |
| 海外 API 路由、海外 Token、Audience/JWKS | `gateway-overseas/` | `gateway-overseas/AGENTS.md` |
+2 -1
View File
@@ -11,7 +11,7 @@
| 目标 | 入口 |
|---|---|
| 商户/平台资质 | `controller/QualificationFileController.java`、`PlatformQualificationFileController.java`、`controller/QualificationTypes.java`;契约 `src/main/resources/openapi/qualification-required-package-v1.yaml`。营业执照必须与 18 位统一社会信用代码走 `required-package`;法人身份证选填,客户可在已有营业执照后通过 `/api/merchant/v1/files/qualifications` 单独新增或替换 |
| 导出中心 | `controller/PlatformExportTaskController.java`、`service/ExportTaskService.java`、`service/ExportStateService.java`、`service/GenericXlsxExportRunner.java`、`config/ExportDefinitionRegistry.java`、`client/ExportProviderClient.java`、`mq/` |
| 导出中心与退避重试 | `controller/PlatformExportTaskController.java`、`service/ExportTaskService.java`、`service/ExportStateService.java`、`service/GenericXlsxExportRunner.java`、`config/ExportDefinitionRegistry.java`、`client/ExportProviderClient.java`、`mq/`;响应公开 `attemptCount/maxAttempts/nextRetryAt` |
| 测量图片/证据 | `controller/PlatformMeasurementImageController.java`、`InternalMeasurementImageController.java`、`service/MeasurementImageService.java` |
| 平台订单商品图片读取 | `controller/PlatformProductImageController.java` 的 `/api/platform/v1/files/product-images/{tenantId}/{fileId}`、`service/ProductImageService.java`;仅平台 `platform:order:list` 可按明确客户与文件 ID 内联读取,响应 `no-store` |
| 装柜与业务文件证据 | `controller/PlatformLoadingEvidenceController.java`、`InternalLoadingEvidenceController.java`、`service/LoadingEvidenceService.java`、`src/main/resources/openapi/loading-evidence-v1.yaml`;五类装柜证据固定为平台所有、`tenantId=0`、`businessId=containerBookingId`,不得绑定客户或入仓单。其他业务文件进入 `InternalBusinessFileController.java`、`service/InternalBusinessFileService.java` |
@@ -31,6 +31,7 @@
- 客户业务文件按客户、业务 ID 和文件类型校验归属;重量图片可 OCR,第三方尺寸证据不得触发重量 OCR。装柜四步照片与铅封是跨客户物理柜证据,所有者固定为平台(`tenantId=0`),业务 ID 固定为 `containerBookingId`,File 按柜与五类 `LOADING_*` 精确校验且不得跨柜复用;PDA `taskId` 只由 Firstleg 保存为来源/审计,不进入 File 所有权。国内 PDA 禁止直连 File 平台接口,必须经 Firstleg 校验任务、仓库和操作者后,由 Firstleg 使用内部服务令牌访问 File。
- 商品图片平台读取必须同时校验平台身份、`platform:order:list`、客户范围、文件业务类型与正整数 ID;不得提供匿名 URL、跨客户读取或把内部存储路径返回页面。
- 公共导出只接受 `ExportDefinitionRegistry` 明确登记的 `exportType`,由 File 调用所属业务服务的内部 Provider 分批取数并生成文件;业务页面不得直连 Provider。新增或修改导出类型时同步检查业务服务 Provider、File Feign 客户端/响应上限、权限、Admin 下载中心标签与重试映射及契约测试。
- 人工重试只允许 `FAILED`,或 `PENDING` 且已有失败尝试、正在等待自动退避的任务;后者只把当前任务与现有 Outbox 投递立即到期,不增加新任务或并发消息。`RUNNING`、初次等待消费、已成功或已过期任务均拒绝重试。
- 生产环境显式配置 `FILE_STORAGE_ROOT`;磁盘故障返回 `S0503`,不得包装成业务错误。
- 报关源文件和完成文件的 `businessId` 固定为 loadingPlanId,File DTO 仍为 `Metadata(fileId, tenantId, businessType, businessId, fileName, sha256, contentType, sizeBytes)`,其中 `businessType` 即 fileCategory;Firstleg 保存业务文件版本并决定用户入口。权限固定为 Platform 上传结果 `platform:firstleg:customs:manage`、下载 `platform:firstleg:loading-list:list`,Client 上传源文件 `merchant:order:edit`、下载自身源文件或获准结果 `merchant:order:view`。结果还必须满足整柜完成和可见性,买单客户是否存在结果入口同样由 Firstleg 判定,File 不复制这些状态。
+2 -1
View File
@@ -23,7 +23,7 @@
| Finance 导出数据 Provider | `controller/FinanceExportProviderController.java`、`service/FinanceExportProviderService.java`;由 File `ExportProviderClient` 按已登记 `exportType` 分批拉取,不从 Admin 直接生成文件 |
| 五页产品 OpenAPI | `../../docs/财务管理/openapi/finance-phase1-five-pages-v1.yaml`;人工财务补充契约 `finance-f1-manual-v1.yaml` |
| 业务费用来源事件入口 | Firstleg:`controller/InternalFinanceFeeSourceController.java`、`service/FinanceFeeSourceEventService.java`;海外完整快照:`controller/InternalBusinessFeeSourceController.java` 的 `/internal/finance/v1/business-fee-source-events`、`service/BusinessFeeSourceEventService.java`;事件模型 `../common-event-contract/src/main/java/com/baiyue/common/event/BusinessFeeSourceChangedV1.java` |
| 查询模型与客户可见性 | `service/FinanceQueryService.java`、`dto/FinanceReadModels.java` |
| 查询模型、客户可见性与账单行入仓码 | `service/FinanceQueryService.java`、`dto/FinanceReadModels.java`;`StatementLine.inboundCode` 从对应费用来源不可变快照读取,供 Client 跳转客户隔离的入仓关系详情 |
| 账期、账单、收款、成本及幂等写模型 | `service/FinanceService.java` |
| Firstleg 费用来源与柜次成本快照 | `client/FirstlegFeeSourceClient.java`、`client/FirstlegCostSnapshotClient.java`;生产端见 `../firstleg/AGENTS.md` |
| Warehouse 供应商校验与名称快照 | `client/WarehouseSupplierClient.java`、`service/FinanceSupplierValidator.java`;供应商事实来源及类别契约见 `../warehouse/AGENTS.md`、`../warehouse/src/main/resources/openapi/supplier-v1.yaml` |
@@ -48,6 +48,7 @@
- 整柜成本分摊使用 Firstleg 返回的真实客户归属与订单体积快照;不得信任前端提交的订单归属或体积。凭证必须由 File 元数据校验客户和业务类型,依赖不可用时不得静默跳过。
- 应付费用来源、成本登记和应付结算单引用的供应商必须通过 Warehouse 校验 ID 与 `ACTIVE` 状态;费用项目 `supplierCategory` 为空时允许任意启用供应商,非空时严格校验类别,不得从费用编码或名称猜测。名称快照只能取 Warehouse 返回值,不能接受自由文本替代。Warehouse 不可用、供应商停用或非空类别不匹配时必须失败关闭;修改该链路时同步检查事件消费、人工成本、结算创建/付款、Nacos 客户端地址和双方契约测试。
- 修改平台或客户 DTO 时同步检查 Admin `../../apps/admin-web/src/modules/finance/`、Client `../../apps/client-web/src/modules/finance/`、Gateway 路由、权限码和契约测试;客户响应不得暴露供应商成本、内部备注、平台凭证或其他客户数据。
- 客户账单明细以业务 `inboundCode` 展示和联查入仓关系;缺失时返回空并显示“—”,不得把 `inboundId`、`sourceId` 或其他内部 ID 当业务编号回退。Finance 只从同客户费用来源快照取值,Client 通过 `first-leg/public.ts` 复用客户隔离的详情组件。
- 修改 Finance 导出字段、筛选或 `exportType` 时,同步检查 `FinanceExportProviderService`、File `ExportDefinitionRegistry`/`ExportProviderClient`、Admin `modules/finance/` 与 `modules/downloads/`、导出权限和双方契约测试;页面只创建异步下载任务,不绕过 File 导出中心直接拼装业务文件。
- Finance 费用池必须保留上游 `direction`、客户/供应商名称和付款证据快照;Firstleg 的 `DIRECT_PAID` 校验仍归 Firstleg。Finance 上传端只创建受客户范围约束的 `FINANCE_VOUCHER` 文件,不替代上游费用审核与付款快照校验。
+7 -5
View File
@@ -15,12 +15,13 @@
| 履约任务/分配 | `controller/PlatformFulfillmentAssignmentController.java`、`service/FulfillmentTaskService.java` |
| 入库单查询/聚合 | `controller/PlatformInboundOrderController.java`、`service/InboundOrderQueryService.java` |
| Order 申报同步与渠道变更履约门禁 | `controller/InternalInboundOrderController.java` 的 `/order-channel-changes/validate`、`/order-declaration-updated`,`service/OrderDeclarationSyncService.java`、`mapper/OrderDeclarationSyncMapper.java`、`dto/OrderDeclarationSync.java`;契约 `src/main/resources/openapi/firstleg-v1.yaml`,只接受内部服务令牌 |
| 平台测量/OCR | `controller/PlatformInboundMeasurementController.java`、`service/InboundMeasurementService.java`、`service/CartonMeasurementService.java`;手工单箱测量也从该 Controller 与 `CartonMeasurementService` 进入 |
| 平台测量/OCR 与箱规计费快照 | `controller/PlatformInboundMeasurementController.java`、`service/InboundMeasurementService.java`、`service/CartonMeasurementService.java`、`service/CustomerDimensionWeightingService.java`;手工单箱测量保存实际尺寸、可选外箱修正、`chargeRuleId` 与 `coefficientMode`,计算结果以服务端返回值为准 |
| 箱唛箱码生成、打印与测量绑定 | 审核建单生成钩子 `controller/InternalInboundOrderController.java`;生成/解析 `service/InboundCartonLabelService.java`、`repository/InboundCartonLabelRepository.java`;平台与客户打印 `controller/PlatformInboundOrderController.java`、`MerchantInboundCartonLabelController.java`、`service/InboundCartonLabelPrintService.java`。`fl_inbound_carton_label.sku_code` 是箱唛的 SKU 冻结快照,生成时经 `client/OrderCargoClient.java` 获取;既有记录按 `inbound_detail_id` 回填。`ONE_CLICK_DROPSHIP` 缺 SKU 时禁止生成箱唛,打印面显示 SKU;`box_code` 与二维码内容仍只携带稳定箱码。基线/升级 SQL 为 `src/main/resources/db/phase2-inbound-carton-label.sql`、`phase2-carton-label-versioning-migration.sql`、`../deploy/sql/头程数据库统一增量-MySQL8.sql` |
| 一件代发测量后 PDA 合托 | `controller/PlatformDropshipPalletizationController.java`、`service/DropshipPalletizationService.java`、`mapper/DropshipPalletizationMapper.java`;测量通过进入 `WAIT_PALLETIZE` 并下发独立任务,不创建国内库存,封托生成 `DP` 托盘号,全部箱数完成后进入 `WAIT_SPLIT`。契约 `src/main/resources/openapi/dropship-palletization-v1.yaml` |
| 墨家设备测量上传 | `controller/OpenApiInboundMeasurementController.java`、`dto/MojiaMeasurementDtos.java`、`service/MojiaMeasurementOpenApiService.java`、`security/MeasurementRequestAuthenticator.java`;接口契约见 `src/main/resources/openapi/firstleg-v1.yaml` 与 `../../docs/墨家设备测量上传接口说明-V3.md` |
| 审核与初始计价 | `controller/PlatformInboundMeasurementReviewController.java`、`service/MeasurementReviewService.java`、`service/InitialInboundPricingService.java`、`dto/MeasurementApprovalResponse.java` |
| FBA 主数据/计费 | `controller/PlatformFbaMasterDataController.java`、`PlatformFbaPricingController.java`、`service/FbaPricingService.java` |
| 客户四档箱规与默认算法 | `controller/PlatformCustomerDimensionConfigController.java`、`service/CustomerDimensionConfigService.java`、`repository/CustomerDimensionConfigRepository.java`;固定 `SMALL/MEDIUM/LARGE/EXTRA_LARGE`,默认算法只允许 `ADD/MULTIPLY` |
| FBA 主数据/计费与客户规则 | `controller/PlatformFbaMasterDataController.java`、`PlatformFbaPricingController.java`、`service/FbaPricingService.java`、`repository/FbaPricingRepository.java`;客户规则可保存多个版本,但每个客户仅一条默认规则,计算请求可提交已确认的重量/体积选择值 |
| 入库费用明细 | `controller/PlatformInboundFeeDetailController.java`、`service/InboundFeeDetailService.java`、`dto/InboundFeeDetailDtos.java`;Admin 消费端见 `../../apps/admin-web/src/modules/unified-orders/api/inboundFeeDetails.ts` |
| 入库/柜次直接已付快照与凭证校验 | `service/FeePaymentSnapshotValidator.java`、`client/FeePaymentVoucherFileClient.java`;写入入口分别为 `InboundFeeDetailService.java`、`ContainerFeeDetailService.java`,File 业务类型固定 `FINANCE_VOUCHER` |
| 客户费用查询 | `controller/MerchantInboundBillingController.java`、`service/MerchantInboundBillingService.java` |
@@ -32,10 +33,10 @@
| 财务整柜成本分摊快照 | `loading/controller/InternalFinanceCostSnapshotController.java`、`loading/service/FinanceCostSnapshotService.java` |
| 分柜、预装、真实柜号后补与正式装柜清单生命周期 | `split/controller/PlatformSplitPreloadController.java`、`controller/PlatformLoadingListController.java`、`service/LoadingListService.java`;契约 `src/main/resources/openapi/loading-lists-v1.yaml`,详情阶段先查 `/loading-plans/{planId}/lifecycle`。创建柜次时原子生成唯一预装计划,加入入仓码明细时同步创建入库预装聚合与关联,不设推送步骤;同一 `planId` 在 `EDITING/LOADING` 返回 `PRELOAD`,`COMPLETED` 返回 `LOADING_LIST`。订舱关联、预装确认和 PDA task 创建不依赖真实集装箱号;可在 `EDITING/LOADING` 首次补录,最终 PDA 完成前必须存在;已有值纠正仅限 `EDITING` 且任务未领取、无物理作业事实 |
| 同柜不可拆组 | `loading/coload/controller/PlatformColoadGroupController.java`、`service/ColoadGroupService.java`、`persistence/ColoadGroupMapper.java`;契约 `src/main/resources/openapi/coload-groups-v1.yaml`。请求按订单 ID 建组,服务端冻结每票全部 `WAIT_SPLIT` 可用入仓片段与满箱数;仅要求同国内仓、同渠道,允许跨客户及不同目的仓港;分配、移除、取消均保持整组原子 |
| 货柜与智能排柜 | `controller/PlatformContainerController.java`、`PlatformSmartLoadingController.java`、`service/SmartLoadingService.java`、`repository/SmartLoadingRepository.java`、`loading/`;按入仓码候选与箱数分配分别从 `/inbound-candidates`、`/plans/{planId}/inbound-assignments` 进入,明细规格拆分留在服务端 |
| 货柜与全局智能排柜 | `controller/PlatformContainerController.java`、`PlatformSmartLoadingController.java`、`service/SmartLoadingService.java`、`repository/SmartLoadingRepository.java`、`loading/`;全局候选、工作台柜次和箱数分配分别从 `/inbound-candidates`、`/containers`、`/plans/{planId}/inbound-assignments` 进入,同柜组从 `/group-assignments` 整组处理,明细规格拆分留在服务端 |
| 货柜资料、PDA 证据与业务总览 | `controller/PlatformContainerController.java` 的 `/{id}/overview`、`/{id}/documents/{documentType}`,聚合 `mapper/ContainerOverviewMapper.java`;契约 `src/main/resources/openapi/phase3-container-overview-v1.yaml`,四类资料固定为目的港清关、国内出口报关、DO、PU 的当前唯一版本 |
| PDA 拣货与装柜、证据幂等上传 | `controller/PlatformLoadingPdaController.java`、`loading/controller/PlatformLoadingDeliveryController.java`、`loading/service/CabinetLoadingEvidenceService.java`;证据写入和 multipart 上传均先以任务内幂等键匹配请求哈希,上传哈希覆盖仓库、版本、证据类型、锁码、备注、文件名、媒体类型、大小与文件内容;完全重放返回当前视图且不得再次上传 File,相同键不同内容返回冲突 |
| 装柜完成跨区事件 | `service/LoadingCompletedOutboxService.java`、`LoadingCompletedOutboxRelay.java`、`repository/LoadingCompletedOutboxRepository.java`、`mapper/LoadingCartonManifestMapper.java`、`client/EventBridgeClient.java`;从 `fl_inbound_order.destination_type` 冻结 V2 `orders[].destinationType=FBA|OVERSEAS`,OVERSEAS 按最终实装与稳定箱码冻结,FBA 可仅冻结箱数;真实生产验收 `src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java`;事件模型见 `../common-event-contract/AGENTS.md` |
| 装柜完成跨区事件 | `service/LoadingCompletedOutboxService.java`、`LoadingCompletedOutboxRelay.java`、`repository/LoadingCompletedOutboxRepository.java`、`mapper/LoadingCartonManifestMapper.java`、`client/EventBridgeClient.java`;从 `fl_inbound_order.destination_type` 冻结 V2 `orders[].destinationType`(`FBA` 或 `OVERSEAS`),OVERSEAS 按最终实装与稳定箱码冻结,FBA 可仅冻结箱数;真实生产验收 `src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java`;事件模型见 `../common-event-contract/AGENTS.md` |
| 海外卸柜完成事件回写 | 内部入口 `controller/InternalOverseasContainerEventController.java`、消费者 `service/OverseasContainerEventConsumer.java`、Inbox `entity/ContainerEventInboxEntity.java` 与 `mapper/ContainerEventInboxMapper.java`;事件模型见 `../common-event-contract/AGENTS.md` |
| 海外暂扣状态回写 | 同一内部事件入口、消费者 `service/OverseasInboundOrderDetainedConsumer.java`;按来源入仓单 ID 更新 `fl_inbound_order.hold_flag/current_hold_reason`,复用跨区 Inbox 并写 `fl_inbound_hold_record` |
| 三期海运订舱、资料/打印、关联候选与出仓前船期资料 | `oceanbooking/controller/PlatformOceanBookingController.java`、`oceanbooking/service/OceanBookingService.java`、`OceanBookingDocumentService.java`、`OceanBookingPrintService.java`、`oceanbooking/mapper/OceanBookingMapper.java`、`portschedule/controller/PlatformPortScheduleController.java`;文件经 `client/OceanBookingFileClient.java` 进入 File,关联候选 `/api/platform/v1/ocean-bookings/link-candidates`,契约 `src/main/resources/openapi/ocean-bookings-v1.yaml`,表结构见 `src/main/resources/db/phase3-ocean-booking-schema.sql` |
@@ -60,6 +61,7 @@
- 稳定箱码映射以入库快照和 Order 货物明细为事实;订单审核创建入库单时必须幂等生成,打印接口只复用或补齐同一映射,用户是否下载箱唛不得决定箱码有效性。手工测量、墨家设备和 PDA 扫码统一通过 `InboundCartonLabelService` 校验客户、入库单、货物明细、总箱数/下标和有效批次,不得在测量时临时伪造箱码或接受跨客户、跨入库箱码。
- 一件代发以 `fl_inbound_carton_label.sku_code` 作为不可变 SKU 快照;PDA 仍只扫描稳定 `box_code`,不得将 SKU 塞入二维码或以 SKU 替代箱码。实际打托后的查询必须能按“现场托盘 -> 客户 -> SKU -> 箱数”聚合;历史箱唛仅能在同客户、同入库单范围内按 `inbound_detail_id` 回填,禁止按货名、箱规或箱号猜测 SKU。修改本链路时同步检查 `OrderCargoClient`、Order 内部货物接口及 PDA 打托实现。
- 测量审核通过后直接进入 `WAIT_SPLIT`,费用仅作为独立初算和人工维护结果,不得阻塞或回退订单主流程;计费量、单价和附加费调整必须版本化并由服务端重算金额。状态或 DTO 变更时逐个检查 Order/Warehouse/File/Integration 消费者。
- 客户箱规配置必须恰好保持四档且默认算法在四行一致;`ADD` 以实测尺寸为基准对三个维度分别增加命中箱规的尺寸值,`MULTIPLY` 使用当前客户唯一默认计费规则,并由服务端按规则中的规格档位选择系数。测量行必须保存实际尺寸、选用规则和算法快照,页面不得自行重算后覆盖服务端结果;计价使用用户确认的重量/体积选择值并纳入幂等请求哈希。
- 报关、订舱和拖车引用的供应商必须通过 `WarehouseSupplierSnapshotService` 按 ID 向 Warehouse 校验:报关使用 `CUSTOMS`,订舱使用 `BOOKING`(不区分国内/国外 scope),拖车使用 `TRUCKING`;供应商必须为 `ACTIVE`。请求中的名称只能作为一致性提示,持久化名称必须来自 Warehouse 快照,禁止用自由文本绕过 ID、类别或启用状态校验。修改任一供应商引用 DTO、OpenAPI 或内部客户端时,必须同步检查三条业务链、Warehouse `supplier-v1.yaml`、数据库快照字段和对应契约测试。
- Order 渠道变更门禁必须检查装柜或后续状态、PDA task、预装、分柜及同柜组兼容性;任何不确定结果均失败关闭。`ORDER_DECLARATION_UPDATED_V1` 消费必须校验订单号/入仓码归属,永久保留 `eventId + payload hash` 幂等历史并按版本单调更新申报投影;不得覆盖仓库实测、箱码、测量审核或已确认费用。
- 订舱状态固定为 `DRAFT → RELEASED`:草稿允许资料不完整并可替换或删除,放舱时校验完整持久化聚合,放舱后永久只读。创建草稿和放舱都只维护订舱聚合、三类参与方及柜型需求,不创建真实 `containerNo`、货柜行或分配关系;真实货柜只在后续提柜关联时占用已放舱柜型需求。
@@ -77,7 +79,7 @@
## 三期装柜与 PDA 资源结算
- 智能装柜候选只能按同一国内中转仓合柜;推荐顺序按入库时间先进先出。装柜计划状态为 `EDITING -> LOADING -> COMPLETED`,PDA 全部明细处理完成后自动完成。
- 智能装柜全局工作台由服务端把业务渠道分组映射为实际渠道,并分别分页查询候选与 `WAIT_LOADING/EDITING/LOADING` 柜次;候选只返回具备实际装柜资格的 `WAIT_SPLIT` 数据,同柜不可拆组必须成员完整、满箱且作为单个分页与分配单元,不能跨页或拆分。分配到柜时继续复核国内中转仓、实际渠道、容量、组锁和版本;推荐顺序按入库时间先进先出。装柜计划状态为 `EDITING -> LOADING -> COMPLETED`,PDA 全部明细处理完成后自动完成。
- PDA 拣货以 `fl_inbound_carton_label.box_code` 对应的稳定箱唛箱码为扫描身份;审核建单、重复打印、手工测量和墨家测量复用同一映射,版本字段只用于并发控制,不进入扫码文本。一个有效且未重复的箱码扫描表示拣出一箱,不得以 SKU、商品件数或页面展示字段替代箱码。任务下发与提交契约见 `src/main/resources/openapi/phase3-pda-convergence-v1.yaml`。
- PDA 部分拣货生成二级子入库单时,数量必须守恒:`已拣数量 + 子单数量 = 拣货前主单可用数量`。子单只允许一层,不能继续拆分。
- `releaseMode=AUTO` 时,释放资源数为 `ceil(实际拣货箱数 × 原资源数 / 原可用箱数)`;`MANUAL` 按 `locationIds` 选择,空数组表示明确释放 0 个。当前契约中未传模式且 `locationIds=null` 时按 AUTO 处理。
+2 -1
View File
@@ -17,7 +17,7 @@
| 申报同步、渠道履约/计价门禁 | `service/OrderFulfillmentChannelChangeGuard.java`、`OrderPricingChannelGuard.java`、`OrderInboundOutboxPublisher.java`,内部客户端 `FirstlegInboundClient.java`、`FirstlegPricingClient.java`;审核后通过版本化 `ORDER_DECLARATION_UPDATED_V1` 同步 Firstleg |
| 入仓码业务关系摘要 | `service/InboundCodeAssociationService.java`、`dto/InboundCodeAssociationSummary.java`、平台/客户 `FirstLegOrderController` 的 `/inbound-code-associations/{inboundCode}`;客户入口必须按认证客户隔离 |
| 订单编排/状态/幂等 | `service/OrderApplicationService.java`、`entity/OrderStatus.java` |
| 商户订单列表与批量草稿 | `service/MerchantInboundOrderListService.java`、`service/MerchantBatchDraftService.java`、`service/MerchantBatchDraftItemService.java` |
| 商户订单列表、批量草稿与中文 XLSX 导入 | `service/MerchantInboundOrderListService.java`、`MerchantBatchDraftService.java`、`MerchantBatchDraftItemService.java`、`MerchantBatchImportService.java`;动态目录客户端 `client/MerchantWarehouseCatalogClient.java`、`MerchantPricingCatalogClient.java`,客户地址由 `CustomerAddressService.java` 提供;接口见 `controller/MerchantFirstLegOrderController.java` 的 `/batch-imports/template`、`/batch-imports` |
| 入库号生成 | `config/InboundCodeModeProvider.java`、`mapper/InboundCodeSequenceMapper.java`、`repository/OrderRepository.java`、`service/OrderApplicationService.java` |
| 下单计价渠道校验 | `service/OrderPricingChannelGuard.java`、`client/FirstlegPricingClient.java` |
| 下单仓库主数据守卫 | `service/OrderMasterDataGuard.java`、`client/WarehouseClient.java`;校验公共收货仓、目的仓类型、状态、地址与收货能力,并保存服务端快照 |
@@ -34,6 +34,7 @@
- `inbound_code` 是跨客户全局唯一业务码;生成模式由 `InboundCodeModeProvider` 控制,序列与唯一约束不得退化为客户内唯一。修改格式、长度或生成时机时同步检查 Order SQL、Firstleg 入库快照、箱唛箱码、前端展示和全局唯一性契约测试。
- 下单和后续关键状态操作不得信任客户端仓库名称、类型或启用状态;公共仓库必须通过 Warehouse 内部接口按 ID 实时校验。私人地址不得伪装为公共目的仓,非私人目的地必须引用类型匹配的公共仓库;Warehouse 不可用时失败关闭,不回退到旧快照或模拟数据。
- Warehouse 往来单位、Firstleg 入库协作只通过客户端 DTO/内部令牌;修改契约需同步消费者。
- 中文批量模板按登录客户生成取货/私人地址、国内收货仓、目的仓和渠道下拉;仓库目录必须翻页读取全部候选,不得只取第一页。导入时重新读取并校验动态目录、客户归属和启用状态,不信任模板隐藏页;文件上限 5MB、有效数据最多 50 行,仅创建草稿,`PARTIAL` 允许单行失败、`ATOMIC` 任一行失败不得落单。
- 平台只能在 `DRAFT/REJECTED/SUBMITTED/APPROVED` 版本化修改客户原始申报;不得修改客户、国内收货仓、订单号、入仓码、状态或仓库/PDA/设备实测、测量审核和计费事实,不得增删或重排货物行。国内收货仓或中转仓变化必须重新下单。
- 只有渠道发生变化才同时调用 Firstleg 履约可逆性与重新计价门禁,任一内部校验不可用或失败均不写入;成功后 Outbox 必须携带完整申报快照和字段变化。Firstleg 消费按 `eventId + payload hash` 幂等、按订单版本单调推进,渠道变化才使未确认价格预估失效。
- 对 `ONE_CLICK_DROPSHIP`,货物明细 SKU 是箱唛绑定及海外一件代发库存追踪的前置条件,不得依赖货名、箱规或其他展示字段推导 SKU。修改 `CargoLabelBinding` 时同步更新 Firstleg 客户端、相关测试与服务间契约。
@@ -12,8 +12,18 @@ import com.baiyue.order.dto.OrderRequests;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.CellStyle;
import org.apache.poi.ss.usermodel.BorderStyle;
import org.apache.poi.ss.usermodel.DataFormatter;
import org.apache.poi.ss.usermodel.DataValidationConstraint;
import org.apache.poi.ss.usermodel.DateUtil;
import org.apache.poi.ss.usermodel.FillPatternType;
import org.apache.poi.ss.usermodel.Font;
import org.apache.poi.ss.usermodel.HorizontalAlignment;
import org.apache.poi.ss.usermodel.IndexedColors;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.VerticalAlignment;
import org.apache.poi.ss.util.CellRangeAddress;
import org.apache.poi.ss.util.CellRangeAddressList;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.springframework.beans.factory.annotation.Value;
@@ -25,9 +35,15 @@ import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.time.format.ResolverStyle;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.function.Function;
@@ -44,24 +60,48 @@ public class MerchantBatchImportService {
@Resource private ObjectMapper json;
@Value("${baiyue.security.internal-service-token:}") private String token;
private static final List<Column> COLUMNS = List.of(
new Column("客户行号", "clientRowId", null), new Column("客户名称", "customerName", null),
new Column("运输方式", "transportMode", "transport"), new Column("贸易术语", "tradeTerm", null),
new Column("目的国家代码", "destinationCountryCode", null), new Column("目的仓库", "$destinationWarehouse", "destinationWarehouses"),
new Column("预计出运日期", "expectedShipDate", null), new Column("客户备注", "customerRemark", null),
new Column("提货方式", "pickupMethod", "pickup"), new Column("预约提货时间", "requestedPickupAt", null),
new Column("取货地址", "$senderAddress", "pickupAddresses"), new Column("国内收货仓", "$receivingWarehouse", "receivingWarehouses"),
new Column("运输渠道", "$channel", "channels"), new Column("报关方式", "taxMode", "tax"),
new Column("计价税模式", "pricingTaxMode", "pricingTax"), new Column("装箱方式", "containerMode", "container"),
new Column("是否需要商检", "customsInspection", "boolean"), new Column("目的类型", "destinationType", "destination"),
new Column("私人目的仓地址", "$privateAddress", "privateAddresses"), new Column("亚马逊仓库代码", "amazonWarehouseCode", null),
new Column("货件编号", "shipmentId", null), new Column("参考编号", "referenceId", null),
new Column("履约类型", "fulfillmentType", "fulfillment"), new Column("货物名称", "cargo.cargoName", null),
new Column("包装类型", "cargo.packageType", "package"), new Column("包装数量", "cargo.packageCount", null),
new Column("毛重(千克)", "cargo.grossWeightKg", null), new Column("体积(立方米)", "cargo.volumeCbm", null),
new Column("材质", "cargo.material", null), new Column("是否木制品", "cargo.woodenProduct", "boolean"),
new Column("是否危险品", "cargo.dangerousGoods", "boolean"), new Column("SKU", "cargo.skuNo", null),
new Column("UPC", "cargo.upcNo", null), new Column("货物货件编号", "cargo.shipmentId", null),
new Column("货物参考编号", "cargo.referenceId", null));
col("订单基础", "客户行号", "clientRowId", null, true, "每行唯一,用于错误定位", 16),
col("订单基础", "客户名称(企业名称)", "customerName", null, true, "填写当前下单企业的正式名称,系统不会猜测或自动替换", 24),
col("订单基础", "运输方式", "transportMode", "transport", true, "始终填写,从下拉选择", 14),
col("订单基础", "贸易术语", "tradeTerm", null, false, "有约定时填写,如FOB、EXW", 14),
col("订单基础", "预计出运日期", "expectedShipDate", null, false, "有预计日期时填写,格式yyyy-mm-dd", 18),
col("订单基础", "客户备注", "customerRemark", null, false, "有补充说明时填写", 24),
col("目的地与仓库", "目的类型", "destinationType", "destination", true, "始终填写,并与对应目的仓字段保持一致", 16),
col("目的地与仓库", "FBA目的仓", "$fbaWarehouse", "fbaWarehouses", false, "目的类型为亚马逊FBA时必填", 22),
col("目的地与仓库", "海外仓目的仓", "$overseasWarehouse", "overseasWarehouses", false, "目的类型为海外仓时必填", 24),
col("目的地与仓库", "私人目的仓地址", "$privateAddress", "privateAddresses", false, "目的类型为私人地址时必填", 32),
col("国内提货与运输", "提货方式", "pickupMethod", "pickup", true, "始终填写,从下拉选择", 18),
col("国内提货与运输", "预约提货时间", "requestedPickupAt", null, false, "提货方式为平台上门提货时填写,格式yyyy-mm-dd hh:mm:ss", 23),
col("国内提货与运输", "取货地址", "$senderAddress", "pickupAddresses", false, "提货方式为平台上门提货时必填", 32),
col("国内提货与运输", "国内收货仓", "$receivingWarehouse", "receivingWarehouses", true, "始终填写,从启用的国内仓选择", 24),
col("国内提货与运输", "运输渠道", "$channel", "channels", true, "始终填写;导入时校验与国内收货仓的组合", 24),
col("报关与计价", "报关方式", "taxMode", "tax", true, "始终填写", 16),
col("报关与计价", "计价税模式", "pricingTaxMode", "pricingTax", true, "始终填写", 16),
col("报关与计价", "装箱方式", "containerMode", "container", false, "已确定整柜或散货拼箱时填写", 16),
col("报关与计价", "是否需要商检", "customsInspection", "boolean", false, "木制品或业务要求商检时填写", 18),
col("FBA业务信息", "货件编号", "shipmentId", null, false, "FBA业务有货件编号时填写", 18),
col("FBA业务信息", "参考编号", "referenceId", null, false, "FBA业务有参考编号时填写", 18),
col("FBA业务信息", "履约类型", "fulfillmentType", "fulfillment", false, "未填写时按普通头程处理", 16),
col("货物信息", "货物名称", "cargo.cargoName", null, true, "填写本行订单的货物名称", 22),
col("货物信息", "包装类型", "cargo.packageType", "package", true, "始终填写,从下拉选择", 16),
col("货物信息", "包装数量", "cargo.packageCount", null, true, "正整数", 14),
col("货物信息", "毛重(千克)", "cargo.grossWeightKg", null, true, "填写大于0的数字", 16),
col("货物信息", "体积(立方米)", "cargo.volumeCbm", null, true, "填写大于0的数字", 18),
col("货物信息", "材质", "cargo.material", null, false, "按实际材质填写", 18),
col("货物信息", "是否木制品", "cargo.woodenProduct", "boolean", true, "始终填写", 16),
col("货物信息", "是否危险品", "cargo.dangerousGoods", "boolean", true, "始终填写", 16),
col("货物信息", "SKU", "cargo.skuNo", null, false, "有SKU时填写", 16),
col("货物信息", "UPC", "cargo.upcNo", null, false, "有UPC时填写", 16),
col("货物信息", "货物货件编号", "cargo.shipmentId", null, false, "货物级货件编号,有则填写", 20),
col("货物信息", "货物参考编号", "cargo.referenceId", null, false, "货物级参考编号,有则填写", 20));
private static final int DATA_START_ROW = 2;
private static final int EXPECTED_SHIP_DATE_COLUMN = 4;
private static final int REQUESTED_PICKUP_AT_COLUMN = 11;
private static final ThreadLocal<DataFormatter> DATA_FORMATTER =
ThreadLocal.withInitial(() -> new DataFormatter(Locale.SIMPLIFIED_CHINESE));
private static final DateTimeFormatter DATE = DateTimeFormatter.ISO_LOCAL_DATE;
private static final DateTimeFormatter DATE_TIME =
DateTimeFormatter.ofPattern("uuuu-MM-dd HH:mm:ss").withResolverStyle(ResolverStyle.STRICT);
private static final Map<String, List<String>> STATIC_OPTIONS = Map.of(
"transport", List.of("海运", "空运", "铁路", "卡车"),
"pickup", List.of("自行快递", "平台上门提货", "自行送仓"),
@@ -82,17 +122,30 @@ public class MerchantBatchImportService {
public byte[] template(long tenantId, String authorization) {
Catalog catalog = catalog(tenantId, authorization);
try (var workbook = new XSSFWorkbook(); var out = new ByteArrayOutputStream()) {
workbook.getFontAt(0).setFontName("Microsoft YaHei");
var sheet = workbook.createSheet("订单导入");
Row header = sheet.createRow(0);
for (int i = 0; i < COLUMNS.size(); i++) header.createCell(i).setCellValue(COLUMNS.get(i).title());
sheet.createFreezePane(0, 1);
for (int i = 0; i < COLUMNS.size(); i++) sheet.setColumnWidth(i, 20 * 256);
createHeaders(workbook, sheet);
CellStyle baseStyle = workbook.createCellStyle();
baseStyle.setVerticalAlignment(VerticalAlignment.CENTER);
CellStyle dateStyle = workbook.createCellStyle(); dateStyle.cloneStyleFrom(baseStyle);
dateStyle.setDataFormat(workbook.createDataFormat().getFormat("yyyy-mm-dd"));
CellStyle dateTimeStyle = workbook.createCellStyle(); dateTimeStyle.cloneStyleFrom(baseStyle);
dateTimeStyle.setDataFormat(workbook.createDataFormat().getFormat("yyyy-mm-dd hh:mm:ss"));
for (int rowNo = DATA_START_ROW; rowNo < DATA_START_ROW + MAX_ROWS; rowNo++) {
Row row = sheet.createRow(rowNo); row.setHeightInPoints(22);
for (int col = 0; col < COLUMNS.size(); col++) {
Cell cell = row.createCell(col);
cell.setCellStyle(col == EXPECTED_SHIP_DATE_COLUMN ? dateStyle
: col == REQUESTED_PICKUP_AT_COLUMN ? dateTimeStyle : baseStyle);
}
}
var options = workbook.createSheet("选项数据");
Map<String, List<String>> optionValues = new LinkedHashMap<>(STATIC_OPTIONS);
optionValues.put("pickupAddresses", catalog.pickupAddresses().keySet().stream().toList());
optionValues.put("privateAddresses", catalog.privateAddresses().keySet().stream().toList());
optionValues.put("receivingWarehouses", catalog.receivingWarehouses().keySet().stream().toList());
optionValues.put("destinationWarehouses", catalog.destinationWarehouses().keySet().stream().toList());
optionValues.put("fbaWarehouses", catalog.fbaWarehouses().keySet().stream().toList());
optionValues.put("overseasWarehouses", catalog.overseasWarehouses().keySet().stream().toList());
optionValues.put("channels", catalog.channels().keySet().stream().toList());
int optionCol = 0;
Map<String, String> ranges = new LinkedHashMap<>();
@@ -112,17 +165,15 @@ public class MerchantBatchImportService {
String range = ranges.get(COLUMNS.get(col).optionKey());
if (range == null) continue;
DataValidationConstraint constraint = helper.createFormulaListConstraint(range);
var validation = helper.createValidation(constraint, new CellRangeAddressList(1, MAX_ROWS, col, col));
var validation = helper.createValidation(constraint,
new CellRangeAddressList(DATA_START_ROW, DATA_START_ROW + MAX_ROWS - 1, col, col));
validation.setShowErrorBox(true); validation.createErrorBox("选项不正确", "请从下拉列表中选择");
sheet.addValidationData(validation);
}
addDateValidation(helper, sheet, EXPECTED_SHIP_DATE_COLUMN, "请输入有效日期(yyyy-mm-dd)");
addDateValidation(helper, sheet, REQUESTED_PICKUP_AT_COLUMN, "请输入有效日期时间(yyyy-mm-dd hh:mm:ss)");
workbook.setSheetHidden(workbook.getSheetIndex(options), true);
var guide = workbook.createSheet("填写说明");
String[] guides = {"每一行代表一张订单,最多填写50行。", "客户行号由您自定义且在文件内唯一,用于定位错误行。",
"带下拉的字段必须从列表选择;系统导入时会重新核验仓库、渠道和地址的状态及客户归属。",
"日期格式:yyyy-MM-dd;预约提货时间格式:yyyy-MM-dd HH:mm:ss。", "本说明页不会被导入,请勿在订单导入页放置示例行。"};
for (int i = 0; i < guides.length; i++) guide.createRow(i).createCell(0).setCellValue(guides[i]);
guide.setColumnWidth(0, 100 * 256);
createGuide(workbook);
workbook.write(out);
return out.toByteArray();
} catch (Exception exception) {
@@ -130,6 +181,97 @@ public class MerchantBatchImportService {
}
}
private static void createHeaders(XSSFWorkbook workbook, org.apache.poi.ss.usermodel.Sheet sheet) {
Font groupFont = workbook.createFont(); groupFont.setFontName("Microsoft YaHei");
groupFont.setBold(true); groupFont.setColor(IndexedColors.WHITE.getIndex());
CellStyle groupStyle = workbook.createCellStyle(); groupStyle.setFont(groupFont);
groupStyle.setFillForegroundColor(IndexedColors.DARK_BLUE.getIndex()); groupStyle.setFillPattern(FillPatternType.SOLID_FOREGROUND);
groupStyle.setAlignment(HorizontalAlignment.CENTER); groupStyle.setVerticalAlignment(VerticalAlignment.CENTER);
groupStyle.setBorderBottom(BorderStyle.THIN); groupStyle.setBorderRight(BorderStyle.THIN);
Font headerFont = workbook.createFont(); headerFont.setFontName("Microsoft YaHei");
headerFont.setBold(true); headerFont.setColor(IndexedColors.DARK_BLUE.getIndex());
CellStyle headerStyle = workbook.createCellStyle(); headerStyle.setFont(headerFont);
headerStyle.setFillForegroundColor(IndexedColors.PALE_BLUE.getIndex()); headerStyle.setFillPattern(FillPatternType.SOLID_FOREGROUND);
headerStyle.setAlignment(HorizontalAlignment.CENTER); headerStyle.setVerticalAlignment(VerticalAlignment.CENTER);
headerStyle.setWrapText(true); headerStyle.setBorderBottom(BorderStyle.THIN); headerStyle.setBorderRight(BorderStyle.THIN);
Row groupRow = sheet.createRow(0); groupRow.setHeightInPoints(26);
Row headerRow = sheet.createRow(1); headerRow.setHeightInPoints(38);
var drawing = sheet.createDrawingPatriarch();
var creation = workbook.getCreationHelper();
int start = 0;
while (start < COLUMNS.size()) {
String group = COLUMNS.get(start).group();
int end = start;
while (end + 1 < COLUMNS.size() && group.equals(COLUMNS.get(end + 1).group())) end++;
if (end > start) sheet.addMergedRegion(new CellRangeAddress(0, 0, start, end));
for (int col = start; col <= end; col++) {
Cell cell = groupRow.createCell(col); cell.setCellStyle(groupStyle);
if (col == start) cell.setCellValue(group);
}
start = end + 1;
}
for (int col = 0; col < COLUMNS.size(); col++) {
Column column = COLUMNS.get(col);
Cell cell = headerRow.createCell(col); cell.setCellValue(column.displayTitle()); cell.setCellStyle(headerStyle);
var anchor = creation.createClientAnchor();
anchor.setCol1(col); anchor.setCol2(Math.min(COLUMNS.size(), col + 3)); anchor.setRow1(1); anchor.setRow2(5);
var comment = drawing.createCellComment(anchor);
comment.setString(creation.createRichTextString(column.instruction())); comment.setAuthor("订单服务");
cell.setCellComment(comment);
sheet.setColumnWidth(col, Math.min(60, column.width()) * 256);
}
sheet.createFreezePane(1, 2);
sheet.setAutoFilter(new CellRangeAddress(1, 1, 0, COLUMNS.size() - 1));
}
private static void addDateValidation(org.apache.poi.ss.usermodel.DataValidationHelper helper,
org.apache.poi.ss.usermodel.Sheet sheet, int column, String message) {
var constraint = helper.createDateConstraint(DataValidationConstraint.OperatorType.BETWEEN,
"DATE(2000,1,1)", "DATE(2100,12,31)", "yyyy-mm-dd");
var validation = helper.createValidation(constraint,
new CellRangeAddressList(DATA_START_ROW, DATA_START_ROW + MAX_ROWS - 1, column, column));
validation.setEmptyCellAllowed(true); validation.setShowErrorBox(true);
validation.createErrorBox("日期格式不正确", message); sheet.addValidationData(validation);
}
private static void createGuide(XSSFWorkbook workbook) {
var guide = workbook.createSheet("填写说明");
Font titleFont = workbook.createFont(); titleFont.setFontName("Microsoft YaHei");
titleFont.setBold(true); titleFont.setFontHeightInPoints((short) 14);
CellStyle titleStyle = workbook.createCellStyle(); titleStyle.setFont(titleFont);
guide.createRow(0).createCell(0).setCellValue("头程订单批量导入填写说明");
guide.getRow(0).getCell(0).setCellStyle(titleStyle);
guide.createRow(1).createCell(0).setCellValue("订单导入页每行代表一张订单,最多50行;示例不会放入导入区域。动态选项在导入时按当前客户重新校验。");
Row header = guide.createRow(3);
String[] titles = {"字段", "什么时候填写 / 说明", "格式", "是否必填"};
for (int col = 0; col < titles.length; col++) header.createCell(col).setCellValue(titles[col]);
int rowNo = 4;
for (Column column : COLUMNS) {
Row row = guide.createRow(rowNo++);
row.createCell(0).setCellValue(column.title()); row.createCell(1).setCellValue(column.instruction());
row.createCell(2).setCellValue(formatDescription(column));
row.createCell(3).setCellValue(column.required() ? "必填" : "按条件/选填");
}
guide.setColumnWidth(0, 26 * 256); guide.setColumnWidth(1, 62 * 256);
guide.setColumnWidth(2, 28 * 256); guide.setColumnWidth(3, 16 * 256);
guide.createFreezePane(0, 4); guide.setAutoFilter(new CellRangeAddress(3, 3, 0, 3));
}
private static String formatDescription(Column column) {
if ("expectedShipDate".equals(column.field())) return "日期:yyyy-mm-dd";
if ("requestedPickupAt".equals(column.field())) return "日期时间:yyyy-mm-dd hh:mm:ss";
if (column.optionKey() != null) return "从下拉列表选择";
if (column.field().contains("Count")) return "正整数";
if (column.field().contains("Weight") || column.field().contains("volume")) return "数字";
return "文本";
}
private static Column col(String group, String title, String field, String optionKey, boolean required,
String instruction, int width) {
return new Column(group, title, field, optionKey, required, instruction, width);
}
public MerchantBatchImportDtos.Response importFile(long tenantId, long userId, String username, String authorization,
String idempotencyKey, MerchantBatchImportDtos.Mode mode,
MultipartFile file) {
@@ -154,16 +296,16 @@ public class MerchantBatchImportService {
Catalog catalog = catalog(tenantId, authorization);
try (var workbook = new XSSFWorkbook(new ByteArrayInputStream(content))) {
var sheet = workbook.getSheetAt(0);
if (sheet.getLastRowNum() < 1) throw new BizException(ResultCode.VALIDATION_FAILED, "Excel 没有订单数据行");
if (sheet.getLastRowNum() < DATA_START_ROW) throw new BizException(ResultCode.VALIDATION_FAILED, "Excel 没有订单数据行");
List<MerchantBatchDraftDtos.Item> items = new ArrayList<>();
Row header = sheet.getRow(0);
if (header == null || !COLUMNS.get(0).title().equals(text(header.getCell(0)))) {
Row header = sheet.getRow(1);
if (header == null || !COLUMNS.get(0).displayTitle().equals(text(header.getCell(0)))) {
throw new BizException(ResultCode.VALIDATION_FAILED, "Excel 模板表头不正确");
}
if (header.getLastCellNum() != COLUMNS.size()) throw new BizException(ResultCode.VALIDATION_FAILED, "Excel 模板表头不正确");
for (int col = 0; col < COLUMNS.size(); col++) if (!COLUMNS.get(col).title().equals(text(header.getCell(col))))
for (int col = 0; col < COLUMNS.size(); col++) if (!COLUMNS.get(col).displayTitle().equals(text(header.getCell(col))))
throw new BizException(ResultCode.VALIDATION_FAILED, "Excel 模板表头不正确");
for (int rowNo = 1; rowNo <= sheet.getLastRowNum(); rowNo++) {
for (int rowNo = DATA_START_ROW; rowNo <= sheet.getLastRowNum(); rowNo++) {
Row row = sheet.getRow(rowNo);
if (row == null || !StringUtils.hasText(text(row.getCell(0)))) continue;
if (items.size() >= MAX_ROWS) throw new BizException(ResultCode.VALIDATION_FAILED, "Excel 最多 50 条订单");
@@ -171,14 +313,26 @@ public class MerchantBatchImportService {
try {
var draft = json.createObjectNode();
var cargo = json.createObjectNode();
boolean fbaSelected = false;
boolean overseasSelected = false;
boolean privateSelected = false;
for (int col = 1; col < COLUMNS.size(); col++) {
String field = COLUMNS.get(col).field(); String value = text(row.getCell(col));
String field = COLUMNS.get(col).field();
String value = value(row.getCell(col), field);
if (!StringUtils.hasText(field) || !StringUtils.hasText(value)) continue;
value = VALUE_CODES.getOrDefault(value, value);
if (field.startsWith("$")) { applySelection(draft, field, value, catalog); continue; }
if (field.startsWith("$")) {
applySelection(draft, field, value, catalog);
fbaSelected |= "$fbaWarehouse".equals(field);
overseasSelected |= "$overseasWarehouse".equals(field);
privateSelected |= "$privateAddress".equals(field);
continue;
}
if (field.startsWith("cargo.")) cargo.put(field.substring("cargo.".length()), value);
else draft.put(field, value);
}
validateDestination(draft.path("destinationType").asText(null),
fbaSelected, overseasSelected, privateSelected);
draft.set("cargoes", json.createArrayNode().add(cargo));
items.add(new MerchantBatchDraftDtos.Item(rowId,
json.treeToValue(draft, OrderRequests.SaveRequest.class)));
@@ -203,7 +357,7 @@ public class MerchantBatchImportService {
int rowNo = 1;
for (var item : result.items()) if (!item.success()) {
Row row = sheet.createRow(rowNo++);
row.createCell(0).setCellValue(item.index() + 2);
row.createCell(0).setCellValue(item.index() + DATA_START_ROW + 1);
row.createCell(1).setCellValue(item.clientRowId());
row.createCell(2).setCellValue(item.errorCode());
row.createCell(3).setCellValue(item.errorMessage());
@@ -221,7 +375,9 @@ public class MerchantBatchImportService {
var receiving = optionMap(allWarehouses.stream().filter(value -> "DOMESTIC".equals(value.warehouseType())).toList(),
value -> label(value.warehouseName(), value.warehouseCode()));
if (receiving.isEmpty()) throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "暂无可用国内收货仓");
var destination = optionMap(allWarehouses.stream().filter(value -> !"DOMESTIC".equals(value.warehouseType())).toList(),
var fba = optionMap(allWarehouses.stream().filter(value -> "FBA".equals(value.warehouseType())).toList(),
value -> label(value.warehouseName(), value.warehouseCode()));
var overseas = optionMap(allWarehouses.stream().filter(value -> "OVERSEAS".equals(value.warehouseType())).toList(),
value -> label(value.warehouseName(), value.warehouseCode()));
Map<String, MerchantPricingCatalogClient.Channel> channels = new LinkedHashMap<>();
for (var warehouse : receiving.values()) {
@@ -241,7 +397,7 @@ public class MerchantBatchImportService {
return new Catalog(
addressOptions(addresses.list(tenantId, "PICKUP")),
addressOptions(addresses.list(tenantId, "PRIVATE_DESTINATION")),
receiving, destination, channels);
receiving, fba, overseas, channels);
} catch (BizException exception) {
throw exception;
} catch (RuntimeException exception) {
@@ -319,15 +475,19 @@ public class MerchantBatchImportService {
String value, Catalog catalog) {
switch (field) {
case "$senderAddress" -> draft.put("senderAddressId", required(catalog.pickupAddresses(), value, "取货地址").id());
case "$privateAddress" -> draft.put("privateDestinationAddressId", required(catalog.privateAddresses(), value, "私人目的仓地址").id());
case "$privateAddress" -> {
var address = required(catalog.privateAddresses(), value, "私人目的仓地址");
draft.put("privateDestinationAddressId", address.id());
draft.put("destinationCountryCode", address.countryCode());
}
case "$receivingWarehouse" -> {
var warehouse = required(catalog.receivingWarehouses(), value, "国内收货仓");
draft.put("receivingWarehouseId", warehouse.id()); draft.put("receivingWarehouseName", warehouse.warehouseName());
}
case "$destinationWarehouse" -> {
var warehouse = required(catalog.destinationWarehouses(), value, "目的仓库");
draft.put("destinationWarehouseId", warehouse.id()); draft.put("destinationWarehouseName", warehouse.warehouseName());
}
case "$fbaWarehouse" -> applyDestinationWarehouse(draft,
required(catalog.fbaWarehouses(), value, "FBA目的仓"), true);
case "$overseasWarehouse" -> applyDestinationWarehouse(draft,
required(catalog.overseasWarehouses(), value, "海外仓目的仓"), false);
case "$channel" -> {
var channel = required(catalog.channels(), value, "运输渠道");
draft.put("channelCode", channel.channelCode()); draft.put("channelName", channel.channelName());
@@ -342,15 +502,44 @@ public class MerchantBatchImportService {
return selected;
}
private static String label(String name, String code) { return name + "|" + code; }
private static void applyDestinationWarehouse(com.fasterxml.jackson.databind.node.ObjectNode draft,
MerchantWarehouseCatalogClient.Warehouse warehouse,
boolean fba) {
draft.put("destinationWarehouseId", warehouse.id());
draft.put("destinationWarehouseName", warehouse.warehouseName());
draft.put("destinationCountryCode", warehouse.countryCode());
if (fba) draft.put("amazonWarehouseCode", warehouse.warehouseCode());
}
private static void validateDestination(String destinationType, boolean fba, boolean overseas, boolean privateAddress) {
if ("FBA".equals(destinationType) && fba && !overseas && !privateAddress) return;
if ("OVERSEAS".equals(destinationType) && overseas && !fba && !privateAddress) return;
if ("PRIVATE".equals(destinationType) && privateAddress && !fba && !overseas) return;
if (!StringUtils.hasText(destinationType)) throw new BizException(ResultCode.VALIDATION_FAILED, "请选择目的类型");
throw new BizException(ResultCode.VALIDATION_FAILED, switch (destinationType) {
case "FBA" -> "亚马逊FBA订单必须且只能选择FBA目的仓";
case "OVERSEAS" -> "海外仓订单必须且只能选择海外仓目的仓";
case "PRIVATE" -> "私人地址订单必须且只能选择私人目的仓地址";
default -> "目的类型不正确";
});
}
private static String label(String name, String code) {
if (!StringUtils.hasText(name)) return code;
return name.equalsIgnoreCase(code) ? code : name + "|" + code;
}
private static String columnLetter(int column) { return String.valueOf((char) ('A' + column)); }
private record Column(String title, String field, String optionKey) { }
private record Column(String group, String title, String field, String optionKey, boolean required,
String instruction, int width) {
String displayTitle() { return required ? title + " *" : title; }
}
private record Catalog(
Map<String, CustomerAddressDtos.Response> pickupAddresses,
Map<String, CustomerAddressDtos.Response> privateAddresses,
Map<String, MerchantWarehouseCatalogClient.Warehouse> receivingWarehouses,
Map<String, MerchantWarehouseCatalogClient.Warehouse> destinationWarehouses,
Map<String, MerchantWarehouseCatalogClient.Warehouse> fbaWarehouses,
Map<String, MerchantWarehouseCatalogClient.Warehouse> overseasWarehouses,
Map<String, MerchantPricingCatalogClient.Channel> channels) { }
private Long upload(long tenantId, long userId, String batchNo, String type, MultipartFile file) {
@@ -373,11 +562,31 @@ public class MerchantBatchImportService {
private static String text(Cell cell) {
if (cell == null) return null;
cell.setCellType(org.apache.poi.ss.usermodel.CellType.STRING);
String value = cell.getStringCellValue();
String value = DATA_FORMATTER.get().formatCellValue(cell);
return value == null ? null : value.trim();
}
private static String value(Cell cell, String field) {
String formatted = text(cell);
if (!StringUtils.hasText(formatted)) return null;
boolean date = "expectedShipDate".equals(field);
boolean dateTime = "requestedPickupAt".equals(field);
if (!date && !dateTime) return formatted;
try {
if (cell.getCellType() == org.apache.poi.ss.usermodel.CellType.NUMERIC) {
if (!DateUtil.isValidExcelDate(cell.getNumericCellValue())) throw new DateTimeParseException("invalid", formatted, 0);
LocalDateTime excelValue = DateUtil.getLocalDateTime(cell.getNumericCellValue());
return date ? DATE.format(excelValue.toLocalDate())
: DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(excelValue.withNano(0));
}
if (date) return DATE.format(LocalDate.parse(formatted, DATE));
return DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(LocalDateTime.parse(formatted, DATE_TIME));
} catch (RuntimeException exception) {
throw new BizException(ResultCode.VALIDATION_FAILED,
date ? "预计出运日期格式必须为yyyy-mm-dd" : "预约提货时间格式必须为yyyy-mm-dd hh:mm:ss");
}
}
private static String safeName(String name) { return StringUtils.hasText(name) ? name : "order-batch.xlsx"; }
private static String batchNo(long tenantId, long userId, String key) {
if (!StringUtils.hasText(key)) throw new BizException(ResultCode.BAD_REQUEST, "必须提供 Idempotency-Key");
@@ -71,8 +71,10 @@ paths:
get:
summary: 下载 Excel 批量下单模板
description: >-
返回仅含中文业务列名的 xlsx。静态业务枚举使用中文下拉;仓库、渠道和客户地址按当前登录客户及正式主数据实时生成,
工作表不暴露内部 ID。示例与填写规则位于独立说明页,不占用可导入数据行。导入时重新校验动态选项的状态、归属和组合有效性。
返回两级中文业务表头的 xlsx,按订单基础、目的地与仓库、国内提货与运输、报关与计价、FBA业务信息、货物信息分组。
静态业务枚举使用中文下拉;仓库按全部 ACTIVE 分页结果构建,FBA目的仓仅包含 FBA,海外仓目的仓仅包含 OVERSEAS,
客户地址按当前客户生成。预计出运日期和预约提货时间提供日期格式及校验。工作表不暴露内部 ID,权威仓库选择自动推导目的国、
目的仓 ID/名称及 FBA 仓库代码。示例与逐列填写规则位于独立说明页,不占用50行编辑区。
responses:
'200': {description: xlsx 模板}
'403': {description: 需要 merchant:order:create 权限}
@@ -83,6 +85,7 @@ paths:
ATOMIC 在任一行失败时整批回滚;PARTIAL 每行独立事务,错误行不影响成功行。
同一操作者重试必须复用 Idempotency-Key,每行再以 clientRowId 派生稳定幂等键。
仅接受当前中文模板,中文下拉值由服务端映射为权威代码,不兼容历史英文技术表头。
导入支持 Excel 原生日期值,并重新校验动态选项;FBA、OVERSEAS、PRIVATE 必须分别且只能填写对应目的仓字段。
原文件和错误报告分别通过 File 的 ORDER_BATCH_IMPORT_SOURCE、ORDER_BATCH_IMPORT_ERROR_REPORT 契约保存。
parameters:
- $ref: '#/components/parameters/IdempotencyKey'
@@ -13,6 +13,8 @@ import org.springframework.test.util.ReflectionTestUtils;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
@@ -34,13 +36,14 @@ class MerchantBatchImportServiceTest {
ReflectionTestUtils.setField(service, "addresses", addresses);
ReflectionTestUtils.setField(service, "warehouses", warehouses);
ReflectionTestUtils.setField(service, "pricing", pricing);
ReflectionTestUtils.setField(service, "json", new ObjectMapper());
ReflectionTestUtils.setField(service, "json", new ObjectMapper().findAndRegisterModules());
when(addresses.list(88L, "PICKUP")).thenReturn(List.of(address(11L, "工厂", "PICKUP")));
when(addresses.list(88L, "PRIVATE_DESTINATION")).thenReturn(List.of(address(12L, "美国门店", "PRIVATE_DESTINATION")));
var domestic = new MerchantWarehouseCatalogClient.Warehouse(21L, "CN-YW", "义乌收货仓", "DOMESTIC", "CN", "ACTIVE");
var overseas = new MerchantWarehouseCatalogClient.Warehouse(22L, "US-LAX", "洛杉矶海外仓", "OVERSEAS", "US", "ACTIVE");
var fba = new MerchantWarehouseCatalogClient.Warehouse(22L, "ATL6", "ATL6", "FBA", "US", "ACTIVE");
var overseas = new MerchantWarehouseCatalogClient.Warehouse(23L, "US-LAX", "洛杉矶海外仓", "OVERSEAS", "US", "ACTIVE");
when(warehouses.page("Bearer test", 1, 200, "ACTIVE"))
.thenReturn(ApiResponse.success(new PageResult<>(List.of(domestic, overseas), 1, 200, 2, 1)));
.thenReturn(ApiResponse.success(new PageResult<>(List.of(domestic, fba, overseas), 1, 200, 3, 1)));
when(pricing.channels("Bearer test", 21L)).thenReturn(ApiResponse.success(List.of(
new MerchantPricingCatalogClient.Channel("US-SEA", "美森海运"))));
}
@@ -50,14 +53,27 @@ class MerchantBatchImportServiceTest {
byte[] content = service.template(88L, "Bearer test");
try (var workbook = new XSSFWorkbook(new ByteArrayInputStream(content))) {
var sheet = workbook.getSheet("订单导入");
assertThat(sheet.getRow(0).getCell(0).getStringCellValue()).isEqualTo("客户行号");
assertThat(sheet.getRow(0).getCell(10).getStringCellValue()).isEqualTo("取货地址");
assertThat(sheet.getRow(0).getCell(11).getStringCellValue()).isEqualTo("国内收货仓");
assertThat(sheet.getRow(0).getCell(29).getStringCellValue()).isEqualTo("是否木制品");
assertThat((Object) sheet.getRow(1)).isNull();
assertThat(sheet.getRow(0).getCell(0).getStringCellValue()).isEqualTo("订单基础");
assertThat(sheet.getRow(0).getCell(6).getStringCellValue()).isEqualTo("目的地与仓库");
assertThat(sheet.getRow(1).getCell(0).getStringCellValue()).isEqualTo("客户行号 *");
assertThat(sheet.getRow(1).getCell(6).getStringCellValue()).isEqualTo("目的类型 *");
assertThat(sheet.getRow(1).getCell(7).getStringCellValue()).isEqualTo("FBA目的仓");
assertThat(sheet.getRow(1).getCell(8).getStringCellValue()).isEqualTo("海外仓目的仓");
assertThat(sheet.getRow(1).getLastCellNum()).isEqualTo((short) 34);
assertThat(rowValues(sheet.getRow(1))).doesNotContain("目的国家代码", "目的仓库名称", "亚马逊仓库代码");
assertThat(sheet.getRow(2).getCell(0).getStringCellValue()).isEmpty();
assertThat(sheet.getRow(51).getCell(0).getStringCellValue()).isEmpty();
assertThat(sheet.getRow(2).getCell(4).getCellStyle().getDataFormatString()).isEqualTo("yyyy-mm-dd");
assertThat(sheet.getRow(2).getCell(11).getCellStyle().getDataFormatString()).isEqualTo("yyyy-mm-dd hh:mm:ss");
assertThat(sheet.getPaneInformation().isFreezePane()).isTrue();
assertThat(sheet.getMergedRegions()).hasSize(6);
assertThat(sheet.getCTWorksheet().isSetAutoFilter()).isTrue();
assertThat(sheet.getRow(1).getCell(0).getCellComment()).isNotNull();
assertThat(sheet.getDataValidations()).hasSizeGreaterThanOrEqualTo(10);
assertThat(workbook.getSheet("填写说明")).isNotNull();
assertThat(workbook.isSheetHidden(workbook.getSheetIndex("选项数据"))).isTrue();
assertThat(hasCellValue(workbook.getSheet("选项数据"), "ATL6")).isTrue();
assertThat(hasCellValue(workbook.getSheet("选项数据"), "洛杉矶海外仓|US-LAX")).isTrue();
}
}
@@ -65,16 +81,18 @@ class MerchantBatchImportServiceTest {
void importMapsChineseAndBusinessLabelsToCodesAndAuthoritativeIds() throws Exception {
byte[] content = service.template(88L, "Bearer test");
try (var workbook = new XSSFWorkbook(new ByteArrayInputStream(content)); var out = new ByteArrayOutputStream()) {
var row = workbook.getSheet("订单导入").createRow(1);
var row = workbook.getSheet("订单导入").getRow(2);
row.createCell(0).setCellValue("第1行"); row.createCell(1).setCellValue("测试客户");
row.createCell(2).setCellValue("海运"); row.createCell(8).setCellValue("平台上门提货");
row.createCell(10).setCellValue("工厂|张三|深圳|科技园1号");
row.createCell(11).setCellValue("义乌收货仓|CN-YW"); row.createCell(12).setCellValue("美森海运|US-SEA");
row.createCell(13).setCellValue("买单报关"); row.createCell(14).setCellValue("不包税");
row.createCell(17).setCellValue("私人地址"); row.createCell(18).setCellValue("美国门店|张三|深圳|科技园1号");
row.createCell(23).setCellValue("蓝牙音箱"); row.createCell(24).setCellValue("纸箱");
row.createCell(25).setCellValue("1"); row.createCell(26).setCellValue("1.2"); row.createCell(27).setCellValue("0.1");
row.createCell(29).setCellValue("否"); row.createCell(30).setCellValue("是");
row.createCell(2).setCellValue("海运"); row.getCell(4).setCellValue(LocalDate.of(2026, 9, 20));
row.createCell(6).setCellValue("亚马逊FBA"); row.createCell(7).setCellValue("ATL6");
row.createCell(10).setCellValue("平台上门提货");
row.getCell(11).setCellValue(LocalDateTime.of(2026, 9, 10, 14, 30, 0));
row.createCell(12).setCellValue("工厂|张三|深圳|科技园1号");
row.createCell(13).setCellValue("义乌收货仓|CN-YW"); row.createCell(14).setCellValue("美森海运|US-SEA");
row.createCell(15).setCellValue("买单报关"); row.createCell(16).setCellValue("不包税");
row.createCell(22).setCellValue("蓝牙音箱"); row.createCell(23).setCellValue("纸箱");
row.createCell(24).setCellValue("1"); row.createCell(25).setCellValue("1.2"); row.createCell(26).setCellValue("0.1");
row.createCell(28).setCellValue("否"); row.createCell(29).setCellValue("是");
workbook.write(out);
var item = service.parse(88L, "Bearer test", out.toByteArray()).get(0);
assertThat(item.parseError()).isNull();
@@ -84,11 +102,31 @@ class MerchantBatchImportServiceTest {
assertThat(item.draft().receivingWarehouseId()).isEqualTo(21L);
assertThat(item.draft().channelCode()).isEqualTo("US-SEA");
assertThat(item.draft().taxMode()).isEqualTo("BUY_DECLARATION");
assertThat(item.draft().destinationWarehouseId()).isEqualTo(22L);
assertThat(item.draft().destinationWarehouseName()).isEqualTo("ATL6");
assertThat(item.draft().destinationCountryCode()).isEqualTo("US");
assertThat(item.draft().amazonWarehouseCode()).isEqualTo("ATL6");
assertThat(item.draft().expectedShipDate()).isEqualTo(LocalDate.of(2026, 9, 20));
assertThat(item.draft().requestedPickupAt()).isEqualTo(LocalDateTime.of(2026, 9, 10, 14, 30, 0));
assertThat(item.draft().cargoes().get(0).packageType()).isEqualTo("CARTON");
assertThat(item.draft().cargoes().get(0).dangerousGoods()).isTrue();
}
}
@Test
void importRejectsOverseasWarehouseWhenDestinationTypeIsFba() throws Exception {
byte[] content = service.template(88L, "Bearer test");
try (var workbook = new XSSFWorkbook(new ByteArrayInputStream(content)); var out = new ByteArrayOutputStream()) {
var row = workbook.getSheet("订单导入").getRow(2);
row.createCell(0).setCellValue("第1行"); row.createCell(6).setCellValue("亚马逊FBA");
row.createCell(8).setCellValue("洛杉矶海外仓|US-LAX");
workbook.write(out);
var item = service.parse(88L, "Bearer test", out.toByteArray()).get(0);
assertThat(item.draft()).isNull();
assertThat(item.parseError()).isEqualTo("亚马逊FBA订单必须且只能选择FBA目的仓");
}
}
@Test
void templateReadsEveryActiveWarehousePageWithoutApplyingBusinessLimit() throws Exception {
List<MerchantWarehouseCatalogClient.Warehouse> firstPage = new ArrayList<>();
@@ -135,6 +173,12 @@ class MerchantBatchImportServiceTest {
return false;
}
private static List<String> rowValues(org.apache.poi.ss.usermodel.Row row) {
List<String> values = new ArrayList<>();
for (var cell : row) values.add(cell.getStringCellValue());
return values;
}
private static CustomerAddressDtos.Response address(long id, String name, String type) {
return new CustomerAddressDtos.Response(id, type, name, "测试公司", "张三", "13800000000", null,
"CN", "广东", "深圳", "南山", "科技园1号", "518000", true);
+23 -1
View File
@@ -167,6 +167,28 @@ Assert-Contains $orderAgents '`channelCode` 直接来自 Order/Firstleg 权威
Assert-Contains $orderAgents 'InboundCodeAssociationService.java' 'Order 入仓码业务关系入口'
Assert-Contains $firstlegAgents '/loading-plans/{planId}/lifecycle' 'Firstleg 装柜生命周期入口'
# 本轮新增的批量下单、测量计费、智能装柜、账单联查与导出重试必须从最近指引直达。
$fileAgents = Read-RequiredText (Join-Path $backendRoot 'file/AGENTS.md')
$financeAgents = Read-RequiredText (Join-Path $backendRoot 'finance/AGENTS.md')
Assert-Contains $rootAgents '客户头程订单批量导入' '根批量下单快速路由'
Assert-Contains $orderAgents 'MerchantBatchImportService.java' 'Order 批量导入入口'
Assert-Contains $orderAgents '仓库目录必须翻页读取全部候选' 'Order 批量模板全量仓库约束'
Assert-Contains $clientAgents '中文批量导入' 'Client 批量导入入口'
Assert-Contains $firstlegAgents '客户四档箱规与默认算法' 'Firstleg 客户箱规入口'
Assert-Contains $firstlegAgents '`ADD/MULTIPLY`' 'Firstleg 箱规算法约束'
Assert-Contains $adminAgents '客户箱规策略与计价主数据' 'Admin 箱规计费入口'
Assert-Contains $backendAgents '全局智能装柜候选、柜次与箱数分配' '后端全局智能装柜路由'
Assert-Contains $firstlegAgents '同柜不可拆组必须成员完整、满箱且作为单个分页与分配单元' 'Firstleg 智能装柜组分页边界'
Assert-Contains $adminAgents 'GlobalSmartLoadingView.vue' 'Admin 全局智能装柜入口'
Assert-Contains $financeAgents 'StatementLine.inboundCode' 'Finance 账单入仓码字段'
Assert-Contains $clientAgents '账单行只显示服务端 `inboundCode`' 'Client 账单入仓码联查边界'
Assert-Contains $fileAgents '`PENDING` 且已有失败尝试' 'File 导出退避重试边界'
Assert-Contains $adminAgents 'attemptCount/maxAttempts/nextRetryAt' 'Admin 导出重试状态入口'
Assert-Path 'baiyue-firstleg/order/src/main/java/com/baiyue/order/service/MerchantBatchImportService.java'
Assert-Path 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/service/CustomerDimensionWeightingService.java'
Assert-Path 'apps/admin-web/src/modules/container-loading/pages/GlobalSmartLoadingView.vue'
Assert-Path 'apps/client-web/src/modules/finance/views/FinanceBillsView.vue'
# 平台修改客户原始申报必须能从各层索引直达,并锁住 Order -> Firstleg 的版本化同步与渠道门禁。
$orderOpenApi = Read-RequiredText (Join-Path $backendRoot 'order/src/main/resources/openapi/order-v1.yaml')
$firstlegOpenApi = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/resources/openapi/firstleg-v1.yaml')
@@ -915,5 +937,5 @@ if ($failures.Count -gt 0) {
exit 1
}
Write-Host "AGENTS 索引合同通过:$checked 项检查,覆盖快速表唯一性、部署单元、开发/测试任务登记隔离、前端模块、Client active 路由、脚本目录治理、Finance 扩展 HTTP 模块、柜费跨客户原子分摊、海外五类费用跨区入池与直接已付隔离、企业联系信息与登录标识解耦、选填法人身份证独立上传、待审核订单状态优先、订单申报版本同步与渠道门禁、装柜证据内容哈希重放、业务编号与内部 ID 隔离、装柜清单生命周期与真实柜号后补、订舱级拖车与柜级证据、四/五期范围边界、四期柜级待处理计划筛选与范围漂移、仓库主档唯一权威、订舱候选与柜量语义、整柜出仓与船节点单调性、Gateway 覆盖、PDA 页面/运行配置/独立更新/语言预留/能力开关、人工分段验收链、Maven 模块、Lastleg 海外安全投影、同步 Trace、关键入口和排除目录。" -ForegroundColor Green
Write-Host "AGENTS 索引合同通过:$checked 项检查,覆盖快速表唯一性、部署单元、开发/测试任务登记隔离、前端模块、Client active 路由、客户中文批量下单、箱规算法与测量计费、全局智能装柜组分页、客户账单入仓码联查、导出退避任务重试、脚本目录治理、Finance 扩展 HTTP 模块、柜费跨客户原子分摊、海外五类费用跨区入池与直接已付隔离、企业联系信息与登录标识解耦、选填法人身份证独立上传、待审核订单状态优先、订单申报版本同步与渠道门禁、装柜证据内容哈希重放、业务编号与内部 ID 隔离、装柜清单生命周期与真实柜号后补、订舱级拖车与柜级证据、四/五期范围边界、四期柜级待处理计划筛选与范围漂移、仓库主档唯一权威、订舱候选与柜量语义、整柜出仓与船节点单调性、Gateway 覆盖、PDA 页面/运行配置/独立更新/语言预留/能力开关、人工分段验收链、Maven 模块、Lastleg 海外安全投影、同步 Trace、关键入口和排除目录。" -ForegroundColor Green
exit 0