Compare commits
150
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a94c541cb | ||
|
|
b223ce82d6 | ||
|
|
1e8f504f15 | ||
|
|
94395faeef | ||
|
|
579c61879d | ||
|
|
41837180f8 | ||
|
|
a10e32a012 | ||
|
|
980d37b9c2 | ||
|
|
f2958bef6e | ||
|
|
0e1ad4ed45 | ||
|
|
76d2da5bf9 | ||
|
|
46f5a73c47 | ||
|
|
b610a128de | ||
|
|
f0ac0c42c9 | ||
|
|
105660bd09 | ||
|
|
2ccfc38cdf | ||
|
|
f551cd8c7a | ||
|
|
d2092bf0b6 | ||
|
|
97168b19d8 | ||
|
|
8b0c99593e | ||
|
|
9e32efdd90 | ||
|
|
6eaf484fcd | ||
|
|
53078b4281 | ||
|
|
49d216cc13 | ||
|
|
9a3b408de0 | ||
|
|
68b584128c | ||
|
|
b5bc77b153 | ||
|
|
ffed9bf898 | ||
|
|
de0eddeee8 | ||
|
|
4991536065 | ||
|
|
7ace1ded56 | ||
|
|
5af79df4bd | ||
|
|
6eb53751a3 | ||
|
|
09e81b429c | ||
|
|
179a20fc38 | ||
|
|
6a0f51f136 | ||
|
|
7d9eaa7f43 | ||
|
|
bfc2a4dbd4 | ||
|
|
0613d1095e | ||
|
|
3d7990250f | ||
|
|
2c3efad70a | ||
|
|
cdcf3e0f4f | ||
|
|
3d6216910f | ||
|
|
505188c3fd | ||
|
|
477971dda1 | ||
|
|
249b182e7f | ||
|
|
936412f351 | ||
|
|
0cc4eae209 | ||
|
|
f2a980e5c1 | ||
|
|
47ce84d122 | ||
|
|
01385c4bc7 | ||
|
|
9b09663b2e | ||
|
|
838bd93bc4 | ||
|
|
60d45613c7 | ||
|
|
4ddfc6fb99 | ||
|
|
df0fbc1c7f | ||
|
|
4795ab958c | ||
|
|
1288c8937c | ||
|
|
ba8e3595e4 | ||
|
|
17d81b0b94 | ||
|
|
c9792b1f63 | ||
|
|
0122ad314b | ||
|
|
ea615b864a | ||
|
|
314ce09b5d | ||
|
|
942dbace19 | ||
|
|
b386f35ab4 | ||
|
|
d6f47e650c | ||
|
|
bef91151a3 | ||
|
|
bf1dc1f209 | ||
|
|
aa583efa4a | ||
|
|
ff6a8498a6 | ||
|
|
ca9e7b60e8 | ||
|
|
0aa77327d5 | ||
|
|
5c83b81e8a | ||
|
|
c9ee319e9d | ||
|
|
4581c8a294 | ||
|
|
9a15e78b2e | ||
|
|
bfcb80b326 | ||
|
|
0cf76df168 | ||
|
|
ba641754de | ||
|
|
81ef43c9fd | ||
|
|
0605c32829 | ||
|
|
66816d5341 | ||
|
|
de101621dd | ||
|
|
b4db37392d | ||
|
|
b1ab909184 | ||
|
|
775dd71227 | ||
|
|
3339ec91f9 | ||
|
|
ac6d3289a6 | ||
|
|
e6ee9cb442 | ||
|
|
da32a67d7f | ||
|
|
969ff05815 | ||
|
|
7f565a24d6 | ||
|
|
1e389d95dd | ||
|
|
f53ff76143 | ||
|
|
f87647d4eb | ||
|
|
833f928a30 | ||
|
|
621aec9b2b | ||
|
|
f72238e43f | ||
|
|
7a15481e2d | ||
|
|
2dca7dfdb2 | ||
|
|
1c51373704 | ||
|
|
9664aa4bf0 | ||
|
|
603bd29cc2 | ||
|
|
0afe75379e | ||
|
|
0ea4aeecc9 | ||
|
|
bb9673358d | ||
|
|
6b5f2f0565 | ||
|
|
fe68551124 | ||
|
|
f998bed09c | ||
|
|
1e5ce54858 | ||
|
|
b34fbb2bd1 | ||
|
|
a6f266687e | ||
|
|
dd117b8fc2 | ||
|
|
a719a903cd | ||
|
|
3b1b4ab9b2 | ||
|
|
9d54c57b7c | ||
|
|
e72ac14425 | ||
|
|
ec8f84c1c0 | ||
|
|
2b7f66cb04 | ||
|
|
c7061b8a74 | ||
|
|
5250f304be | ||
|
|
f5f72d681d | ||
|
|
c226b6a99b | ||
|
|
600b2a2dc4 | ||
|
|
76c24867a4 | ||
|
|
61a97cd057 | ||
|
|
ecf48886d5 | ||
|
|
1db3e48506 | ||
|
|
0f2e201c38 | ||
|
|
adabec13d3 | ||
|
|
0fb51211aa | ||
|
|
d7bff980ca | ||
|
|
ed3c1dafcc | ||
|
|
0a3ec0b8a6 | ||
|
|
3b38fdd33d | ||
|
|
daf0ee2acd | ||
|
|
086a0f9c89 | ||
|
|
c80307a838 | ||
|
|
575cee7a86 | ||
|
|
c1b136c3d6 | ||
|
|
4612181ab0 | ||
|
|
b4c46280de | ||
|
|
15b563610c | ||
|
|
079ff43f73 | ||
|
|
554b6c93b7 | ||
|
|
817e356c21 | ||
|
|
ca12f58d38 | ||
|
|
8fb63e9adc | ||
|
|
d736fdba1b |
@@ -28,5 +28,6 @@ reports/
|
||||
uploads/
|
||||
.codex-temp*
|
||||
.codex-tmp*
|
||||
.runtime-*/
|
||||
.codex-*.out
|
||||
.codex-*.err
|
||||
|
||||
Binary file not shown.
@@ -1,39 +0,0 @@
|
||||
import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.ResultSetMetaData;
|
||||
import java.sql.Statement;
|
||||
|
||||
public final class DbQuery {
|
||||
public static void main(String[] args) throws Exception {
|
||||
if (args.length < 2) {
|
||||
throw new IllegalArgumentException("usage: DbQuery <database> <sql>");
|
||||
}
|
||||
String url = "jdbc:mysql://127.0.0.1:3306/" + args[0]
|
||||
+ "?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai"
|
||||
+ "&useSSL=false&allowPublicKeyRetrieval=true";
|
||||
try (Connection connection = DriverManager.getConnection(url, "root", "123456");
|
||||
Statement statement = connection.createStatement()) {
|
||||
boolean hasResult = statement.execute(args[1]);
|
||||
if (!hasResult) {
|
||||
System.out.println("updateCount=" + statement.getUpdateCount());
|
||||
return;
|
||||
}
|
||||
try (ResultSet result = statement.getResultSet()) {
|
||||
ResultSetMetaData meta = result.getMetaData();
|
||||
for (int column = 1; column <= meta.getColumnCount(); column++) {
|
||||
if (column > 1) System.out.print("\t");
|
||||
System.out.print(meta.getColumnLabel(column));
|
||||
}
|
||||
System.out.println();
|
||||
while (result.next()) {
|
||||
for (int column = 1; column <= meta.getColumnCount(); column++) {
|
||||
if (column > 1) System.out.print("\t");
|
||||
System.out.print(String.valueOf(result.getObject(column)));
|
||||
}
|
||||
System.out.println();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -25,9 +25,12 @@
|
||||
| 订单、货物、快照、状态、幂等、Outbox | `order/` | `order/AGENTS.md` |
|
||||
| 履约分配、入库、测量、审核、FBA 计费 | `firstleg/` | `firstleg/AGENTS.md` |
|
||||
| Firstleg 内部模块化与依赖基线 | `firstleg/` | `firstleg/docs/firstleg-internal-modularization-baseline.md`、`firstleg/scripts/analyze-firstleg-dependencies.mjs`;价格簿查询和客户统计按端口/适配器切片,不改变服务与数据库边界 |
|
||||
| 分柜、柜次、智能排柜、预装/装柜清单、PDA | `firstleg/` | `firstleg/AGENTS.md`,再按 `split/`、`loading/`、`outbound/` 定位 |
|
||||
| Mapper 新增、移动、Bean 缺失与扫描遗漏 | 目标持久化服务 | 所有 Mapper 显式 `@Mapper`,启动类按服务根包注解过滤扫描;先运行 `node scripts/mapper-scan-contract.mjs`,再按目标模块执行 `test-compile/test`,不得等部署启动发现 |
|
||||
| 脚本管理、全量验证、测试造数与诊断 | 仓库根 `scripts/`;服务专属脚本留在 `<service>/scripts/` | 先读 `scripts/AGENTS.md` 和 `scripts/README.md`;Admin HTTP 内部模块继续读 `scripts/admin-test-env-modules/README.md`。新增入口必须登记用途、目标环境、数据副作用和报告位置,并通过 `scripts/script-documentation-contract.test.mjs` |
|
||||
| 分柜、柜次、智能排柜、预装/装柜清单、PDA | `firstleg/` | `firstleg/AGENTS.md`,再按 `split/`、`loading/`、`outbound/` 定位;预装/正式装柜详情阶段由 `/loading-plans/{planId}/lifecycle` 按稳定 planId 解析,不从 404 或文案推断 |
|
||||
| 合柜报关 / 同柜不可拆组、跨客户合柜、整组分配/移除 | `firstleg/` | `loading/coload/controller/PlatformColoadGroupController.java`、`service/ColoadGroupService.java`、`persistence/ColoadGroupMapper.java`、`src/main/resources/openapi/coload-groups-v1.yaml`;按订单建组并冻结全部可用入仓片段,仅要求同国内仓、同渠道 |
|
||||
| 智能装柜入仓码候选与箱数分配 | `firstleg/` | `controller/PlatformSmartLoadingController.java` 的 `/inbound-candidates`、`/plans/{planId}/inbound-assignments`,业务编排 `service/SmartLoadingService.java` |
|
||||
| 订舱、船期、拖车、报关 | `firstleg/` | `oceanbooking/`、`portschedule/`、`outbound/dispatch/`、`customs/` |
|
||||
| 订舱资料/打印、船期、订舱拥有的拖车、报关 | `firstleg/` | `oceanbooking/`、`portschedule/`、`TruckAppointmentService.java`、`outbound/dispatch/`、`customs/`;拖车不带客户归属,必须绑定 `RELEASED` 订舱及其真实柜,订舱文件联查 `file/AGENTS.md` |
|
||||
| 客户账期、费用池、账单、收款、成本、核销 | `finance/` | `finance/AGENTS.md`;费用来源和柜次成本快照再联查 `firstleg/AGENTS.md`,凭证再联查 `file/AGENTS.md` |
|
||||
| 入库/柜次应付、直接已付、付款流水与凭证 | `firstleg/`、`finance/`、`file/` | 业务费用与付款快照见 `firstleg/AGENTS.md` 的 `FeePaymentSnapshotValidator`;`/api/platform/finance/v1/vouchers` 上传凭证,File 按 `FINANCE_VOUCHER` 和客户范围校验 |
|
||||
| 仓库、往来单位、快递、托盘、库位 | `warehouse/` | `warehouse/AGENTS.md` |
|
||||
@@ -40,8 +43,12 @@
|
||||
| 海外 API 路由、海外 Token、Audience/JWKS | `gateway-overseas/` | `gateway-overseas/AGENTS.md` |
|
||||
| 海外 IAM 状态与权限版本投影 | `lastleg-service/security/` | `lastleg-service/AGENTS.md`;内部入口 `/internal/lastleg/security/v1/events`、`/validate`;消费三类 IAM 安全事件并为海外 Gateway 提供 fail-closed 校验,投影表归 `baiyue_lastleg` |
|
||||
| 尾程订单、海外入仓计划、换标、库存、采购、本土卡车 | `lastleg-service/` | `lastleg-service/AGENTS.md`,再按 `inboundplan/`、`relabel/`、`inventory/`、`procurement/`、`trucking/` 定位 |
|
||||
| 柜级入仓计划进度与整柜转换 | `lastleg-service/` | `orders/container-summaries` 按 `sourceContainerBookingId` 跨客户聚合全柜进度与 `allPlansReady`,详情返回柜内全部订单/入仓码/计划且不受订单分页截断;`plannedArrivalAt` 只取装柜完成事件 `estimatedWarehouseArrivalAt` 冻结事实;`inbound-plans/convert-container` 锁定同柜订单,每票生成独立计划,整批原子且幂等 |
|
||||
| 海外入仓分页、FBA 预约、PDA 柜号签收与入仓 | `lastleg-service/` | 平台入口 `inboundplan/controller/InboundPlanController.java`、`InboundPlanQueryMapper.java`、`InboundFbaAppointmentService.java`;PDA 公网入口 `inboundplan/pda/controller/ReceivingContainerController.java`、`ReceivingContainerService.java`,旧 planNo/allocationId Controller 已移除 |
|
||||
| 海外仓排车查询、候选与智能预排 | `lastleg-service/` | `dispatch/controller/DispatchQueryController.java`、`SmartDispatchController.java`;草稿柜、托盘锁和预排归 `dispatch/`,只读排车和预排创建独立开关,不自动开放编辑、取消、发车、PDA 出库或派送 |
|
||||
| 五期海外排车执行、PDA 出库、派送与一件代发 | `lastleg-service/` | `dispatch/controller/DispatchController.java`、`dispatch/pda/controller/DispatchPdaController.java`、`delivery/controller/DeliveryTransferController.java`、`OutboundPdaController.java`、`MerchantDeliveryController.java`,一件代发库存归 `dropship/`;统一经 `gateway-overseas/`,设备端是否已有正式入口继续查 `../apps/pda-overseas-uniapp/AGENTS.md` |
|
||||
| 五期海外异常退回与客户回访 | `lastleg-service/` | `exceptionorder/`、`customervisit/`;平台异常建档和关系解析、派送退回入仓、派送完成/状态、回访资料与附件均以 Lastleg 自身客户范围和状态为准 |
|
||||
| 五期一件代发 SKU/箱唛与暂扣跨区协同 | `order/`、`firstleg/`、`common-event-contract/`、`lastleg-service/` | Order `InternalOrderCargoController` 提供强制 SKU;Firstleg `InboundCartonLabelService` 冻结 SKU、`LoadingWorkPalletService` 维护装柜工作托盘并消费 `OverseasInboundOrderDetainedChangedV1`;Lastleg `dropship/` 建立库存并发布暂扣变更 |
|
||||
| 客户海外仓贴标、转运、废弃与退货换标 | `lastleg-service/` | `relabel/`、`transfer/`、`discard/` 的 Merchant Controller/Service;Client 消费端 `../apps/client-web/src/modules/overseas-warehouse/`,统一经 `gateway-overseas/` |
|
||||
| 海外仓 PDA 盘点、移库、托盘、换标与查询 | `lastleg-service/`、`warehouse-overseas-service/` | Lastleg `pda/stocktake/`、`pda/move/`、`pda/warehouse/`、`palletops/`、`relabel/pda/`;契约 `lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml`;物理托盘流转与补偿进入 Warehouse `InternalPdaPalletTransitionController`,设备端入口是否开放以 `../apps/pda-overseas-uniapp/AGENTS.md` 为准 |
|
||||
| 国内船节点向海外接柜门禁事件 | `firstleg/`、`common-event-contract/`、`lastleg-service/` | Firstleg `portschedule/service/CarrierScheduleNodeOutboxService.java` 发布 `FirstLegContainerShippingNodeRecordedV1`;Lastleg `shipping/service/ContainerShippingNodeConsumer.java` 单调消费,只有实际 `PICKUP_READY` 及以后节点允许海外 PDA 候选进入可收货 |
|
||||
@@ -77,6 +84,7 @@
|
||||
|
||||
- 跨服务修改先检查调用方 `client/`、被调用方 Controller/DTO、`src/main/resources/openapi/` 和双方契约测试;禁止共享领域 Entity。
|
||||
- 修改 Java 方法、构造器、Controller、Client、DTO 或 Mapper 参数签名时,先用 `rg` 定向查找符号及旧参数形态,并同步更新 `src/main/`、`src/test/`、Mock、Fixture、契约测试和脚本中的全部调用点。新增可选参数也必须补齐旧测试调用,不能因生产调用已更新就视为完成。
|
||||
- 所有生产 MyBatis Mapper 接口必须显式标记 `@Mapper`;每个持久化服务只允许在启动类使用一次服务根包 `@MapperScan(..., annotationClass = Mapper.class)`,不得新增子域包白名单或分散的 `*MapperConfiguration`。新增或移动 Mapper 后先运行 `node scripts/mapper-scan-contract.mjs`,全量后端回归也会在 Maven 前执行该门禁。
|
||||
- 平台后台存在正式列表页时,`*:list` 同时授权列表、详情及只读辅助接口;`create`、`edit`、`delete`、`status`、`approve`、`review`、`export`、`upload`、`complete` 等操作保持独立。当前未上线,不兼容旧独立 `*:view`,Controller 不得使用 `hasAnyAuthority(list, view)`,OpenAPI 和角色初始化只保留 `*:list`。
|
||||
- 供应商查询接口固定只使用 `platform:supplier:list`;IAM 中 `platform:supplier:list` 是正式菜单资源,`platform:supplier:view` 必须停用/移除,不得重新提升或兼容。
|
||||
- 权限语义变更必须同步 Controller `@PreAuthorize`、OpenAPI、模块权限 SQL、IAM 菜单资源、统一增量脚本和契约测试。并行模块任务不得覆盖菜单与权限总控已写入共享 SQL 或权限契约的改动。
|
||||
@@ -85,16 +93,20 @@
|
||||
- 通用配置在 `config/nacos/{local,cn}/`;服务日志、上传文件、`target/`、`reports/` 不属于源码。
|
||||
- Java 签名变更的最低编译门禁是 `mvn -pl <module> -am test-compile`,它必须在部署前完成并覆盖测试源码;有行为变化时继续运行 `mvn -pl <module> -am test`。`-DskipTests` 可跳过测试执行但仍须保留测试编译,`-Dmaven.test.skip=true` 会跳过测试编译,不得作为签名变更的交付验证。全量验证仍使用 `mvn -DskipTests validate`、`mvn test`;不要在本地启动 Java/Nacos/数据库,除非用户明确要求。
|
||||
- `AGENTS.md` 索引维护完成后运行 `./scripts/agents-index-contract.ps1`;脚本只读源码目录和指定指引,验证根快速表唯一性、部署单元、Client active 路由、四期范围漂移、仓库主档唯一权威、订舱关联候选与柜量语义、整柜出仓与船节点单调性、海外 Gateway 覆盖、PDA 运行配置/独立更新/语言预留、人工分段验收链、Lastleg 海外安全投影、同步 Trace、模块覆盖、关键入口与排除目录,不访问 HTTP、数据库或部署环境。
|
||||
- Mapper 注册静态合同为 `scripts/mapper-scan-contract.mjs`、`mapper-scan-contract.test.mjs`:自动发现聚合模块、生产 `*Mapper` 接口、服务启动类和分散扫描配置,不启动 Spring、不连接数据库;遗漏注解、扫描根漂移或重新引入子域扫描时直接失败并输出类与文件路径。
|
||||
- 单服务镜像构建/部署从 `Jenkinsfile.service`、`deploy/docker/Dockerfile`、`deploy/scripts/deploy-built-service.sh` 进入;新镜像启动失败或健康检查失败时只允许回滚到同服务先前镜像,并保持任务失败,离线合同见 `scripts/deploy-built-service-rollback.test.mjs`。实际部署仍须当前对话明确授权。
|
||||
|
||||
## 全量 HTTP 验收脚本路由
|
||||
|
||||
- 脚本管理的唯一总索引是 `scripts/README.md`,最近执行规则是 `scripts/AGENTS.md`。仓库级脚本、Admin 内部模块、服务局部合同和部署脚本的目录归属以这两个文件为准,不再通过无范围搜索猜测入口。
|
||||
- 全量验收唯一指向测试环境 HTTP:平台端执行 `scripts/admin-test-env-acceptance.mjs`,客户端执行 `scripts/client-test-env-acceptance.mjs`;二期专项场景才单独执行 `scripts/client-phase2-test-env-acceptance.mjs`,不替代客户端全量入口。
|
||||
- 固定客户待审核订单使用 `scripts/create-pending-review-orders.mjs`;固定“IT测试”与“义乌仓”、随机真实线路并推进至 `WAIT_SPLIT` 使用 `scripts/it-test-wait-split-bulk.mjs`。二者都是造数入口,不属于全量验证;会写目标环境且不自动清理成功数据。
|
||||
- Admin 总入口默认按模块列表覆盖平台全链路;需要定位单个业务时使用 `--modules <name>`,模块名称和静态合同先查 `scripts/admin-test-env-modules/README.md`。
|
||||
- 这些脚本会创建、更新或最终停用测试数据,必须在当前对话明确授权测试环境验收后执行,并保存 JSON 报告;未授权不得请求 Gateway。
|
||||
- 后端 Maven、前端 `pnpm test/typecheck`、PDA `node --test` 和数据库 Dry-run 都是本地质量检查,不属于全量 HTTP 验收。
|
||||
- Java 模块、OpenAPI 覆盖、JaCoCo 与 SpotBugs 聚合摘要使用 `node scripts/java-quality-summary.mjs`;默认仅报告存量缺口,是否阻断由显式 `--require-*` 参数决定,输出位于 `target/` 时不得作为业务事实或提交物。
|
||||
- HTTP 全量通过后,才进入 Admin/Client 真实页面调用、浏览器样式和 PDA 页面/设备验收;自动化 HTTP 通过不代表视觉验收完成。
|
||||
- 修改脚本目录、入口或说明后,至少运行 `node --test scripts/script-documentation-contract.test.mjs`;修改 `AGENTS.md` 后继续运行 `./scripts/agents-index-contract.ps1`。两者均为离线只读合同,不能代替真实 HTTP 验收。
|
||||
|
||||
## 阶段文档入口
|
||||
|
||||
@@ -102,6 +114,7 @@
|
||||
- 二期:`../docs/二期/README.md`、`../docs/二期/二期需求优化版-v2-测量计费入库主流程.md`、需求确认清单及联调报告
|
||||
- 三期:`../docs/三期/README.md`、`../docs/三期/三期开工门禁.md`、`../docs/三期/三期开发规划.md`、`../docs/三期/三期需求确认表.md`、页面与原型清单
|
||||
- 四期:`../docs/四期/README.md`;海外入口 `gateway-overseas/`,尾程与入仓库存 `lastleg-service/`,海外仓储资源 `warehouse-overseas-service/`
|
||||
- 五期:当前以本轮确认的正式 Controller、前端路由、权限、SQL 与跨区事件契约为范围依据;海外排车执行、出库、派送、一件代发、异常退回和客户回访进入 `lastleg-service/`,国内 SKU/箱唛及暂扣协同进入 `order/`、`firstleg/`、`common-event-contract/`
|
||||
- 财务:`../docs/财务管理/README.md` 中已确认的范围、对象、状态和接口约束
|
||||
|
||||
全量验收任务再读取对应阶段进度表、验收报告和证据矩阵;普通代码定位只读取当前链路涉及的范围、契约、模块指引和代码。
|
||||
|
||||
Vendored
+1
@@ -295,6 +295,7 @@ pipeline {
|
||||
echo "统一测试本次选中的服务及其公共依赖: ${selectedModules}"
|
||||
sh """
|
||||
test \"\$(git rev-parse HEAD)\" = \"${env.SOURCE_COMMIT}\"
|
||||
find . -type f -path '*/target/surefire-reports/TEST-*.xml' -delete
|
||||
mvn --settings deploy/maven/settings-ci.xml --batch-mode --errors -pl ${selectedModules} -am test
|
||||
"""
|
||||
echo "统一测试门禁通过: ${env.SOURCE_COMMIT};下游单服务任务不再重复执行测试"
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
## 职责边界
|
||||
|
||||
- Mapper 只由 `BaiyueAuditApplication.java` 对 `com.baiyue.audit` 根包按 `@Mapper` 过滤扫描;新增或移动 Mapper 后从后端根运行 `node scripts/mapper-scan-contract.mjs`,不得增加子域扫描配置。
|
||||
- 负责接收 `common-audit` 产生的审计事件并持久化到 `baiyue_audit`。
|
||||
- 审计注解、切面、脱敏和发送策略在 `common-audit/`;本服务不复制业务服务的审计采集逻辑。
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.baiyue.audit;
|
||||
|
||||
import com.baiyue.common.core.mybatis.MybatisPlusConfiguration;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.mybatis.spring.annotation.MapperScan;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
@@ -10,7 +11,7 @@ import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
/**
|
||||
* 审计微服务启动入口,初始化 Spring Boot 应用并扫描审计日志 Mapper。
|
||||
*/
|
||||
@MapperScan("com.baiyue.audit.mapper")
|
||||
@MapperScan(basePackages = "com.baiyue.audit", annotationClass = Mapper.class)
|
||||
@EnableScheduling
|
||||
@Import(MybatisPlusConfiguration.class)
|
||||
@SpringBootApplication
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
package com.baiyue.audit.mapper;
|
||||
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.audit.entity.AuditLogEntity;
|
||||
|
||||
/**
|
||||
* 审计日志 MyBatis-Plus Mapper,提供 {@code sys_audit_log} 表的单表写入和查询能力。
|
||||
*/
|
||||
@Mapper
|
||||
public interface AuditLogMapper extends BaseMapper<AuditLogEntity> {
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
## 职责与边界
|
||||
|
||||
- Mapper 只由 `BaiyueAuthApplication.java` 对 `com.baiyue.auth` 根包按 `@Mapper` 过滤扫描;新增或移动 Mapper 后从后端根运行 `node scripts/mapper-scan-contract.mjs`,不得增加子域扫描配置。
|
||||
- 负责登录、注册、账号凭据、Access/Refresh Token、退出、密码和账号归属范围。
|
||||
- 客户主档、成员、角色权限归 `iam/`;Auth 通过 IAM 客户端获取授权快照,不跨库写 IAM。
|
||||
- 修改国内/海外 Token、Audience、JWKS 或跨区域身份边界前,先读 `../../docs/跨区域认证与服务通信契约.md`。
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.baiyue.auth;
|
||||
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.mybatis.spring.annotation.MapperScan;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
@@ -8,7 +9,7 @@ import org.springframework.cloud.openfeign.EnableFeignClients;
|
||||
/**
|
||||
* 认证服务启动入口,负责装配统一登录、令牌签发与会话管理相关组件。
|
||||
*/
|
||||
@MapperScan("com.baiyue.auth.mapper")
|
||||
@MapperScan(basePackages = "com.baiyue.auth", annotationClass = Mapper.class)
|
||||
@EnableFeignClients(basePackages = "com.baiyue.auth.client")
|
||||
@SpringBootApplication(scanBasePackages = "com.baiyue")
|
||||
public class BaiyueAuthApplication {
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package com.baiyue.auth.mapper;
|
||||
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
import com.baiyue.auth.entity.AuthAccount;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
|
||||
/**
|
||||
* 认证账号 MyBatis-Plus 数据访问接口,负责 {@code auth_account} 表的基础持久化操作。
|
||||
*/
|
||||
@Mapper
|
||||
public interface AuthAccountMapper extends BaseMapper<AuthAccount> { }
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package com.baiyue.auth.mapper;
|
||||
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
import com.baiyue.auth.entity.AuthRefreshToken;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
|
||||
/**
|
||||
* 刷新令牌 MyBatis-Plus 数据访问接口,负责 {@code auth_refresh_token} 表的基础持久化操作。
|
||||
*/
|
||||
@Mapper
|
||||
public interface AuthRefreshTokenMapper extends BaseMapper<AuthRefreshToken> { }
|
||||
|
||||
@@ -63,7 +63,7 @@ class AuthServiceSmallJwtTest {
|
||||
account.setDefaultAppCode("PLATFORM");
|
||||
account.setTokenVersion(1);
|
||||
AuthorizationSnapshot snapshot = new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM",
|
||||
"WEB", Set.of("WAREHOUSE_PICKER"), Set.of("platform:order:list"), 1L, 900L);
|
||||
"WEB", Set.of("OVERSEAS_WAREHOUSE_PDA_OPERATOR"), Set.of("platform:order:list"), 1L, 900L);
|
||||
Jwt encoded = org.mockito.Mockito.mock(Jwt.class);
|
||||
|
||||
when(repository.findByUsername("operator")).thenReturn(account);
|
||||
@@ -91,7 +91,7 @@ class AuthServiceSmallJwtTest {
|
||||
assertThat(jwt.getValue().getJwsHeader().getAlgorithm()).isEqualTo(MacAlgorithm.HS256);
|
||||
assertThat(jwt.getValue().getJwsHeader().getKeyId()).isNull();
|
||||
verify(tokenSessionStore).store(any(), org.mockito.ArgumentMatchers.eq(42L),
|
||||
org.mockito.ArgumentMatchers.eq(Set.of("WAREHOUSE_PICKER")),
|
||||
org.mockito.ArgumentMatchers.eq(Set.of("OVERSEAS_WAREHOUSE_PDA_OPERATOR")),
|
||||
org.mockito.ArgumentMatchers.eq(Set.of("platform:order:list")), any(),
|
||||
org.mockito.ArgumentMatchers.eq(Duration.ofMinutes(30)),
|
||||
org.mockito.ArgumentMatchers.eq(Duration.ofDays(7)));
|
||||
|
||||
+1
-1
@@ -66,7 +66,7 @@ class OverseasSessionValidationServiceTest {
|
||||
|
||||
private static AuthorizationSnapshot snapshot(Long version, Long warehouseId) {
|
||||
return new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM", "PDA",
|
||||
Set.of("WAREHOUSE_PICKER"), Set.of("lastleg:pda:operate"), version, warehouseId);
|
||||
Set.of("OVERSEAS_WAREHOUSE_PDA_OPERATOR"), Set.of("lastleg:pda:operate"), version, warehouseId);
|
||||
}
|
||||
|
||||
private void inject(String fieldName, Object value) throws Exception {
|
||||
|
||||
@@ -52,7 +52,7 @@ class OverseasTokenServiceTest {
|
||||
String lastlegAuthority = "lastleg:pda:operate";
|
||||
String domesticAuthority = "platform:firstleg:order:list";
|
||||
AuthorizationSnapshot snapshot = new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM", "WEB",
|
||||
Set.of("WAREHOUSE_PICKER"), Set.of(authority, lastlegAuthority, domesticAuthority), 7L, 900L);
|
||||
Set.of("OVERSEAS_WAREHOUSE_PDA_OPERATOR"), Set.of(authority, lastlegAuthority, domesticAuthority), 7L, 900L);
|
||||
Jwt encoded = org.mockito.Mockito.mock(Jwt.class);
|
||||
when(accounts.findById(42L)).thenReturn(account);
|
||||
when(authorizationLoader.load(account)).thenReturn(snapshot);
|
||||
@@ -74,7 +74,7 @@ class OverseasTokenServiceTest {
|
||||
.containsEntry("permission_version", 7L)
|
||||
.containsEntry("token_version", 0)
|
||||
.containsEntry("overseas_warehouse_id", 900L)
|
||||
.containsEntry("roles", Set.of("WAREHOUSE_PICKER"))
|
||||
.containsEntry("roles", Set.of("OVERSEAS_WAREHOUSE_PDA_OPERATOR"))
|
||||
.containsEntry("authorities", Set.of(authority, lastlegAuthority))
|
||||
.doesNotContainKeys("userId", "tenantId", "appCode", "permissionVersion");
|
||||
assertThat(response.expiresIn()).isEqualTo(900);
|
||||
@@ -87,9 +87,9 @@ class OverseasTokenServiceTest {
|
||||
account.setDefaultMembershipId(85L); account.setDefaultAppCode("PLATFORM");
|
||||
account.setTokenVersion(8);
|
||||
AuthorizationSnapshot oldBinding = new AuthorizationSnapshot(43L, 0L, 85L, "PLATFORM", "PDA",
|
||||
Set.of("WAREHOUSE_PICKER"), Set.of("lastleg:pda:operate"), 8L, 900L);
|
||||
Set.of("OVERSEAS_WAREHOUSE_PDA_OPERATOR"), Set.of("lastleg:pda:operate"), 8L, 900L);
|
||||
AuthorizationSnapshot newBinding = new AuthorizationSnapshot(43L, 0L, 85L, "PLATFORM", "PDA",
|
||||
Set.of("WAREHOUSE_PICKER"), Set.of("lastleg:pda:operate"), 9L, 901L);
|
||||
Set.of("OVERSEAS_WAREHOUSE_PDA_OPERATOR"), Set.of("lastleg:pda:operate"), 9L, 901L);
|
||||
Jwt first = org.mockito.Mockito.mock(Jwt.class);
|
||||
Jwt second = org.mockito.Mockito.mock(Jwt.class);
|
||||
when(encoder.encode(any())).thenReturn(first, second);
|
||||
@@ -117,7 +117,7 @@ class OverseasTokenServiceTest {
|
||||
AuthAccount account = new AuthAccount();
|
||||
account.setId(44L); account.setAccountStatus("ACTIVE");
|
||||
AuthorizationSnapshot unbound = new AuthorizationSnapshot(44L, 0L, 86L, "PLATFORM", "PDA",
|
||||
Set.of("WAREHOUSE_PICKER"), Set.of("lastleg:pda:operate"), 10L, null);
|
||||
Set.of("OVERSEAS_WAREHOUSE_PDA_OPERATOR"), Set.of("lastleg:pda:operate"), 10L, null);
|
||||
Jwt encoded = org.mockito.Mockito.mock(Jwt.class);
|
||||
when(encoder.encode(any())).thenReturn(encoded);
|
||||
when(encoded.getTokenValue()).thenReturn("overseas-unbound");
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.baiyue.common.event;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
/** Canonical physical container number used by cross-region business facts. */
|
||||
public final class ContainerNumbers {
|
||||
private static final int[] LETTER_VALUES = {
|
||||
10, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 23, 24,
|
||||
25, 26, 27, 28, 29, 30, 31, 32, 34, 35, 36, 37, 38
|
||||
};
|
||||
|
||||
private ContainerNumbers() { }
|
||||
|
||||
public static String normalizeRequired(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new IllegalArgumentException("containerNo is required");
|
||||
}
|
||||
StringBuilder compact = new StringBuilder(value.length());
|
||||
value.codePoints().filter(codePoint -> !Character.isWhitespace(codePoint)
|
||||
&& !Character.isSpaceChar(codePoint)).forEach(compact::appendCodePoint);
|
||||
String normalized = compact.toString().toUpperCase(Locale.ROOT);
|
||||
if (!normalized.matches("[A-Z]{3}[UJZ][0-9]{7}")) {
|
||||
throw new IllegalArgumentException("containerNo must comply with ISO 6346");
|
||||
}
|
||||
int sum = 0;
|
||||
for (int index = 0; index < 10; index++) {
|
||||
char current = normalized.charAt(index);
|
||||
int number = Character.isDigit(current) ? current - '0' : LETTER_VALUES[current - 'A'];
|
||||
sum += number * (1 << index);
|
||||
}
|
||||
if (normalized.charAt(10) - '0' != sum % 11 % 10) {
|
||||
throw new IllegalArgumentException("containerNo must comply with ISO 6346 check digit");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
public static String requireCanonical(String value) {
|
||||
String normalized = normalizeRequired(value);
|
||||
if (!normalized.equals(value)) {
|
||||
throw new IllegalArgumentException("containerNo must be normalized");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -1,5 +1,6 @@
|
||||
package com.baiyue.common.event;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
|
||||
@@ -8,7 +9,7 @@ public record FirstLegContainerShippingNodeRecordedV1(
|
||||
String queryId,
|
||||
String customerId,
|
||||
String oceanBookingId,
|
||||
String containerBookingId,
|
||||
@JsonProperty("sourceContainerBookingId") String containerBookingId,
|
||||
String containerNo,
|
||||
String standardNode,
|
||||
int nodeSequence,
|
||||
@@ -34,6 +35,7 @@ public record FirstLegContainerShippingNodeRecordedV1(
|
||||
|| containerNo == null || containerNo.isBlank()) {
|
||||
throw new IllegalArgumentException("船节点交接业务标识不能为空");
|
||||
}
|
||||
containerNo = ContainerNumbers.requireCanonical(containerNo);
|
||||
if (standardNode == null || !Integer.valueOf(nodeSequence).equals(STANDARD_NODES.get(standardNode))
|
||||
|| observedAt == null || actualAt == null) {
|
||||
throw new IllegalArgumentException("船节点交接只接受带实际时间的标准节点事实");
|
||||
|
||||
+23
-5
@@ -7,7 +7,14 @@ import java.util.List;
|
||||
public record FirstLegLoadingPlanCompletedV1(
|
||||
String loadingPlanId, String planNo, String containerBookingId,
|
||||
String containerNo, String cabinetNo, String warehouseId, String warehouseName,
|
||||
String estimatedPortArrivalAt, String actualPortArrivalAt, String estimatedWarehouseArrivalAt,
|
||||
List<Order> orders) {
|
||||
public FirstLegLoadingPlanCompletedV1(String loadingPlanId, String planNo, String containerBookingId,
|
||||
String containerNo, String cabinetNo, String warehouseId,
|
||||
String warehouseName, List<Order> orders) {
|
||||
this(loadingPlanId, planNo, containerBookingId, containerNo, cabinetNo, warehouseId,
|
||||
warehouseName, null, null, null, orders);
|
||||
}
|
||||
|
||||
public record Order(
|
||||
String sourceInboundId, String rootInboundId,
|
||||
@@ -16,10 +23,17 @@ public record FirstLegLoadingPlanCompletedV1(
|
||||
String destinationWarehouseCode, String destinationWarehouseName,
|
||||
int packageCount, Integer productQuantity, Integer palletCount,
|
||||
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
|
||||
List<Item> items, String customerId, String orderType, List<PalletSnapshot> pallets,
|
||||
BigDecimal volumeWeightKg,
|
||||
List<Item> items, String customerId, String customerName, String orderType, List<PalletSnapshot> pallets,
|
||||
String destinationType) {
|
||||
public Order {
|
||||
items = items == null ? List.of() : items;
|
||||
if (customerId == null || customerId.isBlank()) {
|
||||
throw new IllegalArgumentException("customerId is required");
|
||||
}
|
||||
if (customerName == null || customerName.isBlank()) {
|
||||
throw new IllegalArgumentException("customerName is required");
|
||||
}
|
||||
orderType = orderType == null || orderType.isBlank() ? "FIRST_LEG" : orderType;
|
||||
pallets = pallets == null ? List.of() : pallets;
|
||||
}
|
||||
@@ -29,11 +43,13 @@ public record FirstLegLoadingPlanCompletedV1(
|
||||
String destinationWarehouseCode, String destinationWarehouseName,
|
||||
int packageCount, Integer productQuantity, Integer palletCount,
|
||||
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
|
||||
List<Item> items, String customerId, String orderType, List<PalletSnapshot> pallets) {
|
||||
List<Item> items, String customerId, String customerName,
|
||||
String orderType, List<PalletSnapshot> pallets) {
|
||||
this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId,
|
||||
inboundCode, destinationWarehouseId, destinationWarehouseCode,
|
||||
destinationWarehouseName, packageCount, productQuantity, palletCount,
|
||||
grossWeightKg, netWeightKg, volumeCbm, items, customerId, orderType, pallets, null);
|
||||
grossWeightKg, netWeightKg, volumeCbm, null, items, customerId, customerName,
|
||||
orderType, pallets, null);
|
||||
}
|
||||
public Order(String sourceInboundId, String rootInboundId,
|
||||
String sourceOrderId, String sourceOrderNo, String rootOrderId,
|
||||
@@ -41,11 +57,13 @@ public record FirstLegLoadingPlanCompletedV1(
|
||||
String destinationWarehouseCode, String destinationWarehouseName,
|
||||
int packageCount, Integer productQuantity, Integer palletCount,
|
||||
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
|
||||
List<Item> items) {
|
||||
List<Item> items, String customerId, String customerName,
|
||||
String orderType, List<PalletSnapshot> pallets, String destinationType) {
|
||||
this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId,
|
||||
inboundCode, destinationWarehouseId, destinationWarehouseCode,
|
||||
destinationWarehouseName, packageCount, productQuantity, palletCount,
|
||||
grossWeightKg, netWeightKg, volumeCbm, items, null, "FIRST_LEG", List.of(), null);
|
||||
grossWeightKg, netWeightKg, volumeCbm, null, items, customerId, customerName,
|
||||
orderType, pallets, destinationType);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+47
-6
@@ -1,13 +1,38 @@
|
||||
package com.baiyue.common.event;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
|
||||
/** 装柜完成交接快照,包含三类逐货物备注、推荐托数和可复用的头程托盘编号快照。 */
|
||||
public record FirstLegLoadingPlanCompletedV2(
|
||||
String loadingPlanId, String planNo, String containerBookingId,
|
||||
String loadingPlanId, String planNo,
|
||||
@JsonProperty("sourceContainerBookingId") String containerBookingId,
|
||||
String containerNo, String cabinetNo, String warehouseId, String warehouseName,
|
||||
String estimatedPortArrivalAt, String actualPortArrivalAt, String estimatedWarehouseArrivalAt,
|
||||
List<Order> orders) {
|
||||
public FirstLegLoadingPlanCompletedV2 {
|
||||
if (loadingPlanId == null || loadingPlanId.isBlank()
|
||||
|| containerBookingId == null || containerBookingId.isBlank()) {
|
||||
throw new IllegalArgumentException("loadingPlanId and sourceContainerBookingId are required");
|
||||
}
|
||||
containerNo = ContainerNumbers.requireCanonical(containerNo);
|
||||
orders = orders == null ? List.of() : List.copyOf(orders);
|
||||
if (orders.isEmpty()) throw new IllegalArgumentException("orders are required");
|
||||
Order owner = orders.get(0);
|
||||
if (orders.stream().anyMatch(order -> !owner.customerId().equals(order.customerId())
|
||||
|| !owner.customerName().equals(order.customerName()))) {
|
||||
throw new IllegalArgumentException("orders must share one immutable customerId/customerName snapshot");
|
||||
}
|
||||
}
|
||||
|
||||
public FirstLegLoadingPlanCompletedV2(String loadingPlanId, String planNo, String containerBookingId,
|
||||
String containerNo, String cabinetNo, String warehouseId,
|
||||
String warehouseName, List<Order> orders) {
|
||||
this(loadingPlanId, planNo, containerBookingId, containerNo, cabinetNo, warehouseId,
|
||||
warehouseName, null, null, null, orders);
|
||||
}
|
||||
|
||||
public record Order(
|
||||
String sourceInboundId, String rootInboundId,
|
||||
String sourceOrderId, String sourceOrderNo, String rootOrderId,
|
||||
@@ -15,11 +40,23 @@ public record FirstLegLoadingPlanCompletedV2(
|
||||
String destinationWarehouseCode, String destinationWarehouseName,
|
||||
int packageCount, Integer productQuantity, Integer palletCount,
|
||||
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
|
||||
List<Item> items, String customerId, String orderType, List<PalletSnapshot> pallets,
|
||||
BigDecimal volumeWeightKg,
|
||||
List<Item> items, String customerId, String customerName, String orderType, List<PalletSnapshot> pallets,
|
||||
/** 目的类型:FBA 可按箱数收货,OVERSEAS 必须逐箱扫码。 */
|
||||
String destinationType) {
|
||||
public Order {
|
||||
items = items == null ? List.of() : items;
|
||||
if (customerId == null || customerId.isBlank()) {
|
||||
throw new IllegalArgumentException("customerId is required");
|
||||
}
|
||||
if (customerName == null || customerName.isBlank()) {
|
||||
throw new IllegalArgumentException("customerName is required");
|
||||
}
|
||||
customerId = customerId.trim();
|
||||
customerName = customerName.trim();
|
||||
if (customerId.length() > 128 || customerName.length() > 128) {
|
||||
throw new IllegalArgumentException("customerId/customerName snapshot is too long");
|
||||
}
|
||||
orderType = orderType == null || orderType.isBlank() ? "FIRST_LEG" : orderType;
|
||||
pallets = pallets == null ? List.of() : pallets;
|
||||
}
|
||||
@@ -30,11 +67,13 @@ public record FirstLegLoadingPlanCompletedV2(
|
||||
String destinationWarehouseCode, String destinationWarehouseName,
|
||||
int packageCount, Integer productQuantity, Integer palletCount,
|
||||
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
|
||||
List<Item> items, String customerId, String orderType, List<PalletSnapshot> pallets) {
|
||||
List<Item> items, String customerId, String customerName,
|
||||
String orderType, List<PalletSnapshot> pallets) {
|
||||
this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId,
|
||||
inboundCode, destinationWarehouseId, destinationWarehouseCode,
|
||||
destinationWarehouseName, packageCount, productQuantity, palletCount,
|
||||
grossWeightKg, netWeightKg, volumeCbm, items, customerId, orderType, pallets, null);
|
||||
grossWeightKg, netWeightKg, volumeCbm, null, items, customerId, customerName,
|
||||
orderType, pallets, null);
|
||||
}
|
||||
|
||||
public Order(String sourceInboundId, String rootInboundId,
|
||||
@@ -43,11 +82,13 @@ public record FirstLegLoadingPlanCompletedV2(
|
||||
String destinationWarehouseCode, String destinationWarehouseName,
|
||||
int packageCount, Integer productQuantity, Integer palletCount,
|
||||
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
|
||||
List<Item> items) {
|
||||
List<Item> items, String customerId, String customerName,
|
||||
String orderType, List<PalletSnapshot> pallets, String destinationType) {
|
||||
this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId,
|
||||
inboundCode, destinationWarehouseId, destinationWarehouseCode,
|
||||
destinationWarehouseName, packageCount, productQuantity, palletCount,
|
||||
grossWeightKg, netWeightKg, volumeCbm, items, null, "FIRST_LEG", List.of(), null);
|
||||
grossWeightKg, netWeightKg, volumeCbm, null, items, customerId, customerName,
|
||||
orderType, pallets, destinationType);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+6
@@ -13,4 +13,10 @@ public record OverseasContainerUnloadedV1(
|
||||
Instant unloadingPhotoUploadedAt,
|
||||
Instant actualEmptyReportedAt
|
||||
) {
|
||||
public OverseasContainerUnloadedV1 {
|
||||
if (sourceContainerBookingId == null || sourceContainerBookingId.isBlank()) {
|
||||
throw new IllegalArgumentException("sourceContainerBookingId is required");
|
||||
}
|
||||
containerNo = ContainerNumbers.requireCanonical(containerNo);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: Baiyue Cross-region Event Contracts
|
||||
version: 1.0.0
|
||||
paths: {}
|
||||
components:
|
||||
schemas:
|
||||
FirstLegLoadingPlanCompletedV2:
|
||||
type: object
|
||||
required: [loadingPlanId, sourceContainerBookingId, containerNo, estimatedPortArrivalAt, actualPortArrivalAt, estimatedWarehouseArrivalAt, orders]
|
||||
properties:
|
||||
loadingPlanId: {type: string}
|
||||
sourceContainerBookingId: {type: string, minLength: 1, description: Immutable Firstleg container booking identity}
|
||||
containerNo: {type: string, minLength: 1, description: Uppercase normalized physical container number frozen before loading completion publication}
|
||||
cabinetNo: {type: string}
|
||||
warehouseId: {type: string}
|
||||
warehouseName: {type: string}
|
||||
estimatedPortArrivalAt: {type: [string, 'null'], format: local-date-time, description: Frozen source-local estimated port arrival; null when Firstleg has no fact}
|
||||
actualPortArrivalAt: {type: [string, 'null'], format: local-date-time, description: Frozen source-local actual port arrival; never copied from an estimate}
|
||||
estimatedWarehouseArrivalAt: {type: [string, 'null'], format: local-date-time, description: Frozen source-local estimated warehouse arrival; null when Firstleg has no fact}
|
||||
orders:
|
||||
type: array
|
||||
items: {$ref: '#/components/schemas/FirstLegLoadingPlanCompletedOrderV2'}
|
||||
FirstLegLoadingPlanCompletedOrderV2:
|
||||
type: object
|
||||
required: [sourceInboundId, customerId, customerName, palletCount, packageCount, productQuantity, volumeCbm, volumeWeightKg, items, orderType, pallets, destinationType]
|
||||
properties:
|
||||
sourceInboundId: {type: string}
|
||||
sourceOrderId: {type: string}
|
||||
sourceOrderNo: {type: string}
|
||||
inboundCode: {type: string}
|
||||
customerId: {type: string, minLength: 1, description: Trusted customer identity frozen by Firstleg}
|
||||
customerName: {type: string, minLength: 1, maxLength: 128, description: Immutable trusted container/customer name snapshot; consumers must not query domestic IAM}
|
||||
packageCount: {type: integer, minimum: 1}
|
||||
productQuantity: {type: [integer, 'null'], minimum: 0, description: Final loaded product quantity; null when unavailable}
|
||||
palletCount: {type: [integer, 'null'], minimum: 1, description: Distinct physical pallet count from Firstleg allocation pallet identity; never a planned zero placeholder}
|
||||
volumeCbm: {type: [number, 'null'], minimum: 0, description: Final loaded volume in cubic metres}
|
||||
volumeWeightKg: {type: [number, 'null'], minimum: 0, description: Sum of final loaded item dimensional weight facts; null when unavailable}
|
||||
items: {type: array, items: {type: object}}
|
||||
orderType: {type: string, enum: [FIRST_LEG, ONE_CLICK_DROPSHIP]}
|
||||
pallets: {type: array, items: {type: object}}
|
||||
destinationType: {type: string, enum: [FBA, OVERSEAS]}
|
||||
FirstLegContainerShippingNodeRecordedV1:
|
||||
type: object
|
||||
required: [queryId, customerId, oceanBookingId, sourceContainerBookingId, containerNo, standardNode, nodeSequence, observedAt, actualAt, providerCode, providerEventId, providerNodeCode, rawPayloadHash]
|
||||
properties:
|
||||
queryId: {type: string}
|
||||
customerId: {type: string}
|
||||
oceanBookingId: {type: string}
|
||||
sourceContainerBookingId: {type: string, minLength: 1}
|
||||
containerNo: {type: string, minLength: 1, description: Uppercase normalized physical container number; never cabinetNo or a technical identity}
|
||||
standardNode: {type: string}
|
||||
nodeSequence: {type: integer}
|
||||
observedAt: {type: string, format: date-time}
|
||||
actualAt: {type: string, format: date-time}
|
||||
providerCode: {type: string}
|
||||
providerEventId: {type: string}
|
||||
providerNodeCode: {type: string}
|
||||
rawPayloadHash: {type: string, pattern: '^[0-9a-fA-F]{64}$'}
|
||||
OverseasContainerUnloadedV1:
|
||||
type: object
|
||||
required: [sourceContainerBookingId, containerNo, unloadingCompletedAt, unloadingPhotoUploadedAt]
|
||||
properties:
|
||||
sourceContainerBookingId: {type: string, minLength: 1}
|
||||
containerNo: {type: string, minLength: 1, description: Must equal the immutable normalized Firstleg container number; the CN consumer never overwrites it}
|
||||
unloadingCompletedAt: {type: string, format: date-time}
|
||||
unloadingPhotoUploadedAt: {type: string, format: date-time}
|
||||
actualEmptyReportedAt: {type: [string, 'null'], format: date-time}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.baiyue.common.event;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class ContainerNumbersTest {
|
||||
@Test
|
||||
void normalizesWhitespaceAndCaseButCanonicalFactsRequireExactIso6346Value() {
|
||||
assertThat(ContainerNumbers.normalizeRequired(" zcsu 7881970 ")).isEqualTo("ZCSU7881970");
|
||||
assertThat(ContainerNumbers.requireCanonical("ZCSU7881970")).isEqualTo("ZCSU7881970");
|
||||
assertThatThrownBy(() -> ContainerNumbers.requireCanonical("zcsu7881970"))
|
||||
.hasMessageContaining("normalized");
|
||||
assertThatThrownBy(() -> ContainerNumbers.normalizeRequired("ZCSU7881971"))
|
||||
.hasMessageContaining("check digit");
|
||||
assertThatThrownBy(() -> ContainerNumbers.normalizeRequired("CAB-260831-0001"))
|
||||
.hasMessageContaining("ISO 6346");
|
||||
}
|
||||
}
|
||||
+12
@@ -21,6 +21,18 @@ class FirstLegContainerShippingNodeRecordedV1Test {
|
||||
assertThat(restored.containerBookingId()).isEqualTo("2088000000000000003");
|
||||
assertThat(restored.standardNode()).isEqualTo("PICKUP_READY");
|
||||
assertThat(restored.nodeSequence()).isEqualTo(80);
|
||||
var json = mapper.valueToTree(payload);
|
||||
assertThat(json.path("sourceContainerBookingId").asText()).isEqualTo("2088000000000000003");
|
||||
assertThat(json.has("containerBookingId")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsNonCanonicalPhysicalContainerNumber() {
|
||||
assertThatThrownBy(() -> new FirstLegContainerShippingNodeRecordedV1(
|
||||
"1", "2", "3", "4", " zcsu7881970 ", "PICKUP_READY", 80,
|
||||
Instant.parse("2026-08-25T01:00:00Z"), Instant.parse("2026-08-25T01:00:00Z"),
|
||||
null, null, "ZIM", "evt", "AVAILABLE", "ZIM:evt", "a".repeat(64)))
|
||||
.hasMessageContaining("normalized");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+12
-4
@@ -19,7 +19,7 @@ class FirstLegLoadingPlanCompletedV1Test {
|
||||
var order = new FirstLegLoadingPlanCompletedV1.Order("2088000000000000003", "2088000000000000004",
|
||||
"2088000000000000005", "FL-1", "2088000000000000006", "RK-1", "2088000000000000002",
|
||||
"ONT8", "ONT8", 2, 20, 1, new BigDecimal("10.500"), new BigDecimal("9.500"),
|
||||
new BigDecimal("0.200"), List.of(item));
|
||||
new BigDecimal("0.200"), List.of(item), "9001", "客户甲", "FIRST_LEG", List.of());
|
||||
var payload = new FirstLegLoadingPlanCompletedV1("2088000000000000007", "P-1", "2088000000000000008",
|
||||
"CONT-1", "CAB-1", "2088000000000000009", "中转仓", List.of(order));
|
||||
String json = mapper.writeValueAsString(payload);
|
||||
@@ -35,7 +35,7 @@ class FirstLegLoadingPlanCompletedV1Test {
|
||||
"SKU-2", "产品2", 1, 5, List.of("BOX-003"));
|
||||
var order = new FirstLegLoadingPlanCompletedV1.Order("100", "100", "200", "FL-200", "200",
|
||||
"RK-100", "300", "ONT8", "ONT8", 3, 25, 2, BigDecimal.ONE,
|
||||
BigDecimal.ONE, BigDecimal.ONE, List.of(), "9001", "ONE_CLICK_DROPSHIP",
|
||||
BigDecimal.ONE, BigDecimal.ONE, List.of(), "9001", "客户甲", "ONE_CLICK_DROPSHIP",
|
||||
List.of(new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-01", List.of(sku1)),
|
||||
new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-02", List.of(sku2))));
|
||||
var payload = new FirstLegLoadingPlanCompletedV1("1", "LP-1", "2", "CONT-1", null,
|
||||
@@ -52,12 +52,20 @@ class FirstLegLoadingPlanCompletedV1Test {
|
||||
void legacyParserRetainsDestinationTypeWhenV2OrderCarriesIt() throws Exception {
|
||||
String json = """
|
||||
{"loadingPlanId":"1","planNo":"LP-1","containerBookingId":"2","containerNo":"CONT-1",
|
||||
"warehouseId":"3","warehouseName":"海外仓","orders":[{"sourceInboundId":"100",
|
||||
"destinationWarehouseId":"3","packageCount":1,"items":[],"destinationType":"OVERSEAS"}]}
|
||||
"warehouseId":"3","warehouseName":"海外仓","estimatedPortArrivalAt":"2026-09-30T12:00",
|
||||
"actualPortArrivalAt":null,"estimatedWarehouseArrivalAt":"2026-10-03T09:00",
|
||||
"orders":[{"sourceInboundId":"100","destinationWarehouseId":"3","packageCount":1,
|
||||
"palletCount":2,"volumeWeightKg":12.500,"items":[],"customerId":"9001",
|
||||
"customerName":"客户甲","destinationType":"OVERSEAS"}]}
|
||||
""";
|
||||
|
||||
FirstLegLoadingPlanCompletedV1 restored = mapper.readValue(json, FirstLegLoadingPlanCompletedV1.class);
|
||||
|
||||
assertThat(restored.orders().get(0).destinationType()).isEqualTo("OVERSEAS");
|
||||
assertThat(restored.estimatedPortArrivalAt()).isEqualTo("2026-09-30T12:00");
|
||||
assertThat(restored.actualPortArrivalAt()).isNull();
|
||||
assertThat(restored.estimatedWarehouseArrivalAt()).isEqualTo("2026-10-03T09:00");
|
||||
assertThat(restored.orders().get(0).palletCount()).isEqualTo(2);
|
||||
assertThat(restored.orders().get(0).volumeWeightKg()).isEqualByComparingTo("12.500");
|
||||
}
|
||||
}
|
||||
|
||||
+93
-8
@@ -4,18 +4,59 @@ import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class FirstLegLoadingPlanCompletedV2Test {
|
||||
@Test
|
||||
void carriesOnlyFrozenContainerTimesAndActualOrderAggregates() {
|
||||
var order = new FirstLegLoadingPlanCompletedV2.Order(
|
||||
"2", "2", "3", "ORD", "3", "RK", null, "ONT8", "ONT8",
|
||||
12, 120, 3, null, null, new BigDecimal("2.500"), new BigDecimal("18.750"),
|
||||
List.of(), "11", "客户甲", "FIRST_LEG", List.of(), "OVERSEAS");
|
||||
var payload = new FirstLegLoadingPlanCompletedV2(
|
||||
"4", "P", "5", "ZCSU7881970", "CAB", "6", "WH",
|
||||
"2026-09-30T12:00", null, "2026-10-03T09:00", List.of(order));
|
||||
|
||||
var json = new ObjectMapper().valueToTree(payload);
|
||||
|
||||
assertThat(json.at("/estimatedPortArrivalAt").asText()).isEqualTo("2026-09-30T12:00");
|
||||
assertThat(json.at("/actualPortArrivalAt").isNull()).isTrue();
|
||||
assertThat(json.at("/estimatedWarehouseArrivalAt").asText()).isEqualTo("2026-10-03T09:00");
|
||||
assertThat(json.at("/sourceContainerBookingId").asText()).isEqualTo("5");
|
||||
assertThat(json.has("containerBookingId")).isFalse();
|
||||
assertThat(json.at("/orders/0/palletCount").asInt()).isEqualTo(3);
|
||||
assertThat(json.at("/orders/0/packageCount").asInt()).isEqualTo(12);
|
||||
assertThat(json.at("/orders/0/productQuantity").asInt()).isEqualTo(120);
|
||||
assertThat(json.at("/orders/0/volumeCbm").decimalValue()).isEqualByComparingTo("2.500");
|
||||
assertThat(json.at("/orders/0/volumeWeightKg").decimalValue()).isEqualByComparingTo("18.750");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsNonCanonicalOrMissingPhysicalContainerIdentity() {
|
||||
var order = new FirstLegLoadingPlanCompletedV2.Order(
|
||||
"2", "2", "3", "ORD", "3", "RK", null, "ONT8", "ONT8",
|
||||
1, 1, null, null, null, null, List.of(), "11", "客户甲", "FIRST_LEG", List.of());
|
||||
assertThatThrownBy(() -> new FirstLegLoadingPlanCompletedV2(
|
||||
"4", "P", "5", " zcsu7881970 ", "CAB", "6", "WH", List.of(order)))
|
||||
.hasMessageContaining("normalized");
|
||||
assertThatThrownBy(() -> new FirstLegLoadingPlanCompletedV2(
|
||||
"4", "P", "5", " ", "CAB", "6", "WH", List.of(order)))
|
||||
.hasMessageContaining("containerNo");
|
||||
}
|
||||
|
||||
@Test
|
||||
void carriesTheThreeItemRemarks() throws Exception {
|
||||
var item = new FirstLegLoadingPlanCompletedV2.Item("1", "SKU", "中文", "EN", 1,
|
||||
2, null, null, null, null, null, null, null, null, null, null, null,
|
||||
"客户备注", "内部备注", "仓库备注");
|
||||
var order = new FirstLegLoadingPlanCompletedV2.Order("2", "2", "3", "ORD", "3", "RK",
|
||||
null, null, null, 1, 2, null, null, null, null, List.of(item));
|
||||
var payload = new FirstLegLoadingPlanCompletedV2("4", "P", "5", "CONT", "CAB", "6", "WH", List.of(order));
|
||||
null, null, null, 1, 2, null, null, null, null, List.of(item),
|
||||
"11", "客户甲", "FIRST_LEG", List.of());
|
||||
var payload = new FirstLegLoadingPlanCompletedV2("4", "P", "5", "ZCSU7881970", "CAB", "6", "WH", List.of(order));
|
||||
String json = new ObjectMapper().writeValueAsString(payload);
|
||||
assertThat(json).contains("customerRemark", "internalRemark", "warehouseRemark");
|
||||
assertThat(new ObjectMapper().readTree(json).at("/orders/0/items/0/warehouseRemark").asText())
|
||||
@@ -29,13 +70,14 @@ class FirstLegLoadingPlanCompletedV2Test {
|
||||
var pallet = new FirstLegLoadingPlanCompletedV2.PalletSnapshot("PLT-01", List.of(sku));
|
||||
var order = new FirstLegLoadingPlanCompletedV2.Order(
|
||||
"2", "2", "3", "ORD", "3", "RK", null, null, null,
|
||||
2, 20, 1, null, null, null, List.of(), "11", "ONE_CLICK_DROPSHIP", List.of(pallet));
|
||||
2, 20, 1, null, null, null, List.of(), "11", "客户甲", "ONE_CLICK_DROPSHIP", List.of(pallet));
|
||||
var payload = new FirstLegLoadingPlanCompletedV2(
|
||||
"4", "P", "5", "CONT", "CAB", "6", "WH", List.of(order));
|
||||
"4", "P", "5", "ZCSU7881970", "CAB", "6", "WH", List.of(order));
|
||||
|
||||
var json = new ObjectMapper().valueToTree(payload);
|
||||
|
||||
assertThat(json.at("/orders/0/customerId").asText()).isEqualTo("11");
|
||||
assertThat(json.at("/orders/0/customerName").asText()).isEqualTo("客户甲");
|
||||
assertThat(json.at("/orders/0/orderType").asText()).isEqualTo("ONE_CLICK_DROPSHIP");
|
||||
assertThat(json.at("/orders/0/pallets/0/skuPackages/0/cartonMarks")).hasSize(2);
|
||||
}
|
||||
@@ -44,10 +86,10 @@ class FirstLegLoadingPlanCompletedV2Test {
|
||||
void carriesFrozenDestinationType() {
|
||||
var order = new FirstLegLoadingPlanCompletedV2.Order(
|
||||
"2", "2", "3", "ORD", "3", "RK", null, "ONT8", "FBA ONT8",
|
||||
2, 20, 0, null, null, null, List.of(), "11", "FIRST_LEG", List.of(), "FBA");
|
||||
2, 20, 0, null, null, null, List.of(), "11", "客户甲", "FIRST_LEG", List.of(), "FBA");
|
||||
|
||||
var json = new ObjectMapper().valueToTree(new FirstLegLoadingPlanCompletedV2(
|
||||
"4", "P", "5", "CONT", "CAB", "6", "WH", List.of(order)));
|
||||
"4", "P", "5", "ZCSU7881970", "CAB", "6", "WH", List.of(order)));
|
||||
|
||||
assertThat(json.at("/orders/0/destinationType").asText()).isEqualTo("FBA");
|
||||
}
|
||||
@@ -58,12 +100,55 @@ class FirstLegLoadingPlanCompletedV2Test {
|
||||
20, null, null, null, null, null, null, null, null, null, null, null,
|
||||
null, null, null, null, List.of("HDHK260711126-01", "HDHK260711126-02"));
|
||||
var order = new FirstLegLoadingPlanCompletedV2.Order("2", "2", "3", "ORD", "3", "RK",
|
||||
null, null, null, 2, 20, null, null, null, null, List.of(item));
|
||||
null, null, null, 2, 20, null, null, null, null, List.of(item),
|
||||
"11", "客户甲", "FIRST_LEG", List.of());
|
||||
|
||||
var json = new ObjectMapper().valueToTree(new FirstLegLoadingPlanCompletedV2(
|
||||
"4", "P", "5", "CONT", "CAB", "6", "WH", List.of(order)));
|
||||
"4", "P", "5", "ZCSU7881970", "CAB", "6", "WH", List.of(order)));
|
||||
|
||||
assertThat(json.at("/orders/0/items/0/skuCode").asText()).isEqualTo("SKU-PSC2");
|
||||
assertThat(json.at("/orders/0/items/0/cartonMarks")).hasSize(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMissingImmutableCustomerName() {
|
||||
assertThatThrownBy(() -> new FirstLegLoadingPlanCompletedV2.Order(
|
||||
"2", "2", "3", "ORD", "3", "RK", null, null, null,
|
||||
1, 1, 0, null, null, null, List.of(), "11", " ", "FIRST_LEG", List.of()))
|
||||
.isInstanceOf(IllegalArgumentException.class).hasMessageContaining("customerName");
|
||||
}
|
||||
|
||||
@Test
|
||||
void freezesTrimmedOwnerSnapshotAndRejectsMixedOwners() {
|
||||
var first = new FirstLegLoadingPlanCompletedV2.Order(
|
||||
"2", "2", "3", "ORD", "3", "RK", null, null, null,
|
||||
1, 1, 0, null, null, null, List.of(), " 11 ", " 客户甲 ", "FIRST_LEG", List.of());
|
||||
assertThat(first.customerId()).isEqualTo("11");
|
||||
assertThat(first.customerName()).isEqualTo("客户甲");
|
||||
|
||||
var anotherOwner = new FirstLegLoadingPlanCompletedV2.Order(
|
||||
"3", "3", "4", "ORD-2", "4", "RK-2", null, null, null,
|
||||
1, 1, 0, null, null, null, List.of(), "12", "客户乙", "FIRST_LEG", List.of());
|
||||
assertThatThrownBy(() -> new FirstLegLoadingPlanCompletedV2(
|
||||
"4", "P", "5", "ZCSU7881970", "CAB", "6", "WH", List.of(first, anotherOwner)))
|
||||
.isInstanceOf(IllegalArgumentException.class).hasMessageContaining("immutable customerId/customerName");
|
||||
}
|
||||
|
||||
@Test
|
||||
void openApiRequiresImmutableCustomerNameNextToCustomerId() throws Exception {
|
||||
String yaml;
|
||||
try (var input = FirstLegLoadingPlanCompletedV2Test.class.getResourceAsStream(
|
||||
"/openapi/cross-region-events-v1.yaml")) {
|
||||
assertThat(input).isNotNull();
|
||||
yaml = new String(input.readAllBytes(), StandardCharsets.UTF_8);
|
||||
}
|
||||
assertThat(yaml)
|
||||
.contains("required: [sourceInboundId, customerId, customerName")
|
||||
.contains("customerId: {type: string, minLength: 1")
|
||||
.contains("customerName: {type: string, minLength: 1, maxLength: 128")
|
||||
.contains("consumers must not query domestic IAM")
|
||||
.contains("estimatedPortArrivalAt", "actualPortArrivalAt", "estimatedWarehouseArrivalAt")
|
||||
.contains("palletCount", "volumeWeightKg", "never a planned zero placeholder");
|
||||
assertThat(yaml).contains("sourceContainerBookingId", "Uppercase normalized physical container number");
|
||||
}
|
||||
}
|
||||
|
||||
+10
-2
@@ -7,20 +7,28 @@ import org.junit.jupiter.api.Test;
|
||||
import java.time.Instant;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class OverseasContainerUnloadedV1Test {
|
||||
private final ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule());
|
||||
|
||||
@Test
|
||||
void payloadRoundTripsWithStableContainerIdAndUtcFacts() throws Exception {
|
||||
var payload = new OverseasContainerUnloadedV1("1001", "MSCU1234567",
|
||||
var payload = new OverseasContainerUnloadedV1("1001", "ZCSU7881970",
|
||||
Instant.parse("2026-08-20T03:00:00Z"), Instant.parse("2026-08-20T02:30:00Z"), null);
|
||||
|
||||
String json = mapper.writeValueAsString(payload);
|
||||
|
||||
assertThat(mapper.readValue(json, OverseasContainerUnloadedV1.class)).isEqualTo(payload);
|
||||
assertThat(json).contains("sourceContainerBookingId", "1001", "MSCU1234567", "unloadingPhotoUploadedAt");
|
||||
assertThat(json).contains("sourceContainerBookingId", "1001", "ZCSU7881970", "unloadingPhotoUploadedAt");
|
||||
assertThat(payload.unloadingCompletedAt()).isAfter(payload.unloadingPhotoUploadedAt());
|
||||
assertThat(payload.unloadingCompletedAt()).isNotEqualTo(payload.unloadingPhotoUploadedAt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsContainerNumberDriftBeforeDomesticConsumption() {
|
||||
assertThatThrownBy(() -> new OverseasContainerUnloadedV1("1001", "zcsu7881970",
|
||||
Instant.parse("2026-08-20T03:00:00Z"), Instant.parse("2026-08-20T02:30:00Z"), null))
|
||||
.hasMessageContaining("normalized");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,10 @@
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework</groupId>
|
||||
<artifactId>spring-tx</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.security</groupId>
|
||||
<artifactId>spring-security-core</artifactId>
|
||||
|
||||
@@ -15,6 +15,7 @@ import org.springframework.security.core.AuthenticationException;
|
||||
import org.springframework.validation.BindException;
|
||||
import org.springframework.validation.FieldError;
|
||||
import org.springframework.http.converter.HttpMessageNotReadableException;
|
||||
import org.springframework.dao.CannotAcquireLockException;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
import org.springframework.web.bind.MissingRequestHeaderException;
|
||||
import org.springframework.web.bind.MissingServletRequestParameterException;
|
||||
@@ -23,6 +24,7 @@ import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
import org.springframework.web.servlet.resource.NoResourceFoundException;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import org.springframework.web.multipart.MaxUploadSizeExceededException;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
@@ -96,6 +98,24 @@ public class GlobalExceptionHandler {
|
||||
return failure(ResultCode.NOT_FOUND, ResultCode.NOT_FOUND.message(), request);
|
||||
}
|
||||
|
||||
@ExceptionHandler(CannotAcquireLockException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleCannotAcquireLock(
|
||||
CannotAcquireLockException exception, HttpServletRequest request) {
|
||||
log.warn("Database lock acquisition conflict requestId={} traceId={}",
|
||||
CorrelationContext.requestId(request), CorrelationContext.traceId(request));
|
||||
return failure(ResultCode.CONFLICT, "资源正在处理中,请稍后重试", request);
|
||||
}
|
||||
|
||||
@ExceptionHandler(MaxUploadSizeExceededException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleMaxUploadSize(
|
||||
MaxUploadSizeExceededException exception, HttpServletRequest request) {
|
||||
long maxBytes = exception.getMaxUploadSize();
|
||||
String message = maxBytes > 0
|
||||
? "上传文件不能超过" + Math.max(1, (maxBytes + 1024 * 1024 - 1) / (1024 * 1024)) + "MB"
|
||||
: "上传文件超过服务允许大小";
|
||||
return failure(ResultCode.BAD_REQUEST, message, request);
|
||||
}
|
||||
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleUnexpected(Exception exception, HttpServletRequest request) {
|
||||
log.error("Unhandled request failure requestId={} traceId={}",
|
||||
|
||||
@@ -16,6 +16,8 @@ import org.springframework.web.servlet.resource.NoResourceFoundException;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import org.springframework.dao.CannotAcquireLockException;
|
||||
import org.springframework.web.multipart.MaxUploadSizeExceededException;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
|
||||
@@ -116,6 +118,32 @@ class GlobalExceptionHandlerTest {
|
||||
assertThat(response.getBody().code()).isEqualTo(ResultCode.NOT_FOUND.code());
|
||||
}
|
||||
|
||||
@Test
|
||||
void lockWaitTimeoutMapsToCanonicalConflict() {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
request.setAttribute(CorrelationContext.REQUEST_ID, "lock-timeout");
|
||||
|
||||
var response = handler.handleCannotAcquireLock(
|
||||
new CannotAcquireLockException("Lock wait timeout exceeded"), request);
|
||||
|
||||
assertThat(response.getBody().code()).isEqualTo(ResultCode.CONFLICT.code());
|
||||
assertThat(response.getBody().requestId()).isEqualTo("lock-timeout");
|
||||
}
|
||||
|
||||
@Test
|
||||
void oversizedMultipartReturnsClearBadRequestInsteadOfInternalError() {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
request.setAttribute(CorrelationContext.REQUEST_ID, "oversized-photo");
|
||||
|
||||
var response = handler.handleMaxUploadSize(
|
||||
new MaxUploadSizeExceededException(10L * 1024 * 1024), request);
|
||||
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||
assertThat(response.getBody().code()).isEqualTo(ResultCode.BAD_REQUEST.code());
|
||||
assertThat(response.getBody().message()).isEqualTo("上传文件不能超过10MB");
|
||||
assertThat(response.getBody().requestId()).isEqualTo("oversized-photo");
|
||||
}
|
||||
|
||||
private void paging(long page) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,16 @@ baiyue:
|
||||
max-attempts: 3
|
||||
batch-size: 500
|
||||
|
||||
LOADING_LIST_BUY_DECLARATION_ITEMS:
|
||||
provider-service: firstleg-service
|
||||
provider-path: /internal/exports/v1/LOADING_LIST_BUY_DECLARATION_ITEMS/batches
|
||||
required-permission: platform:firstleg:customs:manage
|
||||
app-scope: PLATFORM
|
||||
enabled: true
|
||||
max-rows: 200000
|
||||
max-attempts: 3
|
||||
batch-size: 500
|
||||
|
||||
FINANCE_CUSTOMER_ACCOUNT_LIST:
|
||||
provider-service: finance-service
|
||||
provider-path: /internal/exports/v1/FINANCE_CUSTOMER_ACCOUNT_LIST/batches
|
||||
|
||||
@@ -4,6 +4,10 @@ spring:
|
||||
username: ${FIRSTLEG_DB_USERNAME}
|
||||
password: ${FIRSTLEG_DB_PASSWORD}
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
servlet:
|
||||
multipart:
|
||||
max-file-size: ${FIRSTLEG_MULTIPART_MAX_FILE_SIZE:50MB}
|
||||
max-request-size: ${FIRSTLEG_MULTIPART_MAX_REQUEST_SIZE:52MB}
|
||||
baiyue.clients.warehouse.url: ${WAREHOUSE_SERVICE_URL:}
|
||||
baiyue.clients.order.url: ${ORDER_SERVICE_URL:}
|
||||
baiyue.clients.file.url: ${FILE_SERVICE_URL:}
|
||||
|
||||
@@ -18,6 +18,16 @@ baiyue:
|
||||
max-attempts: 3
|
||||
batch-size: 500
|
||||
|
||||
LOADING_LIST_BUY_DECLARATION_ITEMS:
|
||||
provider-service: firstleg-service
|
||||
provider-path: /internal/exports/v1/LOADING_LIST_BUY_DECLARATION_ITEMS/batches
|
||||
required-permission: platform:firstleg:customs:manage
|
||||
app-scope: PLATFORM
|
||||
enabled: true
|
||||
max-rows: 200000
|
||||
max-attempts: 3
|
||||
batch-size: 500
|
||||
|
||||
FINANCE_CUSTOMER_ACCOUNT_LIST:
|
||||
provider-service: finance-service
|
||||
provider-path: /internal/exports/v1/FINANCE_CUSTOMER_ACCOUNT_LIST/batches
|
||||
|
||||
@@ -3,6 +3,8 @@ 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}
|
||||
spring.servlet.multipart.max-file-size: ${FIRSTLEG_MULTIPART_MAX_FILE_SIZE:50MB}
|
||||
spring.servlet.multipart.max-request-size: ${FIRSTLEG_MULTIPART_MAX_REQUEST_SIZE:52MB}
|
||||
baiyue.clients.warehouse.url: ${WAREHOUSE_SERVICE_URL:}
|
||||
baiyue.clients.order.url: ${ORDER_SERVICE_URL:}
|
||||
baiyue.clients.file.url: ${FILE_SERVICE_URL:}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
spring:
|
||||
servlet:
|
||||
multipart:
|
||||
max-file-size: ${LASTLEG_MULTIPART_MAX_FILE_SIZE:10MB}
|
||||
max-request-size: ${LASTLEG_MULTIPART_MAX_REQUEST_SIZE:11MB}
|
||||
datasource:
|
||||
url: ${LASTLEG_DB_URL}
|
||||
username: ${LASTLEG_DB_USERNAME}
|
||||
|
||||
+15
-1
@@ -225,4 +225,18 @@ SELECT
|
||||
|
||||
- 固定入口已同步 `iam/src/main/resources/db/lastleg-permissions.sql` 的 `platform:overseas-*` 权限,并默认授予平台系统管理员。
|
||||
- 旧 `platform:lastleg:*` 权限会撤销角色关联并停用;不保留旧码兼容映射。
|
||||
- 本批不创建箱号、产品库、海外出库、退货出库或海外派送权限。
|
||||
- 四期目录保留海外出库、派送列表入口;五期写操作和客户派送权限由后续五期区块直接收敛,不使用旧码兼容映射。
|
||||
|
||||
## 五期海外排车、出库与派送权限
|
||||
|
||||
- 国内固定入口已在四期 `lastleg-permissions.sql` 权限段之后完整内联 `iam/src/main/resources/db/phase5-delivery-permissions.sql`;测试和部署只执行固定入口,不再单独执行模块脚本。
|
||||
- 平台端固定使用排车编辑/取消/转派送、出库列表/创建/取消和派送列表七项权限;列表权限包含详情只读,写操作保持独立授权。
|
||||
- 客户端固定使用 `merchant:warehouse:delivery:list` 和 `merchant:warehouse:delivery:create`;客户默认管理员获得两项权限,其他客户角色不自动扩权。
|
||||
- `WAREHOUSE_OPERATOR` 获得七项后台作业权限,`WAREHOUSE_TALLY` 只获得出库和派送列表只读;国内、海外 PDA 岗位不得获得上述后台权限。
|
||||
|
||||
## 五期海外排车与出库结构升级
|
||||
|
||||
- 五期数据库部署仍只执行 `海外区域统一增量-MySQL8.sql`;空库先执行 Lastleg 与 Warehouse Overseas 当前基线,再完整执行固定入口并确认最末 `migration_status = PASS`。
|
||||
- 固定入口只补目标字段并做 fail-fast 校验,不回填、改号、清理或从其他表推断开发旧行。`ll_dispatch_pda_task.warehouse_id` 存在 `NULL`、空值或 `0`,或任一待收敛全局业务号/任务号、完成幂等键发生冲突时,脚本立即 `SIGNAL`;应重新造数,重置或清理仍需用户另行授权。
|
||||
- 数据门禁通过后,脚本原位把 `dispatch_no`、`outbound_no`、排车 PDA `task_no`、出库 PDA `task_no` 收敛为全局唯一,并建立 `(customer_id, completion_idempotency_key)` 完成命令唯一约束;旧客户复合索引不保留。
|
||||
- 必须在启动或重启 Lastleg Service 前完成固定入口;不得单独执行模块基线补丁、日期脚本或手工兼容 SQL。
|
||||
|
||||
+823
-497
File diff suppressed because it is too large
Load Diff
@@ -400,7 +400,7 @@ CREATE TABLE IF NOT EXISTS ll_dispatch_order (
|
||||
shipped_package_count INT NOT NULL DEFAULT 0 COMMENT '累计已出库箱数', partial_completion_reason VARCHAR(1000) NULL COMMENT '部分完成原因',
|
||||
created_by VARCHAR(128) 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_ll_dispatch_order_no(customer_id,dispatch_no),
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_ll_dispatch_order_no(dispatch_no),
|
||||
KEY idx_ll_dispatch_order_list(customer_id,status,depart_at), KEY idx_ll_dispatch_order_vehicle(customer_id,vehicle_plate)
|
||||
) ENGINE=InnoDB COMMENT='尾程派车单';
|
||||
|
||||
@@ -493,6 +493,24 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab
|
||||
'ALTER TABLE ll_order MODIFY COLUMN source_container_booking_id VARCHAR(128) NULL COMMENT ''来源头程货柜预订ID''',
|
||||
'ALTER TABLE ll_order ADD COLUMN source_container_booking_id VARCHAR(128) NULL COMMENT ''来源头程货柜预订ID'' AFTER source_loading_plan_id'));
|
||||
PREPARE phase4_event_stmt FROM @ddl; EXECUTE phase4_event_stmt; DEALLOCATE PREPARE phase4_event_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_order' AND index_name='idx_ll_order_pending_container'),
|
||||
'ALTER TABLE ll_order DROP INDEX idx_ll_order_pending_container','SELECT 1'));
|
||||
PREPARE phase4_container_progress_idx_stmt FROM @ddl; EXECUTE phase4_container_progress_idx_stmt; DEALLOCATE PREPARE phase4_container_progress_idx_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_order' AND index_name='idx_ll_order_container_progress'),
|
||||
'SELECT 1','ALTER TABLE ll_order ADD INDEX idx_ll_order_container_progress(source_container_booking_id,estimated_warehouse_at,created_at,id)'));
|
||||
PREPARE phase4_container_progress_idx_stmt FROM @ddl; EXECUTE phase4_container_progress_idx_stmt; DEALLOCATE PREPARE phase4_container_progress_idx_stmt;
|
||||
DROP PROCEDURE IF EXISTS assert_container_progress_index_ready;
|
||||
DELIMITER $$
|
||||
CREATE PROCEDURE assert_container_progress_index_ready()
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE()
|
||||
AND table_name='ll_order' AND index_name='idx_ll_order_container_progress') THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外区域初始化失败:柜级入库计划进度索引缺失';
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
CALL assert_container_progress_index_ready();
|
||||
DROP PROCEDURE assert_container_progress_index_ready;
|
||||
|
||||
-- 头程 V2 事件冻结目的类型;只补结构,不由部署脚本自动执行历史数据回填。
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='destination_type'),
|
||||
@@ -500,6 +518,12 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab
|
||||
'ALTER TABLE ll_order ADD COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码'' AFTER order_type'));
|
||||
PREPARE phase4_destination_type_stmt FROM @ddl; EXECUTE phase4_destination_type_stmt; DEALLOCATE PREPARE phase4_destination_type_stmt;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_order_no_daily_sequence (
|
||||
business_date DATE NOT NULL COMMENT '业务日期(Asia/Shanghai)',
|
||||
last_sequence INT NOT NULL COMMENT '当日已分配尾程单流水号',
|
||||
PRIMARY KEY(business_date)
|
||||
) ENGINE=InnoDB COMMENT='尾程单号按日原子流水';
|
||||
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='customer_id'),
|
||||
'SELECT 1',
|
||||
'ALTER TABLE ll_inbound_plan_line ADD COLUMN customer_id VARCHAR(128) NOT NULL DEFAULT '''' COMMENT ''客户账号标识'' AFTER id'));
|
||||
@@ -581,7 +605,8 @@ CALL assert_pending_orders_do_not_use_source_warehouse();
|
||||
DROP PROCEDURE assert_pending_orders_do_not_use_source_warehouse;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_receiving_container_session (
|
||||
id BIGINT NOT NULL COMMENT '柜号签收会话ID', customer_id VARCHAR(128) NOT NULL COMMENT '所选柜解析出的客户标识', container_no VARCHAR(64) NOT NULL COMMENT '集装箱号',
|
||||
id BIGINT NOT NULL COMMENT '柜号签收会话ID', customer_id VARCHAR(128) NOT NULL COMMENT '所选柜解析出的客户标识',
|
||||
source_container_booking_id VARCHAR(128) NOT NULL COMMENT '稳定来源柜预订ID', container_fact_hash VARCHAR(64) NOT NULL COMMENT '领取时整柜订单与计划事实摘要', container_no VARCHAR(64) NOT NULL COMMENT '集装箱号',
|
||||
overseas_warehouse_id BIGINT NOT NULL COMMENT '操作员Token绑定并冻结的海外实际收货共享物理仓ID', overseas_warehouse_name VARCHAR(255) NOT NULL COMMENT '仓库服务校验后的海外实际收货共享物理仓名称快照',
|
||||
operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识', operator_name VARCHAR(255) NULL COMMENT '操作账号名称', active_operator_key VARCHAR(255) NULL COMMENT '未完成会话平台操作账号全局唯一锁键',
|
||||
active_container_key VARCHAR(255) NULL COMMENT '未完成会话柜号全局唯一锁键', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_SIGN' COMMENT '会话状态:WAIT_SIGN、SIGNED、RECEIVING、COMPLETED',
|
||||
@@ -592,6 +617,13 @@ CREATE TABLE IF NOT EXISTS ll_receiving_container_session (
|
||||
KEY idx_ll_receiving_container(customer_id,container_no,status), KEY idx_ll_receiving_operator(customer_id,operator_key,created_at)
|
||||
) ENGINE=InnoDB COMMENT='海外PDA柜号签收收货会话';
|
||||
|
||||
SET @ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_receiving_container_session' AND column_name='source_container_booking_id'),
|
||||
'SELECT 1','ALTER TABLE ll_receiving_container_session ADD COLUMN source_container_booking_id VARCHAR(128) NULL COMMENT ''稳定来源柜预订ID'' AFTER customer_id'));
|
||||
PREPARE phase4_receiving_fact_stmt FROM @ddl; EXECUTE phase4_receiving_fact_stmt; DEALLOCATE PREPARE phase4_receiving_fact_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_receiving_container_session' AND column_name='container_fact_hash'),
|
||||
'SELECT 1','ALTER TABLE ll_receiving_container_session ADD COLUMN container_fact_hash VARCHAR(64) NULL COMMENT ''领取时整柜订单与计划事实摘要'' AFTER source_container_booking_id'));
|
||||
PREPARE phase4_receiving_fact_stmt FROM @ddl; EXECUTE phase4_receiving_fact_stmt; DEALLOCATE PREPARE phase4_receiving_fact_stmt;
|
||||
|
||||
SET @ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_receiving_container_session' AND column_name='overseas_warehouse_id'),
|
||||
'SELECT 1','ALTER TABLE ll_receiving_container_session ADD COLUMN overseas_warehouse_id BIGINT NULL COMMENT ''操作员Token绑定并冻结的海外实际收货共享物理仓ID'' AFTER container_no'));
|
||||
PREPARE phase4_session_receiving_warehouse_stmt FROM @ddl; EXECUTE phase4_session_receiving_warehouse_stmt; DEALLOCATE PREPARE phase4_session_receiving_warehouse_stmt;
|
||||
@@ -747,6 +779,14 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab
|
||||
'ALTER TABLE ll_inventory_balance MODIFY COLUMN sku_code VARCHAR(128) NOT NULL DEFAULT ''UNSPECIFIED'' COMMENT ''SKU快照;多SKU汇总时为MIXED,不参与当前库存唯一维度''',
|
||||
'ALTER TABLE ll_inventory_balance ADD COLUMN sku_code VARCHAR(128) NOT NULL DEFAULT ''UNSPECIFIED'' COMMENT ''SKU快照;多SKU汇总时为MIXED,不参与当前库存唯一维度'' AFTER inbound_code'));
|
||||
PREPARE phase4_inventory_stmt FROM @ddl; EXECUTE phase4_inventory_stmt; DEALLOCATE PREPARE phase4_inventory_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='customer_name'),
|
||||
'ALTER TABLE ll_inventory_balance MODIFY COLUMN customer_name VARCHAR(255) NOT NULL COMMENT ''客户名称不可变快照''',
|
||||
'ALTER TABLE ll_inventory_balance ADD COLUMN customer_name VARCHAR(255) NOT NULL COMMENT ''客户名称不可变快照'' AFTER customer_id'));
|
||||
PREPARE phase4_inventory_stmt FROM @ddl; EXECUTE phase4_inventory_stmt; DEALLOCATE PREPARE phase4_inventory_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='customer_name'),
|
||||
'ALTER TABLE ll_inventory_ledger MODIFY COLUMN customer_name VARCHAR(255) NOT NULL COMMENT ''客户名称不可变快照''',
|
||||
'ALTER TABLE ll_inventory_ledger ADD COLUMN customer_name VARCHAR(255) NOT NULL COMMENT ''客户名称不可变快照'' AFTER customer_id'));
|
||||
PREPARE phase4_inventory_stmt FROM @ddl; EXECUTE phase4_inventory_stmt; DEALLOCATE PREPARE phase4_inventory_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='product_name_zh'),
|
||||
'SELECT 1', 'ALTER TABLE ll_inventory_balance ADD COLUMN product_name_zh VARCHAR(255) NULL COMMENT ''中文品名快照'' AFTER sku_code'));
|
||||
PREPARE phase4_inventory_stmt FROM @ddl; EXECUTE phase4_inventory_stmt; DEALLOCATE PREPARE phase4_inventory_stmt;
|
||||
@@ -1054,6 +1094,197 @@ CREATE TABLE IF NOT EXISTS ll_order_operation_log (
|
||||
PRIMARY KEY(id), KEY idx_ll_order_operation_customer_order(customer_id,order_id,created_at)
|
||||
) ENGINE=InnoDB COMMENT='尾程订单操作记录';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_outbound_pda_task (
|
||||
id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', outbound_order_id BIGINT NOT NULL COMMENT '出库单ID',
|
||||
task_no VARCHAR(64) NOT NULL COMMENT 'PDA任务号', status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '任务状态',
|
||||
completion_idempotency_key VARCHAR(255) NULL COMMENT '完成命令幂等键',
|
||||
pushed_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) 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_ll_outbound_pda_task_order(customer_id,outbound_order_id), UNIQUE KEY uk_ll_outbound_pda_task_no(task_no),
|
||||
UNIQUE KEY uk_ll_outbound_pda_complete_key(customer_id,completion_idempotency_key)
|
||||
) ENGINE=InnoDB COMMENT='一件代发出库PDA拣货任务';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_outbound_pda_pick (
|
||||
id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', task_id BIGINT NOT NULL COMMENT 'PDA任务ID',
|
||||
outbound_pallet_item_id BIGINT NOT NULL COMMENT '出库托盘明细ID', inventory_position_id BIGINT NOT NULL COMMENT '库存仓位ID', packages BIGINT NOT NULL COMMENT '拣货箱数',
|
||||
idempotency_key VARCHAR(255) NOT NULL COMMENT '拣货幂等键', picked_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '拣货时间',
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_ll_outbound_pda_pick_key(customer_id,idempotency_key)
|
||||
) ENGINE=InnoDB COMMENT='一件代发出库PDA托盘拣货记录';
|
||||
|
||||
-- 五期排车、出库与海外PDA正式业务号/幂等约束。只补结构并 fail-fast 校验开发数据,不回填或修复旧行。
|
||||
SET @ddl = (SELECT IF(EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_task' AND column_name='warehouse_id'
|
||||
), 'SELECT 1',
|
||||
'ALTER TABLE ll_dispatch_pda_task ADD COLUMN warehouse_id VARCHAR(128) NULL COMMENT ''Token绑定的海外仓库ID'' AFTER operator_id'));
|
||||
PREPARE phase5_pda_scope_ddl FROM @ddl; EXECUTE phase5_pda_scope_ddl; DEALLOCATE PREPARE phase5_pda_scope_ddl;
|
||||
|
||||
SET @ddl = (SELECT IF(EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema=DATABASE() AND table_name='ll_outbound_pda_task' AND column_name='completion_idempotency_key'
|
||||
), 'SELECT 1',
|
||||
'ALTER TABLE ll_outbound_pda_task ADD COLUMN completion_idempotency_key VARCHAR(255) NULL COMMENT ''完成命令幂等键'' AFTER status'));
|
||||
PREPARE phase5_pda_scope_ddl FROM @ddl; EXECUTE phase5_pda_scope_ddl; DEALLOCATE PREPARE phase5_pda_scope_ddl;
|
||||
|
||||
DROP PROCEDURE IF EXISTS assert_phase5_lastleg_identifiers_migratable;
|
||||
DELIMITER $$
|
||||
CREATE PROCEDURE assert_phase5_lastleg_identifiers_migratable()
|
||||
BEGIN
|
||||
IF (SELECT COUNT(*) FROM information_schema.tables
|
||||
WHERE table_schema='baiyue_lastleg' AND table_name IN (
|
||||
'll_dispatch_order','ll_outbound_order','ll_dispatch_pda_task','ll_outbound_pda_task')) <> 4 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期排车、出库或PDA任务表未完整部署';
|
||||
END IF;
|
||||
IF EXISTS (SELECT dispatch_no FROM ll_dispatch_order GROUP BY dispatch_no HAVING COUNT(*)>1) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='ll_dispatch_order存在跨客户重复dispatch_no,无法收敛全局唯一';
|
||||
END IF;
|
||||
IF EXISTS (SELECT outbound_no FROM ll_outbound_order GROUP BY outbound_no HAVING COUNT(*)>1) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='ll_outbound_order存在跨客户重复outbound_no,无法收敛全局唯一';
|
||||
END IF;
|
||||
IF EXISTS (SELECT task_no FROM ll_dispatch_pda_task GROUP BY task_no HAVING COUNT(*)>1) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='ll_dispatch_pda_task存在跨客户重复task_no,无法收敛全局唯一';
|
||||
END IF;
|
||||
IF EXISTS (SELECT task_no FROM ll_outbound_pda_task GROUP BY task_no HAVING COUNT(*)>1) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='ll_outbound_pda_task存在跨客户重复task_no,无法收敛全局唯一';
|
||||
END IF;
|
||||
IF EXISTS (SELECT customer_id,completion_idempotency_key FROM ll_outbound_pda_task
|
||||
WHERE completion_idempotency_key IS NOT NULL
|
||||
GROUP BY customer_id,completion_idempotency_key HAVING COUNT(*)>1) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='ll_outbound_pda_task存在重复完成幂等键,无法创建客户范围唯一约束';
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM ll_dispatch_pda_task
|
||||
WHERE warehouse_id IS NULL OR TRIM(warehouse_id)='' OR TRIM(warehouse_id)='0') THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='ll_dispatch_pda_task存在空或0仓库范围;请重新造数后重跑,脚本不回填旧行';
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
CALL assert_phase5_lastleg_identifiers_migratable();
|
||||
DROP PROCEDURE assert_phase5_lastleg_identifiers_migratable;
|
||||
|
||||
SET @ddl = (SELECT IF(EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_task' AND column_name='warehouse_id'
|
||||
AND is_nullable='NO' AND column_type='varchar(128)' AND column_comment='Token绑定的海外仓库ID'
|
||||
), 'SELECT 1',
|
||||
'ALTER TABLE ll_dispatch_pda_task MODIFY COLUMN warehouse_id VARCHAR(128) NOT NULL COMMENT ''Token绑定的海外仓库ID'''));
|
||||
PREPARE phase5_pda_scope_ddl FROM @ddl; EXECUTE phase5_pda_scope_ddl; DEALLOCATE PREPARE phase5_pda_scope_ddl;
|
||||
|
||||
SET @index_ready = (SELECT COUNT(*)=1 AND MIN(non_unique)=0
|
||||
AND MAX(seq_in_index=1 AND column_name='dispatch_no')=1
|
||||
FROM information_schema.statistics WHERE table_schema=DATABASE()
|
||||
AND table_name='ll_dispatch_order' AND index_name='uk_ll_dispatch_order_no');
|
||||
SET @index_exists = (SELECT COUNT(*)>0 FROM information_schema.statistics WHERE table_schema=DATABASE()
|
||||
AND table_name='ll_dispatch_order' AND index_name='uk_ll_dispatch_order_no');
|
||||
SET @ddl = IF(@index_ready, 'SELECT 1', IF(@index_exists,
|
||||
'ALTER TABLE ll_dispatch_order DROP INDEX uk_ll_dispatch_order_no, ADD UNIQUE KEY uk_ll_dispatch_order_no(dispatch_no)',
|
||||
'ALTER TABLE ll_dispatch_order ADD UNIQUE KEY uk_ll_dispatch_order_no(dispatch_no)'));
|
||||
PREPARE phase5_identifier_ddl FROM @ddl; EXECUTE phase5_identifier_ddl; DEALLOCATE PREPARE phase5_identifier_ddl;
|
||||
|
||||
SET @index_ready = (SELECT COUNT(*)=1 AND MIN(non_unique)=0
|
||||
AND MAX(seq_in_index=1 AND column_name='outbound_no')=1
|
||||
FROM information_schema.statistics WHERE table_schema=DATABASE()
|
||||
AND table_name='ll_outbound_order' AND index_name='uk_ll_outbound_no');
|
||||
SET @index_exists = (SELECT COUNT(*)>0 FROM information_schema.statistics WHERE table_schema=DATABASE()
|
||||
AND table_name='ll_outbound_order' AND index_name='uk_ll_outbound_no');
|
||||
SET @ddl = IF(@index_ready, 'SELECT 1', IF(@index_exists,
|
||||
'ALTER TABLE ll_outbound_order DROP INDEX uk_ll_outbound_no, ADD UNIQUE KEY uk_ll_outbound_no(outbound_no)',
|
||||
'ALTER TABLE ll_outbound_order ADD UNIQUE KEY uk_ll_outbound_no(outbound_no)'));
|
||||
PREPARE phase5_identifier_ddl FROM @ddl; EXECUTE phase5_identifier_ddl; DEALLOCATE PREPARE phase5_identifier_ddl;
|
||||
|
||||
SET @index_ready = (SELECT COUNT(*)=1 AND MIN(non_unique)=0
|
||||
AND MAX(seq_in_index=1 AND column_name='task_no')=1
|
||||
FROM information_schema.statistics WHERE table_schema=DATABASE()
|
||||
AND table_name='ll_dispatch_pda_task' AND index_name='uk_ll_dispatch_pda_task_no');
|
||||
SET @index_exists = (SELECT COUNT(*)>0 FROM information_schema.statistics WHERE table_schema=DATABASE()
|
||||
AND table_name='ll_dispatch_pda_task' AND index_name='uk_ll_dispatch_pda_task_no');
|
||||
SET @ddl = IF(@index_ready, 'SELECT 1', IF(@index_exists,
|
||||
'ALTER TABLE ll_dispatch_pda_task DROP INDEX uk_ll_dispatch_pda_task_no, ADD UNIQUE KEY uk_ll_dispatch_pda_task_no(task_no)',
|
||||
'ALTER TABLE ll_dispatch_pda_task ADD UNIQUE KEY uk_ll_dispatch_pda_task_no(task_no)'));
|
||||
PREPARE phase5_identifier_ddl FROM @ddl; EXECUTE phase5_identifier_ddl; DEALLOCATE PREPARE phase5_identifier_ddl;
|
||||
|
||||
SET @index_ready = (SELECT COUNT(*)=1 AND MIN(non_unique)=0
|
||||
AND MAX(seq_in_index=1 AND column_name='task_no')=1
|
||||
FROM information_schema.statistics WHERE table_schema=DATABASE()
|
||||
AND table_name='ll_outbound_pda_task' AND index_name='uk_ll_outbound_pda_task_no');
|
||||
SET @index_exists = (SELECT COUNT(*)>0 FROM information_schema.statistics WHERE table_schema=DATABASE()
|
||||
AND table_name='ll_outbound_pda_task' AND index_name='uk_ll_outbound_pda_task_no');
|
||||
SET @ddl = IF(@index_ready, 'SELECT 1', IF(@index_exists,
|
||||
'ALTER TABLE ll_outbound_pda_task DROP INDEX uk_ll_outbound_pda_task_no, ADD UNIQUE KEY uk_ll_outbound_pda_task_no(task_no)',
|
||||
'ALTER TABLE ll_outbound_pda_task ADD UNIQUE KEY uk_ll_outbound_pda_task_no(task_no)'));
|
||||
PREPARE phase5_identifier_ddl FROM @ddl; EXECUTE phase5_identifier_ddl; DEALLOCATE PREPARE phase5_identifier_ddl;
|
||||
|
||||
SET @index_ready = (SELECT COUNT(*)=2 AND MIN(non_unique)=0
|
||||
AND SUM(seq_in_index=1 AND column_name='customer_id')=1
|
||||
AND SUM(seq_in_index=2 AND column_name='completion_idempotency_key')=1
|
||||
FROM information_schema.statistics WHERE table_schema=DATABASE()
|
||||
AND table_name='ll_outbound_pda_task' AND index_name='uk_ll_outbound_pda_complete_key');
|
||||
SET @index_exists = (SELECT COUNT(*)>0 FROM information_schema.statistics WHERE table_schema=DATABASE()
|
||||
AND table_name='ll_outbound_pda_task' AND index_name='uk_ll_outbound_pda_complete_key');
|
||||
SET @ddl = IF(@index_ready, 'SELECT 1', IF(@index_exists,
|
||||
'ALTER TABLE ll_outbound_pda_task DROP INDEX uk_ll_outbound_pda_complete_key, ADD UNIQUE KEY uk_ll_outbound_pda_complete_key(customer_id,completion_idempotency_key)',
|
||||
'ALTER TABLE ll_outbound_pda_task ADD UNIQUE KEY uk_ll_outbound_pda_complete_key(customer_id,completion_idempotency_key)'));
|
||||
PREPARE phase5_identifier_ddl FROM @ddl; EXECUTE phase5_identifier_ddl; DEALLOCATE PREPARE phase5_identifier_ddl;
|
||||
|
||||
DROP PROCEDURE IF EXISTS assert_phase5_lastleg_identifiers_ready;
|
||||
DELIMITER $$
|
||||
CREATE PROCEDURE assert_phase5_lastleg_identifiers_ready()
|
||||
BEGIN
|
||||
IF (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='baiyue_lastleg'
|
||||
AND table_name='ll_dispatch_pda_task' AND column_name='warehouse_id' AND is_nullable='NO') <> 1 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='ll_dispatch_pda_task.warehouse_id未收敛为非空';
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM ll_dispatch_pda_task
|
||||
WHERE warehouse_id IS NULL OR TRIM(warehouse_id)='' OR TRIM(warehouse_id)='0') THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='ll_dispatch_pda_task仍存在空或0仓库范围';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='baiyue_lastleg'
|
||||
AND table_name='ll_outbound_pda_task' AND column_name='completion_idempotency_key'
|
||||
AND column_type='varchar(255)' AND is_nullable='YES') <> 1 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='ll_outbound_pda_task完成幂等字段未部署';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema='baiyue_lastleg'
|
||||
AND table_name='ll_dispatch_order' AND index_name='uk_ll_dispatch_order_no') <> 1
|
||||
OR (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema='baiyue_lastleg'
|
||||
AND table_name='ll_dispatch_order' AND index_name='uk_ll_dispatch_order_no'
|
||||
AND non_unique=0 AND seq_in_index=1 AND column_name='dispatch_no') <> 1 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='dispatch_no全局唯一索引未部署';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema='baiyue_lastleg'
|
||||
AND table_name='ll_outbound_order' AND index_name='uk_ll_outbound_no') <> 1
|
||||
OR (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema='baiyue_lastleg'
|
||||
AND table_name='ll_outbound_order' AND index_name='uk_ll_outbound_no'
|
||||
AND non_unique=0 AND seq_in_index=1 AND column_name='outbound_no') <> 1 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='outbound_no全局唯一索引未部署';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema='baiyue_lastleg'
|
||||
AND table_name='ll_dispatch_pda_task' AND index_name='uk_ll_dispatch_pda_task_no') <> 1
|
||||
OR (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema='baiyue_lastleg'
|
||||
AND table_name='ll_dispatch_pda_task' AND index_name='uk_ll_dispatch_pda_task_no'
|
||||
AND non_unique=0 AND seq_in_index=1 AND column_name='task_no') <> 1 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='排车PDA task_no全局唯一索引未部署';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema='baiyue_lastleg'
|
||||
AND table_name='ll_outbound_pda_task' AND index_name='uk_ll_outbound_pda_task_no') <> 1
|
||||
OR (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema='baiyue_lastleg'
|
||||
AND table_name='ll_outbound_pda_task' AND index_name='uk_ll_outbound_pda_task_no'
|
||||
AND non_unique=0 AND seq_in_index=1 AND column_name='task_no') <> 1 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='出库PDA task_no全局唯一索引未部署';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema='baiyue_lastleg'
|
||||
AND table_name='ll_outbound_pda_task' AND index_name='uk_ll_outbound_pda_complete_key') <> 2
|
||||
OR (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema='baiyue_lastleg'
|
||||
AND table_name='ll_outbound_pda_task' AND index_name='uk_ll_outbound_pda_complete_key'
|
||||
AND non_unique=0 AND seq_in_index=1 AND column_name='customer_id') <> 1
|
||||
OR (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema='baiyue_lastleg'
|
||||
AND table_name='ll_outbound_pda_task' AND index_name='uk_ll_outbound_pda_complete_key'
|
||||
AND non_unique=0 AND seq_in_index=2 AND column_name='completion_idempotency_key') <> 1 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='出库PDA客户范围完成幂等唯一索引未部署';
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
CALL assert_phase5_lastleg_identifiers_ready();
|
||||
DROP PROCEDURE assert_phase5_lastleg_identifiers_ready;
|
||||
|
||||
USE baiyue_lastleg;
|
||||
SET @ddl = (SELECT IF(EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
## 职责边界
|
||||
|
||||
- Mapper 只由 `EventBridgeApplication.java` 对 `com.baiyue.eventbridge` 根包按 `@Mapper` 过滤扫描;新增或移动 Mapper 后从后端根运行 `node scripts/mapper-scan-contract.mjs`,不得增加子域扫描配置。
|
||||
- 负责跨区事件接收、Inbox 持久化、按目标区域向海外尾程或国内头程投递、失败重试、投递记录和人工补投。
|
||||
- 只承载可靠传输,不执行尾程建单逻辑,不查询头程或尾程业务表。
|
||||
- 事件结构和 Payload 哈希以 `common-event-contract` 为准。
|
||||
|
||||
+2
-1
@@ -1,6 +1,7 @@
|
||||
package com.baiyue.eventbridge;
|
||||
|
||||
import com.baiyue.eventbridge.config.EventDispatcherProperties;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.mybatis.spring.annotation.MapperScan;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
@@ -10,7 +11,7 @@ import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
/**
|
||||
* 跨区域事件桥启动入口。
|
||||
*/
|
||||
@MapperScan("com.baiyue.eventbridge.mapper")
|
||||
@MapperScan(basePackages = "com.baiyue.eventbridge", annotationClass = Mapper.class)
|
||||
@SpringBootApplication
|
||||
@EnableScheduling
|
||||
@EnableConfigurationProperties(EventDispatcherProperties.class)
|
||||
|
||||
+31
-6
@@ -49,6 +49,11 @@ public class EventInboxService {
|
||||
if (existing != null) {
|
||||
return duplicateOrConflict(existing, envelope);
|
||||
}
|
||||
String idempotencyKey = idempotencyKey(envelope);
|
||||
BridgeInbox sameBusinessEvent = inboxStore.findByIdempotencyKey(idempotencyKey);
|
||||
if (sameBusinessEvent != null) {
|
||||
return duplicateBusinessEventOrConflict(sameBusinessEvent, envelope);
|
||||
}
|
||||
BridgeInbox inbox = toInbox(envelope);
|
||||
try {
|
||||
inboxStore.insert(inbox);
|
||||
@@ -56,13 +61,25 @@ public class EventInboxService {
|
||||
return new EventReceiveResult(envelope.eventId(), "已接收", inbox.getReceivedAt());
|
||||
} catch (DuplicateKeyException exception) {
|
||||
BridgeInbox concurrent = inboxStore.findByEventId(envelope.eventId());
|
||||
if (concurrent == null) concurrent = inboxStore.findByIdempotencyKey(idempotencyKey);
|
||||
if (concurrent == null) {
|
||||
throw exception;
|
||||
}
|
||||
return duplicateOrConflict(concurrent, envelope);
|
||||
return Objects.equals(concurrent.getEventId(), envelope.eventId())
|
||||
? duplicateOrConflict(concurrent, envelope)
|
||||
: duplicateBusinessEventOrConflict(concurrent, envelope);
|
||||
}
|
||||
}
|
||||
|
||||
private EventReceiveResult duplicateBusinessEventOrConflict(BridgeInbox existing, EventEnvelope envelope) {
|
||||
if (!sameBusinessEvent(existing, envelope)) {
|
||||
metrics.recordReceive(envelope, EventBridgeMetrics.ReceiveResult.CONFLICT);
|
||||
throw new IllegalArgumentException("相同eventKey对应的事件负载不一致,拒绝柜号或事实漂移");
|
||||
}
|
||||
metrics.recordReceive(envelope, EventBridgeMetrics.ReceiveResult.DUPLICATE);
|
||||
return new EventReceiveResult(existing.getEventId(), "重复", existing.getReceivedAt());
|
||||
}
|
||||
|
||||
private EventReceiveResult duplicateOrConflict(BridgeInbox existing, EventEnvelope envelope) {
|
||||
if (!sameEnvelope(existing, envelope)) {
|
||||
metrics.recordReceive(envelope, EventBridgeMetrics.ReceiveResult.CONFLICT);
|
||||
@@ -73,6 +90,13 @@ public class EventInboxService {
|
||||
}
|
||||
|
||||
private boolean sameEnvelope(BridgeInbox existing, EventEnvelope envelope) {
|
||||
return sameBusinessEvent(existing, envelope)
|
||||
&& Objects.equals(normalizeOccurredAt(existing.getOccurredAt()), normalizeOccurredAt(envelope.occurredAt()))
|
||||
&& Objects.equals(existing.getTraceId(), envelope.traceId())
|
||||
&& Objects.equals(existing.getCausationId(), envelope.causationId());
|
||||
}
|
||||
|
||||
private boolean sameBusinessEvent(BridgeInbox existing, EventEnvelope envelope) {
|
||||
return existing.getPayloadHash() != null
|
||||
&& existing.getPayloadHash().equalsIgnoreCase(envelope.payloadHash())
|
||||
&& Objects.equals(existing.getEventType(), envelope.eventType())
|
||||
@@ -84,16 +108,13 @@ public class EventInboxService {
|
||||
&& Objects.equals(existing.getAggregateId(), envelope.aggregateId())
|
||||
&& Objects.equals(existing.getAggregateVersion(), envelope.aggregateVersion())
|
||||
&& Objects.equals(existing.getHandoverVersion(), envelope.handoverVersion())
|
||||
&& Objects.equals(normalizeOccurredAt(existing.getOccurredAt()), normalizeOccurredAt(envelope.occurredAt()))
|
||||
&& Objects.equals(existing.getTraceId(), envelope.traceId())
|
||||
&& Objects.equals(existing.getCorrelationId(), envelope.correlationId())
|
||||
&& Objects.equals(existing.getCausationId(), envelope.causationId());
|
||||
&& Objects.equals(existing.getCorrelationId(), envelope.correlationId());
|
||||
}
|
||||
|
||||
private BridgeInbox toInbox(EventEnvelope envelope) {
|
||||
BridgeInbox inbox = new BridgeInbox();
|
||||
inbox.setEventId(envelope.eventId());
|
||||
inbox.setIdempotencyKey(envelope.sourceRegion() + ":" + envelope.eventId());
|
||||
inbox.setIdempotencyKey(idempotencyKey(envelope));
|
||||
inbox.setEventType(envelope.eventType());
|
||||
inbox.setEventVersion(envelope.eventVersion());
|
||||
inbox.setSourceRegion(envelope.sourceRegion().name());
|
||||
@@ -121,4 +142,8 @@ public class EventInboxService {
|
||||
private static Instant normalizeOccurredAt(Instant value) {
|
||||
return value == null ? null : value.truncatedTo(ChronoUnit.MILLIS);
|
||||
}
|
||||
|
||||
private static String idempotencyKey(EventEnvelope envelope) {
|
||||
return envelope.sourceRegion() + ":" + envelope.correlationId();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,5 +7,6 @@ import com.baiyue.eventbridge.domain.BridgeInbox;
|
||||
*/
|
||||
public interface InboxStore {
|
||||
BridgeInbox findByEventId(String eventId);
|
||||
BridgeInbox findByIdempotencyKey(String idempotencyKey);
|
||||
void insert(BridgeInbox inbox);
|
||||
}
|
||||
|
||||
+7
@@ -4,6 +4,7 @@ import com.baiyue.eventbridge.domain.BridgeInbox;
|
||||
import com.baiyue.eventbridge.mapper.BridgeInboxMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
|
||||
/**
|
||||
* 基于 MyBatis-Plus 的入站幂等存储。
|
||||
@@ -17,6 +18,12 @@ public class MybatisInboxStore implements InboxStore {
|
||||
return mapper.selectById(eventId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public BridgeInbox findByIdempotencyKey(String idempotencyKey) {
|
||||
return mapper.selectOne(new LambdaQueryWrapper<BridgeInbox>()
|
||||
.eq(BridgeInbox::getIdempotencyKey, idempotencyKey));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void insert(BridgeInbox inbox) {
|
||||
mapper.insert(inbox);
|
||||
|
||||
@@ -34,7 +34,7 @@ CREATE TABLE IF NOT EXISTS bridge_outbox (
|
||||
|
||||
CREATE TABLE IF NOT EXISTS bridge_inbox (
|
||||
event_id VARCHAR(128) NOT NULL COMMENT '事件全局标识',
|
||||
idempotency_key VARCHAR(191) NOT NULL COMMENT '接收幂等键',
|
||||
idempotency_key VARCHAR(191) NOT NULL COMMENT '来源区域与业务eventKey组成的接收幂等键',
|
||||
event_type VARCHAR(64) NOT NULL COMMENT '事件类型',
|
||||
event_version INT NOT NULL COMMENT '事件契约版本',
|
||||
source_region VARCHAR(32) NOT NULL COMMENT '来源区域',
|
||||
|
||||
+26
@@ -106,6 +106,32 @@ class EventInboxServiceTest {
|
||||
.hasMessageContaining("事件信封");
|
||||
}
|
||||
|
||||
@Test
|
||||
void 相同业务eventKey的新eventId只允许同负载重放并拒绝柜号漂移() {
|
||||
EventEnvelope envelope = withHash(envelope("sha256:placeholder"));
|
||||
BridgeInbox existing = existing(envelope, envelope.payloadHash());
|
||||
when(store.findByEventId(envelope.eventId())).thenReturn(null);
|
||||
when(store.findByIdempotencyKey("CN:flow-1")).thenReturn(existing);
|
||||
EventEnvelope replay = new EventEnvelope("550e8400-e29b-41d4-a716-446655440001",
|
||||
envelope.eventType(), envelope.eventVersion(), Instant.parse("2026-07-29T09:00:00Z"),
|
||||
envelope.sourceRegion(), envelope.targetRegion(), envelope.tenantId(), envelope.aggregateType(),
|
||||
envelope.aggregateId(), envelope.aggregateVersion(), envelope.handoverVersion(), "trace-2",
|
||||
envelope.correlationId(), null, envelope.payload(), envelope.payloadHash());
|
||||
assertThat(service.receive(replay).result()).isEqualTo("重复");
|
||||
|
||||
com.fasterxml.jackson.databind.node.ObjectNode driftPayload =
|
||||
(com.fasterxml.jackson.databind.node.ObjectNode) envelope.payload().deepCopy();
|
||||
driftPayload.put("containerNo", "DIFFERENT");
|
||||
String driftHash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(driftPayload));
|
||||
EventEnvelope drift = new EventEnvelope("550e8400-e29b-41d4-a716-446655440002",
|
||||
envelope.eventType(), envelope.eventVersion(), envelope.occurredAt(), envelope.sourceRegion(),
|
||||
envelope.targetRegion(), envelope.tenantId(), envelope.aggregateType(), envelope.aggregateId(),
|
||||
envelope.aggregateVersion(), envelope.handoverVersion(), "trace-3", envelope.correlationId(),
|
||||
null, driftPayload, driftHash);
|
||||
assertThatThrownBy(() -> service.receive(drift))
|
||||
.hasMessageContaining("eventKey").hasMessageContaining("漂移");
|
||||
}
|
||||
|
||||
@Test
|
||||
void 并发插入冲突后按既有记录判定为重复() {
|
||||
EventEnvelope envelope = withHash(envelope("sha256:placeholder"));
|
||||
|
||||
+7
-2
@@ -2,6 +2,7 @@
|
||||
|
||||
## 职责与边界
|
||||
|
||||
- Mapper 只由 `FileServiceApplication.java` 对 `com.baiyue.file` 根包按 `@Mapper` 过滤扫描;新增或移动 Mapper 后从后端根运行 `node scripts/mapper-scan-contract.mjs`,不得增加子域扫描配置。
|
||||
- 负责企业资质文件、公共导出、二期入库测量图片的上传、存储、读取和鉴权。
|
||||
- 资质审核元数据归 `iam/`,入库状态归 `firstleg/`,OCR 适配归 `integration-service/`;不跨库读取业务表。
|
||||
|
||||
@@ -13,8 +14,11 @@
|
||||
| 导出中心 | `controller/PlatformExportTaskController.java`、`service/ExportTaskService.java`、`service/ExportStateService.java`、`service/GenericXlsxExportRunner.java`、`config/ExportDefinitionRegistry.java`、`client/ExportProviderClient.java`、`mq/` |
|
||||
| 测量图片/证据 | `controller/PlatformMeasurementImageController.java`、`InternalMeasurementImageController.java`、`service/MeasurementImageService.java` |
|
||||
| 平台订单商品图片读取 | `controller/PlatformProductImageController.java` 的 `/api/platform/v1/files/product-images/{tenantId}/{fileId}`、`service/ProductImageService.java`;仅平台 `platform:order:list` 可按明确客户与文件 ID 内联读取,响应 `no-store` |
|
||||
| 装柜与业务文件证据 | `controller/PlatformLoadingEvidenceController.java`、`InternalLoadingEvidenceController.java`、`InternalBusinessFileController.java`、`service/InternalBusinessFileService.java` |
|
||||
| 装柜与业务文件证据 | `controller/PlatformLoadingEvidenceController.java`、`InternalLoadingEvidenceController.java`、`service/LoadingEvidenceService.java`、`src/main/resources/openapi/loading-evidence-v1.yaml`;五类装柜证据固定为平台所有、`tenantId=0`、`businessId=containerBookingId`,不得绑定客户或入仓单。其他业务文件进入 `InternalBusinessFileController.java`、`service/InternalBusinessFileService.java` |
|
||||
| 财务付款凭证、海外卸柜照片 | 通用业务文件入口 `InternalBusinessFileController.java`、`InternalBusinessFileService.java`;业务类型分别为 `FINANCE_VOUCHER`、`OVERSEAS_UNLOAD_PHOTO`,必须按客户与业务 ID 精确校验 |
|
||||
| 报关源文件、完成文件与买单明细导出 | `src/main/resources/openapi/customs-file-and-export-v1.yaml`;文件类别 `LOADING_LIST_CUSTOMS_SOURCE`、`LOADING_LIST_CUSTOMS_RESULT` 均为客户作用域且最多 50MB,异步导出类型 `LOADING_LIST_BUY_DECLARATION_ITEMS` 由 Firstleg Provider 按固定十三列供数 |
|
||||
| 订舱文件 | 通用内部业务文件入口,契约 `src/main/resources/openapi/ocean-booking-file-v1.yaml`;类型固定 `OCEAN_BOOKING_DOCUMENT`、`tenantId=订舱customerId`、`businessId=oceanBookingId`,任意格式且最多 50MiB,不得绑定拖车;`BOOKING_CONFIRMATION/BILL_OF_LADING` 文档类型及有效状态由 Firstleg 保存 |
|
||||
| 拖车预约附件 | 通用内部业务文件入口;固定 `tenantId=0`、`businessType=TRUCK_APPOINTMENT_ATTACHMENT`、`businessId=appointmentId`,只允许派车单、司机/车辆资料和其他平台附件;提单文件归订舱 `OCEAN_BOOKING_DOCUMENT`,不得复制为拖车附件 |
|
||||
| 存储与实体 | `service/LocalQualificationStorage.java`、`MeasurementImageStorage.java`、`entity/FileObjectEntity.java` |
|
||||
| 下游 OCR | `client/WeightOcrClient.java` |
|
||||
| SQL/配置 | `src/main/resources/db/file.sql`、`application.yml` |
|
||||
@@ -23,10 +27,11 @@
|
||||
## 修改约束
|
||||
|
||||
- 上传先写真实文件再登记 IAM 元数据;异常结果未知时保留文件供重试,禁止伪造 URL。
|
||||
- 所有文件按客户、入库单、文件类型校验归属;重量图片可 OCR,第三方尺寸证据不得触发重量 OCR。
|
||||
- 客户业务文件按客户、业务 ID 和文件类型校验归属;重量图片可 OCR,第三方尺寸证据不得触发重量 OCR。装柜四步照片与铅封是跨客户物理柜证据,所有者固定为平台(`tenantId=0`),业务 ID 固定为 `containerBookingId`,File 按柜与五类 `LOADING_*` 精确校验且不得跨柜复用;PDA `taskId` 只由 Firstleg 保存为来源/审计,不进入 File 所有权。国内 PDA 禁止直连 File 平台接口,必须经 Firstleg 校验任务、仓库和操作者后,由 Firstleg 使用内部服务令牌访问 File。
|
||||
- 商品图片平台读取必须同时校验平台身份、`platform:order:list`、客户范围、文件业务类型与正整数 ID;不得提供匿名 URL、跨客户读取或把内部存储路径返回页面。
|
||||
- 公共导出只接受 `ExportDefinitionRegistry` 明确登记的 `exportType`,由 File 调用所属业务服务的内部 Provider 分批取数并生成文件;业务页面不得直连 Provider。新增或修改导出类型时同步检查业务服务 Provider、File Feign 客户端/响应上限、权限、Admin 下载中心标签与重试映射及契约测试。
|
||||
- 生产环境显式配置 `FILE_STORAGE_ROOT`;磁盘故障返回 `S0503`,不得包装成业务错误。
|
||||
- 报关源文件和完成文件的 `businessId` 固定为 loadingPlanId,File DTO 仍为 `Metadata(fileId, tenantId, businessType, businessId, fileName, sha256, contentType, sizeBytes)`,其中 `businessType` 即 fileCategory;Firstleg 保存业务文件版本并决定用户入口。权限固定为 Platform 上传结果 `platform:firstleg:customs:manage`、下载 `platform:firstleg:loading-list:list`,Client 上传源文件 `merchant:order:edit`、下载自身源文件或获准结果 `merchant:order:view`。结果还必须满足整柜完成和可见性,买单客户是否存在结果入口同样由 Firstleg 判定,File 不复制这些状态。
|
||||
|
||||
## 全量验收导航
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.baiyue.file;
|
||||
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.mybatis.spring.annotation.MapperScan;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.cloud.openfeign.EnableFeignClients;
|
||||
@@ -9,6 +11,7 @@ import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
* 文件中心服务启动入口。
|
||||
*/
|
||||
@SpringBootApplication(scanBasePackages = "com.baiyue")
|
||||
@MapperScan(basePackages = "com.baiyue.file", annotationClass = Mapper.class)
|
||||
@EnableFeignClients(basePackages = "com.baiyue.file.client")
|
||||
@EnableScheduling
|
||||
public class FileServiceApplication {
|
||||
|
||||
@@ -9,6 +9,7 @@ import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
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;
|
||||
@@ -65,11 +66,17 @@ public class InternalBusinessFileController {
|
||||
check(token);
|
||||
checkTenantId(tenantId);
|
||||
var value = service.download(tenantId, businessType, businessId, fileId);
|
||||
String name = value.fileName().replace("\r", "").replace("\n", "").replace("\"", "");
|
||||
MediaType contentType;
|
||||
try {
|
||||
contentType = MediaType.parseMediaType(value.contentType());
|
||||
} catch (IllegalArgumentException exception) {
|
||||
contentType = MediaType.APPLICATION_OCTET_STREAM;
|
||||
}
|
||||
return ResponseEntity.ok()
|
||||
.cacheControl(CacheControl.noStore())
|
||||
.contentType(MediaType.parseMediaType(value.contentType()))
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + name + "\"")
|
||||
.contentType(contentType)
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION,
|
||||
ContentDisposition.attachment().filename(value.fileName(), StandardCharsets.UTF_8).build().toString())
|
||||
.contentLength(value.content().length)
|
||||
.body(value.content());
|
||||
}
|
||||
|
||||
+12
-12
@@ -25,32 +25,32 @@ public class InternalLoadingEvidenceController {
|
||||
|
||||
@PostConstruct void initializeToken() { expectedToken = internalToken.getBytes(StandardCharsets.UTF_8); }
|
||||
|
||||
@PostMapping(value = "/{tenantId}/{inboundId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@PostMapping(value = "/{containerBookingId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
public ApiResponse<LoadingEvidenceService.UploadResult> upload(
|
||||
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||
@RequestHeader("X-Operator-Id") long operatorId, @PathVariable long tenantId, @PathVariable long inboundId,
|
||||
@RequestHeader("X-Operator-Id") long operatorId, @PathVariable long containerBookingId,
|
||||
@RequestParam String evidenceType, @RequestPart("file") MultipartFile file) {
|
||||
requireToken(token);
|
||||
if (tenantId <= 0 || inboundId <= 0 || operatorId <= 0) throw new BizException(ResultCode.BAD_REQUEST, "租户、入库单和操作主体ID必须大于0");
|
||||
return ApiResponse.success(service.upload(tenantId, inboundId, operatorId, evidenceType, file));
|
||||
if (containerBookingId <= 0 || operatorId <= 0) throw new BizException(ResultCode.BAD_REQUEST, "柜次和操作主体ID必须大于0");
|
||||
return ApiResponse.success(service.upload(containerBookingId, operatorId, evidenceType, file));
|
||||
}
|
||||
|
||||
@GetMapping("/{tenantId}/{inboundId}/{fileId}")
|
||||
@GetMapping("/{containerBookingId}/{fileId}")
|
||||
public ApiResponse<LoadingEvidenceService.FileMetadata> validate(
|
||||
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||
@PathVariable long tenantId, @PathVariable long inboundId, @PathVariable long fileId) {
|
||||
@PathVariable long containerBookingId, @PathVariable long fileId) {
|
||||
requireToken(token);
|
||||
if (tenantId <= 0 || inboundId <= 0 || fileId <= 0) throw new BizException(ResultCode.BAD_REQUEST, "租户、入库单和文件ID必须大于0");
|
||||
return ApiResponse.success(service.validate(tenantId, inboundId, fileId));
|
||||
if (containerBookingId <= 0 || fileId <= 0) throw new BizException(ResultCode.BAD_REQUEST, "柜次和文件ID必须大于0");
|
||||
return ApiResponse.success(service.validate(containerBookingId, fileId));
|
||||
}
|
||||
|
||||
@GetMapping("/{tenantId}/{inboundId}/{fileId}/content")
|
||||
@GetMapping("/{containerBookingId}/{fileId}/content")
|
||||
public ResponseEntity<byte[]> download(
|
||||
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||
@PathVariable long tenantId, @PathVariable long inboundId, @PathVariable long fileId) {
|
||||
@PathVariable long containerBookingId, @PathVariable long fileId) {
|
||||
requireToken(token);
|
||||
if (tenantId <= 0 || inboundId <= 0 || fileId <= 0) throw new BizException(ResultCode.BAD_REQUEST, "租户、入库单和文件ID必须大于0");
|
||||
var value = service.download(tenantId, inboundId, fileId);
|
||||
if (containerBookingId <= 0 || fileId <= 0) throw new BizException(ResultCode.BAD_REQUEST, "柜次和文件ID必须大于0");
|
||||
var value = service.download(containerBookingId, fileId);
|
||||
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
|
||||
.contentType(safeImageMediaType(value.contentType())).contentLength(value.content().length)
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment()
|
||||
|
||||
+8
-8
@@ -20,21 +20,21 @@ import java.nio.charset.StandardCharsets;
|
||||
public class PlatformLoadingEvidenceController {
|
||||
@Resource private LoadingEvidenceService service;
|
||||
|
||||
@PostMapping(value = "/{tenantId}/{inboundId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@PostMapping(value = "/{containerBookingId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:inbound:loading:edit')")
|
||||
public ApiResponse<LoadingEvidenceService.UploadResult> upload(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long tenantId, @PathVariable long inboundId,
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long containerBookingId,
|
||||
@RequestParam String evidenceType, @RequestPart("file") MultipartFile file) {
|
||||
long operatorId = requirePlatform(user); requirePositive(tenantId, inboundId);
|
||||
return ApiResponse.success(service.upload(tenantId, inboundId, operatorId, evidenceType, file));
|
||||
long operatorId = requirePlatform(user); requirePositive(containerBookingId);
|
||||
return ApiResponse.success(service.upload(containerBookingId, operatorId, evidenceType, file));
|
||||
}
|
||||
|
||||
@GetMapping("/{tenantId}/{inboundId}/{fileId}")
|
||||
@GetMapping("/{containerBookingId}/{fileId}")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:inbound:loading:view')")
|
||||
public ResponseEntity<byte[]> download(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable long tenantId, @PathVariable long inboundId, @PathVariable long fileId) {
|
||||
requirePlatform(user); requirePositive(tenantId, inboundId, fileId);
|
||||
var value = service.download(tenantId, inboundId, fileId);
|
||||
@PathVariable long containerBookingId, @PathVariable long fileId) {
|
||||
requirePlatform(user); requirePositive(containerBookingId, fileId);
|
||||
var value = service.download(containerBookingId, fileId);
|
||||
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
|
||||
.contentType(safeImageMediaType(value.contentType())).contentLength(value.content().length)
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.inline()
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.baiyue.file.service;
|
||||
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
/** File-owned storage policy. Business visibility and lifecycle remain in the calling service. */
|
||||
enum BusinessFileCategory {
|
||||
TRUCK_APPOINTMENT_ATTACHMENT(false, false),
|
||||
OCEAN_BOOKING_DOCUMENT(true, true),
|
||||
CUSTOMS_DOCUMENT(false, false),
|
||||
CUSTOMS_RECEIPT(false, false),
|
||||
FINANCE_VOUCHER(false, false),
|
||||
CONTAINER_CLEARANCE_DOCUMENT(false, false),
|
||||
LOADING_CUSTOMS_DECLARATION(false, false),
|
||||
CONTAINER_DELIVERY_ORDER(false, false),
|
||||
CONTAINER_PICKUP_ORDER(false, false),
|
||||
OVERSEAS_UNLOAD_PHOTO(true, false),
|
||||
OVERSEAS_RELABEL_PHOTO(true, false),
|
||||
LOADING_LIST_CUSTOMS_SOURCE(true, true),
|
||||
LOADING_LIST_CUSTOMS_RESULT(true, true);
|
||||
|
||||
static final long STANDARD_MAX_SIZE = 10L * 1024 * 1024;
|
||||
static final long CUSTOMS_MAX_SIZE = 50L * 1024 * 1024;
|
||||
|
||||
private final boolean customerOwned;
|
||||
private final boolean arbitraryFormat;
|
||||
|
||||
BusinessFileCategory(boolean customerOwned, boolean arbitraryFormat) {
|
||||
this.customerOwned = customerOwned;
|
||||
this.arbitraryFormat = arbitraryFormat;
|
||||
}
|
||||
|
||||
static BusinessFileCategory require(String value) {
|
||||
return Arrays.stream(values()).filter(item -> item.name().equals(value)).findFirst()
|
||||
.orElseThrow(() -> new BizException(ResultCode.BAD_REQUEST, "不支持的附件业务类型"));
|
||||
}
|
||||
|
||||
void validateOwner(long tenantId) {
|
||||
if (customerOwned && tenantId <= 0) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "该附件类型必须归属明确客户");
|
||||
}
|
||||
}
|
||||
|
||||
long maxSizeBytes() {
|
||||
return arbitraryFormat ? CUSTOMS_MAX_SIZE : STANDARD_MAX_SIZE;
|
||||
}
|
||||
|
||||
boolean arbitraryFormat() {
|
||||
return arbitraryFormat;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
@@ -21,7 +22,6 @@ import java.util.UUID;
|
||||
|
||||
@Component
|
||||
public class BusinessFileStorage {
|
||||
private static final long MAX_SIZE = 10L * 1024 * 1024;
|
||||
private static final Map<String, String> TYPES = Map.of(
|
||||
"text/plain", ".txt", "application/pdf", ".pdf",
|
||||
"image/jpeg", ".jpg", "image/png", ".png");
|
||||
@@ -35,13 +35,13 @@ public class BusinessFileStorage {
|
||||
root = configuredRoot.toAbsolutePath().normalize();
|
||||
}
|
||||
|
||||
Stored storeLoading(long tenantId, long businessId, MultipartFile file) {
|
||||
Stored store(long tenantId, long businessId, MultipartFile file, long maxSize, boolean arbitraryFormat) {
|
||||
if (file == null || file.isEmpty()) throw bad("附件不能为空");
|
||||
if (file.getSize() > MAX_SIZE) throw bad("附件不能超过10MB");
|
||||
String type = file.getContentType() == null ? "" : file.getContentType().toLowerCase(Locale.ROOT);
|
||||
String extension = TYPES.get(type);
|
||||
if (extension == null) throw bad("附件仅支持TXT、PDF、JPG、PNG");
|
||||
verifyContent(file, type);
|
||||
if (file.getSize() > maxSize) throw bad("附件不能超过" + maxSize / 1024 / 1024 + "MB");
|
||||
String type = safeContentType(file.getContentType());
|
||||
String extension = arbitraryFormat ? "" : TYPES.get(type);
|
||||
if (!arbitraryFormat && extension == null) throw bad("附件仅支持TXT、PDF、JPG、PNG");
|
||||
if (!arbitraryFormat) verifyContent(file, type);
|
||||
String name = safeName(file);
|
||||
String objectKey = "business-files/" + tenantId + "/" + businessId + "/" + UUID.randomUUID() + extension;
|
||||
Path target = root.resolve(objectKey).normalize();
|
||||
@@ -55,6 +55,15 @@ public class BusinessFileStorage {
|
||||
}
|
||||
}
|
||||
|
||||
private static String safeContentType(String contentType) {
|
||||
if (contentType == null || contentType.isBlank()) return MediaType.APPLICATION_OCTET_STREAM_VALUE;
|
||||
try {
|
||||
return MediaType.parseMediaType(contentType).toString().toLowerCase(Locale.ROOT);
|
||||
} catch (IllegalArgumentException exception) {
|
||||
return MediaType.APPLICATION_OCTET_STREAM_VALUE;
|
||||
}
|
||||
}
|
||||
|
||||
byte[] read(String objectKey) {
|
||||
Path target = root.resolve(objectKey).normalize();
|
||||
if (!target.startsWith(root)) throw new BizException(ResultCode.FORBIDDEN, "文件路径越界");
|
||||
@@ -91,7 +100,14 @@ public class BusinessFileStorage {
|
||||
|
||||
private static String safeName(MultipartFile file) {
|
||||
String name = file.getOriginalFilename();
|
||||
return name == null || name.isBlank() ? "attachment" : Path.of(name).getFileName().toString();
|
||||
if (name == null || name.isBlank()) return "attachment";
|
||||
String normalized = name.replace('\\', '/');
|
||||
normalized = normalized.substring(normalized.lastIndexOf('/') + 1)
|
||||
.replaceAll("[\\p{Cntrl}]", "").trim();
|
||||
if (normalized.isBlank()) return "attachment";
|
||||
int codePoints = normalized.codePointCount(0, normalized.length());
|
||||
if (codePoints <= 255) return normalized;
|
||||
return normalized.substring(normalized.offsetByCodePoints(0, codePoints - 255));
|
||||
}
|
||||
|
||||
private static String sha256(Path path) throws IOException {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.baiyue.file.service;
|
||||
|
||||
import com.baiyue.common.core.export.ExportContracts.ExportColumn;
|
||||
import com.baiyue.common.core.export.ExportContracts.ExportColumnType;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
final class ExportSchemaContracts {
|
||||
static final String BUY_DECLARATION_ITEMS = "LOADING_LIST_BUY_DECLARATION_ITEMS";
|
||||
static final List<ExpectedColumn> BUY_DECLARATION_COLUMNS = List.of(
|
||||
column("customerName", "客户名称", ExportColumnType.STRING),
|
||||
column("orderNo", "订单号", ExportColumnType.STRING),
|
||||
column("inboundCode", "入仓码", ExportColumnType.STRING),
|
||||
column("sku", "SKU", ExportColumnType.STRING),
|
||||
column("chineseName", "中文品名", ExportColumnType.STRING),
|
||||
column("englishName", "英文品名", ExportColumnType.STRING),
|
||||
column("cartonCount", "箱数", ExportColumnType.INTEGER),
|
||||
column("productQuantity", "产品数量", ExportColumnType.INTEGER),
|
||||
column("grossWeight", "毛重", ExportColumnType.DECIMAL),
|
||||
column("netWeight", "净重", ExportColumnType.DECIMAL),
|
||||
column("volume", "体积", ExportColumnType.DECIMAL),
|
||||
column("mark", "唛头", ExportColumnType.STRING),
|
||||
column("customerRemark", "客户备注", ExportColumnType.STRING));
|
||||
|
||||
private ExportSchemaContracts() { }
|
||||
|
||||
static void validate(String exportType, List<ExportColumn> columns) {
|
||||
if (!BUY_DECLARATION_ITEMS.equals(exportType)) return;
|
||||
if (columns.size() != BUY_DECLARATION_COLUMNS.size()) {
|
||||
throw ExportFailure.permanent("买单报关导出列定义不匹配");
|
||||
}
|
||||
for (int index = 0; index < columns.size(); index++) {
|
||||
ExportColumn actual = columns.get(index);
|
||||
ExpectedColumn expected = BUY_DECLARATION_COLUMNS.get(index);
|
||||
if (!expected.key.equals(actual.key()) || !expected.title.equals(actual.title())
|
||||
|| expected.type != actual.type()) {
|
||||
throw ExportFailure.permanent("买单报关导出列定义不匹配");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static ExpectedColumn column(String key, String title, ExportColumnType type) {
|
||||
return new ExpectedColumn(key, title, type);
|
||||
}
|
||||
|
||||
record ExpectedColumn(String key, String title, ExportColumnType type) { }
|
||||
}
|
||||
@@ -45,6 +45,7 @@ public class ExportTaskService {
|
||||
throw new BizException(ResultCode.FORBIDDEN, "缺少该导出类型的操作权限");
|
||||
}
|
||||
validateParameters(request.parameters());
|
||||
validateTypeParameters(type, request.parameters());
|
||||
String parametersJson = parametersJson(request.parameters());
|
||||
long id = IdWorker.getId();
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
@@ -176,6 +177,21 @@ public class ExportTaskService {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED, "导出筛选值类型无效");
|
||||
}
|
||||
|
||||
private void validateTypeParameters(String type, Map<String, Object> parameters) {
|
||||
if (!ExportSchemaContracts.BUY_DECLARATION_ITEMS.equals(type)) return;
|
||||
Map<String, Object> values = parameters == null ? Map.of() : parameters;
|
||||
if (!values.keySet().equals(java.util.Set.of("loadingPlanId"))) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED, "买单报关导出仅接受 loadingPlanId");
|
||||
}
|
||||
Object raw = values.get("loadingPlanId");
|
||||
try {
|
||||
if (!(raw instanceof String text) || !text.matches("[1-9][0-9]*")
|
||||
|| Long.parseLong(text) <= 0) throw new NumberFormatException();
|
||||
} catch (NumberFormatException exception) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED, "loadingPlanId 必须为正整数字符串");
|
||||
}
|
||||
}
|
||||
|
||||
private ExportTaskResponse response(FileExportTask task) {
|
||||
return new ExportTaskResponse(String.valueOf(task.getId()), task.getTaskNo(), task.getExportType(),
|
||||
task.getFormat(), task.getTaskName(), task.getStatus(), task.getTotalRows(),
|
||||
|
||||
@@ -88,7 +88,9 @@ public class GenericXlsxExportRunner {
|
||||
if (batch.schemaVersion() != 1 || blank(batch.schemaHash()) || blank(batch.snapshotToken()) || batch.snapshotToken().length() > 512) {
|
||||
throw ExportFailure.permanent("Provider 首批契约无效");
|
||||
}
|
||||
columns = validateColumns(batch.columns()); columnKeys = columns.stream().map(ExportColumn::key).collect(java.util.stream.Collectors.toUnmodifiableSet()); schemaHash = batch.schemaHash(); snapshot = batch.snapshotToken();
|
||||
columns = validateColumns(batch.columns());
|
||||
ExportSchemaContracts.validate(task.getExportType(), columns);
|
||||
columnKeys = columns.stream().map(ExportColumn::key).collect(java.util.stream.Collectors.toUnmodifiableSet()); schemaHash = batch.schemaHash(); snapshot = batch.snapshotToken();
|
||||
sheet = newSheet(workbook, 1, columns);
|
||||
} else if (!schemaHash.equals(batch.schemaHash())) {
|
||||
throw ExportFailure.permanent("Provider 导出列定义在执行期间发生变化");
|
||||
|
||||
@@ -10,23 +10,8 @@ import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Set;
|
||||
|
||||
@Service
|
||||
public class InternalBusinessFileService {
|
||||
private static final Set<String> BUSINESS_TYPES = Set.of(
|
||||
"TRUCK_APPOINTMENT_ATTACHMENT",
|
||||
"CUSTOMS_DOCUMENT",
|
||||
"CUSTOMS_RECEIPT",
|
||||
"FINANCE_VOUCHER",
|
||||
"CONTAINER_CLEARANCE_DOCUMENT",
|
||||
"LOADING_CUSTOMS_DECLARATION",
|
||||
"CONTAINER_DELIVERY_ORDER",
|
||||
"CONTAINER_PICKUP_ORDER",
|
||||
"OVERSEAS_UNLOAD_PHOTO",
|
||||
"OVERSEAS_RELABEL_PHOTO",
|
||||
"OVERSEAS_CUSTOMER_VISIT");
|
||||
|
||||
@Resource private FileObjectMapper files;
|
||||
@Resource private BusinessFileStorage storage;
|
||||
|
||||
@@ -35,9 +20,10 @@ public class InternalBusinessFileService {
|
||||
if (tenantId < 0 || operatorId <= 0) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "归属和操作人必须有效");
|
||||
}
|
||||
validateBusinessType(businessType);
|
||||
BusinessFileCategory category = BusinessFileCategory.require(businessType);
|
||||
category.validateOwner(tenantId);
|
||||
long parsedBusinessId = parseBusinessId(businessId);
|
||||
var stored = storage.storeLoading(tenantId, parsedBusinessId, file);
|
||||
var stored = storage.store(tenantId, parsedBusinessId, file, category.maxSizeBytes(), category.arbitraryFormat());
|
||||
FileObjectEntity value = new FileObjectEntity();
|
||||
value.setTenantId(tenantId);
|
||||
value.setBusinessType(businessType);
|
||||
@@ -90,16 +76,11 @@ public class InternalBusinessFileService {
|
||||
if (tenantId < 0 || fileId <= 0) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "归属和文件ID必须有效");
|
||||
}
|
||||
validateBusinessType(businessType);
|
||||
BusinessFileCategory category = BusinessFileCategory.require(businessType);
|
||||
category.validateOwner(tenantId);
|
||||
parseBusinessId(businessId);
|
||||
}
|
||||
|
||||
private static void validateBusinessType(String businessType) {
|
||||
if (!BUSINESS_TYPES.contains(businessType)) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "不支持的附件业务类型");
|
||||
}
|
||||
}
|
||||
|
||||
private static long parseBusinessId(String businessId) {
|
||||
final long value;
|
||||
try {
|
||||
@@ -112,11 +93,12 @@ public class InternalBusinessFileService {
|
||||
}
|
||||
|
||||
private static Metadata metadata(FileObjectEntity value) {
|
||||
return new Metadata(value.getId(), value.getTenantId(), value.getBusinessType(), value.getBusinessId(),
|
||||
return new Metadata(value.getId(), value.getTenantId() == 0 ? "PLATFORM" : "CUSTOMER",
|
||||
value.getTenantId(), value.getBusinessType(), value.getBusinessId(),
|
||||
value.getOriginalName(), value.getSha256(), value.getContentType(), value.getSizeBytes());
|
||||
}
|
||||
|
||||
public record Metadata(Long fileId, Long tenantId, String businessType, String businessId,
|
||||
public record Metadata(Long fileId, String ownerScope, Long tenantId, String businessType, String businessId,
|
||||
String fileName, String sha256, String contentType, Long sizeBytes) {}
|
||||
public record Download(String fileName, String contentType, byte[] content) {}
|
||||
}
|
||||
|
||||
@@ -11,22 +11,28 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Set;
|
||||
|
||||
/** 装柜四步照片和铅封证据的受控文件入口。 */
|
||||
@Service
|
||||
public class LoadingEvidenceService {
|
||||
private static final long PLATFORM_TENANT_ID = 0L;
|
||||
private static final String PLATFORM_OWNER_SCOPE = "PLATFORM";
|
||||
private static final Set<String> BUSINESS_TYPES = Set.of(
|
||||
"LOADING_PHOTO_STEP_1", "LOADING_PHOTO_STEP_2", "LOADING_PHOTO_STEP_3",
|
||||
"LOADING_PHOTO_STEP_4", "LOADING_SEAL");
|
||||
@Resource private MeasurementImageStorage storage;
|
||||
@Resource private FileObjectMapper files;
|
||||
|
||||
@Transactional
|
||||
public UploadResult upload(long tenantId, long inboundId, long operatorId, String evidenceType,
|
||||
public UploadResult upload(long containerBookingId, long operatorId, String evidenceType,
|
||||
MultipartFile file) {
|
||||
validateType(evidenceType);
|
||||
var stored = storage.storeLoading(tenantId, inboundId, file);
|
||||
var stored = storage.storeLoading(PLATFORM_TENANT_ID, containerBookingId, file);
|
||||
FileObjectEntity object = new FileObjectEntity();
|
||||
object.setTenantId(tenantId);
|
||||
object.setTenantId(PLATFORM_TENANT_ID);
|
||||
object.setBusinessType("LOADING_" + evidenceType);
|
||||
object.setBusinessId(Long.toString(inboundId));
|
||||
object.setBusinessId(Long.toString(containerBookingId));
|
||||
object.setOriginalName(stored.originalName());
|
||||
object.setObjectKey(stored.objectKey());
|
||||
object.setStorageProvider("LOCAL");
|
||||
@@ -39,24 +45,28 @@ public class LoadingEvidenceService {
|
||||
object.setCreatedAt(LocalDateTime.now()); object.setUpdatedAt(LocalDateTime.now());
|
||||
object.setDeleted(false); object.setVersion(0);
|
||||
files.insert(object);
|
||||
return new UploadResult(object.getId(), object.getOriginalName(), object.getContentType(), object.getSizeBytes(), object.getBusinessType());
|
||||
return new UploadResult(object.getId(), PLATFORM_OWNER_SCOPE, object.getTenantId(),
|
||||
object.getBusinessType(), object.getBusinessId(), object.getOriginalName(),
|
||||
object.getContentType(), object.getSizeBytes());
|
||||
}
|
||||
|
||||
public FileMetadata validate(long tenantId, long inboundId, long fileId) {
|
||||
FileObjectEntity object = requireObject(tenantId, inboundId, fileId);
|
||||
return new FileMetadata(object.getId(), object.getOriginalName(), object.getBusinessType(), object.getContentType());
|
||||
public FileMetadata validate(long containerBookingId, long fileId) {
|
||||
FileObjectEntity object = requireObject(containerBookingId, fileId);
|
||||
return new FileMetadata(object.getId(), PLATFORM_OWNER_SCOPE, object.getTenantId(),
|
||||
object.getBusinessType(), object.getBusinessId(), object.getOriginalName(), object.getContentType());
|
||||
}
|
||||
|
||||
public Download download(long tenantId, long inboundId, long fileId) {
|
||||
FileObjectEntity object = requireObject(tenantId, inboundId, fileId);
|
||||
public Download download(long containerBookingId, long fileId) {
|
||||
FileObjectEntity object = requireObject(containerBookingId, fileId);
|
||||
return new Download(object.getOriginalName(), object.getContentType(), storage.read(object.getObjectKey()));
|
||||
}
|
||||
|
||||
private FileObjectEntity requireObject(long tenantId, long inboundId, long fileId) {
|
||||
private FileObjectEntity requireObject(long containerBookingId, long fileId) {
|
||||
FileObjectEntity object = files.selectOne(new LambdaQueryWrapper<FileObjectEntity>()
|
||||
.eq(FileObjectEntity::getTenantId, tenantId).eq(FileObjectEntity::getId, fileId)
|
||||
.eq(FileObjectEntity::getBusinessId, Long.toString(inboundId))
|
||||
.likeRight(FileObjectEntity::getBusinessType, "LOADING_")
|
||||
.eq(FileObjectEntity::getTenantId, PLATFORM_TENANT_ID).eq(FileObjectEntity::getId, fileId)
|
||||
.eq(FileObjectEntity::getBusinessId, Long.toString(containerBookingId))
|
||||
.in(FileObjectEntity::getBusinessType, BUSINESS_TYPES)
|
||||
.eq(FileObjectEntity::getStatus, "ACTIVE")
|
||||
.eq(FileObjectEntity::getDeleted, false));
|
||||
if (object == null) throw new BizException(ResultCode.NOT_FOUND, "装柜证据文件不存在");
|
||||
return object;
|
||||
@@ -68,7 +78,9 @@ public class LoadingEvidenceService {
|
||||
}
|
||||
}
|
||||
|
||||
public record UploadResult(Long fileId, String fileName, String contentType, Long sizeBytes, String businessType) {}
|
||||
public record FileMetadata(Long fileId, String fileName, String businessType, String contentType) {}
|
||||
public record UploadResult(Long fileId, String ownerScope, Long tenantId, String businessType,
|
||||
String businessId, String fileName, String contentType, Long sizeBytes) {}
|
||||
public record FileMetadata(Long fileId, String ownerScope, Long tenantId, String businessType,
|
||||
String businessId, String fileName, String contentType) {}
|
||||
public record Download(String fileName, String contentType, byte[] content) {}
|
||||
}
|
||||
|
||||
@@ -40,8 +40,8 @@ spring:
|
||||
read-timeout: 15000
|
||||
servlet:
|
||||
multipart:
|
||||
max-file-size: 10MB
|
||||
max-request-size: 11MB
|
||||
max-file-size: 50MB
|
||||
max-request-size: 51MB
|
||||
rocketmq:
|
||||
name-server: ${ROCKETMQ_NAME_SERVER:127.0.0.1:9876}
|
||||
producer:
|
||||
@@ -101,6 +101,15 @@ baiyue:
|
||||
max-rows: 200000
|
||||
max-attempts: 3
|
||||
batch-size: 500
|
||||
LOADING_LIST_BUY_DECLARATION_ITEMS:
|
||||
provider-service: firstleg-service
|
||||
provider-path: /internal/exports/v1/LOADING_LIST_BUY_DECLARATION_ITEMS/batches
|
||||
required-permission: platform:firstleg:customs:manage
|
||||
app-scope: PLATFORM
|
||||
enabled: true
|
||||
max-rows: 200000
|
||||
max-attempts: 3
|
||||
batch-size: 500
|
||||
FINANCE_CUSTOMER_ACCOUNT_LIST:
|
||||
provider-service: finance-service
|
||||
provider-path: /internal/exports/v1/FINANCE_CUSTOMER_ACCOUNT_LIST/batches
|
||||
|
||||
@@ -5,9 +5,9 @@ USE baiyue_file;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS file_object (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '文件ID(雪花ID)',
|
||||
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
|
||||
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '所有者范围键:0为平台,正整数为客户ID',
|
||||
business_type VARCHAR(32) NULL COMMENT '关联业务类型',
|
||||
business_id VARCHAR(64) NULL COMMENT '关联业务逻辑ID',
|
||||
business_id VARCHAR(64) NULL COMMENT '关联业务逻辑ID;装柜证据固定为物理containerBookingId',
|
||||
original_name VARCHAR(255) NOT NULL COMMENT '原始文件名',
|
||||
object_key VARCHAR(512) NOT NULL COMMENT '对象存储键',
|
||||
storage_provider VARCHAR(32) NOT NULL COMMENT '存储提供方',
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: File Customs Business File and Export Contract
|
||||
version: 1.0.0
|
||||
description: |
|
||||
File 只保存不可变文件对象并生成异步 XLSX,不保存报关状态或业务版本。
|
||||
Firstleg 持有 loadingPlanId、文件版本、完成状态和可见性,并在调用本内部接口前完成 Platform/Client 鉴权。
|
||||
LOADING_LIST_CUSTOMS_SOURCE 与 LOADING_LIST_CUSTOMS_RESULT 均为 CUSTOMER owner scope,tenantId 必须大于 0;
|
||||
客户源文件上传/下载由 Firstleg 校验当前客户,结果文件下载还须由 Firstleg 校验整柜完成和结果可见性。
|
||||
用户侧权限冻结为:Platform 上传结果 platform:firstleg:customs:manage,Platform 下载 platform:firstleg:customs:list;
|
||||
Client 上传源文件 merchant:order:edit,Client 下载自身源文件或获准结果 merchant:order:view。
|
||||
买单客户是否有结果入口由 Firstleg 判定,File 不据此增加结果文件或下载入口。
|
||||
paths:
|
||||
/internal/file/v1/business-files/{tenantId}/{fileCategory}/{loadingPlanId}:
|
||||
post:
|
||||
summary: 保存报关业务文件对象
|
||||
description: |
|
||||
仅服务令牌可调用。fileCategory 取 LOADING_LIST_CUSTOMS_SOURCE 或 LOADING_LIST_CUSTOMS_RESULT。
|
||||
支持任意文件格式,单文件最多 50MB。operatorId 为已通过 Firstleg 鉴权的实际操作人。
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/InternalToken'
|
||||
- $ref: '#/components/parameters/OperatorId'
|
||||
- $ref: '#/components/parameters/TenantId'
|
||||
- $ref: '#/components/parameters/FileCategory'
|
||||
- $ref: '#/components/parameters/LoadingPlanId'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
multipart/form-data:
|
||||
schema:
|
||||
type: object
|
||||
required: [file]
|
||||
properties:
|
||||
file: {type: string, format: binary}
|
||||
responses:
|
||||
'200':
|
||||
description: ApiResponse<BusinessFileMetadata>
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/BusinessFileResponse'}
|
||||
/internal/file/v1/business-files/{tenantId}/{fileCategory}/{loadingPlanId}/{fileId}:
|
||||
get:
|
||||
summary: 校验报关文件对象的精确复合归属
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/InternalToken'
|
||||
- $ref: '#/components/parameters/TenantId'
|
||||
- $ref: '#/components/parameters/FileCategory'
|
||||
- $ref: '#/components/parameters/LoadingPlanId'
|
||||
- $ref: '#/components/parameters/FileId'
|
||||
responses:
|
||||
'200':
|
||||
description: ApiResponse<BusinessFileMetadata>
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/BusinessFileResponse'}
|
||||
/internal/file/v1/business-files/{tenantId}/{fileCategory}/{loadingPlanId}/{fileId}/content:
|
||||
get:
|
||||
summary: 下载精确归属的报关文件对象
|
||||
description: 响应固定为 attachment 且 Cache-Control 包含 no-store;用户可见性由 Firstleg 在调用前判定。
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/InternalToken'
|
||||
- $ref: '#/components/parameters/TenantId'
|
||||
- $ref: '#/components/parameters/FileCategory'
|
||||
- $ref: '#/components/parameters/LoadingPlanId'
|
||||
- $ref: '#/components/parameters/FileId'
|
||||
responses:
|
||||
'200':
|
||||
description: 文件内容
|
||||
headers:
|
||||
Cache-Control: {schema: {type: string, example: no-store}}
|
||||
Content-Disposition: {schema: {type: string, example: attachment}}
|
||||
content:
|
||||
application/octet-stream:
|
||||
schema: {type: string, format: binary}
|
||||
/internal/exports/v1/LOADING_LIST_BUY_DECLARATION_ITEMS/batches:
|
||||
post:
|
||||
summary: Firstleg 买单报关明细导出 Provider 契约
|
||||
description: |
|
||||
此路径由 Firstleg 提供、File 调用。一柜一份;parameters 只能包含正整数 loadingPlanId。
|
||||
File 创建该导出任务的唯一 requiredPermission 为 platform:firstleg:customs:manage。
|
||||
Provider 必须按 schemaVersion=1 返回下列固定顺序列,File 校验后生成 XLSX。
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/InternalToken'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/ExportBatchRequest'}
|
||||
responses:
|
||||
'200':
|
||||
description: ApiResponse<ExportBatchResponse>
|
||||
components:
|
||||
parameters:
|
||||
InternalToken: {name: X-Internal-Service-Token, in: header, required: true, schema: {type: string}}
|
||||
OperatorId: {name: X-Operator-Id, in: header, required: true, schema: {type: integer, format: int64, minimum: 1}}
|
||||
TenantId: {name: tenantId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}, description: 客户ID;不得使用平台0作用域}
|
||||
FileCategory: {name: fileCategory, in: path, required: true, schema: {type: string, enum: [LOADING_LIST_CUSTOMS_SOURCE, LOADING_LIST_CUSTOMS_RESULT]}}
|
||||
LoadingPlanId: {name: loadingPlanId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}}
|
||||
FileId: {name: fileId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}}
|
||||
schemas:
|
||||
BusinessFileResponse:
|
||||
type: object
|
||||
required: [code, data]
|
||||
properties:
|
||||
code: {type: string, example: '200'}
|
||||
message: {type: string}
|
||||
data: {$ref: '#/components/schemas/BusinessFileMetadata'}
|
||||
BusinessFileMetadata:
|
||||
type: object
|
||||
required: [fileId, ownerScope, tenantId, businessType, businessId, fileName, sha256, contentType, sizeBytes]
|
||||
properties:
|
||||
fileId: {type: string, description: File不可变对象ID,跨前端按字符串传输}
|
||||
ownerScope: {type: string, enum: [CUSTOMER]}
|
||||
tenantId: {type: string, description: CUSTOMER owner scope客户ID}
|
||||
businessType: {type: string, description: fileCategory}
|
||||
businessId: {type: string, description: loadingPlanId}
|
||||
fileName: {type: string}
|
||||
sha256: {type: string}
|
||||
contentType: {type: string}
|
||||
sizeBytes: {type: integer, format: int64, maximum: 52428800}
|
||||
ExportBatchRequest:
|
||||
type: object
|
||||
required: [taskId, tenantId, appCode, requestedBy, dispatchVersion, parameters, limit]
|
||||
properties:
|
||||
taskId: {type: string}
|
||||
tenantId: {type: string, enum: ['0']}
|
||||
appCode: {type: string, enum: [PLATFORM]}
|
||||
requestedBy: {type: string}
|
||||
dispatchVersion: {type: integer}
|
||||
parameters:
|
||||
type: object
|
||||
required: [loadingPlanId]
|
||||
additionalProperties: false
|
||||
properties:
|
||||
loadingPlanId: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
cursor: {type: string, nullable: true}
|
||||
snapshotToken: {type: string, nullable: true}
|
||||
limit: {type: integer, minimum: 50, maximum: 1000}
|
||||
BuyDeclarationColumns:
|
||||
type: object
|
||||
description: 固定列顺序
|
||||
properties:
|
||||
customerName: {type: string, title: 客户名称}
|
||||
orderNo: {type: string, title: 订单号}
|
||||
inboundCode: {type: string, title: 入仓码}
|
||||
sku: {type: string, title: SKU}
|
||||
chineseName: {type: string, title: 中文品名}
|
||||
englishName: {type: string, title: 英文品名}
|
||||
cartonCount: {type: integer, title: 箱数}
|
||||
productQuantity: {type: integer, title: 产品数量}
|
||||
grossWeight: {type: number, title: 毛重}
|
||||
netWeight: {type: number, title: 净重}
|
||||
volume: {type: number, title: 体积}
|
||||
mark: {type: string, title: 唛头}
|
||||
customerRemark: {type: string, title: 客户备注}
|
||||
@@ -0,0 +1,196 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Loading Evidence File API
|
||||
version: 1.0.0
|
||||
description: >-
|
||||
装柜四步照片与铅封文件只归属物理柜。所有文件固定 ownerScope=PLATFORM、tenantId=0、
|
||||
businessId=containerBookingId;PDA taskId 仅由 Firstleg 保存为来源和审计,不参与 File 归属。
|
||||
servers:
|
||||
- url: /
|
||||
tags:
|
||||
- name: LoadingEvidence
|
||||
paths:
|
||||
/api/platform/v1/files/loading-evidence/{containerBookingId}:
|
||||
post:
|
||||
tags: [LoadingEvidence]
|
||||
summary: Admin上传柜级装柜证据
|
||||
description: >-
|
||||
仅Admin装柜证据维护权限可直连;国内PDA禁止直连File,必须先调用Firstleg任务证据接口。
|
||||
不接受客户ID或入仓单ID;同一文件不得跨containerBookingId复用。
|
||||
x-required-permission: platform:firstleg:inbound:loading:edit
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/ContainerBookingId'
|
||||
- $ref: '#/components/parameters/EvidenceType'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
multipart/form-data:
|
||||
schema:
|
||||
type: object
|
||||
required: [file]
|
||||
properties:
|
||||
file: {type: string, format: binary}
|
||||
responses:
|
||||
'200':
|
||||
description: 上传结果
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/UploadResponse'}
|
||||
/api/platform/v1/files/loading-evidence/{containerBookingId}/{fileId}:
|
||||
get:
|
||||
tags: [LoadingEvidence]
|
||||
summary: Admin下载柜级装柜证据
|
||||
description: >-
|
||||
仅Admin装柜证据读取权限可直连;国内PDA必须经Firstleg任务、仓库和操作者校验后,由Firstleg使用内部服务令牌下载。
|
||||
文件必须同时命中平台所有权、containerBookingId、fileId、五类LOADING业务类型和ACTIVE状态;响应禁止缓存。
|
||||
x-required-permission: platform:firstleg:inbound:loading:view
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/ContainerBookingId'
|
||||
- $ref: '#/components/parameters/FileId'
|
||||
responses:
|
||||
'200':
|
||||
description: 文件内容
|
||||
headers:
|
||||
Cache-Control:
|
||||
schema: {type: string, enum: [no-store]}
|
||||
content:
|
||||
image/jpeg: {schema: {type: string, format: binary}}
|
||||
image/png: {schema: {type: string, format: binary}}
|
||||
image/bmp: {schema: {type: string, format: binary}}
|
||||
application/octet-stream: {schema: {type: string, format: binary}}
|
||||
/internal/file/v1/loading-evidence/{containerBookingId}:
|
||||
post:
|
||||
tags: [LoadingEvidence]
|
||||
summary: Firstleg内部上传柜级装柜证据
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/InternalToken'
|
||||
- $ref: '#/components/parameters/OperatorId'
|
||||
- $ref: '#/components/parameters/ContainerBookingId'
|
||||
- $ref: '#/components/parameters/EvidenceType'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
multipart/form-data:
|
||||
schema:
|
||||
type: object
|
||||
required: [file]
|
||||
properties:
|
||||
file: {type: string, format: binary}
|
||||
responses:
|
||||
'200':
|
||||
description: 上传结果
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/UploadResponse'}
|
||||
/internal/file/v1/loading-evidence/{containerBookingId}/{fileId}:
|
||||
get:
|
||||
tags: [LoadingEvidence]
|
||||
summary: Firstleg内部校验柜级文件元数据
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/InternalToken'
|
||||
- $ref: '#/components/parameters/ContainerBookingId'
|
||||
- $ref: '#/components/parameters/FileId'
|
||||
responses:
|
||||
'200':
|
||||
description: 元数据
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/MetadataResponse'}
|
||||
/internal/file/v1/loading-evidence/{containerBookingId}/{fileId}/content:
|
||||
get:
|
||||
tags: [LoadingEvidence]
|
||||
summary: Firstleg内部下载柜级文件
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/InternalToken'
|
||||
- $ref: '#/components/parameters/ContainerBookingId'
|
||||
- $ref: '#/components/parameters/FileId'
|
||||
responses:
|
||||
'200':
|
||||
description: 文件内容,Cache-Control固定no-store
|
||||
headers:
|
||||
Cache-Control:
|
||||
schema: {type: string, enum: [no-store]}
|
||||
content:
|
||||
application/octet-stream: {schema: {type: string, format: binary}}
|
||||
components:
|
||||
parameters:
|
||||
InternalToken:
|
||||
in: header
|
||||
name: X-Internal-Service-Token
|
||||
required: true
|
||||
schema: {type: string}
|
||||
OperatorId:
|
||||
in: header
|
||||
name: X-Operator-Id
|
||||
required: true
|
||||
schema: {$ref: '#/components/schemas/Id'}
|
||||
ContainerBookingId:
|
||||
in: path
|
||||
name: containerBookingId
|
||||
required: true
|
||||
schema: {$ref: '#/components/schemas/Id'}
|
||||
FileId:
|
||||
in: path
|
||||
name: fileId
|
||||
required: true
|
||||
schema: {$ref: '#/components/schemas/Id'}
|
||||
EvidenceType:
|
||||
in: query
|
||||
name: evidenceType
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
enum: [PHOTO_STEP_1, PHOTO_STEP_2, PHOTO_STEP_3, PHOTO_STEP_4, SEAL]
|
||||
schemas:
|
||||
Id:
|
||||
type: string
|
||||
pattern: '^[1-9][0-9]*$'
|
||||
ApiEnvelope:
|
||||
type: object
|
||||
required: [code, message, requestId, traceId, timestamp]
|
||||
properties:
|
||||
code: {type: string}
|
||||
message: {type: string}
|
||||
requestId: {type: string}
|
||||
traceId: {type: string}
|
||||
timestamp: {type: string, format: date-time}
|
||||
UploadResponse:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/ApiEnvelope'
|
||||
- type: object
|
||||
required: [data]
|
||||
properties:
|
||||
data: {$ref: '#/components/schemas/UploadResult'}
|
||||
MetadataResponse:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/ApiEnvelope'
|
||||
- type: object
|
||||
required: [data]
|
||||
properties:
|
||||
data: {$ref: '#/components/schemas/FileMetadata'}
|
||||
FileIdentity:
|
||||
type: object
|
||||
required: [fileId, ownerScope, tenantId, businessType, businessId, fileName, contentType]
|
||||
properties:
|
||||
fileId: {$ref: '#/components/schemas/Id'}
|
||||
ownerScope: {type: string, enum: [PLATFORM]}
|
||||
tenantId: {type: string, enum: ['0']}
|
||||
businessType:
|
||||
type: string
|
||||
enum: [LOADING_PHOTO_STEP_1, LOADING_PHOTO_STEP_2, LOADING_PHOTO_STEP_3, LOADING_PHOTO_STEP_4, LOADING_SEAL]
|
||||
businessId:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/Id'
|
||||
description: 物理containerBookingId
|
||||
fileName: {type: string}
|
||||
contentType: {type: string}
|
||||
UploadResult:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/FileIdentity'
|
||||
- type: object
|
||||
required: [sizeBytes]
|
||||
properties:
|
||||
sizeBytes: {type: integer, format: int64, minimum: 1}
|
||||
FileMetadata:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/FileIdentity'
|
||||
@@ -0,0 +1,114 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Ocean Booking Document Internal API
|
||||
version: 1.0.0
|
||||
description: >-
|
||||
订舱文件归属订舱聚合,不归属拖车。固定ownerScope=CUSTOMER、tenantId=订舱customerId、
|
||||
businessType=OCEAN_BOOKING_DOCUMENT、businessId=oceanBookingId。BOOKING_CONFIRMATION与
|
||||
BILL_OF_LADING文档类型、版本和有效状态归Firstleg;File不判断订舱业务状态。
|
||||
x-firstleg-document-types: [BOOKING_CONFIRMATION, BILL_OF_LADING]
|
||||
x-lifecycle-owner: FIRSTLEG
|
||||
x-file-object-semantics: IMMUTABLE_CREATE_ONLY
|
||||
paths:
|
||||
/internal/file/v1/business-files/{tenantId}/OCEAN_BOOKING_DOCUMENT/{oceanBookingId}:
|
||||
post:
|
||||
summary: Firstleg上传订舱附件
|
||||
security: [{internalServiceToken: []}]
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/OperatorId'}
|
||||
- {$ref: '#/components/parameters/TenantId'}
|
||||
- {$ref: '#/components/parameters/OceanBookingId'}
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
multipart/form-data:
|
||||
schema:
|
||||
type: object
|
||||
required: [file]
|
||||
properties:
|
||||
file:
|
||||
type: string
|
||||
format: binary
|
||||
description: 任意格式,最大50MiB;File保留规范化Content-Type与原始文件名
|
||||
responses:
|
||||
'200': {$ref: '#/components/responses/Metadata'}
|
||||
/internal/file/v1/business-files/{tenantId}/OCEAN_BOOKING_DOCUMENT/{oceanBookingId}/{fileId}:
|
||||
get:
|
||||
summary: Firstleg校验订舱附件元数据
|
||||
security: [{internalServiceToken: []}]
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/TenantId'}
|
||||
- {$ref: '#/components/parameters/OceanBookingId'}
|
||||
- {$ref: '#/components/parameters/FileId'}
|
||||
responses:
|
||||
'200': {$ref: '#/components/responses/Metadata'}
|
||||
/internal/file/v1/business-files/{tenantId}/OCEAN_BOOKING_DOCUMENT/{oceanBookingId}/{fileId}/content:
|
||||
get:
|
||||
summary: Firstleg下载订舱附件
|
||||
security: [{internalServiceToken: []}]
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/TenantId'}
|
||||
- {$ref: '#/components/parameters/OceanBookingId'}
|
||||
- {$ref: '#/components/parameters/FileId'}
|
||||
responses:
|
||||
'200':
|
||||
description: 附件内容;错误订舱ID、拖车业务类型或文件ID不得命中
|
||||
headers:
|
||||
Cache-Control: {schema: {type: string, enum: [no-store]}}
|
||||
content:
|
||||
application/octet-stream: {schema: {type: string, format: binary}}
|
||||
components:
|
||||
securitySchemes:
|
||||
internalServiceToken: {type: apiKey, in: header, name: X-Internal-Service-Token}
|
||||
parameters:
|
||||
OperatorId:
|
||||
name: X-Operator-Id
|
||||
in: header
|
||||
required: true
|
||||
schema: {$ref: '#/components/schemas/Id'}
|
||||
TenantId:
|
||||
name: tenantId
|
||||
in: path
|
||||
required: true
|
||||
description: 订舱customerId
|
||||
schema: {$ref: '#/components/schemas/Id'}
|
||||
OceanBookingId:
|
||||
name: oceanBookingId
|
||||
in: path
|
||||
required: true
|
||||
schema: {$ref: '#/components/schemas/Id'}
|
||||
FileId:
|
||||
name: fileId
|
||||
in: path
|
||||
required: true
|
||||
schema: {$ref: '#/components/schemas/Id'}
|
||||
responses:
|
||||
Metadata:
|
||||
description: 精确订舱归属元数据
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [code, data]
|
||||
properties:
|
||||
code: {type: string, enum: ['200']}
|
||||
data: {$ref: '#/components/schemas/Metadata'}
|
||||
schemas:
|
||||
Id: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
Metadata:
|
||||
type: object
|
||||
required: [fileId, ownerScope, tenantId, businessType, businessId, fileName, sha256, contentType, sizeBytes]
|
||||
properties:
|
||||
fileId: {$ref: '#/components/schemas/Id'}
|
||||
ownerScope: {type: string, enum: [CUSTOMER]}
|
||||
tenantId:
|
||||
allOf: [{$ref: '#/components/schemas/Id'}]
|
||||
description: 订舱customerId
|
||||
businessType: {type: string, enum: [OCEAN_BOOKING_DOCUMENT]}
|
||||
businessId:
|
||||
allOf: [{$ref: '#/components/schemas/Id'}]
|
||||
description: oceanBookingId
|
||||
fileName: {type: string}
|
||||
sha256: {type: string, pattern: '^[0-9a-f]{64}$'}
|
||||
contentType: {type: string}
|
||||
sizeBytes: {type: integer, format: int64, minimum: 1, maximum: 52428800}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.baiyue.file.config;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class CustomsFileExportContractTest {
|
||||
private static final String TYPE = "LOADING_LIST_BUY_DECLARATION_ITEMS";
|
||||
|
||||
@Test void applicationAndNacosRegisterTheSameFirstlegProviderContract() throws Exception {
|
||||
for (String file : new String[]{
|
||||
"file/src/main/resources/application.yml",
|
||||
"config/nacos/local/file-service.yml",
|
||||
"config/nacos/cn/file-service.yml"}) {
|
||||
String source = Files.readString(repositoryRoot().resolve(file));
|
||||
assertThat(source).contains(TYPE + ":")
|
||||
.contains("provider-service: firstleg-service")
|
||||
.contains("provider-path: /internal/exports/v1/" + TYPE + "/batches")
|
||||
.contains("required-permission: platform:firstleg:customs:manage")
|
||||
.contains("app-scope: PLATFORM")
|
||||
.doesNotContain("loading-list:export");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void openApiFreezesOwnerScopeSizeVisibilityAndColumns() throws Exception {
|
||||
String source = Files.readString(repositoryRoot()
|
||||
.resolve("file/src/main/resources/openapi/customs-file-and-export-v1.yaml"));
|
||||
|
||||
assertThat(source).contains("LOADING_LIST_CUSTOMS_SOURCE", "LOADING_LIST_CUSTOMS_RESULT")
|
||||
.contains("maximum: 52428800", "no-store", "CUSTOMER owner scope", "loadingPlanId")
|
||||
.contains("platform:firstleg:customs:manage", "platform:firstleg:customs:list",
|
||||
"merchant:order:edit", "merchant:order:view")
|
||||
.contains("customerName", "orderNo", "inboundCode", "sku", "chineseName", "englishName",
|
||||
"cartonCount", "productQuantity", "grossWeight", "netWeight", "volume", "mark",
|
||||
"customerRemark")
|
||||
.doesNotContain("productNameZh", "productNameEn", "shippingMark", "loading-list:export",
|
||||
"platform:firstleg:loading-list:list");
|
||||
}
|
||||
|
||||
private static Path repositoryRoot() {
|
||||
Path current = Path.of("").toAbsolutePath();
|
||||
return Files.isDirectory(current.resolve("file")) ? current : current.getParent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.baiyue.file.config;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class LoadingEvidenceFileContractTest {
|
||||
@Test
|
||||
void freezesPhysicalContainerOwnershipWithoutInboundCompatibilityPath() throws Exception {
|
||||
String yaml = Files.readString(Path.of("src/main/resources/openapi/loading-evidence-v1.yaml"));
|
||||
|
||||
assertThat(yaml)
|
||||
.contains("/api/platform/v1/files/loading-evidence/{containerBookingId}:")
|
||||
.contains("/internal/file/v1/loading-evidence/{containerBookingId}:")
|
||||
.contains("ownerScope: {type: string, enum: [PLATFORM]}")
|
||||
.contains("tenantId: {type: string, enum: ['0']}")
|
||||
.contains("description: 物理containerBookingId")
|
||||
.contains("LOADING_PHOTO_STEP_1", "LOADING_PHOTO_STEP_2", "LOADING_PHOTO_STEP_3",
|
||||
"LOADING_PHOTO_STEP_4", "LOADING_SEAL")
|
||||
.contains("x-required-permission: platform:firstleg:inbound:loading:edit")
|
||||
.contains("x-required-permission: platform:firstleg:inbound:loading:view")
|
||||
.contains("国内PDA禁止直连File", "Cache-Control固定no-store")
|
||||
.doesNotContain("platform:firstleg:pda:work")
|
||||
.doesNotContain("/{tenantId}/{inboundId}", "businessId=inboundId");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.baiyue.file.config;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class OceanBookingFileContractTest {
|
||||
@Test
|
||||
void freezesInternalCustomerOwnedBookingDocumentContract() throws Exception {
|
||||
String yaml = Files.readString(Path.of("src/main/resources/openapi/ocean-booking-file-v1.yaml"));
|
||||
|
||||
assertThat(yaml)
|
||||
.contains("/internal/file/v1/business-files/{tenantId}/OCEAN_BOOKING_DOCUMENT/{oceanBookingId}:")
|
||||
.contains("ownerScope: {type: string, enum: [CUSTOMER]}")
|
||||
.contains("description: 订舱customerId")
|
||||
.contains("description: oceanBookingId")
|
||||
.contains("x-firstleg-document-types: [BOOKING_CONFIRMATION, BILL_OF_LADING]")
|
||||
.contains("x-lifecycle-owner: FIRSTLEG")
|
||||
.contains("x-file-object-semantics: IMMUTABLE_CREATE_ONLY")
|
||||
.contains("任意格式,最大50MiB")
|
||||
.contains("maximum: 52428800")
|
||||
.contains("name: X-Internal-Service-Token", "Cache-Control")
|
||||
.doesNotContain("TRUCK_APPOINTMENT_ATTACHMENT", "/api/platform/", "/api/merchant/",
|
||||
"Idempotency-Key", "expectedVersion", "replace");
|
||||
}
|
||||
}
|
||||
+32
-1
@@ -23,7 +23,7 @@ class InternalBusinessFileControllerTest {
|
||||
|
||||
@Test void zeroTenantUploadAndMetadataRemainBehindInternalToken() {
|
||||
var file = new MockMultipartFile("file", "clearance.pdf", "application/pdf", "%PDF-1.4".getBytes());
|
||||
var metadata = new InternalBusinessFileService.Metadata(81L, 0L, "CONTAINER_CLEARANCE_DOCUMENT",
|
||||
var metadata = new InternalBusinessFileService.Metadata(81L, "PLATFORM", 0L, "CONTAINER_CLEARANCE_DOCUMENT",
|
||||
"41", "clearance.pdf", "hash", "application/pdf", file.getSize());
|
||||
when(service.upload(0L, "CONTAINER_CLEARANCE_DOCUMENT", "41", 7L, file)).thenReturn(metadata);
|
||||
when(service.metadata(0L, "CONTAINER_CLEARANCE_DOCUMENT", "41", 81L)).thenReturn(metadata);
|
||||
@@ -45,6 +45,34 @@ class InternalBusinessFileControllerTest {
|
||||
verify(service).download(0L, "LOADING_CUSTOMS_DECLARATION", "51", 91L);
|
||||
}
|
||||
|
||||
@Test void arbitraryResultDownloadUsesAttachmentAndFallsBackToBinaryContentType() {
|
||||
when(service.download(10001L, "LOADING_LIST_CUSTOMS_RESULT", "51", 92L))
|
||||
.thenReturn(new InternalBusinessFileService.Download("完成文件.unknown", "invalid content type", new byte[]{3}));
|
||||
|
||||
var response = controller.download("internal-token", 10001L,
|
||||
"LOADING_LIST_CUSTOMS_RESULT", "51", 92L);
|
||||
|
||||
assertThat(response.getHeaders().getCacheControl()).contains("no-store");
|
||||
assertThat(response.getHeaders().getContentDisposition().getType()).isEqualTo("attachment");
|
||||
assertThat(response.getHeaders().getContentType()).isEqualTo(org.springframework.http.MediaType.APPLICATION_OCTET_STREAM);
|
||||
}
|
||||
|
||||
@Test void unicodeSpecialFileNameUsesUtf8AttachmentDisposition() {
|
||||
String fileName = "订舱 确认 🚢 #[]()+,最终版.xlsx";
|
||||
when(service.download(10001L, "OCEAN_BOOKING_DOCUMENT", "61", 93L))
|
||||
.thenReturn(new InternalBusinessFileService.Download(fileName,
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", new byte[]{4}));
|
||||
|
||||
var response = controller.download("internal-token", 10001L,
|
||||
"OCEAN_BOOKING_DOCUMENT", "61", 93L);
|
||||
|
||||
assertThat(response.getHeaders().getContentDisposition().getType()).isEqualTo("attachment");
|
||||
assertThat(response.getHeaders().getContentDisposition().getFilename()).isEqualTo(fileName);
|
||||
assertThat(response.getHeaders().getFirst(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION))
|
||||
.contains("filename*=");
|
||||
assertThat(response.getHeaders().getCacheControl()).contains("no-store");
|
||||
}
|
||||
|
||||
@Test void rejectsInvalidTokenAndNegativeTenantBeforeServiceAccess() {
|
||||
var file = new MockMultipartFile("file", "x.txt", "text/plain", "x".getBytes());
|
||||
assertThatThrownBy(() -> controller.upload("wrong", 7L, 0L,
|
||||
@@ -53,6 +81,9 @@ class InternalBusinessFileControllerTest {
|
||||
assertThatThrownBy(() -> controller.metadata("internal-token", -1L,
|
||||
"CONTAINER_CLEARANCE_DOCUMENT", "41", 81L))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("不能为负数");
|
||||
assertThatThrownBy(() -> controller.download("wrong", 10001L,
|
||||
"OCEAN_BOOKING_DOCUMENT", "61", 81L))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("令牌无效");
|
||||
verifyNoInteractions(service);
|
||||
}
|
||||
}
|
||||
|
||||
+8
-8
@@ -21,32 +21,32 @@ class InternalLoadingEvidenceControllerTest {
|
||||
@Resource InternalLoadingEvidenceController controller;
|
||||
|
||||
@Test void downloadsOwnedEvidenceBehindInternalToken() {
|
||||
when(service.download(81L, 91L, 101L)).thenReturn(
|
||||
when(service.download(91L, 101L)).thenReturn(
|
||||
new LoadingEvidenceService.Download("door.jpg", "image/jpeg", new byte[]{1, 2, 3}));
|
||||
|
||||
var response = controller.download("internal-token", 81L, 91L, 101L);
|
||||
var response = controller.download("internal-token", 91L, 101L);
|
||||
|
||||
assertThat(response.getBody()).containsExactly(1, 2, 3);
|
||||
assertThat(response.getHeaders().getContentType().toString()).isEqualTo("image/jpeg");
|
||||
assertThat(response.getHeaders().getCacheControl()).contains("no-store");
|
||||
verify(service).download(81L, 91L, 101L);
|
||||
verify(service).download(91L, 101L);
|
||||
}
|
||||
|
||||
@Test void rejectsInvalidTokenBeforeReadingEvidence() {
|
||||
assertThatThrownBy(() -> controller.download("wrong", 81L, 91L, 101L))
|
||||
assertThatThrownBy(() -> controller.download("wrong", 91L, 101L))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("凭证无效");
|
||||
verifyNoInteractions(service);
|
||||
}
|
||||
|
||||
@Test void fallsBackForInvalidOrNonImageContentTypes() {
|
||||
when(service.download(81L, 91L, 101L)).thenReturn(
|
||||
when(service.download(91L, 101L)).thenReturn(
|
||||
new LoadingEvidenceService.Download("door.jpg", "not a/media/type", new byte[]{1}));
|
||||
when(service.download(81L, 91L, 102L)).thenReturn(
|
||||
when(service.download(91L, 102L)).thenReturn(
|
||||
new LoadingEvidenceService.Download("door.txt", "text/plain", new byte[]{2}));
|
||||
|
||||
assertThat(controller.download("internal-token", 81L, 91L, 101L).getHeaders().getContentType())
|
||||
assertThat(controller.download("internal-token", 91L, 101L).getHeaders().getContentType())
|
||||
.isEqualTo(org.springframework.http.MediaType.APPLICATION_OCTET_STREAM);
|
||||
assertThat(controller.download("internal-token", 81L, 91L, 102L).getHeaders().getContentType())
|
||||
assertThat(controller.download("internal-token", 91L, 102L).getHeaders().getContentType())
|
||||
.isEqualTo(org.springframework.http.MediaType.APPLICATION_OCTET_STREAM);
|
||||
}
|
||||
}
|
||||
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
package com.baiyue.file.controller;
|
||||
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.file.service.LoadingEvidenceService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
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;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class PlatformLoadingEvidenceControllerTest {
|
||||
private final LoadingEvidenceService service = mock(LoadingEvidenceService.class);
|
||||
private final PlatformLoadingEvidenceController controller = new PlatformLoadingEvidenceController();
|
||||
|
||||
PlatformLoadingEvidenceControllerTest() throws Exception {
|
||||
var field = PlatformLoadingEvidenceController.class.getDeclaredField("service");
|
||||
field.setAccessible(true);
|
||||
field.set(controller, service);
|
||||
}
|
||||
|
||||
@Test
|
||||
void uploadsPlatformOwnedContainerEvidence() {
|
||||
var file = new MockMultipartFile("file", "door.jpg", "image/jpeg", new byte[]{1});
|
||||
when(service.upload(91L, 7L, "PHOTO_STEP_1", file)).thenReturn(
|
||||
new LoadingEvidenceService.UploadResult(101L, "PLATFORM", 0L,
|
||||
"LOADING_PHOTO_STEP_1", "91", "door.jpg", "image/jpeg", 1L));
|
||||
|
||||
var response = controller.upload(platform(), 91L, "PHOTO_STEP_1", file);
|
||||
|
||||
assertThat(response.data()).extracting(LoadingEvidenceService.UploadResult::ownerScope,
|
||||
LoadingEvidenceService.UploadResult::tenantId, LoadingEvidenceService.UploadResult::businessId)
|
||||
.containsExactly("PLATFORM", 0L, "91");
|
||||
verify(service).upload(91L, 7L, "PHOTO_STEP_1", file);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMerchantBeforeAccessingContainerEvidence() {
|
||||
assertThatThrownBy(() -> controller.download(merchant(), 91L, 101L))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("平台端身份");
|
||||
}
|
||||
|
||||
@Test
|
||||
void platformEndpointsExcludePdaWorkPermission() throws Exception {
|
||||
var upload = PlatformLoadingEvidenceController.class.getMethod("upload", AuthenticatedUser.class,
|
||||
long.class, String.class, MultipartFile.class);
|
||||
var download = PlatformLoadingEvidenceController.class.getMethod("download", AuthenticatedUser.class,
|
||||
long.class, long.class);
|
||||
|
||||
assertThat(upload.getAnnotation(PreAuthorize.class).value())
|
||||
.isEqualTo("hasAuthority('platform:firstleg:inbound:loading:edit')")
|
||||
.doesNotContain("pda:work");
|
||||
assertThat(download.getAnnotation(PreAuthorize.class).value())
|
||||
.isEqualTo("hasAuthority('platform:firstleg:inbound:loading:view')")
|
||||
.doesNotContain("pda:work");
|
||||
}
|
||||
|
||||
private static AuthenticatedUser platform() {
|
||||
return new AuthenticatedUser(7L, 0L, 0L, 7L, "operator", "PLATFORM", "PDA", Set.of());
|
||||
}
|
||||
|
||||
private static AuthenticatedUser merchant() {
|
||||
return new AuthenticatedUser(8L, 88L, 88L, 8L, "merchant", "MERCHANT", "WEB", Set.of());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package com.baiyue.file.service;
|
||||
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.RandomAccessFile;
|
||||
import java.nio.file.Files;
|
||||
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.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class BusinessFileStorageTest {
|
||||
@TempDir Path root;
|
||||
|
||||
@Test void arbitraryCustomsFormatIsStoredAsAttachmentObject() {
|
||||
BusinessFileStorage storage = storage();
|
||||
var file = new MockMultipartFile("file", "folder/manifest.custom", "application/x-custom", new byte[]{1, 2, 3});
|
||||
|
||||
var stored = storage.store(10001L, 51L, file, BusinessFileCategory.CUSTOMS_MAX_SIZE, true);
|
||||
|
||||
assertThat(stored.originalName()).isEqualTo("manifest.custom");
|
||||
assertThat(stored.contentType()).isEqualTo("application/x-custom");
|
||||
assertThat(storage.read(stored.objectKey())).containsExactly(1, 2, 3);
|
||||
}
|
||||
|
||||
@Test void customsFileAboveFiftyMegabytesIsRejectedBeforeRead() {
|
||||
BusinessFileStorage storage = storage();
|
||||
MultipartFile file = mock(MultipartFile.class);
|
||||
when(file.isEmpty()).thenReturn(false);
|
||||
when(file.getSize()).thenReturn(BusinessFileCategory.CUSTOMS_MAX_SIZE + 1);
|
||||
|
||||
assertThatThrownBy(() -> storage.store(10001L, 51L, file,
|
||||
BusinessFileCategory.CUSTOMS_MAX_SIZE, true))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("50MB");
|
||||
}
|
||||
|
||||
@Test void rejectsZeroByteAndAcceptsExactFiftyMibArbitraryBinary() throws Exception {
|
||||
BusinessFileStorage storage = storage();
|
||||
var empty = new MockMultipartFile("file", "empty.bin", "application/octet-stream", new byte[0]);
|
||||
assertThatThrownBy(() -> storage.store(10001L, 61L, empty,
|
||||
BusinessFileCategory.CUSTOMS_MAX_SIZE, true))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("不能为空");
|
||||
|
||||
MultipartFile boundary = new SparseMultipartFile("exact-50m.bin", "application/x-booking-binary",
|
||||
BusinessFileCategory.CUSTOMS_MAX_SIZE);
|
||||
var stored = storage.store(10001L, 61L, boundary,
|
||||
BusinessFileCategory.CUSTOMS_MAX_SIZE, true);
|
||||
|
||||
assertThat(stored.size()).isEqualTo(BusinessFileCategory.CUSTOMS_MAX_SIZE);
|
||||
assertThat(stored.contentType()).isEqualTo("application/x-booking-binary");
|
||||
assertThat(Files.size(root.resolve(stored.objectKey())))
|
||||
.isEqualTo(BusinessFileCategory.CUSTOMS_MAX_SIZE);
|
||||
}
|
||||
|
||||
@Test void preservesUnicodeSpacesAndSpecialCharactersWithoutSplittingSurrogatePairs() {
|
||||
BusinessFileStorage storage = storage();
|
||||
String original = "folder\\" + "超".repeat(280) + " 中文 空格 #[]()+,🚢.xlsx ";
|
||||
var file = new MockMultipartFile("file", original,
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", new byte[]{1, 2});
|
||||
|
||||
var stored = storage.store(10001L, 61L, file,
|
||||
BusinessFileCategory.CUSTOMS_MAX_SIZE, true);
|
||||
|
||||
assertThat(stored.originalName()).endsWith(" 中文 空格 #[]()+,🚢.xlsx").contains("🚢");
|
||||
assertThat(stored.originalName()).doesNotContain("/", "\\", "\r", "\n");
|
||||
assertThat(stored.originalName().codePointCount(0, stored.originalName().length())).isEqualTo(255);
|
||||
}
|
||||
|
||||
@Test void legacyBusinessFilesRetainRestrictedFormatsAndTenMegabyteLimit() {
|
||||
BusinessFileStorage storage = storage();
|
||||
var file = new MockMultipartFile("file", "archive.zip", "application/zip", new byte[]{1});
|
||||
|
||||
assertThatThrownBy(() -> storage.store(10001L, 51L, file,
|
||||
BusinessFileCategory.STANDARD_MAX_SIZE, false))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("仅支持");
|
||||
}
|
||||
|
||||
private BusinessFileStorage storage() {
|
||||
BusinessFileStorage storage = new BusinessFileStorage();
|
||||
ReflectionTestUtils.setField(storage, "configuredRoot", root);
|
||||
storage.initializeRoot();
|
||||
return storage;
|
||||
}
|
||||
|
||||
private record SparseMultipartFile(String originalFilename, String contentType, long size)
|
||||
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 size == 0; }
|
||||
@Override public long getSize() { return size; }
|
||||
@Override public byte[] getBytes() { throw new UnsupportedOperationException(); }
|
||||
@Override public InputStream getInputStream() {
|
||||
return new ByteArrayInputStream(new byte[0]);
|
||||
}
|
||||
@Override public void transferTo(File destination) throws IOException {
|
||||
try (RandomAccessFile output = new RandomAccessFile(destination, "rw")) {
|
||||
output.setLength(size);
|
||||
}
|
||||
}
|
||||
@Override public void transferTo(Path destination) throws IOException {
|
||||
transferTo(destination.toFile());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -84,6 +84,34 @@ class ExportTaskServiceTest {
|
||||
.getResultCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void buyDeclarationExportRequiresOnePositiveLoadingPlanId() {
|
||||
var definition = definition();
|
||||
definition.setProviderService("firstleg-service");
|
||||
definition.setProviderPath("/internal/exports/v1/LOADING_LIST_BUY_DECLARATION_ITEMS/batches");
|
||||
definition.setRequiredPermission("platform:firstleg:customs:manage");
|
||||
when(registry.require("LOADING_LIST_BUY_DECLARATION_ITEMS")).thenReturn(definition);
|
||||
when(states.createConstrained(any(FileExportTask.class))).thenAnswer(invocation ->
|
||||
new ExportStateService.CreationResult(invocation.getArgument(0), true));
|
||||
var user = user(1L, Set.of("platform:firstleg:customs:manage"));
|
||||
|
||||
var response = service.create(user, new CreateExportTaskRequest(
|
||||
"LOADING_LIST_BUY_DECLARATION_ITEMS", "XLSX", "买单报关明细", Map.of("loadingPlanId", "51")));
|
||||
|
||||
assertEquals("LOADING_LIST_BUY_DECLARATION_ITEMS", response.exportType());
|
||||
assertEquals(ResultCode.VALIDATION_FAILED, assertThrows(BizException.class, () -> service.create(user,
|
||||
new CreateExportTaskRequest("LOADING_LIST_BUY_DECLARATION_ITEMS", "XLSX", "买单报关明细", Map.of())))
|
||||
.getResultCode());
|
||||
assertEquals(ResultCode.VALIDATION_FAILED, assertThrows(BizException.class, () -> service.create(user,
|
||||
new CreateExportTaskRequest("LOADING_LIST_BUY_DECLARATION_ITEMS", "XLSX", "买单报关明细",
|
||||
Map.of("loadingPlanId", 0))))
|
||||
.getResultCode());
|
||||
assertEquals(ResultCode.VALIDATION_FAILED, assertThrows(BizException.class, () -> service.create(user,
|
||||
new CreateExportTaskRequest("LOADING_LIST_BUY_DECLARATION_ITEMS", "XLSX", "买单报关明细",
|
||||
Map.of("loadingPlanId", 51L))))
|
||||
.getResultCode());
|
||||
}
|
||||
|
||||
private AuthenticatedUser user(long id, Set<String> permissions) {
|
||||
return new AuthenticatedUser(id, 0L, 0L, id, "user" + id, "PLATFORM", "WEB", permissions);
|
||||
}
|
||||
|
||||
@@ -86,6 +86,56 @@ class GenericXlsxExportRunnerTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test void buyDeclarationProviderMustReturnTheFrozenThirteenColumnsInOrder() {
|
||||
List<ExportColumn> invalid = ExportSchemaContracts.BUY_DECLARATION_COLUMNS.stream()
|
||||
.map(column -> new ExportColumn(column.key(), column.title(), column.type(), 18)).toList();
|
||||
invalid = new java.util.ArrayList<>(invalid);
|
||||
invalid.set(4, new ExportColumn("productNameZh", "中文品名", ExportColumnType.STRING, 18));
|
||||
var batch = new ExportBatchResponse(1, "sha256:buy-declaration", "snapshot-51",
|
||||
invalid, List.of(), null, false, 0L);
|
||||
when(provider.batch(anyString(), eq(ExportSchemaContracts.BUY_DECLARATION_ITEMS), any(), any()))
|
||||
.thenReturn(ApiResponse.success(batch));
|
||||
FileExportTask task = new FileExportTask(); task.setId(51L); task.setTenantId(0L);
|
||||
task.setAppCode("PLATFORM"); task.setRequestedBy(7L); task.setDispatchVersion(1);
|
||||
task.setExportType(ExportSchemaContracts.BUY_DECLARATION_ITEMS); task.setTaskName("买单报关明细");
|
||||
task.setParametersJson("{\"loadingPlanId\":\"51\"}");
|
||||
var definition = new ExportDefinitionRegistry.Definition(); definition.setMaxRows(200000); definition.setBatchSize(500);
|
||||
|
||||
assertThrows(ExportFailure.class,
|
||||
() -> runner.run(task, definition, new AtomicBoolean(), ignored -> { }));
|
||||
}
|
||||
|
||||
@Test void frozenBuyDeclarationProviderColumnsGenerateWorkbookDirectly() throws Exception {
|
||||
List<ExportColumn> columns = ExportSchemaContracts.BUY_DECLARATION_COLUMNS.stream()
|
||||
.map(column -> new ExportColumn(column.key(), column.title(), column.type(), 18)).toList();
|
||||
Map<String, Object> row = new java.util.LinkedHashMap<>();
|
||||
row.put("customerName", "客户A"); row.put("orderNo", "O-1"); row.put("inboundCode", "IN-1");
|
||||
row.put("sku", "SKU-1"); row.put("chineseName", "中文品名"); row.put("englishName", "English name");
|
||||
row.put("cartonCount", 2); row.put("productQuantity", 20); row.put("grossWeight", "12.5");
|
||||
row.put("netWeight", "11.5"); row.put("volume", "1.25"); row.put("mark", "MARK-1");
|
||||
row.put("customerRemark", "备注");
|
||||
var batch = new ExportBatchResponse(1, "sha256:buy-declaration", "snapshot-51",
|
||||
columns, List.of(row), null, false, 1L);
|
||||
when(provider.batch(anyString(), eq(ExportSchemaContracts.BUY_DECLARATION_ITEMS), any(), any()))
|
||||
.thenReturn(ApiResponse.success(batch));
|
||||
FileExportTask task = new FileExportTask(); task.setId(52L); task.setTenantId(0L);
|
||||
task.setAppCode("PLATFORM"); task.setRequestedBy(7L); task.setDispatchVersion(1);
|
||||
task.setExportType(ExportSchemaContracts.BUY_DECLARATION_ITEMS); task.setTaskName("买单报关明细");
|
||||
task.setParametersJson("{\"loadingPlanId\":\"51\"}");
|
||||
var definition = new ExportDefinitionRegistry.Definition(); definition.setMaxRows(200000); definition.setBatchSize(500);
|
||||
|
||||
var result = runner.run(task, definition, new AtomicBoolean(), ignored -> { });
|
||||
|
||||
assertEquals(1L, result.rows());
|
||||
try (var workbook = new XSSFWorkbook(storage.load(result.objectKey()).getInputStream())) {
|
||||
var sheet = workbook.getSheetAt(0);
|
||||
assertEquals("客户名称", sheet.getRow(0).getCell(0).getStringCellValue());
|
||||
assertEquals("客户A", sheet.getRow(1).getCell(0).getStringCellValue());
|
||||
assertEquals("中文品名", sheet.getRow(0).getCell(4).getStringCellValue());
|
||||
assertEquals("唛头", sheet.getRow(0).getCell(11).getStringCellValue());
|
||||
}
|
||||
}
|
||||
|
||||
@Configuration
|
||||
static class TestConfig {
|
||||
@Bean ObjectMapper objectMapper() { return new ObjectMapper(); }
|
||||
|
||||
@@ -8,6 +8,10 @@ import org.junit.jupiter.api.Test;
|
||||
import org.mockito.*;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
@@ -21,7 +25,7 @@ class InternalBusinessFileServiceTest {
|
||||
|
||||
@Test void customsDocumentUploadPersistsExactBusinessOwnership() {
|
||||
MockMultipartFile file = new MockMultipartFile("file", "invoice.txt", "text/plain", "invoice".getBytes());
|
||||
when(storage.storeLoading(10001L, 41L, file)).thenReturn(new BusinessFileStorage.Stored(
|
||||
when(storage.store(10001L, 41L, file, BusinessFileCategory.STANDARD_MAX_SIZE, false)).thenReturn(new BusinessFileStorage.Stored(
|
||||
"business-files/10001/41/key.txt", "text/plain", 7, "a".repeat(64), "invoice.txt"));
|
||||
when(files.insert(any(FileObjectEntity.class))).thenAnswer(invocation -> {
|
||||
((FileObjectEntity) invocation.getArgument(0)).setId(71L); return 1;
|
||||
@@ -37,10 +41,10 @@ class InternalBusinessFileServiceTest {
|
||||
|
||||
@Test void platformContainerAndLoadingDocumentsSupportZeroTenantScope() {
|
||||
MockMultipartFile file = new MockMultipartFile("file", "customs.pdf", "application/pdf", "%PDF-1.4".getBytes());
|
||||
when(storage.storeLoading(eq(0L), anyLong(), same(file))).thenAnswer(invocation ->
|
||||
when(storage.store(eq(0L), anyLong(), same(file), eq(BusinessFileCategory.STANDARD_MAX_SIZE), eq(false))).thenAnswer(invocation ->
|
||||
new BusinessFileStorage.Stored("business-files/0/" + invocation.getArgument(1) + "/customs.pdf",
|
||||
"application/pdf", file.getSize(), "b".repeat(64), "customs.pdf"));
|
||||
when(storage.storeLoading(11L, 51L, file)).thenReturn(new BusinessFileStorage.Stored(
|
||||
when(storage.store(11L, 51L, file, BusinessFileCategory.STANDARD_MAX_SIZE, false)).thenReturn(new BusinessFileStorage.Stored(
|
||||
"business-files/11/51/unload.jpg", "image/jpeg", file.getSize(), "c".repeat(64), "unload.jpg"));
|
||||
when(files.insert(any(FileObjectEntity.class))).thenAnswer(invocation -> {
|
||||
((FileObjectEntity) invocation.getArgument(0)).setId(81L); return 1;
|
||||
@@ -64,9 +68,49 @@ class InternalBusinessFileServiceTest {
|
||||
InternalBusinessFileService.Metadata::businessType,
|
||||
InternalBusinessFileService.Metadata::businessId)
|
||||
.containsExactly(11L, "OVERSEAS_UNLOAD_PHOTO", "51");
|
||||
verify(storage, times(3)).storeLoading(0L, 41L, file);
|
||||
verify(storage).storeLoading(0L, 51L, file);
|
||||
verify(storage).storeLoading(11L, 51L, file);
|
||||
verify(storage, times(3)).store(0L, 41L, file, BusinessFileCategory.STANDARD_MAX_SIZE, false);
|
||||
verify(storage).store(0L, 51L, file, BusinessFileCategory.STANDARD_MAX_SIZE, false);
|
||||
verify(storage).store(11L, 51L, file, BusinessFileCategory.STANDARD_MAX_SIZE, false);
|
||||
}
|
||||
|
||||
@Test void oceanBookingDocumentUsesCustomerAndBookingOwnership() {
|
||||
MockMultipartFile file = new MockMultipartFile("file", "booking.xlsx",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "xlsx".getBytes());
|
||||
when(storage.store(10001L, 61L, file, BusinessFileCategory.CUSTOMS_MAX_SIZE, true))
|
||||
.thenReturn(new BusinessFileStorage.Stored("business-files/10001/61/booking",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
file.getSize(), "f".repeat(64), "booking.xlsx"));
|
||||
when(files.insert(any(FileObjectEntity.class))).thenAnswer(invocation -> {
|
||||
((FileObjectEntity) invocation.getArgument(0)).setId(111L); return 1;
|
||||
});
|
||||
|
||||
var metadata = service.upload(10001L, "OCEAN_BOOKING_DOCUMENT", "61", 7L, file);
|
||||
|
||||
assertThat(metadata).extracting(InternalBusinessFileService.Metadata::ownerScope,
|
||||
InternalBusinessFileService.Metadata::tenantId,
|
||||
InternalBusinessFileService.Metadata::businessType,
|
||||
InternalBusinessFileService.Metadata::businessId)
|
||||
.containsExactly("CUSTOMER", 10001L, "OCEAN_BOOKING_DOCUMENT", "61");
|
||||
assertThatThrownBy(() -> service.upload(0L, "OCEAN_BOOKING_DOCUMENT", "61", 7L, file))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("明确客户");
|
||||
}
|
||||
|
||||
@Test void oceanBookingDocumentRejectsWrongCustomerBookingAndTruckOwnership() {
|
||||
FileObjectEntity value = new FileObjectEntity(); value.setId(111L); value.setTenantId(10001L);
|
||||
value.setBusinessType("OCEAN_BOOKING_DOCUMENT"); value.setBusinessId("61");
|
||||
value.setOriginalName("booking.xlsx"); value.setObjectKey("business-files/10001/61/booking");
|
||||
value.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
|
||||
value.setSha256("f".repeat(64)); value.setSizeBytes(8L);
|
||||
when(files.selectOne(any())).thenReturn(value, null, null, null);
|
||||
|
||||
assertThat(service.metadata(10001L, "OCEAN_BOOKING_DOCUMENT", "61", 111L).ownerScope())
|
||||
.isEqualTo("CUSTOMER");
|
||||
assertThatThrownBy(() -> service.metadata(10002L, "OCEAN_BOOKING_DOCUMENT", "61", 111L))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("归属不匹配");
|
||||
assertThatThrownBy(() -> service.metadata(10001L, "OCEAN_BOOKING_DOCUMENT", "62", 111L))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("归属不匹配");
|
||||
assertThatThrownBy(() -> service.metadata(10001L, "TRUCK_APPOINTMENT_ATTACHMENT", "61", 111L))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("归属不匹配");
|
||||
}
|
||||
|
||||
@Test void rejectsNegativeTenantAndInvalidOwnershipInputs() {
|
||||
@@ -82,7 +126,7 @@ class InternalBusinessFileServiceTest {
|
||||
|
||||
@Test void overseasRelabelPhotoPersistsExactTaskOwnership() {
|
||||
MockMultipartFile file = new MockMultipartFile("file", "relabel.jpg", "image/jpeg", "photo".getBytes());
|
||||
when(storage.storeLoading(100L, 81L, file)).thenReturn(new BusinessFileStorage.Stored(
|
||||
when(storage.store(100L, 81L, file, BusinessFileCategory.STANDARD_MAX_SIZE, false)).thenReturn(new BusinessFileStorage.Stored(
|
||||
"business-files/100/81/relabel.jpg", "image/jpeg", file.getSize(), "d".repeat(64), "relabel.jpg"));
|
||||
when(files.insert(any(FileObjectEntity.class))).thenAnswer(invocation -> {
|
||||
((FileObjectEntity) invocation.getArgument(0)).setId(91L); return 1;
|
||||
@@ -96,6 +140,36 @@ class InternalBusinessFileServiceTest {
|
||||
.containsExactly(100L, "OVERSEAS_RELABEL_PHOTO", "81");
|
||||
}
|
||||
|
||||
@Test void loadingCustomsSourceAndResultAreCustomerOwnedArbitraryFormatObjects() {
|
||||
MockMultipartFile file = new MockMultipartFile("file", "资料包.zip", "application/zip", "zip".getBytes());
|
||||
when(storage.store(10001L, 51L, file, BusinessFileCategory.CUSTOMS_MAX_SIZE, true))
|
||||
.thenReturn(new BusinessFileStorage.Stored("business-files/10001/51/key", "application/zip",
|
||||
file.getSize(), "e".repeat(64), "资料包.zip"));
|
||||
when(files.insert(any(FileObjectEntity.class))).thenAnswer(invocation -> {
|
||||
((FileObjectEntity) invocation.getArgument(0)).setId(101L); return 1;
|
||||
});
|
||||
|
||||
var source = service.upload(10001L, "LOADING_LIST_CUSTOMS_SOURCE", "51", 7L, file);
|
||||
var result = service.upload(10001L, "LOADING_LIST_CUSTOMS_RESULT", "51", 7L, file);
|
||||
|
||||
assertThat(source).extracting(InternalBusinessFileService.Metadata::tenantId,
|
||||
InternalBusinessFileService.Metadata::businessType,
|
||||
InternalBusinessFileService.Metadata::businessId)
|
||||
.containsExactly(10001L, "LOADING_LIST_CUSTOMS_SOURCE", "51");
|
||||
assertThat(result.businessType()).isEqualTo("LOADING_LIST_CUSTOMS_RESULT");
|
||||
verify(storage, times(2)).store(10001L, 51L, file, BusinessFileCategory.CUSTOMS_MAX_SIZE, true);
|
||||
}
|
||||
|
||||
@Test void loadingCustomsCategoriesRejectPlatformOwnerScope() {
|
||||
var file = new MockMultipartFile("file", "source.bin", "application/octet-stream", new byte[]{1});
|
||||
|
||||
assertThatThrownBy(() -> service.upload(0L, "LOADING_LIST_CUSTOMS_SOURCE", "51", 7L, file))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("明确客户");
|
||||
assertThatThrownBy(() -> service.metadata(0L, "LOADING_LIST_CUSTOMS_RESULT", "51", 91L))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("明确客户");
|
||||
verifyNoInteractions(files, storage);
|
||||
}
|
||||
|
||||
@Test void unsupportedBusinessTypeFailsClosed() {
|
||||
assertThatThrownBy(() -> service.upload(10001L, "ARBITRARY", "41", 7L,
|
||||
new MockMultipartFile("file", "a.txt", "text/plain", "x".getBytes())))
|
||||
@@ -138,4 +212,34 @@ class InternalBusinessFileServiceTest {
|
||||
verify(storage).read(value.getObjectKey());
|
||||
}
|
||||
|
||||
@Test void concurrentRepeatedUploadsCreateDistinctImmutableObjectsForFirstlegToVersion() throws Exception {
|
||||
MockMultipartFile file = new MockMultipartFile("file", "booking.bin",
|
||||
"application/x-booking", new byte[]{1, 2, 3});
|
||||
when(storage.store(10001L, 61L, file, BusinessFileCategory.CUSTOMS_MAX_SIZE, true))
|
||||
.thenReturn(new BusinessFileStorage.Stored("business-files/10001/61/object",
|
||||
"application/x-booking", 3, "a".repeat(64), "booking.bin"));
|
||||
AtomicLong ids = new AtomicLong(200);
|
||||
when(files.insert(any(FileObjectEntity.class))).thenAnswer(invocation -> {
|
||||
((FileObjectEntity) invocation.getArgument(0)).setId(ids.incrementAndGet());
|
||||
return 1;
|
||||
});
|
||||
var executor = Executors.newFixedThreadPool(2);
|
||||
try {
|
||||
var firstFuture = executor.submit(() ->
|
||||
service.upload(10001L, "OCEAN_BOOKING_DOCUMENT", "61", 7L, file));
|
||||
var secondFuture = executor.submit(() ->
|
||||
service.upload(10001L, "OCEAN_BOOKING_DOCUMENT", "61", 7L, file));
|
||||
|
||||
var first = firstFuture.get();
|
||||
var second = secondFuture.get();
|
||||
assertThat(first.fileId()).isNotEqualTo(second.fileId());
|
||||
assertThat(first.businessId()).isEqualTo("61");
|
||||
assertThat(second.businessId()).isEqualTo("61");
|
||||
verify(files, times(2)).insert(any(FileObjectEntity.class));
|
||||
verify(files, never()).updateById(any(FileObjectEntity.class));
|
||||
} finally {
|
||||
executor.shutdownNow();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class LoadingEvidenceServiceTest {
|
||||
@@ -28,24 +29,42 @@ class LoadingEvidenceServiceTest {
|
||||
@Test
|
||||
void uploadsEvidenceWithDedicatedBusinessType() {
|
||||
var multipart = new MockMultipartFile("file", "step.jpg", "image/jpeg", new byte[]{1});
|
||||
when(storage.storeLoading(10, 20, multipart)).thenReturn(new MeasurementImageStorage.Stored(
|
||||
"loading-evidence/10/20/a.jpg", Path.of("a.jpg"), "image/jpeg", 1, "hash", "step.jpg"));
|
||||
var result = service.upload(10, 20, 7, "PHOTO_STEP_2", multipart);
|
||||
when(storage.storeLoading(0, 20, multipart)).thenReturn(new MeasurementImageStorage.Stored(
|
||||
"loading-evidence/0/20/a.jpg", Path.of("a.jpg"), "image/jpeg", 1, "hash", "step.jpg"));
|
||||
var result = service.upload(20, 7, "PHOTO_STEP_2", multipart);
|
||||
assertThat(result.businessType()).isEqualTo("LOADING_PHOTO_STEP_2");
|
||||
verify(files).insert(any(FileObjectEntity.class));
|
||||
assertThat(result.ownerScope()).isEqualTo("PLATFORM");
|
||||
assertThat(result.tenantId()).isZero();
|
||||
assertThat(result.businessId()).isEqualTo("20");
|
||||
var object = ArgumentCaptor.forClass(FileObjectEntity.class);
|
||||
verify(files).insert(object.capture());
|
||||
assertThat(object.getValue()).extracting(FileObjectEntity::getTenantId,
|
||||
FileObjectEntity::getBusinessId, FileObjectEntity::getBucketName)
|
||||
.containsExactly(0L, "20", "loading-evidence");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsUnknownEvidenceType() {
|
||||
var multipart = new MockMultipartFile("file", "x.jpg", "image/jpeg", new byte[]{1});
|
||||
assertThatThrownBy(() -> service.upload(10, 20, 7, "UNKNOWN", multipart)).isInstanceOf(BizException.class);
|
||||
assertThatThrownBy(() -> service.upload(20, 7, "UNKNOWN", multipart)).isInstanceOf(BizException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void validatesTenantBoundFile() {
|
||||
FileObjectEntity object = new FileObjectEntity();
|
||||
object.setId(30L); object.setOriginalName("seal.jpg"); object.setBusinessType("LOADING_SEAL"); object.setContentType("image/jpeg");
|
||||
object.setId(30L); object.setTenantId(0L); object.setBusinessId("20");
|
||||
object.setOriginalName("seal.jpg"); object.setBusinessType("LOADING_SEAL"); object.setContentType("image/jpeg");
|
||||
when(files.selectOne(any(Wrapper.class))).thenReturn(object);
|
||||
assertThat(service.validate(10, 20, 30).fileId()).isEqualTo(30L);
|
||||
var metadata = service.validate(20, 30);
|
||||
assertThat(metadata.fileId()).isEqualTo(30L);
|
||||
assertThat(metadata.ownerScope()).isEqualTo("PLATFORM");
|
||||
assertThat(metadata.tenantId()).isZero();
|
||||
assertThat(metadata.businessId()).isEqualTo("20");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsFileWhenContainerBookingDoesNotMatch() {
|
||||
assertThatThrownBy(() -> service.validate(21, 30))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("装柜证据文件不存在");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ public class PlatformFinanceController {
|
||||
|
||||
@GetMapping("/customer-accounts/{customerId}")
|
||||
@PreAuthorize("hasAuthority('platform:finance:customer-account:list')")
|
||||
@Operation(summary = "客户人工应收账户及流水")
|
||||
@Operation(summary = "客户正式应收账户及收款流水")
|
||||
public ApiResponse<?> customerAccount(@PathVariable long customerId) {
|
||||
return FinanceResponses.success(queryService.customerAccount(customerId));
|
||||
}
|
||||
@@ -61,6 +61,15 @@ public class PlatformFinanceController {
|
||||
@GetMapping("/fee-pool")
|
||||
@PreAuthorize("hasAuthority('platform:finance:customer-arap:list')")
|
||||
@Operation(summary = "客户应收应付费用池")
|
||||
public ApiResponse<?> feePool(
|
||||
@Parameter(description = "可选客户筛选;省略时查询全部客户")
|
||||
@RequestParam(required = false) Long customerId,
|
||||
@RequestParam(required = false) String direction,
|
||||
@RequestParam(required = false) Long supplierId) {
|
||||
return FinanceResponses.success(queryService.feePool(customerId, direction, supplierId));
|
||||
}
|
||||
|
||||
@PreAuthorize("hasAuthority('platform:finance:customer-arap:list')")
|
||||
public ApiResponse<?> feePool(
|
||||
@Parameter(description = "可选客户筛选;省略时查询全部客户")
|
||||
@RequestParam(required = false) Long customerId,
|
||||
|
||||
@@ -62,17 +62,17 @@ public class FinanceCustomerAccountService {
|
||||
String placeholders = String.join(",", customerIds.stream().map(id -> "?").toList());
|
||||
Map<Long, MutableMetrics> result = new HashMap<>();
|
||||
customerIds.forEach(id -> result.put(id, new MutableMetrics()));
|
||||
jdbc.queryForList("SELECT tenant_id customer_id,SUM(outstanding_amount) amount FROM fin_bill WHERE tenant_id IN (" + placeholders + ") AND status<>'VOIDED' GROUP BY tenant_id", customerIds.toArray())
|
||||
jdbc.queryForList("SELECT tenant_id customer_id,SUM(outstanding_amount) amount FROM fin_settlement WHERE tenant_id IN (" + placeholders + ") AND direction='RECEIVABLE' AND status<>'VOIDED' GROUP BY tenant_id", customerIds.toArray())
|
||||
.forEach(row -> metric(result, row, "outstandingReceivable", "amount"));
|
||||
jdbc.queryForList("SELECT customer_id,SUM(amount) amount FROM fin_fee_source f WHERE customer_id IN (" + placeholders + ") AND direction='RECEIVABLE' AND status='AVAILABLE' AND NOT EXISTS (SELECT 1 FROM fin_bill_line bl WHERE bl.source_id=f.id AND bl.source_version=f.source_fee_version) GROUP BY customer_id", customerIds.toArray())
|
||||
jdbc.queryForList("SELECT customer_id,SUM(amount) amount FROM fin_fee_source f WHERE customer_id IN (" + placeholders + ") AND direction='RECEIVABLE' AND review_status='APPROVED' AND currency_code='CNY' AND amount>0 AND status='AVAILABLE' AND NOT EXISTS (SELECT 1 FROM fin_bill_line bl JOIN fin_bill b ON b.id=bl.bill_id AND b.tenant_id=bl.tenant_id WHERE bl.tenant_id=f.customer_id AND bl.source_id=f.id AND bl.source_version=f.source_fee_version AND b.status<>'VOIDED') GROUP BY customer_id", customerIds.toArray())
|
||||
.forEach(row -> metric(result, row, "pendingBilling", "amount"));
|
||||
jdbc.queryForList("SELECT tenant_id customer_id,SUM(received_amount) amount FROM fin_receipt WHERE tenant_id IN (" + placeholders + ") AND status='CONFIRMED' GROUP BY tenant_id", customerIds.toArray())
|
||||
.forEach(row -> metric(result, row, "receivedInPeriod", "amount"));
|
||||
jdbc.queryForList("SELECT customer_id,SUM(amount) amount FROM (SELECT customer_id,amount FROM fin_fee_source WHERE customer_id IN (" + placeholders + ") AND direction='PAYABLE' AND settlement_mode='ON_ACCOUNT' AND review_status='APPROVED' AND status NOT IN ('VOIDED','SUPERSEDED') UNION ALL SELECT tenant_id customer_id,cost_amount amount FROM fin_cost WHERE tenant_id IN (" + placeholders + ") AND status='CONFIRMED') costs GROUP BY customer_id", concat(customerIds, 2))
|
||||
jdbc.queryForList("SELECT customer_id,SUM(amount) amount FROM (SELECT customer_id,amount FROM fin_fee_source WHERE customer_id IN (" + placeholders + ") AND direction='PAYABLE' AND settlement_mode='ON_ACCOUNT' AND review_status='APPROVED' AND currency_code='CNY' AND amount>0 AND status NOT IN ('VOIDED','SUPERSEDED') UNION ALL SELECT tenant_id customer_id,cost_amount amount FROM fin_cost WHERE tenant_id IN (" + placeholders + ") AND cost_origin='FIRSTLEG_DIRECT_PAID' AND status='CONFIRMED') costs GROUP BY customer_id", concat(customerIds, 2))
|
||||
.forEach(row -> metric(result, row, "totalCost", "amount"));
|
||||
jdbc.queryForList("SELECT customer_id,SUM(amount) amount FROM (SELECT tenant_id customer_id,cost_amount amount FROM fin_cost WHERE tenant_id IN (" + placeholders + ") AND status='CONFIRMED' UNION ALL SELECT tenant_id customer_id,paid_amount amount FROM fin_settlement WHERE tenant_id IN (" + placeholders + ") AND direction='PAYABLE' AND status<>'VOIDED') paid_costs GROUP BY customer_id", concat(customerIds, 2))
|
||||
jdbc.queryForList("SELECT customer_id,SUM(amount) amount FROM (SELECT tenant_id customer_id,cost_amount amount FROM fin_cost WHERE tenant_id IN (" + placeholders + ") AND cost_origin='FIRSTLEG_DIRECT_PAID' AND status='CONFIRMED' UNION ALL SELECT tenant_id customer_id,paid_amount amount FROM fin_settlement WHERE tenant_id IN (" + placeholders + ") AND direction='PAYABLE' AND status<>'VOIDED') paid_costs GROUP BY customer_id", concat(customerIds, 2))
|
||||
.forEach(row -> metric(result, row, "paidCost", "amount"));
|
||||
jdbc.queryForList("SELECT tenant_id customer_id,SUM(outstanding_amount) amount FROM fin_settlement WHERE tenant_id IN (" + placeholders + ") AND direction='PAYABLE' AND status<>'VOIDED' GROUP BY tenant_id", customerIds.toArray())
|
||||
jdbc.queryForList("SELECT customer_id,SUM(amount) amount FROM (SELECT tenant_id customer_id,outstanding_amount amount FROM fin_settlement WHERE tenant_id IN (" + placeholders + ") AND direction='PAYABLE' AND status<>'VOIDED' UNION ALL SELECT f.customer_id,f.amount FROM fin_fee_source f WHERE f.customer_id IN (" + placeholders + ") AND f.direction='PAYABLE' AND f.settlement_mode='ON_ACCOUNT' AND f.review_status='APPROVED' AND f.currency_code='CNY' AND f.amount>0 AND f.status NOT IN ('VOIDED','SUPERSEDED') AND NOT EXISTS (SELECT 1 FROM fin_settlement_line sl JOIN fin_settlement s ON s.id=sl.settlement_id AND s.tenant_id=sl.tenant_id WHERE sl.tenant_id=f.customer_id AND sl.fee_source_id=f.id AND s.direction='PAYABLE' AND s.status<>'VOIDED')) payable_obligations GROUP BY customer_id", concat(customerIds, 2))
|
||||
.forEach(row -> metric(result, row, "outstandingPayable", "amount"));
|
||||
jdbc.queryForList("SELECT customer_id,MAX(updated_at) last_changed_at FROM (SELECT tenant_id customer_id,updated_at FROM fin_bill WHERE tenant_id IN (" + placeholders + ") UNION ALL SELECT tenant_id customer_id,updated_at FROM fin_receipt WHERE tenant_id IN (" + placeholders + ") UNION ALL SELECT tenant_id customer_id,updated_at FROM fin_cost WHERE tenant_id IN (" + placeholders + ") UNION ALL SELECT customer_id,updated_at FROM fin_fee_source WHERE customer_id IN (" + placeholders + ") UNION ALL SELECT tenant_id customer_id,updated_at FROM fin_settlement WHERE tenant_id IN (" + placeholders + ")) changes GROUP BY customer_id", concat(customerIds, 5))
|
||||
.forEach(row -> metric(result, row, "lastChangedAt", "last_changed_at"));
|
||||
|
||||
@@ -59,14 +59,15 @@ public class FinanceExportProviderService {
|
||||
RECEIVABLE_STATISTICS);
|
||||
private static final Set<String> SETTLEMENT_STATUSES = Set.of("ISSUED", "PARTIALLY_PAID", "PAID", "VOIDED");
|
||||
private static final Set<String> LEDGER_TYPES = Set.of("FEE_SOURCE_EDIT", "FEE_SOURCE_SUBMIT", "FEE_SOURCE_REVIEW_APPROVE", "FEE_SOURCE_REVIEW_REJECT", "FEE_SOURCE_ADJUST", "FEE_SOURCE_VOID", "RECONCILIATION_CREATED",
|
||||
"SETTLEMENT_ISSUED", "RECEIPT", "PAYMENT", "ADJUSTMENT", "VOID");
|
||||
"SETTLEMENT_ISSUED", "RECEIPT", "PAYMENT", "SUPPLIER_DIRECT_PAID",
|
||||
"SUPPLIER_ON_ACCOUNT_PAYMENT", "ADJUSTMENT", "VOID");
|
||||
|
||||
private static final List<ExportColumn> ACCOUNT_COLUMNS = List.of(
|
||||
column("customerCode", "客户编码", ExportColumnType.STRING, 18), column("customerName", "客户", ExportColumnType.STRING, 28),
|
||||
column("contactName", "联系人", ExportColumnType.STRING, 16), column("contactPhone", "联系电话", ExportColumnType.STRING, 18),
|
||||
column("outstandingReceivable", "待收(CNY)", ExportColumnType.DECIMAL, 16), column("pendingBilling", "待开账(CNY)", ExportColumnType.DECIMAL, 16),
|
||||
column("outstandingReceivable", "待收客户款(CNY)", ExportColumnType.DECIMAL, 18), column("pendingBilling", "待开账(CNY)", ExportColumnType.DECIMAL, 16),
|
||||
column("receivedInPeriod", "本期已收(CNY)", ExportColumnType.DECIMAL, 16), column("totalCost", "总成本(CNY)", ExportColumnType.DECIMAL, 16),
|
||||
column("paidCost", "已付成本(CNY)", ExportColumnType.DECIMAL, 16), column("outstandingPayable", "待付(CNY)", ExportColumnType.DECIMAL, 16),
|
||||
column("paidCost", "已线下支付供应商款(CNY)", ExportColumnType.DECIMAL, 24), column("outstandingPayable", "待付供应商款(CNY)", ExportColumnType.DECIMAL, 18),
|
||||
column("status", "客户状态", ExportColumnType.STRING, 14), column("lastChangedAt", "最近变动", ExportColumnType.DATETIME, 21));
|
||||
private static final List<ExportColumn> LEDGER_COLUMNS = List.of(
|
||||
column("ledgerId", "流水号", ExportColumnType.STRING, 22), column("occurredAt", "发生时间", ExportColumnType.DATETIME, 21),
|
||||
|
||||
@@ -72,6 +72,7 @@ public class FinanceFeeSourceEventService {
|
||||
}
|
||||
long id = ((Number) row.get("id")).longValue();
|
||||
if ("FIRSTLEG_FEE_VOIDED".equals(event.eventType())) {
|
||||
requireNotFrozen(id);
|
||||
jdbc.update("UPDATE fin_fee_source SET status='VOIDED',operation_version=operation_version+1 WHERE id=? AND status<>'VOIDED'", id);
|
||||
voidDirectPaidCost(snapshot);
|
||||
}
|
||||
@@ -80,6 +81,7 @@ public class FinanceFeeSourceEventService {
|
||||
if ("FIRSTLEG_FEE_VOIDED".equals(event.eventType())) {
|
||||
throw error("FIN-EVT-005", "作废事件对应的费用来源版本不存在");
|
||||
}
|
||||
requireSourceNotFrozen(snapshot.feeId());
|
||||
jdbc.update("UPDATE fin_fee_source SET status='SUPERSEDED' WHERE source_system='FIRSTLEG' AND source_fee_id=? AND status='AVAILABLE'",
|
||||
snapshot.feeId());
|
||||
jdbc.update("UPDATE fin_cost SET status='VOIDED',voided_at=CURRENT_TIMESTAMP(3),void_reason='来源版本已替代',version=version+1,updated_by=0 WHERE cost_origin='FIRSTLEG_DIRECT_PAID' AND source_system='FIRSTLEG' AND source_fee_id=? AND status='CONFIRMED'",
|
||||
@@ -100,6 +102,16 @@ public class FinanceFeeSourceEventService {
|
||||
return sourceId;
|
||||
}
|
||||
|
||||
private void requireSourceNotFrozen(long sourceFeeId) {
|
||||
Integer frozen = jdbc.queryForObject("SELECT COUNT(*) FROM fin_fee_source f WHERE f.source_system='FIRSTLEG' AND f.source_fee_id=? AND (EXISTS (SELECT 1 FROM fin_bill_line bl JOIN fin_bill b ON b.id=bl.bill_id AND b.tenant_id=bl.tenant_id WHERE bl.tenant_id=f.customer_id AND bl.source_id=f.id AND bl.source_version=f.source_fee_version AND b.status<>'VOIDED') OR EXISTS (SELECT 1 FROM fin_settlement_line sl JOIN fin_settlement s ON s.id=sl.settlement_id AND s.tenant_id=sl.tenant_id WHERE sl.tenant_id=f.customer_id AND sl.fee_source_id=f.id AND s.status<>'VOIDED'))", Integer.class, sourceFeeId);
|
||||
if (frozen != null && frozen > 0) throw error("FIN-EVT-016", "费用来源已进入有效单据,禁止调整或替代");
|
||||
}
|
||||
|
||||
private void requireNotFrozen(long feeSourceId) {
|
||||
Integer frozen = jdbc.queryForObject("SELECT COUNT(*) FROM fin_fee_source f WHERE f.id=? AND (EXISTS (SELECT 1 FROM fin_bill_line bl JOIN fin_bill b ON b.id=bl.bill_id AND b.tenant_id=bl.tenant_id WHERE bl.tenant_id=f.customer_id AND bl.source_id=f.id AND bl.source_version=f.source_fee_version AND b.status<>'VOIDED') OR EXISTS (SELECT 1 FROM fin_settlement_line sl JOIN fin_settlement s ON s.id=sl.settlement_id AND s.tenant_id=sl.tenant_id WHERE sl.tenant_id=f.customer_id AND sl.fee_source_id=f.id AND s.status<>'VOIDED'))", Integer.class, feeSourceId);
|
||||
if (frozen != null && frozen > 0) throw error("FIN-EVT-016", "费用来源已进入有效单据,禁止作废");
|
||||
}
|
||||
|
||||
private void materializeDirectPaidCost(FirstlegFeeSourceClient.Snapshot snapshot) {
|
||||
String costNo = "DIRECT-" + snapshot.sourceType() + "-" + snapshot.feeId() + "-V" + snapshot.feeVersion();
|
||||
jdbc.update("INSERT INTO fin_cost(tenant_id,cost_no,cost_type,cost_origin,source_system,source_type,source_fee_id,source_fee_version,source_hash,order_id,inbound_id,container_id,supplier_id,supplier_name_snapshot,fee_item_code,fee_item_name,currency_code,cost_amount,voucher_file_ids,paid_at,payment_method,payment_reference_no,status,confirmed_at,created_by,created_by_name,updated_by) VALUES(?,?,'SOURCE_DIRECT_PAID','FIRSTLEG_DIRECT_PAID','FIRSTLEG',?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'CONFIRMED',CURRENT_TIMESTAMP(3),0,'系统',0)",
|
||||
|
||||
+12
-9
@@ -66,12 +66,12 @@ public class FinancePhase1OperationService {
|
||||
String base = " FROM (" + CUSTOMER_IDS + ") c LEFT JOIN fin_customer_settlement_profile p ON p.tenant_id=c.customer_id" + filter;
|
||||
Long total = jdbc.queryForObject("SELECT COUNT(*)" + base, Long.class, filterArgs);
|
||||
String select = "SELECT c.customer_id,COALESCE(p.customer_name_snapshot,CONCAT('客户',c.customer_id)) customer_name,COALESCE(p.status,'ACTIVE') account_status," +
|
||||
"(SELECT COALESCE(SUM(outstanding_amount),0) FROM fin_bill b WHERE b.tenant_id=c.customer_id AND b.status<>'VOIDED') outstanding_receivable," +
|
||||
"(SELECT COALESCE(SUM(amount),0) FROM fin_fee_source f WHERE f.customer_id=c.customer_id AND f.direction='RECEIVABLE' AND f.status='AVAILABLE' AND NOT EXISTS(SELECT 1 FROM fin_bill_line bl WHERE bl.source_id=f.id AND bl.source_version=f.source_fee_version)) pending_billing," +
|
||||
"(SELECT COALESCE(SUM(outstanding_amount),0) FROM fin_settlement ar WHERE ar.tenant_id=c.customer_id AND ar.direction='RECEIVABLE' AND ar.status<>'VOIDED') outstanding_receivable," +
|
||||
"(SELECT COALESCE(SUM(amount),0) FROM fin_fee_source f WHERE f.customer_id=c.customer_id AND f.direction='RECEIVABLE' AND f.review_status='APPROVED' AND f.currency_code='CNY' AND f.amount>0 AND f.status='AVAILABLE' AND NOT EXISTS(SELECT 1 FROM fin_bill_line bl JOIN fin_bill b ON b.id=bl.bill_id AND b.tenant_id=bl.tenant_id WHERE bl.tenant_id=f.customer_id AND bl.source_id=f.id AND bl.source_version=f.source_fee_version AND b.status<>'VOIDED')) pending_billing," +
|
||||
"(SELECT COALESCE(SUM(received_amount),0) FROM fin_receipt r WHERE r.tenant_id=c.customer_id AND r.status='CONFIRMED') received_period," +
|
||||
"((SELECT COALESCE(SUM(amount),0) FROM fin_fee_source f WHERE f.customer_id=c.customer_id AND f.direction='PAYABLE' AND f.settlement_mode='ON_ACCOUNT' AND f.review_status='APPROVED' AND f.status NOT IN ('VOIDED','SUPERSEDED'))+(SELECT COALESCE(SUM(cost_amount),0) FROM fin_cost x WHERE x.tenant_id=c.customer_id AND x.status='CONFIRMED')) total_cost," +
|
||||
"((SELECT COALESCE(SUM(cost_amount),0) FROM fin_cost x WHERE x.tenant_id=c.customer_id AND x.status='CONFIRMED')+(SELECT COALESCE(SUM(paid_amount),0) FROM fin_settlement x WHERE x.tenant_id=c.customer_id AND x.direction='PAYABLE' AND x.status<>'VOIDED')) paid_cost," +
|
||||
"(SELECT COALESCE(SUM(outstanding_amount),0) FROM fin_settlement x WHERE x.tenant_id=c.customer_id AND x.direction='PAYABLE' AND x.status<>'VOIDED') outstanding_payable," +
|
||||
"((SELECT COALESCE(SUM(amount),0) FROM fin_fee_source f WHERE f.customer_id=c.customer_id AND f.direction='PAYABLE' AND f.settlement_mode='ON_ACCOUNT' AND f.review_status='APPROVED' AND f.currency_code='CNY' AND f.amount>0 AND f.status NOT IN ('VOIDED','SUPERSEDED'))+(SELECT COALESCE(SUM(cost_amount),0) FROM fin_cost x WHERE x.tenant_id=c.customer_id AND x.cost_origin='FIRSTLEG_DIRECT_PAID' AND x.status='CONFIRMED')) total_cost," +
|
||||
"((SELECT COALESCE(SUM(cost_amount),0) FROM fin_cost x WHERE x.tenant_id=c.customer_id AND x.cost_origin='FIRSTLEG_DIRECT_PAID' AND x.status='CONFIRMED')+(SELECT COALESCE(SUM(paid_amount),0) FROM fin_settlement x WHERE x.tenant_id=c.customer_id AND x.direction='PAYABLE' AND x.status<>'VOIDED')) paid_cost," +
|
||||
"((SELECT COALESCE(SUM(outstanding_amount),0) FROM fin_settlement x WHERE x.tenant_id=c.customer_id AND x.direction='PAYABLE' AND x.status<>'VOIDED')+(SELECT COALESCE(SUM(f.amount),0) FROM fin_fee_source f WHERE f.customer_id=c.customer_id AND f.direction='PAYABLE' AND f.settlement_mode='ON_ACCOUNT' AND f.review_status='APPROVED' AND f.currency_code='CNY' AND f.amount>0 AND f.status NOT IN ('VOIDED','SUPERSEDED') AND NOT EXISTS(SELECT 1 FROM fin_settlement_line sl JOIN fin_settlement s ON s.id=sl.settlement_id AND s.tenant_id=sl.tenant_id WHERE sl.tenant_id=f.customer_id AND sl.fee_source_id=f.id AND s.direction='PAYABLE' AND s.status<>'VOIDED'))) outstanding_payable," +
|
||||
"GREATEST(COALESCE((SELECT MAX(updated_at) FROM fin_bill b WHERE b.tenant_id=c.customer_id),'1970-01-01'),COALESCE((SELECT MAX(updated_at) FROM fin_receipt r WHERE r.tenant_id=c.customer_id),'1970-01-01'),COALESCE((SELECT MAX(updated_at) FROM fin_cost x WHERE x.tenant_id=c.customer_id),'1970-01-01'),COALESCE((SELECT MAX(updated_at) FROM fin_fee_source f WHERE f.customer_id=c.customer_id),'1970-01-01'),COALESCE((SELECT MAX(updated_at) FROM fin_settlement x WHERE x.tenant_id=c.customer_id),'1970-01-01')) last_changed_at" + base + " ORDER BY last_changed_at DESC,c.customer_id DESC LIMIT ? OFFSET ?";
|
||||
Object[] args = java.util.Arrays.copyOf(filterArgs, filterArgs.length + 2);
|
||||
args[args.length - 2] = pageSize; args[args.length - 1] = (page - 1) * pageSize;
|
||||
@@ -89,14 +89,15 @@ public class FinancePhase1OperationService {
|
||||
List<RawLedger> rows = new ArrayList<>();
|
||||
rows.addAll(jdbc.query("SELECT id,bill_no,created_at,total_amount,created_by,created_by_name,remark FROM fin_bill WHERE tenant_id=? AND status<>'VOIDED'", (rs, n) -> raw(rs, "SETTLEMENT_ISSUED", "BILL", "bill_no", "total_amount", null), customerId));
|
||||
rows.addAll(jdbc.query("SELECT id,receipt_no,received_at received_time,received_amount,created_by,created_by_name,remark FROM fin_receipt WHERE tenant_id=? AND status='CONFIRMED'", (rs, n) -> raw(rs, "RECEIPT", "RECEIPT", "receipt_no", null, "received_amount"), customerId));
|
||||
rows.addAll(jdbc.query("SELECT id,cost_no,created_at,cost_amount,created_by,created_by_name,remark FROM fin_cost WHERE tenant_id=? AND status<>'VOIDED'", (rs, n) -> raw(rs, "PAYMENT", "COST", "cost_no", null, null), customerId));
|
||||
rows.addAll(jdbc.query("SELECT id,cost_no,created_at,cost_amount,created_by,created_by_name,remark FROM fin_cost WHERE tenant_id=? AND cost_origin='FIRSTLEG_DIRECT_PAID' AND status='CONFIRMED'", (rs, n) -> raw(rs, "SUPPLIER_DIRECT_PAID", "DIRECT_PAID_COST", "cost_no", null, null), customerId));
|
||||
rows.addAll(jdbc.query("SELECT p.id,CONCAT(s.settlement_no,'-',p.id) payment_no,p.created_at,p.amount cost_amount,p.created_by,p.created_by_name,p.remark FROM fin_settlement_payment p JOIN fin_settlement s ON s.id=p.settlement_id AND s.tenant_id=p.tenant_id WHERE p.tenant_id=? AND p.transaction_type='PAYMENT' AND s.direction='PAYABLE' AND s.status<>'VOIDED'", (rs, n) -> raw(rs, "SUPPLIER_ON_ACCOUNT_PAYMENT", "AP_PAYMENT", "payment_no", null, null), customerId));
|
||||
rows.sort(Comparator.comparing(RawLedger::occurredAt).thenComparing(RawLedger::id));
|
||||
BigDecimal outstanding = BigDecimal.ZERO;
|
||||
List<LedgerEntry> projected = new ArrayList<>();
|
||||
for (RawLedger row : rows) {
|
||||
BigDecimal receivableChange = row.receivable();
|
||||
outstanding = outstanding.add(receivableChange);
|
||||
BigDecimal payable = "COST".equals(row.documentType()) ? row.amount() : BigDecimal.ZERO;
|
||||
BigDecimal payable = List.of("DIRECT_PAID_COST", "AP_PAYMENT").contains(row.documentType()) ? row.amount() : BigDecimal.ZERO;
|
||||
LedgerEntry entry = new LedgerEntry(row.id(), row.occurredAt(), row.businessType(), row.documentType(), row.id(), row.documentNo(), receivableChange, payable, outstanding, row.operatorName(), row.remark());
|
||||
if ((businessType == null || businessType.isBlank() || businessType.equals(entry.businessType())) && inRange(entry.occurredAt(), startDate, endDate)) projected.add(entry);
|
||||
}
|
||||
@@ -118,7 +119,8 @@ public class FinancePhase1OperationService {
|
||||
throw error("FIN-REQ-400", "开始日期不能晚于结束日期");
|
||||
}
|
||||
String normalizedDirection = normalized(direction, List.of("RECEIVABLE", "PAYABLE"), "费用方向无效");
|
||||
String normalizedReview = normalized(reviewStatus, List.of("PENDING", "APPROVED", "REJECTED"), "审核状态无效");
|
||||
String normalizedReview = normalized(reviewStatus,
|
||||
List.of("DRAFT", "PENDING", "APPROVED", "REJECTED"), "审核状态无效");
|
||||
String normalizedSourceStatus = normalized(sourceStatus,
|
||||
List.of("AVAILABLE", "LOCKED", "BILLED", "VOIDED", "SUPERSEDED"), "费用来源状态无效");
|
||||
String generatedSql = "EXISTS(SELECT 1 FROM fin_bill_line bl WHERE bl.source_id=f.id AND bl.source_version=f.source_fee_version)";
|
||||
@@ -342,7 +344,8 @@ public class FinancePhase1OperationService {
|
||||
|
||||
private static RawLedger raw(ResultSet rs, String businessType, String documentType, String noColumn, String debitColumn, String creditColumn) throws SQLException {
|
||||
LocalDateTime occurred = time(rs, documentType.equals("RECEIPT") ? "received_time" : "created_at");
|
||||
BigDecimal amount = documentType.equals("COST") ? money(rs, "cost_amount") : debitColumn != null ? money(rs, debitColumn) : money(rs, creditColumn);
|
||||
BigDecimal amount = List.of("DIRECT_PAID_COST", "AP_PAYMENT").contains(documentType)
|
||||
? money(rs, "cost_amount") : debitColumn != null ? money(rs, debitColumn) : money(rs, creditColumn);
|
||||
BigDecimal receivable = debitColumn != null ? amount : creditColumn != null ? amount.negate() : BigDecimal.ZERO;
|
||||
return new RawLedger(rs.getLong("id"), occurred, businessType, documentType, rs.getString(noColumn), amount, receivable,
|
||||
rs.getLong("created_by"), rs.getString("created_by_name"), rs.getString("remark"));
|
||||
|
||||
@@ -19,7 +19,6 @@ import com.baiyue.finance.dto.FinanceReadModels.ReconciliationSummary;
|
||||
import com.baiyue.finance.dto.FinanceReadModels.StatementDetail;
|
||||
import com.baiyue.finance.dto.FinanceReadModels.StatementLine;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.BadSqlGrammarException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
@@ -49,12 +48,12 @@ public class FinanceQueryService {
|
||||
|
||||
public CustomerAccount customerAccount(long customerId) {
|
||||
requireCustomer(customerId);
|
||||
BigDecimal total = amount("SELECT COALESCE(SUM(total_amount),0) FROM fin_bill WHERE tenant_id=? AND status<>'VOIDED'", customerId);
|
||||
BigDecimal received = amount("SELECT COALESCE(SUM(received_amount),0) FROM fin_bill WHERE tenant_id=? AND status<>'VOIDED'", customerId);
|
||||
BigDecimal outstanding = amount("SELECT COALESCE(SUM(outstanding_amount),0) FROM fin_bill WHERE tenant_id=? AND status<>'VOIDED'", customerId);
|
||||
BigDecimal total = amount("SELECT COALESCE(SUM(total_amount),0) FROM fin_settlement WHERE tenant_id=? AND direction='RECEIVABLE' AND status<>'VOIDED'", customerId);
|
||||
BigDecimal received = amount("SELECT COALESCE(SUM(paid_amount),0) FROM fin_settlement WHERE tenant_id=? AND direction='RECEIVABLE' AND status<>'VOIDED'", customerId);
|
||||
BigDecimal outstanding = amount("SELECT COALESCE(SUM(outstanding_amount),0) FROM fin_settlement WHERE tenant_id=? AND direction='RECEIVABLE' AND status<>'VOIDED'", customerId);
|
||||
List<AccountEntry> entries = new ArrayList<>();
|
||||
entries.addAll(jdbc.query("SELECT id,bill_no,created_at,total_amount,currency_code,status FROM fin_bill WHERE tenant_id=? AND status<>'VOIDED'", (rs, rowNum) ->
|
||||
new AccountEntry("RECEIVABLE", rs.getLong("id"), rs.getString("bill_no"), time(rs, "created_at"),
|
||||
entries.addAll(jdbc.query("SELECT id,settlement_no,created_at,total_amount,currency_code,status FROM fin_settlement WHERE tenant_id=? AND direction='RECEIVABLE' AND status<>'VOIDED'", (rs, rowNum) ->
|
||||
new AccountEntry("RECEIVABLE", rs.getLong("id"), rs.getString("settlement_no"), time(rs, "created_at"),
|
||||
rs.getBigDecimal("total_amount"), BigDecimal.ZERO, rs.getString("currency_code"), rs.getString("status")), customerId));
|
||||
entries.addAll(jdbc.query("SELECT id,receipt_no,received_at,received_amount,currency_code,status FROM fin_receipt WHERE tenant_id=? AND status<>'VOIDED'", (rs, rowNum) ->
|
||||
new AccountEntry("RECEIPT", rs.getLong("id"), rs.getString("receipt_no"), time(rs, "received_at"),
|
||||
@@ -69,37 +68,37 @@ public class FinanceQueryService {
|
||||
"COALESCE(SUM(CASE WHEN s.direction='RECEIVABLE' AND s.status<>'VOIDED' THEN s.total_amount ELSE 0 END),0) total_receivable," +
|
||||
"COALESCE(SUM(CASE WHEN s.direction='RECEIVABLE' AND s.status<>'VOIDED' THEN s.paid_amount ELSE 0 END),0) received_amount," +
|
||||
"COALESCE(SUM(CASE WHEN s.direction='RECEIVABLE' AND s.status<>'VOIDED' THEN s.outstanding_amount ELSE 0 END),0) outstanding_receivable," +
|
||||
"COALESCE(SUM(CASE WHEN s.direction='PAYABLE' AND s.status<>'VOIDED' THEN s.outstanding_amount ELSE 0 END),0) outstanding_payable," +
|
||||
"(SELECT COALESCE(SUM(f.amount),0) FROM fin_fee_source f WHERE f.customer_id=? AND f.direction='RECEIVABLE' " +
|
||||
"AND f.currency_code='CNY' AND f.status='AVAILABLE' " +
|
||||
"AND f.review_status='APPROVED' AND f.amount>0 " +
|
||||
"AND NOT EXISTS (SELECT 1 FROM fin_bill_line bl JOIN fin_bill b ON b.id=bl.bill_id AND b.tenant_id=bl.tenant_id " +
|
||||
"WHERE bl.tenant_id=f.customer_id AND bl.source_id=f.id AND b.status<>'VOIDED')) pending_billing," +
|
||||
"((SELECT COALESCE(SUM(f.amount),0) FROM fin_fee_source f WHERE f.customer_id=? AND f.direction='PAYABLE' AND f.settlement_mode='ON_ACCOUNT' AND f.review_status='APPROVED' AND f.status NOT IN ('VOIDED','SUPERSEDED'))+" +
|
||||
"(SELECT COALESCE(SUM(c.cost_amount),0) FROM fin_cost c WHERE c.tenant_id=? AND c.status='CONFIRMED')) total_cost," +
|
||||
"((SELECT COALESCE(SUM(c.cost_amount),0) FROM fin_cost c WHERE c.tenant_id=? AND c.status='CONFIRMED')+" +
|
||||
"COALESCE(SUM(CASE WHEN s.direction='PAYABLE' AND s.status<>'VOIDED' THEN s.paid_amount ELSE 0 END),0)) paid_cost," +
|
||||
"((SELECT COALESCE(SUM(f.amount),0) FROM fin_fee_source f WHERE f.customer_id=? AND f.direction='PAYABLE' AND f.settlement_mode='ON_ACCOUNT' AND f.review_status='APPROVED' AND f.currency_code='CNY' AND f.amount>0 AND f.status NOT IN ('VOIDED','SUPERSEDED'))+" +
|
||||
"(SELECT COALESCE(SUM(c.cost_amount),0) FROM fin_cost c WHERE c.tenant_id=? AND c.cost_origin='FIRSTLEG_DIRECT_PAID' AND c.status='CONFIRMED')) total_cost," +
|
||||
"((SELECT COALESCE(SUM(c.cost_amount),0) FROM fin_cost c WHERE c.tenant_id=? AND c.cost_origin='FIRSTLEG_DIRECT_PAID' AND c.status='CONFIRMED')+" +
|
||||
"(SELECT COALESCE(SUM(ps.paid_amount),0) FROM fin_settlement ps WHERE ps.tenant_id=? AND ps.direction='PAYABLE' AND ps.status<>'VOIDED')) paid_cost," +
|
||||
"((SELECT COALESCE(SUM(os.outstanding_amount),0) FROM fin_settlement os WHERE os.tenant_id=? AND os.direction='PAYABLE' AND os.status<>'VOIDED')+" +
|
||||
"(SELECT COALESCE(SUM(f.amount),0) FROM fin_fee_source f WHERE f.customer_id=? AND f.direction='PAYABLE' AND f.settlement_mode='ON_ACCOUNT' AND f.review_status='APPROVED' AND f.currency_code='CNY' AND f.amount>0 AND f.status NOT IN ('VOIDED','SUPERSEDED') " +
|
||||
"AND NOT EXISTS (SELECT 1 FROM fin_settlement_line sl JOIN fin_settlement aps ON aps.id=sl.settlement_id AND aps.tenant_id=sl.tenant_id WHERE sl.tenant_id=f.customer_id AND sl.fee_source_id=f.id AND aps.direction='PAYABLE' AND aps.status<>'VOIDED'))) outstanding_payable," +
|
||||
"GREATEST(COALESCE(MAX(CASE WHEN s.status<>'VOIDED' THEN s.updated_at END),'1970-01-01')," +
|
||||
"COALESCE((SELECT MAX(f.updated_at) FROM fin_fee_source f WHERE f.customer_id=?),'1970-01-01')," +
|
||||
"COALESCE((SELECT MAX(c.updated_at) FROM fin_cost c WHERE c.tenant_id=?),'1970-01-01')) last_changed_at " +
|
||||
"FROM fin_settlement s WHERE s.tenant_id=?";
|
||||
try {
|
||||
return jdbc.queryForObject(sql, (rs, rowNum) -> new CustomerAccountSummary(customerId, CNY,
|
||||
money(rs, "total_receivable"), money(rs, "received_amount"), money(rs, "outstanding_receivable"),
|
||||
money(rs, "pending_billing"), money(rs, "total_cost"), money(rs, "paid_cost"),
|
||||
money(rs, "outstanding_payable"), Instant.now(), time(rs, "last_changed_at")),
|
||||
customerId, customerId, customerId, customerId, customerId, customerId, customerId);
|
||||
} catch (BadSqlGrammarException ignored) {
|
||||
// Empty/partially migrated Finance databases may not have a settlement projection yet.
|
||||
// The read-only account summary must still be usable as a zero CNY baseline.
|
||||
return new CustomerAccountSummary(customerId, CNY, BigDecimal.ZERO, BigDecimal.ZERO,
|
||||
BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO,
|
||||
Instant.now(), null);
|
||||
}
|
||||
return jdbc.queryForObject(sql, (rs, rowNum) -> new CustomerAccountSummary(customerId, CNY,
|
||||
money(rs, "total_receivable"), money(rs, "received_amount"), money(rs, "outstanding_receivable"),
|
||||
money(rs, "pending_billing"), money(rs, "total_cost"), money(rs, "paid_cost"),
|
||||
money(rs, "outstanding_payable"), Instant.now(), time(rs, "last_changed_at")),
|
||||
customerId, customerId, customerId, customerId, customerId, customerId, customerId,
|
||||
customerId, customerId, customerId);
|
||||
}
|
||||
|
||||
public List<FeePoolItem> feePool(Long customerId, String direction) {
|
||||
return feePool(customerId, direction, null);
|
||||
}
|
||||
|
||||
public List<FeePoolItem> feePool(Long customerId, String direction, Long supplierId) {
|
||||
validateOptionalCustomer(customerId);
|
||||
if (supplierId != null && supplierId <= 0) throw bad("供应商范围无效");
|
||||
String normalized = direction == null || direction.isBlank() ? "ALL" : direction.trim().toUpperCase(java.util.Locale.ROOT);
|
||||
if (!List.of("ALL", "RECEIVABLE", "PAYABLE").contains(normalized)) throw bad("费用方向只能是RECEIVABLE或PAYABLE");
|
||||
List<FeePoolItem> result = new ArrayList<>();
|
||||
@@ -107,6 +106,14 @@ public class FinanceQueryService {
|
||||
List<Object> sourceArgs = new ArrayList<>();
|
||||
if (customerId != null) { sourceSql += " AND customer_id=?"; sourceArgs.add(customerId); }
|
||||
if (!"ALL".equals(normalized)) { sourceSql += " AND direction=?"; sourceArgs.add(normalized); }
|
||||
if ("PAYABLE".equals(normalized)) {
|
||||
sourceSql += " AND review_status='APPROVED' AND settlement_mode='ON_ACCOUNT' AND currency_code='CNY' AND amount>0";
|
||||
sourceSql += " AND NOT EXISTS (SELECT 1 FROM fin_settlement_line sl JOIN fin_settlement s ON s.id=sl.settlement_id WHERE sl.fee_source_id=fin_fee_source.id AND s.status<>'VOIDED')";
|
||||
if (supplierId != null) {
|
||||
sourceSql += " AND CAST(JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.supplierId')) AS UNSIGNED)=?";
|
||||
sourceArgs.add(supplierId);
|
||||
}
|
||||
}
|
||||
sourceSql += " ORDER BY created_at DESC,id DESC";
|
||||
result.addAll(jdbc.query(sourceSql, (rs, rowNum) -> {
|
||||
String rowDirection = rs.getString("direction");
|
||||
|
||||
@@ -34,6 +34,7 @@ CREATE TABLE IF NOT EXISTS fin_fee_source (
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_fin_fee_source (source_system, source_fee_id, source_fee_version),
|
||||
KEY idx_fin_fee_source_customer (customer_id, status),
|
||||
KEY idx_fin_fee_source_customer_fund (customer_id, direction, settlement_mode, review_status, status, currency_code, id),
|
||||
CONSTRAINT chk_fin_fee_source_currency CHECK (currency_code = 'CNY'),
|
||||
CONSTRAINT chk_fin_fee_source_amount CHECK (amount >= 0),
|
||||
CONSTRAINT chk_fin_fee_source_settlement CHECK (
|
||||
@@ -227,7 +228,7 @@ CREATE TABLE IF NOT EXISTS fin_cost (
|
||||
fee_item_code VARCHAR(64) NOT NULL COMMENT '成本费用项目编码快照',
|
||||
fee_item_name VARCHAR(200) NOT NULL COMMENT '成本费用项目名称快照',
|
||||
currency_code CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217三位币种代码,首期仅CNY',
|
||||
cost_amount DECIMAL(20,6) NOT NULL COMMENT '成本总金额,精度6位小数',
|
||||
cost_amount DECIMAL(20,6) NOT NULL COMMENT '成本总金额;仅FIRSTLEG_DIRECT_PAID且CONFIRMED计入已线下支付供应商款',
|
||||
voucher_file_ids JSON NOT NULL COMMENT 'File服务财务凭证文件ID字符串数组JSON',
|
||||
paid_at DATETIME(3) NULL COMMENT '直接已付时间;人工补充成本可为空',
|
||||
payment_method VARCHAR(32) NULL COMMENT '直接已付方式:ONLINE、BANK_TRANSFER、CASH、OTHER',
|
||||
@@ -250,6 +251,7 @@ CREATE TABLE IF NOT EXISTS fin_cost (
|
||||
UNIQUE KEY uk_fin_cost_source_version (source_system, source_type, source_fee_id, source_fee_version),
|
||||
KEY idx_fin_cost_ticket (tenant_id, order_id, inbound_id),
|
||||
KEY idx_fin_cost_container (tenant_id, container_id),
|
||||
KEY idx_fin_cost_customer_origin_status (tenant_id, cost_origin, status),
|
||||
CONSTRAINT chk_fin_cost_tenant CHECK (tenant_id > 0),
|
||||
CONSTRAINT chk_fin_cost_currency CHECK (currency_code = 'CNY'),
|
||||
CONSTRAINT chk_fin_cost_amount CHECK (cost_amount > 0),
|
||||
@@ -356,8 +358,8 @@ CREATE TABLE IF NOT EXISTS fin_settlement (
|
||||
business_period VARCHAR(32) NULL COMMENT '结算业务期间快照',
|
||||
currency_code CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217三位币种代码,首期仅CNY',
|
||||
total_amount DECIMAL(20,6) NOT NULL COMMENT '正式结算总金额,精度6位小数',
|
||||
paid_amount DECIMAL(20,6) NOT NULL DEFAULT 0 COMMENT '累计已收或已付金额,精度6位小数',
|
||||
outstanding_amount DECIMAL(20,6) NOT NULL COMMENT '未结清金额,精度6位小数',
|
||||
paid_amount DECIMAL(20,6) NOT NULL DEFAULT 0 COMMENT '累计已收或已付金额;PAYABLE已付计入已线下支付供应商款',
|
||||
outstanding_amount DECIMAL(20,6) NOT NULL COMMENT '未结清金额;RECEIVABLE计待收,PAYABLE与未制单ON_ACCOUNT来源共同计待付',
|
||||
generated_date DATE NOT NULL COMMENT '结算单生成日期',
|
||||
due_date DATE NOT NULL COMMENT '结算到期日期',
|
||||
payment_status VARCHAR(24) NOT NULL DEFAULT 'UNPAID' COMMENT '收付款状态:UNPAID未收付、PARTIALLY_PAID部分收付、PAID已结清',
|
||||
@@ -415,6 +417,7 @@ CREATE TABLE IF NOT EXISTS fin_settlement_payment (
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_fin_settlement_payment_no (tenant_id, transaction_no),
|
||||
KEY idx_fin_settlement_payment_settlement (tenant_id, settlement_id, created_at),
|
||||
KEY idx_fin_settlement_payment_customer_type_time (tenant_id, transaction_type, created_at, settlement_id),
|
||||
CONSTRAINT chk_fin_settlement_payment_tenant CHECK (tenant_id > 0),
|
||||
CONSTRAINT chk_fin_settlement_payment_currency CHECK (currency_code = 'CNY'),
|
||||
CONSTRAINT chk_fin_settlement_payment_amount CHECK (amount > 0)
|
||||
|
||||
@@ -16,8 +16,10 @@ import java.math.BigDecimal;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.Timestamp;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
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.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.mock;
|
||||
@@ -27,11 +29,36 @@ import org.springframework.jdbc.BadSqlGrammarException;
|
||||
|
||||
class CustomerAccountSummaryBehaviorTest {
|
||||
|
||||
@Test
|
||||
void accountReadUsesFormalArSettlementsAndKeepsReceiptHistorySeparate() {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
when(jdbc.queryForObject(anyString(), org.mockito.ArgumentMatchers.eq(BigDecimal.class),
|
||||
org.mockito.ArgumentMatchers.eq(9L))).thenReturn(new BigDecimal("100.00"));
|
||||
when(jdbc.query(anyString(), any(RowMapper.class), org.mockito.ArgumentMatchers.eq(9L)))
|
||||
.thenReturn(List.of());
|
||||
|
||||
var result = new FinanceQueryService(jdbc, new ObjectMapper()).customerAccount(9L);
|
||||
|
||||
assertThat(result.accountMode()).isEqualTo("MANUAL_RECEIVABLE_LEDGER");
|
||||
var amountSql = org.mockito.ArgumentCaptor.forClass(String.class);
|
||||
verify(jdbc, org.mockito.Mockito.times(3)).queryForObject(amountSql.capture(),
|
||||
org.mockito.ArgumentMatchers.eq(BigDecimal.class), org.mockito.ArgumentMatchers.eq(9L));
|
||||
assertThat(amountSql.getAllValues()).allSatisfy(sql -> assertThat(sql)
|
||||
.contains("FROM fin_settlement", "direction='RECEIVABLE'", "status<>'VOIDED'"));
|
||||
var entrySql = org.mockito.ArgumentCaptor.forClass(String.class);
|
||||
verify(jdbc, org.mockito.Mockito.times(2)).query(entrySql.capture(), any(RowMapper.class),
|
||||
org.mockito.ArgumentMatchers.eq(9L));
|
||||
assertThat(entrySql.getAllValues()).anySatisfy(sql -> assertThat(sql)
|
||||
.contains("settlement_no", "FROM fin_settlement", "direction='RECEIVABLE'"));
|
||||
assertThat(entrySql.getAllValues()).anySatisfy(sql -> assertThat(sql)
|
||||
.contains("FROM fin_receipt", "status<>'VOIDED'"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aggregatesFormalSettlementsInOneCustomerScopedQuery() throws Exception {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
ResultSet rs = summaryRow("300.00", "140.00", "160.00", "25.00", "260.00", "180.00", "80.00");
|
||||
when(jdbc.queryForObject(anyString(), any(RowMapper.class), any(), any(), any(), any(), any(), any(), any())).thenAnswer(invocation ->
|
||||
when(jdbc.queryForObject(anyString(), any(RowMapper.class), any(), any(), any(), any(), any(), any(), any(), any(), any(), any())).thenAnswer(invocation ->
|
||||
((RowMapper<?>) invocation.getArgument(1)).mapRow(rs, 0));
|
||||
FinanceQueryService service = new FinanceQueryService(jdbc, new ObjectMapper());
|
||||
|
||||
@@ -52,14 +79,15 @@ class CustomerAccountSummaryBehaviorTest {
|
||||
org.mockito.ArgumentMatchers.eq(9L), org.mockito.ArgumentMatchers.eq(9L),
|
||||
org.mockito.ArgumentMatchers.eq(9L), org.mockito.ArgumentMatchers.eq(9L),
|
||||
org.mockito.ArgumentMatchers.eq(9L), org.mockito.ArgumentMatchers.eq(9L),
|
||||
org.mockito.ArgumentMatchers.eq(9L));
|
||||
org.mockito.ArgumentMatchers.eq(9L), org.mockito.ArgumentMatchers.eq(9L),
|
||||
org.mockito.ArgumentMatchers.eq(9L), org.mockito.ArgumentMatchers.eq(9L));
|
||||
assertThat(sql.getValue())
|
||||
.contains("FROM fin_settlement s WHERE s.tenant_id=?")
|
||||
.contains("s.direction='RECEIVABLE' AND s.status<>'VOIDED'")
|
||||
.contains("s.direction='PAYABLE' AND s.status<>'VOIDED'")
|
||||
.contains("f.settlement_mode='ON_ACCOUNT'", "c.status='CONFIRMED'", "s.paid_amount")
|
||||
.contains("os.direction='PAYABLE' AND os.status<>'VOIDED'")
|
||||
.contains("f.settlement_mode='ON_ACCOUNT'", "c.cost_origin='FIRSTLEG_DIRECT_PAID'", "ps.paid_amount")
|
||||
.contains("f.customer_id=?", "f.status='AVAILABLE'", "f.review_status='APPROVED'", "f.amount>0")
|
||||
.contains("b.status<>'VOIDED'")
|
||||
.contains("b.status<>'VOIDED'", "FROM fin_settlement_line sl", "sl.fee_source_id=f.id", "aps.status<>'VOIDED'")
|
||||
.doesNotContain("JSON_EXTRACT")
|
||||
.doesNotContain("SUM(received_amount)", "FROM fin_receipt r");
|
||||
}
|
||||
@@ -68,7 +96,7 @@ class CustomerAccountSummaryBehaviorTest {
|
||||
void emptyCustomerReturnsZeroAmounts() throws Exception {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
ResultSet rs = summaryRow("0", "0", "0", "0", "0", "0", "0");
|
||||
when(jdbc.queryForObject(anyString(), any(RowMapper.class), any(), any(), any(), any(), any(), any(), any())).thenAnswer(invocation ->
|
||||
when(jdbc.queryForObject(anyString(), any(RowMapper.class), any(), any(), any(), any(), any(), any(), any(), any(), any(), any())).thenAnswer(invocation ->
|
||||
((RowMapper<?>) invocation.getArgument(1)).mapRow(rs, 0));
|
||||
|
||||
CustomerAccountSummary result = new FinanceQueryService(jdbc, new ObjectMapper()).customerAccountSummary(77L);
|
||||
@@ -83,23 +111,13 @@ class CustomerAccountSummaryBehaviorTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingSettlementProjectionReturnsZeroCnyBaseline() {
|
||||
void missingSettlementProjectionFailsClosedInsteadOfReturningFakeZeroAmounts() {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
when(jdbc.queryForObject(anyString(), any(RowMapper.class), any(), any(), any(), any(), any(), any(), any()))
|
||||
when(jdbc.queryForObject(anyString(), any(RowMapper.class), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
|
||||
.thenThrow(new BadSqlGrammarException("customer summary", "SELECT ...", null));
|
||||
|
||||
CustomerAccountSummary result = new FinanceQueryService(jdbc, new ObjectMapper()).customerAccountSummary(88L);
|
||||
|
||||
assertThat(result.customerId()).isEqualTo(88L);
|
||||
assertThat(result.currency()).isEqualTo("CNY");
|
||||
assertThat(result.totalReceivable()).isZero();
|
||||
assertThat(result.receivedAmount()).isZero();
|
||||
assertThat(result.outstandingReceivable()).isZero();
|
||||
assertThat(result.pendingBilling()).isZero();
|
||||
assertThat(result.totalCost()).isZero();
|
||||
assertThat(result.paidCost()).isZero();
|
||||
assertThat(result.outstandingPayable()).isZero();
|
||||
assertThat(result.lastChangedAt()).isNull();
|
||||
assertThatThrownBy(() -> new FinanceQueryService(jdbc, new ObjectMapper()).customerAccountSummary(88L))
|
||||
.isInstanceOf(BadSqlGrammarException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -16,6 +16,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.atLeastOnce;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@@ -42,6 +43,29 @@ class FinanceCustomerAccountServiceTest {
|
||||
verify(iam).page("internal-token", 1, 20, "Alice", "ACTIVE", 900000000000000001L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void operatingFundQueriesUseFormalArUnbilledApDedupAndSupplierOnlyPaidAmounts() {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
IamCustomerClient iam = mock(IamCustomerClient.class);
|
||||
when(jdbc.queryForList(anyString(), any(Object[].class))).thenReturn(List.of());
|
||||
when(iam.page("internal-token", 1, 20, null, null, 9L)).thenReturn(ApiResponse.success(
|
||||
new PageResult<>(List.of(new IamCustomerClient.Customer(9L, "C-9", "客户9", "ACTIVE", null, null)),
|
||||
1, 20, 1, 1)));
|
||||
|
||||
new FinanceCustomerAccountService(jdbc, iam, "internal-token").list(1, 20, null, null, 9L);
|
||||
|
||||
var sql = org.mockito.ArgumentCaptor.forClass(String.class);
|
||||
verify(jdbc, atLeastOnce()).queryForList(sql.capture(), any(Object[].class));
|
||||
String all = String.join("\n", sql.getAllValues());
|
||||
assertThat(all)
|
||||
.contains("SUM(outstanding_amount) amount FROM fin_settlement", "direction='RECEIVABLE'")
|
||||
.contains("cost_origin='FIRSTLEG_DIRECT_PAID' AND status='CONFIRMED'")
|
||||
.contains("direction='PAYABLE' AND status<>'VOIDED'")
|
||||
.contains("NOT EXISTS (SELECT 1 FROM fin_settlement_line")
|
||||
.contains("sl.fee_source_id=f.id", "s.status<>'VOIDED'")
|
||||
.contains("review_status='APPROVED'", "currency_code='CNY'", "amount>0");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsUnsupportedFinanceAccountStatus() {
|
||||
var service = new FinanceCustomerAccountService(mock(JdbcTemplate.class), mock(IamCustomerClient.class), "token");
|
||||
|
||||
@@ -49,6 +49,11 @@ class FinanceExportContractTest {
|
||||
assertThat(openApi).contains("/internal/exports/v1/{exportType}/batches:")
|
||||
.contains("customerId?, status?, settlementNo?")
|
||||
.contains("所有 DETAIL 仅 settlementId")
|
||||
.contains("仅三类运营资金:待收客户款、待付供应商款、已线下支付供应商款")
|
||||
.contains("operatingCards: [outstandingReceivable, outstandingPayable, paidCost]")
|
||||
.contains("辅助字段只允许用于列表、明细或历史说明,不得生成第四类运营资金卡片")
|
||||
.contains("AP outstanding加未制单有效ON_ACCOUNT来源并去重")
|
||||
.contains("已线下支付供应商款;不存在客户线下支付含义")
|
||||
.doesNotContain("DETAIL: settlementId, direction");
|
||||
for (String type : TYPES) {
|
||||
assertThat(openApi).contains("- " + type);
|
||||
|
||||
@@ -39,14 +39,35 @@ class FinanceFeePoolDirectionBehaviorTest {
|
||||
assertThat(jdbc.costQueryCount).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void payablePoolOnlyQueriesApprovedUnsettledSourcesForTheSelectedSupplier() {
|
||||
DirectionJdbc jdbc = new DirectionJdbc();
|
||||
FinanceQueryService service = new FinanceQueryService(jdbc, new ObjectMapper());
|
||||
|
||||
service.feePool(7L, "PAYABLE", 81L);
|
||||
|
||||
assertThat(jdbc.sourceSql).contains(
|
||||
"review_status='APPROVED'",
|
||||
"settlement_mode='ON_ACCOUNT'",
|
||||
"currency_code='CNY'",
|
||||
"NOT EXISTS (SELECT 1 FROM fin_settlement_line",
|
||||
"s.status<>'VOIDED'",
|
||||
"CAST(JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.supplierId')) AS UNSIGNED)=?");
|
||||
assertThat(jdbc.sourceArgs).containsExactly(7L, "PAYABLE", 81L);
|
||||
}
|
||||
|
||||
private static final class DirectionJdbc extends JdbcTemplate {
|
||||
private final List<String> sourceDirections = new ArrayList<>();
|
||||
private int costQueryCount;
|
||||
private String sourceSql;
|
||||
private List<Object> sourceArgs = List.of();
|
||||
|
||||
@Override
|
||||
@SuppressWarnings("unchecked")
|
||||
public <T> List<T> query(String sql, RowMapper<T> mapper, Object... args) {
|
||||
if (sql.contains("FROM fin_fee_source")) {
|
||||
sourceSql = sql;
|
||||
sourceArgs = List.of(args);
|
||||
String direction = String.valueOf(args[1]);
|
||||
sourceDirections.add(direction);
|
||||
return (List<T>) List.of(item(direction, "FIRSTLEG_FEE", 1L));
|
||||
|
||||
@@ -147,6 +147,22 @@ class FinanceFeeSourceEventServiceTest {
|
||||
verify(jdbc).update(startsWith("INSERT INTO fin_fee_source"), any(Object[].class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void adjustmentRejectsSourceFrozenByActiveBillOrAp() {
|
||||
FinanceFeeSourceEvent event = inboundEvent("adjusted-frozen", "FIRSTLEG_FEE_ADJUSTED", 2, 7);
|
||||
givenNewEvent();
|
||||
when(firstleg.inbound(anyString(), eq(21L), eq(31L), eq(2)))
|
||||
.thenReturn(ApiResponse.success(snapshot("INBOUND_FEE", 7, 2, 21L, null)));
|
||||
when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_fee_source f WHERE f.source_system='FIRSTLEG'"),
|
||||
eq(Integer.class), eq(31L))).thenReturn(1);
|
||||
|
||||
assertThatThrownBy(() -> service.consume(event))
|
||||
.isInstanceOf(FinanceEventException.class)
|
||||
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-EVT-016");
|
||||
verify(jdbc, never()).update(contains("status='SUPERSEDED'"), anyLong());
|
||||
verify(jdbc, never()).update(startsWith("INSERT INTO fin_fee_source"), any(Object[].class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void voidMarksExistingVersionWithoutDeletingHistory() {
|
||||
FinanceFeeSourceEvent event = inboundEvent("voided-2", "FIRSTLEG_FEE_VOIDED", 2, 7);
|
||||
@@ -160,6 +176,24 @@ class FinanceFeeSourceEventServiceTest {
|
||||
verify(jdbc, never()).update(startsWith("DELETE"), any(Object[].class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void voidRejectsSourceFrozenByActiveBillOrAp() {
|
||||
FinanceFeeSourceEvent event = inboundEvent("voided-frozen", "FIRSTLEG_FEE_VOIDED", 2, 7);
|
||||
when(jdbc.queryForList(startsWith("SELECT payload_hash,status"), eq("FIRSTLEG"), eq("voided-frozen")))
|
||||
.thenReturn(List.of());
|
||||
when(firstleg.inbound(anyString(), eq(21L), eq(31L), eq(2)))
|
||||
.thenReturn(ApiResponse.success(snapshot("INBOUND_FEE", 7, 2, 21L, null)));
|
||||
when(jdbc.queryForList(startsWith("SELECT id,source_hash,status"), eq(31L), eq(2)))
|
||||
.thenReturn(List.of(Map.of("id", 88L, "source_hash", "source-hash", "status", "AVAILABLE")));
|
||||
when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_fee_source f WHERE f.id=?"),
|
||||
eq(Integer.class), eq(88L))).thenReturn(1);
|
||||
|
||||
assertThatThrownBy(() -> service.consume(event))
|
||||
.isInstanceOf(FinanceEventException.class)
|
||||
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-EVT-016");
|
||||
verify(jdbc, never()).update(contains("SET status='VOIDED'"), eq(88L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sameEventAndContentReturnsFirstResourceWithoutCallingFirstlegAgain() throws Exception {
|
||||
FinanceFeeSourceEvent event = inboundEvent("same-1", "FIRSTLEG_FEE_PUBLISHED", 1, 7);
|
||||
|
||||
@@ -106,6 +106,9 @@ class FinancePhase1OperationBehaviorTest {
|
||||
assertThat(result.requestId()).isNotBlank();
|
||||
assertThat(result.traceId()).isNotBlank();
|
||||
assertThat(jdbc.accountListSql).contains("p.customer_name_snapshot");
|
||||
assertThat(jdbc.accountListSql)
|
||||
.contains("ar.direction='RECEIVABLE'", "cost_origin='FIRSTLEG_DIRECT_PAID'", "FROM fin_settlement_line sl")
|
||||
.contains("sl.fee_source_id=f.id", "s.status<>'VOIDED'");
|
||||
assertThat(jdbc.accountListSql).doesNotContain("CAST(c.customer_id AS CHAR)");
|
||||
}
|
||||
|
||||
@@ -118,7 +121,11 @@ class FinancePhase1OperationBehaviorTest {
|
||||
service.listDerivedCustomerAccountLedger(9L, 1, 20, null, null, null);
|
||||
|
||||
assertThat(result.data()).isEmpty();
|
||||
assertThat(jdbc.ledgerCustomerIds).containsExactly(9L, 9L, 9L);
|
||||
assertThat(jdbc.ledgerCustomerIds).containsExactly(9L, 9L, 9L, 9L);
|
||||
assertThat(jdbc.ledgerSql).anySatisfy(sql -> assertThat(sql)
|
||||
.contains("cost_origin='FIRSTLEG_DIRECT_PAID'", "status='CONFIRMED'"));
|
||||
assertThat(jdbc.ledgerSql).anySatisfy(sql -> assertThat(sql)
|
||||
.contains("fin_settlement_payment", "transaction_type='PAYMENT'", "s.direction='PAYABLE'"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -349,6 +356,7 @@ class FinancePhase1OperationBehaviorTest {
|
||||
|
||||
private static final class AccountJdbc extends JdbcTemplate {
|
||||
private final List<Long> ledgerCustomerIds = new ArrayList<>();
|
||||
private final List<String> ledgerSql = new ArrayList<>();
|
||||
private String accountListSql = "";
|
||||
|
||||
@Override
|
||||
@@ -369,8 +377,9 @@ class FinancePhase1OperationBehaviorTest {
|
||||
"ACTIVE", LocalDateTime.of(2026, 8, 11, 10, 0)));
|
||||
}
|
||||
if (sql.startsWith("SELECT id,bill_no") || sql.startsWith("SELECT id,receipt_no")
|
||||
|| sql.startsWith("SELECT id,cost_no")) {
|
||||
|| sql.startsWith("SELECT id,cost_no") || sql.startsWith("SELECT p.id")) {
|
||||
ledgerCustomerIds.add(((Number) Arrays.asList(args).get(0)).longValue());
|
||||
ledgerSql.add(sql);
|
||||
return List.of();
|
||||
}
|
||||
return List.of();
|
||||
|
||||
@@ -56,6 +56,8 @@ class FinanceSqlCommentContractTest {
|
||||
assertThat(sql).contains("RECEIVED已接收、PROCESSED已完成、FAILED处理失败");
|
||||
assertThat(sql).contains("Firstleg费用来源完整不可变JSON快照");
|
||||
assertThat(sql).contains("ON_ACCOUNT账期应付、DIRECT_PAID业务已付");
|
||||
assertThat(sql).contains("仅FIRSTLEG_DIRECT_PAID且CONFIRMED计入已线下支付供应商款");
|
||||
assertThat(sql).contains("RECEIVABLE计待收,PAYABLE与未制单ON_ACCOUNT来源共同计待付");
|
||||
assertThat(sql).contains("规范化完整请求的SHA-256十六进制哈希");
|
||||
}
|
||||
|
||||
@@ -72,6 +74,17 @@ class FinanceSqlCommentContractTest {
|
||||
.contains("queue_column_comment('baiyue_finance', 'fin_bill_line', 'adjustment_reason'");
|
||||
}
|
||||
|
||||
@Test
|
||||
void operatingFundAggregatesHaveDedicatedBaselineIndexes() throws Exception {
|
||||
String sql = Files.readString(Path.of("src/main/resources/db/finance.sql"), StandardCharsets.UTF_8);
|
||||
assertThat(sql)
|
||||
.contains("KEY idx_fin_fee_source_customer_fund (customer_id, direction, settlement_mode, review_status, status, currency_code, id)")
|
||||
.contains("KEY idx_fin_cost_customer_origin_status (tenant_id, cost_origin, status)")
|
||||
.contains("KEY idx_fin_settlement_customer_direction_status (tenant_id, direction, status)")
|
||||
.contains("KEY idx_fin_settlement_line_source (fee_source_id)")
|
||||
.contains("KEY idx_fin_settlement_payment_customer_type_time (tenant_id, transaction_type, created_at, settlement_id)");
|
||||
}
|
||||
|
||||
@Test
|
||||
void financeLedgerSourcesPersistOperatorNameSnapshotsInBaselineAndUnifiedSql() throws Exception {
|
||||
String baseline = Files.readString(Path.of("src/main/resources/db/finance.sql"), StandardCharsets.UTF_8);
|
||||
|
||||
+18
@@ -112,6 +112,24 @@ class FinanceExportProviderServiceTest {
|
||||
verify(operationService).listDerivedCustomerAccountLedger(10001L, 1, 200, null, null, null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void accountExportUsesOperatingFundNamesAndAcceptsSupplierPaymentLedgerFilters() {
|
||||
when(accountService.list(1, 100, null, null, 10001L))
|
||||
.thenReturn(PageEnvelope.success(List.of(), 1, 100, 0));
|
||||
var account = service.exportBatch(FinanceExportProviderService.CUSTOMER_ACCOUNT_LIST,
|
||||
request(Map.of("customerId", "10001"), null, null));
|
||||
assertThat(account.columns()).extracting(column -> column.title()).contains(
|
||||
"待收客户款(CNY)", "待付供应商款(CNY)", "已线下支付供应商款(CNY)");
|
||||
|
||||
for (String businessType : List.of("SUPPLIER_DIRECT_PAID", "SUPPLIER_ON_ACCOUNT_PAYMENT")) {
|
||||
when(operationService.listDerivedCustomerAccountLedger(10001L, 1, 100, businessType, null, null))
|
||||
.thenReturn(PageEnvelope.success(List.of(), 1, 100, 0));
|
||||
service.exportBatch(FinanceExportProviderService.CUSTOMER_ACCOUNT_LEDGER,
|
||||
request(Map.of("customerId", "10001", "businessType", businessType), null, null));
|
||||
verify(operationService).listDerivedCustomerAccountLedger(10001L, 1, 100, businessType, null, null);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void exportsTheSameMonthlyCnyCustomerStatisticsWithoutExcludedPrototypeFields() {
|
||||
when(receivableStatisticsService.exportRows(YearMonth.of(2026, 7), 10001L)).thenReturn(List.of(
|
||||
|
||||
+12
-6
@@ -2,6 +2,7 @@
|
||||
|
||||
## 职责与边界
|
||||
|
||||
- Mapper 统一显式使用 `@Mapper`,仅由 `BaiyueFirstlegApplication.java` 对 `com.baiyue.firstleg` 根包按 `annotationClass = Mapper.class` 扫描;禁止恢复业务子域 Mapper 包白名单。静态门禁见根 `scripts/mapper-scan-contract.mjs`,模块内编译门禁见 `MapperRegistrationArchitectureTest.java`、`BaiyueFirstlegApplicationContractTest.java`。
|
||||
- 负责履约任务/分配、入库单、测量/OCR、审核后初始计价、FBA 计费、客户在途柜量统计、国内出仓履约链路,以及四期国内出仓后的船节点查询任务和节点事实。
|
||||
- 订单主数据归 `order/`,托盘/库位归 `warehouse/`,文件对象归 `file/`,OCR 供应商归 `integration-service/`;通过客户端 DTO 和内部令牌协作。
|
||||
- 入库、分柜、装柜、PDA 和出仓能力均在本模块按对应服务端契约协作;`WAIT_SPLIT + APPROVED + CONFIRMED` 是入库链路状态节点,不是全量验收的范围终点。财务状态仍归 `finance`,本模块只生产不可变费用来源和柜次成本快照。
|
||||
@@ -14,7 +15,8 @@
|
||||
| 履约任务/分配 | `controller/PlatformFulfillmentAssignmentController.java`、`service/FulfillmentTaskService.java` |
|
||||
| 入库单查询/聚合 | `controller/PlatformInboundOrderController.java`、`service/InboundOrderQueryService.java` |
|
||||
| 平台测量/OCR | `controller/PlatformInboundMeasurementController.java`、`service/InboundMeasurementService.java`、`service/CartonMeasurementService.java`;手工单箱测量也从该 Controller 与 `CartonMeasurementService` 进入 |
|
||||
| 箱唛箱码生成、打印与测量绑定 | 审核建单生成钩子 `controller/InternalInboundOrderController.java`;生成/解析 `service/InboundCartonLabelService.java`、`repository/InboundCartonLabelRepository.java`;平台与客户打印 `controller/PlatformInboundOrderController.java`、`MerchantInboundCartonLabelController.java`、`service/InboundCartonLabelPrintService.java`。`fl_inbound_carton_label.sku_code` 是箱唛的 SKU 冻结快照,生成时经 `client/OrderCargoClient.java` 获取;既有记录按 `inbound_detail_id` 回填。`ONE_CLICK_DROPSHIP` 缺 SKU 时禁止生成箱唛。`box_code`、二维码内容和现有打印版式不携带 SKU,不能因该绑定改变。基线/升级 SQL 为 `src/main/resources/db/phase2-inbound-carton-label.sql`、`phase2-carton-label-versioning-migration.sql`、`../deploy/sql/头程数据库统一增量-MySQL8.sql` |
|
||||
| 箱唛箱码生成、打印与测量绑定 | 审核建单生成钩子 `controller/InternalInboundOrderController.java`;生成/解析 `service/InboundCartonLabelService.java`、`repository/InboundCartonLabelRepository.java`;平台与客户打印 `controller/PlatformInboundOrderController.java`、`MerchantInboundCartonLabelController.java`、`service/InboundCartonLabelPrintService.java`。`fl_inbound_carton_label.sku_code` 是箱唛的 SKU 冻结快照,生成时经 `client/OrderCargoClient.java` 获取;既有记录按 `inbound_detail_id` 回填。`ONE_CLICK_DROPSHIP` 缺 SKU 时禁止生成箱唛,打印面显示 SKU;`box_code` 与二维码内容仍只携带稳定箱码。基线/升级 SQL 为 `src/main/resources/db/phase2-inbound-carton-label.sql`、`phase2-carton-label-versioning-migration.sql`、`../deploy/sql/头程数据库统一增量-MySQL8.sql` |
|
||||
| 一件代发测量后 PDA 合托 | `controller/PlatformDropshipPalletizationController.java`、`service/DropshipPalletizationService.java`、`mapper/DropshipPalletizationMapper.java`;测量通过进入 `WAIT_PALLETIZE` 并下发独立任务,不创建国内库存,封托生成 `DP` 托盘号,全部箱数完成后进入 `WAIT_SPLIT`。契约 `src/main/resources/openapi/dropship-palletization-v1.yaml` |
|
||||
| 墨家设备测量上传 | `controller/OpenApiInboundMeasurementController.java`、`dto/MojiaMeasurementDtos.java`、`service/MojiaMeasurementOpenApiService.java`、`security/MeasurementRequestAuthenticator.java`;接口契约见 `src/main/resources/openapi/firstleg-v1.yaml` 与 `../../docs/墨家设备测量上传接口说明-V3.md` |
|
||||
| 审核与初始计价 | `controller/PlatformInboundMeasurementReviewController.java`、`service/MeasurementReviewService.java`、`service/InitialInboundPricingService.java`、`dto/MeasurementApprovalResponse.java` |
|
||||
| FBA 主数据/计费 | `controller/PlatformFbaMasterDataController.java`、`PlatformFbaPricingController.java`、`service/FbaPricingService.java` |
|
||||
@@ -27,19 +29,21 @@
|
||||
| 财务费用来源快照 | `controller/InternalFinanceFeeSourceController.java`;入库费用与柜次费用分别按来源 ID、版本向 Finance 提供不可变快照 |
|
||||
| 柜次费用明细 | `controller/PlatformContainerFeeDetailController.java`、`service/ContainerFeeDetailService.java`、`dto/ContainerFeeDetailDtos.java`;Admin 消费端见 `../../apps/admin-web/src/modules/loading-lists/api/containerFees.ts` |
|
||||
| 财务整柜成本分摊快照 | `loading/controller/InternalFinanceCostSnapshotController.java`、`loading/service/FinanceCostSnapshotService.java` |
|
||||
| 分柜与预装 | `split/controller/PlatformSplitPreloadController.java`、`controller/PlatformLoadingListController.java` |
|
||||
| 分柜、预装与正式装柜清单生命周期 | `split/controller/PlatformSplitPreloadController.java`、`controller/PlatformLoadingListController.java`、`service/LoadingListService.java`;契约 `src/main/resources/openapi/loading-lists-v1.yaml`。同一 `planId` 在 `EDITING/LOADING` 返回 `PRELOAD`,`COMPLETED` 返回 `LOADING_LIST` 与独立读取权限;调用方先查 `/loading-plans/{planId}/lifecycle`,不得用 A0404 或错误文案猜阶段 |
|
||||
| 同柜不可拆组 | `loading/coload/controller/PlatformColoadGroupController.java`、`service/ColoadGroupService.java`、`persistence/ColoadGroupMapper.java`;契约 `src/main/resources/openapi/coload-groups-v1.yaml`。请求按订单 ID 建组,服务端冻结每票全部 `WAIT_SPLIT` 可用入仓片段与满箱数;仅要求同国内仓、同渠道,允许跨客户及不同目的仓港;分配、移除、取消均保持整组原子 |
|
||||
| 货柜与智能排柜 | `controller/PlatformContainerController.java`、`PlatformSmartLoadingController.java`、`service/SmartLoadingService.java`、`repository/SmartLoadingRepository.java`、`loading/`;按入仓码候选与箱数分配分别从 `/inbound-candidates`、`/plans/{planId}/inbound-assignments` 进入,明细规格拆分留在服务端 |
|
||||
| 货柜资料、PDA 证据与业务总览 | `controller/PlatformContainerController.java` 的 `/{id}/overview`、`/{id}/documents/{documentType}`,聚合 `mapper/ContainerOverviewMapper.java`;契约 `src/main/resources/openapi/phase3-container-overview-v1.yaml`,四类资料固定为目的港清关、国内出口报关、DO、PU 的当前唯一版本 |
|
||||
| PDA 拣货与装柜 | `controller/PlatformLoadingPdaController.java`、`loading/controller/PlatformLoadingDeliveryController.java` |
|
||||
| 装柜完成跨区事件 | `service/LoadingCompletedOutboxService.java`、`LoadingCompletedOutboxRelay.java`、`repository/LoadingCompletedOutboxRepository.java`、`mapper/LoadingCartonManifestMapper.java`、`client/EventBridgeClient.java`;从 `fl_inbound_order.destination_type` 冻结 V2 `orders[].destinationType=FBA|OVERSEAS`,OVERSEAS 按最终实装与稳定箱码冻结,FBA 可仅冻结箱数;真实生产验收 `src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java`;事件模型见 `../common-event-contract/AGENTS.md` |
|
||||
| 海外卸柜完成事件回写 | 内部入口 `controller/InternalOverseasContainerEventController.java`、消费者 `service/OverseasContainerEventConsumer.java`、Inbox `entity/ContainerEventInboxEntity.java` 与 `mapper/ContainerEventInboxMapper.java`;事件模型见 `../common-event-contract/AGENTS.md` |
|
||||
| 海外暂扣状态回写 | 同一内部事件入口、消费者 `service/OverseasInboundOrderDetainedConsumer.java`;按来源入仓单 ID 更新 `fl_inbound_order.hold_flag/current_hold_reason`,复用跨区 Inbox 并写 `fl_inbound_hold_record` |
|
||||
| 三期海运订舱、关联候选与出仓前船期资料 | `oceanbooking/controller/PlatformOceanBookingController.java`、`oceanbooking/service/OceanBookingService.java`、`oceanbooking/mapper/OceanBookingMapper.java`、`portschedule/controller/PlatformPortScheduleController.java`;关联候选入口 `/api/platform/v1/ocean-bookings/link-candidates`,订舱契约见 `src/main/resources/openapi/ocean-bookings-v1.yaml`,表结构见 `src/main/resources/db/phase3-ocean-booking-schema.sql` |
|
||||
| 三期海运订舱、资料/打印、关联候选与出仓前船期资料 | `oceanbooking/controller/PlatformOceanBookingController.java`、`oceanbooking/service/OceanBookingService.java`、`OceanBookingDocumentService.java`、`OceanBookingPrintService.java`、`oceanbooking/mapper/OceanBookingMapper.java`、`portschedule/controller/PlatformPortScheduleController.java`;文件经 `client/OceanBookingFileClient.java` 进入 File,关联候选 `/api/platform/v1/ocean-bookings/link-candidates`,契约 `src/main/resources/openapi/ocean-bookings-v1.yaml`,表结构见 `src/main/resources/db/phase3-ocean-booking-schema.sql` |
|
||||
| 四期国内出仓后船节点定时查询 | `outbound/dispatch/service/OutboundDispatchService.java` 在确认出仓事务内幂等创建现有 `CarrierScheduleQuery`;编排与租约 `portschedule/service/CarrierScheduleQueryScheduler.java`,任务/十节点事实/异常事实/不可变 attempt `CarrierScheduleQueryService.java`,平台查询与人工补录入口 `PlatformCarrierScheduleQueryController.java` 的 `/{queryId}/manual-nodes`;停止只由实际 `EMPTY_RETURNED` 节点触发,不提供无节点停止入口;外部船司/轨迹调用经 `integration-service/`;标准见 `../../docs/四期/船节点标准化契约.md` |
|
||||
| 船节点跨区交接事件 | `portschedule/service/CarrierScheduleNodeOutboxService.java`;实际节点落库后发布 `FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED` V1,复用 `LoadingCompletedOutboxRepository`/Relay 投 Event Bridge;海外消费见 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java` |
|
||||
| Firstleg 业务单据编号 | `service/FirstlegDocumentNumberGenerator.java`;当前支持 `OB`、`TA`、`CB`、`CAB`、`LP` 前缀,按北京时间每日独立编号;具体业务归属以各调用服务为准 |
|
||||
| 拖车与整柜出仓调度 | `controller/PlatformTruckAppointmentController.java`、`outbound/dispatch/controller/PlatformOutboundDispatchController.java`;按真实 `containerBookingId` 查询聚合和 `gate`、确认离仓,契约 `src/main/resources/openapi/phase3-outbound-dispatch-v1.yaml` |
|
||||
| 报关协同 | `customs/controller/PlatformCustomsController.java`、`MerchantCustomsController.java` |
|
||||
| 订舱拥有的拖车与整柜出仓调度 | `controller/PlatformTruckAppointmentController.java`、`service/TruckAppointmentService.java`、`repository/TruckAppointmentRepository.java`、`outbound/dispatch/controller/PlatformOutboundDispatchController.java`;拖车契约 `src/main/resources/openapi/truck-appointments-v1.yaml`,出仓契约 `phase3-outbound-dispatch-v1.yaml`。预约无客户字段,必须绑定 `RELEASED` 订舱及其已分配真实 ISO 柜,同柜最多一条有效预约;提单号和仓库取订舱/柜次权威事实,附件固定平台作用域 |
|
||||
| 整柜出口报关 | `customs/container/controller/PlatformContainerCustomsController.java`、`customs/container/controller/MerchantContainerCustomsController.java`、`customs/container/service/ContainerCustomsService.java`、`customs/container/mapper/ContainerCustomsMapper.java`;装柜完成后按柜收集退税资料,多对多覆盖入仓码,开始报关冻结,完成文件逐资料一对一回传,全买单柜由平台确认并通过导出中心取明细;契约 `src/main/resources/openapi/customs-v1.yaml`,基线 `src/main/resources/db/phase3-container-customs-schema.sql`;消费者为 Admin `../../apps/admin-web/src/modules/container-loading/components/ContainerCustomsPanel.vue` 和 Client `../../apps/client-web/src/modules/first-leg/components/CustomsDocumentsDialog.vue` |
|
||||
| 买单订单明细导出 Provider | `customs/container/controller/LoadingListBuyDeclarationExportController.java`、`service/LoadingListBuyDeclarationExportService.java`;只为 File 导出中心提供 `LOADING_LIST_BUY_DECLARATION_ITEMS` 的已完成装柜实际买单行,不在 Firstleg 生成文件 |
|
||||
| 跨模型桥接 | `loading/bridge/controller/PlatformLoadingModelBridgeController.java` |
|
||||
| 跨服务客户端 | `client/OrderCargoClient.java`、`WarehousePalletLocationClient.java`、`MeasurementImageClient.java`、`LoadingEvidenceFileClient.java`、`EventBridgeClient.java` |
|
||||
| 仓库公共供应商快照 | `client/WarehouseSupplierClient.java`、`service/WarehouseSupplierSnapshotService.java`;报关、订舱和拖车分别消费 `customs-v1.yaml`、`ocean-bookings-v1.yaml`、`truck-appointments-v1.yaml` 中的供应商 ID,并由 Warehouse 返回服务端名称快照 |
|
||||
@@ -60,8 +64,10 @@
|
||||
- 订舱柜型需求至少一项、最多 20 项,数量必须为正整数,容量和限重必须匹配平台柜型规格。起运仓必须是启用的国内中转仓;物流渠道必须来自该仓当前 ACTIVE 价格版本,服务端保存渠道名称快照,渠道失效时失败关闭。
|
||||
- 订舱号统一使用 `OB-yyMMdd-####`,按北京时间创建日每日独立递增且单日最多 9999;不得以实体 ID 拼接生成。放舱不要求真实柜号,也不得为满足放舱校验伪造柜号。
|
||||
- 订舱列表 `containerCount` 固定为该订舱全部柜型需求 `quantity` 之和,与真实柜关联/解除无关;关联候选只返回 `RELEASED`、剩余数量大于 0 且 `warehouseId + channelCode + containerType` 精确匹配的需求,由服务端集合查询 ACTIVE 分配量。前端不得扇出详情计算或用已关联柜数覆盖该字段,最终关联命令仍须重验目标货柜可修改性。
|
||||
- 拖车预约是平台级订舱履约资源,不是客户资源:请求、实体、查询和附件不得恢复 `customerId/customerName`。创建或编辑必须选择 `RELEASED` 订舱及其已分配、有效且带真实 ISO 6346 柜号的柜次,订舱还须有有效提单号和至少一份 ACTIVE `BOOKING_CONFIRMATION` 或 `BILL_OF_LADING`;同一真实柜通过查询锁与唯一约束最多保留一条非取消预约。拖车附件使用 `tenantId=0 + TRUCK_APPOINTMENT_ATTACHMENT + appointmentId`,提单文件继续归订舱,不复制到拖车附件。
|
||||
- 四期船节点任务从确认柜子离开国内仓库后启用,必须绑定真实订舱和柜次;十个标准节点、预计/实际时间、异常事实、供应商缺失节点和还柜停止规则统一以 `../../docs/四期/船节点标准化契约.md` 为准。`CarrierScheduleQuery` 的正常业务查询槽位固定为 `Asia/Shanghai` 每日 09:00、12:30、16:00;每分钟任务只扫描已到期 `next_allowed_at`,不是每分钟请求船司。成功、无变化、需人工或永久失败进入下一固定槽位;仅技术性 `RETRYABLE_FAILURE` 在当前槽内按15/30分钟退避,含首次最多3次且不得跨下一槽,实际 `EMPTY_RETURNED` 后停止。错过的正常槽位直接顺延,不在服务恢复时补跑;单轮按受控批次持续领取并受最大任务数保护。租约、调度尝试和内部停止入口不得暴露为平台页面 API,外部失败不得推进业务节点,不得在三期另建查询任务。
|
||||
- 国内出仓确认以真实 `containerBookingId` 聚合全部入仓绑定并锁定校验;装柜证据按物理货柜只计算一次,国内出口报关资料必须存在且已完成,拖车状态和独立船期只作参考。全部门禁在同一事务通过后推进所有技术行、记录一条可见历史并幂等创建 `CarrierScheduleQuery/DEPARTED_WAREHOUSE`,不允许单入仓局部成功。
|
||||
- 国内出口报关在装柜完成后启用,不再阻塞 `LOADED`。整柜 `fl_customs_container.status=COMPLETED` 才通过离仓报关门禁;完成后以系统身份、确定性幂等键立即尝试确认国内离仓,其他门禁未齐时由受控定时补偿重试,禁止部分入仓成功或重复创建离仓/船司查询事实。
|
||||
- 国内出仓确认以真实 `containerBookingId` 聚合全部入仓绑定并锁定校验;装柜证据按物理货柜只计算一次,PDA `taskId` 仅用于来源审计和任务授权,File 所有权仍固定 `tenantId=0 + containerBookingId`;拖车状态和独立船期只作参考。全部门禁在同一事务通过后推进所有技术行、记录一条可见历史并幂等创建 `CarrierScheduleQuery/DEPARTED_WAREHOUSE`,不允许单入仓局部成功。
|
||||
- 手工船节点的预计时间只追加事实,不推进 `latestStandardNode`;携带 `actualAt` 时标准节点序号必须严格大于最新实际节点,重复、相同或回退节点返回冲突。已审核实际时间修正只能更新同一事实的时间/证据,不得制造第二条同节点实际事实或绕过单调推进。
|
||||
- 海外卸柜完成事件只接受 `OVERSEAS -> CN` 的 `OVERSEAS_CONTAINER_UNLOADED` V1,并通过内部服务令牌进入。消费者必须校验信封、Payload 哈希、来源货柜和时间顺序,以 event ID + Payload 哈希幂等,使用事件聚合版本阻止乱序覆盖;预计报空时间固定按卸柜照片上传时间加两天计算,实际报空时间可为空。
|
||||
- Firstleg 只拥有业务费用及履约事实;Finance 拥有费用入池、账期、账单、收款、成本分摊结果和财务状态。修改费用来源版本、发布条件、柜次归属或体积快照时同步检查 `../finance/src/main/java/com/baiyue/finance/client/FirstlegFeeSourceClient.java`、同目录 `FirstlegCostSnapshotClient.java` 及双方契约测试,不在 Firstleg 复制财务状态机。
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.baiyue.firstleg;
|
||||
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.mybatis.spring.annotation.MapperScan;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
@@ -9,18 +10,7 @@ import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
/**
|
||||
* 头程履约微服务启动入口,负责装配履约任务组件并扫描 MyBatis-Plus Mapper。
|
||||
*/
|
||||
@MapperScan(basePackages = {
|
||||
"com.baiyue.firstleg.mapper",
|
||||
"com.baiyue.firstleg.split.mapper",
|
||||
"com.baiyue.firstleg.loading.mapper",
|
||||
"com.baiyue.firstleg.loading.bridge.mapper",
|
||||
"com.baiyue.firstleg.oceanbooking.mapper",
|
||||
"com.baiyue.firstleg.outbound.dispatch.mapper",
|
||||
"com.baiyue.firstleg.portschedule.mapper",
|
||||
"com.baiyue.firstleg.customs.mapper",
|
||||
"com.baiyue.firstleg.customerstatistics.adapter.out.persistence",
|
||||
"com.baiyue.firstleg.pricebookquery.adapter.out.persistence"
|
||||
})
|
||||
@MapperScan(basePackages = "com.baiyue.firstleg", annotationClass = Mapper.class)
|
||||
@EnableFeignClients(basePackages = "com.baiyue.firstleg")
|
||||
@EnableScheduling
|
||||
@SpringBootApplication(scanBasePackages = "com.baiyue")
|
||||
|
||||
@@ -6,20 +6,35 @@ import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RequestPart;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
/** 仅用于确认证据文件属于当前客户和入库单,不在 firstleg 复制文件元数据。 */
|
||||
/** Validate that a loading evidence file is owned by the physical cabinet. */
|
||||
@FeignClient(name = "file-service", contextId = "firstlegLoadingEvidenceFileClient",
|
||||
url = "${baiyue.clients.file.url:}", path = "/internal/file/v1/loading-evidence")
|
||||
public interface LoadingEvidenceFileClient {
|
||||
@GetMapping("/{tenantId}/{inboundId}/{fileId}")
|
||||
@PostMapping(value = "/{containerBookingId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
ApiResponse<UploadResult> upload(@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@RequestHeader("X-Operator-Id") long operatorId, @PathVariable long containerBookingId,
|
||||
@RequestParam String evidenceType, @RequestPart("file") MultipartFile file);
|
||||
|
||||
@GetMapping("/{containerBookingId}/{fileId}")
|
||||
ApiResponse<FileMetadata> validate(@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@PathVariable long tenantId, @PathVariable long inboundId,
|
||||
@PathVariable long containerBookingId,
|
||||
@RequestParam String evidenceType,
|
||||
@PathVariable long fileId);
|
||||
|
||||
@GetMapping("/{tenantId}/{inboundId}/{fileId}/content")
|
||||
@GetMapping("/{containerBookingId}/{fileId}/content")
|
||||
ResponseEntity<byte[]> download(@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@PathVariable long tenantId, @PathVariable long inboundId,
|
||||
@PathVariable long containerBookingId,
|
||||
@RequestParam String evidenceType,
|
||||
@PathVariable long fileId);
|
||||
|
||||
record FileMetadata(Long fileId, String fileName, String businessType, String contentType) {}
|
||||
record FileMetadata(Long fileId, String ownerScope, Long tenantId, String businessType,
|
||||
String businessId, String fileName, String contentType) {}
|
||||
record UploadResult(Long fileId, String ownerScope, Long tenantId, String businessType,
|
||||
String businessId, String fileName, String contentType, Long sizeBytes) {}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
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.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RequestPart;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
|
||||
@FeignClient(name = "file-service", contextId = "oceanBookingFileClient",
|
||||
url = "${baiyue.clients.file.url:}", path = "/internal/file/v1/business-files")
|
||||
public interface OceanBookingFileClient {
|
||||
@PostMapping(value = "/{tenantId}/{businessType}/{businessId}", consumes = "multipart/form-data")
|
||||
ApiResponse<Metadata> upload(@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@RequestHeader("X-Operator-Id") long operatorId,
|
||||
@PathVariable long tenantId,
|
||||
@PathVariable String businessType,
|
||||
@PathVariable String businessId,
|
||||
@RequestPart("file") MultipartFile file);
|
||||
|
||||
@GetMapping("/{tenantId}/{businessType}/{businessId}/{fileId}")
|
||||
ApiResponse<Metadata> metadata(@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@PathVariable long tenantId,
|
||||
@PathVariable String businessType,
|
||||
@PathVariable String businessId,
|
||||
@PathVariable long fileId);
|
||||
|
||||
@GetMapping("/{tenantId}/{businessType}/{businessId}/{fileId}/content")
|
||||
ResponseEntity<byte[]> download(@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@PathVariable long tenantId,
|
||||
@PathVariable String businessType,
|
||||
@PathVariable String businessId,
|
||||
@PathVariable long fileId);
|
||||
|
||||
record Metadata(Long fileId, Long tenantId, String businessType, String businessId,
|
||||
String fileName, String sha256, String contentType, Long sizeBytes) {}
|
||||
}
|
||||
@@ -8,8 +8,9 @@ import org.springframework.web.bind.annotation.RequestHeader;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** Print Service contract for stateless inbound carton label rendering. */
|
||||
/** Print Service contract for stateless Firstleg PDF rendering. */
|
||||
@FeignClient(name = "print-service", contextId = "firstlegPrintPdfClient",
|
||||
url = "${baiyue.clients.print.url:}", path = "/internal/print/v1/pdfs")
|
||||
public interface PrintPdfClient {
|
||||
@@ -19,6 +20,12 @@ public interface PrintPdfClient {
|
||||
@RequestHeader(value = "X-Request-Id", required = false) String requestId,
|
||||
@RequestBody InboundMarkPdfRequest request);
|
||||
|
||||
@PostMapping(value = "/freight-a4-documents", consumes = "application/json")
|
||||
ResponseEntity<byte[]> generateFreightA4Documents(
|
||||
@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@RequestHeader(value = "X-Request-Id", required = false) String requestId,
|
||||
@RequestBody FreightA4PdfRequest request);
|
||||
|
||||
record InboundMarkPdfRequest(String templateCode, PageSpec pageSpec,
|
||||
String downloadFileName, List<Label> labels) {
|
||||
}
|
||||
@@ -27,6 +34,14 @@ public interface PrintPdfClient {
|
||||
}
|
||||
|
||||
record Label(String topCode, String warehouseText, String cartonText,
|
||||
String routeText, String qrContent) {
|
||||
String routeText, String qrContent, String skuCode) {
|
||||
}
|
||||
|
||||
record FreightA4PdfRequest(String templateCode, PageSpec pageSpec,
|
||||
String downloadFileName, List<FreightDocument> documents) {}
|
||||
|
||||
record FreightDocument(Map<String, String> fields, List<CargoRow> cargoRows) {}
|
||||
|
||||
record CargoRow(String marksAndNos, String quantity, String packaging,
|
||||
String descriptionOfGoods, String grossWeight, String measurement) {}
|
||||
}
|
||||
|
||||
+11
-1
@@ -1,6 +1,7 @@
|
||||
package com.baiyue.firstleg.client;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import org.springframework.cloud.openfeign.FeignClient;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
@@ -16,8 +17,17 @@ import java.util.List;
|
||||
url = "${baiyue.clients.warehouse.url:}", path = "/internal/warehouse/v1")
|
||||
public interface WarehousePalletLocationClient {
|
||||
@GetMapping("/pallets/snapshots")
|
||||
ApiResponse<List<PalletSnapshot>> palletSnapshots(
|
||||
ApiResponse<PageResult<PalletSnapshot>> palletSnapshots(
|
||||
@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@RequestParam("warehouseId") long warehouseId,
|
||||
@RequestParam("page") long page,
|
||||
@RequestParam("size") long size,
|
||||
@RequestParam(value = "keyword", required = false) String keyword);
|
||||
|
||||
@GetMapping("/pallets/{palletId}/snapshot")
|
||||
ApiResponse<PalletSnapshot> palletSnapshot(
|
||||
@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@org.springframework.web.bind.annotation.PathVariable("palletId") long palletId,
|
||||
@RequestParam("warehouseId") long warehouseId);
|
||||
|
||||
@PostMapping("/pallet-locations/reservations")
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
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.util.List;
|
||||
|
||||
@FeignClient(name = "warehouse-service", contextId = "firstlegWarehousePartnerClient",
|
||||
url = "${baiyue.clients.warehouse.url:}", path = "/internal/warehouse-partners")
|
||||
public interface WarehousePartnerClient {
|
||||
@GetMapping("/{tenantId}/{partnerId}/active")
|
||||
ApiResponse<Snapshot> active(@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@PathVariable long tenantId, @PathVariable long partnerId);
|
||||
|
||||
record Snapshot(long id, long tenantId, String partnerName, String partnerType,
|
||||
String carrierCode, List<String> types, String status) {}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import java.util.List;
|
||||
|
||||
@FeignClient(name = "warehouse-service", contextId = "firstlegWarehouseSupplierClient",
|
||||
url = "${baiyue.clients.warehouse.url:}", path = "/internal/warehouse/v1/suppliers")
|
||||
@@ -16,5 +17,11 @@ public interface WarehouseSupplierClient {
|
||||
@RequestParam(defaultValue = "true") boolean requireActive,
|
||||
@RequestParam(required = false) String category);
|
||||
|
||||
record Snapshot(long supplierId, String name, String category, String status) {}
|
||||
@GetMapping
|
||||
ApiResponse<List<Snapshot>> candidates(@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@RequestParam String category,
|
||||
@RequestParam String carrierCode);
|
||||
|
||||
record Snapshot(long supplierId, String name, String category, String status,
|
||||
List<String> carrierCodes) {}
|
||||
}
|
||||
|
||||
+3
-1
@@ -29,7 +29,9 @@ public class MerchantInboundBillingController {
|
||||
@GetMapping("/{orderId}/inbound-billing")
|
||||
@PreAuthorize("hasAuthority('merchant:order:view')")
|
||||
@Operation(summary = "查询订单入库结果和最新费用",
|
||||
description = "客户只读查看已达到 WAIT_SPLIT + APPROVED + CONFIRMED 的二期费用,不需要确认费用。")
|
||||
description = "客户按登录态客户范围只读查看;入库流程达到 WAIT_SPLIT 后,"
|
||||
+ "在 LOADING、LOADED、DELIVERED 仍持续可见,且始终要求 APPROVED + SENT "
|
||||
+ "并存在已发布入库运费明细,不需要客户确认费用。")
|
||||
public ApiResponse<MerchantInboundBillingResponse> find(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long orderId) {
|
||||
return ApiResponse.success(service.find(FirstlegScopeGuard.merchant(user), orderId));
|
||||
|
||||
+1
-17
@@ -6,10 +6,8 @@ import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.firstleg.dto.ContainerDtos.FullUpdateRequest;
|
||||
import com.baiyue.firstleg.dto.ContainerDtos.Query;
|
||||
import com.baiyue.firstleg.dto.ContainerDtos.TransportUpdateRequest;
|
||||
import com.baiyue.firstleg.dto.ContainerDtos.SimulationCreateRequest;
|
||||
import com.baiyue.firstleg.dto.ContainerDtos.OceanBookingLinkRequest;
|
||||
import com.baiyue.firstleg.dto.ContainerDtos.OceanBookingUnlinkRequest;
|
||||
import com.baiyue.firstleg.entity.ContainerBookingEntity;
|
||||
import com.baiyue.firstleg.service.ContainerService;
|
||||
import com.baiyue.firstleg.service.FirstlegScopeGuard;
|
||||
@@ -96,29 +94,15 @@ public class PlatformContainerController {
|
||||
@PostMapping("/{id}/ocean-booking-link") @PreAuthorize("hasAuthority('platform:firstleg:container:edit')")
|
||||
@BusinessAudit(event="CONTAINER_OCEAN_BOOKING_LINK", resourceType="CONTAINER", resourceId="#id", description="关联真实订舱柜")
|
||||
public ApiResponse<ContainerBookingEntity> linkOceanBooking(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id, @Valid @RequestBody OceanBookingLinkRequest request) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.linkOceanBooking(id, user.userId(), user.username(), request)); }
|
||||
@DeleteMapping("/{id}/ocean-booking-link") @PreAuthorize("hasAuthority('platform:firstleg:container:edit')")
|
||||
@BusinessAudit(event="CONTAINER_OCEAN_BOOKING_UNLINK", resourceType="CONTAINER", resourceId="#id", description="解除真实订舱柜关联")
|
||||
public ApiResponse<ContainerBookingEntity> unlinkOceanBooking(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id, @Valid @RequestBody OceanBookingUnlinkRequest request) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.unlinkOceanBooking(id, user.userId(), user.username(), request)); }
|
||||
@PutMapping("/{id}") @PreAuthorize("hasAuthority('platform:firstleg:container:edit')")
|
||||
@BusinessAudit(event="CONTAINER_UPDATE", resourceType="CONTAINER", resourceId="#id", description="完整编辑货柜基础资料")
|
||||
@Operation(summary="完整编辑货柜", description="仅待装柜或预装柜中时允许")
|
||||
@Operation(summary="编辑货柜基础资料", description="仅待装柜或预装草稿时允许;不得写入真实集装箱号或内部柜次号")
|
||||
public ApiResponse<ContainerBookingEntity> updateDraft(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long id,
|
||||
@Valid @RequestBody FullUpdateRequest request) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return ApiResponse.success(service.updateDraft(id, user.userId(), user.username(), request));
|
||||
}
|
||||
@PatchMapping("/{id}/transport") @PreAuthorize("hasAuthority('platform:firstleg:container:edit')")
|
||||
@BusinessAudit(event="CONTAINER_TRANSPORT_UPDATE", resourceType="CONTAINER", resourceId="#id",
|
||||
description="修改货柜集装箱号和航次")
|
||||
@Operation(summary="修改集装箱号和航次", description="PDA拣货中仅开放此白名单;装柜完成后全部只读")
|
||||
public ApiResponse<ContainerBookingEntity> updateTransport(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long id,
|
||||
@Valid @RequestBody TransportUpdateRequest request) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return ApiResponse.success(service.updateTransport(id, user.userId(), user.username(), request));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/clearance")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:customs:manage')")
|
||||
@Operation(summary = "查询货柜目的港清关资料")
|
||||
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
package com.baiyue.firstleg.controller;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.firstleg.dto.DropshipPalletizationDtos;
|
||||
import com.baiyue.firstleg.dto.DropshipPalletizationDtos.Pallet;
|
||||
import com.baiyue.firstleg.dto.DropshipPalletizationDtos.Task;
|
||||
import com.baiyue.firstleg.service.DropshipPalletizationService;
|
||||
import com.baiyue.firstleg.service.FirstlegScopeGuard;
|
||||
import com.baiyue.firstleg.service.InboundCartonLabelPrintService;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
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 org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** 国内 PDA 一件代发合托;与装柜 PDA 分离。 */
|
||||
@Validated
|
||||
@RestController
|
||||
@RequestMapping("/api/platform/v1/first-leg/pda-dropship")
|
||||
public class PlatformDropshipPalletizationController {
|
||||
@Resource private DropshipPalletizationService service;
|
||||
@Resource private InboundCartonLabelPrintService printService;
|
||||
|
||||
@GetMapping("/tasks/pending")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:pda:view')")
|
||||
public ApiResponse<List<DropshipPalletizationDtos.PendingTask>> pending(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam(required = false) @Size(max = 64) String keyword) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return ApiResponse.success(service.pending(user.userId(), keyword));
|
||||
}
|
||||
|
||||
@GetMapping("/tasks/{taskId}")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:pda:view')")
|
||||
public ApiResponse<Task> task(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long taskId,
|
||||
@RequestParam @Positive long warehouseId) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return ApiResponse.success(service.task(taskId, warehouseId, user.userId()));
|
||||
}
|
||||
|
||||
@PostMapping("/tasks/{taskId}/claim")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:pda:work')")
|
||||
public ApiResponse<Task> claim(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long taskId,
|
||||
@Valid @RequestBody DropshipPalletizationDtos.ClaimRequest request) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return ApiResponse.success(service.claim(taskId, request.warehouseId(), user.userId(), user.username()));
|
||||
}
|
||||
|
||||
@PostMapping("/tasks/{taskId}/pallets/open")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:pda:work')")
|
||||
public ApiResponse<Pallet> open(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long taskId,
|
||||
@Valid @RequestBody DropshipPalletizationDtos.OpenPalletRequest request) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return ApiResponse.success(service.open(taskId, request, user.userId(), user.username()));
|
||||
}
|
||||
|
||||
@PostMapping("/tasks/{taskId}/pallets/{palletId}/scan")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:pda:work')")
|
||||
public ApiResponse<DropshipPalletizationDtos.ScanResult> scan(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long taskId, @PathVariable @Positive long palletId,
|
||||
@Valid @RequestBody DropshipPalletizationDtos.ScanRequest request) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return ApiResponse.success(service.scan(taskId, palletId, request, user.userId(), user.username()));
|
||||
}
|
||||
|
||||
@PostMapping("/tasks/{taskId}/pallets/{palletId}/seal")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:pda:work')")
|
||||
public ApiResponse<Task> seal(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long taskId,
|
||||
@PathVariable @Positive long palletId,
|
||||
@Valid @RequestBody DropshipPalletizationDtos.SealPalletRequest request) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return ApiResponse.success(service.seal(taskId, palletId, request, user.userId(), user.username()));
|
||||
}
|
||||
|
||||
@GetMapping("/inbound-orders/{inboundId}/pallets")
|
||||
@PreAuthorize("hasAuthority('firstleg:inbound:list')")
|
||||
public ApiResponse<List<Pallet>> inboundPallets(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long inboundId,
|
||||
@RequestParam @Positive long accountId) {
|
||||
FirstlegScopeGuard.platform(user, accountId);
|
||||
return ApiResponse.success(service.inboundPallets(accountId, inboundId));
|
||||
}
|
||||
|
||||
@GetMapping("/inbound-orders/{inboundId}/pallets/{palletId}/print")
|
||||
@PreAuthorize("hasAuthority('firstleg:inbound:list')")
|
||||
public ResponseEntity<byte[]> printPallet(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long inboundId,
|
||||
@PathVariable @Positive long palletId,
|
||||
@RequestParam @Positive long accountId,
|
||||
@RequestHeader(value = "X-Request-Id", required = false) String requestId) {
|
||||
FirstlegScopeGuard.platform(user, accountId);
|
||||
Pallet pallet = service.inboundPallets(accountId, inboundId).stream()
|
||||
.filter(item -> item.palletId() == palletId).findFirst()
|
||||
.orElseThrow(() -> new com.baiyue.common.core.exception.BizException(
|
||||
com.baiyue.common.core.api.ResultCode.NOT_FOUND, "托盘不存在"));
|
||||
return printService.generateDropshipPallet(pallet, requestId);
|
||||
}
|
||||
}
|
||||
+13
-5
@@ -4,6 +4,7 @@ import jakarta.annotation.Resource;
|
||||
|
||||
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;
|
||||
@@ -18,8 +19,12 @@ 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 jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
@@ -33,10 +38,10 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** 平台后台入库测量录入、提交和 OCR 重量结果维护接口。 */
|
||||
@Tag(name = "平台入库测量", description = "维护测量草稿、箱规明细和可追溯 OCR 重量结果。")
|
||||
@Validated
|
||||
@RestController
|
||||
@RequestMapping("/api/platform/v1/first-leg/inbounds/{inboundId}")
|
||||
public class PlatformInboundMeasurementController {
|
||||
@@ -56,16 +61,19 @@ public class PlatformInboundMeasurementController {
|
||||
}
|
||||
|
||||
@Operation(summary = "查询称重可选托盘快照",
|
||||
description = "按入库单所属仓库返回未删除的托盘只读快照,不校验托盘状态或占用资格。")
|
||||
description = "按入库单所属仓库分页返回当前有效托盘候选,支持编码或类型关键词;托盘可复用,不按预占或占用过滤。")
|
||||
@GetMapping("/weight-pallet-options")
|
||||
@PreAuthorize("hasAnyAuthority('platform:firstleg:inbound:measure:view',"
|
||||
+ " 'platform:firstleg:inbound:weight:edit')")
|
||||
public ApiResponse<List<InboundMeasurementResponses.PalletSnapshot>> weightPalletOptions(
|
||||
public ApiResponse<PageResult<InboundMeasurementResponses.PalletSnapshot>> weightPalletOptions(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam long tenantId,
|
||||
@PathVariable long inboundId) {
|
||||
@PathVariable long inboundId,
|
||||
@RequestParam(defaultValue = "1") @Min(1) long page,
|
||||
@RequestParam(defaultValue = "20") @Min(1) @Max(100) long size,
|
||||
@RequestParam(required = false) @Size(max = 50) String keyword) {
|
||||
requirePlatformTenant(user, tenantId);
|
||||
return ApiResponse.success(service.weightPalletOptions(tenantId, inboundId));
|
||||
return ApiResponse.success(service.weightPalletOptions(tenantId, inboundId, page, size, keyword));
|
||||
}
|
||||
|
||||
@BusinessAudit(event = "INBOUND_MEASUREMENT_DRAFT_SAVE", resourceType = "INBOUND",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user