Compare commits

...
150 Commits
Author SHA1 Message Date
liuyihe 8a94c541cb Merge remote-tracking branch 'origin/dev' into dev 2026-09-01 15:14:24 +08:00
ouyangxq b223ce82d6 Merge remote-tracking branch 'origin/test' into dev 2026-09-01 15:11:52 +08:00
ouyangxq 1e8f504f15 fix(firstleg): allow booking link before container pickup 2026-09-01 15:06:03 +08:00
ouyangxq 94395faeef fix: make phase 5 permission validation mysql compatible 2026-09-01 15:00:38 +08:00
ouyangxq 579c61879d fix: include phase 3 customs baseline in unified sql 2026-09-01 14:54:47 +08:00
ouyangxq 41837180f8 Merge remote-tracking branch 'origin/test' into dev 2026-09-01 14:49:41 +08:00
ouyangxq a10e32a012 test(firstleg): finish task evidence migration 2026-09-01 14:48:51 +08:00
ouyangxq 980d37b9c2 Merge remote-tracking branch 'origin/test' into dev 2026-09-01 14:48:33 +08:00
ouyangxq f2958bef6e 优化 2026-09-01 14:47:45 +08:00
ouyangxq 0e1ad4ed45 优化 2026-09-01 14:47:43 +08:00
ouyangxq 76d2da5bf9 test(firstleg): use task-scoped loading evidence 2026-09-01 14:46:48 +08:00
ouyangxq 46f5a73c47 优化 2026-09-01 14:46:44 +08:00
ouyangxq b610a128de Merge branch 'test' of http://git.buggoglobal.com/java-project/baiyue-firstleg into dev 2026-09-01 14:46:36 +08:00
ouyangxq f0ac0c42c9 test(firstleg): use valid PDF truck fixtures 2026-09-01 14:44:30 +08:00
ouyangxq 105660bd09 test(firstleg): scope loading evidence by cabinet 2026-09-01 14:37:02 +08:00
ouyangxq 2ccfc38cdf test(firstleg): seed booking file before loading release 2026-09-01 14:31:01 +08:00
ouyangxq f551cd8c7a 优化 2026-09-01 14:27:16 +08:00
ouyangxq d2092bf0b6 优化 2026-09-01 14:27:04 +08:00
ouyangxq 97168b19d8 feat: complete phase 5 overseas fulfillment contracts 2026-09-01 14:24:59 +08:00
liuyihe 8b0c99593e 一件代发拣货 2026-09-01 13:41:00 +08:00
ouyangxq 9e32efdd90 test(firstleg): harden measurement demo package 2026-09-01 12:03:21 +08:00
ouyangxq 6eaf484fcd test(firstleg): package carrier node demo checks 2026-09-01 12:02:57 +08:00
ouyangxq 53078b4281 test(firstleg): extend shipment demo regression 2026-09-01 12:02:47 +08:00
ouyangxq 49d216cc13 test(order): harden demo order lifecycle fixture 2026-09-01 12:02:35 +08:00
ouyangxq 9a3b408de0 优化 2026-09-01 11:38:42 +08:00
ouyangxq 68b584128c fix(firstleg): surface existing truck appointment 2026-09-01 11:30:35 +08:00
ouyangxq b5bc77b153 fix(firstleg): atomically finalize PDA completion 2026-09-01 11:11:43 +08:00
ouyangxq ffed9bf898 Merge branch 'dev-lyhfive' of http://git.buggoglobal.com/java-project/baiyue-firstleg into dev 2026-09-01 11:09:14 +08:00
ouyangxq de0eddeee8 修复尾程货主快照与短单号合同 2026-09-01 11:06:26 +08:00
ouyangxq 4991536065 test(firstleg): harden pallet option page contract 2026-09-01 11:03:40 +08:00
ouyangxq 7ace1ded56 优化 2026-09-01 11:02:10 +08:00
ouyangxq 5af79df4bd test(firstleg): align trucking attachment regressions 2026-09-01 11:01:34 +08:00
ouyangxq 6eb53751a3 test(pda): keep overseas acceptance business-scoped 2026-09-01 11:01:20 +08:00
ouyangxq 09e81b429c test(firstleg): align fulfillment matrix with container customs 2026-09-01 10:48:33 +08:00
ouyangxq 179a20fc38 refactor(firstleg): keep trucking payable only 2026-09-01 10:38:15 +08:00
ouyangxq 6a0f51f136 优化 2026-09-01 10:29:14 +08:00
ouyangxq 7d9eaa7f43 test(firstleg): verify trucking detail after dispatch 2026-09-01 10:26:22 +08:00
ouyangxq bfc2a4dbd4 test(firstleg): scope trucking warehouse lookup 2026-09-01 10:22:15 +08:00
ouyangxq 0613d1095e test(firstleg): resolve trucking fixtures from active catalogs 2026-09-01 10:21:37 +08:00
ouyangxq 3d7990250f fix(finance): align operating fund export labels 2026-09-01 10:19:35 +08:00
ouyangxq 2c3efad70a test: bound A0 location isolation writes 2026-09-01 10:18:19 +08:00
ouyangxq cdcf3e0f4f test(firstleg): parse platform warehouse scope explicitly 2026-09-01 10:17:41 +08:00
ouyangxq 3d6216910f test(finance): align full acceptance with current workflows 2026-09-01 10:17:03 +08:00
ouyangxq 505188c3fd test(firstleg): make trucking acceptance self-contained 2026-09-01 10:16:07 +08:00
ouyangxq 477971dda1 优化 2026-09-01 10:00:13 +08:00
ouyangxq 249b182e7f refactor(firstleg): remove customer scope from trucking 2026-09-01 09:51:59 +08:00
ouyangxq 936412f351 test(file-print): extend booking document regression 2026-08-31 20:56:52 +08:00
ouyangxq 0cc4eae209 feat(firstleg): enforce booking-owned truck workflow 2026-08-31 19:55:58 +08:00
ouyangxq f2a980e5c1 fix(file): allow arbitrary ocean booking documents 2026-08-31 19:54:05 +08:00
ouyangxq 47ce84d122 feat(file-print): support ocean booking documents 2026-08-31 19:30:37 +08:00
ouyangxq 01385c4bc7 优化 2026-08-31 18:55:03 +08:00
ouyangxq 9b09663b2e fix(firstleg): allow corrective evidence after completion failure 2026-08-31 18:42:08 +08:00
ouyangxq 838bd93bc4 test(firstleg): align cabinet evidence SQL contracts 2026-08-31 18:31:39 +08:00
ouyangxq 60d45613c7 fix(firstleg): import multipart request annotation 2026-08-31 18:24:58 +08:00
ouyangxq 4ddfc6fb99 chore(firstleg): remove legacy inbound evidence schema 2026-08-31 18:19:25 +08:00
ouyangxq df0fbc1c7f fix(firstleg): scope loading evidence to cabinet tasks 2026-08-31 18:14:36 +08:00
ouyangxq 4795ab958c fix(file): require Firstleg for PDA evidence access 2026-08-31 17:42:19 +08:00
ouyangxq 1288c8937c fix(file): bind loading evidence to cabinet 2026-08-31 17:33:11 +08:00
ouyangxq ba8e3595e4 fix(firstleg): explain coload group eligibility 2026-08-31 15:40:24 +08:00
ouyangxq 17d81b0b94 feat(order): expose unified order channel code 2026-08-31 15:38:18 +08:00
ouyangxq c9792b1f63 fix(firstleg): resolve loading list lifecycle by plan 2026-08-31 15:26:47 +08:00
ouyangxq 0122ad314b feat(firstleg): expose smart-loading channel directory 2026-08-31 14:54:00 +08:00
ouyangxq ea615b864a fix(firstleg): cancel coload groups by inbound code 2026-08-31 14:53:44 +08:00
ouyangxq 314ce09b5d fix(firstleg): load container overview transport history 2026-08-31 12:39:24 +08:00
ouyangxq 942dbace19 feat: enforce canonical container identity across regions 2026-08-31 12:13:20 +08:00
ouyangxq b386f35ab4 优化 2026-08-31 10:31:51 +08:00
ouyangxq d6f47e650c 优化 2026-08-31 10:19:48 +08:00
ouyangxq bef91151a3 优化 2026-08-31 10:17:42 +08:00
ouyangxq bf1dc1f209 优化 2026-08-31 10:17:03 +08:00
ouyangxq aa583efa4a 优化 2026-08-31 10:09:14 +08:00
ouyangxq ff6a8498a6 fix(lastleg): resolve actual overseas receiving warehouse 2026-08-30 18:07:38 +08:00
ouyangxq ca9e7b60e8 优化 2026-08-30 17:02:19 +08:00
ouyangxq 0aa77327d5 test(firstleg): stabilize coload smart and short-pick checks 2026-08-30 16:14:07 +08:00
ouyangxq 5c83b81e8a test(firstleg): harden coload capacity acceptance 2026-08-30 16:07:12 +08:00
ouyangxq c9ee319e9d fix(firstleg): refresh recommendation capacity 2026-08-30 16:06:20 +08:00
ouyangxq 4581c8a294 test(firstleg): assert atomic smart coload recommendation 2026-08-30 15:58:14 +08:00
ouyangxq 9a15e78b2e test(firstleg): extend coload group acceptance 2026-08-30 15:55:48 +08:00
ouyangxq bfcb80b326 fix(firstleg): align coload group eligibility 2026-08-29 12:44:42 +08:00
ouyangxq 0cf76df168 fix(firstleg): align coload loading route gates 2026-08-29 12:36:07 +08:00
ouyangxq ba641754de test(firstleg): document coload PDA acceptance 2026-08-29 11:59:53 +08:00
ouyangxq 81ef43c9fd 优化 2026-08-29 11:59:11 +08:00
ouyangxq 0605c32829 fix(firstleg): persist pricing conversion metadata 2026-08-29 11:48:19 +08:00
ouyangxq 66816d5341 优化 2026-08-29 11:42:52 +08:00
ouyangxq de101621dd fix(firstleg): align merchant billing with sent fees 2026-08-29 11:13:35 +08:00
ouyangxq b4db37392d fix(firstleg): complete coload SQL comments 2026-08-29 11:13:10 +08:00
ouyangxq b1ab909184 优化 2026-08-29 11:10:28 +08:00
ouyangxq 775dd71227 fix(firstleg): keep published billing visible after loading 2026-08-29 11:00:19 +08:00
ouyangxq 3339ec91f9 feat(firstleg): enforce indivisible coload loading 2026-08-29 10:59:38 +08:00
ouyangxq ac6d3289a6 feat(firstleg): add indivisible coload groups 2026-08-29 10:51:50 +08:00
ouyangxq e6ee9cb442 fix(firstleg): recover multi-customer PDA completion 2026-08-29 08:25:42 +08:00
ouyangxq da32a67d7f 优化 2026-08-28 23:01:52 +08:00
ouyangxq 969ff05815 test(firstleg): correct mixed customs completion probe 2026-08-28 22:54:23 +08:00
ouyangxq 7f565a24d6 test(firstleg): fix mixed customs permission probe 2026-08-28 22:50:55 +08:00
ouyangxq 1e389d95dd fix(firstleg): serialize concurrent customs start replay 2026-08-28 22:44:37 +08:00
ouyangxq f53ff76143 test(firstleg): add mixed container customs acceptance 2026-08-28 22:39:18 +08:00
ouyangxq f87647d4eb fix(firstleg): allow customs uploads up to 50MB 2026-08-28 22:15:24 +08:00
ouyangxq 833f928a30 fix(file): align customs download permission contract 2026-08-28 22:12:29 +08:00
ouyangxq 621aec9b2b fix(firstleg): replay completed customs uploads 2026-08-28 21:42:51 +08:00
ouyangxq f72238e43f fix(firstleg): map customs document versions explicitly 2026-08-28 21:20:43 +08:00
ouyangxq 7a15481e2d fix(firstleg): resolve merchant customs plan lookup 2026-08-28 17:28:39 +08:00
ouyangxq 2dca7dfdb2 优化 2026-08-28 17:10:12 +08:00
ouyangxq 1c51373704 fix(firstleg): register container customs mapper 2026-08-28 17:10:07 +08:00
ouyangxq 9664aa4bf0 test(firstleg): align customs contract assertions 2026-08-28 16:59:43 +08:00
ouyangxq 603bd29cc2 优化 2026-08-28 16:54:21 +08:00
ouyangxq 0afe75379e test(order): align batch draft service mocks 2026-08-28 16:53:28 +08:00
ouyangxq 0ea4aeecc9 feat(firstleg): implement container customs workflow 2026-08-28 16:48:50 +08:00
ouyangxq bb9673358d 优化 2026-08-28 13:58:49 +08:00
ouyangxq 6b5f2f0565 优化 2026-08-28 13:54:32 +08:00
ouyangxq fe68551124 优化 2026-08-28 12:33:14 +08:00
ouyangxq f998bed09c 优化 2026-08-28 11:28:30 +08:00
ouyangxq 1e5ce54858 优化 2026-08-28 10:38:58 +08:00
ouyangxq b34fbb2bd1 fix(ci): ignore stale Surefire reports 2026-08-28 10:32:41 +08:00
ouyangxq a6f266687e 优化 2026-08-28 10:03:32 +08:00
ouyangxq dd117b8fc2 Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into test 2026-08-28 09:19:19 +08:00
ouyangxq a719a903cd 优化 2026-08-28 09:19:18 +08:00
wujinliang 3b1b4ab9b2 Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into test 2026-08-27 21:49:00 +08:00
wujinliang 9d54c57b7c 1 2026-08-27 21:48:47 +08:00
ouyangxq e72ac14425 fix: map warehouse pallet lookup failure 2026-08-27 19:31:38 +08:00
ouyangxq ec8f84c1c0 fix: reject missing receiving pallet 2026-08-27 19:27:59 +08:00
ouyangxq 2b7f66cb04 fix: reclaim newly added container plans 2026-08-27 19:14:54 +08:00
ouyangxq c7061b8a74 优化 2026-08-27 18:18:14 +08:00
ouyangxq 5250f304be 优化 2026-08-27 18:11:57 +08:00
ouyangxq f5f72d681d 优化 2026-08-27 18:05:02 +08:00
ouyangxq c226b6a99b 优化 2026-08-27 17:49:02 +08:00
ouyangxq 600b2a2dc4 优化 2026-08-27 17:31:19 +08:00
ouyangxq 76c24867a4 优化 2026-08-27 17:16:33 +08:00
ouyangxq 61a97cd057 优化 2026-08-27 16:42:27 +08:00
ouyangxq ecf48886d5 优化 2026-08-27 16:34:30 +08:00
ouyangxq 1db3e48506 优化 2026-08-27 16:17:11 +08:00
ouyangxq 0f2e201c38 fix(lastleg): unblock inbound plan handoff 2026-08-27 15:56:14 +08:00
ouyangxq adabec13d3 fix(print): include Spring DAO exceptions at runtime 2026-08-27 15:21:28 +08:00
ouyangxq 0fb51211aa 优化 2026-08-27 15:02:37 +08:00
ouyangxq d7bff980ca 优化 2026-08-27 14:53:27 +08:00
ouyangxq ed3c1dafcc 优化 2026-08-27 14:30:33 +08:00
ouyangxq 0a3ec0b8a6 优化 2026-08-27 14:30:23 +08:00
ouyangxq 3b38fdd33d 优化 2026-08-27 13:50:33 +08:00
ouyangxq daf0ee2acd 优化 2026-08-27 11:45:52 +08:00
ouyangxq 086a0f9c89 优化 2026-08-27 11:42:05 +08:00
ouyangxq c80307a838 优化 2026-08-27 11:39:32 +08:00
ouyangxq 575cee7a86 优化 2026-08-27 11:38:53 +08:00
ouyangxq c1b136c3d6 优化 2026-08-27 11:09:51 +08:00
ouyangxq 4612181ab0 优化 2026-08-27 11:07:57 +08:00
ouyangxq b4c46280de test(acceptance): cover booking agent carrier matrix 2026-08-27 10:38:55 +08:00
ouyangxq 15b563610c fix(firstleg): validate booking carrier associations 2026-08-27 10:30:38 +08:00
ouyangxq 079ff43f73 feat(warehouse): support supplier carrier associations 2026-08-27 10:29:28 +08:00
ouyangxq 554b6c93b7 优化 2026-08-27 09:21:01 +08:00
ouyangxq 817e356c21 Merge remote-tracking branch 'origin/dev' into test 2026-08-26 21:17:25 +08:00
ouyangxq ca12f58d38 chore: remove tracked runtime artifacts 2026-08-26 20:51:42 +08:00
ouyangxq 8fb63e9adc Merge remote-tracking branch 'origin/dev' into test
# Conflicts:
#	warehouse/src/main/java/com/baiyue/warehouse/dto/WarehouseRequests.java
#	warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseImportService.java
#	warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseSaveNormalizer.java
#	warehouse/src/main/resources/db/warehouse.sql
2026-08-26 20:51:27 +08:00
wujinliang d736fdba1b 1 2026-08-26 18:12:55 +08:00
727 changed files with 28199 additions and 7422 deletions
+1
View File
@@ -28,5 +28,6 @@ reports/
uploads/
.codex-temp*
.codex-tmp*
.runtime-*/
.codex-*.out
.codex-*.err
Binary file not shown.
-39
View File
@@ -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.
+15 -2
View File
@@ -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
View File
@@ -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};下游单服务任务不再重复执行测试"
+1
View File
@@ -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> {
}
+1
View File
@@ -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)));
@@ -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;
}
}
@@ -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("船节点交接只接受带实际时间的标准节点事实");
@@ -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);
}
}
@@ -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);
}
}
@@ -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");
}
}
@@ -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
@@ -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");
}
}
@@ -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");
}
}
@@ -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");
}
}
+4
View File
@@ -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) {
}
}
+10
View File
@@ -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
View File
@@ -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:}
+10
View File
@@ -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
+2
View File
@@ -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
View File
@@ -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。
File diff suppressed because it is too large Load Diff
+233 -2
View File
@@ -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
+1
View File
@@ -2,6 +2,7 @@
## 职责边界
- Mapper 只由 `EventBridgeApplication.java` 对 `com.baiyue.eventbridge` 根包按 `@Mapper` 过滤扫描;新增或移动 Mapper 后从后端根运行 `node scripts/mapper-scan-contract.mjs`,不得增加子域扫描配置。
- 负责跨区事件接收、Inbox 持久化、按目标区域向海外尾程或国内头程投递、失败重试、投递记录和人工补投。
- 只承载可靠传输,不执行尾程建单逻辑,不查询头程或尾程业务表。
- 事件结构和 Payload 哈希以 `common-event-contract` 为准。
@@ -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)
@@ -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);
}
@@ -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 '来源区域',
@@ -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
View File
@@ -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());
}
@@ -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()
@@ -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) {}
}
+11 -2
View File
@@ -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
+2 -2
View File
@@ -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");
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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)",
@@ -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");
+6 -3
View File
@@ -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);
@@ -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
View File
@@ -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) {}
}
@@ -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) {}
}
@@ -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));
@@ -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 = "查询货柜目的港清关资料")
@@ -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);
}
}
@@ -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