Merge remote-tracking branch 'origin/test' into dev
# Conflicts: # AGENTS.md # firstleg/AGENTS.md # scripts/agents-index-contract.ps1
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user