Compare commits

...
17 Commits
Author SHA1 Message Date
liuyihe c3bf4edd53 二期内容提交 2026-08-04 19:48:35 +08:00
ouyangxq 954adb1287 Merge branch 'test-01' of http://8.163.30.128:10082/java-project/baiyue-firstleg into test-01 2026-08-04 18:54:23 +08:00
ouyangxq dc2f844467 打印服务 2026-08-04 18:54:22 +08:00
liuyihe 3292f2c597 二期内容提交 2026-08-04 17:29:36 +08:00
liuyihe b7d36d4766 二期内容提交 2026-08-04 17:18:07 +08:00
ouyangxq db72bdf884 Merge branch 'test-01' of http://8.163.30.128:10082/java-project/baiyue-firstleg into test-01 2026-08-04 15:41:21 +08:00
ouyangxq f1dcb91fa9 添加AGENTS.md 快速索引 2026-08-04 15:41:19 +08:00
liuyihe 2b712f9cdf Merge remote-tracking branch 'origin/test-01' into test-01 2026-08-04 14:56:12 +08:00
liuyihe 656f05d3c6 二期内容提交 2026-08-04 14:55:58 +08:00
liuyihe 4d8b31429c Merge remote-tracking branch 'origin/test' into test-01 2026-08-04 14:55:52 +08:00
ouyangxq f330098ff6 Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into test-01 2026-08-04 14:54:57 +08:00
liuyihe 7e945229be Merge remote-tracking branch 'origin/test' into test-01
# Conflicts:
#	config/nacos/cn/file-service.yml
#	config/nacos/local/file-service.yml
#	deploy/sql/README.md
#	firstleg/pom.xml
2026-08-04 12:27:25 +08:00
liuyihe addde4375e 二期内容提交 2026-08-04 12:23:46 +08:00
liuyihe 25d2a13581 二期内容提交 2026-08-04 10:24:43 +08:00
liuyihe 57b54a5236 二期内容提交 2026-08-04 10:07:45 +08:00
liuyihe b2b4ff429d 暂存 2026-08-03 17:26:59 +08:00
liuyihe 28e9b45314 暂存 2026-08-03 16:40:46 +08:00
263 changed files with 18900 additions and 225 deletions
+1
View File
@@ -24,3 +24,4 @@ reports/runtime*/
reports/uploads/
reports/db-backup*/
uploads/
.codex-temp*
+26 -6
View File
@@ -3,7 +3,7 @@
## 适用范围
- 本文件适用于 `baiyue-firstleg/` 整个 Maven 多模块仓库;进入子模块后同时遵守该模块的 `AGENTS.md`。
- 开始修改前先读根目录 `README.md`、`pom.xml` 和 `docs/头程项目目录结构与服务说明.md`;业务边界以本文件及该说明的“项目定位、主要调用关系、开发和拆分约束”为准。
- 普通开发先读本文件、目标模块 `AGENTS.md` 和相邻代码;项目概览或聚合构建问题再读 `README.md`、`pom.xml`,跨服务架构、端口数据库、根目录结构、启动或部署问题再读 `docs/头程项目目录结构与服务说明.md`。
- 本仓只承载国内头程业务。尾程源码、数据库、Redis、Nacos 和内部接口不得并入本仓。
## 模块快速索引
@@ -20,11 +20,11 @@
| `gateway/` | 路由、CORS、入口安全、请求标识、访问域隔离 | `config/`、`filter/CorrelationIdWebFilter.java`、`src/main/resources/application*.yml` |
| `auth/` | 登录、令牌、注册、账号、密码与账号归属范围 | `InternalAccountController.java`、`AccountManagementService.java`、`AccountManagementDtos.java`、`src/main/resources/db/` |
| `iam/` | 租户、商户成员、平台统一用户管理、企业资料/资质、RBAC 与授权快照 | `PlatformUserManagementController.java`、`PlatformTenantController.java`、`ProfileResourceController.java`、`RbacController.java` |
| `order/` | 订单主数据、租户运单数、持久化幂等、状态编排、快照与 Outbox | `PlatformFirstLegOrderController.java`、`OrderApplicationService.java`、`TenantOrderCountResponse.java`、`README-ORDER.md` |
| `firstleg/` | 履约任务分页/详情、操作分配与分配历史 | `PlatformFulfillmentAssignmentController.java`、`FulfillmentTaskService.java`、`src/main/resources/db/phase1-firstleg-task-list-index.sql` |
| `warehouse/` | 仓库、地址、服务能力、往来单位多类型、快递公司与 XLSX 导入 | `controller/`、`PlatformPartnerQueryService.java`、`PartnerImportService.java`、`ExpressCompanyImportService.java` |
| `file/` | 企业资质文件与公共导出/下载中心 | 资质:`QualificationFileController.java`、`LocalQualificationStorage.java`;导出:`PlatformExportTaskController.java`、`ExportStateService.java`、`mq/`、`GenericXlsxExportRunner.java`、`ExportReconciler.java` |
| `print/` | 打印中心边界 | 当前为应用、健康检查和 `print/src/main/resources/db/print.sql` 骨架 |
| `order/` | 订单主数据、租户运单数、持久化幂等、状态编排、快照、货物明细内部查询与 Outbox | `PlatformFirstLegOrderController.java`、`OrderApplicationService.java`、`InternalOrderCargoController.java`、`README-ORDER.md` |
| `firstleg/` | 履约任务与分配;二期入库单、测量/OCR 结果、审核、FBA 计费和自动完成入库 | 履约:`PlatformFulfillmentAssignmentController.java`、`FulfillmentTaskService.java`;入库:`controller/PlatformInbound*.java`、`service/Inbound*.java`、`service/Fba*.java` |
| `warehouse/` | 仓库、地址、服务能力、往来单位、快递公司;二期托盘、库位、预占和占用 | 主数据:`controller/`、`PlatformPartnerQueryService.java`;二期:`PlatformPalletController.java`、`PlatformLocationController.java`、`PalletLocationAllocationService.java` |
| `file/` | 企业资质文件、公共导出/下载中心;入库测量图片与重量 OCR 协作 | 资质:`QualificationFileController.java`;导出:`PlatformExportTaskController.java`、`ExportStateService.java`;测量图片:`PlatformMeasurementImageController.java`、`InternalMeasurementImageController.java`、`MeasurementImageService.java` |
| `print/` | 无数据库的无状态 PDF 渲染;固定入仓标签模板、二维码、字体嵌入和资源保护 | `InternalPdfController.java`、`InboundMarkPdfService.java`、`InboundMarkV1Renderer.java`、`src/main/resources/openapi/print-internal-v1.yaml` |
| `audit-service/` | 审计事件接收与持久化 | `AuditIngestController.java`、`AuditIngestService.java`;当前接收接口未实现内部令牌保护 |
| `integration-service/` | 重量 OCR、百度 OCR、Amazon 等第三方适配 | `controller/WeightOcrController.java`、`service/ocr/`、`provider/ocr/`、`IntegrationProperties.java` |
| `event-bridge-service/` | 跨区域事件接收、校验与 Inbox 幂等 | `EventBridgeController.java`、`EventInboxService.java` |
@@ -39,9 +39,28 @@
| 平台代客户上传或替换资质 | `file/.../controller/PlatformQualificationFileController.java` → `client/IamQualificationClient.java` → `iam/.../controller/InternalTenantController.java` 的 `/platform-qualifications` |
| 往来单位多类型 | `warehouse/.../service/PlatformPartnerQueryService.java`、`entity/PartnerTypeEntity.java`、`src/main/resources/db/warehouse-partner.sql`,消费者看 `order/.../client/PartnerClient.java` |
| 往来单位/快递公司批量导入 | `warehouse/.../service/PartnerImportService.java`、`ExpressCompanyImportService.java`、两个平台 Controller 的 `/import-template` 与 `/import` |
| 入仓标签 PDF 生成 | 调用方先读 `print/README.md`;实现为 `print/.../controller/InternalPdfController.java` → `service/InboundMarkPdfService.java` → `renderer/InboundMarkV1Renderer.java`;机器契约见 `print/src/main/resources/openapi/print-internal-v1.yaml`,仅供携带内部令牌的服务调用,不加入 Gateway |
| HTTP 200、body code、成功码 `"200"` | `docs/统一响应约定改造方案-HTTP200.md`、`common-core/ResultCode.java`、`common-web/GlobalExceptionHandler.java` |
| 单服务 Jenkins 构建或部署 | 总控 `Jenkinsfile`、子 Job `Jenkinsfile.service`、模块/JAR 映射 `deploy/service-map.yml` |
## 二期入库快速索引
| 业务链路 | API 与编排入口 | 数据、跨服务与验证入口 |
|---|---|---|
| 平台入库单列表、页签与聚合详情 | `firstleg/.../controller/PlatformInboundOrderController.java` → `service/InboundOrderQueryService.java` | `repository/InboundOrderRepository.java`、`mapper/InboundAllocationQueryMapper.java`;测试 `service/InboundOrderQueryServiceTest.java`、`controller/InboundOrderControllerContractTest.java` |
| 平台测量草稿、提交及重量 OCR 结果 | `firstleg/.../controller/PlatformInboundMeasurementController.java` → `service/InboundMeasurementService.java`、`MeasurementSubmissionService.java` | 图片走 `client/MeasurementImageClient.java`,托盘库位走 `client/WarehousePalletLocationClient.java`;测试 `service/InboundMeasurementServiceTest.java`、`MeasurementSubmissionServiceTest.java` |
| 第三方尺寸测量回传 | `firstleg/.../controller/OpenApiInboundMeasurementController.java` → `security/MeasurementRequestAuthenticator.java` → `service/InboundMeasurementOpenApiService.java` | 订单货物校验走 `client/OrderCargoClient.java` → `order/.../controller/InternalOrderCargoController.java`;证据图片走 File 内部接口;配置 `config/MeasurementOpenApiProperties.java`,测试 `service/InboundMeasurementOpenApiServiceTest.java` |
| 测量审核、异常放行与自动完成入库 | `firstleg/.../controller/PlatformInboundMeasurementReviewController.java` → `service/MeasurementReviewService.java` → `AutomaticInboundCompletionService.java` | 费用与入库状态编排在 `FbaPricingService.java`、`InboundCompletionOrchestrator.java`;测试 `service/MeasurementReviewServiceTest.java`、`InboundCompletionOrchestratorTest.java` |
| FBA 客户规则、承运商价格与费用快照 | 主数据 `firstleg/.../controller/PlatformFbaMasterDataController.java` → `service/FbaMasterDataService.java`;计费 `PlatformFbaPricingController.java` → `service/FbaPricingService.java` | `repository/FbaPricingRepository.java`、`src/main/resources/openapi/fba-pricing-v1.yaml`、`src/main/resources/db/phase2-fba-pricing-migration.sql`;测试 `service/FbaMasterDataServiceTest.java`、`FbaPricingServiceTest.java` |
| 商户查看入库费用 | `firstleg/.../controller/MerchantInboundBillingController.java` → `service/MerchantInboundBillingService.java` | 只按已认证商户租户查询;接口为 `/api/merchant/v1/first-leg/orders/{orderId}/inbound-billing` |
| 托盘、库位与预占/占用 | 平台 CRUD:`warehouse/.../controller/PlatformPalletController.java`、`PlatformLocationController.java`;内部事务接口:`InternalPalletLocationController.java` | `service/PalletApplicationService.java`、`LocationApplicationService.java`、`PalletLocationAllocationService.java`;表与迁移在 `warehouse/src/main/resources/db/phase2-*.sql` |
| 称重图片、OCR 重试与第三方测量证据 | 平台入口 `file/.../controller/PlatformMeasurementImageController.java`;Firstleg 内部入口 `InternalMeasurementImageController.java` | `service/MeasurementImageService.java`、`MeasurementImageStorage.java`、`client/WeightOcrClient.java`;重量识别服务入口 `integration-service/.../controller/WeightOcrController.java` |
| 入库状态、幂等、历史与 Outbox | `firstleg/.../domain/InboundProcessStatus.java`、`service/InboundIdempotencyService.java`、`repository/InboundWorkflowRepository.java` | 表结构与增量在 `firstleg/src/main/resources/db/phase2-*.sql`;SQL 契约测试 `repository/InboundWorkflowSqlContractTest.java` |
| 二期数据库部署 | 测试环境/存量环境统一入口 `deploy/sql/2026-08-04-二期入库新增表字段-MySQL8一键执行.sql` | 覆盖 `baiyue_file`、`baiyue_firstleg`、`baiyue_warehouse`;前置、可重复执行边界和末尾 `PASS` 核验见脚本头及 `deploy/sql/README.md`,不得在未核对目标库和备份的情况下执行 |
- 上表中的 `模块/.../` 从对应模块的 `src/main/java/com/baiyue/<domain>/` 起算;二期链路跨 `order`、`firstleg`、`warehouse`、`file`、`integration-service`,修改 DTO、内部令牌、状态或表结构时必须逐个检查调用方与被调用方。
- 二期入库主状态归 `firstleg`,订单货物主数据仍归 `order`,托盘/库位资源归 `warehouse`,文件对象归 `file`,OCR 供应商适配归 `integration-service`;禁止通过跨库查询绕过内部 API。
## 配置、数据库与验证入口
- 服务本地配置在各模块 `src/main/resources/application.yml`;Nacos 环境配置集中在 `config/nacos/local/` 和 `config/nacos/cn/`。
@@ -54,6 +73,7 @@
- 平台管理与头程履约补齐的本地数据库只读核验、SQL 清单和回归结果见 `docs/2026-07-31平台管理与头程履约补齐回归报告.md`。
- IAM 授角、订单主数据、File、Gateway CORS 与文件日志的本轮结果见 `docs/2026-07-31-P0-P2问题修复回归报告.md`。
- 本轮测试环境数据库增量统一从 `deploy/sql/2026-07-31一期回归测试环境增量迁移.sql` 执行,执行边界与核验方式见 `deploy/sql/README.md`。
- 二期入库新建或存量测试环境升级使用 `deploy/sql/2026-08-04-二期入库新增表字段-MySQL8一键执行.sql`;旧的 `2026-08-03-二期入库表结构-TEST环境一键执行.sql` 只保留为早期基线参考,不替代 8 月 4 日最终增量入口。
- CI 总控入口是 `Jenkinsfile`,负责按变更或勾选项并行调度 11 个独立服务 Job;单服务构建、镜像与部署逻辑在 `Jenkinsfile.service`。Job 名必须以模块名结尾,公共模块或 `deploy/` 变化会触发全部服务。
- 从本目录运行全量校验:`mvn -DskipTests validate`、`mvn test`。单模块修改优先运行 `mvn -pl <module> -am test`。
- 公共导出中心测试环境真实任务验收使用 `scripts/export-center-test-env-acceptance.ps1`;脚本只调用 Gateway,不负责部署或停止 Broker/File/IAM,故障注入由有权限的运维配合。
+2 -2
View File
@@ -9,7 +9,7 @@
- `order`:订单主数据及订单状态编排。
- `firstleg`:头程揽收、入仓、装载、发运和交接。
- `warehouse`:国内仓储能力。
- `file`、`print`、`audit-service`、`integration-service`:国内公共业务能力。
- `file`、`print`、`audit-service`、`integration-service`:国内公共业务能力;Print 调用方接入见 `print/README.md`。
- `event-bridge-service`:跨区域事件接收幂等和投递记录基础骨架;真实跨区发送、重试调度和死信补投仍需完成。
- `common-*`、`common-event-contract`:由本仓维护并以 `com.baiyue.shared` 发布的公共制品。
- `platform-bom`:发布 `com.baiyue.shared:baiyue-platform-bom:1.0.0-SNAPSHOT`,尾程仓只通过固定版本引用。
@@ -39,4 +39,4 @@ CORS_ALLOWED_ORIGINS=https://merchant.example.com,https://admin.example.com
## 仓库边界
详细规则见 `docs/repository-boundary.md`。本仓不得引入尾程源码,不允许跨区域共享数据库、Redis 或 Nacos,也不允许通过公网直接调用对方内部服务。
仓库边界、服务调用和拆分规则见 `docs/头程项目目录结构与服务说明.md` 的“项目定位”“主要调用关系”和“开发和拆分约束”。本仓不得引入尾程源码,不允许跨区域共享数据库、Redis 或 Nacos,也不允许通过公网直接调用对方内部服务。
@@ -10,6 +10,7 @@ import org.springframework.boot.autoconfigure.SpringBootApplication;
@MapperScan("com.baiyue.audit.mapper")
@SpringBootApplication
public class BaiyueAuditApplication {
public static void main(String[] args) {
SpringApplication.run(BaiyueAuditApplication.class, args);
}
@@ -76,6 +76,7 @@ public class BaiyueSecurityAutoConfiguration {
.authorizeHttpRequests(auth -> auth
.requestMatchers("/actuator/health", "/v3/api-docs/**", "/swagger-ui/**").permitAll()
.requestMatchers("/internal/**").permitAll()
.requestMatchers("/openapi/**").permitAll()
.requestMatchers("/api/merchant/**").hasAuthority("APP_MERCHANT")
.requestMatchers("/api/platform/**").hasAuthority("APP_PLATFORM")
.anyRequest().authenticated())
+1
View File
@@ -7,6 +7,7 @@ spring:
# 当前代码只实现本地/共享文件目录,非本地环境必须显式注入并挂载可写目录。
baiyue.file.storage-root: ${FILE_STORAGE_ROOT}
baiyue.clients.integration.url: ${INTEGRATION_SERVICE_URL:}
baiyue:
file:
export:
+20
View File
@@ -4,3 +4,23 @@ spring:
username: ${FIRSTLEG_DB_USERNAME}
password: ${FIRSTLEG_DB_PASSWORD}
driver-class-name: com.mysql.cj.jdbc.Driver
baiyue.clients.warehouse.url: ${WAREHOUSE_SERVICE_URL:}
baiyue.clients.order.url: ${ORDER_SERVICE_URL:}
baiyue.clients.file.url: ${FILE_SERVICE_URL:}
firstleg:
inbound:
allow-rejected-measurement: false
default-pallet-capacity-cbm: ${DEFAULT_PALLET_CAPACITY_CBM:1.200000}
reservation-ttl: ${PALLET_RESERVATION_TTL:30m}
measurement-openapi:
enabled: ${MEASUREMENT_OPENAPI_ENABLED:false}
max-clock-skew: ${MEASUREMENT_OPENAPI_MAX_CLOCK_SKEW:5m}
nonce-ttl: ${MEASUREMENT_OPENAPI_NONCE_TTL:10m}
apps:
device-provider:
enabled: ${MEASUREMENT_DEVICE_PROVIDER_ENABLED:false}
secret: ${MEASUREMENT_DEVICE_PROVIDER_SECRET:}
tenant-id: ${MEASUREMENT_DEVICE_PROVIDER_TENANT_ID:0}
subject-id: ${MEASUREMENT_DEVICE_PROVIDER_SUBJECT_ID:0}
warehouse-bindings:
CN-YW-01: ${MEASUREMENT_DEVICE_PROVIDER_WAREHOUSE_ID:0}
+11 -6
View File
@@ -1,6 +1,11 @@
spring:
datasource:
url: ${PRINT_DB_URL}
username: ${PRINT_DB_USERNAME}
password: ${PRINT_DB_PASSWORD}
driver-class-name: com.mysql.cj.jdbc.Driver
print:
pdf:
max-pages: ${PRINT_PDF_MAX_PAGES:2000}
max-request-bytes: ${PRINT_PDF_MAX_REQUEST_BYTES:2097152}
max-output-bytes: ${PRINT_PDF_MAX_OUTPUT_BYTES:33554432}
max-concurrent-generations: ${PRINT_PDF_MAX_CONCURRENT_GENERATIONS:4}
acquire-timeout: ${PRINT_PDF_ACQUIRE_TIMEOUT:1s}
template-code: INBOUND_MARK_V1
supported-width-mm: 100
supported-height-mm: 100
font-resource: fonts/NotoSansSC-Bold.ttf
+1
View File
@@ -17,3 +17,4 @@ baiyue:
max-rows: 200000
max-attempts: 3
batch-size: 500
baiyue.clients.integration.url: ${INTEGRATION_SERVICE_URL:}
+21
View File
@@ -3,3 +3,24 @@ spring.datasource:
url: ${FIRSTLEG_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_firstleg?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true}
username: ${FIRSTLEG_DB_USERNAME:root}
password: ${FIRSTLEG_DB_PASSWORD:123456}
baiyue.clients.warehouse.url: ${WAREHOUSE_SERVICE_URL:}
baiyue.clients.order.url: ${ORDER_SERVICE_URL:}
baiyue.clients.file.url: ${FILE_SERVICE_URL:}
firstleg:
inbound:
allow-rejected-measurement: false
default-pallet-capacity-cbm: ${DEFAULT_PALLET_CAPACITY_CBM:1.200000}
reservation-ttl: ${PALLET_RESERVATION_TTL:30m}
warehouse-resource-tenant-id: ${WAREHOUSE_RESOURCE_TENANT_ID:0}
measurement-openapi:
enabled: ${MEASUREMENT_OPENAPI_ENABLED:false}
max-clock-skew: ${MEASUREMENT_OPENAPI_MAX_CLOCK_SKEW:5m}
nonce-ttl: ${MEASUREMENT_OPENAPI_NONCE_TTL:10m}
apps:
device-provider:
enabled: ${MEASUREMENT_DEVICE_PROVIDER_ENABLED:false}
secret: ${MEASUREMENT_DEVICE_PROVIDER_SECRET:}
tenant-id: ${MEASUREMENT_DEVICE_PROVIDER_TENANT_ID:0}
subject-id: ${MEASUREMENT_DEVICE_PROVIDER_SUBJECT_ID:0}
warehouse-bindings:
CN-YW-01: ${MEASUREMENT_DEVICE_PROVIDER_WAREHOUSE_ID:0}
+11 -5
View File
@@ -1,5 +1,11 @@
spring.datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: ${PRINT_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_print?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true}
username: ${PRINT_DB_USERNAME:root}
password: ${PRINT_DB_PASSWORD:123456}
print:
pdf:
max-pages: ${PRINT_PDF_MAX_PAGES:2000}
max-request-bytes: ${PRINT_PDF_MAX_REQUEST_BYTES:2097152}
max-output-bytes: ${PRINT_PDF_MAX_OUTPUT_BYTES:33554432}
max-concurrent-generations: ${PRINT_PDF_MAX_CONCURRENT_GENERATIONS:4}
acquire-timeout: ${PRINT_PDF_ACQUIRE_TIMEOUT:1s}
template-code: INBOUND_MARK_V1
supported-width-mm: 100
supported-height-mm: 100
font-resource: fonts/NotoSansSC-Bold.ttf
+1 -3
View File
@@ -142,9 +142,7 @@ services:
environment:
<<: *common-environment
SERVER_PORT: 9207
PRINT_DB_URL: ${PRINT_DB_URL}
PRINT_DB_USERNAME: ${PRINT_DB_USERNAME}
PRINT_DB_PASSWORD: ${PRINT_DB_PASSWORD}
JAVA_TOOL_OPTIONS: "-Xms512m -Xmx1g -XX:+ExitOnOutOfMemoryError -Dfile.encoding=UTF-8"
integration-service:
<<: *common
@@ -0,0 +1,202 @@
-- 一期头程下单前置增量迁移。
-- 适用:旧版测试库已存在基础表,但缺少客户档案、往来单位或订单一期字段。
-- 特性:仅新增缺失字段/表,可重复执行;不删除数据,不写入订单业务数据。
SET NAMES utf8mb4;
-- 1. IAM:下单资格校验会查询以下租户档案字段。
USE baiyue_iam;
SET @ddl = IF(
EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'iam_tenant' AND column_name = 'service_package'),
'SELECT 1', 'ALTER TABLE iam_tenant ADD COLUMN service_package VARCHAR(64) NOT NULL DEFAULT ''基础版'' COMMENT ''客户服务套餐'' AFTER contact_email');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(
EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'iam_tenant' AND column_name = 'contact_wechat'),
'SELECT 1', 'ALTER TABLE iam_tenant ADD COLUMN contact_wechat VARCHAR(64) NULL COMMENT ''联系微信'' AFTER contact_email');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(
EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'iam_tenant' AND column_name = 'sales_owner_name'),
'SELECT 1', 'ALTER TABLE iam_tenant ADD COLUMN sales_owner_name VARCHAR(64) NULL COMMENT ''销售负责人姓名'' AFTER contact_wechat');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(
EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'iam_tenant' AND column_name = 'payment_term_days'),
'SELECT 1', 'ALTER TABLE iam_tenant ADD COLUMN payment_term_days SMALLINT UNSIGNED NULL COMMENT ''账期天数'' AFTER sales_owner_name');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(
EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'iam_tenant' AND column_name = 'enterprise_type'),
'SELECT 1', 'ALTER TABLE iam_tenant ADD COLUMN enterprise_type VARCHAR(64) NULL COMMENT ''企业类型'' AFTER payment_term_days');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(
EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'iam_tenant' AND column_name = 'established_date'),
'SELECT 1', 'ALTER TABLE iam_tenant ADD COLUMN established_date DATE NULL COMMENT ''成立日期'' AFTER enterprise_type');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(
EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'iam_tenant' AND column_name = 'registered_capital'),
'SELECT 1', 'ALTER TABLE iam_tenant ADD COLUMN registered_capital DECIMAL(18,2) NULL COMMENT ''注册资本'' AFTER established_date');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(
EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'iam_tenant' AND column_name = 'registered_capital_currency'),
'SELECT 1', 'ALTER TABLE iam_tenant ADD COLUMN registered_capital_currency CHAR(3) NULL COMMENT ''注册资本币种'' AFTER registered_capital');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(
EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'iam_tenant' AND column_name = 'registered_address'),
'SELECT 1', 'ALTER TABLE iam_tenant ADD COLUMN registered_address VARCHAR(500) NULL COMMENT ''注册地址'' AFTER registered_capital_currency');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(
EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'iam_tenant' AND column_name = 'internal_remark'),
'SELECT 1', 'ALTER TABLE iam_tenant ADD COLUMN internal_remark VARCHAR(1000) NULL COMMENT ''平台内部备注'' AFTER registered_address');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 2. Warehouse:订单主数据校验依赖往来单位及其 SHIPPER 类型。
USE baiyue_warehouse;
DROP PROCEDURE IF EXISTS order_prerequisite_add_partner_column;
DELIMITER $$
CREATE PROCEDURE order_prerequisite_add_partner_column(
IN p_column VARCHAR(64),
IN p_definition TEXT
)
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'wh_partner' AND column_name = p_column
) THEN
SET @sql = CONCAT('ALTER TABLE wh_partner ADD COLUMN `', p_column, '` ', p_definition);
PREPARE add_column_stmt FROM @sql;
EXECUTE add_column_stmt;
DEALLOCATE PREPARE add_column_stmt;
END IF;
END$$
DELIMITER ;
CALL order_prerequisite_add_partner_column('tenant_id', 'BIGINT UNSIGNED NOT NULL DEFAULT 0');
CALL order_prerequisite_add_partner_column('data_scope_id', 'BIGINT UNSIGNED NOT NULL DEFAULT 0');
CALL order_prerequisite_add_partner_column('partner_code', 'VARCHAR(50) NOT NULL DEFAULT ''''');
CALL order_prerequisite_add_partner_column('partner_name', 'VARCHAR(128) NOT NULL DEFAULT ''''');
CALL order_prerequisite_add_partner_column('partner_type', 'VARCHAR(20) NOT NULL DEFAULT ''CUSTOMER''');
CALL order_prerequisite_add_partner_column('contact_name', 'VARCHAR(64) NOT NULL DEFAULT ''''');
CALL order_prerequisite_add_partner_column('contact_phone', 'VARCHAR(32) NOT NULL DEFAULT ''''');
CALL order_prerequisite_add_partner_column('contact_email', 'VARCHAR(128) NULL');
CALL order_prerequisite_add_partner_column('country_code', 'CHAR(2) NOT NULL DEFAULT ''CN''');
CALL order_prerequisite_add_partner_column('province', 'VARCHAR(64) NULL');
CALL order_prerequisite_add_partner_column('city', 'VARCHAR(64) NOT NULL DEFAULT ''''');
CALL order_prerequisite_add_partner_column('district', 'VARCHAR(64) NULL');
CALL order_prerequisite_add_partner_column('address_line', 'VARCHAR(255) NOT NULL DEFAULT ''''');
CALL order_prerequisite_add_partner_column('postal_code', 'VARCHAR(20) NULL');
CALL order_prerequisite_add_partner_column('status', 'VARCHAR(20) NOT NULL DEFAULT ''ACTIVE''');
CALL order_prerequisite_add_partner_column('remark', 'VARCHAR(500) NULL');
CALL order_prerequisite_add_partner_column('created_by', 'BIGINT UNSIGNED NULL');
CALL order_prerequisite_add_partner_column('created_at', 'DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)');
CALL order_prerequisite_add_partner_column('updated_by', 'BIGINT UNSIGNED NULL');
CALL order_prerequisite_add_partner_column('updated_at', 'DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)');
CALL order_prerequisite_add_partner_column('deleted', 'TINYINT(1) NOT NULL DEFAULT 0');
CALL order_prerequisite_add_partner_column('version', 'INT NOT NULL DEFAULT 0');
DROP PROCEDURE IF EXISTS order_prerequisite_add_partner_column;
UPDATE wh_partner SET data_scope_id = tenant_id WHERE data_scope_id = 0;
CREATE TABLE IF NOT EXISTS wh_partner_type (
partner_id BIGINT UNSIGNED NOT NULL,
tenant_id BIGINT UNSIGNED NOT NULL,
data_scope_id BIGINT UNSIGNED NOT NULL,
partner_type VARCHAR(32) NOT NULL,
PRIMARY KEY (partner_id, partner_type),
KEY idx_wh_partner_type_scope (tenant_id, data_scope_id, partner_type, partner_id)
) ENGINE=InnoDB COMMENT='往来单位业务类型';
-- 将既有有效往来单位的兼容单值类型回填到类型关联表。
INSERT IGNORE INTO wh_partner_type(partner_id, tenant_id, data_scope_id, partner_type)
SELECT id, tenant_id, data_scope_id, partner_type
FROM wh_partner
WHERE deleted = 0 AND partner_type <> 'BOTH';
INSERT IGNORE INTO wh_partner_type(partner_id, tenant_id, data_scope_id, partner_type)
SELECT id, tenant_id, data_scope_id, 'CUSTOMER'
FROM wh_partner
WHERE deleted = 0 AND partner_type = 'BOTH';
INSERT IGNORE INTO wh_partner_type(partner_id, tenant_id, data_scope_id, partner_type)
SELECT id, tenant_id, data_scope_id, 'SUPPLIER'
FROM wh_partner
WHERE deleted = 0 AND partner_type = 'BOTH';
-- 一期演示商户的既有客户同时作为发货单位,供头程订单 customerId=191000000000000001 使用。
INSERT IGNORE INTO wh_partner_type(partner_id, tenant_id, data_scope_id, partner_type)
SELECT id, tenant_id, data_scope_id, 'SHIPPER'
FROM wh_partner
WHERE id = 191000000000000001 AND tenant_id = 10001 AND status = 'ACTIVE' AND deleted = 0;
-- 3. Order:草稿创建、货物快照和 Idempotency-Key 持久化。
USE baiyue_order;
DROP PROCEDURE IF EXISTS order_prerequisite_add_order_column;
DELIMITER $$
CREATE PROCEDURE order_prerequisite_add_order_column(
IN p_table VARCHAR(64),
IN p_column VARCHAR(64),
IN p_definition TEXT
)
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = p_table AND column_name = p_column
) THEN
SET @sql = CONCAT('ALTER TABLE `', p_table, '` ADD COLUMN `', p_column, '` ', p_definition);
PREPARE add_column_stmt FROM @sql;
EXECUTE add_column_stmt;
DEALLOCATE PREPARE add_column_stmt;
END IF;
END$$
DELIMITER ;
CALL order_prerequisite_add_order_column('ord_order', 'pickup_method', 'VARCHAR(24) NULL');
CALL order_prerequisite_add_order_column('ord_order', 'receiving_warehouse_id', 'BIGINT UNSIGNED NULL');
CALL order_prerequisite_add_order_column('ord_order', 'receiving_warehouse_name', 'VARCHAR(128) NULL');
CALL order_prerequisite_add_order_column('ord_order', 'receiving_warehouse_address', 'VARCHAR(600) NULL');
CALL order_prerequisite_add_order_column('ord_order', 'receiving_time', 'VARCHAR(128) NULL');
CALL order_prerequisite_add_order_column('ord_order', 'channel_code', 'VARCHAR(64) NULL');
CALL order_prerequisite_add_order_column('ord_order', 'channel_name', 'VARCHAR(128) NULL');
CALL order_prerequisite_add_order_column('ord_order', 'tax_mode', 'VARCHAR(24) NULL');
CALL order_prerequisite_add_order_column('ord_order', 'container_mode', 'VARCHAR(24) NULL');
CALL order_prerequisite_add_order_column('ord_order', 'customs_inspection', 'TINYINT(1) NULL');
CALL order_prerequisite_add_order_column('ord_order', 'destination_type', 'VARCHAR(24) NULL');
CALL order_prerequisite_add_order_column('ord_order', 'amazon_warehouse_code', 'VARCHAR(32) NULL');
CALL order_prerequisite_add_order_column('ord_order', 'shipment_id', 'VARCHAR(64) NULL');
CALL order_prerequisite_add_order_column('ord_order', 'reference_id', 'VARCHAR(64) NULL');
CALL order_prerequisite_add_order_column('ord_order', 'express_company', 'VARCHAR(64) NULL');
CALL order_prerequisite_add_order_column('ord_order', 'express_tracking_no', 'VARCHAR(128) NULL');
CALL order_prerequisite_add_order_column('ord_order_cargo', 'sku_no', 'VARCHAR(64) NULL');
CALL order_prerequisite_add_order_column('ord_order_cargo', 'upc_no', 'VARCHAR(64) NULL');
CALL order_prerequisite_add_order_column('ord_order_cargo', 'cargo_name_en', 'VARCHAR(128) NULL');
CALL order_prerequisite_add_order_column('ord_order_cargo', 'customs_hs_code', 'VARCHAR(20) NULL');
CALL order_prerequisite_add_order_column('ord_order_cargo', 'units_per_package', 'INT UNSIGNED NULL');
CALL order_prerequisite_add_order_column('ord_order_cargo', 'total_quantity', 'INT UNSIGNED NULL');
CALL order_prerequisite_add_order_column('ord_order_cargo', 'net_weight_kg', 'DECIMAL(18,3) NULL');
CALL order_prerequisite_add_order_column('ord_order_cargo', 'unit_declared_value', 'DECIMAL(18,2) NULL');
CALL order_prerequisite_add_order_column('ord_order_cargo', 'material', 'VARCHAR(128) NULL');
CALL order_prerequisite_add_order_column('ord_order_cargo', 'product_usage', 'VARCHAR(128) NULL');
CALL order_prerequisite_add_order_column('ord_order_cargo', 'model', 'VARCHAR(64) NULL');
CALL order_prerequisite_add_order_column('ord_order_cargo', 'brand', 'VARCHAR(64) NULL');
DROP PROCEDURE IF EXISTS order_prerequisite_add_order_column;
CREATE TABLE IF NOT EXISTS ord_idempotency_record (
id BIGINT UNSIGNED NOT NULL,
tenant_id BIGINT UNSIGNED NOT NULL,
operator_id BIGINT UNSIGNED NOT NULL,
operation VARCHAR(32) NOT NULL,
resource_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
idempotency_key VARCHAR(128) NOT NULL,
request_hash CHAR(64) NOT NULL,
status VARCHAR(16) NOT NULL,
result_order_id BIGINT UNSIGNED NULL,
completed_at DATETIME(3) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_order_idempotency_scope (tenant_id, operator_id, operation, idempotency_key),
KEY idx_order_idempotency_created (created_at)
) ENGINE=InnoDB COMMENT='订单幂等记录';
@@ -0,0 +1,699 @@
-- 柏悦头程二期入库表结构:TEST环境一键执行脚本
-- 生成日期:2026-08-03
-- 适用版本:MySQL 8.0及以上
-- 执行范围:只新增二期表、字段、索引和约束,不插入、更新或删除业务数据
-- 目标数据库:baiyue_firstleg、baiyue_warehouse
-- 前置条件:一期基线表 fl_fulfillment_task、wh_warehouse 已存在
-- 注意:MySQL DDL会自动提交,请使用具有目标库建表权限的测试环境账号执行
SET NAMES utf8mb4;
SET SESSION sql_mode = 'ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION';
SELECT VERSION() AS mysql_version,
'开始执行二期入库表结构' AS execution_message;
SET NAMES utf8mb4;
CREATE DATABASE IF NOT EXISTS baiyue_firstleg
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
USE baiyue_firstleg;
CREATE TABLE IF NOT EXISTS fl_inbound_order (
id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID(雪花ID)',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
task_id BIGINT UNSIGNED NULL COMMENT '头程履约任务ID',
order_id BIGINT UNSIGNED NOT NULL COMMENT '订单服务订单ID,不建立跨库外键',
order_no VARCHAR(32) NOT NULL COMMENT '订单号快照',
inbound_code VARCHAR(40) NOT NULL COMMENT '租户内唯一入仓码',
process_status VARCHAR(24) NOT NULL DEFAULT 'WAIT_MEASURE' COMMENT '入库流程状态',
order_status_snapshot VARCHAR(24) NOT NULL COMMENT '订单状态快照',
measure_review_status VARCHAR(24) NOT NULL DEFAULT 'NOT_SUBMITTED' COMMENT '测量数据审核状态',
fee_status VARCHAR(24) NOT NULL DEFAULT 'UNPRICED' COMMENT '费用业务状态',
fee_review_status VARCHAR(24) NOT NULL DEFAULT 'NOT_SUBMITTED' COMMENT '费用审核状态',
notification_status VARCHAR(24) NOT NULL DEFAULT 'NOT_CREATED' COMMENT '客户通知状态',
expected_arrival_at DATETIME(3) NULL COMMENT '预计到达时间',
customer_id BIGINT UNSIGNED NULL COMMENT '客户逻辑ID',
customer_name VARCHAR(128) NOT NULL COMMENT '客户名称快照',
company_id BIGINT UNSIGNED NULL COMMENT '运营公司逻辑ID',
company_name VARCHAR(128) NULL COMMENT '运营公司名称快照',
warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '实际作业仓库ID',
warehouse_name VARCHAR(128) NOT NULL COMMENT '实际作业仓库名称快照',
delivery_warehouse_id BIGINT UNSIGNED NULL COMMENT '交货仓逻辑ID',
delivery_warehouse_name VARCHAR(128) NULL COMMENT '交货仓名称快照',
business_type VARCHAR(32) NOT NULL COMMENT '业务类型',
channel_code VARCHAR(64) NULL COMMENT '渠道编码',
channel_name VARCHAR(128) NULL COMMENT '渠道名称快照',
tax_mode VARCHAR(24) NULL COMMENT '买单或退税模式',
container_mode VARCHAR(24) NULL COMMENT '整柜或散货模式',
customs_inspection TINYINT(1) NULL COMMENT '是否商检',
destination_type VARCHAR(24) NULL COMMENT '目的仓类型',
destination_warehouse_id BIGINT UNSIGNED NULL COMMENT '目的仓逻辑ID',
destination_code VARCHAR(32) NULL COMMENT '目的仓编码,例如FBA仓编码',
destination_name VARCHAR(128) NULL COMMENT '目的仓名称快照',
hold_flag TINYINT(1) NOT NULL DEFAULT 0 COMMENT '当前是否扣货',
current_hold_reason VARCHAR(500) NULL COMMENT '当前扣货理由快照',
total_package_count INT UNSIGNED NULL COMMENT '确认后的总箱数',
actual_gross_weight_kg DECIMAL(18,3) NULL COMMENT '确认后的实际总毛重kg',
chargeable_weight_kg DECIMAL(18,3) NULL COMMENT '当前费用快照的计费重量kg',
actual_volume_cbm DECIMAL(18,4) NULL COMMENT '确认后的实际总体积m3',
chargeable_volume_cbm DECIMAL(18,4) NULL COMMENT '当前费用快照的计费体积m3',
pallet_count INT UNSIGNED NULL COMMENT '已分配总托盘数快照',
volume_weight_kg DECIMAL(18,3) NULL COMMENT '总材积重kg',
actual_net_weight_kg DECIMAL(18,3) NULL COMMENT '总净重kg',
measure_completed_at DATETIME(3) NULL COMMENT '测量完成时间',
inbounded_at DATETIME(3) NULL COMMENT '入库完成时间',
express_company_code VARCHAR(64) NULL COMMENT '快递公司编码快照',
express_company_name VARCHAR(128) NULL COMMENT '快递公司名称快照',
express_tracking_no VARCHAR(128) NULL COMMENT '快递单号快照',
public_remark VARCHAR(1000) NULL COMMENT '客户可见备注',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
created_by_name VARCHAR(64) NULL COMMENT '创建人名称快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_inbound_tenant_code (tenant_id, inbound_code),
UNIQUE KEY uk_inbound_tenant_order (tenant_id, order_id),
KEY idx_inbound_process_time (tenant_id, process_status, created_at, id),
KEY idx_inbound_measure_time (tenant_id, measure_review_status, created_at, id),
KEY idx_inbound_fee_time (tenant_id, fee_review_status, created_at, id),
KEY idx_inbound_warehouse_status (tenant_id, warehouse_id, process_status, created_at),
KEY idx_inbound_customer_time (tenant_id, customer_id, created_at),
KEY idx_inbound_company_time (tenant_id, company_id, created_at),
KEY idx_inbound_arrival (tenant_id, expected_arrival_at),
KEY idx_inbound_destination (tenant_id, destination_code, created_at),
KEY idx_inbound_channel (tenant_id, channel_code, created_at),
CONSTRAINT fk_inbound_task FOREIGN KEY (task_id) REFERENCES fl_fulfillment_task (id)
) ENGINE=InnoDB COMMENT='二期头程入库单聚合根和列表查询快照';
CREATE TABLE IF NOT EXISTS fl_inbound_pickup_snapshot (
id BIGINT UNSIGNED NOT NULL COMMENT '提货信息快照ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
pickup_method VARCHAR(24) NULL COMMENT '提货方式',
pickup_at DATETIME(3) NULL COMMENT '提货时间',
contact_name VARCHAR(64) NULL COMMENT '提货联系人',
contact_phone VARCHAR(32) NULL COMMENT '提货联系人电话',
contact_email VARCHAR(128) NULL COMMENT '提货联系人邮箱',
country_code CHAR(2) NULL COMMENT '国家二字码',
province VARCHAR(64) NULL COMMENT '省或州',
city VARCHAR(64) NULL COMMENT '城市',
district VARCHAR(64) NULL COMMENT '区县',
address_line VARCHAR(512) NULL COMMENT '提货详细地址',
postal_code VARCHAR(20) NULL COMMENT '提货邮编',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
PRIMARY KEY (id),
UNIQUE KEY uk_pickup_inbound (tenant_id, inbound_id),
CONSTRAINT fk_pickup_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id)
) ENGINE=InnoDB COMMENT='入库单提货信息不可变快照';
CREATE TABLE IF NOT EXISTS fl_inbound_destination_snapshot (
id BIGINT UNSIGNED NOT NULL COMMENT '目的仓地址快照ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
destination_type VARCHAR(24) NULL COMMENT '目的仓类型',
destination_code VARCHAR(32) NULL COMMENT '目的仓编码',
destination_name VARCHAR(128) NULL COMMENT '目的仓名称',
country_code CHAR(2) NULL COMMENT '国家二字码',
state_province VARCHAR(100) NULL COMMENT '省或州',
city VARCHAR(100) NULL COMMENT '城市',
address_line VARCHAR(512) NULL COMMENT '详细地址',
postal_code VARCHAR(20) NULL COMMENT '邮编',
contact_name VARCHAR(64) NULL COMMENT '联系人',
contact_phone VARCHAR(32) NULL COMMENT '联系人电话',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
PRIMARY KEY (id),
UNIQUE KEY uk_destination_inbound (tenant_id, inbound_id),
CONSTRAINT fk_destination_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id)
) ENGINE=InnoDB COMMENT='入库单目的仓地址和联系人快照';
CREATE TABLE IF NOT EXISTS fl_inbound_transport_snapshot (
id BIGINT UNSIGNED NOT NULL COMMENT '运输信息快照ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
loading_status VARCHAR(24) NULL COMMENT '配载状态快照',
container_no VARCHAR(64) NULL COMMENT '集装箱号',
cabinet_no VARCHAR(64) NULL COMMENT '柜号或内部柜单号',
vessel_name VARCHAR(128) NULL COMMENT '船名',
voyage_no VARCHAR(64) NULL COMMENT '航次',
sailing_at DATETIME(3) NULL COMMENT '开船时间',
port_arrival_at DATETIME(3) NULL COMMENT '到港时间',
source_updated_at DATETIME(3) NULL COMMENT '上游数据更新时间',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY (id),
UNIQUE KEY uk_transport_inbound (tenant_id, inbound_id),
KEY idx_transport_container (tenant_id, container_no),
KEY idx_transport_cabinet (tenant_id, cabinet_no),
KEY idx_transport_loading (tenant_id, loading_status, sailing_at),
CONSTRAINT fk_transport_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id)
) ENGINE=InnoDB COMMENT='入库单关联运输信息只读快照';
CREATE TABLE IF NOT EXISTS fl_inbound_measurement (
id BIGINT UNSIGNED NOT NULL COMMENT '测量批次ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
source_type VARCHAR(24) NOT NULL COMMENT '数据来源:THIRD_PARTY或MANUAL',
source_code VARCHAR(64) NULL COMMENT '第三方应用或来源编码',
external_event_no VARCHAR(128) NULL COMMENT '第三方事件号',
device_code VARCHAR(64) NULL COMMENT '测量设备编码',
warehouse_code VARCHAR(50) NULL COMMENT '第三方回传仓库编码',
measured_at DATETIME(3) NULL COMMENT '设备测量时间',
status VARCHAR(24) NOT NULL DEFAULT 'RECEIVED' COMMENT '测量批次状态',
raw_payload_json JSON NULL COMMENT '第三方原始报文',
total_package_count INT UNSIGNED NULL COMMENT '本批次总箱数',
total_volume_raw_cbm DECIMAL(18,6) NULL COMMENT '本批次原始总体积m3',
total_volume_cbm DECIMAL(18,4) NULL COMMENT '本批次业务总体积m3',
created_by BIGINT UNSIGNED NULL COMMENT '创建人或系统主体ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY (id),
UNIQUE KEY uk_measure_external_event (tenant_id, source_code, external_event_no),
KEY idx_measure_inbound_time (tenant_id, inbound_id, created_at),
CONSTRAINT fk_measure_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id)
) ENGINE=InnoDB COMMENT='入库长宽高测量批次和第三方原始数据';
CREATE TABLE IF NOT EXISTS fl_inbound_measurement_item (
id BIGINT UNSIGNED NOT NULL COMMENT '测量明细ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
measurement_id BIGINT UNSIGNED NOT NULL COMMENT '测量批次ID',
item_no VARCHAR(32) NOT NULL COMMENT '批次内明细号',
package_count INT UNSIGNED NOT NULL COMMENT '相同箱规箱数',
length_cm DECIMAL(12,3) NOT NULL COMMENT '原始长度cm',
width_cm DECIMAL(12,3) NOT NULL COMMENT '原始宽度cm',
height_cm DECIMAL(12,3) NOT NULL COMMENT '原始高度cm',
volume_raw_cbm DECIMAL(18,6) NOT NULL COMMENT '原始体积m3',
volume_cbm DECIMAL(18,4) NOT NULL COMMENT '业务体积m3',
calculation_rule_version VARCHAR(32) NOT NULL COMMENT '体积计算和舍入规则版本',
source_image_file_id BIGINT UNSIGNED NULL COMMENT '测量设备图片文件ID',
remark VARCHAR(500) NULL COMMENT '测量明细备注',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
PRIMARY KEY (id),
UNIQUE KEY uk_measure_item_no (tenant_id, measurement_id, item_no),
CONSTRAINT fk_measure_item_batch FOREIGN KEY (measurement_id) REFERENCES fl_inbound_measurement (id)
) ENGINE=InnoDB COMMENT='入库长宽高和箱数测量明细';
CREATE TABLE IF NOT EXISTS fl_inbound_weight_result (
id BIGINT UNSIGNED NOT NULL COMMENT 'OCR重量结果ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
file_id BIGINT UNSIGNED NOT NULL COMMENT '称重图片文件ID',
ocr_provider VARCHAR(64) NULL COMMENT 'OCR服务商编码',
ocr_raw_text VARCHAR(1000) NULL COMMENT 'OCR原始文本',
ocr_weight_value DECIMAL(18,3) NULL COMMENT 'OCR识别原始重量值',
ocr_weight_unit VARCHAR(8) NULL COMMENT 'OCR识别原始单位',
confidence DECIMAL(6,5) NULL COMMENT '识别置信度',
adjusted_weight_value DECIMAL(18,3) NULL COMMENT '人工修正后的原单位重量',
adjusted_weight_unit VARCHAR(8) NULL COMMENT '人工修正后的单位',
final_weight_kg DECIMAL(18,3) NULL COMMENT '确认并统一换算后的重量kg',
recognition_status VARCHAR(24) NOT NULL DEFAULT 'PENDING' COMMENT '识别和确认状态',
adjustment_reason VARCHAR(500) NULL COMMENT '人工修正原因',
adjusted_by BIGINT UNSIGNED NULL COMMENT '修正人ID',
adjusted_at DATETIME(3) NULL COMMENT '修正时间',
confirmed_by BIGINT UNSIGNED NULL COMMENT '确认人ID',
confirmed_at DATETIME(3) NULL COMMENT '确认时间',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_weight_inbound_file (tenant_id, inbound_id, file_id),
KEY idx_weight_inbound_status (tenant_id, inbound_id, recognition_status),
CONSTRAINT fk_weight_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id)
) ENGINE=InnoDB COMMENT='称重图片OCR原值、人工修正值和确认重量';
CREATE TABLE IF NOT EXISTS fl_measure_review_record (
id BIGINT UNSIGNED NOT NULL COMMENT '测量审核记录ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
measurement_id BIGINT UNSIGNED NULL COMMENT '被审核的测量批次ID',
review_result VARCHAR(24) NOT NULL COMMENT '审核结果',
review_reason VARCHAR(1000) NULL COMMENT '审核或驳回原因',
exception_release TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否异常放行',
release_reason VARCHAR(1000) NULL COMMENT '异常放行原因',
allow_rejected_config TINYINT(1) NOT NULL DEFAULT 0 COMMENT '操作时Nacos异常放行开关值',
reviewer_id BIGINT UNSIGNED NOT NULL COMMENT '审核人ID',
reviewer_name VARCHAR(64) NULL COMMENT '审核人名称快照',
reviewed_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '审核时间',
PRIMARY KEY (id),
KEY idx_review_inbound_time (tenant_id, inbound_id, reviewed_at),
CONSTRAINT fk_review_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id),
CONSTRAINT fk_review_measurement FOREIGN KEY (measurement_id) REFERENCES fl_inbound_measurement (id)
) ENGINE=InnoDB COMMENT='测量审核、驳回和异常放行不可变记录';
CREATE TABLE IF NOT EXISTS fl_customer_charge_rule (
id BIGINT UNSIGNED NOT NULL COMMENT '客户计费规则ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
rule_code VARCHAR(50) NOT NULL COMMENT '租户内规则编码',
rule_version INT UNSIGNED NOT NULL COMMENT '规则版本号',
customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户逻辑ID',
customer_name VARCHAR(128) NOT NULL COMMENT '客户名称快照',
business_type VARCHAR(32) NOT NULL COMMENT '业务类型',
charge_mode VARCHAR(16) NOT NULL COMMENT '计费口径:KG或CBM',
tax_mode VARCHAR(24) NULL COMMENT '包税或不包税类型',
conversion_factor DECIMAL(18,6) NOT NULL COMMENT '重量体积换算系数',
rounding_scale TINYINT UNSIGNED NOT NULL DEFAULT 3 COMMENT '结果保留小数位数',
rounding_mode VARCHAR(24) NOT NULL DEFAULT 'CEILING' COMMENT '舍入方式',
currency CHAR(3) NOT NULL COMMENT '默认币种',
effective_from DATETIME(3) NOT NULL COMMENT '生效开始时间',
effective_to DATETIME(3) NULL COMMENT '生效结束时间',
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '规则状态',
remark VARCHAR(500) NULL COMMENT '规则备注',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记',
PRIMARY KEY (id),
UNIQUE KEY uk_charge_rule_version (tenant_id, rule_code, rule_version),
KEY idx_charge_rule_match (tenant_id, customer_id, business_type, status, effective_from, effective_to)
) ENGINE=InnoDB COMMENT='客户头程计费规则版本';
CREATE TABLE IF NOT EXISTS fl_carrier_fba_rate (
id BIGINT UNSIGNED NOT NULL COMMENT '船司FBA价格ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
rate_code VARCHAR(50) NOT NULL COMMENT '租户内价格编码',
rate_version INT UNSIGNED NOT NULL COMMENT '价格版本号',
origin_warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '起运仓逻辑ID',
origin_warehouse_name VARCHAR(128) NOT NULL COMMENT '起运仓名称快照',
channel_code VARCHAR(64) NULL COMMENT '渠道编码',
carrier_id BIGINT UNSIGNED NULL COMMENT '船司逻辑ID',
carrier_name VARCHAR(128) NOT NULL COMMENT '船司名称快照',
destination_code VARCHAR(32) NOT NULL COMMENT 'FBA目的仓编码',
charge_unit VARCHAR(16) NOT NULL COMMENT '计费单位:KG或CBM',
unit_price DECIMAL(18,6) NOT NULL COMMENT '基础单价',
currency CHAR(3) NOT NULL COMMENT '币种',
additional_fee DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '固定附加费',
effective_from DATETIME(3) NOT NULL COMMENT '生效开始时间',
effective_to DATETIME(3) NULL COMMENT '生效结束时间',
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '价格状态',
remark VARCHAR(500) NULL COMMENT '价格备注',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记',
PRIMARY KEY (id),
UNIQUE KEY uk_fba_rate_version (tenant_id, rate_code, rate_version),
KEY idx_fba_rate_match (tenant_id, origin_warehouse_id, channel_code, destination_code, charge_unit, status, effective_from)
) ENGINE=InnoDB COMMENT='船司FBA目的仓价格版本';
CREATE TABLE IF NOT EXISTS fl_inbound_fee (
id BIGINT UNSIGNED NOT NULL COMMENT '入库费用ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
fee_no VARCHAR(40) NOT NULL COMMENT '租户内唯一费用单号',
fee_version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '费用快照版本',
charge_rule_id BIGINT UNSIGNED NOT NULL COMMENT '客户计费规则ID',
charge_rule_version INT UNSIGNED NOT NULL COMMENT '客户规则版本快照',
carrier_rate_id BIGINT UNSIGNED NOT NULL COMMENT '船司FBA价格ID',
carrier_rate_version INT UNSIGNED NOT NULL COMMENT '船司价格版本快照',
charge_mode VARCHAR(16) NOT NULL COMMENT '计费口径:KG或CBM',
conversion_factor DECIMAL(18,6) NOT NULL COMMENT '使用的换算系数快照',
actual_weight_kg DECIMAL(18,3) NOT NULL COMMENT '试算时实际重量kg',
actual_volume_cbm DECIMAL(18,4) NOT NULL COMMENT '试算时实际体积m3',
converted_quantity DECIMAL(18,4) NOT NULL COMMENT '换算后的重量或体积',
chargeable_quantity DECIMAL(18,4) NOT NULL COMMENT '最终计费量',
charge_unit VARCHAR(16) NOT NULL COMMENT '计费单位',
currency CHAR(3) NOT NULL COMMENT '币种',
subtotal_amount DECIMAL(18,2) NOT NULL COMMENT '基础费用金额',
additional_amount DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '附加费金额',
total_amount DECIMAL(18,2) NOT NULL COMMENT '总金额',
fee_status VARCHAR(24) NOT NULL DEFAULT 'CALCULATED' COMMENT '费用状态',
review_status VARCHAR(24) NOT NULL DEFAULT 'NOT_SUBMITTED' COMMENT '费用审核状态',
exception_release TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否基于测量异常放行生成',
calculated_by BIGINT UNSIGNED NOT NULL COMMENT '试算人ID',
calculated_at DATETIME(3) NOT NULL COMMENT '试算时间',
confirmed_by BIGINT UNSIGNED NULL COMMENT '确认人ID',
confirmed_at DATETIME(3) NULL COMMENT '确认时间',
sent_by BIGINT UNSIGNED NULL COMMENT '发送人ID',
sent_at DATETIME(3) NULL COMMENT '发送客户时间',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY (id),
UNIQUE KEY uk_inbound_fee_no (tenant_id, fee_no),
UNIQUE KEY uk_inbound_fee_version (tenant_id, inbound_id, fee_version),
KEY idx_inbound_fee_status (tenant_id, inbound_id, fee_status, review_status),
CONSTRAINT fk_fee_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id),
CONSTRAINT fk_fee_charge_rule FOREIGN KEY (charge_rule_id) REFERENCES fl_customer_charge_rule (id),
CONSTRAINT fk_fee_carrier_rate FOREIGN KEY (carrier_rate_id) REFERENCES fl_carrier_fba_rate (id)
) ENGINE=InnoDB COMMENT='入库头程费用计算和确认快照';
CREATE TABLE IF NOT EXISTS fl_inbound_fee_item (
id BIGINT UNSIGNED NOT NULL COMMENT '费用明细ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
fee_id BIGINT UNSIGNED NOT NULL COMMENT '费用头ID',
item_no INT UNSIGNED NOT NULL COMMENT '费用单内序号',
fee_code VARCHAR(50) NOT NULL COMMENT '费用项目编码',
fee_name VARCHAR(128) NOT NULL COMMENT '费用项目名称快照',
quantity DECIMAL(18,4) NOT NULL COMMENT '计费数量',
unit VARCHAR(16) NOT NULL COMMENT '计费单位',
unit_price DECIMAL(18,6) NOT NULL COMMENT '单价',
amount DECIMAL(18,2) NOT NULL COMMENT '金额',
currency CHAR(3) NOT NULL COMMENT '币种',
automatic_flag TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否自动计算',
remark VARCHAR(500) NULL COMMENT '费用备注',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
PRIMARY KEY (id),
UNIQUE KEY uk_fee_item_no (tenant_id, fee_id, item_no),
CONSTRAINT fk_fee_item_header FOREIGN KEY (fee_id) REFERENCES fl_inbound_fee (id)
) ENGINE=InnoDB COMMENT='入库头程费用项目明细';
CREATE TABLE IF NOT EXISTS fl_inbound_pallet_allocation (
id BIGINT UNSIGNED NOT NULL COMMENT '头程托盘分配引用ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
allocation_no INT UNSIGNED NOT NULL COMMENT '入库单内分配序号',
warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '仓库服务仓库ID',
pallet_id BIGINT UNSIGNED NOT NULL COMMENT '仓库服务托盘ID',
pallet_code VARCHAR(50) NOT NULL COMMENT '托盘编码快照',
pallet_capacity_cbm DECIMAL(18,4) NOT NULL COMMENT '托盘可用容积快照',
allocated_volume_cbm DECIMAL(18,4) NOT NULL COMMENT '本托盘分配体积',
allocated_package_count INT UNSIGNED NULL COMMENT '本托盘分配箱数',
location_id BIGINT UNSIGNED NOT NULL COMMENT '仓库服务库位ID',
location_code VARCHAR(50) NOT NULL COMMENT '库位编码快照',
reservation_no VARCHAR(64) NOT NULL COMMENT '仓库库位预占单号',
allocation_status VARCHAR(24) NOT NULL DEFAULT 'RESERVED' COMMENT '分配状态',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY (id),
UNIQUE KEY uk_pallet_allocation_no (tenant_id, inbound_id, allocation_no),
UNIQUE KEY uk_pallet_allocation_pallet (tenant_id, inbound_id, pallet_id),
KEY idx_allocation_location (tenant_id, warehouse_id, location_id, allocation_status),
CONSTRAINT fk_allocation_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id)
) ENGINE=InnoDB COMMENT='头程侧托盘、库位和仓库预占结果引用';
CREATE TABLE IF NOT EXISTS fl_inbound_file (
id BIGINT UNSIGNED NOT NULL COMMENT '入库单文件关系ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
file_id BIGINT UNSIGNED NOT NULL COMMENT '文件服务文件ID',
file_type VARCHAR(24) NOT NULL COMMENT '文件类型:RELATED、MARK、PC_ORDER、MEASURE或WEIGHT',
file_name_snapshot VARCHAR(255) NOT NULL COMMENT '文件名快照',
visibility VARCHAR(16) NOT NULL DEFAULT 'INTERNAL' COMMENT '可见范围',
file_version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '业务文件版本',
uploaded_by BIGINT UNSIGNED NOT NULL COMMENT '上传人ID',
uploaded_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '上传时间',
deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记',
PRIMARY KEY (id),
UNIQUE KEY uk_inbound_file_relation (tenant_id, inbound_id, file_id, file_type),
KEY idx_inbound_file_type (tenant_id, inbound_id, file_type, deleted),
CONSTRAINT fk_file_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id)
) ENGINE=InnoDB COMMENT='入库单与文件中心对象的分类关系';
CREATE TABLE IF NOT EXISTS fl_inbound_hold_record (
id BIGINT UNSIGNED NOT NULL COMMENT '扣货记录ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
hold_action VARCHAR(16) NOT NULL COMMENT '动作:HOLD或RELEASE',
reason VARCHAR(1000) NOT NULL COMMENT '扣货或解除原因',
evidence_file_id BIGINT UNSIGNED NULL COMMENT '证据文件ID',
operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID',
operator_name VARCHAR(64) NULL COMMENT '操作人名称快照',
operated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作时间',
PRIMARY KEY (id),
KEY idx_hold_inbound_time (tenant_id, inbound_id, operated_at),
CONSTRAINT fk_hold_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id)
) ENGINE=InnoDB COMMENT='入库单扣货和解除扣货不可变历史';
CREATE TABLE IF NOT EXISTS fl_inbound_internal_note (
id BIGINT UNSIGNED NOT NULL COMMENT '客服内部备注ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
content VARCHAR(2000) NOT NULL COMMENT '仅内部可见备注',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
created_by_name VARCHAR(64) NULL COMMENT '创建人名称快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
PRIMARY KEY (id),
KEY idx_internal_note_time (tenant_id, inbound_id, created_at),
CONSTRAINT fk_internal_note_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id)
) ENGINE=InnoDB COMMENT='入库单客服内部备注历史';
CREATE TABLE IF NOT EXISTS fl_inbound_status_history (
id BIGINT UNSIGNED NOT NULL COMMENT '入库状态历史ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
from_status VARCHAR(24) NULL COMMENT '变更前状态',
to_status VARCHAR(24) NOT NULL COMMENT '变更后状态',
action VARCHAR(32) NOT NULL COMMENT '触发动作',
reason VARCHAR(1000) NULL COMMENT '变更原因',
operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID',
operator_name VARCHAR(64) NULL COMMENT '操作人名称快照',
occurred_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '发生时间',
PRIMARY KEY (id),
KEY idx_inbound_history_time (tenant_id, inbound_id, occurred_at),
CONSTRAINT fk_history_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id)
) ENGINE=InnoDB COMMENT='入库单流程状态不可变历史';
CREATE TABLE IF NOT EXISTS fl_inbound_outbox (
id BIGINT UNSIGNED NOT NULL COMMENT '领域事件全局ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '事件所属租户ID',
aggregate_type VARCHAR(32) NOT NULL DEFAULT 'INBOUND' COMMENT '聚合类型',
aggregate_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
event_type VARCHAR(64) NOT NULL COMMENT '事件类型',
payload_json JSON NOT NULL COMMENT '事件负载快照',
publish_status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '发布状态',
retry_count INT NOT NULL DEFAULT 0 COMMENT '重试次数',
next_retry_at DATETIME(3) NULL COMMENT '下次重试时间',
published_at DATETIME(3) NULL COMMENT '发布时间',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
PRIMARY KEY (id),
KEY idx_inbound_outbox_publish (publish_status, next_retry_at, created_at)
) ENGINE=InnoDB COMMENT='入库事务Outbox事件表';
CREATE TABLE IF NOT EXISTS fl_inbound_idempotency (
id BIGINT UNSIGNED NOT NULL COMMENT '入库写操作幂等记录ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '租户ID',
operation VARCHAR(40) NOT NULL COMMENT '操作类型',
operator_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '操作者或外部应用ID',
inbound_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '入库单ID,创建前可为0',
idempotency_key VARCHAR(128) NOT NULL COMMENT '幂等键',
request_hash CHAR(64) NOT NULL COMMENT '请求内容摘要',
status VARCHAR(16) NOT NULL COMMENT '处理状态',
result_json JSON NULL COMMENT '成功结果摘要',
completed_at DATETIME(3) NULL COMMENT '完成时间',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY (id),
UNIQUE KEY uk_inbound_idempotency (tenant_id, operation, operator_id, idempotency_key),
KEY idx_inbound_idempotency_created (created_at)
) ENGINE=InnoDB COMMENT='第三方测量、审核、费用发送和入库写操作幂等记录';
-- 二期托盘、库位、预占与占用表结构。
-- 本脚本只新增表、字段、索引和约束,不包含业务数据及流程实现。
-- 可在 warehouse.sql 和一期增量脚本之后执行;所有建表均可重复执行。
SET NAMES utf8mb4;
CREATE DATABASE IF NOT EXISTS baiyue_warehouse
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
USE baiyue_warehouse;
CREATE TABLE IF NOT EXISTS wh_pallet (
id BIGINT UNSIGNED NOT NULL COMMENT '托盘ID(雪花ID)',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID',
pallet_code VARCHAR(50) NOT NULL COMMENT '租户仓库内唯一托盘编码',
pallet_type VARCHAR(32) NOT NULL COMMENT '托盘类型',
pallet_name VARCHAR(100) NULL COMMENT '托盘名称',
capacity_cbm DECIMAL(18,6) NOT NULL COMMENT '托盘可用容积CBM',
status VARCHAR(20) NOT NULL DEFAULT 'AVAILABLE' COMMENT 'AVAILABLE、RESERVED、OCCUPIED或DISABLED',
remark VARCHAR(500) NULL COMMENT '备注',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_pallet_tenant_warehouse_code (tenant_id, warehouse_id, pallet_code),
KEY idx_pallet_available (tenant_id, warehouse_id, status, deleted),
KEY idx_pallet_type (tenant_id, warehouse_id, pallet_type, status, deleted),
CONSTRAINT fk_pallet_warehouse FOREIGN KEY (warehouse_id) REFERENCES wh_warehouse (id),
CONSTRAINT ck_pallet_capacity CHECK (capacity_cbm > 0)
) ENGINE=InnoDB COMMENT='二期托盘基础资料和物理托盘实例';
CREATE TABLE IF NOT EXISTS wh_location (
id BIGINT UNSIGNED NOT NULL COMMENT '库位ID(雪花ID)',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID',
zone_code VARCHAR(50) NULL COMMENT '库区编码',
zone_name VARCHAR(100) NULL COMMENT '库区名称',
location_code VARCHAR(50) NOT NULL COMMENT '租户仓库内唯一库位编码',
location_name VARCHAR(100) NULL COMMENT '库位名称',
location_type VARCHAR(32) NOT NULL DEFAULT 'PALLET' COMMENT '库位类型,二期为PALLET',
max_weight_kg DECIMAL(18,3) NULL COMMENT '最大承重展示值,二期不作容量拦截',
status VARCHAR(20) NOT NULL DEFAULT 'AVAILABLE' COMMENT 'AVAILABLE、RESERVED、OCCUPIED或DISABLED',
remark VARCHAR(500) NULL COMMENT '备注',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_location_tenant_warehouse_code (tenant_id, warehouse_id, location_code),
KEY idx_location_available (tenant_id, warehouse_id, status, deleted),
KEY idx_location_zone (tenant_id, warehouse_id, zone_code, status, deleted),
CONSTRAINT fk_location_warehouse FOREIGN KEY (warehouse_id) REFERENCES wh_warehouse (id),
CONSTRAINT ck_location_max_weight CHECK (max_weight_kg IS NULL OR max_weight_kg > 0)
) ENGINE=InnoDB COMMENT='二期库位基础资料,默认一托盘对应一个库位且不作容量校验';
CREATE TABLE IF NOT EXISTS wh_location_reservation (
id BIGINT UNSIGNED NOT NULL COMMENT '库位预占ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
reservation_no VARCHAR(40) NOT NULL COMMENT '租户内唯一预占业务号',
idempotency_key VARCHAR(128) NOT NULL COMMENT '调用方幂等键',
source_service VARCHAR(40) NOT NULL DEFAULT 'FIRSTLEG' COMMENT '来源服务',
business_id BIGINT UNSIGNED NOT NULL COMMENT '来源服务入库单ID',
inbound_code VARCHAR(40) NOT NULL COMMENT '入仓码快照',
warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '仓库ID',
pallet_id BIGINT UNSIGNED NOT NULL COMMENT '托盘ID',
pallet_code VARCHAR(50) NOT NULL COMMENT '托盘编码快照',
location_id BIGINT UNSIGNED NOT NULL COMMENT '库位ID',
location_code VARCHAR(50) NOT NULL COMMENT '库位编码快照',
reservation_status VARCHAR(20) NOT NULL DEFAULT 'RESERVED' COMMENT 'RESERVED、CONFIRMED、RELEASED或EXPIRED',
expires_at DATETIME(3) NOT NULL COMMENT '预占过期时间',
confirmed_at DATETIME(3) NULL COMMENT '确认占用时间',
released_at DATETIME(3) NULL COMMENT '释放时间',
release_reason VARCHAR(500) NULL COMMENT '释放或过期原因',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人或系统主体ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
active_reservation TINYINT GENERATED ALWAYS AS (CASE WHEN reservation_status = 'RESERVED' THEN 1 ELSE NULL END) STORED COMMENT '活动预占唯一标记',
PRIMARY KEY (id),
UNIQUE KEY uk_location_reservation_no (tenant_id, reservation_no),
UNIQUE KEY uk_location_reservation_idempotency (tenant_id, source_service, idempotency_key),
UNIQUE KEY uk_location_active_reservation (tenant_id, location_id, active_reservation),
KEY idx_location_reservation_business (tenant_id, source_service, business_id, reservation_status),
KEY idx_location_reservation_expire (reservation_status, expires_at),
KEY idx_location_reservation_target (tenant_id, warehouse_id, location_id, reservation_status),
CONSTRAINT fk_reservation_warehouse FOREIGN KEY (warehouse_id) REFERENCES wh_warehouse (id),
CONSTRAINT fk_reservation_pallet FOREIGN KEY (pallet_id) REFERENCES wh_pallet (id),
CONSTRAINT fk_reservation_location FOREIGN KEY (location_id) REFERENCES wh_location (id)
) ENGINE=InnoDB COMMENT='跨服务入库Saga的托盘和库位临时预占';
CREATE TABLE IF NOT EXISTS wh_location_occupancy (
id BIGINT UNSIGNED NOT NULL COMMENT '库位占用ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
occupancy_no VARCHAR(40) NOT NULL COMMENT '租户内唯一占用业务号',
reservation_id BIGINT UNSIGNED NOT NULL COMMENT '来源预占ID',
source_service VARCHAR(40) NOT NULL DEFAULT 'FIRSTLEG' COMMENT '来源服务',
business_id BIGINT UNSIGNED NOT NULL COMMENT '来源服务入库单ID',
inbound_code VARCHAR(40) NOT NULL COMMENT '入仓码快照',
warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '仓库ID',
pallet_id BIGINT UNSIGNED NOT NULL COMMENT '托盘ID',
pallet_code VARCHAR(50) NOT NULL COMMENT '托盘编码快照',
location_id BIGINT UNSIGNED NOT NULL COMMENT '库位ID',
location_code VARCHAR(50) NOT NULL COMMENT '库位编码快照',
allocated_volume_cbm DECIMAL(18,6) NOT NULL COMMENT '本托盘分配体积CBM',
allocated_package_count INT UNSIGNED NULL COMMENT '本托盘分配箱数',
occupancy_status VARCHAR(20) NOT NULL DEFAULT 'OCCUPIED' COMMENT 'OCCUPIED或RELEASED',
occupied_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '占用开始时间',
released_at DATETIME(3) NULL COMMENT '释放时间',
release_reason VARCHAR(500) NULL COMMENT '释放原因',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人或系统主体ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
active_occupancy TINYINT GENERATED ALWAYS AS (CASE WHEN occupancy_status = 'OCCUPIED' THEN 1 ELSE NULL END) STORED COMMENT '活动占用唯一标记',
PRIMARY KEY (id),
UNIQUE KEY uk_location_occupancy_no (tenant_id, occupancy_no),
UNIQUE KEY uk_occupancy_reservation (tenant_id, reservation_id),
UNIQUE KEY uk_location_active_occupancy (tenant_id, location_id, active_occupancy),
UNIQUE KEY uk_pallet_active_occupancy (tenant_id, pallet_id, active_occupancy),
KEY idx_occupancy_business (tenant_id, source_service, business_id, occupancy_status),
KEY idx_occupancy_warehouse (tenant_id, warehouse_id, occupancy_status, occupied_at),
CONSTRAINT fk_occupancy_reservation FOREIGN KEY (reservation_id) REFERENCES wh_location_reservation (id),
CONSTRAINT fk_occupancy_warehouse FOREIGN KEY (warehouse_id) REFERENCES wh_warehouse (id),
CONSTRAINT fk_occupancy_pallet FOREIGN KEY (pallet_id) REFERENCES wh_pallet (id),
CONSTRAINT fk_occupancy_location FOREIGN KEY (location_id) REFERENCES wh_location (id),
CONSTRAINT ck_occupancy_volume CHECK (allocated_volume_cbm > 0)
) ENGINE=InnoDB COMMENT='已确认的一托一库位占用事实记录';
-- 头程入库单属于其他数据库,不建立跨库外键。
-- 执行后结构核对:只输出检查结果,不写入业务数据。
SELECT '头程二期表数量' AS check_item,
COUNT(*) AS actual_value,
19 AS expected_value,
IF(COUNT(*) = 19, 'PASS', 'FAIL') AS check_result
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = 'baiyue_firstleg'
AND TABLE_NAME IN (
'fl_inbound_order',
'fl_inbound_pickup_snapshot',
'fl_inbound_destination_snapshot',
'fl_inbound_transport_snapshot',
'fl_inbound_measurement',
'fl_inbound_measurement_item',
'fl_inbound_weight_result',
'fl_measure_review_record',
'fl_customer_charge_rule',
'fl_carrier_fba_rate',
'fl_inbound_fee',
'fl_inbound_fee_item',
'fl_inbound_pallet_allocation',
'fl_inbound_file',
'fl_inbound_hold_record',
'fl_inbound_internal_note',
'fl_inbound_status_history',
'fl_inbound_outbox',
'fl_inbound_idempotency'
)
UNION ALL
SELECT '仓库二期表数量' AS check_item,
COUNT(*) AS actual_value,
4 AS expected_value,
IF(COUNT(*) = 4, 'PASS', 'FAIL') AS check_result
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = 'baiyue_warehouse'
AND TABLE_NAME IN (
'wh_pallet',
'wh_location',
'wh_location_reservation',
'wh_location_occupancy'
);
SELECT '关键唯一索引数量' AS check_item,
COUNT(DISTINCT CONCAT(TABLE_SCHEMA, '.', TABLE_NAME, '.', INDEX_NAME)) AS actual_value,
5 AS expected_value,
IF(COUNT(DISTINCT CONCAT(TABLE_SCHEMA, '.', TABLE_NAME, '.', INDEX_NAME)) = 5, 'PASS', 'FAIL') AS check_result
FROM information_schema.STATISTICS
WHERE (TABLE_SCHEMA = 'baiyue_firstleg'
AND TABLE_NAME = 'fl_inbound_order'
AND INDEX_NAME IN ('uk_inbound_tenant_code', 'uk_inbound_tenant_order'))
OR (TABLE_SCHEMA = 'baiyue_warehouse'
AND TABLE_NAME = 'wh_location_reservation'
AND INDEX_NAME = 'uk_location_active_reservation')
OR (TABLE_SCHEMA = 'baiyue_warehouse'
AND TABLE_NAME = 'wh_location_occupancy'
AND INDEX_NAME IN ('uk_location_active_occupancy', 'uk_pallet_active_occupancy'));
SELECT '关键字段数量' AS check_item,
COUNT(*) AS actual_value,
8 AS expected_value,
IF(COUNT(*) = 8, 'PASS', 'FAIL') AS check_result
FROM information_schema.COLUMNS
WHERE (TABLE_SCHEMA = 'baiyue_firstleg'
AND TABLE_NAME = 'fl_inbound_order'
AND COLUMN_NAME IN (
'inbound_code',
'measure_review_status',
'fee_review_status',
'expected_arrival_at',
'chargeable_weight_kg',
'chargeable_volume_cbm'
))
OR (TABLE_SCHEMA = 'baiyue_warehouse'
AND TABLE_NAME = 'wh_location_reservation'
AND COLUMN_NAME = 'active_reservation')
OR (TABLE_SCHEMA = 'baiyue_warehouse'
AND TABLE_NAME = 'wh_location_occupancy'
AND COLUMN_NAME = 'active_occupancy');
SELECT '二期入库表结构执行完成,请确认以上检查全部为PASS' AS execution_message;
File diff suppressed because it is too large Load Diff
+7
View File
@@ -69,3 +69,10 @@ SELECT COUNT(*) = 8 AS scheduler_columns_ready
```
任一结果不是 1,禁止启动 File Service。后续一旦表内存在生产数据,必须改用可重复执行的增量迁移,不能继续 DROP 重建。
## 二期第三方测量增量
- 模块脚本:`firstleg/src/main/resources/db/phase2-third-party-single-measurement-migration.sql`。
- 二期统一脚本:`2026-08-04-二期入库新增表字段-MySQL8一键执行.sql` 已内联该增量。
- 新增 `external_request_hash`、`inbound_detail_id`、`external_detail_no` 和第三方明细唯一索引,用于一条回传绑定一条订单货物明细及一张文件中心图片。
- 本次代码回归不执行真实数据库迁移;测试环境部署 Firstleg 新版本前必须先执行该增量并确认末尾两项检查为 `PASS`。
@@ -0,0 +1,417 @@
# 无状态 PDF 打印服务设计
> 日期:2026-08-04
> 状态:待评审
> 适用模块:`print-service`
> 首版模板:`INBOUND_MARK_V1`
> 参考文件:`PCHK2608040174-19件-MCC1.pdf` 及同名标签截图
## 1. 结论
`print-service` 调整为无状态 PDF 渲染服务:调用方传入完整的 `labels[]`,服务校验后在内存中生成一个多页 PDF,并在同一次 HTTP 响应中返回 PDF 二进制。
首版边界如下:
- 不连接数据库,不保存模板、任务、历史或文件。
- 不调用 Firstleg、Order、File、IAM 等业务服务,不反查或修正业务数据。
- 不连接打印机,不处理驱动、打印队列、重试、补打或设备状态。
- 不上传 File 服务,不写本地临时文件,不提供二次下载地址。
- 一次请求生成一个 PDF;`labels[]` 中一个元素对应一页,数组顺序就是 PDF 页序。
- 每页默认且首版只支持 `100mm x 100mm`,所有内容水平居中。
- 最多支持 2000 页;超限时整次请求失败,不生成部分结果。
- 成功直接返回 `application/pdf`;失败返回统一 JSON 错误响应。
现有 `print_template`、`print_job`、`print_job_history` 数据库草案不再属于本方案。实施时应移除 Print 模块的数据源、MyBatis、MySQL 依赖及环境配置,数据库脚本不进入部署流程。
## 2. 需求依据
### 2.1 已确认规则
1. 调用方传入完整 `labels[]`,Print 不自行按箱数扩展页面。
2. `CTNS:218-1` 表示总计 218 箱中的第 1 箱。
3. 二维码内容由调用方生成,例如 `PCHK2607211476#218-1`。
4. 入仓码只在页面顶部展示一次;`MADE IN CHINA` 是固定文案。
5. 仓库、渠道等字段由调用方传最终展示文案,Print 不维护代码映射。
6. 首版不处理普通展示文字的长度适配;调用方保证文案合理,Print 使用固定字号直接居中绘制。
7. 页面尺寸作为请求参数显式传入,首版只接受 `100mm x 100mm`。
8. PDF 仅临时使用;需要再次下载时由调用方重新生成。
9. 单次最多约 2000 箱,即最多 2000 页。
10. 调用方对页面数据的真实性、页序、唯一性和业务权限负责。
11. 中文字体随服务打包并嵌入 PDF,不依赖容器或宿主机字体。
12. 首版只实现固定模板,不建设模板管理系统。
### 2.2 参考 PDF 核验结果
参考 PDF 共 19 页:
- 每页 MediaBox 为 `282.96pt x 282.96pt`,约 `99.822mm x 99.822mm`。
- 页码内容从 `CTNS:19-1` 连续变化到 `CTNS:19-19`。
- 每页依次展示顶部入仓码、仓库文案、箱号文案、渠道文案、二维码、二维码明文和固定产地文案。
- 参考文件使用 Microsoft YaHei 子集字体。
本服务以明确的 `100mm x 100mm` 契约为准,输出 MediaBox 使用 `mm * 72 / 25.4` 换算,即约 `283.465pt x 283.465pt`,不复制参考文件约 `0.178mm` 的尺寸误差。
## 3. 架构与调用边界
### 3.1 推荐调用方式
Admin 浏览器不直接调用 Print。公开业务接口由 Firstleg 或其他业务归属服务提供;业务服务完成登录态、按钮权限、租户、企业、仓库和业务数据校验,组装最终 `labels[]` 后,通过服务发现调用 Print 内部接口,再把 PDF 响应透传给浏览器。
```mermaid
sequenceDiagram
participant A as "Admin 浏览器"
participant B as "业务归属服务"
participant P as "Print Service"
A->>B: 请求生成业务标签 PDF
B->>B: 校验 JWT、权限和数据范围
B->>B: 组装完整 labels[]
B->>P: 内部令牌 + PDF 请求
P->>P: 全量校验并在内存渲染
P-->>B: application/pdf
B-->>A: application/pdf
```
采用内部调用的原因:
- Print 不持有业务数据,无法判断浏览器提交的入仓码、仓库或箱数是否真实。
- 若直接暴露给 Admin,Print 必须复制业务权限和数据范围规则,破坏服务边界。
- 内部接口能避免任意已登录用户把 Print 当作通用 PDF/二维码生成器滥用。
- 各业务服务可以复用同一个 Print 渲染能力,同时继续拥有自己的公开接口和权限码。
### 3.2 网络与鉴权
- 内部接口路径不加入 Gateway 公网路由。
- 调用方通过 Nacos 服务发现访问 `print-service`。
- 请求必须携带共享的 `X-Internal-Service-Token`。
- Print 使用与现有内部接口一致的 `INTERNAL_SERVICE_TOKEN` 配置,令牌至少 32 字节。
- 令牌缺失或不匹配返回 `A0403`,不得在日志中打印令牌。
- Print 不接受浏览器 JWT,也不新增 Print 业务权限码;权限由调用方业务服务校验。
## 4. 接口契约
### 4.1 生成接口
```http
POST /internal/print/v1/pdfs/inbound-marks
Content-Type: application/json
Accept: application/pdf, application/json
X-Internal-Service-Token: ***
X-Request-Id: 可选
```
`Accept` 必须同时列出两种类型:成功响应是 PDF,失败响应是 JSON `ApiResponse`。若只声明 `application/pdf`,Spring MVC 在异常解析阶段重新做内容协商时会因为无法接受 `application/json` 而返回 406 空响应体,调用方拿不到本设计约定的错误码。原因与实现约束见 4.4。
请求示例:
```json
{
"templateCode": "INBOUND_MARK_V1",
"pageSpec": {
"widthMm": 100,
"heightMm": 100
},
"downloadFileName": "PCHK2608040174-19件-MCC1.pdf",
"labels": [
{
"topCode": "PCHK2608040174",
"warehouseText": "MCC1",
"cartonText": "CTNS:19-1",
"routeText": "洛杉矶普船海卡",
"qrContent": "PCHK2608040174#19-1"
},
{
"topCode": "PCHK2608040174",
"warehouseText": "MCC1",
"cartonText": "CTNS:19-2",
"routeText": "洛杉矶普船海卡",
"qrContent": "PCHK2608040174#19-2"
}
]
}
```
`qrContent` 同时用于生成二维码和二维码下方的明文,避免两处内容不一致。`MADE IN CHINA` 由模板固定输出,不作为请求字段。
### 4.2 请求字段
| 字段 | 必填 | 首版规则 |
|---|---|---|
| `templateCode` | 是 | 只能为 `INBOUND_MARK_V1` |
| `pageSpec.widthMm` | 是 | 只能为数值 `100` |
| `pageSpec.heightMm` | 是 | 只能为数值 `100` |
| `downloadFileName` | 否 | 1-120 个 Unicode 码点;只允许文件基本名,必须以 `.pdf` 结尾 |
| `labels` | 是 | 1-2000 项;数组顺序即页序 |
| `labels[].topCode` | 是 | 非空;单行;禁止控制字符 |
| `labels[].warehouseText` | 是 | 非空;单行;禁止控制字符 |
| `labels[].cartonText` | 是 | 非空;单行;禁止控制字符 |
| `labels[].routeText` | 是 | 非空;单行;禁止控制字符 |
| `labels[].qrContent` | 是 | 1-34 个可打印 ASCII 字符;单行 |
普通展示字段不设置业务字符数上限,也不因实际文字宽度拒绝请求。调用方负责保证文案适合固定版式;若文字超过页面安全区,Print 仍按固定字号和中心点绘制,不换行、不缩小、不截断,也不把版面越界判定为接口错误。`qrContent` 的限制继续保留,因为它同时影响固定尺寸二维码的扫描密度和二维码下方明文的可读性。
补充规则:
- 所有字符串先做 Unicode NFC 规范化,再按各字段规则进行非空、长度和字符校验;普通展示字段不增加长度校验。
- 不允许 `\r`、`\n`、制表符、双向控制符、NUL 或其他不可见控制字符。该规则适用于全部请求字符串,`downloadFileName` 同样适用。
- `pageSpec.widthMm` 和 `heightMm` 按数值比较,`100` 与 `100.0` 都接受,非整数或其他数值一律 `A0422`。
- Print 不解析 `cartonText`,不校验箱号是否连续,也不校验 `qrContent` 与其他字段的业务关系。
- 调用方必须保证页面没有重复、缺失或乱序;Print 只保证按数组顺序渲染。
- 文件名剥离路径语义,禁止 `/`、`\`、`:` 及其他文件系统保留字符,避免调用方把带路径的名字直接落到磁盘。
- 未传文件名时使用 `inbound-marks-{requestId}.pdf`。`requestId` 取自 `CorrelationContext`,实施前需确认请求未带 `X-Request-Id` 时该组件会生成一个,否则文件名会出现 `null`。
### 4.3 成功响应
```http
HTTP/1.1 200 OK
Content-Type: application/pdf
Content-Disposition: attachment; filename="inbound-marks.pdf"; filename*=UTF-8''PCHK2608040174-19%E4%BB%B6-MCC1.pdf
Content-Length: ...
Cache-Control: no-store, max-age=0
Pragma: no-cache
X-Content-Type-Options: nosniff
X-Print-Page-Count: 19
X-Request-Id: ...
```
响应头实现约定:
- 复用项目现有下载写法 `ContentDisposition.attachment().filename(name, StandardCharsets.UTF_8)`(见 `file/src/main/java/com/baiyue/file/controller/PlatformExportTaskController.java:66`),由它统一完成 RFC 5987 编码并同时输出 ASCII `filename=` 回退与 `filename*=`,不手工拼接响应头。
- 控制器方法**不得**声明 `produces = "application/pdf"`,否则会把该方法的可产出类型钉死为 PDF,异常处理阶段无法写出 JSON 错误体。成功分支显式调用 `.contentType(MediaType.APPLICATION_PDF)` 设置类型。
- `Cache-Control` 使用 `CacheControl.noStore()`,与现有下载接口一致。
PDF 完整生成并通过大小检查后才写入 HTTP 响应,禁止边生成边返回半个 PDF。
### 4.4 失败响应
成功响应是 PDF,失败响应是项目统一的 JSON `ApiResponse`。调用方必须同时检查 HTTP 状态、`Content-Type` 和 JSON `code`,不能只判断 HTTP 200。
| 场景 | 响应约定 |
|---|---|
| JSON、字段或字符校验失败 | HTTP 200 + `application/json` + `A0422` |
| 模板或页面尺寸不支持 | HTTP 200 + `application/json` + `A0422` |
| 内部令牌缺失或错误 | HTTP 200 + `application/json` + `A0403` |
| 请求体超过容器限制 | HTTP 413 + `application/json` |
| 并发生成槽位已满 | HTTP 200 + `application/json` + `A0429` |
| PDF 生成失败或超过输出上限 | HTTP 200 + `application/json` + `S0500` |
错误信息只指出字段位置和规则,例如 `labels[18].routeText contains a line break`,不回显完整标签数组或内部异常堆栈。
## 5. PDF 渲染契约
### 5.1 页面与坐标
- 页面物理尺寸:严格 `100mm x 100mm`。
- PDF 坐标换算:`points = millimeters * 72 / 25.4`。
- 页面旋转:`0`。
- 背景:纯白。
- 前景:纯黑,不使用灰色、透明度、渐变或彩色元素。
- 内容安全区:四周至少 `5mm`;所有文本和二维码水平居中。
- 每个字段只占一行,不自动换行,不截断,不输出省略号。
首版布局基线。**下表所有 `y` 均为「自页面顶部向下量取」的中心线位置**,与阅读顺序一致,不是 PDF 原生坐标:
| 区域 | 顶部向下中心或范围 | 初始字号/尺寸 | 规则 |
|---|---:|---:|---|
| 顶部入仓码 | 中心 `y = 11mm` | `24pt` 粗体 | 单行居中 |
| 仓库文案 | 中心 `y = 26mm` | `36pt` 粗体 | 单行居中 |
| 箱号文案 | 中心 `y = 37mm` | `22pt` 粗体 | 单行居中 |
| 渠道文案 | 中心 `y = 47mm` | `21pt` 粗体 | 单行居中 |
| 二维码画布 | `48.72mm-84mm` | `35.28mm x 35.28mm`(100pt) | 水平居中,含 4 modules quiet zone |
| 二维码明文 | 中心 `y = 85.5mm` | `12pt` 粗体 | 内容取 `qrContent` |
| 固定产地 | 中心 `y = 92mm` | `14pt` 粗体 | 固定 `MADE IN CHINA` |
PDF 原生坐标原点在**左下角**,y 轴向上,与上表方向相反。二者混用会让整张标签上下颠倒,因此坐标换算必须收敛到 `PdfUnits` 一处:
- `PdfUnits` 只对外暴露 `topDownMmToPdfY(double mmFromTop)` 与 `mmToPt(double mm)` 两个入口,前者内部完成 `pageHeightPt - mmToPt(mmFromTop)` 翻转。
- 渲染代码一律以「自顶向下的毫米值」思考和传参,禁止在绘制方法中出现手写 point 常量,也禁止绕过 `PdfUnits` 直接使用 PDF 原生 y。
- 文本按中心线垂直居中时,基线由字体上升距离和字号在 `PdfUnits` 内换算,绘制方法不自行加减半个字号。
上述位置是依据参考 PDF 得到的首版基线,最终以渲染后的金样验收为准。
### 5.2 文本绘制
- 所有普通展示文字使用 5.1 约定的固定字号和中心点直接绘制。
- 不测量文字是否超出安全区,不动态缩小字号,不换行、不截断、不输出省略号,也不横向压缩字体。
- Print 只保证请求中的文字被交给 PDFBox 绘制,不保证任意长度文字都能落在页面范围内;版式内容质量由调用方负责。
- 固定字号或位置需要调整时,通过新增模板版本完成,不得静默改变 `INBOUND_MARK_V1`。
### 5.3 字体
- 随服务打包 `Noto Sans SC Bold` 静态 TTF 字体及对应 OFL 许可证。
- 每个 PDF 文档只加载一次字体对象,启用字体子集并嵌入 PDF。
- 不使用 Windows 的 Microsoft YaHei,不从系统字体目录动态加载字体。
- 启动时校验字体资源存在且可解析;缺失时 readiness 失败,不能静默回退到不支持中文的字体。
### 5.4 二维码
- 使用 ZXing 根据 `qrContent` 生成 QR Code。
- 字符集固定为 UTF-8;首版字段限制为可打印 ASCII。
- 纠错等级使用 `M`,保留 4 modules quiet zone。
- 二维码在 PDF 中按模块直接绘制黑色矢量矩形,不先生成低分辨率 PNG,不进行插值缩放。
- 二维码下方明文与编码内容来自同一个 `qrContent` 值。
- 每页二维码必须可由 ZXing 反向解码,并与请求值完全一致。
## 6. Java 实现方案
### 6.1 依赖选型
- PDF:Apache PDFBox。
- QR Code:ZXing Core。
- 输入校验:Jakarta Validation 加服务层全量校验。
- HTTP:现有 Spring Boot MVC、Common Web 和内部令牌模式。
不采用 HTML/CSS 转 PDF 或无头浏览器:标签要求精确物理尺寸,HTML 打印受 DPI、浏览器分页和字体环境影响更大。不采用 iText:当前需求不需要其高级能力,同时需要避免额外商业许可边界。
### 6.2 建议包结构
```text
com.baiyue.print
├─ controller
│ └─ InternalPdfController.java
├─ dto
│ └─ InboundMarkPdfDtos.java
├─ service
│ ├─ InboundMarkPdfService.java
│ ├─ InboundMarkRequestValidator.java
│ └─ PdfGenerationLimiter.java
├─ renderer
│ ├─ InboundMarkV1Renderer.java
│ ├─ PdfQrRenderer.java
│ └─ PdfUnits.java
└─ config
└─ PrintPdfProperties.java
```
建议资源:
```text
print/src/main/resources/fonts/NotoSansSC-Bold.ttf
print/src/main/resources/fonts/OFL.txt
print/src/main/resources/openapi/print-internal-v1.yaml
```
### 6.3 处理流程
1. 校验内部令牌。
2. 校验请求体大小、模板、页面尺寸、文件名和页数。
3. 对全部页面字符串进行规范化、非空和控制字符校验。
4. 获取并发生成槽位。
5. 在并发保护范围内生成并校验全部 QR BitMatrix;任一失败则终止。
6. 创建一个 PDF 文档,加载一次嵌入字体。
7. 按 `labels[]` 顺序创建页面并渲染。
8. 保存到受上限保护的内存输出流。
9. 校验 PDF 字节数、页数和基本结构。
10. 构造下载响应,释放文档、字体和内存对象。
全过程不创建数据库事务、任务 ID、磁盘文件或异步队列。
## 7. 容量与资源保护
首版默认配置建议:
```yaml
print:
pdf:
max-pages: 2000
max-request-bytes: 2097152
max-output-bytes: 33554432
max-concurrent-generations: 4
acquire-timeout: 1s
template-code: INBOUND_MARK_V1
supported-width-mm: 100
supported-height-mm: 100
```
- `max-pages` 是硬上限,不能通过请求覆盖。
- `max-request-bytes` 同时在应用和入口代理配置,防止超大 JSON 在反序列化前占用内存。
- 内存输出流在超过 `max-output-bytes` 时立即抛出受控异常。
- 使用进程内 Semaphore 限制并发生成数;槽位满时快速失败,不在 JVM 内形成无界等待队列。
- 字体只在单个 PDF 文档中加载一次,不得每页创建一份字体子集。
- 服务保持无状态,可以水平扩容;单实例并发数应根据容器内存压测后调整。
发布前容量验收至少覆盖 1、19、218 和 2000 页。2000 页请求的目标是 30 秒内完成且 PDF 小于 32MiB;实际 P95、峰值堆内存和容器 CPU 以测试环境压测结果回填,不在未经测量时承诺更激进指标。
## 8. 日志、监控与隐私
允许记录:
- `requestId`、调用服务名、模板编码、页面尺寸、页数。
- 生成耗时、PDF 字节数、成功/失败结果和有限错误码。
- 当前并发数、等待槽位时间和内存上限拒绝次数。
禁止记录:
- 完整 `labels[]`、二维码内容、内部令牌和 PDF 字节。
- 将生成的 PDF 写入日志、异常附件或故障目录。
建议指标:
- `print_pdf_requests_total{result,template}`
- `print_pdf_generation_seconds{template}`
- `print_pdf_pages_total{template}`
- `print_pdf_output_bytes{template}`
- `print_pdf_rejections_total{reason}`
- `print_pdf_active_generations`
## 9. 测试与验收
### 9.1 自动化测试
- DTO 校验:空数组、1001 页、空字段、控制字符、超长二维码内容、非法文件名和错误页面尺寸。
- 接口契约:内部令牌、成功 PDF、JSON 错误、响应头和 `Cache-Control: no-store`。
- PDF 结构:页数等于 `labels.size()`,每页精确 `100mm x 100mm`,旋转为 0。
- 文本内容:每页六个文本区完整,顶部入仓码不在底部重复。
- 页序:输入第 N 项必须成为 PDF 第 N 页。
- 二维码:渲染页面后用 ZXing 解码,结果必须等于该页 `qrContent`。
- 字体:PDF 中存在嵌入字体,中文渲染无方框、乱码或系统字体依赖。
- 金样渲染:对固定业务样本渲染 PNG,检查内容边界、居中和布局容差;不比较易受元数据影响的完整 PDF 二进制。
- 容量:1、19、218、2000 页生成成功,输出不超过配置上限。
- 并发:超过生成槽位时快速返回受控错误,完成后槽位必定释放。
- 失败原子性:任一页无效时不返回部分 PDF,也不产生磁盘文件。
### 9.2 人工验收
1. 用参考文件的 19 页数据生成 PDF,逐页比对页面内容和页序。
2. 渲染第一页、中间页和最后一页,确认全部元素水平居中且无重叠、裁切或乱码。
3. 在实际打印环境按 100% 比例打印,确认成品尺寸为 `100mm x 100mm`,关闭“适合页面/缩放”选项。
4. 使用现场扫码设备扫描首、中、末页二维码。
5. 使用真实业务中的典型较长文案确认固定字号下的展示结果可接受。
6. 生成 2000 页并记录耗时、PDF 大小、峰值堆内存和 CPU。
## 10. 实施范围
### 10.1 本次实现应包含
- 移除 Print 数据库、MyBatis 和 MySQL 依赖及配置。
- 新增 PDFBox、ZXing 和合法授权的中文字体资源。
- 实现内部 PDF 接口、请求校验、固定模板渲染和资源保护。
- 增加内部 OpenAPI、单元测试、契约测试、金样渲染测试和 2000 页容量测试。
- 保留 Actuator 健康检查;现有逻辑状态接口可继续用于平台运维,但不承担业务能力。
- 更新 Print 模块指引、部署配置和 Gateway 说明,明确内部业务接口不公开。
### 10.2 本次不包含
- Admin 页面、业务服务公开下载接口及业务权限码。
- 打印模板 CRUD、模板版本或数据库表。
- 文件中心、对象存储、下载中心或下载次数管理。
- 打印机发现、USB/网络打印、本地代理、驱动和设备状态。
- 打印任务、失败重试、补打、换标、作废和审计历史。
- 任意页面尺寸、任意字段布局或通用报表生成。
## 11. 后续兼容策略
- `templateCode` 和 `pageSpec` 从首版进入契约,但首版只接受一个固定组合。
- 新尺寸或新版布局必须新增模板编码,例如 `INBOUND_MARK_V2`,不得静默改变 V1 的位置、字号或固定文案。
- 若未来需要直接从 Admin 调用,必须先定义独立的授权与数据真实性方案,不能直接开放本内部接口。
- 若未来需要暂存或重复下载,应由 File/下载中心承担文件生命周期,不能在当前纯渲染接口中逐步加入数据库状态。
- 若未来需要控制佳博等热敏打印机,应新增本地打印代理或设备服务,本服务仍只负责生成可打印内容。
## 12. 待评审项
以下事项不阻塞设计评审,但应在编码或上线前确认:
1. `Noto Sans SC Bold` 的最终字体文件版本、仓库体积和许可证归档位置。
2. 业务归属服务的公开下载接口路径与按钮权限码。
3. 测试环境单实例 CPU、内存限制及 `max-concurrent-generations` 最终值。
4. 现场打印驱动是否把 `100mm x 100mm` PDF 保持为 100% 比例,是否存在硬件不可打印边缘。
+46 -93
View File
@@ -1,5 +1,13 @@
# 头程项目目录结构与服务说明
## 文档定位与读取时机
本文是面向架构理解和运维协作的服务总览,主要说明技术基线、目录结构、服务端口、数据库归属、调用关系和启动部署拓扑。
- 普通单模块开发不需要预先通读本文;先读仓库根 `AGENTS.md` 和目标模块 `AGENTS.md`。
- 涉及跨服务设计、模块拆分、端口或数据库归属、根目录调整、本地启动和部署拓扑时再读本文。
- Controller、Service、SQL、测试和当前实现入口以各级 `AGENTS.md` 与代码为准;本文不维护文件级快速索引,避免与执行指引重复和漂移。
## 1. 项目定位
`baiyue-firstleg` 是国内头程业务后端仓库,采用 Maven 多模块单仓结构。仓库内包含 7 个公共模块和 11 个可独立启动、构建、制作镜像的微服务。
@@ -34,9 +42,9 @@ baiyue-firstleg/
├─ auth/ # 统一登录与账号凭据
├─ iam/ # 租户、成员、角色、权限、企业审核
├─ order/ # 头程订单中心
├─ firstleg/ # 头程履约任务与分配
├─ warehouse/ # 仓库与往来单位主数据
├─ file/ # 文件中心
├─ firstleg/ # 履约任务、二期入库、测量与计费
├─ warehouse/ # 仓库主数据、托盘与库位资源
├─ file/ # 资质、导出与测量图片文件中心
├─ print/ # 打印中心
├─ audit-service/ # 审计日志中心
├─ integration-service/ # 第三方平台集成适配
@@ -80,11 +88,11 @@ baiyue-firstleg/
| `gateway` | `gateway` | 9000 | 无 | 统一路由、CORS、JWT/Redis 会话校验、商户端与平台端访问域隔离、Swagger 聚合 |
| `auth` | `auth-service` | 9100 | `baiyue_auth` | 登录、刷新 Token、退出、商户注册、账号密码和账号内部管理 |
| `iam` | `iam-service` | 9201 | `baiyue_iam` | 租户、企业资料、企业审核、成员、角色、权限、按钮权限、个人资料和授权快照 |
| `firstleg` | `firstleg-service` | 9202 | `baiyue_firstleg` | 履约任务、操作分配和分配历史;不是订单主数据服务 |
| `firstleg` | `firstleg-service` | 9202 | `baiyue_firstleg` | 履约任务与分配;二期入库单、测量审核、FBA 计费、费用发布和完成入库编排;不是订单主数据服务 |
| `order` | `order-service` | 9204 | `baiyue_order` | 订单草稿、编辑、提交、详情、状态历史、货物、往来单位快照、内部备注和 Outbox |
| `warehouse` | `warehouse-service` | 9205 | `baiyue_warehouse` | 仓库、仓库地址、服务能力、启停校验,以及往来单位查询;往来单位不单独拆服务 |
| `file` | `file-service` | 9206 | `baiyue_file` | 文件存储与鉴权;实现企业资质上传预览及公共异步导出、下载中心 |
| `print` | `print-service` | 9207 | `baiyue_print` | 打印中心边界已经建立;当前主要是服务骨架、健康检查和数据库结构,打印模板与任务仍需继续完善 |
| `warehouse` | `warehouse-service` | 9205 | `baiyue_warehouse` | 仓库、地址、服务能力、往来单位、快递公司,以及二期托盘、库位、预占和占用;往来单位不单独拆服务 |
| `file` | `file-service` | 9206 | `baiyue_file` | 企业资质文件、公共异步导出下载、入库测量图片及重量 OCR 调用协作 |
| `print` | `print-service` | 9207 | 无 | 无状态 PDF 渲染服务;调用方传入完整标签数据,服务在内存中生成一次性多页 PDF |
| `integration-service` | `integration-service` | 9210 | 无 | 百度 OCR、Amazon SP-API 等第三方适配器和启停状态;默认关闭外部调用 |
| `event-bridge-service` | `event-bridge-service` | 9300 | `baiyue_event_bridge` | 国内外事件接收、签名/内部令牌保护、幂等 Inbox 和接收结果 |
| `audit-service` | `audit-service` | 9700 | `baiyue_audit` | 接收公共审计事件并持久化;当前配置默认关闭业务审计发送,可按环境开启 |
@@ -95,119 +103,57 @@ MySQL、Redis、Nacos 是外部基础设施,不属于上述 11 个业务服务
### 5.1 `gateway/`:统一入口
主要内容:
- `config/`:网关安全、CORS、访问域规则和 Redis 会话过滤
- `filter/`:可信身份请求头处理
- `application.yml`:本地默认路由与安全配置
- `application-cn.yml`:国内环境路由和 Swagger 聚合配置
前端只应访问 Gateway,不应直接暴露各业务服务端口。网关负责入口认证和路由,业务服务仍需执行租户、数据范围和按钮权限检查。
Gateway 负责统一路由、CORS、入口认证、可信身份头和 Swagger 聚合。前端只访问 Gateway,不直接暴露业务服务端口;业务服务仍需执行租户、访问域、数据范围和按钮权限校验,不能把网关认证视为最终授权。
### 5.2 `auth/`:统一登录与账号凭据
主要内容:
- `controller/AuthController`:登录、刷新 Token、退出、注册入口
- `controller/InternalAccountController`:IAM 调用的账号管理接口
- `service/AuthService`:认证和 Token 生命周期
- `service/AccountManagementService`:密码修改、密码重置和账号状态处理
- `service/MerchantRegistrationService`:商户注册协作
- `client/`:向 IAM 获取授权快照和企业注册结果
- `resources/db/`:账号和刷新 Token 表结构、一期初始化账号
密码散列、登录失败锁定和刷新 Token 属于 Auth。成员资料、角色和权限属于 IAM,不能放入 Auth。
Auth 负责登录、Token 生命周期、商户注册协作、账号密码和账号状态。密码散列、登录失败锁定和刷新 Token 属于 Auth;成员资料、角色、权限和企业审核属于 IAM,两者通过内部接口协作,不共享数据库。
### 5.3 `iam/`:租户、成员和权限中心
主要内容:
- `MerchantTenantController`:商户端企业、成员相关接口
- `PlatformTenantController`:平台端企业审核和管理接口
- `ProfileResourceController`:个人资料、企业资料和资质资源
- `RbacController`:角色、权限资源、启停和角色授权
- `InternalAuthorizationController`:向 Auth/其他服务返回权限快照
- `entity/`:租户、用户、成员关系、角色、权限、数据范围、企业地址和资质审核
- `resources/db/`:IAM 主结构及一期成员、角色、权限、企业入驻扩展脚本
IAM 以 `tenant_id` 作为核心隔离维度,同时维护两套访问域:商户端和平台后台端。按钮权限通过 permission code / permissionIds 管理,不能只依赖前端隐藏按钮。
IAM 负责租户、企业资料与审核、成员、平台用户、角色、菜单、按钮权限、数据范围和授权快照。它以 `tenant_id` 为核心隔离维度,并区分商户端与平台后台访问域;权限必须由后端校验,不能只依赖前端隐藏按钮。
### 5.4 `order/`:订单中心
主要内容:
Order 负责订单草稿、提交、平台处理状态、货物、参与方和主数据快照、状态历史、内部备注、幂等记录与 Outbox。订单保存关键业务快照,Warehouse 或往来单位后续变化不能改写历史订单。
- `MerchantFirstLegOrderController`:商户端订单草稿、编辑、提交和查询
- `PlatformFirstLegOrderController`:平台端订单查询和内部处理入口
- `OrderApplicationService`:订单业务编排和状态转换
- `OrderEligibilityGuard`:企业审核资格校验
- `OrderMasterDataGuard`:仓库、往来单位和主数据有效性校验
- `OrderScopeGuard`:租户与访问范围校验
- `entity/`:订单、货物、订单参与方快照、状态历史、内部备注和 Outbox 事件
- `client/`:调用 IAM、Warehouse 获取资格和主数据
订单保存的是仓库、往来单位等关键字段的业务快照,主数据后续修改不能改变历史订单。跨服务通知通过 Outbox/事件方式演进,不应让数据库跨库直连。
企业资格和仓库主数据通过 IAM、Warehouse 内部接口校验;Firstleg 查询订单货物明细也通过受保护的内部接口完成。服务之间不建立跨库外键或跨库查询,通知优先通过 Outbox 和事件演进。
### 5.5 `firstleg/`:头程履约中心
主要内容:
Firstleg 承担订单提交后的履约任务、操作分配与历史,以及二期入库单查询、测量回传、人工测量、审核、异常放行、FBA 计费、费用发布和完成入库状态编排。
- `PlatformFulfillmentAssignmentController`:平台端履约分配入口
- `FulfillmentTaskService`:履约任务和分配业务
- `FulfillmentTaskEntity`:履约任务
- `AssignmentHistoryEntity`:分配历史
订单主单和货物主数据仍属于 Order;托盘和库位资源属于 Warehouse;图片对象属于 File;OCR 供应商协议属于 Integration。Firstleg 只保存自身业务状态、快照和逻辑引用,通过内部 DTO 调用其他服务,不复制对方领域模型。
该服务承担订单提交后的执行过程。订单主单及商户草稿仍属于 `order`,避免订单交易状态与履约作业状态混在同一张表。
### 5.6 `warehouse/`:仓库与仓储资源
### 5.6 `warehouse/`:仓库与往来单位
Warehouse 负责仓库、地址、服务能力、往来单位、快递公司,以及二期托盘、库位、预占和占用资源。往来单位只是 Warehouse 下的主数据配置,保存在 `baiyue_warehouse.wh_partner`,不创建独立服务和数据库。
主要内容:
- 商户端、平台端和内部仓库 Controller
- `WarehouseApplicationService`:仓库查询、地址、能力和状态校验
- `PartnerQueryService`:往来单位只读查询和停用过滤
- `WarehouseEntity`、`WarehouseAddressEntity`、`ServiceCapabilityEntity`
- `PartnerEntity`:往来单位
- `warehouse-seed.sql`:测试环境仓库初始化数据
往来单位只是 Warehouse 服务下的主数据配置,数据库表为 `baiyue_warehouse.wh_partner`,不创建独立微服务和数据库。
Firstleg 通过内部接口发起托盘库位预占、确认和释放,只保存逻辑引用与入库快照。容量、共享溢出、预占和占用一致性由 Warehouse 的数据库事务和条件更新最终保证。
### 5.7 `file/`:文件中心
主要内容:
File 负责企业资质文件、公共异步导出与下载、入库称重图片和第三方测量证据。企业资质审核及其业务元数据仍归 IAM;测量图片通过 `file_object` 保存文件元数据,并调用 Integration 执行受控重量 OCR。
- `QualificationFileController`:商户端企业资质文件
- `PlatformQualificationFileController`:平台端鉴权查看
- `PlatformExportTaskController`:平台异步导出任务、下载与失败重试
- `ExportTaskHandler`:业务导出类型扩展点;接入规则见 `docs/公共导出与下载中心接入说明.md`
- `QualificationFileStorage`:存储抽象
- `LocalQualificationStorage`:当前本地磁盘实现
- `IamQualificationClient`:校验企业资质访问权限
生产环境应把存储实现替换为对象存储或共享文件存储。数据库保存元数据,文件内容不写入 Git,也不依赖应用容器临时目录。
当前文件内容使用本地或共享磁盘实现,生产环境应演进为对象存储或稳定共享存储。文件内容不写入 Git,也不能依赖应用容器临时目录;入库和测量状态仍归 Firstleg。
### 5.8 `print/`:打印中心
当前目录已有独立应用、端口、数据库脚本、公共安全和审计依赖,但业务 Controller 目前只有健康检查。后续应在此目录增加:
Print 是无数据库的无状态 PDF 渲染服务。业务归属服务完成权限和数据真实性校验,组装完整 `labels[]` 后通过内部令牌调用 Print;Print 只在内存中生成一次性多页 PDF 并同步返回。本服务不保存模板、任务、历史或文件,不调用 File,不连接打印机,也不提供二次下载地址。
- 打印模板版本管理
- 打印任务和状态机
- PDF/标签生成
- 失败重试和幂等键
- 打印文件与 File 服务的引用关系
订单服务只提交打印请求,不应自己生成面单或直接操作打印机。
业务服务接入时先读 `print/README.md`,机器可读契约以 `print/src/main/resources/openapi/print-internal-v1.yaml` 为准。
### 5.9 `audit-service/`:审计日志中心
通过 `AuditIngestController` 接收审计事件,`AuditIngestService` 处理后写入 `baiyue_audit`。公共模块负责采集和脱敏,本服务负责集中持久化、查询扩展和留存策略。
公共审计模块负责采集、脱敏和发送,Audit Service 负责集中接收并写入 `baiyue_audit`,后续查询和留存策略也归本服务。审计链路不得记录密码、Token、第三方密钥或未经脱敏的客户敏感数据。
### 5.10 `integration-service/`:第三方集成中心
当前包含百度 OCR 和 Amazon Logistics/SP-API 适配器,以及第三方能力状态查询。服务不使用业务数据库,密钥必须通过 Jenkins Credentials、环境变量或 Nacos 密文注入。
Integration 负责百度 OCR、重量识别和 Amazon Logistics/SP-API 等第三方协议适配及能力状态。二期重量 OCR 由 File 调用,业务图片和入库状态不归 Integration;服务不使用业务数据库,真实外部调用默认关闭,密钥必须通过 Jenkins Credentials、环境变量或 Nacos 密文注入。
### 5.11 `event-bridge-service/`:跨区域事件桥
负责接收国内外服务器之间的业务事件,并通过 Inbox 表实现事件去重。它只传输必要业务事件,不共享头程和尾程数据库。事件载荷遵循 `common-event-contract`,应包含事件 ID、来源区域、目标区域、业务键、版本、时间和 Payload 哈希。
Event Bridge 负责接收国内外服务器之间的必要业务事件,并通过 Inbox 实现幂等。事件载荷遵循 `common-event-contract`,包含事件 ID、来源和目标区域、业务键、版本、时间及 Payload 哈希;头程与尾程不共享 MySQL、Redis、Nacos 或内部接口。
## 6. 配置、部署和辅助目录
@@ -229,6 +175,8 @@ deploy/
├─ env/.env.test.example # 环境变量示例
├─ scripts/deploy-services.sh # Jenkins 增量部署脚本
├─ docker-compose.yml # 11 个业务服务编排
├─ docker-compose.rocketmq.yml # 公共导出 RocketMQ 基础设施
├─ sql/ # 测试/存量环境统一迁移入口与说明
└─ service-map.yml # 模块、JAR、端口映射
```
@@ -240,6 +188,7 @@ deploy/
- `phase1-business-maturity-regression.ps1`:业务成熟度回归
- `phase1-onboarding-order-e2e.ps1`:企业入驻到订单端到端验证
- `order-warehouse-real-api-regression.ps1`:订单、仓库、往来单位真实 API 回归
- `export-center-test-env-acceptance.ps1`:公共导出中心测试环境验收
### `docs/` 与 `reports/`
@@ -254,12 +203,16 @@ deploy/
├─ IAM ── Auth(内部账号管理)
├─ Order ── IAM(企业资格)
│ └─ Warehouse(仓库、往来单位)
├─ Firstleg(履约任务与分配)
├─ File ── IAM(资质文件鉴权)
├─ Firstleg(履约、入库、测量审核与计费)
│ ├─ Order(订单货物明细)
│ ├─ Warehouse(托盘、库位预占与占用)
│ └─ File(第三方测量证据图片)
├─ File
│ ├─ IAM(资质文件鉴权与登记)
│ └─ Integration Service(重量 OCR)
├─ Print
└─ 平台内部能力
├─ Audit Service
├─ Integration Service
└─ Event Bridge Service ── 国外尾程事件入口
```
@@ -269,11 +222,11 @@ deploy/
推荐顺序:
1. 外部基础设施:MySQL、Redis、Nacos。
1. 外部基础设施:MySQL、Redis、Nacos;启用公共导出时再准备 RocketMQ。
2. 核心认证:IAM、Auth。
3. 业务主数据:Warehouse、File。
4. 核心业务:Order、Firstleg、Print。
5. 支撑能力:Audit、Integration、Event Bridge。
3. 主数据与适配:Warehouse、Integration。
4. 业务依赖:Order、File。
5. 核心编排与支撑:Firstleg、Print、Audit、Event Bridge。
6. 最后启动 Gateway。
Nacos 服务发现允许部分服务交换顺序,但联调时按上述顺序更容易判断故障来源。
+1 -2
View File
@@ -53,11 +53,10 @@ TEST_DB_PASSWORD=测试数据库密码
| 仓库服务 | `baiyue_warehouse` |
| 头程履约服务 | `baiyue_firstleg` |
| 文件服务 | `baiyue_file` |
| 打印服务 | `baiyue_print` |
| 审计服务 | `baiyue_audit` |
| 事件桥接服务 | `baiyue_event_bridge` |
网关和集成服务没有业务数据库,但同样支持 `dev/test` Profile。网关在两种环境下都会自动加载 `cn` 路由配置。
网关、打印和集成服务没有业务数据库,但同样支持 `dev/test` Profile。网关在两种环境下都会自动加载 `cn` 路由配置。
## 注意事项
+12 -4
View File
@@ -4,9 +4,10 @@
- File 服务是企业资质文件的唯一公开上传入口。必须先持久化并验证真实文件,再调用 IAM 内部接口登记元数据;禁止只生成或改写一个不存在文件的 URL。
- 负责企业资质文件的上传、存储、读取和访问鉴权;当前实现只使用本地磁盘,文件内容不进入 Git。
- `src/main/resources/db/file.sql` 是文件元数据、下载令牌和下载日志的目标表结构,当前代码尚未接入 Entity、Mapper 或 Repository,不能据此判断元数据持久化已实现。
- 负责企业资质文件、公共导出文件和二期入库测量图片的上传、存储、读取及访问鉴权;当前文件内容使用本地磁盘,不能进入 Git。
- `src/main/resources/db/file.sql` 中的 `file_object` 已由测量图片链路通过 `FileObjectEntity`、`FileObjectMapper` 接入,导出任务表也已接入;企业资质文件元数据仍归 IAM,不能把不同文件链路的持久化状态混为一谈。
- 企业资质审核状态和元数据归 `iam/`;本模块通过 IAM 客户端校验访问权限,不跨库读取 IAM 数据。
- 入库单和测量状态归 `firstleg/`,重量识别适配归 `integration-service/`;File 只保存图片对象、返回逻辑文件 ID 并代理受控 OCR 调用。
## 快速索引
@@ -23,7 +24,11 @@
| 毒消息隔离 | `mq/ExportDlqHandler.java`;定位必须使用四个 User Properties,不解析损坏 body |
| 未被当前 Controller 使用的旧实现 | `service/QualificationFileStorage.java` |
| IAM 权限协作 | `client/IamQualificationClient.java`;商户登记 `/qualifications`,平台登记/替换 `/platform-qualifications` |
| 目标表结构(尚未接入) | `src/main/resources/db/file.sql` |
| 平台称重图片上传、OCR 重试与查看 | `controller/PlatformMeasurementImageController.java`(`/api/platform/v1/files/measurements`)→ `service/MeasurementImageService.java` |
| Firstleg 第三方测量证据图片 | `controller/InternalMeasurementImageController.java`(`/internal/file/v1/measurement-images`)→ `MeasurementImageService.uploadMeasurementEvidence/deleteMeasurementEvidence`;必须校验内部令牌 |
| 测量图片磁盘与元数据 | `service/MeasurementImageStorage.java`、`entity/FileObjectEntity.java`、`mapper/FileObjectMapper.java`;表结构 `src/main/resources/db/file.sql` |
| 重量 OCR 下游 | `client/WeightOcrClient.java` → `integration-service` 的 `/internal/integrations/weight-ocr/single`;尺寸测量证据不触发重量 OCR |
| 测量图片测试 | `controller/PlatformMeasurementImageControllerContractTest.java`、`InternalMeasurementImageControllerTest.java`、`service/MeasurementImageServiceEvidenceTest.java` |
| 历史资质文件迁移 | `../scripts/migrate-local-qualification-file.ps1`、`../docs/企业资质文件迁移说明.md`;只适用于当前本地/共享目录存储实现 |
| 本地/Nacos 配置 | `src/main/resources/application.yml`、`../config/nacos/{local,cn}/file-service.yml` |
| 上传契约与存储测试 | `src/test/java/com/baiyue/file/controller/QualificationFileContractTest.java`、`service/LocalQualificationStorageTest.java` |
@@ -33,7 +38,10 @@
- 严格校验租户、文件名/路径、大小、类型和读取权限,防止目录穿越、越权访问和任意文件上传。
- 企业资质文件只接受 PDF、JPG 和 PNG,单个文件上限 5MB;Client、Admin 与 `LocalQualificationStorage` 的限制和提示必须保持一致。
- 生产存储应使用对象存储或共享文件存储;容器本地目录仅适合当前本地实现,不得硬编码环境路径。
- 接入元数据持久化时需同时实现 Entity、Mapper、Repository、事务和迁移验证,并同步检查 IAM 资质流程及平台/商户 Controller。
- 企业资质元数据后续若迁入 File,需同时实现 Entity、Mapper、Repository、事务和迁移验证,并同步检查 IAM 资质流程及平台/商户 Controller;不得复用测量图片 `file_object` 的已接入状态宣称资质元数据已迁移。
- 测量图片必须同时按 `tenantId + inboundId + fileId + businessType` 校验归属。平台称重图片使用 `INBOUND_WEIGHT_IMAGE` 并可调用 OCR;第三方尺寸证据使用 `INBOUND_MEASUREMENT_IMAGE`,只允许内部上传/删除且不得触发重量 OCR。
- 平台测量图片权限与 Firstleg 权限码保持一致:上传和重试要求 `platform:firstleg:inbound:weight:edit`,查看要求 `platform:firstleg:inbound:measure:view`;内部证据接口只信任共享内部令牌和明确操作人。
- OCR 失败时保留已落盘文件和 `file_object` 元数据供重试或人工录入;删除第三方证据采用逻辑删除并校验业务类型,不能删除称重图片或其他租户文件。
- 从父目录运行 `mvn -pl file -am test`,并按接口影响验证商户/平台上传、替换、预览、无权限、签名伪造和磁盘故障场景。
- 非本地环境必须显式配置 `FILE_STORAGE_ROOT` 并挂载持久化可写目录;服务启动时会校验目录,存储不可用时直接启动失败,避免健康检查正常而首次上传才返回 HTTP 200 + `S0503`。
- 文件写入失败属于基础设施故障,统一返回 `S0503`,服务日志保留实际目录和底层异常;禁止将磁盘故障包装成 `B0001`。
@@ -0,0 +1,23 @@
package com.baiyue.file.client;
import com.baiyue.common.core.api.ApiResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.multipart.MultipartFile;
import java.math.BigDecimal;
@FeignClient(name = "integration-service", contextId = "measurementWeightOcrClient",
url = "${baiyue.clients.integration.url:}", path = "/internal/integrations/weight-ocr")
public interface WeightOcrClient {
@PostMapping(value = "/single", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
ApiResponse<Result> recognize(@RequestHeader("X-Internal-Service-Token") String token,
@RequestPart("file") MultipartFile file);
record Result(String sourceImage, String croppedImage, String rawResponseFile,
String status, BigDecimal weight, Double confidence,
java.util.List<?> candidates, String error) {}
}
@@ -0,0 +1,95 @@
package com.baiyue.file.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.file.service.MeasurementImageService;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import org.springframework.http.CacheControl;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;
/** Firstleg 保存第三方尺寸测量证据图片的内部入口。 */
@RestController
@RequestMapping("/internal/file/v1/measurement-images")
public class InternalMeasurementImageController {
private final MeasurementImageService images;
private final byte[] expectedToken;
public InternalMeasurementImageController(
MeasurementImageService images,
@Value("${baiyue.security.internal-service-token}") String token) {
this.images = images;
this.expectedToken = token.getBytes(StandardCharsets.UTF_8);
}
@PostMapping(value = "/{tenantId}/{inboundId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ApiResponse<MeasurementImageService.UploadResult> upload(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@RequestHeader("X-Operator-Id") long operatorId,
@PathVariable long tenantId,
@PathVariable long inboundId,
@RequestPart("file") MultipartFile file) {
requireToken(token);
if (tenantId <= 0 || inboundId <= 0 || operatorId <= 0) {
throw new BizException(ResultCode.BAD_REQUEST, "租户、入库单和操作主体ID必须大于0");
}
return ApiResponse.success(
images.uploadMeasurementEvidence(tenantId, inboundId, operatorId, file));
}
@DeleteMapping("/{tenantId}/{inboundId}/{fileId}")
public ApiResponse<Void> delete(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@RequestHeader("X-Operator-Id") long operatorId,
@PathVariable long tenantId,
@PathVariable long inboundId,
@PathVariable long fileId) {
requireToken(token);
if (tenantId <= 0 || inboundId <= 0 || fileId <= 0 || operatorId <= 0) {
throw new BizException(ResultCode.BAD_REQUEST, "租户、入库单、文件和操作主体ID必须大于0");
}
images.deleteMeasurementEvidence(tenantId, inboundId, fileId, operatorId);
return ApiResponse.success(null);
}
@GetMapping("/{tenantId}/{inboundId}/{fileId}")
public ResponseEntity<byte[]> download(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@PathVariable long tenantId,
@PathVariable long inboundId,
@PathVariable long fileId) {
requireToken(token);
if (tenantId <= 0 || inboundId <= 0 || fileId <= 0) {
throw new BizException(ResultCode.BAD_REQUEST, "租户、入库单和文件ID必须大于0");
}
var image = images.download(tenantId, inboundId, fileId);
return ResponseEntity.ok()
.cacheControl(CacheControl.noStore())
.contentType(MediaType.parseMediaType(image.contentType()))
.contentLength(image.content().length)
.header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.inline()
.filename(image.fileName(), StandardCharsets.UTF_8).build().toString())
.body(image.content());
}
private void requireToken(String token) {
if (token == null || !MessageDigest.isEqual(expectedToken, token.getBytes(StandardCharsets.UTF_8))) {
throw new BizException(ResultCode.FORBIDDEN, "内部服务凭证无效");
}
}
}
@@ -0,0 +1,98 @@
package com.baiyue.file.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.file.service.MeasurementImageService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.http.CacheControl;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.nio.charset.StandardCharsets;
/** 平台入库测量图片上传、OCR 和受控读取入口。 */
@RestController
@RequestMapping("/api/platform/v1/files/measurements")
@Tag(name = "平台入库测量图片")
public class PlatformMeasurementImageController {
private final MeasurementImageService images;
public PlatformMeasurementImageController(MeasurementImageService images) {
this.images = images;
}
@PostMapping(value = "/{tenantId}/{inboundId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("hasAuthority('platform:firstleg:inbound:weight:edit')")
@Operation(summary = "上传称重图片并执行 OCR",
description = "图片先持久化再识别;OCR 失败时保留文件,允许重试或人工录入。")
public ApiResponse<MeasurementImageService.UploadResult> upload(
@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long tenantId,
@PathVariable long inboundId,
@RequestPart("file") MultipartFile file) {
long operatorId = requirePlatform(user);
requirePositive(tenantId, inboundId);
return ApiResponse.success(images.upload(tenantId, inboundId, operatorId, file));
}
@PostMapping("/{tenantId}/{inboundId}/{fileId}/recognize")
@PreAuthorize("hasAuthority('platform:firstleg:inbound:weight:edit')")
@Operation(summary = "重试称重图片 OCR")
public ApiResponse<MeasurementImageService.UploadResult> retryRecognition(
@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long tenantId,
@PathVariable long inboundId,
@PathVariable long fileId) {
requirePlatform(user);
requirePositive(tenantId, inboundId, fileId);
return ApiResponse.success(images.retryRecognition(tenantId, inboundId, fileId));
}
@GetMapping("/{tenantId}/{inboundId}/{fileId}")
@PreAuthorize("hasAuthority('platform:firstleg:inbound:measure:view')")
@Operation(summary = "查看入库单称重图片")
public ResponseEntity<byte[]> download(
@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long tenantId,
@PathVariable long inboundId,
@PathVariable long fileId) {
requirePlatform(user);
requirePositive(tenantId, inboundId, fileId);
var image = images.download(tenantId, inboundId, fileId);
return ResponseEntity.ok()
.cacheControl(CacheControl.noStore())
.contentType(MediaType.parseMediaType(image.contentType()))
.contentLength(image.content().length)
.header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.inline()
.filename(image.fileName(), StandardCharsets.UTF_8).build().toString())
.body(image.content());
}
private long requirePlatform(AuthenticatedUser user) {
if (user == null || !"PLATFORM".equalsIgnoreCase(user.appCode())
|| user.userId() == null || user.userId() <= 0) {
throw new BizException(ResultCode.FORBIDDEN, "需要平台端身份");
}
return user.userId();
}
private void requirePositive(long... ids) {
for (long id : ids) {
if (id <= 0) throw new BizException(ResultCode.BAD_REQUEST, "业务ID必须大于0");
}
}
}
@@ -0,0 +1,34 @@
package com.baiyue.file.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
@Getter
@Setter
@TableName("file_object")
public class FileObjectEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private String businessType;
private String businessId;
private String originalName;
private String objectKey;
private String storageProvider;
private String bucketName;
private String contentType;
private Long sizeBytes;
private String sha256;
private String status;
private Long createdBy;
private LocalDateTime createdAt;
private Long updatedBy;
private LocalDateTime updatedAt;
private Boolean deleted;
private Integer version;
}
@@ -0,0 +1,9 @@
package com.baiyue.file.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.file.entity.FileObjectEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface FileObjectMapper extends BaseMapper<FileObjectEntity> {
}
@@ -0,0 +1,155 @@
package com.baiyue.file.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.file.client.WeightOcrClient;
import com.baiyue.file.entity.FileObjectEntity;
import com.baiyue.file.mapper.FileObjectMapper;
import feign.FeignException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Service
public class MeasurementImageService {
private final MeasurementImageStorage storage;
private final FileObjectMapper files;
private final WeightOcrClient ocr;
private final String internalToken;
public MeasurementImageService(MeasurementImageStorage storage, FileObjectMapper files,
WeightOcrClient ocr,
@Value("${baiyue.security.internal-service-token}") String internalToken) {
this.storage = storage;
this.files = files;
this.ocr = ocr;
this.internalToken = internalToken;
}
@Transactional
public UploadResult upload(long tenantId, long inboundId, long operatorId, MultipartFile file) {
return recognize(storeObject(tenantId, inboundId, operatorId, file, "INBOUND_WEIGHT_IMAGE"));
}
/** 保存第三方尺寸测量证据图片,不触发重量 OCR。 */
@Transactional
public UploadResult uploadMeasurementEvidence(long tenantId, long inboundId, long operatorId,
MultipartFile file) {
FileObjectEntity object = storeObject(
tenantId, inboundId, operatorId, file, "INBOUND_MEASUREMENT_IMAGE");
return new UploadResult(object.getId(), object.getOriginalName(), object.getContentType(),
object.getSizeBytes(), "NOT_REQUIRED", null, null, null, null);
}
private FileObjectEntity storeObject(long tenantId, long inboundId, long operatorId,
MultipartFile file, String businessType) {
var stored = storage.store(tenantId, inboundId, file);
FileObjectEntity object = new FileObjectEntity();
object.setTenantId(tenantId);
object.setBusinessType(businessType);
object.setBusinessId(Long.toString(inboundId));
object.setOriginalName(stored.originalName());
object.setObjectKey(stored.objectKey());
object.setStorageProvider("LOCAL");
object.setBucketName("measurement-images");
object.setContentType(stored.contentType());
object.setSizeBytes(stored.size());
object.setSha256(stored.sha256());
object.setStatus("ACTIVE");
object.setCreatedBy(operatorId);
object.setUpdatedBy(operatorId);
object.setCreatedAt(LocalDateTime.now());
object.setUpdatedAt(LocalDateTime.now());
object.setDeleted(false);
object.setVersion(0);
files.insert(object);
return object;
}
public UploadResult retryRecognition(long tenantId, long inboundId, long fileId) {
FileObjectEntity object = requireObject(tenantId, inboundId, fileId);
if (!"INBOUND_WEIGHT_IMAGE".equals(object.getBusinessType())) {
throw new BizException(ResultCode.CONFLICT, "尺寸测量证据图片不执行重量OCR");
}
return recognize(object);
}
public Download download(long tenantId, long inboundId, long fileId) {
FileObjectEntity object = requireObject(tenantId, inboundId, fileId);
return new Download(object.getOriginalName(), object.getContentType(), storage.read(object.getObjectKey()));
}
@Transactional
public void deleteMeasurementEvidence(long tenantId, long inboundId, long fileId, long operatorId) {
FileObjectEntity object = requireObject(tenantId, inboundId, fileId);
if (!"INBOUND_MEASUREMENT_IMAGE".equals(object.getBusinessType())) {
throw new BizException(ResultCode.CONFLICT, "仅允许清理第三方尺寸测量证据图片");
}
object.setDeleted(true);
object.setStatus("DELETED");
object.setUpdatedBy(operatorId);
object.setUpdatedAt(LocalDateTime.now());
files.updateById(object);
storage.delete(object.getObjectKey());
}
private FileObjectEntity requireObject(long tenantId, long inboundId, long fileId) {
FileObjectEntity object = files.selectOne(new LambdaQueryWrapper<FileObjectEntity>()
.eq(FileObjectEntity::getTenantId, tenantId)
.eq(FileObjectEntity::getId, fileId)
.in(FileObjectEntity::getBusinessType, "INBOUND_WEIGHT_IMAGE", "INBOUND_MEASUREMENT_IMAGE")
.eq(FileObjectEntity::getBusinessId, Long.toString(inboundId))
.eq(FileObjectEntity::getDeleted, false));
if (object == null) throw new BizException(ResultCode.NOT_FOUND, "称重图片不存在");
return object;
}
private UploadResult recognize(FileObjectEntity object) {
byte[] content = storage.read(object.getObjectKey());
MultipartFile image = new StoredImageMultipartFile(
object.getOriginalName(), object.getContentType(), content);
try {
var response = ocr.recognize(internalToken, image);
if (response == null || !response.successful() || response.data() == null) {
return failed(object, response == null ? "OCR服务无响应" : response.message());
}
var result = response.data();
return new UploadResult(object.getId(), object.getOriginalName(), object.getContentType(),
object.getSizeBytes(), result.status(), result.weight(), "KG", result.confidence(), result.error());
} catch (FeignException exception) {
return failed(object, "OCR服务不可用,可稍后重试或人工录入");
}
}
private UploadResult failed(FileObjectEntity object, String message) {
return new UploadResult(object.getId(), object.getOriginalName(), object.getContentType(),
object.getSizeBytes(), "FAILED", null, null, null, message);
}
public record UploadResult(Long fileId, String fileName, String contentType, Long sizeBytes,
String recognitionStatus, BigDecimal weightValue, String weightUnit,
Double confidence, String error) {}
public record Download(String fileName, String contentType, byte[] content) {}
private record StoredImageMultipartFile(String originalFilename, String contentType, byte[] bytes)
implements MultipartFile {
@Override public String getName() { return "file"; }
@Override public String getOriginalFilename() { return originalFilename; }
@Override public String getContentType() { return contentType; }
@Override public boolean isEmpty() { return bytes.length == 0; }
@Override public long getSize() { return bytes.length; }
@Override public byte[] getBytes() { return bytes.clone(); }
@Override public InputStream getInputStream() { return new ByteArrayInputStream(bytes); }
@Override public void transferTo(java.io.File destination) throws IOException {
java.nio.file.Files.write(destination.toPath(), bytes);
}
}
}
@@ -0,0 +1,99 @@
package com.baiyue.file.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
import javax.imageio.ImageIO;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
import java.util.Locale;
import java.util.Set;
import java.util.UUID;
@Component
public class MeasurementImageStorage {
private static final long MAX_SIZE = 5L * 1024 * 1024;
private static final Set<String> TYPES = Set.of("image/jpeg", "image/png", "image/bmp");
private final Path root;
public MeasurementImageStorage(@Value("${baiyue.file.storage-root}") Path root) {
this.root = root.toAbsolutePath().normalize();
}
public Stored store(long tenantId, long inboundId, MultipartFile file) {
if (file == null || file.isEmpty()) throw bad("称重图片不能为空");
if (file.getSize() > MAX_SIZE) throw bad("称重图片不能超过5MB");
String type = file.getContentType() == null ? "" : file.getContentType().toLowerCase(Locale.ROOT);
if (!TYPES.contains(type)) throw bad("称重图片仅支持JPG、PNG、BMP");
verifyImage(file);
String extension = "image/png".equals(type) ? ".png" : ("image/bmp".equals(type) ? ".bmp" : ".jpg");
String objectKey = "measurements/" + tenantId + "/" + inboundId + "/" + UUID.randomUUID() + extension;
Path target = root.resolve(objectKey).normalize();
if (!target.startsWith(root)) throw new BizException(ResultCode.FORBIDDEN, "文件路径越界");
try {
Files.createDirectories(target.getParent());
file.transferTo(target);
return new Stored(objectKey, target, type, file.getSize(), sha256(target), safeName(file));
} catch (IOException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "称重图片保存失败");
}
}
public byte[] read(String objectKey) {
Path path = root.resolve(objectKey).normalize();
if (!path.startsWith(root) || !Files.isRegularFile(path)) {
throw new BizException(ResultCode.NOT_FOUND, "称重图片不存在");
}
try { return Files.readAllBytes(path); }
catch (IOException exception) { throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "称重图片读取失败"); }
}
public void delete(String objectKey) {
Path path = root.resolve(objectKey).normalize();
if (!path.startsWith(root)) throw new BizException(ResultCode.FORBIDDEN, "文件路径越界");
try {
Files.deleteIfExists(path);
} catch (IOException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "测量图片清理失败");
}
}
private void verifyImage(MultipartFile file) {
try (InputStream input = file.getInputStream()) {
if (ImageIO.read(input) == null) throw bad("上传内容不是有效图片");
} catch (IOException exception) {
throw bad("上传内容不是有效图片");
}
}
private static String sha256(Path path) throws IOException {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
try (InputStream input = Files.newInputStream(path)) {
byte[] buffer = new byte[8192];
for (int count; (count = input.read(buffer)) >= 0;) digest.update(buffer, 0, count);
}
return HexFormat.of().formatHex(digest.digest());
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException(exception);
}
}
private static String safeName(MultipartFile file) {
String name = file.getOriginalFilename();
if (name == null || name.isBlank()) return "weight-image";
return Path.of(name).getFileName().toString();
}
private static BizException bad(String message) { return new BizException(ResultCode.BAD_REQUEST, message); }
public record Stored(String objectKey, Path path, String contentType, long size, String sha256,
String originalName) {}
}
+2
View File
@@ -51,6 +51,8 @@ baiyue:
clients:
iam:
url: ${IAM_SERVICE_URL:}
integration:
url: ${INTEGRATION_SERVICE_URL:}
file:
storage-root: ${FILE_STORAGE_ROOT:${user.home}/baiyue-firstleg/uploads}
export:
@@ -0,0 +1,55 @@
package com.baiyue.file.controller;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.file.service.MeasurementImageService;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockMultipartFile;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class InternalMeasurementImageControllerTest {
private final MeasurementImageService images = mock(MeasurementImageService.class);
private final InternalMeasurementImageController controller =
new InternalMeasurementImageController(images, "internal-token");
@Test
void 保存第三方尺寸测量图片但不触发重量识别契约() {
var file = new MockMultipartFile("file", "measure.png", MediaType.IMAGE_PNG_VALUE,
new byte[]{1, 2, 3});
var result = new MeasurementImageService.UploadResult(41L, "measure.png",
MediaType.IMAGE_PNG_VALUE, 3L, "NOT_REQUIRED", null, null, null, null);
when(images.uploadMeasurementEvidence(11L, 21L, 31L, file)).thenReturn(result);
var response = controller.upload("internal-token", 31L, 11L, 21L, file);
assertThat(response.data().fileId()).isEqualTo(41L);
assertThat(response.data().recognitionStatus()).isEqualTo("NOT_REQUIRED");
verify(images).uploadMeasurementEvidence(11L, 21L, 31L, file);
}
@Test
void 内部令牌错误时拒绝保存() {
var file = new MockMultipartFile("file", "measure.png", MediaType.IMAGE_PNG_VALUE,
new byte[]{1});
assertThatThrownBy(() -> controller.upload("bad-token", 31L, 11L, 21L, file))
.isInstanceOf(BizException.class)
.hasMessageContaining("凭证无效");
}
@Test
void 内部下载同时校验租户入库单和文件ID() {
when(images.download(11L, 21L, 41L)).thenReturn(
new MeasurementImageService.Download("measure.png", MediaType.IMAGE_PNG_VALUE,
new byte[]{1, 2, 3}));
var response = controller.download("internal-token", 11L, 21L, 41L);
assertThat(response.getHeaders().getContentType()).isEqualTo(MediaType.IMAGE_PNG);
assertThat(response.getBody()).containsExactly(1, 2, 3);
verify(images).download(11L, 21L, 41L);
}
}
@@ -0,0 +1,59 @@
package com.baiyue.file.controller;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.file.service.MeasurementImageService;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import java.lang.reflect.Method;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
class PlatformMeasurementImageControllerContractTest {
private final MeasurementImageService images = mock(MeasurementImageService.class);
private final PlatformMeasurementImageController controller =
new PlatformMeasurementImageController(images);
@Test
void 暴露平台测量图片multipart入口并校验重量维护权限() throws Exception {
RequestMapping base = PlatformMeasurementImageController.class.getAnnotation(RequestMapping.class);
assertThat(base.value()).containsExactly("/api/platform/v1/files/measurements");
Method method = PlatformMeasurementImageController.class.getDeclaredMethod("upload",
AuthenticatedUser.class, long.class, long.class, org.springframework.web.multipart.MultipartFile.class);
assertThat(method.getAnnotation(PostMapping.class).consumes())
.containsExactly(MediaType.MULTIPART_FORM_DATA_VALUE);
assertThat(method.getAnnotation(PreAuthorize.class).value())
.isEqualTo("hasAuthority('platform:firstleg:inbound:weight:edit')");
}
@Test
void 商户身份不能调用平台上传接口() {
var file = new MockMultipartFile("file", "weight.png", MediaType.IMAGE_PNG_VALUE, new byte[]{1});
var merchant = new AuthenticatedUser(2L, 10001L, 10001L, 2L,
"merchant", "MERCHANT", "WEB", Set.of());
assertThatThrownBy(() -> controller.upload(merchant, 10001L, 88L, file))
.isInstanceOf(BizException.class);
}
@Test
void 下载始终同时使用租户入库单和文件ID隔离() {
var platform = new AuthenticatedUser(1L, 0L, 0L, 1L,
"admin", "PLATFORM", "WEB", Set.of());
org.mockito.Mockito.when(images.download(10001L, 88L, 99L)).thenReturn(
new MeasurementImageService.Download("weight.png", MediaType.IMAGE_PNG_VALUE, new byte[]{1, 2}));
controller.download(platform, 10001L, 88L, 99L);
verify(images).download(10001L, 88L, 99L);
}
}
@@ -0,0 +1,63 @@
package com.baiyue.file.service;
import com.baiyue.file.client.WeightOcrClient;
import com.baiyue.file.entity.FileObjectEntity;
import com.baiyue.file.mapper.FileObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockMultipartFile;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
class MeasurementImageServiceEvidenceTest {
@Test
void 尺寸测量证据图片落库但不调用重量Ocr() {
MeasurementImageStorage storage = mock(MeasurementImageStorage.class);
FileObjectMapper files = mock(FileObjectMapper.class);
WeightOcrClient ocr = mock(WeightOcrClient.class);
MeasurementImageService service = new MeasurementImageService(storage, files, ocr, "internal-token");
var image = new MockMultipartFile("file", "measure.png", MediaType.IMAGE_PNG_VALUE,
new byte[]{1, 2, 3});
when(storage.store(11L, 21L, image)).thenReturn(new MeasurementImageStorage.Stored(
"measurements/11/21/a.png", Path.of("a.png"), MediaType.IMAGE_PNG_VALUE,
3L, "hash", "measure.png"));
doAnswer(invocation -> {
FileObjectEntity object = invocation.getArgument(0);
object.setId(41L);
return 1;
}).when(files).insert(any(FileObjectEntity.class));
var result = service.uploadMeasurementEvidence(11L, 21L, 31L, image);
assertThat(result.fileId()).isEqualTo(41L);
assertThat(result.recognitionStatus()).isEqualTo("NOT_REQUIRED");
verifyNoInteractions(ocr);
}
@Test
void 尺寸测量证据图片不能通过重试入口触发重量Ocr() {
MeasurementImageStorage storage = mock(MeasurementImageStorage.class);
FileObjectMapper files = mock(FileObjectMapper.class);
WeightOcrClient ocr = mock(WeightOcrClient.class);
MeasurementImageService service = new MeasurementImageService(storage, files, ocr, "internal-token");
FileObjectEntity object = new FileObjectEntity();
object.setId(41L);
object.setTenantId(11L);
object.setBusinessType("INBOUND_MEASUREMENT_IMAGE");
object.setBusinessId("21");
when(files.selectOne(any())).thenReturn(object);
assertThatThrownBy(() -> service.retryRecognition(11L, 21L, 41L))
.isInstanceOf(com.baiyue.common.core.exception.BizException.class)
.hasMessageContaining("不执行重量OCR");
verifyNoInteractions(ocr);
}
}
+23 -1
View File
@@ -2,8 +2,9 @@
## 职责边界
- 负责订单提交后的国内头程履约任务、操作分配和分配历史。
- 负责订单提交后的国内头程履约任务、操作分配和分配历史,以及二期入库单、测量、审核、FBA 计费、费用发布和完成入库状态编排。
- 订单主单、商户草稿和交易状态属于 `order/`;不要把订单模型复制进本服务。
- 订单货物明细归 `order/`,托盘和库位资源归 `warehouse/`,图片对象归 `file/`,OCR 供应商适配归 `integration-service/`;本服务只保存业务快照、逻辑引用和自身状态。
## 快速索引
@@ -20,6 +21,22 @@
| OpenAPI 与测试 | `src/main/resources/openapi/firstleg-v1.yaml`、`service/FulfillmentTaskServiceTest.java`、`controller/FulfillmentTaskControllerContractTest.java`(测试路径均从 `src/test/java/com/baiyue/firstleg/` 起算) |
| 本地/Nacos 配置 | `src/main/resources/application.yml`、`../config/nacos/{local,cn}/firstleg-service.yml` |
## 二期入库快速索引
以下 Java 包路径从 `src/main/java/com/baiyue/firstleg/` 起算,测试路径从 `src/test/java/com/baiyue/firstleg/` 起算。
| 功能 | API 与业务入口 | 持久化、协作与测试 |
|---|---|---|
| 入库单列表、状态页签和聚合详情 | `controller/PlatformInboundOrderController.java` → `service/InboundOrderQueryService.java` | `repository/InboundOrderRepository.java`、`mapper/InboundAllocationQueryMapper.java`;测试 `service/InboundOrderQueryServiceTest.java`、`controller/InboundOrderControllerContractTest.java` |
| 平台测量草稿、提交和重量结果 | `controller/PlatformInboundMeasurementController.java` → `service/InboundMeasurementService.java`、`MeasurementSubmissionService.java` | `repository/InboundMeasurementRepository.java`;图片 `client/MeasurementImageClient.java`,托盘库位 `client/WarehousePalletLocationClient.java`;测试 `service/InboundMeasurementServiceTest.java`、`MeasurementSubmissionServiceTest.java` |
| 第三方尺寸测量回传 | `controller/OpenApiInboundMeasurementController.java` → `security/MeasurementRequestAuthenticator.java` → `service/InboundMeasurementOpenApiService.java` | 货物明细 `client/OrderCargoClient.java`,证据图片 `client/MeasurementImageClient.java`;配置 `config/MeasurementOpenApiProperties.java`;测试 `service/InboundMeasurementOpenApiServiceTest.java`、`controller/OpenApiInboundMeasurementControllerContractTest.java` |
| 测量审核、驳回和异常放行 | `controller/PlatformInboundMeasurementReviewController.java` → `service/MeasurementReviewService.java` | 自动后续流程 `service/AutomaticInboundCompletionService.java`;测试 `service/MeasurementReviewServiceTest.java`、`controller/InboundMeasurementReviewContractTest.java` |
| 客户规则和承运商价格版本 | `controller/PlatformFbaMasterDataController.java` → `service/FbaMasterDataService.java` | `repository/FbaPricingRepository.java`、`src/main/resources/openapi/fba-pricing-v1.yaml`;测试 `service/FbaMasterDataServiceTest.java` |
| 费用计算、确认、发布与快照 | `controller/PlatformFbaPricingController.java` → `service/FbaPricingService.java` | 自动计费和完成入库 `AutomaticInboundCompletionService.java`、`InboundCompletionOrchestrator.java`;测试 `service/FbaPricingServiceTest.java`、`InboundCompletionOrchestratorTest.java` |
| 商户入库费用查询 | `controller/MerchantInboundBillingController.java` → `service/MerchantInboundBillingService.java` | 测试 `service/MerchantInboundBillingServiceTest.java`、`controller/MerchantInboundBillingControllerContractTest.java` |
| 状态、幂等、历史和 Outbox | `domain/InboundProcessStatus.java`、`service/InboundIdempotencyService.java` | `repository/InboundWorkflowRepository.java`、`entity/Inbound*.java`、`mapper/Inbound*.java`;测试 `service/InboundIdempotencyServiceTest.java`、`repository/InboundWorkflowSqlContractTest.java` |
| 二期表结构和增量 | `src/main/resources/db/phase2-inbound-schema.sql`、`phase2-inbound-workflow-migration.sql`、`phase2-inbound-measurement-maintenance.sql`、`phase2-measurement-openapi.sql`、`phase2-third-party-single-measurement-migration.sql`、`phase2-fba-pricing-migration.sql` | 测试/存量环境统一入口为 `../deploy/sql/2026-08-04-二期入库新增表字段-MySQL8一键执行.sql`,执行说明见 `../deploy/sql/README.md` |
## 修改与验证
- 任务状态与订单状态保持领域分离;需要联动时使用稳定 DTO 或事件,不共享 Order Entity 或数据库。
@@ -28,5 +45,10 @@
- 普通任务不存在、状态冲突和参数错误均遵循 HTTP 200 + body code,平台 Security 拒绝仍保留真实 403。
- 新建库由 `firstleg.sql` 创建查询索引;既有库必须再执行可重复部署的 `phase1-firstleg-task-list-index.sql`,不能只依赖 `CREATE TABLE IF NOT EXISTS`。
- 修改任务查询、状态或分配逻辑时同步扩展 Service 测试和 Controller 契约测试,不以模块构建代替行为验证。
- 二期入库所有平台查询和写入必须显式校验目标租户;商户费用查询只能使用登录态租户。状态推进、审核、计费和完成入库必须保留乐观锁、幂等键、历史和审计语义。
- 第三方测量入口必须执行签名、时间窗、防重放和请求哈希校验;一条回传绑定一条订单货物明细及一张 File 证据图片,不得信任第三方直接提交的租户或订单快照。
- Firstleg 调用 Order、Warehouse、File 的内部接口统一使用共享 `INTERNAL_SERVICE_TOKEN`,只通过客户端 DTO 协作;任何内部接口、Feign DTO 或状态变化都要同步检查被调用模块和契约测试。
- 测量审核通过后的自动链路依次完成计费、确认、费用发布与入库完成;异常放行必须受配置开关、权限和审核历史共同约束,不能跳过测量门禁直接改状态。
- 从父目录运行 `mvn -pl firstleg -am test`。
- 数据库执行顺序:新库执行 `firstleg.sql`;已有库在此后执行 `phase1-firstleg-task-list-index.sql`,为任务列表、按订单查询和分配历史分页补齐索引。
- 二期数据库不得只执行单个建表脚本覆盖存量库;测试/存量环境按 `../deploy/sql/README.md` 使用 8 月 4 日统一脚本,并确认末尾核验全部为 `PASS`。
+4
View File
@@ -14,9 +14,13 @@
<dependency><groupId>com.alibaba.cloud</groupId><artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId></dependency>
<dependency><groupId>com.alibaba.cloud</groupId><artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId></dependency>
<dependency><groupId>com.alibaba.cloud</groupId><artifactId>spring-cloud-starter-alibaba-sentinel</artifactId></dependency>
<dependency><groupId>org.springframework.cloud</groupId><artifactId>spring-cloud-starter-openfeign</artifactId></dependency>
<dependency><groupId>org.springframework.cloud</groupId><artifactId>spring-cloud-starter-loadbalancer</artifactId></dependency>
<dependency><groupId>com.alibaba.cloud</groupId><artifactId>spring-cloud-starter-stream-rocketmq</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-actuator</artifactId></dependency>
<dependency><groupId>com.mysql</groupId><artifactId>mysql-connector-j</artifactId><scope>runtime</scope></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency>
</dependencies>
<build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build>
</project>
@@ -3,11 +3,13 @@ package com.baiyue.firstleg;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.openfeign.EnableFeignClients;
/**
* 头程履约微服务启动入口,负责装配履约任务组件并扫描 MyBatis-Plus Mapper。
*/
@MapperScan("com.baiyue.firstleg.mapper")
@EnableFeignClients(basePackages = "com.baiyue.firstleg.client")
@SpringBootApplication(scanBasePackages = "com.baiyue")
public class BaiyueFirstlegApplication {
public static void main(String[] args) { SpringApplication.run(BaiyueFirstlegApplication.class, args); }
@@ -0,0 +1,43 @@
package com.baiyue.firstleg.client;
import com.baiyue.common.core.api.ApiResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.multipart.MultipartFile;
import java.math.BigDecimal;
@FeignClient(name = "file-service", contextId = "firstlegMeasurementImageClient",
url = "${baiyue.clients.file.url:}", path = "/internal/file/v1/measurement-images")
public interface MeasurementImageClient {
@PostMapping(value = "/{tenantId}/{inboundId}", consumes = "multipart/form-data")
ApiResponse<UploadResult> upload(@RequestHeader("X-Internal-Service-Token") String token,
@RequestHeader("X-Operator-Id") long operatorId,
@PathVariable long tenantId,
@PathVariable long inboundId,
@RequestPart("file") MultipartFile file);
@DeleteMapping("/{tenantId}/{inboundId}/{fileId}")
ApiResponse<Void> delete(@RequestHeader("X-Internal-Service-Token") String token,
@RequestHeader("X-Operator-Id") long operatorId,
@PathVariable long tenantId,
@PathVariable long inboundId,
@PathVariable long fileId);
@GetMapping("/{tenantId}/{inboundId}/{fileId}")
org.springframework.http.ResponseEntity<byte[]> download(
@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable long tenantId,
@PathVariable long inboundId,
@PathVariable long fileId);
record UploadResult(Long fileId, String fileName, String contentType, Long sizeBytes,
String recognitionStatus, BigDecimal weightValue, String weightUnit,
Double confidence, String error) {
}
}
@@ -0,0 +1,24 @@
package com.baiyue.firstleg.client;
import com.baiyue.common.core.api.ApiResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestHeader;
import java.math.BigDecimal;
@FeignClient(name = "order-service", contextId = "firstlegOrderCargoClient",
url = "${baiyue.clients.order.url:}", path = "/internal/order/v1/cargoes")
public interface OrderCargoClient {
@GetMapping("/{tenantId}/{orderId}/{cargoId}")
ApiResponse<CargoBinding> detail(@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable long tenantId,
@PathVariable long orderId,
@PathVariable long cargoId);
record CargoBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName,
Integer originalPackageCount, BigDecimal originalGrossWeightKg,
BigDecimal originalVolumeCbm) {
}
}
@@ -0,0 +1,42 @@
package com.baiyue.firstleg.client;
import com.baiyue.common.core.api.ApiResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
@FeignClient(name = "warehouse-service", contextId = "warehousePalletLocationClient",
url = "${baiyue.clients.warehouse.url:}", path = "/internal/warehouse/v1/pallet-locations")
public interface WarehousePalletLocationClient {
@PostMapping("/reservations")
ApiResponse<BatchResult> reserve(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody ReserveRequest request);
@PostMapping("/confirm")
ApiResponse<BatchResult> confirm(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody ConfirmRequest request);
@PostMapping("/release")
ApiResponse<List<ActiveResource>> release(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody ReleaseRequest request);
record ReserveRequest(long tenantId, long warehouseId, String sourceService, long businessId,
String inboundCode, String idempotencyKey, long operatorId,
BigDecimal totalVolumeCbm, BigDecimal palletCapacityCbm, String palletType,
LocalDateTime expiresAt, boolean measurementCompleted) {}
record ConfirmRequest(long tenantId, long warehouseId, String sourceService, long businessId,
long operatorId, BigDecimal totalVolumeCbm, BigDecimal palletCapacityCbm) {}
record ReleaseRequest(long tenantId, String sourceService, long businessId, long operatorId,
String reason) {}
record AllocationResult(long reservationId, String reservationNo, int allocationNo,
long palletId, String palletCode, long locationId, String locationCode,
BigDecimal allocatedVolumeCbm, String state) {}
record BatchResult(int palletCount, List<AllocationResult> allocations) {}
record ActiveResource(long reservationId, long warehouseId, long palletId, String palletCode,
long locationId, String locationCode, String state) {}
}
@@ -0,0 +1,23 @@
package com.baiyue.firstleg.config;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
import java.time.Duration;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* 头程入库动态配置。代码默认值必须保持关闭,避免 Nacos 缺失时异常放行。
*/
@Getter
@Setter
@Component
@ConfigurationProperties(prefix = "firstleg.inbound")
public class InboundProperties {
private boolean allowRejectedMeasurement = false;
private BigDecimal defaultPalletCapacityCbm = new BigDecimal("1.200000");
private Duration reservationTtl = Duration.ofMinutes(30);
/** 共享收货仓资源所属租户;0 表示与入库业务租户一致。 */
private long warehouseResourceTenantId = 0L;
}
@@ -0,0 +1,29 @@
package com.baiyue.firstleg.config;
import io.swagger.v3.oas.annotations.enums.SecuritySchemeIn;
import io.swagger.v3.oas.annotations.enums.SecuritySchemeType;
import io.swagger.v3.oas.annotations.security.SecurityScheme;
import org.springdoc.core.models.GroupedOpenApi;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/** 第三方测量接口独立 Swagger 分组和 HMAC 请求头定义。 */
@Configuration
@SecurityScheme(name = "measurementAppKey", type = SecuritySchemeType.APIKEY,
in = SecuritySchemeIn.HEADER, paramName = "X-App-Key", description = "分配给测量服务商的应用标识")
@SecurityScheme(name = "measurementTimestamp", type = SecuritySchemeType.APIKEY,
in = SecuritySchemeIn.HEADER, paramName = "X-Timestamp", description = "Unix 毫秒时间戳")
@SecurityScheme(name = "measurementNonce", type = SecuritySchemeType.APIKEY,
in = SecuritySchemeIn.HEADER, paramName = "X-Nonce", description = "每次请求唯一的随机串")
@SecurityScheme(name = "measurementSignature", type = SecuritySchemeType.APIKEY,
in = SecuritySchemeIn.HEADER, paramName = "X-Signature", description = "签名串的 HMAC-SHA256 小写十六进制值")
public class MeasurementOpenApiDocumentationConfiguration {
@Bean
GroupedOpenApi measurementExternalApi() {
return GroupedOpenApi.builder()
.group("measurement-openapi")
.displayName("Third-party Measurement OpenAPI / 第三方测量开放接口")
.pathsToMatch("/openapi/v1/inbound-measurements/**")
.build();
}
}
@@ -0,0 +1,34 @@
package com.baiyue.firstleg.config;
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 第三方测量开放接口配置。密钥只能由环境变量或配置中心密文注入。
*/
@Getter
@Setter
@Component
@ConfigurationProperties(prefix = "firstleg.measurement-openapi")
public class MeasurementOpenApiProperties {
private boolean enabled = false;
private Duration maxClockSkew = Duration.ofMinutes(5);
private Duration nonceTtl = Duration.ofMinutes(10);
private Map<String, AppCredential> apps = new LinkedHashMap<>();
@Getter
@Setter
public static class AppCredential {
private boolean enabled = true;
private String secret;
private Long tenantId;
private Long subjectId;
private Map<String, Long> warehouseBindings = new LinkedHashMap<>();
}
}
@@ -0,0 +1,44 @@
package com.baiyue.firstleg.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.OrderApprovedInboundCommand;
import com.baiyue.firstleg.service.InboundOrderCreationService;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
/** 接收订单服务审核通过事件,创建入库单。 */
@RestController
@RequestMapping("/internal/firstleg/v1/inbounds")
public class InternalInboundOrderController {
private final InboundOrderCreationService service;
private final byte[] expectedToken;
public InternalInboundOrderController(InboundOrderCreationService service,
@Value("${baiyue.security.internal-service-token}") String token) {
this.service = service;
this.expectedToken = token.getBytes(StandardCharsets.UTF_8);
}
@PostMapping("/order-approved")
public ApiResponse<Long> createFromApprovedOrder(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@RequestBody OrderApprovedInboundCommand command) {
if (token == null || !MessageDigest.isEqual(expectedToken, token.getBytes(StandardCharsets.UTF_8))) {
throw new BizException(ResultCode.FORBIDDEN, "内部服务凭证无效");
}
if (command.tenantId() <= 0 || command.orderId() <= 0 || command.warehouseId() == null
|| command.warehouseId() <= 0 || command.customerName() == null || command.customerName().isBlank()) {
throw new BizException(ResultCode.BAD_REQUEST, "订单入库快照不完整");
}
return ApiResponse.success(service.createFromApprovedOrder(command));
}
}
@@ -0,0 +1,58 @@
package com.baiyue.firstleg.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.MerchantInboundBillingResponse;
import com.baiyue.firstleg.service.MerchantInboundBillingService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.http.CacheControl;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
@Tag(name = "客户端入库结果与费用")
@RestController
@RequestMapping("/api/merchant/v1/first-leg/orders")
public class MerchantInboundBillingController {
private final MerchantInboundBillingService service;
public MerchantInboundBillingController(MerchantInboundBillingService service) {
this.service = service;
}
@GetMapping("/{orderId}/inbound-billing")
@PreAuthorize("hasAuthority('merchant:order:view')")
@Operation(summary = "查询订单入库结果和最新费用",
description = "客户只读查看客服审核通过后发布的费用,不需要确认费用。")
public ApiResponse<MerchantInboundBillingResponse> find(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long orderId) {
if (user == null || !"MERCHANT".equals(user.appCode()) || user.tenantId() == null) {
throw new BizException(ResultCode.FORBIDDEN, "仅允许客户端访问");
}
return ApiResponse.success(service.find(user.tenantId(), orderId));
}
@GetMapping("/{orderId}/inbound-billing/images/{fileId}")
@PreAuthorize("hasAuthority('merchant:order:view')")
@Operation(summary = "查看已发布计费明细中的测量或称重图片")
public ResponseEntity<byte[]> image(
@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long orderId,
@PathVariable long fileId) {
if (user == null || !"MERCHANT".equals(user.appCode()) || user.tenantId() == null) {
throw new BizException(ResultCode.FORBIDDEN, "仅允许客户端访问");
}
var image = service.downloadImage(user.tenantId(), orderId, fileId);
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
.contentType(MediaType.parseMediaType(image.contentType()))
.contentLength(image.content().length).body(image.content());
}
}
@@ -0,0 +1,77 @@
package com.baiyue.firstleg.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.firstleg.dto.OpenApiMeasurementDtos.EventResponse;
import com.baiyue.firstleg.security.MeasurementRequestHeaders;
import com.baiyue.firstleg.service.InboundMeasurementOpenApiService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.enums.ParameterIn;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.constraints.Pattern;
import org.springframework.validation.annotation.Validated;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.multipart.MultipartFile;
/** 第三方设备长宽高测量开放接口。 */
@Validated
@RestController
@RequestMapping("/openapi/v1/inbound-measurements")
@Tag(name = "第三方入库测量 OpenAPI", description = "按入仓码和订单货物明细接收一条箱规及一张图片,只落测量数据,不审核、计费或入库")
@SecurityRequirement(name = "measurementAppKey")
@SecurityRequirement(name = "measurementTimestamp")
@SecurityRequirement(name = "measurementNonce")
@SecurityRequirement(name = "measurementSignature")
public class OpenApiInboundMeasurementController {
private final InboundMeasurementOpenApiService service;
public OpenApiInboundMeasurementController(InboundMeasurementOpenApiService service) {
this.service = service;
}
@Operation(summary = "提交一条第三方箱规测量结果和图片",
description = "multipart 包含 data 原始JSON和 image 图片;签名 body 为 data原文、换行、图片SHA-256,外层仍按 METHOD、PATH、时间戳、Nonce、Idempotency-Key、body SHA-256 六行签名")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "成功或业务失败,按响应体 code 判断"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "413", description = "请求体超过网关限制"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "415", description = "Content-Type 不支持")
})
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ApiResponse<EventResponse> receive(
@RequestHeader("X-App-Key") String appKey,
@RequestHeader("X-Timestamp") String timestamp,
@RequestHeader("X-Nonce") String nonce,
@RequestHeader("X-Signature") String signature,
@RequestHeader("Idempotency-Key") String idempotencyKey,
@RequestPart("data") String rawData,
@RequestPart("image") MultipartFile image) {
return ApiResponse.success(service.receive(
new MeasurementRequestHeaders(appKey, timestamp, nonce, signature, idempotencyKey),
rawData, image));
}
@Operation(summary = "查询第三方测量事件状态",
description = "GET 签名的 Idempotency-Key 行和 body SHA-256 分别为空字符串、空字节 SHA-256")
@ApiResponses(@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200", description = "查询成功或业务失败,按响应体 code 判断"))
@GetMapping("/events/{eventNo}")
public ApiResponse<EventResponse> query(
@Parameter(description = "第三方业务事件号", required = true)
@PathVariable @Pattern(regexp = "[A-Za-z0-9._:-]{1,128}") String eventNo,
@RequestHeader("X-App-Key") String appKey,
@RequestHeader("X-Timestamp") String timestamp,
@RequestHeader("X-Nonce") String nonce,
@RequestHeader("X-Signature") String signature) {
return ApiResponse.success(service.query(eventNo,
new MeasurementRequestHeaders(appKey, timestamp, nonce, signature, null)));
}
}
@@ -0,0 +1,108 @@
package com.baiyue.firstleg.controller;
import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.FbaPricingDtos.CarrierRateVersionRequest;
import com.baiyue.firstleg.dto.FbaPricingDtos.CustomerRuleVersionRequest;
import com.baiyue.firstleg.entity.CarrierFbaRateEntity;
import com.baiyue.firstleg.entity.CustomerChargeRuleEntity;
import com.baiyue.firstleg.service.FbaMasterDataService;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/** 平台端客户计费规则和船司 FBA 价格版本管理。 */
@Tag(name = "FBA计费基础资料")
@RestController
@RequestMapping("/api/platform/v1/fba-pricing")
public class PlatformFbaMasterDataController {
private final FbaMasterDataService service;
public PlatformFbaMasterDataController(FbaMasterDataService service) { this.service = service; }
@GetMapping("/customer-rules")
@PreAuthorize("hasAuthority('firstleg:fba-pricing:rule:view')")
public ApiResponse<List<CustomerChargeRuleEntity>> rules(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId,
@RequestParam(required = false) String ruleCode) {
return ApiResponse.success(service.listRules(FirstlegScopeGuard.platform(user, tenantId), ruleCode));
}
@GetMapping("/customer-rules/{id}")
@PreAuthorize("hasAuthority('firstleg:fba-pricing:rule:view')")
public ApiResponse<CustomerChargeRuleEntity> rule(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId, @PathVariable long id) {
return ApiResponse.success(service.rule(FirstlegScopeGuard.platform(user, tenantId), id));
}
@PostMapping("/customer-rules/versions")
@PreAuthorize("hasAuthority('firstleg:fba-pricing:rule:edit')")
@BusinessAudit(event = "FBA_RULE_VERSION_CREATE", resourceType = "CUSTOMER_CHARGE_RULE",
description = "创建客户FBA计费规则版本")
public ApiResponse<CustomerChargeRuleEntity> createRule(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId,
@Valid @RequestBody CustomerRuleVersionRequest request) {
long scope = FirstlegScopeGuard.platform(user, tenantId);
return ApiResponse.success(service.createRuleVersion(scope, user.userId(), request));
}
@DeleteMapping("/customer-rules/{id}")
@PreAuthorize("hasAuthority('firstleg:fba-pricing:rule:edit')")
@BusinessAudit(event = "FBA_RULE_RETIRE", resourceType = "CUSTOMER_CHARGE_RULE", resourceId = "#id",
description = "停用客户FBA计费规则版本")
public ApiResponse<Void> retireRule(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long tenantId, @PathVariable long id) {
service.retireRule(FirstlegScopeGuard.platform(user, tenantId), id, user.userId());
return ApiResponse.success(null);
}
@GetMapping("/carrier-rates")
@PreAuthorize("hasAuthority('firstleg:fba-pricing:rate:view')")
public ApiResponse<List<CarrierFbaRateEntity>> rates(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId,
@RequestParam(required = false) String rateCode) {
return ApiResponse.success(service.listRates(FirstlegScopeGuard.platform(user, tenantId), rateCode));
}
@GetMapping("/carrier-rates/{id}")
@PreAuthorize("hasAuthority('firstleg:fba-pricing:rate:view')")
public ApiResponse<CarrierFbaRateEntity> rate(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId, @PathVariable long id) {
return ApiResponse.success(service.rate(FirstlegScopeGuard.platform(user, tenantId), id));
}
@PostMapping("/carrier-rates/versions")
@PreAuthorize("hasAuthority('firstleg:fba-pricing:rate:edit')")
@BusinessAudit(event = "FBA_RATE_VERSION_CREATE", resourceType = "CARRIER_FBA_RATE",
description = "创建船司FBA价格版本")
public ApiResponse<CarrierFbaRateEntity> createRate(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId,
@Valid @RequestBody CarrierRateVersionRequest request) {
long scope = FirstlegScopeGuard.platform(user, tenantId);
return ApiResponse.success(service.createRateVersion(scope, user.userId(), request));
}
@DeleteMapping("/carrier-rates/{id}")
@PreAuthorize("hasAuthority('firstleg:fba-pricing:rate:edit')")
@BusinessAudit(event = "FBA_RATE_RETIRE", resourceType = "CARRIER_FBA_RATE", resourceId = "#id",
description = "停用船司FBA价格版本")
public ApiResponse<Void> retireRate(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long tenantId, @PathVariable long id) {
service.retireRate(FirstlegScopeGuard.platform(user, tenantId), id, user.userId());
return ApiResponse.success(null);
}
}
@@ -0,0 +1,90 @@
package com.baiyue.firstleg.controller;
import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.FbaPricingDtos.CalculateRequest;
import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse;
import com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest;
import com.baiyue.firstleg.service.FbaPricingService;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RestController;
/** 平台端入库单 FBA 费用试算、确认和发送接口。 */
@Tag(name = "入库FBA计费")
@RestController
@RequestMapping("/api/platform/v1/inbounds/{inboundId}/fees")
public class PlatformFbaPricingController {
private final FbaPricingService service;
public PlatformFbaPricingController(FbaPricingService service) { this.service = service; }
@PostMapping("/calculate")
@PreAuthorize("hasAuthority('firstleg:fba-pricing:calculate')")
@BusinessAudit(event = "FBA_FEE_CALCULATE", resourceType = "INBOUND", resourceId = "#inboundId",
description = "试算FBA费用")
public ApiResponse<FeeSnapshotResponse> calculate(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId,
@PathVariable long inboundId, @RequestHeader("Idempotency-Key") String idempotencyKey,
@Valid @RequestBody(required = false) CalculateRequest request) {
long scope = FirstlegScopeGuard.platform(user, tenantId);
return ApiResponse.success(service.calculate(scope, user.userId(), inboundId,
request == null ? null : request.pricingAt(), idempotencyKey));
}
@PostMapping("/confirm")
@PreAuthorize("hasAuthority('firstleg:fba-pricing:confirm')")
@BusinessAudit(event = "FBA_FEE_CONFIRM", resourceType = "INBOUND", resourceId = "#inboundId",
description = "确认FBA费用快照")
public ApiResponse<FeeSnapshotResponse> confirm(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId,
@PathVariable long inboundId, @RequestHeader("Idempotency-Key") String idempotencyKey) {
long scope = FirstlegScopeGuard.platform(user, tenantId);
return ApiResponse.success(service.confirm(scope, user.userId(), inboundId, idempotencyKey));
}
@PostMapping("/send")
@PreAuthorize("hasAuthority('firstleg:inbound:send-fee')")
@BusinessAudit(event = "FBA_FEE_SEND", resourceType = "INBOUND", resourceId = "#inboundId",
description = "发送FBA费用快照并进入待托盘状态")
public ApiResponse<FeeSnapshotResponse> send(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId,
@PathVariable long inboundId, @RequestHeader("Idempotency-Key") String idempotencyKey) {
long scope = FirstlegScopeGuard.platform(user, tenantId);
return ApiResponse.success(service.send(scope, user.userId(), inboundId, idempotencyKey));
}
@GetMapping("/latest")
@PreAuthorize("hasAuthority('firstleg:fba-pricing:view')")
public ApiResponse<FeeSnapshotResponse> latest(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId,
@PathVariable long inboundId) {
return ApiResponse.success(service.latest(FirstlegScopeGuard.platform(user, tenantId), inboundId));
}
@PutMapping("/published")
@PreAuthorize("hasAuthority('firstleg:fba-pricing:adjust')")
@BusinessAudit(event = "FBA_FEE_ADJUST", resourceType = "INBOUND", resourceId = "#inboundId",
description = "调整已发布入库费用,不回退入库状态")
public ApiResponse<FeeSnapshotResponse> adjustPublished(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId,
@PathVariable long inboundId, @RequestHeader("Idempotency-Key") String idempotencyKey,
@Valid @RequestBody AdjustFeeRequest request) {
long scope = FirstlegScopeGuard.platform(user, tenantId);
return ApiResponse.success(service.adjustPublished(
scope, user.userId(), inboundId, request, idempotencyKey));
}
}
@@ -0,0 +1,134 @@
package com.baiyue.firstleg.controller;
import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.InboundMeasurementRequests;
import com.baiyue.firstleg.dto.InboundMeasurementResponses;
import com.baiyue.firstleg.service.InboundMeasurementService;
import com.baiyue.firstleg.service.MeasurementSubmissionService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/** 平台后台入库测量录入、提交和 OCR 重量结果维护接口。 */
@Tag(name = "平台入库测量", description = "维护测量草稿、箱规明细和可追溯 OCR 重量结果。")
@RestController
@RequestMapping("/api/platform/v1/first-leg/inbounds/{inboundId}")
public class PlatformInboundMeasurementController {
private final InboundMeasurementService service;
private final MeasurementSubmissionService submission;
public PlatformInboundMeasurementController(InboundMeasurementService service,
MeasurementSubmissionService submission) {
this.service = service;
this.submission = submission;
}
@Operation(summary = "查询入库测量详情", description = "返回原单、实测箱规、审核快照和 OCR 重量结果。")
@GetMapping("/measurement")
@PreAuthorize("hasAuthority('platform:firstleg:inbound:measure:view')")
public ApiResponse<InboundMeasurementResponses.Detail> detail(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long tenantId,
@PathVariable long inboundId) {
requirePlatformTenant(user, tenantId);
return ApiResponse.success(service.detail(tenantId, inboundId));
}
@BusinessAudit(event = "INBOUND_MEASUREMENT_DRAFT_SAVE", resourceType = "INBOUND",
resourceId = "#inboundId", description = "保存入库测量草稿")
@Operation(summary = "保存测量草稿", description = "保存人工测量批次和服务端计算明细,不推进入库状态。")
@PutMapping("/measurement/draft")
@PreAuthorize("hasAuthority('platform:firstleg:inbound:measure:edit')")
public ApiResponse<InboundMeasurementResponses.DraftSaved> saveDraft(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long tenantId,
@PathVariable long inboundId,
@Valid @RequestBody InboundMeasurementRequests.SaveDraftRequest request) {
requirePlatformTenant(user, tenantId);
return ApiResponse.success(service.saveDraft(tenantId, inboundId, user.userId(), request));
}
@BusinessAudit(event = "INBOUND_MEASUREMENT_SUBMIT", resourceType = "INBOUND",
resourceId = "#inboundId", description = "确认测量并提交审核")
@Operation(summary = "确认测量并提交审核",
description = "校验测量图片和全部 OCR 重量后,最多推进到 MEASURE_PENDING。")
@PostMapping("/measurement/submit")
@PreAuthorize("hasAuthority('platform:firstleg:inbound:measure:submit')")
public ApiResponse<InboundMeasurementResponses.MeasurementSubmitted> submit(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long tenantId,
@PathVariable long inboundId,
@Valid @RequestBody InboundMeasurementRequests.SubmitRequest request) {
requirePlatformTenant(user, tenantId);
return ApiResponse.success(submission.submit(tenantId, inboundId, user.userId(), request));
}
@BusinessAudit(event = "INBOUND_WEIGHT_OCR_RECORD", resourceType = "INBOUND",
resourceId = "#inboundId", description = "登记称重图片 OCR 原值")
@Operation(summary = "登记 OCR 重量结果", description = "按图片文件 ID 保存不可覆盖的 OCR 原值。")
@PostMapping("/weight-results")
@PreAuthorize("hasAuthority('platform:firstleg:inbound:weight:edit')")
public ApiResponse<InboundMeasurementResponses.WeightResult> createWeightResult(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long tenantId,
@PathVariable long inboundId,
@Valid @RequestBody InboundMeasurementRequests.CreateWeightResultRequest request) {
requirePlatformTenant(user, tenantId);
return ApiResponse.success(service.createWeightResult(tenantId, inboundId, user.userId(), request));
}
@BusinessAudit(event = "INBOUND_WEIGHT_OCR_CORRECT", resourceType = "INBOUND_WEIGHT_RESULT",
resourceId = "#resultId", description = "人工修正 OCR 重量")
@Operation(summary = "人工修正 OCR 重量", description = "保留 OCR 原值并新增不可变修正版本。")
@PutMapping("/weight-results/{resultId}")
@PreAuthorize("hasAuthority('platform:firstleg:inbound:weight:edit')")
public ApiResponse<InboundMeasurementResponses.WeightResult> correctWeightResult(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long tenantId,
@PathVariable long inboundId,
@PathVariable long resultId,
@Valid @RequestBody InboundMeasurementRequests.CorrectWeightResultRequest request) {
requirePlatformTenant(user, tenantId);
return ApiResponse.success(service.correctWeightResult(
tenantId, inboundId, resultId, user.userId(), user.username(), request));
}
@BusinessAudit(event = "INBOUND_WEIGHT_OCR_CONFIRM", resourceType = "INBOUND_WEIGHT_RESULT",
resourceId = "#resultId", description = "确认 OCR 重量并累加实际总毛重")
@Operation(summary = "确认 OCR 重量", description = "统一换算为 KG 并重算所有已确认图片的总重量。")
@PostMapping("/weight-results/{resultId}/confirm")
@PreAuthorize("hasAuthority('platform:firstleg:inbound:weight:confirm')")
public ApiResponse<InboundMeasurementResponses.WeightResult> confirmWeightResult(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long tenantId,
@PathVariable long inboundId,
@PathVariable long resultId,
@Valid @RequestBody InboundMeasurementRequests.ConfirmWeightResultRequest request) {
requirePlatformTenant(user, tenantId);
return ApiResponse.success(service.confirmWeightResult(
tenantId, inboundId, resultId, user.userId(), user.username(), request));
}
private static void requirePlatformTenant(AuthenticatedUser user, long tenantId) {
if (user == null || !"PLATFORM".equals(user.appCode())) {
throw new BizException(ResultCode.FORBIDDEN, "仅允许平台端访问");
}
if (tenantId <= 0 || user.userId() == null || user.userId() <= 0) {
throw new BizException(ResultCode.BAD_REQUEST, "必须指定有效目标租户和操作人");
}
}
}
@@ -0,0 +1,122 @@
package com.baiyue.firstleg.controller;
import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.ExceptionReleaseRequest;
import com.baiyue.firstleg.dto.MeasurementReviewRequest;
import com.baiyue.firstleg.entity.MeasureReviewRecordEntity;
import com.baiyue.firstleg.service.MeasurementReviewService;
import com.baiyue.firstleg.service.AutomaticInboundCompletionService;
import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/** 平台端入库测量审核、异常放行和审核历史接口。 */
@Tag(name = "平台入库测量审核")
@RestController
@RequestMapping("/api/platform/v1/inbounds")
public class PlatformInboundMeasurementReviewController {
private final MeasurementReviewService service;
private final AutomaticInboundCompletionService automaticCompletion;
public PlatformInboundMeasurementReviewController(MeasurementReviewService service,
AutomaticInboundCompletionService automaticCompletion) {
this.service = service;
this.automaticCompletion = automaticCompletion;
}
@BusinessAudit(event = "INBOUND_MEASURE_APPROVE", resourceType = "INBOUND",
resourceId = "#inboundId", description = "测量审核通过")
@Operation(summary = "测量审核通过",
description = "客服审核通过后自动计费、发布费用给客户并完成入库;客户无需确认费用。")
@PostMapping("/{inboundId}/measurements/approve")
@PreAuthorize("hasAuthority('firstleg:inbound:measurement-review')")
public ApiResponse<FeeSnapshotResponse> approve(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long tenantId,
@PathVariable long inboundId,
@Parameter(required = true, description = "同键同请求重试返回首次成功结果")
@RequestHeader("Idempotency-Key") String idempotencyKey,
@Valid @RequestBody MeasurementReviewRequest request) {
validateScope(user, tenantId);
service.approve(tenantId, user.userId(), user.username(), inboundId, idempotencyKey, request);
return ApiResponse.success(automaticCompletion.completeApproved(
tenantId, inboundId, user.userId(), user.username(), request.measurementId(),
request.expectedVersion() + 1));
}
@BusinessAudit(event = "INBOUND_MEASURE_REJECT", resourceType = "INBOUND",
resourceId = "#inboundId", description = "测量审核驳回")
@Operation(summary = "测量审核驳回",
description = "驳回原因必填,只退回测量数据修改;已确认的托盘和库位占用保持不动。")
@PostMapping("/{inboundId}/measurements/reject")
@PreAuthorize("hasAuthority('firstleg:inbound:measurement-review')")
public ApiResponse<Void> reject(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long tenantId,
@PathVariable long inboundId,
@RequestHeader("Idempotency-Key") String idempotencyKey,
@Valid @RequestBody MeasurementReviewRequest request) {
validateScope(user, tenantId);
service.reject(tenantId, user.userId(), user.username(), inboundId, idempotencyKey, request);
return ApiResponse.success(null);
}
@BusinessAudit(event = "INBOUND_MEASURE_EXCEPTION_RELEASE", resourceType = "INBOUND",
resourceId = "#inboundId", description = "测量驳回异常放行")
@Operation(summary = "测量驳回异常放行",
description = "Nacos 开关开启、有异常放行权限且原因非空时,才可从 MEASURE_REJECTED 推进至 WAIT_PRICING。")
@PostMapping("/{inboundId}/measurements/exception-release")
@PreAuthorize("hasAuthority('firstleg:inbound:exception-release')")
public ApiResponse<Void> exceptionRelease(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long tenantId,
@PathVariable long inboundId,
@RequestHeader("Idempotency-Key") String idempotencyKey,
@Valid @RequestBody ExceptionReleaseRequest request) {
validateScope(user, tenantId);
service.exceptionRelease(
tenantId, user.userId(), user.username(), inboundId, idempotencyKey, request);
return ApiResponse.success(null);
}
@Operation(summary = "查询测量审核历史")
@GetMapping("/{inboundId}/measurement-review-history")
@PreAuthorize("hasAuthority('firstleg:inbound:measurement-review-history')")
public ApiResponse<PageResult<MeasureReviewRecordEntity>> history(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long tenantId,
@PathVariable long inboundId,
@RequestParam(defaultValue = "1") @Min(1) long page,
@RequestParam(defaultValue = "20") @Min(1) @Max(200) long size) {
validateScope(user, tenantId);
return ApiResponse.success(service.history(tenantId, inboundId, page, size));
}
private static void validateScope(AuthenticatedUser user, long tenantId) {
if (user == null || !"PLATFORM".equals(user.appCode())) {
throw new BizException(ResultCode.FORBIDDEN, "仅允许平台端访问");
}
if (tenantId <= 0) {
throw new BizException(ResultCode.BAD_REQUEST, "必须指定有效目标租户");
}
}
}
@@ -0,0 +1,67 @@
package com.baiyue.firstleg.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.InboundOrderDetail;
import com.baiyue.firstleg.dto.InboundOrderQuery;
import com.baiyue.firstleg.dto.InboundOrderSummary;
import com.baiyue.firstleg.service.InboundOrderQueryService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/** 平台端入库单列表和详情只读接口。 */
@Tag(name = "平台入库单", description = "二期入库单列表、页签、筛选和只读聚合详情")
@RestController
@RequestMapping("/api/platform/inbounds")
public class PlatformInboundOrderController {
private final InboundOrderQueryService service;
public PlatformInboundOrderController(InboundOrderQueryService service) {
this.service = service;
}
@Operation(summary = "分页查询入库单", description = "按目标租户和二期状态页签分页查询入库单快照。")
@GetMapping
@PreAuthorize("hasAuthority('platform:firstleg:inbound:list')")
public ApiResponse<PageResult<InboundOrderSummary>> page(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long tenantId,
@Valid InboundOrderQuery query) {
requirePlatformTenant(user, tenantId);
return ApiResponse.success(service.page(tenantId, query));
}
@Operation(summary = "查询入库单详情", description = "查询入库单、运输、托盘库位、文件、备注和状态历史快照。")
@GetMapping("/{inboundId}")
@PreAuthorize("hasAuthority('platform:firstleg:inbound:view')")
public ApiResponse<InboundOrderDetail> detail(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long tenantId,
@PathVariable long inboundId) {
requirePlatformTenant(user, tenantId);
if (inboundId <= 0) {
throw new BizException(ResultCode.BAD_REQUEST, "入库单ID必须大于零");
}
return ApiResponse.success(service.detail(tenantId, inboundId));
}
private static void requirePlatformTenant(AuthenticatedUser user, long tenantId) {
if (user == null || !"PLATFORM".equals(user.appCode())) {
throw new BizException(ResultCode.FORBIDDEN, "仅允许平台端访问");
}
if (tenantId <= 0) {
throw new BizException(ResultCode.BAD_REQUEST, "必须指定有效目标租户");
}
}
}
@@ -0,0 +1,21 @@
package com.baiyue.firstleg.domain;
import java.util.List;
/** 入库列表页签及其流程状态映射。 */
public enum InboundListTab {
WAIT_MEASURE(List.of("WAIT_MEASURE", "MEASURING", "MEASURE_REJECTED")),
WAIT_REVIEW(List.of("MEASURE_PENDING")),
WAIT_FEE_SEND(List.of("WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING")),
INBOUNDED(List.of("INBOUNDED"));
private final List<String> processStatuses;
InboundListTab(List<String> processStatuses) {
this.processStatuses = processStatuses;
}
public List<String> processStatuses() {
return processStatuses;
}
}
@@ -0,0 +1,14 @@
package com.baiyue.firstleg.domain;
import java.util.Set;
/** 二期入库主流程状态,不包含原型中的“待客户确认”。 */
public final class InboundProcessStatus {
public static final Set<String> VALUES = Set.of(
"WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED",
"WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING", "INBOUNDED", "VOIDED"
);
private InboundProcessStatus() {
}
}
@@ -0,0 +1,15 @@
package com.baiyue.firstleg.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.PositiveOrZero;
import jakarta.validation.constraints.Size;
/** 测量驳回后的异常放行请求。 */
public record ExceptionReleaseRequest(
@NotNull @Positive Long measurementId,
@NotNull @PositiveOrZero Integer expectedVersion,
@NotBlank @Size(max = 1000) String reason
) {
}
@@ -0,0 +1,77 @@
package com.baiyue.firstleg.dto;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/** 二期 FBA 计费请求与只读快照响应。 */
public final class FbaPricingDtos {
private FbaPricingDtos() {}
public record CustomerRuleVersionRequest(
@NotBlank @Size(max = 50) String ruleCode,
@NotNull @Min(1) Long customerId,
@NotBlank @Size(max = 128) String customerName,
@NotBlank @Size(max = 32) String businessType,
@NotBlank @Pattern(regexp = "KG|CBM") String chargeMode,
@NotBlank @Pattern(regexp = "TAX_INCLUDED|TAX_EXCLUDED") String taxMode,
@NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal conversionFactor,
@NotNull @Min(0) @Max(4) Integer roundingScale,
@NotBlank @Pattern(regexp = "UP|DOWN|CEILING|FLOOR|HALF_UP|HALF_DOWN|HALF_EVEN") String roundingMode,
@NotBlank @Pattern(regexp = "[A-Z]{3}") String currency,
@NotNull LocalDateTime effectiveFrom,
LocalDateTime effectiveTo,
@Size(max = 500) String remark
) {}
public record CarrierRateVersionRequest(
@NotBlank @Size(max = 50) String rateCode,
@NotNull @Min(1) Long originWarehouseId,
@NotBlank @Size(max = 128) String originWarehouseName,
@NotBlank @Size(max = 32) String businessType,
@NotBlank @Size(max = 64) String channelCode,
@NotNull @Min(1) Long carrierId,
@NotBlank @Size(max = 128) String carrierName,
@NotBlank @Size(max = 32) String destinationCode,
@NotBlank @Pattern(regexp = "KG|CBM") String chargeUnit,
@NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal unitPrice,
@NotBlank @Pattern(regexp = "[A-Z]{3}") String currency,
@NotNull @DecimalMin("0") BigDecimal additionalFee,
@NotNull LocalDateTime effectiveFrom,
LocalDateTime effectiveTo,
@Size(max = 500) String remark
) {}
public record CalculateRequest(LocalDateTime pricingAt) {}
public record AdjustFeeRequest(
@NotNull @DecimalMin("0.00") BigDecimal subtotalAmount,
@NotNull @DecimalMin("0.00") BigDecimal additionalAmount,
@NotBlank @Size(max = 500) String reason
) {}
public record FeeSnapshotResponse(
Long feeId, String feeNo, Integer feeVersion, String feeStatus,
Long inboundId, String inboundCode,
Long chargeRuleId, String ruleCode, Integer chargeRuleVersion,
Long carrierRateId, String rateCode, Integer carrierRateVersion,
Long customerId, String customerName, Long originWarehouseId, String originWarehouseName,
String businessType, String channelCode, Long carrierId, String carrierName,
String destinationCode, String taxMode, String chargeMode,
BigDecimal conversionFactor, Integer roundingScale, String roundingMode,
BigDecimal actualWeightKg, BigDecimal actualVolumeCbm,
BigDecimal convertedQuantity, BigDecimal chargeableQuantity, String chargeUnit,
BigDecimal unitPrice, BigDecimal subtotalAmount, BigDecimal additionalAmount,
BigDecimal totalAmount, String currency,
LocalDateTime rateEffectiveFrom, LocalDateTime rateEffectiveTo,
LocalDateTime calculatedAt, LocalDateTime confirmedAt, LocalDateTime sentAt,
String nextProcessStatus
) {}
}
@@ -0,0 +1,82 @@
package com.baiyue.firstleg.dto;
import jakarta.validation.Valid;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.DecimalMax;
import jakarta.validation.constraints.Digits;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.PositiveOrZero;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
import java.util.List;
/**
* 平台端入库测量写请求。计算字段只由服务端生成,避免前端篡改汇总结果。
*/
public final class InboundMeasurementRequests {
private InboundMeasurementRequests() {
}
public record SaveDraftRequest(
Long measurementId,
@NotNull @PositiveOrZero Integer version,
@Size(max = 64) String originalDocumentNo,
@DecimalMin("0.000") @Digits(integer = 15, fraction = 3) BigDecimal originalGrossWeightKg,
@DecimalMin("0.0000") @Digits(integer = 14, fraction = 4) BigDecimal originalVolumeCbm,
@Size(max = 255) String actualGoodsName,
@NotEmpty @Size(max = 500) List<@Valid ItemRequest> items
) {
}
public record ItemRequest(
@NotBlank @Size(max = 32) String itemNo,
@NotBlank @Size(max = 64) String boxSpecification,
@NotNull @Positive @Max(1000000) Integer packageCount,
@NotNull @DecimalMin("0.001") @Digits(integer = 9, fraction = 3) BigDecimal lengthCm,
@NotNull @DecimalMin("0.001") @Digits(integer = 9, fraction = 3) BigDecimal widthCm,
@NotNull @DecimalMin("0.001") @Digits(integer = 9, fraction = 3) BigDecimal heightCm,
@NotNull @DecimalMin("0.001") @Digits(integer = 15, fraction = 3) BigDecimal unitWeightKg,
@Positive Long intelligentImageFileId,
@Size(max = 500) String overLimitWarning,
@DecimalMin("0.000") @Digits(integer = 9, fraction = 3) BigDecimal outerLengthCm,
@DecimalMin("0.000") @Digits(integer = 9, fraction = 3) BigDecimal outerWidthCm,
@DecimalMin("0.000") @Digits(integer = 9, fraction = 3) BigDecimal outerHeightCm,
@Size(max = 500) String remark
) {
}
public record SubmitRequest(
@NotNull @Positive Long measurementId,
@NotNull @PositiveOrZero Integer version
) {
}
public record CreateWeightResultRequest(
@NotNull @Positive Long fileId,
@Size(max = 64) String ocrProvider,
@Size(max = 1000) String ocrRawText,
@DecimalMin("0.000") @Digits(integer = 15, fraction = 3) BigDecimal ocrWeightValue,
@Size(max = 8) String ocrWeightUnit,
@DecimalMin("0.00000") @DecimalMax("1.00000")
@Digits(integer = 1, fraction = 5) BigDecimal confidence
) {
}
public record CorrectWeightResultRequest(
@NotNull @PositiveOrZero Integer version,
@NotNull @DecimalMin("0.001") @Digits(integer = 15, fraction = 3) BigDecimal adjustedWeightValue,
@NotBlank @Size(max = 8) String adjustedWeightUnit,
@NotBlank @Size(max = 500) String adjustmentReason
) {
}
public record ConfirmWeightResultRequest(
@NotNull @PositiveOrZero Integer version
) {
}
}
@@ -0,0 +1,101 @@
package com.baiyue.firstleg.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
/**
* 入库测量详情响应,覆盖原单、实测明细、审核快照和 OCR 重量维护字段。
*/
public final class InboundMeasurementResponses {
private InboundMeasurementResponses() {
}
public record Detail(
Long inboundId,
String inboundCode,
String companyName,
String channelCode,
String channelName,
String processStatus,
String measureReviewStatus,
@Schema(description = "测量完成后的推荐托盘类型,仅为建议,不代表已分配托盘",
allowableValues = "STANDARD", example = "STANDARD")
String recommendedPalletType,
String reviewReason,
Long reviewerId,
String reviewerName,
Long measurementId,
String sourceType,
String measurementStatus,
Integer version,
String originalDocumentNo,
BigDecimal originalGrossWeightKg,
BigDecimal originalVolumeCbm,
Integer actualPackageCount,
BigDecimal actualGrossWeightKg,
BigDecimal actualVolumeCbm,
BigDecimal volumeWeightKg,
String actualGoodsName,
List<Item> items,
List<WeightResult> weightResults
) {
}
public record Item(
Long id,
String itemNo,
String boxSpecification,
Integer packageCount,
BigDecimal lengthCm,
BigDecimal widthCm,
BigDecimal heightCm,
BigDecimal girthCm,
BigDecimal unitWeightKg,
BigDecimal unitVolumeCbm,
BigDecimal totalWeightKg,
BigDecimal totalVolumeCbm,
BigDecimal volumeWeightKg,
Long intelligentImageFileId,
String overLimitWarning,
BigDecimal outerLengthCm,
BigDecimal outerWidthCm,
BigDecimal outerHeightCm,
String remark
) {
}
public record WeightResult(
Long id,
Long fileId,
String ocrProvider,
String ocrRawText,
BigDecimal ocrWeightValue,
String ocrWeightUnit,
BigDecimal confidence,
BigDecimal adjustedWeightValue,
String adjustedWeightUnit,
BigDecimal finalWeightKg,
String recognitionStatus,
String adjustmentReason,
Long adjustedBy,
LocalDateTime adjustedAt,
Long confirmedBy,
LocalDateTime confirmedAt,
Integer version
) {
}
public record DraftSaved(Long measurementId, Integer version, String status) {
}
public record MeasurementSubmitted(Long measurementId, Integer version, String processStatus,
String measureReviewStatus,
@Schema(description = "测量完成后的推荐托盘类型,仅为建议,不代表已分配托盘",
allowableValues = "STANDARD", example = "STANDARD")
String recommendedPalletType,
Long feeId, String feeNo, String feeStatus) {
}
}
@@ -0,0 +1,35 @@
package com.baiyue.firstleg.dto;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
/** 入库单只读聚合详情。 */
public record InboundOrderDetail(
InboundOrderSummary summary,
String publicRemark,
List<PalletLocationSummary> palletLocations,
List<FileSummary> files,
List<InternalNoteSummary> internalNotes,
List<StatusHistorySummary> statusHistory
) {
public record PalletLocationSummary(
Integer allocationNo, Long warehouseId, Long palletId, String palletCode,
BigDecimal palletCapacityCbm, BigDecimal allocatedVolumeCbm, Integer allocatedPackageCount,
Long locationId, String locationCode, String reservationNo, String allocationStatus) {
}
public record FileSummary(
Long fileId, String fileType, String fileNameSnapshot, String visibility,
Integer fileVersion, Long uploadedBy, LocalDateTime uploadedAt) {
}
public record InternalNoteSummary(
Long id, String content, Long createdBy, String createdByName, LocalDateTime createdAt) {
}
public record StatusHistorySummary(
String fromStatus, String toStatus, String action, String reason,
Long operatorId, String operatorName, LocalDateTime occurredAt) {
}
}
@@ -0,0 +1,42 @@
package com.baiyue.firstleg.dto;
import com.baiyue.firstleg.domain.InboundListTab;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Size;
import java.time.LocalDateTime;
/** 入库单分页筛选条件。 */
@Schema(name = "InboundOrderQuery", description = "入库单列表筛选和页签条件")
public record InboundOrderQuery(
@Size(max = 40) String inboundCode,
@Size(max = 32) String orderNo,
@Size(max = 24) String processStatus,
@Size(max = 24) String orderStatus,
@Size(max = 24) String measureReviewStatus,
@Size(max = 24) String feeStatus,
@Size(max = 24) String feeReviewStatus,
InboundListTab tab,
LocalDateTime expectedArrivalFrom,
LocalDateTime expectedArrivalTo,
Long customerId,
@Size(max = 128) String customerName,
Long companyId,
Long warehouseId,
Long deliveryWarehouseId,
@Size(max = 32) String destinationCode,
Boolean holdFlag,
@Min(1) long page,
@Min(1) @Max(200) long size
) {
public InboundOrderQuery {
if (page == 0) {
page = 1;
}
if (size == 0) {
size = 20;
}
}
}
@@ -0,0 +1,62 @@
package com.baiyue.firstleg.dto;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.entity.InboundTransportSnapshotEntity;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/** 入库列表行,所有名称和运输信息均为业务快照。 */
public record InboundOrderSummary(
Long id, Long orderId, String orderNo, String inboundCode,
String processStatus, String orderStatusSnapshot, String measureReviewStatus,
String feeStatus, String feeReviewStatus, String notificationStatus,
Integer version,
LocalDateTime expectedArrivalAt,
Long customerId, String customerName, Long companyId, String companyName,
Long warehouseId, String warehouseName,
Long deliveryWarehouseId, String deliveryWarehouseName,
String businessType, String channelCode, String channelName,
String taxMode, String containerMode, Boolean customsInspection,
String destinationType, Long destinationWarehouseId, String destinationCode, String destinationName,
Boolean holdFlag, String currentHoldReason,
Integer totalPackageCount, BigDecimal actualGrossWeightKg, BigDecimal actualNetWeightKg,
BigDecimal actualVolumeCbm, BigDecimal volumeWeightKg,
BigDecimal chargeableWeightKg, BigDecimal chargeableVolumeCbm, Integer palletCount,
String locationCodes,
LocalDateTime measureCompletedAt, LocalDateTime inboundedAt,
String expressCompanyCode, String expressCompanyName, String expressTrackingNo,
String loadingStatus, String containerNo, String cabinetNo,
String vesselName, String voyageNo, LocalDateTime sailingAt, LocalDateTime portArrivalAt,
LocalDateTime createdAt, LocalDateTime updatedAt
) {
public static InboundOrderSummary from(
InboundOrderEntity order, InboundTransportSnapshotEntity transport, String locationCodes) {
return new InboundOrderSummary(
order.getId(), order.getOrderId(), order.getOrderNo(), order.getInboundCode(),
order.getProcessStatus(), order.getOrderStatusSnapshot(), order.getMeasureReviewStatus(),
order.getFeeStatus(), order.getFeeReviewStatus(), order.getNotificationStatus(),
order.getVersion(),
order.getExpectedArrivalAt(), order.getCustomerId(), order.getCustomerName(),
order.getCompanyId(), order.getCompanyName(), order.getWarehouseId(), order.getWarehouseName(),
order.getDeliveryWarehouseId(), order.getDeliveryWarehouseName(), order.getBusinessType(),
order.getChannelCode(), order.getChannelName(), order.getTaxMode(), order.getContainerMode(),
order.getCustomsInspection(), order.getDestinationType(), order.getDestinationWarehouseId(),
order.getDestinationCode(), order.getDestinationName(), order.getHoldFlag(),
order.getCurrentHoldReason(), order.getTotalPackageCount(), order.getActualGrossWeightKg(),
order.getActualNetWeightKg(), order.getActualVolumeCbm(), order.getVolumeWeightKg(),
order.getChargeableWeightKg(), order.getChargeableVolumeCbm(), order.getPalletCount(),
locationCodes,
order.getMeasureCompletedAt(), order.getInboundedAt(), order.getExpressCompanyCode(),
order.getExpressCompanyName(), order.getExpressTrackingNo(),
transport == null ? null : transport.getLoadingStatus(),
transport == null ? null : transport.getContainerNo(),
transport == null ? null : transport.getCabinetNo(),
transport == null ? null : transport.getVesselName(),
transport == null ? null : transport.getVoyageNo(),
transport == null ? null : transport.getSailingAt(),
transport == null ? null : transport.getPortArrivalAt(),
order.getCreatedAt(), order.getUpdatedAt()
);
}
}
@@ -0,0 +1,46 @@
package com.baiyue.firstleg.dto;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
/**
* 测量领域统一写命令。后台 DTO 与第三方开放 API DTO 都应先适配为该命令。
* 该命令的持久化过程不改变入库单流程状态。
*/
public record MeasurementBatchCommand(
long tenantId,
long inboundId,
Long measurementId,
int expectedVersion,
String sourceType,
String sourceCode,
String externalEventNo,
String deviceCode,
String warehouseCode,
LocalDateTime measuredAt,
String rawPayloadJson,
String originalDocumentNo,
BigDecimal originalGrossWeightKg,
BigDecimal originalVolumeCbm,
String actualGoodsName,
long operatorId,
List<Item> items
) {
public record Item(
String itemNo,
String boxSpecification,
int packageCount,
BigDecimal lengthCm,
BigDecimal widthCm,
BigDecimal heightCm,
BigDecimal unitWeightKg,
Long intelligentImageFileId,
String overLimitWarning,
BigDecimal outerLengthCm,
BigDecimal outerWidthCm,
BigDecimal outerHeightCm,
String remark
) {
}
}
@@ -0,0 +1,14 @@
package com.baiyue.firstleg.dto;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.PositiveOrZero;
import jakarta.validation.constraints.Size;
/** 测量审核请求,expectedVersion 用于拒绝过期页面提交。 */
public record MeasurementReviewRequest(
@NotNull @Positive Long measurementId,
@NotNull @PositiveOrZero Integer expectedVersion,
@Size(max = 1000) String reason
) {
}
@@ -0,0 +1,48 @@
package com.baiyue.firstleg.dto;
import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse;
import java.math.BigDecimal;
import java.util.List;
public record MerchantInboundBillingResponse(InboundOrderDetail inbound,
MeasurementSummary measurementSummary,
List<MeasurementItem> measurementItems,
List<WeightResult> weightResults,
List<ImageReference> measurementImages,
List<ImageReference> weightImages,
BillingBasis billingBasis,
FeeSnapshotResponse latestFee) {
public record MeasurementSummary(
Long measurementId, String sourceType, String measurementStatus,
Integer actualPackageCount, BigDecimal actualGrossWeightKg,
BigDecimal actualVolumeCbm, BigDecimal volumeWeightKg, String actualGoodsName) {
}
public record ImageReference(
Long fileId, String imageType, Long measurementItemId,
Long weightResultId, String previewUrl) {
}
public record MeasurementItem(
Long id, String itemNo, String boxSpecification, Integer packageCount,
BigDecimal lengthCm, BigDecimal widthCm, BigDecimal heightCm,
BigDecimal unitWeightKg, BigDecimal totalWeightKg,
BigDecimal unitVolumeCbm, BigDecimal totalVolumeCbm,
BigDecimal volumeWeightKg) {
}
public record WeightResult(
Long id, Long fileId, BigDecimal recognizedWeightValue, String recognizedWeightUnit,
BigDecimal adjustedWeightValue, String adjustedWeightUnit,
BigDecimal finalWeightKg, String recognitionStatus) {
}
public record BillingBasis(
String chargeMode, String chargeUnit, String chargeableBasis,
BigDecimal actualWeightKg, BigDecimal actualVolumeCbm,
BigDecimal convertedQuantity, BigDecimal chargeableQuantity,
BigDecimal chargeableWeightKg, BigDecimal chargeableVolumeCbm) {
}
}
@@ -0,0 +1,71 @@
package com.baiyue.firstleg.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.DecimalMax;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
/** 第三方长宽高测量开放接口 DTO。 */
public final class OpenApiMeasurementDtos {
private OpenApiMeasurementDtos() {
}
@Schema(name = "InboundMeasurementCallbackRequest", description = "按入仓码和订单货物明细提交一条箱规测量结果")
public record CallbackRequest(
@NotBlank @Size(max = 128)
@Pattern(regexp = "[A-Za-z0-9._:-]+")
@Schema(description = "第三方业务事件号,同一 appKey 下唯一", example = "M202608010001")
String eventNo,
@NotBlank @Size(max = 128)
@Pattern(regexp = "[A-Za-z0-9._:-]+")
@Schema(description = "第三方测量明细唯一号,用于防止同一物理明细重复绑定", example = "BOX-20260801-0001")
String externalDetailNo,
@NotBlank @Size(max = 50)
@Schema(description = "已绑定到应用和租户的仓库编码", example = "CN-YW-01")
String warehouseCode,
@NotBlank @Size(max = 40)
@Schema(description = "入仓码,不接受内部数据库主键", example = "RK202608010001")
String inboundCode,
@NotNull @Min(1)
@Schema(description = "由柏悦订单明细接口提供的货物明细ID", example = "190000000000000001")
Long inboundDetailId,
@NotBlank @Size(max = 64)
@Schema(description = "测量设备编码", example = "DEVICE-01")
String deviceCode,
@NotNull
@Schema(description = "设备测量时间,必须包含时区", example = "2026-08-01T10:30:00+08:00")
OffsetDateTime measuredAt,
@NotNull @DecimalMin(value = "0", inclusive = false) @DecimalMax("999999999.999") BigDecimal lengthCm,
@NotNull @DecimalMin(value = "0", inclusive = false) @DecimalMax("999999999.999") BigDecimal widthCm,
@NotNull @DecimalMin(value = "0", inclusive = false) @DecimalMax("999999999.999") BigDecimal heightCm,
@NotNull @Min(1) @Max(1000000) Integer packageCount,
@DecimalMin(value = "0", inclusive = false) @DecimalMax("999999999.999") BigDecimal unitWeightKg,
@NotBlank @Size(max = 64) String boxSpecification,
@Size(max = 500) String remark) {
}
@Schema(name = "InboundMeasurementEventResponse", description = "第三方测量事件处理结果")
public record EventResponse(
String eventNo,
String inboundCode,
Long inboundDetailId,
Long measurementId,
Long measurementItemId,
Long imageFileId,
boolean accepted,
String status,
OffsetDateTime receivedAt) {
public EventResponse(String eventNo, String inboundCode, boolean accepted,
String status, OffsetDateTime receivedAt) {
this(eventNo, inboundCode, null, null, null, null, accepted, status, receivedAt);
}
}
}
@@ -0,0 +1,33 @@
package com.baiyue.firstleg.dto;
import java.time.LocalDateTime;
/** 订单审核通过后创建入库单的内部服务契约。 */
public record OrderApprovedInboundCommand(
long tenantId,
long orderId,
String orderNo,
Long customerId,
String customerName,
Long warehouseId,
String warehouseName,
Long deliveryWarehouseId,
String deliveryWarehouseName,
LocalDateTime expectedArrivalAt,
String businessType,
String channelCode,
String channelName,
Long carrierId,
String carrierName,
String taxMode,
String containerMode,
Boolean customsInspection,
String destinationType,
Long destinationWarehouseId,
String destinationCode,
String destinationName,
String publicRemark,
String expressCompanyName,
String expressTrackingNo
) {
}
@@ -0,0 +1,38 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baiyue.common.core.model.BaseEntity;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/** 船司 FBA 目的仓价格的不可变版本。 */
@Getter
@Setter
@TableName("fl_carrier_fba_rate")
public class CarrierFbaRateEntity extends BaseEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private String rateCode;
private Integer rateVersion;
private Long originWarehouseId;
private String originWarehouseName;
private String businessType;
private String channelCode;
private Long carrierId;
private String carrierName;
private String destinationCode;
private String chargeUnit;
private BigDecimal unitPrice;
private String currency;
private BigDecimal additionalFee;
private LocalDateTime effectiveFrom;
private LocalDateTime effectiveTo;
private String status;
private String remark;
}
@@ -0,0 +1,36 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baiyue.common.core.model.BaseEntity;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/** 客户 FBA 计费规则的不可变版本。 */
@Getter
@Setter
@TableName("fl_customer_charge_rule")
public class CustomerChargeRuleEntity extends BaseEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private String ruleCode;
private Integer ruleVersion;
private Long customerId;
private String customerName;
private String businessType;
private String chargeMode;
private String taxMode;
private BigDecimal conversionFactor;
private Integer roundingScale;
private String roundingMode;
private String currency;
private LocalDateTime effectiveFrom;
private LocalDateTime effectiveTo;
private String status;
private String remark;
}
@@ -0,0 +1,67 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/** 已保存全部计算输入的入库费用快照。 */
@Getter
@Setter
@TableName("fl_inbound_fee")
public class InboundFeeEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private Long inboundId;
private String feeNo;
private Integer feeVersion;
private Long chargeRuleId;
private Integer chargeRuleVersion;
private Long carrierRateId;
private Integer carrierRateVersion;
private String ruleCode;
private Long customerId;
private String customerName;
private String businessType;
private String taxMode;
private Integer roundingScale;
private String roundingMode;
private LocalDateTime ruleEffectiveFrom;
private LocalDateTime ruleEffectiveTo;
private String rateCode;
private Long originWarehouseId;
private String originWarehouseName;
private String channelCode;
private Long carrierId;
private String carrierName;
private String destinationCode;
private LocalDateTime rateEffectiveFrom;
private LocalDateTime rateEffectiveTo;
private String chargeMode;
private BigDecimal conversionFactor;
private BigDecimal actualWeightKg;
private BigDecimal actualVolumeCbm;
private BigDecimal convertedQuantity;
private BigDecimal chargeableQuantity;
private String chargeUnit;
private String currency;
private BigDecimal subtotalAmount;
private BigDecimal additionalAmount;
private BigDecimal totalAmount;
private String feeStatus;
private String reviewStatus;
private Boolean exceptionRelease;
private Long calculatedBy;
private LocalDateTime calculatedAt;
private Long confirmedBy;
private LocalDateTime confirmedAt;
private Long sentBy;
private LocalDateTime sentAt;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,32 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/** 费用快照明细。 */
@Getter
@Setter
@TableName("fl_inbound_fee_item")
public class InboundFeeItemEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private Long feeId;
private Integer itemNo;
private String feeCode;
private String feeName;
private BigDecimal quantity;
private String unit;
private BigDecimal unitPrice;
private BigDecimal amount;
private String currency;
private Boolean automaticFlag;
private String remark;
private LocalDateTime createdAt;
}
@@ -0,0 +1,30 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
/** 入库单与文件中心对象的分类关系。 */
@Getter
@Setter
@TableName("fl_inbound_file")
public class InboundFileEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private Long inboundId;
private Long fileId;
private String fileType;
private String fileNameSnapshot;
private String visibility;
private Integer fileVersion;
private Long uploadedBy;
private LocalDateTime uploadedAt;
@TableLogic
private Boolean deleted;
}
@@ -0,0 +1,29 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
/** 入库写操作幂等记录。 */
@Getter
@Setter
@TableName("fl_inbound_idempotency")
public class InboundIdempotencyEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private String operation;
private Long operatorId;
private Long inboundId;
private String idempotencyKey;
private String requestHash;
private String status;
private String resultJson;
private LocalDateTime completedAt;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,24 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
/** 客服内部备注历史,仅平台详情可见。 */
@Getter
@Setter
@TableName("fl_inbound_internal_note")
public class InboundInternalNoteEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private Long inboundId;
private String content;
private Long createdBy;
private String createdByName;
private LocalDateTime createdAt;
}
@@ -0,0 +1,44 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.annotation.Version;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/** 后台和第三方共同使用的测量批次。 */
@Getter
@Setter
@TableName("fl_inbound_measurement")
public class InboundMeasurementEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private Long inboundId;
private String sourceType;
private String sourceCode;
private String externalEventNo;
private String externalRequestHash;
private String deviceCode;
private String warehouseCode;
private LocalDateTime measuredAt;
private String status;
private String rawPayloadJson;
private String originalDocumentNo;
private BigDecimal originalGrossWeightKg;
private BigDecimal originalVolumeCbm;
private String actualGoodsName;
private Integer totalPackageCount;
private BigDecimal totalVolumeRawCbm;
private BigDecimal totalVolumeCbm;
private BigDecimal volumeWeightKg;
private Long createdBy;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
@Version
private Integer version;
}
@@ -0,0 +1,45 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/** 测量批次箱规明细,计算字段由服务端生成。 */
@Getter
@Setter
@TableName("fl_inbound_measurement_item")
public class InboundMeasurementItemEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private Long measurementId;
private Long inboundDetailId;
private String externalDetailNo;
private String itemNo;
private String boxSpecification;
private Integer packageCount;
private BigDecimal lengthCm;
private BigDecimal widthCm;
private BigDecimal heightCm;
private BigDecimal girthCm;
private BigDecimal unitWeightKg;
private BigDecimal unitVolumeCbm;
private BigDecimal volumeRawCbm;
private BigDecimal volumeCbm;
private BigDecimal totalWeightKg;
private BigDecimal totalVolumeCbm;
private BigDecimal volumeWeightKg;
private String calculationRuleVersion;
private Long sourceImageFileId;
private String overLimitWarning;
private BigDecimal outerLengthCm;
private BigDecimal outerWidthCm;
private BigDecimal outerHeightCm;
private String remark;
private LocalDateTime createdAt;
}
@@ -0,0 +1,69 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baiyue.common.core.model.BaseEntity;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/** 二期入库单聚合根及列表查询快照。 */
@Getter
@Setter
@TableName("fl_inbound_order")
public class InboundOrderEntity extends BaseEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private Long taskId;
private Long orderId;
private String orderNo;
private String inboundCode;
private String processStatus;
private String orderStatusSnapshot;
private String measureReviewStatus;
private String feeStatus;
private String feeReviewStatus;
private String notificationStatus;
private LocalDateTime expectedArrivalAt;
private Long customerId;
private String customerName;
private Long companyId;
private String companyName;
private Long warehouseId;
private String warehouseName;
private Long deliveryWarehouseId;
private String deliveryWarehouseName;
private String businessType;
private String channelCode;
private String channelName;
private Long carrierId;
private String carrierName;
private String taxMode;
private String containerMode;
private Boolean customsInspection;
private String destinationType;
private Long destinationWarehouseId;
private String destinationCode;
private String destinationName;
private Boolean holdFlag;
private String currentHoldReason;
private Integer totalPackageCount;
private BigDecimal actualGrossWeightKg;
private BigDecimal chargeableWeightKg;
private BigDecimal actualVolumeCbm;
private BigDecimal chargeableVolumeCbm;
private Integer palletCount;
private BigDecimal volumeWeightKg;
private BigDecimal actualNetWeightKg;
private LocalDateTime measureCompletedAt;
private LocalDateTime inboundedAt;
private String expressCompanyCode;
private String expressCompanyName;
private String expressTrackingNo;
private String publicRemark;
private String createdByName;
}
@@ -0,0 +1,21 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
/** 开放接口只读使用的入库单最小投影,禁止通过该实体更新主单。 */
@Getter
@Setter
@TableName("fl_inbound_order")
public class InboundOrderLookupEntity {
@TableId
private Long id;
private Long tenantId;
private String inboundCode;
private Long orderId;
private Long warehouseId;
private String processStatus;
private Integer deleted;
}
@@ -0,0 +1,26 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
/** 入库完成事件 Outbox 记录。 */
@Getter
@Setter
@TableName("fl_inbound_outbox")
public class InboundOutboxEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private String aggregateType;
private Long aggregateId;
private String eventType;
private String payloadJson;
private String publishStatus;
private Integer retryCount;
private LocalDateTime createdAt;
}
@@ -0,0 +1,35 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/** 头程侧托盘和库位分配结果快照。 */
@Getter
@Setter
@TableName("fl_inbound_pallet_allocation")
public class InboundPalletAllocationEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private Long inboundId;
private Integer allocationNo;
private Long warehouseId;
private Long warehouseResourceTenantId;
private Long palletId;
private String palletCode;
private BigDecimal palletCapacityCbm;
private BigDecimal allocatedVolumeCbm;
private Integer allocatedPackageCount;
private Long locationId;
private String locationCode;
private String reservationNo;
private String allocationStatus;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,39 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baiyue.common.core.model.BaseEntity;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
/** 计费所需的入库单聚合根字段。 */
@Getter
@Setter
@TableName("fl_inbound_order")
public class InboundPricingEntity extends BaseEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private String inboundCode;
private String processStatus;
private String measureReviewStatus;
private String feeStatus;
private Long customerId;
private String customerName;
private Long warehouseId;
private String warehouseName;
private String businessType;
private String channelCode;
private Long carrierId;
private String carrierName;
private String taxMode;
private String destinationType;
private String destinationCode;
private BigDecimal actualGrossWeightKg;
private BigDecimal actualVolumeCbm;
private BigDecimal chargeableWeightKg;
private BigDecimal chargeableVolumeCbm;
}
@@ -0,0 +1,30 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
/** 费用试算、确认和发送操作的持久化幂等记录。 */
@Getter
@Setter
@TableName("fl_inbound_idempotency")
public class InboundPricingIdempotencyEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private String operation;
private Long operatorId;
private Long inboundId;
private String idempotencyKey;
private String requestHash;
private String status;
private String resultJson;
private Long resultResourceId;
private LocalDateTime completedAt;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,27 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
/** 入库流程状态不可变历史。 */
@Getter
@Setter
@TableName("fl_inbound_status_history")
public class InboundStatusHistoryEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private Long inboundId;
private String fromStatus;
private String toStatus;
private String action;
private String reason;
private Long operatorId;
private String operatorName;
private LocalDateTime occurredAt;
}
@@ -0,0 +1,30 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
/** 关联运输信息只读快照。 */
@Getter
@Setter
@TableName("fl_inbound_transport_snapshot")
public class InboundTransportSnapshotEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private Long inboundId;
private String loadingStatus;
private String containerNo;
private String cabinetNo;
private String vesselName;
private String voyageNo;
private LocalDateTime sailingAt;
private LocalDateTime portArrivalAt;
private LocalDateTime sourceUpdatedAt;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,37 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baiyue.common.core.model.BaseEntity;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/** 称重图片 OCR 原值、人工修正值和最终确认重量。 */
@Getter
@Setter
@TableName("fl_inbound_weight_result")
public class InboundWeightResultEntity extends BaseEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private Long inboundId;
private Long fileId;
private String ocrProvider;
private String ocrRawText;
private BigDecimal ocrWeightValue;
private String ocrWeightUnit;
private BigDecimal confidence;
private BigDecimal adjustedWeightValue;
private String adjustedWeightUnit;
private BigDecimal finalWeightKg;
private String recognitionStatus;
private String adjustmentReason;
private Long adjustedBy;
private LocalDateTime adjustedAt;
private Long confirmedBy;
private LocalDateTime confirmedAt;
}
@@ -0,0 +1,33 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/** OCR 重量结果每次人工修正和确认的不可变版本。 */
@Getter
@Setter
@TableName("fl_inbound_weight_revision")
public class InboundWeightRevisionEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private Long inboundId;
private Long weightResultId;
private Integer revisionNo;
private String action;
private BigDecimal beforeWeightValue;
private String beforeWeightUnit;
private BigDecimal afterWeightValue;
private String afterWeightUnit;
private BigDecimal finalWeightKg;
private String reason;
private Long operatorId;
private String operatorName;
private LocalDateTime operatedAt;
}
@@ -0,0 +1,29 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
/** 测量审核、驳回和异常放行不可变历史。 */
@Getter
@Setter
@TableName("fl_measure_review_record")
public class MeasureReviewRecordEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private Long inboundId;
private Long measurementId;
private String reviewResult;
private String reviewReason;
private Boolean exceptionRelease;
private String releaseReason;
private Boolean allowRejectedConfig;
private Long reviewerId;
private String reviewerName;
private LocalDateTime reviewedAt;
}
@@ -0,0 +1,36 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
/** 第三方测量 API 每次调用的原始报文和处理结果留档。 */
@Getter
@Setter
@TableName("fl_third_party_call_log")
public class ThirdPartyCallLogEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private String appKey;
private String signatureSubject;
private String requestMethod;
private String requestPath;
private String requestTimestamp;
private String nonce;
private String idempotencyKey;
private Integer idempotencyAnchor;
private String signatureValue;
private String requestHash;
private String rawPayload;
private String processingStatus;
private String resultCode;
private String resultMessage;
private String resultJson;
private LocalDateTime requestedAt;
private LocalDateTime processedAt;
}
@@ -0,0 +1,4 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.CarrierFbaRateEntity;
public interface CarrierFbaRateMapper extends BaseMapper<CarrierFbaRateEntity> {}
@@ -0,0 +1,4 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.CustomerChargeRuleEntity;
public interface CustomerChargeRuleMapper extends BaseMapper<CustomerChargeRuleEntity> {}
@@ -0,0 +1,16 @@
package com.baiyue.firstleg.mapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
/** 只检查任务5已准备好的托盘/库位引用,不在头程服务实现仓库 CRUD。 */
@Mapper
public interface InboundAllocationQueryMapper {
@Select("""
SELECT COUNT(*) FROM fl_inbound_pallet_allocation
WHERE tenant_id = #{tenantId} AND inbound_id = #{inboundId}
AND allocation_status IN ('RESERVED', 'CONFIRMED')
""")
long countPrepared(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId);
}
@@ -0,0 +1,4 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.InboundFeeItemEntity;
public interface InboundFeeItemMapper extends BaseMapper<InboundFeeItemEntity> {}
@@ -0,0 +1,19 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.InboundFeeEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;
@Mapper
public interface InboundFeeMapper extends BaseMapper<InboundFeeEntity> {
@Update("""
UPDATE fl_inbound_fee
SET fee_status = 'SENT', sent_by = #{operatorId}, sent_at = CURRENT_TIMESTAMP(3)
WHERE tenant_id = #{tenantId} AND inbound_id = #{inboundId} AND id = #{feeId}
AND fee_status = 'CONFIRMED'
""")
int markSent(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
@Param("feeId") long feeId, @Param("operatorId") long operatorId);
}
@@ -0,0 +1,9 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.InboundFileEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface InboundFileMapper extends BaseMapper<InboundFileEntity> {
}
@@ -0,0 +1,17 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.InboundIdempotencyEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;
@Mapper
public interface InboundIdempotencyMapper extends BaseMapper<InboundIdempotencyEntity> {
@Update("""
UPDATE fl_inbound_idempotency
SET status = 'SUCCESS', result_json = #{resultJson}, completed_at = CURRENT_TIMESTAMP(3)
WHERE id = #{id} AND status = 'PROCESSING'
""")
int complete(@Param("id") long id, @Param("resultJson") String resultJson);
}
@@ -0,0 +1,9 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.InboundInternalNoteEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface InboundInternalNoteMapper extends BaseMapper<InboundInternalNoteEntity> {
}
@@ -0,0 +1,9 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.InboundMeasurementItemEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface InboundMeasurementItemMapper extends BaseMapper<InboundMeasurementItemEntity> {
}
@@ -0,0 +1,19 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.InboundMeasurementEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;
/** 测量批次 Mapper。 */
@Mapper
public interface InboundMeasurementMapper extends BaseMapper<InboundMeasurementEntity> {
@Update("""
UPDATE fl_inbound_measurement SET status = #{toStatus}, updated_at = CURRENT_TIMESTAMP(3)
WHERE tenant_id = #{tenantId} AND inbound_id = #{inboundId} AND id = #{measurementId}
AND status = 'SUBMITTED'
""")
int reviewSubmitted(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
@Param("measurementId") long measurementId, @Param("toStatus") String toStatus);
}
@@ -0,0 +1,8 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.InboundOrderLookupEntity;
/** 入库单只读 Mapper,开放接口不得在此增加 update 方法。 */
public interface InboundOrderLookupMapper extends BaseMapper<InboundOrderLookupEntity> {
}
@@ -0,0 +1,80 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;
/** 入库聚合根条件更新 Mapper,所有推进均同时校验租户、前置状态和版本。 */
@Mapper
public interface InboundOrderMapper extends BaseMapper<InboundOrderEntity> {
@Update("""
UPDATE fl_inbound_order
SET process_status = 'WAIT_PRICING', measure_review_status = 'APPROVED',
updated_by = #{operatorId}, version = version + 1
WHERE tenant_id = #{tenantId} AND id = #{inboundId} AND deleted = 0
AND process_status = 'MEASURE_PENDING' AND measure_review_status = 'PENDING'
AND version = #{expectedVersion}
""")
int approveMeasurement(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
@Param("operatorId") long operatorId,
@Param("expectedVersion") int expectedVersion);
@Update("""
UPDATE fl_inbound_order
SET process_status = 'MEASURE_REJECTED', measure_review_status = 'REJECTED',
updated_by = #{operatorId}, version = version + 1
WHERE tenant_id = #{tenantId} AND id = #{inboundId} AND deleted = 0
AND process_status = 'MEASURE_PENDING' AND measure_review_status = 'PENDING'
AND version = #{expectedVersion}
""")
int rejectMeasurement(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
@Param("operatorId") long operatorId,
@Param("expectedVersion") int expectedVersion);
@Update("""
UPDATE fl_inbound_order
SET process_status = 'WAIT_PRICING', updated_by = #{operatorId}, version = version + 1
WHERE tenant_id = #{tenantId} AND id = #{inboundId} AND deleted = 0
AND process_status = 'MEASURE_REJECTED' AND measure_review_status = 'REJECTED'
AND version = #{expectedVersion}
""")
int releaseRejectedMeasurement(@Param("tenantId") long tenantId,
@Param("inboundId") long inboundId,
@Param("operatorId") long operatorId,
@Param("expectedVersion") int expectedVersion);
@Update("""
UPDATE fl_inbound_order
SET process_status = 'INBOUNDING', updated_by = #{operatorId}, version = version + 1
WHERE tenant_id = #{tenantId} AND id = #{inboundId} AND deleted = 0
AND process_status = 'FEE_CONFIRMING' AND fee_status = 'CONFIRMED'
AND version = #{expectedVersion}
""")
int beginInbound(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
@Param("operatorId") long operatorId,
@Param("expectedVersion") int expectedVersion);
@Update("""
UPDATE fl_inbound_order
SET process_status = 'INBOUNDED', fee_status = 'SENT', notification_status = 'PENDING',
inbounded_at = CURRENT_TIMESTAMP(3), updated_by = #{operatorId}, version = version + 1
WHERE tenant_id = #{tenantId} AND id = #{inboundId} AND deleted = 0
AND process_status = 'INBOUNDING' AND fee_status = 'CONFIRMED'
AND version = #{expectedVersion}
""")
int completeInbound(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
@Param("operatorId") long operatorId,
@Param("expectedVersion") int expectedVersion);
@Update("""
UPDATE fl_inbound_order
SET process_status = 'FEE_CONFIRMING', updated_by = #{operatorId}, version = version + 1
WHERE tenant_id = #{tenantId} AND id = #{inboundId} AND deleted = 0
AND process_status = 'INBOUNDING' AND version = #{expectedVersion}
""")
int rollbackInbound(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
@Param("operatorId") long operatorId,
@Param("expectedVersion") int expectedVersion);
}
@@ -0,0 +1,9 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.InboundOutboxEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface InboundOutboxMapper extends BaseMapper<InboundOutboxEntity> {
}
@@ -0,0 +1,9 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.InboundPalletAllocationEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface InboundPalletAllocationMapper extends BaseMapper<InboundPalletAllocationEntity> {
}
@@ -0,0 +1,4 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.InboundPricingIdempotencyEntity;
public interface InboundPricingIdempotencyMapper extends BaseMapper<InboundPricingIdempotencyEntity> {}
@@ -0,0 +1,4 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.InboundPricingEntity;
public interface InboundPricingMapper extends BaseMapper<InboundPricingEntity> {}
@@ -0,0 +1,9 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.InboundStatusHistoryEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface InboundStatusHistoryMapper extends BaseMapper<InboundStatusHistoryEntity> {
}

Some files were not shown because too many files have changed in this diff Show More