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/ uploads/
.codex-temp* .codex-temp*
.codex-tmp* .codex-tmp*
.runtime-*/
.codex-*.out .codex-*.out
.codex-*.err .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` | | 订单、货物、快照、状态、幂等、Outbox | `order/` | `order/AGENTS.md` |
| 履约分配、入库、测量、审核、FBA 计费 | `firstleg/` | `firstleg/AGENTS.md` | | 履约分配、入库、测量、审核、FBA 计费 | `firstleg/` | `firstleg/AGENTS.md` |
| Firstleg 内部模块化与依赖基线 | `firstleg/` | `firstleg/docs/firstleg-internal-modularization-baseline.md`、`firstleg/scripts/analyze-firstleg-dependencies.mjs`;价格簿查询和客户统计按端口/适配器切片,不改变服务与数据库边界 | | 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/` | `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` | | 客户账期、费用池、账单、收款、成本、核销 | `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` 和客户范围校验 | | 入库/柜次应付、直接已付、付款流水与凭证 | `firstleg/`、`finance/`、`file/` | 业务费用与付款快照见 `firstleg/AGENTS.md` 的 `FeePaymentSnapshotValidator`;`/api/platform/finance/v1/vouchers` 上传凭证,File 按 `FINANCE_VOUCHER` 和客户范围校验 |
| 仓库、往来单位、快递、托盘、库位 | `warehouse/` | `warehouse/AGENTS.md` | | 仓库、往来单位、快递、托盘、库位 | `warehouse/` | `warehouse/AGENTS.md` |
@@ -40,8 +43,12 @@
| 海外 API 路由、海外 Token、Audience/JWKS | `gateway-overseas/` | `gateway-overseas/AGENTS.md` | | 海外 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` | | 海外 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/` | `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 已移除 | | 海外入仓分页、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 出库或派送 | | 海外仓排车查询、候选与智能预排 | `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/` | | 客户海外仓贴标、转运、废弃与退货换标 | `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` 为准 | | 海外仓 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 候选进入可收货 | | 国内船节点向海外接柜门禁事件 | `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。 - 跨服务修改先检查调用方 `client/`、被调用方 Controller/DTO、`src/main/resources/openapi/` 和双方契约测试;禁止共享领域 Entity。
- 修改 Java 方法、构造器、Controller、Client、DTO 或 Mapper 参数签名时,先用 `rg` 定向查找符号及旧参数形态,并同步更新 `src/main/`、`src/test/`、Mock、Fixture、契约测试和脚本中的全部调用点。新增可选参数也必须补齐旧测试调用,不能因生产调用已更新就视为完成。 - 修改 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`。 - 平台后台存在正式列表页时,`*: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` 必须停用/移除,不得重新提升或兼容。 - 供应商查询接口固定只使用 `platform:supplier:list`;IAM 中 `platform:supplier:list` 是正式菜单资源,`platform:supplier:view` 必须停用/移除,不得重新提升或兼容。
- 权限语义变更必须同步 Controller `@PreAuthorize`、OpenAPI、模块权限 SQL、IAM 菜单资源、统一增量脚本和契约测试。并行模块任务不得覆盖菜单与权限总控已写入共享 SQL 或权限契约的改动。 - 权限语义变更必须同步 Controller `@PreAuthorize`、OpenAPI、模块权限 SQL、IAM 菜单资源、统一增量脚本和契约测试。并行模块任务不得覆盖菜单与权限总控已写入共享 SQL 或权限契约的改动。
@@ -85,16 +93,20 @@
- 通用配置在 `config/nacos/{local,cn}/`;服务日志、上传文件、`target/`、`reports/` 不属于源码。 - 通用配置在 `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/数据库,除非用户明确要求。 - 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、数据库或部署环境。 - `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`。实际部署仍须当前对话明确授权。 - 单服务镜像构建/部署从 `Jenkinsfile.service`、`deploy/docker/Dockerfile`、`deploy/scripts/deploy-built-service.sh` 进入;新镜像启动失败或健康检查失败时只允许回滚到同服务先前镜像,并保持任务失败,离线合同见 `scripts/deploy-built-service-rollback.test.mjs`。实际部署仍须当前对话明确授权。
## 全量 HTTP 验收脚本路由 ## 全量 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`,不替代客户端全量入口。 - 全量验收唯一指向测试环境 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`。 - Admin 总入口默认按模块列表覆盖平台全链路;需要定位单个业务时使用 `--modules <name>`,模块名称和静态合同先查 `scripts/admin-test-env-modules/README.md`。
- 这些脚本会创建、更新或最终停用测试数据,必须在当前对话明确授权测试环境验收后执行,并保存 JSON 报告;未授权不得请求 Gateway。 - 这些脚本会创建、更新或最终停用测试数据,必须在当前对话明确授权测试环境验收后执行,并保存 JSON 报告;未授权不得请求 Gateway。
- 后端 Maven、前端 `pnpm test/typecheck`、PDA `node --test` 和数据库 Dry-run 都是本地质量检查,不属于全量 HTTP 验收。 - 后端 Maven、前端 `pnpm test/typecheck`、PDA `node --test` 和数据库 Dry-run 都是本地质量检查,不属于全量 HTTP 验收。
- Java 模块、OpenAPI 覆盖、JaCoCo 与 SpotBugs 聚合摘要使用 `node scripts/java-quality-summary.mjs`;默认仅报告存量缺口,是否阻断由显式 `--require-*` 参数决定,输出位于 `target/` 时不得作为业务事实或提交物。 - Java 模块、OpenAPI 覆盖、JaCoCo 与 SpotBugs 聚合摘要使用 `node scripts/java-quality-summary.mjs`;默认仅报告存量缺口,是否阻断由显式 `--require-*` 参数决定,输出位于 `target/` 时不得作为业务事实或提交物。
- HTTP 全量通过后,才进入 Admin/Client 真实页面调用、浏览器样式和 PDA 页面/设备验收;自动化 HTTP 通过不代表视觉验收完成。 - 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/二期/二期需求优化版-v2-测量计费入库主流程.md`、需求确认清单及联调报告
- 三期:`../docs/三期/README.md`、`../docs/三期/三期开工门禁.md`、`../docs/三期/三期开发规划.md`、`../docs/三期/三期需求确认表.md`、页面与原型清单 - 三期:`../docs/三期/README.md`、`../docs/三期/三期开工门禁.md`、`../docs/三期/三期开发规划.md`、`../docs/三期/三期需求确认表.md`、页面与原型清单
- 四期:`../docs/四期/README.md`;海外入口 `gateway-overseas/`,尾程与入仓库存 `lastleg-service/`,海外仓储资源 `warehouse-overseas-service/` - 四期:`../docs/四期/README.md`;海外入口 `gateway-overseas/`,尾程与入仓库存 `lastleg-service/`,海外仓储资源 `warehouse-overseas-service/`
- 五期:当前以本轮确认的正式 Controller、前端路由、权限、SQL 与跨区事件契约为范围依据;海外排车执行、出库、派送、一件代发、异常退回和客户回访进入 `lastleg-service/`,国内 SKU/箱唛及暂扣协同进入 `order/`、`firstleg/`、`common-event-contract/`
- 财务:`../docs/财务管理/README.md` 中已确认的范围、对象、状态和接口约束 - 财务:`../docs/财务管理/README.md` 中已确认的范围、对象、状态和接口约束
全量验收任务再读取对应阶段进度表、验收报告和证据矩阵;普通代码定位只读取当前链路涉及的范围、契约、模块指引和代码。 全量验收任务再读取对应阶段进度表、验收报告和证据矩阵;普通代码定位只读取当前链路涉及的范围、契约、模块指引和代码。
Vendored
+1
View File
@@ -295,6 +295,7 @@ pipeline {
echo "统一测试本次选中的服务及其公共依赖: ${selectedModules}" echo "统一测试本次选中的服务及其公共依赖: ${selectedModules}"
sh """ sh """
test \"\$(git rev-parse HEAD)\" = \"${env.SOURCE_COMMIT}\" 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 mvn --settings deploy/maven/settings-ci.xml --batch-mode --errors -pl ${selectedModules} -am test
""" """
echo "统一测试门禁通过: ${env.SOURCE_COMMIT};下游单服务任务不再重复执行测试" 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` 产生的审计事件并持久化到 `baiyue_audit`。
- 审计注解、切面、脱敏和发送策略在 `common-audit/`;本服务不复制业务服务的审计采集逻辑。 - 审计注解、切面、脱敏和发送策略在 `common-audit/`;本服务不复制业务服务的审计采集逻辑。
@@ -1,6 +1,7 @@
package com.baiyue.audit; package com.baiyue.audit;
import com.baiyue.common.core.mybatis.MybatisPlusConfiguration; import com.baiyue.common.core.mybatis.MybatisPlusConfiguration;
import org.apache.ibatis.annotations.Mapper;
import org.mybatis.spring.annotation.MapperScan; import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
@@ -10,7 +11,7 @@ import org.springframework.scheduling.annotation.EnableScheduling;
/** /**
* 审计微服务启动入口,初始化 Spring Boot 应用并扫描审计日志 Mapper。 * 审计微服务启动入口,初始化 Spring Boot 应用并扫描审计日志 Mapper。
*/ */
@MapperScan("com.baiyue.audit.mapper") @MapperScan(basePackages = "com.baiyue.audit", annotationClass = Mapper.class)
@EnableScheduling @EnableScheduling
@Import(MybatisPlusConfiguration.class) @Import(MybatisPlusConfiguration.class)
@SpringBootApplication @SpringBootApplication
@@ -1,10 +1,13 @@
package com.baiyue.audit.mapper; package com.baiyue.audit.mapper;
import org.apache.ibatis.annotations.Mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.audit.entity.AuditLogEntity; import com.baiyue.audit.entity.AuditLogEntity;
/** /**
* 审计日志 MyBatis-Plus Mapper,提供 {@code sys_audit_log} 表的单表写入和查询能力。 * 审计日志 MyBatis-Plus Mapper,提供 {@code sys_audit_log} 表的单表写入和查询能力。
*/ */
@Mapper
public interface AuditLogMapper extends BaseMapper<AuditLogEntity> { 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、退出、密码和账号归属范围。 - 负责登录、注册、账号凭据、Access/Refresh Token、退出、密码和账号归属范围。
- 客户主档、成员、角色权限归 `iam/`;Auth 通过 IAM 客户端获取授权快照,不跨库写 IAM。 - 客户主档、成员、角色权限归 `iam/`;Auth 通过 IAM 客户端获取授权快照,不跨库写 IAM。
- 修改国内/海外 Token、Audience、JWKS 或跨区域身份边界前,先读 `../../docs/跨区域认证与服务通信契约.md`。 - 修改国内/海外 Token、Audience、JWKS 或跨区域身份边界前,先读 `../../docs/跨区域认证与服务通信契约.md`。
@@ -1,5 +1,6 @@
package com.baiyue.auth; package com.baiyue.auth;
import org.apache.ibatis.annotations.Mapper;
import org.mybatis.spring.annotation.MapperScan; import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; 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") @EnableFeignClients(basePackages = "com.baiyue.auth.client")
@SpringBootApplication(scanBasePackages = "com.baiyue") @SpringBootApplication(scanBasePackages = "com.baiyue")
public class BaiyueAuthApplication { public class BaiyueAuthApplication {
@@ -1,9 +1,12 @@
package com.baiyue.auth.mapper; package com.baiyue.auth.mapper;
import org.apache.ibatis.annotations.Mapper;
import com.baiyue.auth.entity.AuthAccount; import com.baiyue.auth.entity.AuthAccount;
import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper;
/** /**
* 认证账号 MyBatis-Plus 数据访问接口,负责 {@code auth_account} 表的基础持久化操作。 * 认证账号 MyBatis-Plus 数据访问接口,负责 {@code auth_account} 表的基础持久化操作。
*/ */
@Mapper
public interface AuthAccountMapper extends BaseMapper<AuthAccount> { } public interface AuthAccountMapper extends BaseMapper<AuthAccount> { }
@@ -1,9 +1,12 @@
package com.baiyue.auth.mapper; package com.baiyue.auth.mapper;
import org.apache.ibatis.annotations.Mapper;
import com.baiyue.auth.entity.AuthRefreshToken; import com.baiyue.auth.entity.AuthRefreshToken;
import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper;
/** /**
* 刷新令牌 MyBatis-Plus 数据访问接口,负责 {@code auth_refresh_token} 表的基础持久化操作。 * 刷新令牌 MyBatis-Plus 数据访问接口,负责 {@code auth_refresh_token} 表的基础持久化操作。
*/ */
@Mapper
public interface AuthRefreshTokenMapper extends BaseMapper<AuthRefreshToken> { } public interface AuthRefreshTokenMapper extends BaseMapper<AuthRefreshToken> { }
@@ -63,7 +63,7 @@ class AuthServiceSmallJwtTest {
account.setDefaultAppCode("PLATFORM"); account.setDefaultAppCode("PLATFORM");
account.setTokenVersion(1); account.setTokenVersion(1);
AuthorizationSnapshot snapshot = new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM", 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); Jwt encoded = org.mockito.Mockito.mock(Jwt.class);
when(repository.findByUsername("operator")).thenReturn(account); when(repository.findByUsername("operator")).thenReturn(account);
@@ -91,7 +91,7 @@ class AuthServiceSmallJwtTest {
assertThat(jwt.getValue().getJwsHeader().getAlgorithm()).isEqualTo(MacAlgorithm.HS256); assertThat(jwt.getValue().getJwsHeader().getAlgorithm()).isEqualTo(MacAlgorithm.HS256);
assertThat(jwt.getValue().getJwsHeader().getKeyId()).isNull(); assertThat(jwt.getValue().getJwsHeader().getKeyId()).isNull();
verify(tokenSessionStore).store(any(), org.mockito.ArgumentMatchers.eq(42L), 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(Set.of("platform:order:list")), any(),
org.mockito.ArgumentMatchers.eq(Duration.ofMinutes(30)), org.mockito.ArgumentMatchers.eq(Duration.ofMinutes(30)),
org.mockito.ArgumentMatchers.eq(Duration.ofDays(7))); org.mockito.ArgumentMatchers.eq(Duration.ofDays(7)));
@@ -66,7 +66,7 @@ class OverseasSessionValidationServiceTest {
private static AuthorizationSnapshot snapshot(Long version, Long warehouseId) { private static AuthorizationSnapshot snapshot(Long version, Long warehouseId) {
return new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM", "PDA", 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 { private void inject(String fieldName, Object value) throws Exception {
@@ -52,7 +52,7 @@ class OverseasTokenServiceTest {
String lastlegAuthority = "lastleg:pda:operate"; String lastlegAuthority = "lastleg:pda:operate";
String domesticAuthority = "platform:firstleg:order:list"; String domesticAuthority = "platform:firstleg:order:list";
AuthorizationSnapshot snapshot = new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM", "WEB", 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); Jwt encoded = org.mockito.Mockito.mock(Jwt.class);
when(accounts.findById(42L)).thenReturn(account); when(accounts.findById(42L)).thenReturn(account);
when(authorizationLoader.load(account)).thenReturn(snapshot); when(authorizationLoader.load(account)).thenReturn(snapshot);
@@ -74,7 +74,7 @@ class OverseasTokenServiceTest {
.containsEntry("permission_version", 7L) .containsEntry("permission_version", 7L)
.containsEntry("token_version", 0) .containsEntry("token_version", 0)
.containsEntry("overseas_warehouse_id", 900L) .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)) .containsEntry("authorities", Set.of(authority, lastlegAuthority))
.doesNotContainKeys("userId", "tenantId", "appCode", "permissionVersion"); .doesNotContainKeys("userId", "tenantId", "appCode", "permissionVersion");
assertThat(response.expiresIn()).isEqualTo(900); assertThat(response.expiresIn()).isEqualTo(900);
@@ -87,9 +87,9 @@ class OverseasTokenServiceTest {
account.setDefaultMembershipId(85L); account.setDefaultAppCode("PLATFORM"); account.setDefaultMembershipId(85L); account.setDefaultAppCode("PLATFORM");
account.setTokenVersion(8); account.setTokenVersion(8);
AuthorizationSnapshot oldBinding = new AuthorizationSnapshot(43L, 0L, 85L, "PLATFORM", "PDA", 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", 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 first = org.mockito.Mockito.mock(Jwt.class);
Jwt second = org.mockito.Mockito.mock(Jwt.class); Jwt second = org.mockito.Mockito.mock(Jwt.class);
when(encoder.encode(any())).thenReturn(first, second); when(encoder.encode(any())).thenReturn(first, second);
@@ -117,7 +117,7 @@ class OverseasTokenServiceTest {
AuthAccount account = new AuthAccount(); AuthAccount account = new AuthAccount();
account.setId(44L); account.setAccountStatus("ACTIVE"); account.setId(44L); account.setAccountStatus("ACTIVE");
AuthorizationSnapshot unbound = new AuthorizationSnapshot(44L, 0L, 86L, "PLATFORM", "PDA", 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); Jwt encoded = org.mockito.Mockito.mock(Jwt.class);
when(encoder.encode(any())).thenReturn(encoded); when(encoder.encode(any())).thenReturn(encoded);
when(encoded.getTokenValue()).thenReturn("overseas-unbound"); 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; package com.baiyue.common.event;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.time.Instant; import java.time.Instant;
import java.util.Map; import java.util.Map;
@@ -8,7 +9,7 @@ public record FirstLegContainerShippingNodeRecordedV1(
String queryId, String queryId,
String customerId, String customerId,
String oceanBookingId, String oceanBookingId,
String containerBookingId, @JsonProperty("sourceContainerBookingId") String containerBookingId,
String containerNo, String containerNo,
String standardNode, String standardNode,
int nodeSequence, int nodeSequence,
@@ -34,6 +35,7 @@ public record FirstLegContainerShippingNodeRecordedV1(
|| containerNo == null || containerNo.isBlank()) { || containerNo == null || containerNo.isBlank()) {
throw new IllegalArgumentException("船节点交接业务标识不能为空"); throw new IllegalArgumentException("船节点交接业务标识不能为空");
} }
containerNo = ContainerNumbers.requireCanonical(containerNo);
if (standardNode == null || !Integer.valueOf(nodeSequence).equals(STANDARD_NODES.get(standardNode)) if (standardNode == null || !Integer.valueOf(nodeSequence).equals(STANDARD_NODES.get(standardNode))
|| observedAt == null || actualAt == null) { || observedAt == null || actualAt == null) {
throw new IllegalArgumentException("船节点交接只接受带实际时间的标准节点事实"); throw new IllegalArgumentException("船节点交接只接受带实际时间的标准节点事实");
@@ -7,7 +7,14 @@ import java.util.List;
public record FirstLegLoadingPlanCompletedV1( public record FirstLegLoadingPlanCompletedV1(
String loadingPlanId, String planNo, String containerBookingId, String loadingPlanId, String planNo, String containerBookingId,
String containerNo, String cabinetNo, String warehouseId, String warehouseName, String containerNo, String cabinetNo, String warehouseId, String warehouseName,
String estimatedPortArrivalAt, String actualPortArrivalAt, String estimatedWarehouseArrivalAt,
List<Order> orders) { 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( public record Order(
String sourceInboundId, String rootInboundId, String sourceInboundId, String rootInboundId,
@@ -16,10 +23,17 @@ public record FirstLegLoadingPlanCompletedV1(
String destinationWarehouseCode, String destinationWarehouseName, String destinationWarehouseCode, String destinationWarehouseName,
int packageCount, Integer productQuantity, Integer palletCount, int packageCount, Integer productQuantity, Integer palletCount,
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm, 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) { String destinationType) {
public Order { public Order {
items = items == null ? List.of() : items; 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; orderType = orderType == null || orderType.isBlank() ? "FIRST_LEG" : orderType;
pallets = pallets == null ? List.of() : pallets; pallets = pallets == null ? List.of() : pallets;
} }
@@ -29,11 +43,13 @@ public record FirstLegLoadingPlanCompletedV1(
String destinationWarehouseCode, String destinationWarehouseName, String destinationWarehouseCode, String destinationWarehouseName,
int packageCount, Integer productQuantity, Integer palletCount, int packageCount, Integer productQuantity, Integer palletCount,
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm, 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, this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId,
inboundCode, destinationWarehouseId, destinationWarehouseCode, inboundCode, destinationWarehouseId, destinationWarehouseCode,
destinationWarehouseName, packageCount, productQuantity, palletCount, 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, public Order(String sourceInboundId, String rootInboundId,
String sourceOrderId, String sourceOrderNo, String rootOrderId, String sourceOrderId, String sourceOrderNo, String rootOrderId,
@@ -41,11 +57,13 @@ public record FirstLegLoadingPlanCompletedV1(
String destinationWarehouseCode, String destinationWarehouseName, String destinationWarehouseCode, String destinationWarehouseName,
int packageCount, Integer productQuantity, Integer palletCount, int packageCount, Integer productQuantity, Integer palletCount,
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm, 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, this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId,
inboundCode, destinationWarehouseId, destinationWarehouseCode, inboundCode, destinationWarehouseId, destinationWarehouseCode,
destinationWarehouseName, packageCount, productQuantity, palletCount, 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; package com.baiyue.common.event;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.util.List; import java.util.List;
/** 装柜完成交接快照,包含三类逐货物备注、推荐托数和可复用的头程托盘编号快照。 */ /** 装柜完成交接快照,包含三类逐货物备注、推荐托数和可复用的头程托盘编号快照。 */
public record FirstLegLoadingPlanCompletedV2( 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 containerNo, String cabinetNo, String warehouseId, String warehouseName,
String estimatedPortArrivalAt, String actualPortArrivalAt, String estimatedWarehouseArrivalAt,
List<Order> orders) { 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( public record Order(
String sourceInboundId, String rootInboundId, String sourceInboundId, String rootInboundId,
String sourceOrderId, String sourceOrderNo, String rootOrderId, String sourceOrderId, String sourceOrderNo, String rootOrderId,
@@ -15,11 +40,23 @@ public record FirstLegLoadingPlanCompletedV2(
String destinationWarehouseCode, String destinationWarehouseName, String destinationWarehouseCode, String destinationWarehouseName,
int packageCount, Integer productQuantity, Integer palletCount, int packageCount, Integer productQuantity, Integer palletCount,
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm, 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 必须逐箱扫码。 */ /** 目的类型:FBA 可按箱数收货,OVERSEAS 必须逐箱扫码。 */
String destinationType) { String destinationType) {
public Order { public Order {
items = items == null ? List.of() : items; 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; orderType = orderType == null || orderType.isBlank() ? "FIRST_LEG" : orderType;
pallets = pallets == null ? List.of() : pallets; pallets = pallets == null ? List.of() : pallets;
} }
@@ -30,11 +67,13 @@ public record FirstLegLoadingPlanCompletedV2(
String destinationWarehouseCode, String destinationWarehouseName, String destinationWarehouseCode, String destinationWarehouseName,
int packageCount, Integer productQuantity, Integer palletCount, int packageCount, Integer productQuantity, Integer palletCount,
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm, 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, this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId,
inboundCode, destinationWarehouseId, destinationWarehouseCode, inboundCode, destinationWarehouseId, destinationWarehouseCode,
destinationWarehouseName, packageCount, productQuantity, palletCount, 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, public Order(String sourceInboundId, String rootInboundId,
@@ -43,11 +82,13 @@ public record FirstLegLoadingPlanCompletedV2(
String destinationWarehouseCode, String destinationWarehouseName, String destinationWarehouseCode, String destinationWarehouseName,
int packageCount, Integer productQuantity, Integer palletCount, int packageCount, Integer productQuantity, Integer palletCount,
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm, 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, this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId,
inboundCode, destinationWarehouseId, destinationWarehouseCode, inboundCode, destinationWarehouseId, destinationWarehouseCode,
destinationWarehouseName, packageCount, productQuantity, palletCount, 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 unloadingPhotoUploadedAt,
Instant actualEmptyReportedAt 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.containerBookingId()).isEqualTo("2088000000000000003");
assertThat(restored.standardNode()).isEqualTo("PICKUP_READY"); assertThat(restored.standardNode()).isEqualTo("PICKUP_READY");
assertThat(restored.nodeSequence()).isEqualTo(80); 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 @Test
@@ -19,7 +19,7 @@ class FirstLegLoadingPlanCompletedV1Test {
var order = new FirstLegLoadingPlanCompletedV1.Order("2088000000000000003", "2088000000000000004", var order = new FirstLegLoadingPlanCompletedV1.Order("2088000000000000003", "2088000000000000004",
"2088000000000000005", "FL-1", "2088000000000000006", "RK-1", "2088000000000000002", "2088000000000000005", "FL-1", "2088000000000000006", "RK-1", "2088000000000000002",
"ONT8", "ONT8", 2, 20, 1, new BigDecimal("10.500"), new BigDecimal("9.500"), "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", var payload = new FirstLegLoadingPlanCompletedV1("2088000000000000007", "P-1", "2088000000000000008",
"CONT-1", "CAB-1", "2088000000000000009", "中转仓", List.of(order)); "CONT-1", "CAB-1", "2088000000000000009", "中转仓", List.of(order));
String json = mapper.writeValueAsString(payload); String json = mapper.writeValueAsString(payload);
@@ -35,7 +35,7 @@ class FirstLegLoadingPlanCompletedV1Test {
"SKU-2", "产品2", 1, 5, List.of("BOX-003")); "SKU-2", "产品2", 1, 5, List.of("BOX-003"));
var order = new FirstLegLoadingPlanCompletedV1.Order("100", "100", "200", "FL-200", "200", var order = new FirstLegLoadingPlanCompletedV1.Order("100", "100", "200", "FL-200", "200",
"RK-100", "300", "ONT8", "ONT8", 3, 25, 2, BigDecimal.ONE, "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)), List.of(new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-01", List.of(sku1)),
new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-02", List.of(sku2)))); new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-02", List.of(sku2))));
var payload = new FirstLegLoadingPlanCompletedV1("1", "LP-1", "2", "CONT-1", null, var payload = new FirstLegLoadingPlanCompletedV1("1", "LP-1", "2", "CONT-1", null,
@@ -52,12 +52,20 @@ class FirstLegLoadingPlanCompletedV1Test {
void legacyParserRetainsDestinationTypeWhenV2OrderCarriesIt() throws Exception { void legacyParserRetainsDestinationTypeWhenV2OrderCarriesIt() throws Exception {
String json = """ String json = """
{"loadingPlanId":"1","planNo":"LP-1","containerBookingId":"2","containerNo":"CONT-1", {"loadingPlanId":"1","planNo":"LP-1","containerBookingId":"2","containerNo":"CONT-1",
"warehouseId":"3","warehouseName":"海外仓","orders":[{"sourceInboundId":"100", "warehouseId":"3","warehouseName":"海外仓","estimatedPortArrivalAt":"2026-09-30T12:00",
"destinationWarehouseId":"3","packageCount":1,"items":[],"destinationType":"OVERSEAS"}]} "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); FirstLegLoadingPlanCompletedV1 restored = mapper.readValue(json, FirstLegLoadingPlanCompletedV1.class);
assertThat(restored.orders().get(0).destinationType()).isEqualTo("OVERSEAS"); 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 org.junit.jupiter.api.Test;
import java.util.List; 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.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class FirstLegLoadingPlanCompletedV2Test { 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 @Test
void carriesTheThreeItemRemarks() throws Exception { void carriesTheThreeItemRemarks() throws Exception {
var item = new FirstLegLoadingPlanCompletedV2.Item("1", "SKU", "中文", "EN", 1, var item = new FirstLegLoadingPlanCompletedV2.Item("1", "SKU", "中文", "EN", 1,
2, null, null, null, null, null, null, null, null, null, null, null, 2, null, null, null, null, null, null, null, null, null, null, null,
"客户备注", "内部备注", "仓库备注"); "客户备注", "内部备注", "仓库备注");
var order = new FirstLegLoadingPlanCompletedV2.Order("2", "2", "3", "ORD", "3", "RK", var order = new FirstLegLoadingPlanCompletedV2.Order("2", "2", "3", "ORD", "3", "RK",
null, null, null, 1, 2, null, null, null, null, List.of(item)); 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)); "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); String json = new ObjectMapper().writeValueAsString(payload);
assertThat(json).contains("customerRemark", "internalRemark", "warehouseRemark"); assertThat(json).contains("customerRemark", "internalRemark", "warehouseRemark");
assertThat(new ObjectMapper().readTree(json).at("/orders/0/items/0/warehouseRemark").asText()) 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 pallet = new FirstLegLoadingPlanCompletedV2.PalletSnapshot("PLT-01", List.of(sku));
var order = new FirstLegLoadingPlanCompletedV2.Order( var order = new FirstLegLoadingPlanCompletedV2.Order(
"2", "2", "3", "ORD", "3", "RK", null, null, null, "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( 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); var json = new ObjectMapper().valueToTree(payload);
assertThat(json.at("/orders/0/customerId").asText()).isEqualTo("11"); 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/orderType").asText()).isEqualTo("ONE_CLICK_DROPSHIP");
assertThat(json.at("/orders/0/pallets/0/skuPackages/0/cartonMarks")).hasSize(2); assertThat(json.at("/orders/0/pallets/0/skuPackages/0/cartonMarks")).hasSize(2);
} }
@@ -44,10 +86,10 @@ class FirstLegLoadingPlanCompletedV2Test {
void carriesFrozenDestinationType() { void carriesFrozenDestinationType() {
var order = new FirstLegLoadingPlanCompletedV2.Order( var order = new FirstLegLoadingPlanCompletedV2.Order(
"2", "2", "3", "ORD", "3", "RK", null, "ONT8", "FBA ONT8", "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( 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"); 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, 20, null, null, null, null, null, null, null, null, null, null, null,
null, null, null, null, List.of("HDHK260711126-01", "HDHK260711126-02")); null, null, null, null, List.of("HDHK260711126-01", "HDHK260711126-02"));
var order = new FirstLegLoadingPlanCompletedV2.Order("2", "2", "3", "ORD", "3", "RK", 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( 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/skuCode").asText()).isEqualTo("SKU-PSC2");
assertThat(json.at("/orders/0/items/0/cartonMarks")).hasSize(2); 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 java.time.Instant;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class OverseasContainerUnloadedV1Test { class OverseasContainerUnloadedV1Test {
private final ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule()); private final ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule());
@Test @Test
void payloadRoundTripsWithStableContainerIdAndUtcFacts() throws Exception { 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); Instant.parse("2026-08-20T03:00:00Z"), Instant.parse("2026-08-20T02:30:00Z"), null);
String json = mapper.writeValueAsString(payload); String json = mapper.writeValueAsString(payload);
assertThat(mapper.readValue(json, OverseasContainerUnloadedV1.class)).isEqualTo(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()).isAfter(payload.unloadingPhotoUploadedAt());
assertThat(payload.unloadingCompletedAt()).isNotEqualTo(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> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId> <artifactId>spring-boot-starter-web</artifactId>
</dependency> </dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-tx</artifactId>
</dependency>
<dependency> <dependency>
<groupId>org.springframework.security</groupId> <groupId>org.springframework.security</groupId>
<artifactId>spring-security-core</artifactId> <artifactId>spring-security-core</artifactId>
@@ -15,6 +15,7 @@ import org.springframework.security.core.AuthenticationException;
import org.springframework.validation.BindException; import org.springframework.validation.BindException;
import org.springframework.validation.FieldError; import org.springframework.validation.FieldError;
import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.dao.CannotAcquireLockException;
import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.MissingRequestHeaderException; import org.springframework.web.bind.MissingRequestHeaderException;
import org.springframework.web.bind.MissingServletRequestParameterException; 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.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.resource.NoResourceFoundException; import org.springframework.web.servlet.resource.NoResourceFoundException;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.multipart.MaxUploadSizeExceededException;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.Map; import java.util.Map;
@@ -96,6 +98,24 @@ public class GlobalExceptionHandler {
return failure(ResultCode.NOT_FOUND, ResultCode.NOT_FOUND.message(), request); 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) @ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<Void>> handleUnexpected(Exception exception, HttpServletRequest request) { public ResponseEntity<ApiResponse<Void>> handleUnexpected(Exception exception, HttpServletRequest request) {
log.error("Unhandled request failure requestId={} traceId={}", 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.test.util.ReflectionTestUtils;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
import org.springframework.dao.CannotAcquireLockException;
import org.springframework.web.multipart.MaxUploadSizeExceededException;
import java.lang.reflect.Method; import java.lang.reflect.Method;
@@ -116,6 +118,32 @@ class GlobalExceptionHandlerTest {
assertThat(response.getBody().code()).isEqualTo(ResultCode.NOT_FOUND.code()); 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) { private void paging(long page) {
} }
} }
+10
View File
@@ -22,6 +22,16 @@ baiyue:
max-attempts: 3 max-attempts: 3
batch-size: 500 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: FINANCE_CUSTOMER_ACCOUNT_LIST:
provider-service: finance-service provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_CUSTOMER_ACCOUNT_LIST/batches provider-path: /internal/exports/v1/FINANCE_CUSTOMER_ACCOUNT_LIST/batches
+4
View File
@@ -4,6 +4,10 @@ spring:
username: ${FIRSTLEG_DB_USERNAME} username: ${FIRSTLEG_DB_USERNAME}
password: ${FIRSTLEG_DB_PASSWORD} password: ${FIRSTLEG_DB_PASSWORD}
driver-class-name: com.mysql.cj.jdbc.Driver 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.warehouse.url: ${WAREHOUSE_SERVICE_URL:}
baiyue.clients.order.url: ${ORDER_SERVICE_URL:} baiyue.clients.order.url: ${ORDER_SERVICE_URL:}
baiyue.clients.file.url: ${FILE_SERVICE_URL:} baiyue.clients.file.url: ${FILE_SERVICE_URL:}
+10
View File
@@ -18,6 +18,16 @@ baiyue:
max-attempts: 3 max-attempts: 3
batch-size: 500 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: FINANCE_CUSTOMER_ACCOUNT_LIST:
provider-service: finance-service provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_CUSTOMER_ACCOUNT_LIST/batches 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} 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} username: ${FIRSTLEG_DB_USERNAME:root}
password: ${FIRSTLEG_DB_PASSWORD:123456} 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.warehouse.url: ${WAREHOUSE_SERVICE_URL:}
baiyue.clients.order.url: ${ORDER_SERVICE_URL:} baiyue.clients.order.url: ${ORDER_SERVICE_URL:}
baiyue.clients.file.url: ${FILE_SERVICE_URL:} baiyue.clients.file.url: ${FILE_SERVICE_URL:}
@@ -1,4 +1,8 @@
spring: spring:
servlet:
multipart:
max-file-size: ${LASTLEG_MULTIPART_MAX_FILE_SIZE:10MB}
max-request-size: ${LASTLEG_MULTIPART_MAX_REQUEST_SIZE:11MB}
datasource: datasource:
url: ${LASTLEG_DB_URL} url: ${LASTLEG_DB_URL}
username: ${LASTLEG_DB_USERNAME} username: ${LASTLEG_DB_USERNAME}
+15 -1
View File
@@ -225,4 +225,18 @@ SELECT
- 固定入口已同步 `iam/src/main/resources/db/lastleg-permissions.sql` 的 `platform:overseas-*` 权限,并默认授予平台系统管理员。 - 固定入口已同步 `iam/src/main/resources/db/lastleg-permissions.sql` 的 `platform:overseas-*` 权限,并默认授予平台系统管理员。
- 旧 `platform:lastleg:*` 权限会撤销角色关联并停用;不保留旧码兼容映射。 - 旧 `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 '部分完成原因', 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 '创建时间', 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 '更新时间', 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) KEY idx_ll_dispatch_order_list(customer_id,status,depart_at), KEY idx_ll_dispatch_order_vehicle(customer_id,vehicle_plate)
) ENGINE=InnoDB COMMENT='尾程派车单'; ) 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 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')); '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; 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 事件冻结目的类型;只补结构,不由部署脚本自动执行历史数据回填。 -- 头程 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'), 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')); '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; 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'), 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', 'SELECT 1',
'ALTER TABLE ll_inbound_plan_line ADD COLUMN customer_id VARCHAR(128) NOT NULL DEFAULT '''' COMMENT ''客户账号标识'' AFTER id')); '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; DROP PROCEDURE assert_pending_orders_do_not_use_source_warehouse;
CREATE TABLE IF NOT EXISTS ll_receiving_container_session ( 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 '仓库服务校验后的海外实际收货共享物理仓名称快照', 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 '未完成会话平台操作账号全局唯一锁键', 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', 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) 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柜号签收收货会话'; ) 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'), 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')); '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; 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 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')); '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; 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'), 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')); '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; 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) PRIMARY KEY(id), KEY idx_ll_order_operation_customer_order(customer_id,order_id,created_at)
) ENGINE=InnoDB COMMENT='尾程订单操作记录'; ) 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; USE baiyue_lastleg;
SET @ddl = (SELECT IF(EXISTS ( SET @ddl = (SELECT IF(EXISTS (
SELECT 1 FROM information_schema.columns 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 持久化、按目标区域向海外尾程或国内头程投递、失败重试、投递记录和人工补投。 - 负责跨区事件接收、Inbox 持久化、按目标区域向海外尾程或国内头程投递、失败重试、投递记录和人工补投。
- 只承载可靠传输,不执行尾程建单逻辑,不查询头程或尾程业务表。 - 只承载可靠传输,不执行尾程建单逻辑,不查询头程或尾程业务表。
- 事件结构和 Payload 哈希以 `common-event-contract` 为准。 - 事件结构和 Payload 哈希以 `common-event-contract` 为准。
@@ -1,6 +1,7 @@
package com.baiyue.eventbridge; package com.baiyue.eventbridge;
import com.baiyue.eventbridge.config.EventDispatcherProperties; import com.baiyue.eventbridge.config.EventDispatcherProperties;
import org.apache.ibatis.annotations.Mapper;
import org.mybatis.spring.annotation.MapperScan; import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; 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 @SpringBootApplication
@EnableScheduling @EnableScheduling
@EnableConfigurationProperties(EventDispatcherProperties.class) @EnableConfigurationProperties(EventDispatcherProperties.class)
@@ -49,6 +49,11 @@ public class EventInboxService {
if (existing != null) { if (existing != null) {
return duplicateOrConflict(existing, envelope); return duplicateOrConflict(existing, envelope);
} }
String idempotencyKey = idempotencyKey(envelope);
BridgeInbox sameBusinessEvent = inboxStore.findByIdempotencyKey(idempotencyKey);
if (sameBusinessEvent != null) {
return duplicateBusinessEventOrConflict(sameBusinessEvent, envelope);
}
BridgeInbox inbox = toInbox(envelope); BridgeInbox inbox = toInbox(envelope);
try { try {
inboxStore.insert(inbox); inboxStore.insert(inbox);
@@ -56,13 +61,25 @@ public class EventInboxService {
return new EventReceiveResult(envelope.eventId(), "已接收", inbox.getReceivedAt()); return new EventReceiveResult(envelope.eventId(), "已接收", inbox.getReceivedAt());
} catch (DuplicateKeyException exception) { } catch (DuplicateKeyException exception) {
BridgeInbox concurrent = inboxStore.findByEventId(envelope.eventId()); BridgeInbox concurrent = inboxStore.findByEventId(envelope.eventId());
if (concurrent == null) concurrent = inboxStore.findByIdempotencyKey(idempotencyKey);
if (concurrent == null) { if (concurrent == null) {
throw exception; 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) { private EventReceiveResult duplicateOrConflict(BridgeInbox existing, EventEnvelope envelope) {
if (!sameEnvelope(existing, envelope)) { if (!sameEnvelope(existing, envelope)) {
metrics.recordReceive(envelope, EventBridgeMetrics.ReceiveResult.CONFLICT); metrics.recordReceive(envelope, EventBridgeMetrics.ReceiveResult.CONFLICT);
@@ -73,6 +90,13 @@ public class EventInboxService {
} }
private boolean sameEnvelope(BridgeInbox existing, EventEnvelope envelope) { 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 return existing.getPayloadHash() != null
&& existing.getPayloadHash().equalsIgnoreCase(envelope.payloadHash()) && existing.getPayloadHash().equalsIgnoreCase(envelope.payloadHash())
&& Objects.equals(existing.getEventType(), envelope.eventType()) && Objects.equals(existing.getEventType(), envelope.eventType())
@@ -84,16 +108,13 @@ public class EventInboxService {
&& Objects.equals(existing.getAggregateId(), envelope.aggregateId()) && Objects.equals(existing.getAggregateId(), envelope.aggregateId())
&& Objects.equals(existing.getAggregateVersion(), envelope.aggregateVersion()) && Objects.equals(existing.getAggregateVersion(), envelope.aggregateVersion())
&& Objects.equals(existing.getHandoverVersion(), envelope.handoverVersion()) && Objects.equals(existing.getHandoverVersion(), envelope.handoverVersion())
&& Objects.equals(normalizeOccurredAt(existing.getOccurredAt()), normalizeOccurredAt(envelope.occurredAt())) && Objects.equals(existing.getCorrelationId(), envelope.correlationId());
&& Objects.equals(existing.getTraceId(), envelope.traceId())
&& Objects.equals(existing.getCorrelationId(), envelope.correlationId())
&& Objects.equals(existing.getCausationId(), envelope.causationId());
} }
private BridgeInbox toInbox(EventEnvelope envelope) { private BridgeInbox toInbox(EventEnvelope envelope) {
BridgeInbox inbox = new BridgeInbox(); BridgeInbox inbox = new BridgeInbox();
inbox.setEventId(envelope.eventId()); inbox.setEventId(envelope.eventId());
inbox.setIdempotencyKey(envelope.sourceRegion() + ":" + envelope.eventId()); inbox.setIdempotencyKey(idempotencyKey(envelope));
inbox.setEventType(envelope.eventType()); inbox.setEventType(envelope.eventType());
inbox.setEventVersion(envelope.eventVersion()); inbox.setEventVersion(envelope.eventVersion());
inbox.setSourceRegion(envelope.sourceRegion().name()); inbox.setSourceRegion(envelope.sourceRegion().name());
@@ -121,4 +142,8 @@ public class EventInboxService {
private static Instant normalizeOccurredAt(Instant value) { private static Instant normalizeOccurredAt(Instant value) {
return value == null ? null : value.truncatedTo(ChronoUnit.MILLIS); 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 { public interface InboxStore {
BridgeInbox findByEventId(String eventId); BridgeInbox findByEventId(String eventId);
BridgeInbox findByIdempotencyKey(String idempotencyKey);
void insert(BridgeInbox inbox); void insert(BridgeInbox inbox);
} }
@@ -4,6 +4,7 @@ import com.baiyue.eventbridge.domain.BridgeInbox;
import com.baiyue.eventbridge.mapper.BridgeInboxMapper; import com.baiyue.eventbridge.mapper.BridgeInboxMapper;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
/** /**
* 基于 MyBatis-Plus 的入站幂等存储。 * 基于 MyBatis-Plus 的入站幂等存储。
@@ -17,6 +18,12 @@ public class MybatisInboxStore implements InboxStore {
return mapper.selectById(eventId); return mapper.selectById(eventId);
} }
@Override
public BridgeInbox findByIdempotencyKey(String idempotencyKey) {
return mapper.selectOne(new LambdaQueryWrapper<BridgeInbox>()
.eq(BridgeInbox::getIdempotencyKey, idempotencyKey));
}
@Override @Override
public void insert(BridgeInbox inbox) { public void insert(BridgeInbox inbox) {
mapper.insert(inbox); mapper.insert(inbox);
@@ -34,7 +34,7 @@ CREATE TABLE IF NOT EXISTS bridge_outbox (
CREATE TABLE IF NOT EXISTS bridge_inbox ( CREATE TABLE IF NOT EXISTS bridge_inbox (
event_id VARCHAR(128) NOT NULL COMMENT '事件全局标识', 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_type VARCHAR(64) NOT NULL COMMENT '事件类型',
event_version INT NOT NULL COMMENT '事件契约版本', event_version INT NOT NULL COMMENT '事件契约版本',
source_region VARCHAR(32) NOT NULL COMMENT '来源区域', source_region VARCHAR(32) NOT NULL COMMENT '来源区域',
@@ -106,6 +106,32 @@ class EventInboxServiceTest {
.hasMessageContaining("事件信封"); .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 @Test
void 并发插入冲突后按既有记录判定为重复() { void 并发插入冲突后按既有记录判定为重复() {
EventEnvelope envelope = withHash(envelope("sha256:placeholder")); 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/`;不跨库读取业务表。 - 资质审核元数据归 `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/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/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/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 精确校验 | | 财务付款凭证、海外卸柜照片 | 通用业务文件入口 `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` | | 存储与实体 | `service/LocalQualificationStorage.java`、`MeasurementImageStorage.java`、`entity/FileObjectEntity.java` |
| 下游 OCR | `client/WeightOcrClient.java` | | 下游 OCR | `client/WeightOcrClient.java` |
| SQL/配置 | `src/main/resources/db/file.sql`、`application.yml` | | SQL/配置 | `src/main/resources/db/file.sql`、`application.yml` |
@@ -23,10 +27,11 @@
## 修改约束 ## 修改约束
- 上传先写真实文件再登记 IAM 元数据;异常结果未知时保留文件供重试,禁止伪造 URL。 - 上传先写真实文件再登记 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、跨客户读取或把内部存储路径返回页面。 - 商品图片平台读取必须同时校验平台身份、`platform:order:list`、客户范围、文件业务类型与正整数 ID;不得提供匿名 URL、跨客户读取或把内部存储路径返回页面。
- 公共导出只接受 `ExportDefinitionRegistry` 明确登记的 `exportType`,由 File 调用所属业务服务的内部 Provider 分批取数并生成文件;业务页面不得直连 Provider。新增或修改导出类型时同步检查业务服务 Provider、File Feign 客户端/响应上限、权限、Admin 下载中心标签与重试映射及契约测试。 - 公共导出只接受 `ExportDefinitionRegistry` 明确登记的 `exportType`,由 File 调用所属业务服务的内部 Provider 分批取数并生成文件;业务页面不得直连 Provider。新增或修改导出类型时同步检查业务服务 Provider、File Feign 客户端/响应上限、权限、Admin 下载中心标签与重试映射及契约测试。
- 生产环境显式配置 `FILE_STORAGE_ROOT`;磁盘故障返回 `S0503`,不得包装成业务错误。 - 生产环境显式配置 `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; 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.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.openfeign.EnableFeignClients; import org.springframework.cloud.openfeign.EnableFeignClients;
@@ -9,6 +11,7 @@ import org.springframework.scheduling.annotation.EnableScheduling;
* 文件中心服务启动入口。 * 文件中心服务启动入口。
*/ */
@SpringBootApplication(scanBasePackages = "com.baiyue") @SpringBootApplication(scanBasePackages = "com.baiyue")
@MapperScan(basePackages = "com.baiyue.file", annotationClass = Mapper.class)
@EnableFeignClients(basePackages = "com.baiyue.file.client") @EnableFeignClients(basePackages = "com.baiyue.file.client")
@EnableScheduling @EnableScheduling
public class FileServiceApplication { public class FileServiceApplication {
@@ -9,6 +9,7 @@ import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Positive; import jakarta.validation.constraints.Positive;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.CacheControl; import org.springframework.http.CacheControl;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
@@ -65,11 +66,17 @@ public class InternalBusinessFileController {
check(token); check(token);
checkTenantId(tenantId); checkTenantId(tenantId);
var value = service.download(tenantId, businessType, businessId, fileId); 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() return ResponseEntity.ok()
.cacheControl(CacheControl.noStore()) .cacheControl(CacheControl.noStore())
.contentType(MediaType.parseMediaType(value.contentType())) .contentType(contentType)
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + name + "\"") .header(HttpHeaders.CONTENT_DISPOSITION,
ContentDisposition.attachment().filename(value.fileName(), StandardCharsets.UTF_8).build().toString())
.contentLength(value.content().length) .contentLength(value.content().length)
.body(value.content()); .body(value.content());
} }
@@ -25,32 +25,32 @@ public class InternalLoadingEvidenceController {
@PostConstruct void initializeToken() { expectedToken = internalToken.getBytes(StandardCharsets.UTF_8); } @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( public ApiResponse<LoadingEvidenceService.UploadResult> upload(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token, @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) { @RequestParam String evidenceType, @RequestPart("file") MultipartFile file) {
requireToken(token); requireToken(token);
if (tenantId <= 0 || inboundId <= 0 || operatorId <= 0) throw new BizException(ResultCode.BAD_REQUEST, "租户、入库单和操作主体ID必须大于0"); if (containerBookingId <= 0 || operatorId <= 0) throw new BizException(ResultCode.BAD_REQUEST, "柜次和操作主体ID必须大于0");
return ApiResponse.success(service.upload(tenantId, inboundId, operatorId, evidenceType, file)); return ApiResponse.success(service.upload(containerBookingId, operatorId, evidenceType, file));
} }
@GetMapping("/{tenantId}/{inboundId}/{fileId}") @GetMapping("/{containerBookingId}/{fileId}")
public ApiResponse<LoadingEvidenceService.FileMetadata> validate( public ApiResponse<LoadingEvidenceService.FileMetadata> validate(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token, @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); requireToken(token);
if (tenantId <= 0 || inboundId <= 0 || fileId <= 0) throw new BizException(ResultCode.BAD_REQUEST, "租户、入库单和文件ID必须大于0"); if (containerBookingId <= 0 || fileId <= 0) throw new BizException(ResultCode.BAD_REQUEST, "柜次和文件ID必须大于0");
return ApiResponse.success(service.validate(tenantId, inboundId, fileId)); return ApiResponse.success(service.validate(containerBookingId, fileId));
} }
@GetMapping("/{tenantId}/{inboundId}/{fileId}/content") @GetMapping("/{containerBookingId}/{fileId}/content")
public ResponseEntity<byte[]> download( public ResponseEntity<byte[]> download(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token, @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); requireToken(token);
if (tenantId <= 0 || inboundId <= 0 || fileId <= 0) throw new BizException(ResultCode.BAD_REQUEST, "租户、入库单和文件ID必须大于0"); if (containerBookingId <= 0 || fileId <= 0) throw new BizException(ResultCode.BAD_REQUEST, "柜次和文件ID必须大于0");
var value = service.download(tenantId, inboundId, fileId); var value = service.download(containerBookingId, fileId);
return ResponseEntity.ok().cacheControl(CacheControl.noStore()) return ResponseEntity.ok().cacheControl(CacheControl.noStore())
.contentType(safeImageMediaType(value.contentType())).contentLength(value.content().length) .contentType(safeImageMediaType(value.contentType())).contentLength(value.content().length)
.header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment() .header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment()
@@ -20,21 +20,21 @@ import java.nio.charset.StandardCharsets;
public class PlatformLoadingEvidenceController { public class PlatformLoadingEvidenceController {
@Resource private LoadingEvidenceService service; @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')") @PreAuthorize("hasAuthority('platform:firstleg:inbound:loading:edit')")
public ApiResponse<LoadingEvidenceService.UploadResult> upload( 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) { @RequestParam String evidenceType, @RequestPart("file") MultipartFile file) {
long operatorId = requirePlatform(user); requirePositive(tenantId, inboundId); long operatorId = requirePlatform(user); requirePositive(containerBookingId);
return ApiResponse.success(service.upload(tenantId, inboundId, operatorId, evidenceType, file)); return ApiResponse.success(service.upload(containerBookingId, operatorId, evidenceType, file));
} }
@GetMapping("/{tenantId}/{inboundId}/{fileId}") @GetMapping("/{containerBookingId}/{fileId}")
@PreAuthorize("hasAuthority('platform:firstleg:inbound:loading:view')") @PreAuthorize("hasAuthority('platform:firstleg:inbound:loading:view')")
public ResponseEntity<byte[]> download(@AuthenticationPrincipal AuthenticatedUser user, public ResponseEntity<byte[]> download(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long tenantId, @PathVariable long inboundId, @PathVariable long fileId) { @PathVariable long containerBookingId, @PathVariable long fileId) {
requirePlatform(user); requirePositive(tenantId, inboundId, fileId); requirePlatform(user); requirePositive(containerBookingId, fileId);
var value = service.download(tenantId, inboundId, fileId); var value = service.download(containerBookingId, fileId);
return ResponseEntity.ok().cacheControl(CacheControl.noStore()) return ResponseEntity.ok().cacheControl(CacheControl.noStore())
.contentType(safeImageMediaType(value.contentType())).contentLength(value.content().length) .contentType(safeImageMediaType(value.contentType())).contentLength(value.content().length)
.header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.inline() .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 com.baiyue.common.core.exception.BizException;
import jakarta.annotation.PostConstruct; import jakarta.annotation.PostConstruct;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
@@ -21,7 +22,6 @@ import java.util.UUID;
@Component @Component
public class BusinessFileStorage { public class BusinessFileStorage {
private static final long MAX_SIZE = 10L * 1024 * 1024;
private static final Map<String, String> TYPES = Map.of( private static final Map<String, String> TYPES = Map.of(
"text/plain", ".txt", "application/pdf", ".pdf", "text/plain", ".txt", "application/pdf", ".pdf",
"image/jpeg", ".jpg", "image/png", ".png"); "image/jpeg", ".jpg", "image/png", ".png");
@@ -35,13 +35,13 @@ public class BusinessFileStorage {
root = configuredRoot.toAbsolutePath().normalize(); 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 == null || file.isEmpty()) throw bad("附件不能为空");
if (file.getSize() > MAX_SIZE) throw bad("附件不能超过10MB"); if (file.getSize() > maxSize) throw bad("附件不能超过" + maxSize / 1024 / 1024 + "MB");
String type = file.getContentType() == null ? "" : file.getContentType().toLowerCase(Locale.ROOT); String type = safeContentType(file.getContentType());
String extension = TYPES.get(type); String extension = arbitraryFormat ? "" : TYPES.get(type);
if (extension == null) throw bad("附件仅支持TXT、PDF、JPG、PNG"); if (!arbitraryFormat && extension == null) throw bad("附件仅支持TXT、PDF、JPG、PNG");
verifyContent(file, type); if (!arbitraryFormat) verifyContent(file, type);
String name = safeName(file); String name = safeName(file);
String objectKey = "business-files/" + tenantId + "/" + businessId + "/" + UUID.randomUUID() + extension; String objectKey = "business-files/" + tenantId + "/" + businessId + "/" + UUID.randomUUID() + extension;
Path target = root.resolve(objectKey).normalize(); 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) { byte[] read(String objectKey) {
Path target = root.resolve(objectKey).normalize(); Path target = root.resolve(objectKey).normalize();
if (!target.startsWith(root)) throw new BizException(ResultCode.FORBIDDEN, "文件路径越界"); if (!target.startsWith(root)) throw new BizException(ResultCode.FORBIDDEN, "文件路径越界");
@@ -91,7 +100,14 @@ public class BusinessFileStorage {
private static String safeName(MultipartFile file) { private static String safeName(MultipartFile file) {
String name = file.getOriginalFilename(); 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 { 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, "缺少该导出类型的操作权限"); throw new BizException(ResultCode.FORBIDDEN, "缺少该导出类型的操作权限");
} }
validateParameters(request.parameters()); validateParameters(request.parameters());
validateTypeParameters(type, request.parameters());
String parametersJson = parametersJson(request.parameters()); String parametersJson = parametersJson(request.parameters());
long id = IdWorker.getId(); long id = IdWorker.getId();
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
@@ -176,6 +177,21 @@ public class ExportTaskService {
throw new BizException(ResultCode.VALIDATION_FAILED, "导出筛选值类型无效"); 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) { private ExportTaskResponse response(FileExportTask task) {
return new ExportTaskResponse(String.valueOf(task.getId()), task.getTaskNo(), task.getExportType(), return new ExportTaskResponse(String.valueOf(task.getId()), task.getTaskNo(), task.getExportType(),
task.getFormat(), task.getTaskName(), task.getStatus(), task.getTotalRows(), 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) { if (batch.schemaVersion() != 1 || blank(batch.schemaHash()) || blank(batch.snapshotToken()) || batch.snapshotToken().length() > 512) {
throw ExportFailure.permanent("Provider 首批契约无效"); 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); sheet = newSheet(workbook, 1, columns);
} else if (!schemaHash.equals(batch.schemaHash())) { } else if (!schemaHash.equals(batch.schemaHash())) {
throw ExportFailure.permanent("Provider 导出列定义在执行期间发生变化"); throw ExportFailure.permanent("Provider 导出列定义在执行期间发生变化");
@@ -10,23 +10,8 @@ import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.Set;
@Service @Service
public class InternalBusinessFileService { 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 FileObjectMapper files;
@Resource private BusinessFileStorage storage; @Resource private BusinessFileStorage storage;
@@ -35,9 +20,10 @@ public class InternalBusinessFileService {
if (tenantId < 0 || operatorId <= 0) { if (tenantId < 0 || operatorId <= 0) {
throw new BizException(ResultCode.BAD_REQUEST, "归属和操作人必须有效"); throw new BizException(ResultCode.BAD_REQUEST, "归属和操作人必须有效");
} }
validateBusinessType(businessType); BusinessFileCategory category = BusinessFileCategory.require(businessType);
category.validateOwner(tenantId);
long parsedBusinessId = parseBusinessId(businessId); 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(); FileObjectEntity value = new FileObjectEntity();
value.setTenantId(tenantId); value.setTenantId(tenantId);
value.setBusinessType(businessType); value.setBusinessType(businessType);
@@ -90,16 +76,11 @@ public class InternalBusinessFileService {
if (tenantId < 0 || fileId <= 0) { if (tenantId < 0 || fileId <= 0) {
throw new BizException(ResultCode.BAD_REQUEST, "归属和文件ID必须有效"); throw new BizException(ResultCode.BAD_REQUEST, "归属和文件ID必须有效");
} }
validateBusinessType(businessType); BusinessFileCategory category = BusinessFileCategory.require(businessType);
category.validateOwner(tenantId);
parseBusinessId(businessId); 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) { private static long parseBusinessId(String businessId) {
final long value; final long value;
try { try {
@@ -112,11 +93,12 @@ public class InternalBusinessFileService {
} }
private static Metadata metadata(FileObjectEntity value) { 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()); 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) {} String fileName, String sha256, String contentType, Long sizeBytes) {}
public record Download(String fileName, String contentType, byte[] content) {} 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 org.springframework.web.multipart.MultipartFile;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.Set;
/** 装柜四步照片和铅封证据的受控文件入口。 */ /** 装柜四步照片和铅封证据的受控文件入口。 */
@Service @Service
public class LoadingEvidenceService { 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 MeasurementImageStorage storage;
@Resource private FileObjectMapper files; @Resource private FileObjectMapper files;
@Transactional @Transactional
public UploadResult upload(long tenantId, long inboundId, long operatorId, String evidenceType, public UploadResult upload(long containerBookingId, long operatorId, String evidenceType,
MultipartFile file) { MultipartFile file) {
validateType(evidenceType); validateType(evidenceType);
var stored = storage.storeLoading(tenantId, inboundId, file); var stored = storage.storeLoading(PLATFORM_TENANT_ID, containerBookingId, file);
FileObjectEntity object = new FileObjectEntity(); FileObjectEntity object = new FileObjectEntity();
object.setTenantId(tenantId); object.setTenantId(PLATFORM_TENANT_ID);
object.setBusinessType("LOADING_" + evidenceType); object.setBusinessType("LOADING_" + evidenceType);
object.setBusinessId(Long.toString(inboundId)); object.setBusinessId(Long.toString(containerBookingId));
object.setOriginalName(stored.originalName()); object.setOriginalName(stored.originalName());
object.setObjectKey(stored.objectKey()); object.setObjectKey(stored.objectKey());
object.setStorageProvider("LOCAL"); object.setStorageProvider("LOCAL");
@@ -39,24 +45,28 @@ public class LoadingEvidenceService {
object.setCreatedAt(LocalDateTime.now()); object.setUpdatedAt(LocalDateTime.now()); object.setCreatedAt(LocalDateTime.now()); object.setUpdatedAt(LocalDateTime.now());
object.setDeleted(false); object.setVersion(0); object.setDeleted(false); object.setVersion(0);
files.insert(object); 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) { public FileMetadata validate(long containerBookingId, long fileId) {
FileObjectEntity object = requireObject(tenantId, inboundId, fileId); FileObjectEntity object = requireObject(containerBookingId, fileId);
return new FileMetadata(object.getId(), object.getOriginalName(), object.getBusinessType(), object.getContentType()); 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) { public Download download(long containerBookingId, long fileId) {
FileObjectEntity object = requireObject(tenantId, inboundId, fileId); FileObjectEntity object = requireObject(containerBookingId, fileId);
return new Download(object.getOriginalName(), object.getContentType(), storage.read(object.getObjectKey())); 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>() FileObjectEntity object = files.selectOne(new LambdaQueryWrapper<FileObjectEntity>()
.eq(FileObjectEntity::getTenantId, tenantId).eq(FileObjectEntity::getId, fileId) .eq(FileObjectEntity::getTenantId, PLATFORM_TENANT_ID).eq(FileObjectEntity::getId, fileId)
.eq(FileObjectEntity::getBusinessId, Long.toString(inboundId)) .eq(FileObjectEntity::getBusinessId, Long.toString(containerBookingId))
.likeRight(FileObjectEntity::getBusinessType, "LOADING_") .in(FileObjectEntity::getBusinessType, BUSINESS_TYPES)
.eq(FileObjectEntity::getStatus, "ACTIVE")
.eq(FileObjectEntity::getDeleted, false)); .eq(FileObjectEntity::getDeleted, false));
if (object == null) throw new BizException(ResultCode.NOT_FOUND, "装柜证据文件不存在"); if (object == null) throw new BizException(ResultCode.NOT_FOUND, "装柜证据文件不存在");
return object; return object;
@@ -68,7 +78,9 @@ public class LoadingEvidenceService {
} }
} }
public record UploadResult(Long fileId, String fileName, String contentType, Long sizeBytes, String businessType) {} public record UploadResult(Long fileId, String ownerScope, Long tenantId, String businessType,
public record FileMetadata(Long fileId, String fileName, String businessType, String contentType) {} 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) {} public record Download(String fileName, String contentType, byte[] content) {}
} }
+11 -2
View File
@@ -40,8 +40,8 @@ spring:
read-timeout: 15000 read-timeout: 15000
servlet: servlet:
multipart: multipart:
max-file-size: 10MB max-file-size: 50MB
max-request-size: 11MB max-request-size: 51MB
rocketmq: rocketmq:
name-server: ${ROCKETMQ_NAME_SERVER:127.0.0.1:9876} name-server: ${ROCKETMQ_NAME_SERVER:127.0.0.1:9876}
producer: producer:
@@ -101,6 +101,15 @@ baiyue:
max-rows: 200000 max-rows: 200000
max-attempts: 3 max-attempts: 3
batch-size: 500 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: FINANCE_CUSTOMER_ACCOUNT_LIST:
provider-service: finance-service provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_CUSTOMER_ACCOUNT_LIST/batches 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 ( CREATE TABLE IF NOT EXISTS file_object (
id BIGINT UNSIGNED NOT NULL COMMENT '文件ID(雪花ID)', 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_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 '原始文件名', original_name VARCHAR(255) NOT NULL COMMENT '原始文件名',
object_key VARCHAR(512) NOT NULL COMMENT '对象存储键', object_key VARCHAR(512) NOT NULL COMMENT '对象存储键',
storage_provider VARCHAR(32) 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() { @Test void zeroTenantUploadAndMetadataRemainBehindInternalToken() {
var file = new MockMultipartFile("file", "clearance.pdf", "application/pdf", "%PDF-1.4".getBytes()); 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()); "41", "clearance.pdf", "hash", "application/pdf", file.getSize());
when(service.upload(0L, "CONTAINER_CLEARANCE_DOCUMENT", "41", 7L, file)).thenReturn(metadata); when(service.upload(0L, "CONTAINER_CLEARANCE_DOCUMENT", "41", 7L, file)).thenReturn(metadata);
when(service.metadata(0L, "CONTAINER_CLEARANCE_DOCUMENT", "41", 81L)).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); 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() { @Test void rejectsInvalidTokenAndNegativeTenantBeforeServiceAccess() {
var file = new MockMultipartFile("file", "x.txt", "text/plain", "x".getBytes()); var file = new MockMultipartFile("file", "x.txt", "text/plain", "x".getBytes());
assertThatThrownBy(() -> controller.upload("wrong", 7L, 0L, assertThatThrownBy(() -> controller.upload("wrong", 7L, 0L,
@@ -53,6 +81,9 @@ class InternalBusinessFileControllerTest {
assertThatThrownBy(() -> controller.metadata("internal-token", -1L, assertThatThrownBy(() -> controller.metadata("internal-token", -1L,
"CONTAINER_CLEARANCE_DOCUMENT", "41", 81L)) "CONTAINER_CLEARANCE_DOCUMENT", "41", 81L))
.isInstanceOf(BizException.class).hasMessageContaining("不能为负数"); .isInstanceOf(BizException.class).hasMessageContaining("不能为负数");
assertThatThrownBy(() -> controller.download("wrong", 10001L,
"OCEAN_BOOKING_DOCUMENT", "61", 81L))
.isInstanceOf(BizException.class).hasMessageContaining("令牌无效");
verifyNoInteractions(service); verifyNoInteractions(service);
} }
} }
@@ -21,32 +21,32 @@ class InternalLoadingEvidenceControllerTest {
@Resource InternalLoadingEvidenceController controller; @Resource InternalLoadingEvidenceController controller;
@Test void downloadsOwnedEvidenceBehindInternalToken() { @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})); 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.getBody()).containsExactly(1, 2, 3);
assertThat(response.getHeaders().getContentType().toString()).isEqualTo("image/jpeg"); assertThat(response.getHeaders().getContentType().toString()).isEqualTo("image/jpeg");
assertThat(response.getHeaders().getCacheControl()).contains("no-store"); assertThat(response.getHeaders().getCacheControl()).contains("no-store");
verify(service).download(81L, 91L, 101L); verify(service).download(91L, 101L);
} }
@Test void rejectsInvalidTokenBeforeReadingEvidence() { @Test void rejectsInvalidTokenBeforeReadingEvidence() {
assertThatThrownBy(() -> controller.download("wrong", 81L, 91L, 101L)) assertThatThrownBy(() -> controller.download("wrong", 91L, 101L))
.isInstanceOf(BizException.class).hasMessageContaining("凭证无效"); .isInstanceOf(BizException.class).hasMessageContaining("凭证无效");
verifyNoInteractions(service); verifyNoInteractions(service);
} }
@Test void fallsBackForInvalidOrNonImageContentTypes() { @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})); 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})); 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); .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); .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()); .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) { private AuthenticatedUser user(long id, Set<String> permissions) {
return new AuthenticatedUser(id, 0L, 0L, id, "user" + id, "PLATFORM", "WEB", 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 @Configuration
static class TestConfig { static class TestConfig {
@Bean ObjectMapper objectMapper() { return new ObjectMapper(); } @Bean ObjectMapper objectMapper() { return new ObjectMapper(); }
@@ -8,6 +8,10 @@ import org.junit.jupiter.api.Test;
import org.mockito.*; import org.mockito.*;
import org.springframework.mock.web.MockMultipartFile; 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.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*; import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*; import static org.mockito.Mockito.*;
@@ -21,7 +25,7 @@ class InternalBusinessFileServiceTest {
@Test void customsDocumentUploadPersistsExactBusinessOwnership() { @Test void customsDocumentUploadPersistsExactBusinessOwnership() {
MockMultipartFile file = new MockMultipartFile("file", "invoice.txt", "text/plain", "invoice".getBytes()); 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")); "business-files/10001/41/key.txt", "text/plain", 7, "a".repeat(64), "invoice.txt"));
when(files.insert(any(FileObjectEntity.class))).thenAnswer(invocation -> { when(files.insert(any(FileObjectEntity.class))).thenAnswer(invocation -> {
((FileObjectEntity) invocation.getArgument(0)).setId(71L); return 1; ((FileObjectEntity) invocation.getArgument(0)).setId(71L); return 1;
@@ -37,10 +41,10 @@ class InternalBusinessFileServiceTest {
@Test void platformContainerAndLoadingDocumentsSupportZeroTenantScope() { @Test void platformContainerAndLoadingDocumentsSupportZeroTenantScope() {
MockMultipartFile file = new MockMultipartFile("file", "customs.pdf", "application/pdf", "%PDF-1.4".getBytes()); 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", new BusinessFileStorage.Stored("business-files/0/" + invocation.getArgument(1) + "/customs.pdf",
"application/pdf", file.getSize(), "b".repeat(64), "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")); "business-files/11/51/unload.jpg", "image/jpeg", file.getSize(), "c".repeat(64), "unload.jpg"));
when(files.insert(any(FileObjectEntity.class))).thenAnswer(invocation -> { when(files.insert(any(FileObjectEntity.class))).thenAnswer(invocation -> {
((FileObjectEntity) invocation.getArgument(0)).setId(81L); return 1; ((FileObjectEntity) invocation.getArgument(0)).setId(81L); return 1;
@@ -64,9 +68,49 @@ class InternalBusinessFileServiceTest {
InternalBusinessFileService.Metadata::businessType, InternalBusinessFileService.Metadata::businessType,
InternalBusinessFileService.Metadata::businessId) InternalBusinessFileService.Metadata::businessId)
.containsExactly(11L, "OVERSEAS_UNLOAD_PHOTO", "51"); .containsExactly(11L, "OVERSEAS_UNLOAD_PHOTO", "51");
verify(storage, times(3)).storeLoading(0L, 41L, file); verify(storage, times(3)).store(0L, 41L, file, BusinessFileCategory.STANDARD_MAX_SIZE, false);
verify(storage).storeLoading(0L, 51L, file); verify(storage).store(0L, 51L, file, BusinessFileCategory.STANDARD_MAX_SIZE, false);
verify(storage).storeLoading(11L, 51L, file); 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() { @Test void rejectsNegativeTenantAndInvalidOwnershipInputs() {
@@ -82,7 +126,7 @@ class InternalBusinessFileServiceTest {
@Test void overseasRelabelPhotoPersistsExactTaskOwnership() { @Test void overseasRelabelPhotoPersistsExactTaskOwnership() {
MockMultipartFile file = new MockMultipartFile("file", "relabel.jpg", "image/jpeg", "photo".getBytes()); 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")); "business-files/100/81/relabel.jpg", "image/jpeg", file.getSize(), "d".repeat(64), "relabel.jpg"));
when(files.insert(any(FileObjectEntity.class))).thenAnswer(invocation -> { when(files.insert(any(FileObjectEntity.class))).thenAnswer(invocation -> {
((FileObjectEntity) invocation.getArgument(0)).setId(91L); return 1; ((FileObjectEntity) invocation.getArgument(0)).setId(91L); return 1;
@@ -96,6 +140,36 @@ class InternalBusinessFileServiceTest {
.containsExactly(100L, "OVERSEAS_RELABEL_PHOTO", "81"); .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() { @Test void unsupportedBusinessTypeFailsClosed() {
assertThatThrownBy(() -> service.upload(10001L, "ARBITRARY", "41", 7L, assertThatThrownBy(() -> service.upload(10001L, "ARBITRARY", "41", 7L,
new MockMultipartFile("file", "a.txt", "text/plain", "x".getBytes()))) new MockMultipartFile("file", "a.txt", "text/plain", "x".getBytes())))
@@ -138,4 +212,34 @@ class InternalBusinessFileServiceTest {
verify(storage).read(value.getObjectKey()); 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.ArgumentMatchers.any;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import org.mockito.ArgumentCaptor;
@ExtendWith(MockitoExtension.class) @ExtendWith(MockitoExtension.class)
class LoadingEvidenceServiceTest { class LoadingEvidenceServiceTest {
@@ -28,24 +29,42 @@ class LoadingEvidenceServiceTest {
@Test @Test
void uploadsEvidenceWithDedicatedBusinessType() { void uploadsEvidenceWithDedicatedBusinessType() {
var multipart = new MockMultipartFile("file", "step.jpg", "image/jpeg", new byte[]{1}); var multipart = new MockMultipartFile("file", "step.jpg", "image/jpeg", new byte[]{1});
when(storage.storeLoading(10, 20, multipart)).thenReturn(new MeasurementImageStorage.Stored( when(storage.storeLoading(0, 20, multipart)).thenReturn(new MeasurementImageStorage.Stored(
"loading-evidence/10/20/a.jpg", Path.of("a.jpg"), "image/jpeg", 1, "hash", "step.jpg")); "loading-evidence/0/20/a.jpg", Path.of("a.jpg"), "image/jpeg", 1, "hash", "step.jpg"));
var result = service.upload(10, 20, 7, "PHOTO_STEP_2", multipart); var result = service.upload(20, 7, "PHOTO_STEP_2", multipart);
assertThat(result.businessType()).isEqualTo("LOADING_PHOTO_STEP_2"); 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 @Test
void rejectsUnknownEvidenceType() { void rejectsUnknownEvidenceType() {
var multipart = new MockMultipartFile("file", "x.jpg", "image/jpeg", new byte[]{1}); 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 @Test
void validatesTenantBoundFile() { void validatesTenantBoundFile() {
FileObjectEntity object = new FileObjectEntity(); 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); 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}") @GetMapping("/customer-accounts/{customerId}")
@PreAuthorize("hasAuthority('platform:finance:customer-account:list')") @PreAuthorize("hasAuthority('platform:finance:customer-account:list')")
@Operation(summary = "客户人工应收账户及流水") @Operation(summary = "客户正式应收账户及收款流水")
public ApiResponse<?> customerAccount(@PathVariable long customerId) { public ApiResponse<?> customerAccount(@PathVariable long customerId) {
return FinanceResponses.success(queryService.customerAccount(customerId)); return FinanceResponses.success(queryService.customerAccount(customerId));
} }
@@ -61,6 +61,15 @@ public class PlatformFinanceController {
@GetMapping("/fee-pool") @GetMapping("/fee-pool")
@PreAuthorize("hasAuthority('platform:finance:customer-arap:list')") @PreAuthorize("hasAuthority('platform:finance:customer-arap:list')")
@Operation(summary = "客户应收应付费用池") @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( public ApiResponse<?> feePool(
@Parameter(description = "可选客户筛选;省略时查询全部客户") @Parameter(description = "可选客户筛选;省略时查询全部客户")
@RequestParam(required = false) Long customerId, @RequestParam(required = false) Long customerId,
@@ -62,17 +62,17 @@ public class FinanceCustomerAccountService {
String placeholders = String.join(",", customerIds.stream().map(id -> "?").toList()); String placeholders = String.join(",", customerIds.stream().map(id -> "?").toList());
Map<Long, MutableMetrics> result = new HashMap<>(); Map<Long, MutableMetrics> result = new HashMap<>();
customerIds.forEach(id -> result.put(id, new MutableMetrics())); 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")); .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")); .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()) 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")); .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")); .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")); .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")); .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)) 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")); .forEach(row -> metric(result, row, "lastChangedAt", "last_changed_at"));
@@ -59,14 +59,15 @@ public class FinanceExportProviderService {
RECEIVABLE_STATISTICS); RECEIVABLE_STATISTICS);
private static final Set<String> SETTLEMENT_STATUSES = Set.of("ISSUED", "PARTIALLY_PAID", "PAID", "VOIDED"); 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", 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( private static final List<ExportColumn> ACCOUNT_COLUMNS = List.of(
column("customerCode", "客户编码", ExportColumnType.STRING, 18), column("customerName", "客户", ExportColumnType.STRING, 28), column("customerCode", "客户编码", ExportColumnType.STRING, 18), column("customerName", "客户", ExportColumnType.STRING, 28),
column("contactName", "联系人", ExportColumnType.STRING, 16), column("contactPhone", "联系电话", ExportColumnType.STRING, 18), 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("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)); column("status", "客户状态", ExportColumnType.STRING, 14), column("lastChangedAt", "最近变动", ExportColumnType.DATETIME, 21));
private static final List<ExportColumn> LEDGER_COLUMNS = List.of( private static final List<ExportColumn> LEDGER_COLUMNS = List.of(
column("ledgerId", "流水号", ExportColumnType.STRING, 22), column("occurredAt", "发生时间", ExportColumnType.DATETIME, 21), column("ledgerId", "流水号", ExportColumnType.STRING, 22), column("occurredAt", "发生时间", ExportColumnType.DATETIME, 21),
@@ -72,6 +72,7 @@ public class FinanceFeeSourceEventService {
} }
long id = ((Number) row.get("id")).longValue(); long id = ((Number) row.get("id")).longValue();
if ("FIRSTLEG_FEE_VOIDED".equals(event.eventType())) { 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); jdbc.update("UPDATE fin_fee_source SET status='VOIDED',operation_version=operation_version+1 WHERE id=? AND status<>'VOIDED'", id);
voidDirectPaidCost(snapshot); voidDirectPaidCost(snapshot);
} }
@@ -80,6 +81,7 @@ public class FinanceFeeSourceEventService {
if ("FIRSTLEG_FEE_VOIDED".equals(event.eventType())) { if ("FIRSTLEG_FEE_VOIDED".equals(event.eventType())) {
throw error("FIN-EVT-005", "作废事件对应的费用来源版本不存在"); 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'", jdbc.update("UPDATE fin_fee_source SET status='SUPERSEDED' WHERE source_system='FIRSTLEG' AND source_fee_id=? AND status='AVAILABLE'",
snapshot.feeId()); 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'", 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; 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) { private void materializeDirectPaidCost(FirstlegFeeSourceClient.Snapshot snapshot) {
String costNo = "DIRECT-" + snapshot.sourceType() + "-" + snapshot.feeId() + "-V" + snapshot.feeVersion(); 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)", 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; 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); 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," + 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(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.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(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(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(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.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(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') outstanding_payable," + "((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 ?"; "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); Object[] args = java.util.Arrays.copyOf(filterArgs, filterArgs.length + 2);
args[args.length - 2] = pageSize; args[args.length - 1] = (page - 1) * pageSize; args[args.length - 2] = pageSize; args[args.length - 1] = (page - 1) * pageSize;
@@ -89,14 +89,15 @@ public class FinancePhase1OperationService {
List<RawLedger> rows = new ArrayList<>(); 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,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,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)); rows.sort(Comparator.comparing(RawLedger::occurredAt).thenComparing(RawLedger::id));
BigDecimal outstanding = BigDecimal.ZERO; BigDecimal outstanding = BigDecimal.ZERO;
List<LedgerEntry> projected = new ArrayList<>(); List<LedgerEntry> projected = new ArrayList<>();
for (RawLedger row : rows) { for (RawLedger row : rows) {
BigDecimal receivableChange = row.receivable(); BigDecimal receivableChange = row.receivable();
outstanding = outstanding.add(receivableChange); 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()); 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); 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", "开始日期不能晚于结束日期"); throw error("FIN-REQ-400", "开始日期不能晚于结束日期");
} }
String normalizedDirection = normalized(direction, List.of("RECEIVABLE", "PAYABLE"), "费用方向无效"); 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, String normalizedSourceStatus = normalized(sourceStatus,
List.of("AVAILABLE", "LOCKED", "BILLED", "VOIDED", "SUPERSEDED"), "费用来源状态无效"); 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)"; 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 { 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"); 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; 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, 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")); 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.StatementDetail;
import com.baiyue.finance.dto.FinanceReadModels.StatementLine; import com.baiyue.finance.dto.FinanceReadModels.StatementLine;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.BadSqlGrammarException;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
@@ -49,12 +48,12 @@ public class FinanceQueryService {
public CustomerAccount customerAccount(long customerId) { public CustomerAccount customerAccount(long customerId) {
requireCustomer(customerId); requireCustomer(customerId);
BigDecimal total = amount("SELECT COALESCE(SUM(total_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(received_amount),0) FROM fin_bill WHERE tenant_id=? 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_bill WHERE tenant_id=? 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<>(); 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) -> 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("bill_no"), time(rs, "created_at"), 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)); 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) -> 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"), 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.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.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='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' " + "(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.currency_code='CNY' AND f.status='AVAILABLE' " +
"AND f.review_status='APPROVED' AND f.amount>0 " + "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 " + "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," + "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(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.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')) total_cost," +
"((SELECT COALESCE(SUM(c.cost_amount),0) FROM fin_cost c WHERE c.tenant_id=? AND c.status='CONFIRMED')+" + "((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')+" +
"COALESCE(SUM(CASE WHEN s.direction='PAYABLE' AND s.status<>'VOIDED' THEN s.paid_amount ELSE 0 END),0)) paid_cost," + "(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')," + "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(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 " + "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=?"; "FROM fin_settlement s WHERE s.tenant_id=?";
try { return jdbc.queryForObject(sql, (rs, rowNum) -> new CustomerAccountSummary(customerId, CNY,
return jdbc.queryForObject(sql, (rs, rowNum) -> new CustomerAccountSummary(customerId, CNY, money(rs, "total_receivable"), money(rs, "received_amount"), money(rs, "outstanding_receivable"),
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, "pending_billing"), money(rs, "total_cost"), money(rs, "paid_cost"), money(rs, "outstanding_payable"), Instant.now(), time(rs, "last_changed_at")),
money(rs, "outstanding_payable"), Instant.now(), time(rs, "last_changed_at")), customerId, customerId, customerId, customerId, customerId, customerId, customerId,
customerId, customerId, customerId, 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);
}
} }
public List<FeePoolItem> feePool(Long customerId, String direction) { 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); validateOptionalCustomer(customerId);
if (supplierId != null && supplierId <= 0) throw bad("供应商范围无效");
String normalized = direction == null || direction.isBlank() ? "ALL" : direction.trim().toUpperCase(java.util.Locale.ROOT); 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"); if (!List.of("ALL", "RECEIVABLE", "PAYABLE").contains(normalized)) throw bad("费用方向只能是RECEIVABLE或PAYABLE");
List<FeePoolItem> result = new ArrayList<>(); List<FeePoolItem> result = new ArrayList<>();
@@ -107,6 +106,14 @@ public class FinanceQueryService {
List<Object> sourceArgs = new ArrayList<>(); List<Object> sourceArgs = new ArrayList<>();
if (customerId != null) { sourceSql += " AND customer_id=?"; sourceArgs.add(customerId); } if (customerId != null) { sourceSql += " AND customer_id=?"; sourceArgs.add(customerId); }
if (!"ALL".equals(normalized)) { sourceSql += " AND direction=?"; sourceArgs.add(normalized); } 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"; sourceSql += " ORDER BY created_at DESC,id DESC";
result.addAll(jdbc.query(sourceSql, (rs, rowNum) -> { result.addAll(jdbc.query(sourceSql, (rs, rowNum) -> {
String rowDirection = rs.getString("direction"); String rowDirection = rs.getString("direction");
+6 -3
View File
@@ -34,6 +34,7 @@ CREATE TABLE IF NOT EXISTS fin_fee_source (
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY uk_fin_fee_source (source_system, source_fee_id, source_fee_version), 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 (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_currency CHECK (currency_code = 'CNY'),
CONSTRAINT chk_fin_fee_source_amount CHECK (amount >= 0), CONSTRAINT chk_fin_fee_source_amount CHECK (amount >= 0),
CONSTRAINT chk_fin_fee_source_settlement CHECK ( 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_code VARCHAR(64) NOT NULL COMMENT '成本费用项目编码快照',
fee_item_name VARCHAR(200) NOT NULL COMMENT '成本费用项目名称快照', fee_item_name VARCHAR(200) NOT NULL COMMENT '成本费用项目名称快照',
currency_code CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217三位币种代码,首期仅CNY', 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', voucher_file_ids JSON NOT NULL COMMENT 'File服务财务凭证文件ID字符串数组JSON',
paid_at DATETIME(3) NULL COMMENT '直接已付时间;人工补充成本可为空', paid_at DATETIME(3) NULL COMMENT '直接已付时间;人工补充成本可为空',
payment_method VARCHAR(32) NULL COMMENT '直接已付方式:ONLINE、BANK_TRANSFER、CASH、OTHER', 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), 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_ticket (tenant_id, order_id, inbound_id),
KEY idx_fin_cost_container (tenant_id, container_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_tenant CHECK (tenant_id > 0),
CONSTRAINT chk_fin_cost_currency CHECK (currency_code = 'CNY'), CONSTRAINT chk_fin_cost_currency CHECK (currency_code = 'CNY'),
CONSTRAINT chk_fin_cost_amount CHECK (cost_amount > 0), 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 '结算业务期间快照', business_period VARCHAR(32) NULL COMMENT '结算业务期间快照',
currency_code CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217三位币种代码,首期仅CNY', currency_code CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217三位币种代码,首期仅CNY',
total_amount DECIMAL(20,6) NOT NULL COMMENT '正式结算总金额,精度6位小数', total_amount DECIMAL(20,6) NOT NULL COMMENT '正式结算总金额,精度6位小数',
paid_amount DECIMAL(20,6) NOT NULL DEFAULT 0 COMMENT '累计已收或已付金额,精度6位小数', paid_amount DECIMAL(20,6) NOT NULL DEFAULT 0 COMMENT '累计已收或已付金额;PAYABLE已付计入已线下支付供应商款',
outstanding_amount DECIMAL(20,6) NOT NULL COMMENT '未结清金额,精度6位小数', outstanding_amount DECIMAL(20,6) NOT NULL COMMENT '未结清金额;RECEIVABLE计待收,PAYABLE与未制单ON_ACCOUNT来源共同计待付',
generated_date DATE NOT NULL COMMENT '结算单生成日期', generated_date DATE NOT NULL COMMENT '结算单生成日期',
due_date DATE NOT NULL COMMENT '结算到期日期', due_date DATE NOT NULL COMMENT '结算到期日期',
payment_status VARCHAR(24) NOT NULL DEFAULT 'UNPAID' COMMENT '收付款状态:UNPAID未收付、PARTIALLY_PAID部分收付、PAID已结清', 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), PRIMARY KEY (id),
UNIQUE KEY uk_fin_settlement_payment_no (tenant_id, transaction_no), 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_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_tenant CHECK (tenant_id > 0),
CONSTRAINT chk_fin_settlement_payment_currency CHECK (currency_code = 'CNY'), CONSTRAINT chk_fin_settlement_payment_currency CHECK (currency_code = 'CNY'),
CONSTRAINT chk_fin_settlement_payment_amount CHECK (amount > 0) CONSTRAINT chk_fin_settlement_payment_amount CHECK (amount > 0)
@@ -16,8 +16,10 @@ import java.math.BigDecimal;
import java.sql.ResultSet; import java.sql.ResultSet;
import java.sql.Timestamp; import java.sql.Timestamp;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat; 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.any;
import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
@@ -27,11 +29,36 @@ import org.springframework.jdbc.BadSqlGrammarException;
class CustomerAccountSummaryBehaviorTest { 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 @Test
void aggregatesFormalSettlementsInOneCustomerScopedQuery() throws Exception { void aggregatesFormalSettlementsInOneCustomerScopedQuery() throws Exception {
JdbcTemplate jdbc = mock(JdbcTemplate.class); JdbcTemplate jdbc = mock(JdbcTemplate.class);
ResultSet rs = summaryRow("300.00", "140.00", "160.00", "25.00", "260.00", "180.00", "80.00"); 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)); ((RowMapper<?>) invocation.getArgument(1)).mapRow(rs, 0));
FinanceQueryService service = new FinanceQueryService(jdbc, new ObjectMapper()); 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), 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()) assertThat(sql.getValue())
.contains("FROM fin_settlement s WHERE s.tenant_id=?") .contains("FROM fin_settlement s WHERE s.tenant_id=?")
.contains("s.direction='RECEIVABLE' AND s.status<>'VOIDED'") .contains("s.direction='RECEIVABLE' AND s.status<>'VOIDED'")
.contains("s.direction='PAYABLE' AND s.status<>'VOIDED'") .contains("os.direction='PAYABLE' AND os.status<>'VOIDED'")
.contains("f.settlement_mode='ON_ACCOUNT'", "c.status='CONFIRMED'", "s.paid_amount") .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("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("JSON_EXTRACT")
.doesNotContain("SUM(received_amount)", "FROM fin_receipt r"); .doesNotContain("SUM(received_amount)", "FROM fin_receipt r");
} }
@@ -68,7 +96,7 @@ class CustomerAccountSummaryBehaviorTest {
void emptyCustomerReturnsZeroAmounts() throws Exception { void emptyCustomerReturnsZeroAmounts() throws Exception {
JdbcTemplate jdbc = mock(JdbcTemplate.class); JdbcTemplate jdbc = mock(JdbcTemplate.class);
ResultSet rs = summaryRow("0", "0", "0", "0", "0", "0", "0"); 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)); ((RowMapper<?>) invocation.getArgument(1)).mapRow(rs, 0));
CustomerAccountSummary result = new FinanceQueryService(jdbc, new ObjectMapper()).customerAccountSummary(77L); CustomerAccountSummary result = new FinanceQueryService(jdbc, new ObjectMapper()).customerAccountSummary(77L);
@@ -83,23 +111,13 @@ class CustomerAccountSummaryBehaviorTest {
} }
@Test @Test
void missingSettlementProjectionReturnsZeroCnyBaseline() { void missingSettlementProjectionFailsClosedInsteadOfReturningFakeZeroAmounts() {
JdbcTemplate jdbc = mock(JdbcTemplate.class); 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)); .thenThrow(new BadSqlGrammarException("customer summary", "SELECT ...", null));
CustomerAccountSummary result = new FinanceQueryService(jdbc, new ObjectMapper()).customerAccountSummary(88L); assertThatThrownBy(() -> new FinanceQueryService(jdbc, new ObjectMapper()).customerAccountSummary(88L))
.isInstanceOf(BadSqlGrammarException.class);
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();
} }
@Test @Test
@@ -16,6 +16,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
@@ -42,6 +43,29 @@ class FinanceCustomerAccountServiceTest {
verify(iam).page("internal-token", 1, 20, "Alice", "ACTIVE", 900000000000000001L); 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 @Test
void rejectsUnsupportedFinanceAccountStatus() { void rejectsUnsupportedFinanceAccountStatus() {
var service = new FinanceCustomerAccountService(mock(JdbcTemplate.class), mock(IamCustomerClient.class), "token"); 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:") assertThat(openApi).contains("/internal/exports/v1/{exportType}/batches:")
.contains("customerId?, status?, settlementNo?") .contains("customerId?, status?, settlementNo?")
.contains("所有 DETAIL 仅 settlementId") .contains("所有 DETAIL 仅 settlementId")
.contains("仅三类运营资金:待收客户款、待付供应商款、已线下支付供应商款")
.contains("operatingCards: [outstandingReceivable, outstandingPayable, paidCost]")
.contains("辅助字段只允许用于列表、明细或历史说明,不得生成第四类运营资金卡片")
.contains("AP outstanding加未制单有效ON_ACCOUNT来源并去重")
.contains("已线下支付供应商款;不存在客户线下支付含义")
.doesNotContain("DETAIL: settlementId, direction"); .doesNotContain("DETAIL: settlementId, direction");
for (String type : TYPES) { for (String type : TYPES) {
assertThat(openApi).contains("- " + type); assertThat(openApi).contains("- " + type);
@@ -39,14 +39,35 @@ class FinanceFeePoolDirectionBehaviorTest {
assertThat(jdbc.costQueryCount).isZero(); 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 static final class DirectionJdbc extends JdbcTemplate {
private final List<String> sourceDirections = new ArrayList<>(); private final List<String> sourceDirections = new ArrayList<>();
private int costQueryCount; private int costQueryCount;
private String sourceSql;
private List<Object> sourceArgs = List.of();
@Override @Override
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
public <T> List<T> query(String sql, RowMapper<T> mapper, Object... args) { public <T> List<T> query(String sql, RowMapper<T> mapper, Object... args) {
if (sql.contains("FROM fin_fee_source")) { if (sql.contains("FROM fin_fee_source")) {
sourceSql = sql;
sourceArgs = List.of(args);
String direction = String.valueOf(args[1]); String direction = String.valueOf(args[1]);
sourceDirections.add(direction); sourceDirections.add(direction);
return (List<T>) List.of(item(direction, "FIRSTLEG_FEE", 1L)); 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)); 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 @Test
void voidMarksExistingVersionWithoutDeletingHistory() { void voidMarksExistingVersionWithoutDeletingHistory() {
FinanceFeeSourceEvent event = inboundEvent("voided-2", "FIRSTLEG_FEE_VOIDED", 2, 7); 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)); 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 @Test
void sameEventAndContentReturnsFirstResourceWithoutCallingFirstlegAgain() throws Exception { void sameEventAndContentReturnsFirstResourceWithoutCallingFirstlegAgain() throws Exception {
FinanceFeeSourceEvent event = inboundEvent("same-1", "FIRSTLEG_FEE_PUBLISHED", 1, 7); FinanceFeeSourceEvent event = inboundEvent("same-1", "FIRSTLEG_FEE_PUBLISHED", 1, 7);
@@ -106,6 +106,9 @@ class FinancePhase1OperationBehaviorTest {
assertThat(result.requestId()).isNotBlank(); assertThat(result.requestId()).isNotBlank();
assertThat(result.traceId()).isNotBlank(); assertThat(result.traceId()).isNotBlank();
assertThat(jdbc.accountListSql).contains("p.customer_name_snapshot"); 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)"); assertThat(jdbc.accountListSql).doesNotContain("CAST(c.customer_id AS CHAR)");
} }
@@ -118,7 +121,11 @@ class FinancePhase1OperationBehaviorTest {
service.listDerivedCustomerAccountLedger(9L, 1, 20, null, null, null); service.listDerivedCustomerAccountLedger(9L, 1, 20, null, null, null);
assertThat(result.data()).isEmpty(); 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 @Test
@@ -349,6 +356,7 @@ class FinancePhase1OperationBehaviorTest {
private static final class AccountJdbc extends JdbcTemplate { private static final class AccountJdbc extends JdbcTemplate {
private final List<Long> ledgerCustomerIds = new ArrayList<>(); private final List<Long> ledgerCustomerIds = new ArrayList<>();
private final List<String> ledgerSql = new ArrayList<>();
private String accountListSql = ""; private String accountListSql = "";
@Override @Override
@@ -369,8 +377,9 @@ class FinancePhase1OperationBehaviorTest {
"ACTIVE", LocalDateTime.of(2026, 8, 11, 10, 0))); "ACTIVE", LocalDateTime.of(2026, 8, 11, 10, 0)));
} }
if (sql.startsWith("SELECT id,bill_no") || sql.startsWith("SELECT id,receipt_no") 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()); ledgerCustomerIds.add(((Number) Arrays.asList(args).get(0)).longValue());
ledgerSql.add(sql);
return List.of(); return List.of();
} }
return List.of(); return List.of();
@@ -56,6 +56,8 @@ class FinanceSqlCommentContractTest {
assertThat(sql).contains("RECEIVED已接收、PROCESSED已完成、FAILED处理失败"); assertThat(sql).contains("RECEIVED已接收、PROCESSED已完成、FAILED处理失败");
assertThat(sql).contains("Firstleg费用来源完整不可变JSON快照"); assertThat(sql).contains("Firstleg费用来源完整不可变JSON快照");
assertThat(sql).contains("ON_ACCOUNT账期应付、DIRECT_PAID业务已付"); 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十六进制哈希"); assertThat(sql).contains("规范化完整请求的SHA-256十六进制哈希");
} }
@@ -72,6 +74,17 @@ class FinanceSqlCommentContractTest {
.contains("queue_column_comment('baiyue_finance', 'fin_bill_line', 'adjustment_reason'"); .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 @Test
void financeLedgerSourcesPersistOperatorNameSnapshotsInBaselineAndUnifiedSql() throws Exception { void financeLedgerSourcesPersistOperatorNameSnapshotsInBaselineAndUnifiedSql() throws Exception {
String baseline = Files.readString(Path.of("src/main/resources/db/finance.sql"), StandardCharsets.UTF_8); 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); 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 @Test
void exportsTheSameMonthlyCnyCustomerStatisticsWithoutExcludedPrototypeFields() { void exportsTheSameMonthlyCnyCustomerStatisticsWithoutExcludedPrototypeFields() {
when(receivableStatisticsService.exportRows(YearMonth.of(2026, 7), 10001L)).thenReturn(List.of( 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 计费、客户在途柜量统计、国内出仓履约链路,以及四期国内出仓后的船节点查询任务和节点事实。 - 负责履约任务/分配、入库单、测量/OCR、审核后初始计价、FBA 计费、客户在途柜量统计、国内出仓履约链路,以及四期国内出仓后的船节点查询任务和节点事实。
- 订单主数据归 `order/`,托盘/库位归 `warehouse/`,文件对象归 `file/`,OCR 供应商归 `integration-service/`;通过客户端 DTO 和内部令牌协作。 - 订单主数据归 `order/`,托盘/库位归 `warehouse/`,文件对象归 `file/`,OCR 供应商归 `integration-service/`;通过客户端 DTO 和内部令牌协作。
- 入库、分柜、装柜、PDA 和出仓能力均在本模块按对应服务端契约协作;`WAIT_SPLIT + APPROVED + CONFIRMED` 是入库链路状态节点,不是全量验收的范围终点。财务状态仍归 `finance`,本模块只生产不可变费用来源和柜次成本快照。 - 入库、分柜、装柜、PDA 和出仓能力均在本模块按对应服务端契约协作;`WAIT_SPLIT + APPROVED + CONFIRMED` 是入库链路状态节点,不是全量验收的范围终点。财务状态仍归 `finance`,本模块只生产不可变费用来源和柜次成本快照。
@@ -14,7 +15,8 @@
| 履约任务/分配 | `controller/PlatformFulfillmentAssignmentController.java`、`service/FulfillmentTaskService.java` | | 履约任务/分配 | `controller/PlatformFulfillmentAssignmentController.java`、`service/FulfillmentTaskService.java` |
| 入库单查询/聚合 | `controller/PlatformInboundOrderController.java`、`service/InboundOrderQueryService.java` | | 入库单查询/聚合 | `controller/PlatformInboundOrderController.java`、`service/InboundOrderQueryService.java` |
| 平台测量/OCR | `controller/PlatformInboundMeasurementController.java`、`service/InboundMeasurementService.java`、`service/CartonMeasurementService.java`;手工单箱测量也从该 Controller 与 `CartonMeasurementService` 进入 | | 平台测量/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/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` | | 审核与初始计价 | `controller/PlatformInboundMeasurementReviewController.java`、`service/MeasurementReviewService.java`、`service/InitialInboundPricingService.java`、`dto/MeasurementApprovalResponse.java` |
| FBA 主数据/计费 | `controller/PlatformFbaMasterDataController.java`、`PlatformFbaPricingController.java`、`service/FbaPricingService.java` | | FBA 主数据/计费 | `controller/PlatformFbaMasterDataController.java`、`PlatformFbaPricingController.java`、`service/FbaPricingService.java` |
@@ -27,19 +29,21 @@
| 财务费用来源快照 | `controller/InternalFinanceFeeSourceController.java`;入库费用与柜次费用分别按来源 ID、版本向 Finance 提供不可变快照 | | 财务费用来源快照 | `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` | | 柜次费用明细 | `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` | | 财务整柜成本分摊快照 | `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` 进入,明细规格拆分留在服务端 | | 货柜与智能排柜 | `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/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` | | 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` | | 装柜完成跨区事件 | `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` | | 海外卸柜完成事件回写 | 内部入口 `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` | | 海外暂扣状态回写 | 同一内部事件入口、消费者 `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` | | 四期国内出仓后船节点定时查询 | `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` | | 船节点跨区交接事件 | `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` 前缀,按北京时间每日独立编号;具体业务归属以各调用服务为准 | | 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` | | 订舱拥有的拖车与整柜出仓调度 | `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/controller/PlatformCustomsController.java`、`MerchantCustomsController.java` | | 整柜出口报关 | `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` | | 跨模型桥接 | `loading/bridge/controller/PlatformLoadingModelBridgeController.java` |
| 跨服务客户端 | `client/OrderCargoClient.java`、`WarehousePalletLocationClient.java`、`MeasurementImageClient.java`、`LoadingEvidenceFileClient.java`、`EventBridgeClient.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 返回服务端名称快照 | | 仓库公共供应商快照 | `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 价格版本,服务端保存渠道名称快照,渠道失效时失败关闭。 - 订舱柜型需求至少一项、最多 20 项,数量必须为正整数,容量和限重必须匹配平台柜型规格。起运仓必须是启用的国内中转仓;物流渠道必须来自该仓当前 ACTIVE 价格版本,服务端保存渠道名称快照,渠道失效时失败关闭。
- 订舱号统一使用 `OB-yyMMdd-####`,按北京时间创建日每日独立递增且单日最多 9999;不得以实体 ID 拼接生成。放舱不要求真实柜号,也不得为满足放舱校验伪造柜号。 - 订舱号统一使用 `OB-yyMMdd-####`,按北京时间创建日每日独立递增且单日最多 9999;不得以实体 ID 拼接生成。放舱不要求真实柜号,也不得为满足放舱校验伪造柜号。
- 订舱列表 `containerCount` 固定为该订舱全部柜型需求 `quantity` 之和,与真实柜关联/解除无关;关联候选只返回 `RELEASED`、剩余数量大于 0 且 `warehouseId + channelCode + containerType` 精确匹配的需求,由服务端集合查询 ACTIVE 分配量。前端不得扇出详情计算或用已关联柜数覆盖该字段,最终关联命令仍须重验目标货柜可修改性。 - 订舱列表 `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,外部失败不得推进业务节点,不得在三期另建查询任务。 - 四期船节点任务从确认柜子离开国内仓库后启用,必须绑定真实订舱和柜次;十个标准节点、预计/实际时间、异常事实、供应商缺失节点和还柜停止规则统一以 `../../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` 时标准节点序号必须严格大于最新实际节点,重复、相同或回退节点返回冲突。已审核实际时间修正只能更新同一事实的时间/证据,不得制造第二条同节点实际事实或绕过单调推进。 - 手工船节点的预计时间只追加事实,不推进 `latestStandardNode`;携带 `actualAt` 时标准节点序号必须严格大于最新实际节点,重复、相同或回退节点返回冲突。已审核实际时间修正只能更新同一事实的时间/证据,不得制造第二条同节点实际事实或绕过单调推进。
- 海外卸柜完成事件只接受 `OVERSEAS -> CN` 的 `OVERSEAS_CONTAINER_UNLOADED` V1,并通过内部服务令牌进入。消费者必须校验信封、Payload 哈希、来源货柜和时间顺序,以 event ID + Payload 哈希幂等,使用事件聚合版本阻止乱序覆盖;预计报空时间固定按卸柜照片上传时间加两天计算,实际报空时间可为空。 - 海外卸柜完成事件只接受 `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 复制财务状态机。 - Firstleg 只拥有业务费用及履约事实;Finance 拥有费用入池、账期、账单、收款、成本分摊结果和财务状态。修改费用来源版本、发布条件、柜次归属或体积快照时同步检查 `../finance/src/main/java/com/baiyue/finance/client/FirstlegFeeSourceClient.java`、同目录 `FirstlegCostSnapshotClient.java` 及双方契约测试,不在 Firstleg 复制财务状态机。
@@ -1,5 +1,6 @@
package com.baiyue.firstleg; package com.baiyue.firstleg;
import org.apache.ibatis.annotations.Mapper;
import org.mybatis.spring.annotation.MapperScan; import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
@@ -9,18 +10,7 @@ import org.springframework.scheduling.annotation.EnableScheduling;
/** /**
* 头程履约微服务启动入口,负责装配履约任务组件并扫描 MyBatis-Plus Mapper。 * 头程履约微服务启动入口,负责装配履约任务组件并扫描 MyBatis-Plus Mapper。
*/ */
@MapperScan(basePackages = { @MapperScan(basePackages = "com.baiyue.firstleg", annotationClass = Mapper.class)
"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"
})
@EnableFeignClients(basePackages = "com.baiyue.firstleg") @EnableFeignClients(basePackages = "com.baiyue.firstleg")
@EnableScheduling @EnableScheduling
@SpringBootApplication(scanBasePackages = "com.baiyue") @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.PathVariable;
import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.http.ResponseEntity; 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", @FeignClient(name = "file-service", contextId = "firstlegLoadingEvidenceFileClient",
url = "${baiyue.clients.file.url:}", path = "/internal/file/v1/loading-evidence") url = "${baiyue.clients.file.url:}", path = "/internal/file/v1/loading-evidence")
public interface LoadingEvidenceFileClient { 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, 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); @PathVariable long fileId);
@GetMapping("/{tenantId}/{inboundId}/{fileId}/content") @GetMapping("/{containerBookingId}/{fileId}/content")
ResponseEntity<byte[]> download(@RequestHeader("X-Internal-Service-Token") String token, 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); @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.math.BigDecimal;
import java.util.List; 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", @FeignClient(name = "print-service", contextId = "firstlegPrintPdfClient",
url = "${baiyue.clients.print.url:}", path = "/internal/print/v1/pdfs") url = "${baiyue.clients.print.url:}", path = "/internal/print/v1/pdfs")
public interface PrintPdfClient { public interface PrintPdfClient {
@@ -19,6 +20,12 @@ public interface PrintPdfClient {
@RequestHeader(value = "X-Request-Id", required = false) String requestId, @RequestHeader(value = "X-Request-Id", required = false) String requestId,
@RequestBody InboundMarkPdfRequest request); @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, record InboundMarkPdfRequest(String templateCode, PageSpec pageSpec,
String downloadFileName, List<Label> labels) { String downloadFileName, List<Label> labels) {
} }
@@ -27,6 +34,14 @@ public interface PrintPdfClient {
} }
record Label(String topCode, String warehouseText, String cartonText, 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; package com.baiyue.firstleg.client;
import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import org.springframework.cloud.openfeign.FeignClient; import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
@@ -16,8 +17,17 @@ import java.util.List;
url = "${baiyue.clients.warehouse.url:}", path = "/internal/warehouse/v1") url = "${baiyue.clients.warehouse.url:}", path = "/internal/warehouse/v1")
public interface WarehousePalletLocationClient { public interface WarehousePalletLocationClient {
@GetMapping("/pallets/snapshots") @GetMapping("/pallets/snapshots")
ApiResponse<List<PalletSnapshot>> palletSnapshots( ApiResponse<PageResult<PalletSnapshot>> palletSnapshots(
@RequestHeader("X-Internal-Service-Token") String token, @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); @RequestParam("warehouseId") long warehouseId);
@PostMapping("/pallet-locations/reservations") @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.PathVariable;
import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
import java.util.List;
@FeignClient(name = "warehouse-service", contextId = "firstlegWarehouseSupplierClient", @FeignClient(name = "warehouse-service", contextId = "firstlegWarehouseSupplierClient",
url = "${baiyue.clients.warehouse.url:}", path = "/internal/warehouse/v1/suppliers") url = "${baiyue.clients.warehouse.url:}", path = "/internal/warehouse/v1/suppliers")
@@ -16,5 +17,11 @@ public interface WarehouseSupplierClient {
@RequestParam(defaultValue = "true") boolean requireActive, @RequestParam(defaultValue = "true") boolean requireActive,
@RequestParam(required = false) String category); @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") @GetMapping("/{orderId}/inbound-billing")
@PreAuthorize("hasAuthority('merchant:order:view')") @PreAuthorize("hasAuthority('merchant:order:view')")
@Operation(summary = "查询订单入库结果和最新费用", @Operation(summary = "查询订单入库结果和最新费用",
description = "客户只读查看已达到 WAIT_SPLIT + APPROVED + CONFIRMED 的二期费用,不需要确认费用。") description = "客户按登录态客户范围只读查看;入库流程达到 WAIT_SPLIT 后,"
+ "在 LOADING、LOADED、DELIVERED 仍持续可见,且始终要求 APPROVED + SENT "
+ "并存在已发布入库运费明细,不需要客户确认费用。")
public ApiResponse<MerchantInboundBillingResponse> find( public ApiResponse<MerchantInboundBillingResponse> find(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long orderId) { @AuthenticationPrincipal AuthenticatedUser user, @PathVariable long orderId) {
return ApiResponse.success(service.find(FirstlegScopeGuard.merchant(user), 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.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.ContainerDtos.FullUpdateRequest; import com.baiyue.firstleg.dto.ContainerDtos.FullUpdateRequest;
import com.baiyue.firstleg.dto.ContainerDtos.Query; 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.SimulationCreateRequest;
import com.baiyue.firstleg.dto.ContainerDtos.OceanBookingLinkRequest; import com.baiyue.firstleg.dto.ContainerDtos.OceanBookingLinkRequest;
import com.baiyue.firstleg.dto.ContainerDtos.OceanBookingUnlinkRequest;
import com.baiyue.firstleg.entity.ContainerBookingEntity; import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.service.ContainerService; import com.baiyue.firstleg.service.ContainerService;
import com.baiyue.firstleg.service.FirstlegScopeGuard; import com.baiyue.firstleg.service.FirstlegScopeGuard;
@@ -96,29 +94,15 @@ public class PlatformContainerController {
@PostMapping("/{id}/ocean-booking-link") @PreAuthorize("hasAuthority('platform:firstleg:container:edit')") @PostMapping("/{id}/ocean-booking-link") @PreAuthorize("hasAuthority('platform:firstleg:container:edit')")
@BusinessAudit(event="CONTAINER_OCEAN_BOOKING_LINK", resourceType="CONTAINER", resourceId="#id", description="关联真实订舱柜") @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)); } 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')") @PutMapping("/{id}") @PreAuthorize("hasAuthority('platform:firstleg:container:edit')")
@BusinessAudit(event="CONTAINER_UPDATE", resourceType="CONTAINER", resourceId="#id", description="完整编辑货柜基础资料") @BusinessAudit(event="CONTAINER_UPDATE", resourceType="CONTAINER", resourceId="#id", description="完整编辑货柜基础资料")
@Operation(summary="完整编辑货柜", description="仅待装柜或预装柜中时允许") @Operation(summary="编辑货柜基础资料", description="仅待装柜或预装草稿时允许;不得写入真实集装箱号或内部柜次号")
public ApiResponse<ContainerBookingEntity> updateDraft(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<ContainerBookingEntity> updateDraft(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long id, @PathVariable @Positive long id,
@Valid @RequestBody FullUpdateRequest request) { @Valid @RequestBody FullUpdateRequest request) {
FirstlegScopeGuard.platform(user); FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.updateDraft(id, user.userId(), user.username(), request)); 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") @GetMapping("/{id}/clearance")
@PreAuthorize("hasAuthority('platform:firstleg:customs:manage')") @PreAuthorize("hasAuthority('platform:firstleg:customs:manage')")
@Operation(summary = "查询货柜目的港清关资料") @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.audit.BusinessAudit;
import com.baiyue.common.core.api.ApiResponse; 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.api.ResultCode;
import com.baiyue.common.core.exception.BizException; import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.security.AuthenticatedUser; 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.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid; 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.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal; 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.GetMapping;
import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PathVariable; 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.http.MediaType;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import java.util.List;
/** 平台后台入库测量录入、提交和 OCR 重量结果维护接口。 */ /** 平台后台入库测量录入、提交和 OCR 重量结果维护接口。 */
@Tag(name = "平台入库测量", description = "维护测量草稿、箱规明细和可追溯 OCR 重量结果。") @Tag(name = "平台入库测量", description = "维护测量草稿、箱规明细和可追溯 OCR 重量结果。")
@Validated
@RestController @RestController
@RequestMapping("/api/platform/v1/first-leg/inbounds/{inboundId}") @RequestMapping("/api/platform/v1/first-leg/inbounds/{inboundId}")
public class PlatformInboundMeasurementController { public class PlatformInboundMeasurementController {
@@ -56,16 +61,19 @@ public class PlatformInboundMeasurementController {
} }
@Operation(summary = "查询称重可选托盘快照", @Operation(summary = "查询称重可选托盘快照",
description = "按入库单所属仓库返回未删除的托盘只读快照,不校验托盘状态或占用资格。") description = "按入库单所属仓库分页返回当前有效托盘候选,支持编码或类型关键词;托盘可复用,不按预占或占用过滤。")
@GetMapping("/weight-pallet-options") @GetMapping("/weight-pallet-options")
@PreAuthorize("hasAnyAuthority('platform:firstleg:inbound:measure:view'," @PreAuthorize("hasAnyAuthority('platform:firstleg:inbound:measure:view',"
+ " 'platform:firstleg:inbound:weight:edit')") + " 'platform:firstleg:inbound:weight:edit')")
public ApiResponse<List<InboundMeasurementResponses.PalletSnapshot>> weightPalletOptions( public ApiResponse<PageResult<InboundMeasurementResponses.PalletSnapshot>> weightPalletOptions(
@AuthenticationPrincipal AuthenticatedUser user, @AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long tenantId, @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); 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", @BusinessAudit(event = "INBOUND_MEASUREMENT_DRAFT_SAVE", resourceType = "INBOUND",

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