Compare commits

...
Author SHA1 Message Date
ouyangxq 6c59525dec Align submit review action eligibility with persisted review state 2026-10-20 11:24:19 +08:00
ouyangxq c76b9888f5 Read frozen dispatch driver license through its exact driver owner 2026-10-20 10:38:20 +08:00
ouyangxq 135ffa662d Fix dispatch physical warehouse binding and empty cancellation 2026-10-20 09:32:13 +08:00
ouyangxq a8037fd97d Fix native dispatch warehouse text projection and TEST receipt setup 2026-10-20 09:16:16 +08:00
ouyangxq d7616899d8 Unify dispatch operations with matched published transfer fixture 2026-10-20 00:29:25 +08:00
ouyangxq a563f63857 feat(appointments): expose destination warehouse options 2026-10-19 17:02:25 +08:00
ouyangxq eb167347f6 test(transfer): prepare source snapshot before foreign relabel ownership check 2026-10-19 15:19:09 +08:00
ouyangxq c8608d50ba merge: retain current overseas inventory fixes with looseboard demand 2026-10-19 15:11:45 +08:00
ouyangxq dff040b509 feat(looseboard): require route contacts and pickup delivery times 2026-10-19 15:11:15 +08:00
liuyihe 26a203b4eb 提交 2026-10-19 15:00:25 +08:00
ouyangxq 21128b895f feat(trucking): refine truck demand contacts vehicle and service times 2026-10-19 14:34:33 +08:00
ouyangxq fafcf75014 fix(appointments): exclude pickup trips from delivery calendar 2026-10-19 12:21:57 +08:00
ouyangxq aa195cef66 feat(appointments): add four-type calendar sources and align release contracts 2026-10-19 12:15:43 +08:00
liuyihe fe1b2a6a46 提交 2026-10-19 11:54:29 +08:00
liuyihe e8f6bb32dc 提交 2026-10-19 10:23:09 +08:00
ouyangxq 45fbd6eff8 fix(lastleg): reconfirm received bookings after releasing all own cargo 2026-10-18 19:53:35 +08:00
ouyangxq 2c330bf9df test(lastleg): derive merchant relabel scope from profile receipt 2026-10-18 19:29:35 +08:00
ouyangxq 4bef8577fa test(lastleg): verify dispatch repair with current-run HTTP evidence 2026-10-18 19:21:12 +08:00
ouyangxq db4354bda8 fix(lastleg): align return receipt fixtures and schema totals 2026-10-18 18:45:47 +08:00
ouyangxq aa33514ec6 fix(lastleg): repair dispatch cancellation and return execution contracts 2026-10-18 16:34:14 +08:00
ouyangxq 66a0beb653 test(lastleg): mark diagnostic fixture mapper for registration contract 2026-10-18 11:49:47 +08:00
ouyangxq 5cd108b39a chore(lastleg): instrument appointment latency from restored 662dead baseline 2026-10-18 11:43:10 +08:00
ouyangxq b0acbfbb34 perf(lastleg): batch appointment requirement generation reads 2026-10-17 12:54:39 +08:00
ouyangxq 662dead053 chore(lastleg): diagnose work fact synchronization phases 2026-10-17 11:37:57 +08:00
ouyangxq 2971d6f7f0 perf(lastleg): reuse warehouse directory within sync generation 2026-10-17 09:57:54 +08:00
ouyangxq 81e4123b10 perf(lastleg): limit transport reconciliation facts and trace sync phases 2026-10-17 09:11:00 +08:00
ouyangxq 732cf99007 perf(lastleg): reuse work items within synchronization batch 2026-10-17 08:30:11 +08:00
ouyangxq 5fd74e9c8c perf(lastleg): batch receiving candidates and trace slow work invalidation 2026-10-17 07:49:56 +08:00
ouyangxq af97cd17d0 Merge production prebuild test fixes into frozen release 2026-10-16 18:44:42 +08:00
ouyangxq 5f5133bd68 test(lastleg): separate stable carton marks from return numbers 2026-10-16 18:44:32 +08:00
ouyangxq 339c70bb56 test(lastleg): isolate SQL semantic tests in bounded CI 2026-10-16 18:41:25 +08:00
ouyangxq e4bf573146 test: integrate production preflight contract correction 2026-10-16 18:14:49 +08:00
ouyangxq 4348296daf test(lastleg): validate shared appointment contract references 2026-10-16 18:14:37 +08:00
liuyihe 9ea03eb540 提交 2026-10-16 17:43:07 +08:00
liuyihe c9186c37a5 提交 2026-10-16 16:56:33 +08:00
liuyihe 6211d4ae37 提交 2026-10-16 16:53:32 +08:00
ouyangxq fee0268391 fix(appointment): link customer work to original batch requirement 2026-10-16 16:38:33 +08:00
ouyangxq 38a1f1079a Merge remote-tracking branch 'origin/test' into test 2026-10-16 16:21:51 +08:00
ouyangxq 58685945b1 fix(appointment): exclude cancelled batches without active allocations 2026-10-16 16:21:51 +08:00
liuyihe ecded9e58b 提交 2026-10-16 16:18:39 +08:00
liuyihe 488c0318ff 提交 2026-10-16 16:18:28 +08:00
ouyangxq 000125c3ec chore(sql): normalize merged initialization ending 2026-10-16 15:45:18 +08:00
ouyangxq 0708bbad2e Merge remote-tracking branch 'origin/test' into test
# Conflicts:
#	deploy/sql/README.md
#	deploy/sql/海外区域统一增量-MySQL8.sql
2026-10-16 15:44:59 +08:00
ouyangxq 30f48bd926 feat(appointments): support scoped receiving and stable trip continuation 2026-10-16 15:43:04 +08:00
liuyihe 358feb5567 提交 2026-10-16 13:57:39 +08:00
liuyihe 5855529e97 提交 2026-10-16 11:41:24 +08:00
ouyangxq 4e2ea89403 fix(selfop): retry continuous outbound failures with bounded diagnostics 2026-10-16 11:05:37 +08:00
ouyangxq a3a5862f89 fix(delivery): use frozen relabel destination in SKU details 2026-10-15 21:48:18 +08:00
ouyangxq b8739eebf0 fix(lastleg): preserve return relabel delivery metadata and measurements 2026-10-15 21:40:37 +08:00
ouyangxq 607aaf74dc fix(lastleg): route all relabel pickups directly to outbound and verify final schemas 2026-10-15 21:05:34 +08:00
ouyangxq 4277601999 perf(firstleg): scope appointment fact source aggregation by container 2026-10-15 20:03:41 +08:00
ouyangxq ae33780dae fix(order): project exact customer fulfillment progress in detail 2026-10-15 19:46:34 +08:00
ouyangxq 097bec33b1 perf(lastleg): batch live appointment work facts and filter candidates 2026-10-15 19:10:57 +08:00
ouyangxq abf4e45e39 perf(firstleg): batch appointment source and execution facts 2026-10-15 19:08:18 +08:00
ouyangxq 2631bd3af4 fix(dispatch): use supported capacity for default draft groups 2026-10-15 18:45:05 +08:00
liuyihe cda5f3dc32 提交 2026-10-15 18:25:53 +08:00
ouyangxq caff693ad8 test(appointments): verify stable batches and current HTTP contracts 2026-10-15 18:13:52 +08:00
ouyangxq 204ae10737 fix(dispatch): persist and reuse the resolved draft group 2026-10-15 18:12:55 +08:00
ouyangxq 23ce486539 fix(firstleg): reuse source manifest within work fact batches 2026-10-15 17:52:56 +08:00
ouyangxq 994215d981 fix(appointment): keep source inspection in work transaction 2026-10-15 17:34:45 +08:00
ouyangxq 42679d4d54 fix(appointment): retain unbookable cargo as blocked work 2026-10-15 17:28:22 +08:00
ouyangxq 14b8cc625b fix(appointment): retain safe synchronization failure diagnostics 2026-10-15 17:18:09 +08:00
ouyangxq 4f9a63fb01 merge: integrate TEST updates and verify appointment release 2026-10-15 17:08:30 +08:00
ouyangxq a1d5ebdab2 feat(appointment): integrate work calendar and stable cargo batches 2026-10-15 16:55:57 +08:00
liuyihe 628b1a5732 提交 2026-10-15 16:30:37 +08:00
liuyihe c6bd6af5ce 提交 2026-10-15 15:42:10 +08:00
liuyihe 095a95e64a Merge remote-tracking branch 'origin/test' into test
# Conflicts:
#	deploy/sql/README.md
2026-10-15 15:02:34 +08:00
ouyangxq dbb93a06c2 feat(selfop): provide frozen turnover batches for backend Excel exports 2026-10-15 15:00:03 +08:00
ouyangxq 22d79e08f9 fix(selfop): restore unique same-second inventory chains 2026-10-15 14:13:41 +08:00
ouyangxq 235b9469d9 fix(selfop): remove obsolete execution binding on config refresh 2026-10-15 13:48:49 +08:00
ouyangxq 99215bbb79 feat(selfop): reconstruct historical inventory and publish qualified turnover 2026-10-15 13:30:37 +08:00
ouyangxq d81d9cbda4 feat(selfop): add bounded inventory flow diagnostic through account egress 2026-10-15 11:37:49 +08:00
ouyangxq e19d303a76 feat(selfop): filter turnover by multiple owners across all aggregates 2026-10-15 11:12:44 +08:00
ouyangxq 72d7ed6962 feat(selfop): hide known zero inventory before turnover pagination 2026-10-15 10:29:42 +08:00
liuyihe d663d53747 Merge remote-tracking branch 'origin/test' into test 2026-10-14 20:27:06 +08:00
ouyangxq b7fb3a5054 feat(selfop): add owner inventory volume top ten chart 2026-10-14 18:17:09 +08:00
ouyangxq 7079455eb0 fix(selfop): support inventory and outbound volume sorting 2026-10-14 18:11:02 +08:00
liuyihe 471e50ffe1 提交 2026-10-14 18:01:25 +08:00
ouyangxq 8bc8518496 feat(selfop): calculate inventory and outbound volume by owner and SKU 2026-10-14 17:48:52 +08:00
ouyangxq 814eb11d2f fix(selfop): avoid loading routing manifests during idle planning 2026-10-14 17:26:41 +08:00
ouyangxq 1112d11c80 fix(lastleg): pace master data pages without holding collection leases 2026-10-14 16:56:40 +08:00
ouyangxq 63883f4ffd fix(integration): read master data source pagination fields 2026-10-14 16:43:11 +08:00
ouyangxq d311d9dc8d feat(selfop): sync versioned owner and product directories 2026-10-14 16:15:39 +08:00
ouyangxq 2b3b13913b feat(self-operated): expose and sort transit stock in turnover views 2026-10-14 14:08:52 +08:00
ouyangxq 5ace2fb584 Merge remote-tracking branch 'origin/test' into test 2026-10-14 14:01:40 +08:00
ouyangxq 6e7d922853 fix(self-operated): align turnover sales eligibility and known zero stock 2026-10-14 14:01:39 +08:00
liuyihe 1425b284dd 提交 2026-10-14 13:57:04 +08:00
liuyihe d017f5d881 提交 2026-10-14 13:42:58 +08:00
ouyangxq bc9c78122b feat(self-operated): support selected-day analytics and global quantity sorting 2026-10-14 12:44:15 +08:00
liuyihe 287edcf32b 提交 2026-10-14 12:13:26 +08:00
liuyihe 166280da5a 贴标申请,仓储转运 2026-10-14 11:37:38 +08:00
liuyihe e14bdb4f49 贴标申请,仓储转运 2026-10-14 11:36:35 +08:00
ouyangxq 83dc6dea3f 优化 2026-10-14 11:03:02 +08:00
ouyangxq 6193043880 perf(selfop): batch list checkpoints and publish each completed day 2026-10-14 10:09:16 +08:00
ouyangxq 674914c651 fix(selfop): allow explicit retry of failed continuous inventory roots 2026-10-14 09:46:49 +08:00
ouyangxq 2e2a354ba6 fix(selfop): retain turnover precision and scope reused publication runs 2026-10-14 09:37:00 +08:00
ouyangxq 6024faa9aa fix(selfop): isolate inventory paging and bound unstable scan retries 2026-10-14 09:36:52 +08:00
ouyangxq 0aa188445b fix(selfop): allow controlled continuous inventory retry in test 2026-10-14 09:29:08 +08:00
ouyangxq c0e1d42b62 perf(selfop): route collected orders once after resolution 2026-10-14 08:55:58 +08:00
ouyangxq c640fa2d0d feat(selfop): prefer list SKU quantities with targeted detail fallback 2026-10-14 08:15:16 +08:00
ouyangxq 4ae158c93e fix(selfop): preserve exact diagnostic decimals in JSON evidence 2026-10-14 05:05:23 +08:00
ouyangxq 913b9e9262 fix(selfop): persist bounded next-root quantity diagnostics 2026-10-14 04:59:05 +08:00
ouyangxq 765b6e6208 perf(lastleg): reduce inventory turnover query aggregation work 2026-10-14 03:36:54 +08:00
ouyangxq 16d0b9c80f fix: finalize inventory turnover queries and reuse snapshot aggregation 2026-10-14 01:51:51 +08:00
ouyangxq d029f9a19e 优化 2026-10-14 00:07:12 +08:00
ouyangxq 3d3507a770 feat(selfop): add bounded independent quantity diagnostic capture 2026-10-13 23:40:44 +08:00
ouyangxq 54f79b3902 feat(self-operated): expose private runtime configuration snapshots 2026-10-13 22:23:48 +08:00
ouyangxq 1d0b0918ba Merge commit 'b033a89aaf0af286cdf60cb2cdc2d51fd02deab1' into test 2026-10-13 21:51:00 +08:00
ouyangxq f1fb5662ca fix(self-operated): remove business collection deadline limits 2026-10-13 21:39:51 +08:00
ouyangxq 242a76221e feat(self-operated): persist continuous collection and frozen coverage 2026-10-13 21:13:31 +08:00
liuyihe b033a89aaf 贴标申请,仓储转运 2026-10-13 20:51:44 +08:00
ouyangxq c2bb769676 fix(selfop): remove business request count quotas 2026-10-13 20:02:07 +08:00
ouyangxq f151e74f5c fix(self-operated): rank hot SKUs for selected dates 2026-10-13 18:44:49 +08:00
liuyihe 38958c891a 贴标申请,仓储转运 2026-10-13 18:05:30 +08:00
liuyihe c07131b26f 贴标申请,仓储转运 2026-10-13 17:42:54 +08:00
ouyangxq ec7e9b36ec Merge remote-tracking branch 'origin/test' into test 2026-10-13 16:53:38 +08:00
ouyangxq 146d468ff4 feat: expose reliable collection activity and stage estimates 2026-10-13 16:50:53 +08:00
liuyihe 55764497c8 贴标申请,仓储转运 2026-10-13 16:48:54 +08:00
liuyihe 03601ab0aa 贴标申请,仓储转运 2026-10-13 15:55:31 +08:00
liuyihe c84b5f460a 贴标申请,仓储转运 2026-10-13 14:46:22 +08:00
liuyihe 89a05a2348 贴标申请,仓储转运 2026-10-13 13:46:16 +08:00
ouyangxq cebcdfb66e fix: preserve complete historical dashboard days 2026-10-13 13:43:13 +08:00
ouyangxq 496fc11b0a feat(selfop): support complete inventory sales windows 2026-10-13 12:35:39 +08:00
ouyangxq 39f914b3b5 fix(lingxing): allow bounded configurable business request pacing 2026-10-13 11:46:57 +08:00
ouyangxq 6e9466d0b4 Merge remote-tracking branch 'origin/test' into test 2026-10-13 11:19:19 +08:00
ouyangxq ed8c9b1543 test(selfop): verify every coverage bucket identity and pagination 2026-10-13 11:18:40 +08:00
liuyihe ce4eb4e8a2 Merge remote-tracking branch 'origin/test' into test 2026-10-13 11:00:37 +08:00
liuyihe 3b86a6d779 贴标申请,仓储转运 2026-10-13 11:00:31 +08:00
liuyihe c920d954ef 贴标申请,仓储转运 2026-10-13 10:47:58 +08:00
liuyihe f7a871e3e4 贴标申请,仓储转运 2026-10-13 10:47:52 +08:00
ouyangxq c13bf96cb9 feat(selfop): expose opt-in test query timing headers 2026-10-13 10:42:19 +08:00
ouyangxq 4c9878c338 feat(selfop): add opt-in test query timing diagnostics 2026-10-13 10:31:46 +08:00
ouyangxq d08ed2f2ca perf(selfop): seek latest outbound date for inventory page 2026-10-13 10:11:06 +08:00
ouyangxq 2d7b6e4381 test(selfop): verify warehouse summary build transactions 2026-10-13 10:04:06 +08:00
ouyangxq 25db631ff7 perf(selfop): build versioned warehouse daily sales summaries 2026-10-13 09:40:55 +08:00
ouyangxq ca4e7086e9 perf(selfop): skip unavailable dashboard aggregates 2026-10-13 03:45:01 +08:00
ouyangxq 7ae91eb276 fix(lastleg): persist bounded test collection session budgets 2026-10-13 03:08:36 +08:00
ouyangxq 1fdd978084 perf(selfop): paginate sales summary before day coverage enrichment 2026-10-13 02:46:20 +08:00
ouyangxq 341b7635c7 perf(selfop): materialize sales bounds without redundant deduplication 2026-10-13 02:31:46 +08:00
ouyangxq afd3bbaa3d perf(selfop): page stock keys before sales enrichment 2026-10-13 02:13:56 +08:00
ouyangxq 2c8c26490a perf(selfop): stabilize profile candidate join order 2026-10-13 02:00:14 +08:00
ouyangxq bf693fab6b perf(selfop): index staged observation joins and verify candidate builds 2026-10-13 01:47:17 +08:00
ouyangxq d689112656 perf(selfop): share dashboard aggregates and cover daily trend reads 2026-10-13 01:29:14 +08:00
ouyangxq 99d206eb00 perf(selfop): cover range reads and stream yearly SKU aggregation 2026-10-13 01:05:52 +08:00
ouyangxq a41c271a67 feat(selfop): add warehouse dashboard and exact inventory drilldowns 2026-10-13 00:45:39 +08:00
ouyangxq a84a9b730e fix(selfop): publish proven manual sales windows for configured warehouses 2026-10-13 00:30:48 +08:00
ouyangxq ae1a809357 fix(selfop): publish complete single-pass inventory observations 2026-10-13 00:10:12 +08:00
ouyangxq 51c0d77fd4 fix(selfop): stabilize inventory and order quantity fingerprints 2026-10-12 23:50:39 +08:00
ouyangxq 86e05a77e6 fix(selfop): allow controlled manual collection in test 2026-10-12 23:21:57 +08:00
ouyangxq 6e9f4b7932 perf(selfop): aggregate daily facts before SKU coverage calculations 2026-10-12 23:06:45 +08:00
ouyangxq cbc316b34d fix(selfop): expose scoped validation tasks without published scopes 2026-10-12 22:57:18 +08:00
ouyangxq b71380de31 fix(selfop): discover warehouses with bounded validation and optimize queries 2026-10-12 22:22:08 +08:00
ouyangxq e42d358e71 Merge remote-tracking branch 'origin/test' into test 2026-10-12 21:25:28 +08:00
ouyangxq 06de9b1c93 test(selfop): verify publication transactions against opt-in TEST MySQL 2026-10-12 21:24:56 +08:00
liuyihe fb98b1a8ee 贴标申请,仓储转运 2026-10-12 21:18:29 +08:00
ouyangxq 8526697b08 fix: quote inline descriptions in Lingxing OpenAPI contract 2026-10-12 21:10:18 +08:00
ouyangxq b702ed9f45 test: verify self-operated analytics through TEST Gateway and synthetic query fixtures 2026-10-12 20:59:54 +08:00
ouyangxq d4dc4f875a fix: register current synchronous service dependencies and guard callback boundaries 2026-10-12 20:58:16 +08:00
ouyangxq d782b2cd24 fix: reject unavailable self-operated collection commands before initialization 2026-10-12 20:45:31 +08:00
ouyangxq 3b137f8946 Merge remote-tracking branch 'origin/test' into test 2026-10-12 20:27:50 +08:00
ouyangxq a1f1d93902 feat: add self-operated analytics and enforce Lastleg module boundaries 2026-10-12 20:27:35 +08:00
liuyihe 0eb58f9dd1 贴标申请,仓储转运 2026-10-12 18:19:08 +08:00
liuyihe 9b333df720 贴标申请,仓储转运 2026-10-12 18:11:23 +08:00
liuyihe 46507e41c6 贴标申请,仓储转运 2026-10-12 18:01:16 +08:00
ouyangxq c06e788b0f fix: complete single-order acceptance and add Lingxing WMS integration 2026-10-12 16:09:21 +08:00
liuyihe 6c4ee2bfde 贴标申请,仓储转运 2026-10-12 15:57:47 +08:00
liuyihe a1cd47dabd 贴标申请,仓储转运 2026-10-12 13:42:23 +08:00
liuyihe ea3aae6093 Merge remote-tracking branch 'origin/test' into test 2026-10-12 13:42:13 +08:00
ouyangxq c545a77190 test(firstleg): keep sailing copy dates in the future 2026-10-12 11:48:49 +08:00
ouyangxq 3cb4a5676a fix(firstleg): use a single cabinet business number 2026-10-12 11:44:58 +08:00
liuyihe b4b00ae3dd 贴标申请,仓储转运 2026-10-12 11:18:58 +08:00
ouyangxq 29b562ebf5 fix: require dedicated appointment cancellation command 2026-10-11 14:14:51 +08:00
ouyangxq 43494cbfa3 fix: retain appointment cancellation reasons and query history 2026-10-11 13:26:00 +08:00
ouyangxq d4b72594ab test(procurement): reject receipt corrections after shipment 2026-10-10 19:52:36 +08:00
ouyangxq 15ab355dc2 fix(appointments): include destination timezone in batch preview 2026-10-10 18:37:38 +08:00
liuyihe c6471d06ff 贴标申请,仓储转运 2026-10-10 16:58:33 +08:00
liuyihe 05302490fb 贴标申请,仓储转运 2026-10-10 16:15:17 +08:00
ouyangxq 8e36bd90ef Merge remote-tracking branch 'origin/test' into test 2026-10-10 15:34:19 +08:00
ouyangxq 0ac2b849c3 feat(appointments): track forecast impacts and handling history 2026-10-10 15:33:13 +08:00
liuyihe 84864c2ecf Merge remote-tracking branch 'origin/test' into test 2026-10-10 14:47:42 +08:00
liuyihe d1b4cc9c0f 贴标申请,仓储转运 2026-10-10 14:47:31 +08:00
1157 changed files with 93164 additions and 13428 deletions
+15
View File
@@ -78,6 +78,8 @@
| 国内船节点向海外接柜门禁事件 | `firstleg/`、`common-event-contract/`、`lastleg-service/` |
| 海外仓库目录、海外库位、海外托盘、资源锁 | 国内仓库目录归 `warehouse/`,海外资源归 `warehouse-overseas-service/` |
| Lastleg 内部模块化与依赖基线 | `lastleg-service/` |
| 自营领星采集、经营统计、SKU 日销量 | `lastleg-service/selfoperated/` 查询与采集编排;领星签名/分页/外部接口查 `integration-service/`,不因采集页面任务同时读取两服务 |
| 跨客户同仓预约、预约跟进、到港日历 | `lastleg-service/inboundplan/`、`appointmentplanning/`;到港事实查 `firstleg/portschedule/` |
| 车辆、司机证照、车次、外接预订、车队报价、出车记录 | `lastleg-service/fleet/`;先读 `lastleg-service/AGENTS.md` 对应入口,客户排车关联再沿 `dispatch/fleet/` 实际调用 |
| PDF 标签、审计、业务事件直投 | `print/`、`audit-service/`、`common-event-contract/` |
@@ -104,6 +106,19 @@
- 装柜、船节点、卸柜、暂扣、派送、费用与资金按统一通信契约的逐事件表直接投递。保留本地 Outbox、消费幂等、重试与失败处置,不携带用户 JWT;旧 Bridge 源码已退出聚合和发布清单,退役待清理;实际停机仍须替代链验证与授权,不得据源码状态认定已部署。
- `lastleg-service` 不读取头程数据库,`warehouse-overseas-service` 不读取尾程订单库。共用测试 MySQL 实例不改变服务数据归属。准确路由、ACK、撤销矩阵和切换门槛只维护在新的两份主契约中。
## 数据库访问与中文代码注释(所有服务及公共模块强制)
- 本节适用于全部后端服务和公共模块;新增、修改的方法必须执行,触及的存量代码同步整改。登记本约束不代表存量代码已全部完成迁移和注释补齐。
- 生产代码的关系型数据库读写统一经 MyBatis/MyBatis-Plus Mapper 执行;Controller、Service、定时任务、消费者、编号生成器和公共基础组件不得直接使用 `JdbcTemplate`、`NamedParameterJdbcTemplate`、原生 JDBC/Connection/Statement 或其他通道执行 SQL。仅改成 Repository 包装 JDBC 不算整改,SQL 必须进入 Mapper;无数据库职责的模块不得为满足本条新增数据库访问。
- 正常业务数据库访问路径统一为 `Controller → Service → Repository → Mapper`,四层职责明确,不得省略 Repository,也不得由 Controller 直接调用 Repository/Mapper,或由 Service 直接调用 Mapper。定时任务、消费者等非 HTTP 入口从 Service 进入,同样经 Repository → Mapper 完成持久化访问。
- Controller 负责请求接收、参数校验、入口权限和响应转换;Service 负责业务规则、编排及业务事务;Repository 负责领域数据访问封装、查询组合与持久化模型转换,对 Service 提供有业务含义的数据操作;Mapper 负责 SQL 执行及数据库记录映射。Repository 不承载业务状态流转,不直接用 JDBC 执行 SQL;Service 不继承 MyBatis-Plus `ServiceImpl` 等方式绕过 Repository。
- 复杂查询、批量处理、原子计数、锁定读取、Outbox 和数据库监控查询同样经 Repository → Mapper,不以 SQL 特殊或实现方便为由跳层。公共模块需要数据库能力时,按现有模块边界定义 Repository/持久化接口,由所属服务的 Repository → Mapper 实现,禁止借此跨服务读库。
- 迁移数据库操作须保留客户隔离条件、事务传播、行锁、幂等和并发语义;`LAST_INSERT_ID()` 等连接级操作必须在同一事务连接完成,并明确关闭相关读取缓存,不能仅机械搬移 SQL。Mapper 注册及扫描仍遵守下文统一规范。
- 测试代码可用 `JdbcTemplate` 创建隔离测试表、准备数据及核对结果;被测生产路径仍必须调用实际 Mapper,不得把测试造数做法带入生产。数据库基线和迁移脚本按现有部署契约管理。
- 每个手写方法及构造器均须添加简短中文注释,通常 1—3 行说明业务用途;接口方法和 Mapper 方法优先使用中文 Javadoc。覆盖方法同样须有简短中文说明,实现中的关键规则仍须就地注释;自动生成的方法不手工逐个补注释。
- 核心业务、复杂 SQL、状态流转、资金与库存变更、权限隔离、事务、锁、幂等、并发、重试和补偿逻辑,须在关键代码前增加适量中文注释,解释业务规则、处理原因、约束边界及失败影响;不逐行翻译代码,不添加无信息量或与实现不符的注释。
- 新增和修改的代码说明注释统一使用中文;必要的技术标识、协议名、类名可保留原文,第三方许可证和生成标记保持原样。修改方法时同步更新相关存量英文或过期说明;审查时同时核对 Controller → Service → Repository → Mapper 分层及中文方法、核心逻辑注释。
## 契约、数据库与验证入口
- 对外 Controller/API 新增、改名或删除时,同时核对调用端、统一 Gateway、OpenAPI 和权限;提交前运行 `node --test scripts/gateway-route-contract.test.mjs`,自动发现生产 Controller 并校验网关目标服务与内部路径隔离。失败不得提交或标记完成;网关配置变化必须列入部署清单,不以业务服务单测代替入口检查。
+5
View File
@@ -1,5 +1,10 @@
# Audit Service 模块指引
## 数据库访问与中文注释约束
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
## 职责边界
- Mapper 只由 `BaiyueAuditApplication.java` 对 `com.baiyue.audit` 根包按 `@Mapper` 过滤扫描;新增或移动 Mapper 后从后端根运行 `node scripts/mapper-scan-contract.mjs`,不得增加子域扫描配置。
+5
View File
@@ -1,5 +1,10 @@
# Auth Service 模块指引
## 数据库访问与中文注释约束
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
## 职责与边界
- Mapper 只由 `BaiyueAuthApplication.java` 对 `com.baiyue.auth` 根包按 `@Mapper` 过滤扫描;新增或移动 Mapper 后从后端根运行 `node scripts/mapper-scan-contract.mjs`,不得增加子域扫描配置。
+5
View File
@@ -1,5 +1,10 @@
# Common Audit 模块指引
## 数据库访问与中文注释约束
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
## 职责边界
- 负责业务审计事件采集、注解切面、敏感字段脱敏以及日志/HTTP 输出。
+5
View File
@@ -1,5 +1,10 @@
# Common Core 模块指引
## 数据库访问与中文注释约束
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
## 职责边界
- 提供不依赖具体业务域的返回模型、异常、基础实体和 MyBatis-Plus 通用配置。
+5
View File
@@ -1,5 +1,10 @@
# Common Event Contract 模块指引
## 数据库访问与中文注释约束
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
## 职责边界
- 维护 Firstleg、Lastleg、Finance 之间稳定、可版本化的业务事件契约。
+5
View File
@@ -1,5 +1,10 @@
# Common OpenAPI 模块指引
## 数据库访问与中文注释约束
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
## 职责边界
- 为 Spring MVC 服务提供 SpringDoc/OpenAPI 自动配置和统一文档元数据。
+5
View File
@@ -1,5 +1,10 @@
# Common Security 模块指引
## 数据库访问与中文注释约束
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
## 职责边界
- 提供业务服务通用的 JWT 解码、认证过滤、Redis Access Token 会话校验、认证用户上下文及 401/403 JSON 响应。
+5
View File
@@ -1,5 +1,10 @@
# Common Web 模块指引
## 数据库访问与中文注释约束
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
## 职责边界
- 提供 Servlet Web 通用能力:请求链路标识、上下文和统一异常响应。
+9
View File
@@ -141,6 +141,15 @@ baiyue:
max-rows: 200000
max-attempts: 3
batch-size: 200
SELFOP_INVENTORY_TURNOVER:
provider-service: lastleg-service
provider-path: /internal/exports/v1/SELFOP_INVENTORY_TURNOVER/batches
required-permission: platform:self-operated-analytics:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 100
OVERSEAS_INVENTORY_LEDGER:
provider-service: lastleg-service
provider-path: /internal/exports/v1/OVERSEAS_INVENTORY_LEDGER/batches
+19
View File
@@ -218,6 +218,12 @@ services:
environment:
<<: *common-environment
SERVER_PORT: 9210
LINGXING_WMS_ENABLED: ${LINGXING_WMS_ENABLED:-false}
LINGXING_COLLECTION_MODE: ${SELFOP_COLLECTION_MODE:-OFF}
LINGXING_ENVIRONMENT_LABEL: ${DEPLOY_ENV:-test}
LINGXING_SOURCE_ACCOUNT: ${SELFOP_SOURCE_ACCOUNT:-lingxing-primary}
LINGXING_OMS_APP_KEY: ${LINGXING_OMS_APP_KEY:-}
LINGXING_OMS_APP_SECRET: ${LINGXING_OMS_APP_SECRET:-}
BAIDU_OCR_ENABLED: ${BAIDU_OCR_ENABLED:-false}
BAIDU_OCR_API_KEY: ${BAIDU_OCR_API_KEY:-}
BAIDU_OCR_SECRET_KEY: ${BAIDU_OCR_SECRET_KEY:-}
@@ -279,9 +285,20 @@ services:
image: ${IMAGE_REPOSITORY}/lastleg-service:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-lastleg
expose: ["9303"]
# 仅限制尾程服务,避免 JVM 异常时拖垮测试服务器.
mem_limit: 3g
memswap_limit: 3g
cpus: "2.0"
environment:
<<: *common-environment
SERVER_PORT: 9303
JAVA_TOOL_OPTIONS: "-Xms512m -Xmx1536m -XX:+ExitOnOutOfMemoryError -Dfile.encoding=UTF-8"
SELFOP_EXECUTION_ENABLED: ${SELFOP_EXECUTION_ENABLED:-false}
SELFOP_COLLECTION_MODE: ${SELFOP_COLLECTION_MODE:-OFF}
SELFOP_SOURCE_ACCOUNT: ${SELFOP_SOURCE_ACCOUNT:-lingxing-primary}
SELFOP_DAILY_REQUEST_BUDGET: ${SELFOP_DAILY_REQUEST_BUDGET:-0}
SELFOP_MIN_REQUEST_INTERVAL_MS: ${SELFOP_MIN_REQUEST_INTERVAL_MS:-5000}
SELFOP_SALES_PUBLICATION_ENABLED: ${SELFOP_SALES_PUBLICATION_ENABLED:-false}
EVENT_CONNECT_TIMEOUT_MS: ${EVENT_CONNECT_TIMEOUT_MS:-3000}
EVENT_READ_TIMEOUT_MS: ${EVENT_READ_TIMEOUT_MS:-15000}
EVENT_LEASE_SECONDS: ${EVENT_LEASE_SECONDS:-60}
@@ -321,3 +338,5 @@ networks:
infra-network:
external: true
name: ${INFRA_NETWORK:-firstleg-${DEPLOY_ENV:-test}-infra-network}
+10
View File
@@ -21,6 +21,16 @@ IMAGE_TAG=
FILE_UPLOADS_HOST_DIR=/data/firstleg/uploads
CORS_ALLOWED_ORIGINS=
INTERNAL_SERVICE_TOKEN=
# 领星唯一账号;凭据仅注入 integration-service。
LINGXING_OMS_APP_KEY=
LINGXING_OMS_APP_SECRET=
LINGXING_WMS_ENABLED=false
SELFOP_COLLECTION_MODE=OFF
SELFOP_SOURCE_ACCOUNT=lingxing-primary
# 未核准额度使用0,真实出口拒绝请求;有效模式仍需显式的独占交接证明与截止时间。
SELFOP_DAILY_REQUEST_BUDGET=0
SELFOP_MIN_REQUEST_INTERVAL_MS=5000
SELFOP_SALES_PUBLICATION_ENABLED=false
REDIS_PASSWORD=
AUTH_DB_URL=jdbc:mysql://host.docker.internal:3306/baiyue_auth?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
AUTH_DB_USERNAME=
@@ -0,0 +1,63 @@
# 2026-10-16 拉取合并数据库变更汇总
范围:IDEA 更新前暂存基点 `cda5f3dc3` 到远程 `test` 的 `4e2ea8940`,加上本次恢复的本地改动。不是任意历史版本的一键全量升级脚本。没有连接或执行实际数据库。
## 数据库对应关系
| 服务 | 数据库 | 本次处理 |
|---|---|---|
| lastleg-service | `baiyue_lastleg` | 执行下列 V28 增量,新增表、字段、索引并放宽四个字段的可空性 |
| warehouse-overseas-service | `baiyue_lastlag_warehouse` | 仅业务代码变化,无新增表或字段,无需 SQL;数据库名称保留正式的 lastlag 拼写 |
| 其他服务 | 各服务原数据库 | 本次比较范围内无 SQL 结构新增,无需 SQL |
已有尾程库使用:[V28 可重复执行 SQL](migrations/overseas/baiyue_lastleg/V28__merge_transfer_measurement_and_compact_numbers.sql)。文件显式指定 `USE baiyue_lastleg`,不访问其他服务数据库。
## 新表与字段
| 表 | 字段 | 定义/用途 |
|---|---|---|
| `ll_compact_number_clock`(新表) | `number_key` | VARCHAR(16),主键,业务前缀及客户名称缩写 |
| 同上 | `last_second` | BIGINT NOT NULL,短单号分配逻辑秒,防止同秒及多实例重号 |
| `ll_warehouse_transfer_item` | `declared_quantity` | BIGINT NULL,SKU 申报件数,独立于箱数 |
| `ll_warehouse_transfer_carton` | `length_cm`、`width_cm`、`height_cm`、`gross_weight_kg` | DECIMAL(12,2) NULL,逐箱实测尺寸、重量 |
| 同上 | `counted_quantity` | BIGINT NULL,实点 SKU 件数 |
| 同上 | `count_idempotency_key` | VARCHAR(255) NULL,清点幂等键 |
| 同上 | `count_request_hash` | VARCHAR(64) NULL,清点请求摘要 |
| 同上 | `counted_by` | VARCHAR(128) NULL,清点操作人 |
| 同上 | `counted_at` | DATETIME(3) NULL,清点时间 |
| 同上 | `discrepancy_reason` | VARCHAR(1000) NULL,实收差异原因 |
| 同上 | `transfer_pallet_id` | BIGINT NULL,所属转运托盘事实 ID |
| 同上 | `sku_items_json` | JSON NULL,一箱多 SKU 清点明细 |
| 同上 | `customer_carton_mark` | VARCHAR(255) NULL,客户原箱唛及现场换标绑定 |
合计:1 张新表(2 列)、既有表补齐 14 列。逐箱清点等字段在旧版独立增量中已有,本次合并统一纳入 V28,避免漏执行;已存在的列会跳过。
## 索引及既有字段调整
| 表 | 调整 |
|---|---|
| `ll_cargo_discard_item` | 缺失时增加普通索引 `idx_ll_cargo_discard_return_carton(customer_id,return_carton_id)` |
| `ll_warehouse_transfer_order` | `estimated_arrival_at` 改为 DATETIME(3) NULL,库存转运无需填写预计入仓时间 |
| `ll_delivery_order` | `total_volume_cbm` 改为 DECIMAL(18,4) NULL,未知体积保持空值 |
| `ll_outbound_order`、`ll_outbound_pallet_item` | `volume_cbm` 改为 DECIMAL(18,4) NULL,未知体积保持空值 |
远程同时完善了 `ll_relabel_application.delivery_method` 的业务说明及照片、预约表的中文注释,没有新增对应字段,不需要因此另建表。照片回显和箱子订单归属校验复用已有结构。
## 执行与重跑
1. 确认连接为需要升级的 MySQL 8 实例,已有 `baiyue_lastleg` 及之前版本的业务表。通过数据库客户端打开并完整执行 V28,失败时停止,不启用忽略错误继续执行。
2. 新表采用 `CREATE TABLE IF NOT EXISTS`;每个新增字段和索引单独检查 `information_schema`,部分完成后可重跑。四个可空字段仅在当前为 NOT NULL 时调整。不会回填、删除或重算历史业务数据。
3. DDL 在 MySQL 中会自动提交,不能整体事务回滚;脚本不会修复同名但定义不一致的已有列、索引或新表,执行前应核对这些对象的结构。脚本末尾提供字段结构查询。
4. 此前 V1–V27 和其他已发布增量仍按原版本顺序管理,V28 不替代它们;缺失整个业务表的旧库应先完成对应历史升级。
空库使用原有海外统一初始化入口,已同步最终建表结构与 209 张表计数。名称含“增量”的海外统一文件仍仅用于空库,不应向已有业务库执行。
## 本地合并记录
远程分支已拉取,当前本地 HEAD 与 `origin/test` 一致。本次异常来自 IDEA 恢复更新前暂存补丁不完整;48 个文件按当前文件、原始基点、暂存内容做三方合并。唯一文本冲突保留远程 `bindingGuard` 与本地 `compactNumbers` 两项测试依赖初始化。
恢复前文件及三方版本保存在 `D:/project/.codex-temp/pull-resolution-20261016`;IDEA 原 shelf 保留。没有自动提交或推送代码。
本地验证:相关业务 Maven 定向测试通过;初始化基线、入库费用/扣货及网关路由合同共 27 项通过;Git 无未解决索引、无残留冲突标记,差异格式检查通过。SQL 仅完成离线核对,未进行 MySQL 实库执行或二次重跑验证。
指引索引检查 `scripts/agents-index-contract.ps1` 因工作区缺少 `D:/project/docs/业务快速索引.md` 而中止,此项未通过;未为消除检查错误而创建替代业务文档。
+36 -5
View File
@@ -1,5 +1,15 @@
# 单测试环境数据库空库初始化
排车整车作业结构同步于 Lastleg 源基线及海外统一空库基线:整车主单移除客户头部,真实客户归属保留在货物、出库、派送和费用;新增公共命令账本、主单历史、派送安排及签收待汇总信号。空排车的计划时间可暂缺,提交审核前由业务校验补齐;`ll_dispatch_physical_pallet_lock` 独立保存无代表客户的实托排他占用,`ll_dispatch_pallet_lock.physical_lock_id` 关联逐客户逻辑来源,同车可共享、跨主单或草稿容器仍排他。`ll_dispatch_item` 按主单和完整来源键唯一,`ll_dispatch_item_source` 冻结换标明细的实际位置、绑定箱数、真实件数(未知为空)及原货源事实;拣货归属新增 `dispatch_item_source_id`,与实收、退回实收、原生退货箱三类凭据四者互斥,不伪造普通收货凭据。智能草稿按拥有者、起运仓和版本维护,一容器转换一整车;独立草稿命令保存原请求和结果。必须与匹配的 Lastleg、IAM、File、Admin 和 PDA 一起核对,空库脚本不能向已有 TEST 重放。本条仅说明代码基线,不表示 SQL 已执行、已部署或全流程已验收;唯一业务规则见工作区 `docs/海外仓业务/排车列表统一作业改造契约.md`。
`ll_physical_pallet_guard` 以真实全局实托ID和首次创建时间提供排车冻结与仓内改托的共同串行点,独立于运输占用,不保存客户、状态、有效期或第三种拥有者。`ll_dispatch_item_source.relabel_round_key`保存真实作业轮次,不能把扫码引起的库存和申请状态变化误当作换标轮次变化。排车、草稿和仓内调用必须使用匹配的公共锁实现;本条只说明空库基线结构,不表示已执行或已部署。
本土散板简化下单的当前结构同步于 Lastleg 源基线及海外统一空库基线:移除散板柜型与旧要求日期,增加商品名称、申报预计托数及当地取货/送达时间,移除运输方式,增加起运地及目的地各自的联系人和联系电话;四项联系信息、取货及送货时间均必填;打盘与运输安排允许未知。预计托数不代表实收。字段语义唯一见工作区 `docs/海外仓业务/README.md`,不回填测试存量,不提供自动重置或启动时 SQL;需与匹配 Lastleg、Admin 和 Client 一起发布。本说明不表示 SQL 已执行。
`migrations/overseas/baiyue_lastleg/V23__align_return_movement_collation.sql` 将退货进销存 UNION 涉及的八张表、22 个文本字段及表默认规则显式统一为 `utf8mb4_unicode_ci`,同步 Lastleg 两份源基线与海外统一空库基线。执行前核对字段长度、默认值、可空性及目标规则下唯一索引无冲突;只修改规则,不回填或删除数据,不重放空库基线。DDL 非事务,失败时停止并复查实际已完成项;可按最终定义重跑。脚本存在不表示测试库已执行,执行后用原始计数/分页查询验证,无需发布查询转换代码。
`migrations/overseas/baiyue_lastleg/V22__selfop_warehouse_daily_sales.sql` 新增自营仓库每日汇总表及销量版本 `warehouse_daily_ready` 标记。与匹配 Lastleg 一起发布;汇总随 SKU 日事实批次原子构建,完整性验证后方可发布。已有未构建汇总的销量版本须重新构建发布,不回填旧版本、不回退旧查询;空版本同样必须有就绪证据。源基线与海外空库基线同步。
`migrations/overseas/baiyue_lastleg/V21__procurement_fee_confirmation_origin.sql` 增加采购费用核定入口,区分业务节点与财务操作,用于独立新增费用、替代核定及其重试权限。同步源基线和海外统一空库基线;不回填测试存量,不表示已执行。
`migrations/overseas/baiyue_lastleg/V20__procurement_exception_control.sql` 增加采购事实更正/阻断说明、异常关闭及逐来源最终去向三表。服务端保持原收货和发运记录,关闭不产生库存、退款或退货运输;需与采购详情聚合和节点阻断校验的匹配版本发布。源基线和海外统一基线同步,本文件存在不表示已执行。
@@ -22,14 +32,27 @@
内部拼车需求池的当前结构在 Lastleg 源基线和海外统一空库基线同步维护,独立五项权限在 IAM 源种子和国内统一基线维护。已存在基础表的指定环境结构补齐使用 `migrations/overseas/baiyue_lastleg/V10__fleet_capacity_pool.sql` 与 `migrations/cn/baiyue_iam/V4__fleet_capacity_offer_permissions.sql`;执行前确认三张新表、六个新增字段均未存在,并单独核对目标环境。随后 `V11__align_capacity_pool_collation.sql` 将预订与商业桥的客户关联字段显式统一为 `utf8mb4_unicode_ci`,与原订单域一致,避免依赖MySQL实例默认排序规则导致JOIN失败。它们不回填、删除或重置业务数据,不能重放整个空库基线。候选文件存在不表示已经执行。
平台自营领星统计十张 `ll_selfop_*` 表直接同步 Lastleg 源基线及海外统一空库基线;时间使用 UTC 存储及已确认北京时间业务日,数量使用 DECIMAL 保留 PCS 精度,维持独立发布版本。包含账号共享预算/冷却、验证根用途与硬预算、TEST BUSINESS 根会话持久预算、稳定任务号、阶段进度/心跳和旧版替代时间;任务的 `business_root_task_id` 标记共享根,只有根保存 `business_budget_json`,生产任务不使用该测试预算。库存 run 包含每日代表日期、选择规则及账号/仓库/日期唯一约束,代表库存与 profile 不适用销量副本淘汰。IAM 权限源为 `iam/src/main/resources/db/selfoperated-analytics-permissions.sql`,自营业务下经营总览、销量分析、库存分析、数据采集的菜单资源与独立读写权限同步国内统一基线,仅平台管理员默认授权。执行和部署状态只维护于工作区测试环境主契约;真实采集模式默认 OFF。
客户/商品资料基线为 `lastleg-service/src/main/resources/db/selfop-master-data.sql`,仅新增 `ll_selfop_owner`、`ll_selfop_product` 两表,并同步在 Lastleg 和海外统一空库基线。按来源账号、货主编码、SKU 和完整资料版本关联,不创建平台客户、收费对象或独立映射表;未知实测尺寸保持 NULL。对 TEST 已有库只审阅并授权这两张新表的 CREATE,不能重放统一初始化文件,也不重置采集数据。
历史库存还原基线为 `lastleg-service/src/main/resources/db/selfop-inventory-history.sql`,新增流水事实、历史日版本和日库存三表;同步 Lastleg 与海外空库基线。仅执行这三表 CREATE,不重放原始库存或销量表。匹配版本复用 `sync_task/run/stage` 的 `INVENTORY_FLOW_HISTORY`、`INVENTORY_HISTORY_BUILD` 与 `INVENTORY_FLOW/HISTORY_INPUT`,原库存快照不改写。完成三表并部署 Integration、Lastleg、Admin 后,才启用 `baiyue.self-operated-analytics.inventory-history-enabled=true`;默认关闭,历史目标30日优先再60日。保留当前账号出口、1200ms间隔和所有被历史版本引用的证据;现有销量副本淘汰不得删除这些引用。
预约工作事项新增结构由 `lastleg-service/src/main/resources/db/appointment-work.sql` 提供五张表:完整范围协调锁、工作关系、不可变历史、承诺影响版本与逐票批次实物分配,已同步 Lastleg 和海外空库初始化入口;对已有 TEST 仅审阅并授权这五表 CREATE 及协调锁初始行。直送目的仓实际卸货独立表由 `firstleg/src/main/resources/db/phase3-direct-delivery-schema.sql` 提供并同步国内空库入口,不产生海外库存。两项独立写权限见 `iam/src/main/resources/db/appointment-work-permissions.sql`,先校验精确权限身份再写入;默认只授权平台管理员。旧预约表的有损重建只允许单独审阅 `reset-test-fba-appointments.sql`;若历史、核实或工作/分配表已存在,前置检查会在 DROP 前拒绝,不能重放或擅自扩大重置范围。以上只维护脚本,不代表已执行或已部署。
换标直接出库的实物体积快照在 `ll_outbound_order.volume_cbm`、`ll_outbound_pallet_item.volume_cbm`、`ll_delivery_order.total_volume_cbm` 三列均允许 NULL,表示来源尚未确认;已有实盘尺寸按箱计算,不能用默认 0 代替未知,也不按 SKU 件数重复累计。两份初始化基线同步此定义,已有 TEST 只能在核对结构和授权后精确调整这三列,不重放空库文件或回填历史行。
## 唯一入口与区域边界
V13 收货覆盖由 `lastleg-service/src/main/resources/db/appointment-receiving.sql` 提供四表:独立收货预约、当前/历史客户成员、待确认变化和不可变覆盖版本;同步 Lastleg 与海外空库基线。有效外部约号唯一性归收货预约的目的仓与约号,批次头表不再独立占用约号。部署前必须核对四表及旧头表唯一索引;本批只准备基线,不提供测试存量回填或自动重置。旧 `reset-test-fba-appointments.sql` 已拒绝存在这些新表的环境,不能用于重放 V13 初始化。实际 SQL 与部署须逐项授权。
两个统一 SQL 文件仍只用于空库初始化,名称中的“增量”不表示可向已有业务库重放。已有生产库的受控结构补齐单独放在 `migrations/`,只允许明确审阅过的增量变更;不提供存量数据回填、清理或兼容入口。
| 区域 | 完整入口 | 服务 schema | 表数 |
|---|---|---|---:|
| 国内 | `头程数据库统一增量-MySQL8.sql` | `baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_finance` | 226 |
| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 181 |
| 国内 | `头程数据库统一增量-MySQL8.sql` | `baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_finance` | 227 |
| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 228 |
- 所有服务位于同一测试环境;两份文件只分组组织服务 schema,不代表两个部署区域。`baiyue_lastlag_warehouse` 保留当前正式的 `lastlag` 拼写。
- 两份文件必须在同一测试数据库实例中完成初始化;业务服务仍只访问本服务 schema,经内部接口访问 IAM/Warehouse。已移除事件桥两个 schema 的创建,不对已有数据库执行 DROP。
@@ -38,7 +61,7 @@
- 外接预订的`quote_source_json`保存可选销售报价来源版本快照,实际成交价仍在货物快照及`agreed_amount`中;手填成交价时无报价来源。该字段随Lastleg模块基线与海外统一入口维护,执行状态只见正式部署契约。
- 异常退回的`return_context_json`保存取消时所需的原始业务状态;逐来源`received_packages`累计实际入仓量且不得超过批准退回量。同一派送可以保留多张已取消/已结束的历史退回单,活动退回仍由派送行锁内校验;普通`idx_ll_exception_delivery_return`替代跨全部历史的唯一索引。数量和状态不能通过回填旧单据伪造。
- 派送`ll_delivery_order.signed_packages`保存签收资料确认时冻结的实际签收箱数;NULL表示未确认,不当作全量签收,数量须在0到本次实发箱数之间。后续退回保留原签收历史,不用当前退回量倒改实签。
- `ll_return_stock_receipt`与真实退回库存入账同事务保存,按客户/入账流水唯一,保留原订单和出库位置以及实际入仓位置。重派候选、草稿锁及拣货显式引用该凭据;拣货`receipt_id`与`return_receipt_id`恰有一个非空。退回不增加原订单箱数,不回退历史出库流水;可重新发运量由实际退回及已消耗凭据核平。
- `ll_return_stock_receipt`与真实退回库存入账同事务保存,按客户/入账流水唯一,保留原订单和出库位置以及实际入仓位置。重派候选、草稿锁及拣货显式引用该凭据;`receipt_id`、`return_receipt_id`、`return_carton_id`、`dispatch_item_source_id` 四类拣货归属凭据恰有一个非空。退回不增加原订单箱数,不回退历史出库流水;可重新发运量由实际退回及已消耗凭据核平。
- 国内 Warehouse 管理包括海外仓在内的公共仓库主档;海外 Warehouse 只保存资源与主档 ID 引用。
- Gateway、Print、Integration 不新增数据库。Compose 继续使用 `SPRING_SQL_INIT_MODE=never`,服务启动不执行这些文件。
@@ -51,7 +74,7 @@
5. DDL 隐式提交,不可整体回滚。失败后停止并保留错误;经另行授权准备空库后从头执行,不针对半初始化库追加迁移或从失败行续跑。
6. Firstleg 测量项与拆分项互为外键:建空表期间暂设会话 `foreign_key_checks=0`,随后立即恢复 `1`,再插入种子。失败时关闭连接。两边外键完整保留在 CREATE TABLE 内。
成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=226/181`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。
成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=227/228`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。
## 平台管理员密码
@@ -67,7 +90,7 @@
| 所属库 | 保留内容 | 不包含 |
|---|---|---|
| Auth / IAM | 平台主体、唯一平台管理员、8 个系统角色及企业管理员模板、417 个权限、598 条角色权限、7 个数据范围、客户编号起点;无人工完成订单权限 | 测试客户、固定客户账号、额外扩权 |
| Auth / IAM | 平台主体、唯一平台管理员、8 个系统角色及企业管理员模板、454 个权限、618 条角色权限、7 个数据范围、客户编号起点;无人工完成订单权限 | 测试客户、固定客户账号、额外扩权 |
| Warehouse | 既有公共仓目录 318 条及地址;义乌、东莞、洛杉矶、纽约 4 个作业仓收货能力 | 示例供应商、快递、往来单位、托盘、库位、预占、库存 |
| Firstleg | 112 个费用项目、15 个渠道目录、7 个始发地别名、4 个公共仓映射 | 客户报价、订单、入库、实测或履约记录 |
| File / Finance | 导出互斥键 `GLOBAL_CREATE`、默认平台财务主体 | 文件任务、资金、账单或交易流水 |
@@ -139,3 +162,11 @@ node --test scripts/admin-test-env-modules/initialization-baseline-contract.test
本次整理使用隔离 MySQL 8.4,国内与海外分别在不存在对方区域 schema 的空库执行两轮,逐表比对整理前最终字段、默认值、索引、约束、注释及业务触发器。SHOW CREATE 的冗余字符集显示与运行态自增计数不视为结构差异;移除示例种子和强制改密是明确行为变更。这不代表已部署或完成真实 HTTP 验收。
码头车队邮件配置见 [Firstleg 车队邮件](../../firstleg/docs/terminal-drayage-mail.md)。
`migrations/overseas/baiyue_lastleg/V24__cargo_discard_photos.sql` 仅新增废弃照片关联表,按客户、申请和托盘关联文件及提交时间。与 Lastleg、File 和三端照片功能共同发布;不回填历史废弃单,不修改库存。脚本存在不表示目标环境已执行。
自营库存周转后端导出权限见 `selfop-turnover-export-permission.sql`,仅新增 `platform:self-operated-analytics:export` 并授予平台管理员;同步 IAM 自营权限源及国内统一基线。配套部署 Lastleg、File、Admin,File 注册 `SELFOP_INVENTORY_TURNOVER`(Nacos同名配置同步);无业务建表及采集配置变更。执行前须逐项授权。
货物废弃通用库存与散箱字段迁移:`migrations/overseas/baiyue_lastleg/V25__cargo_discard_generic_inventory.sql`。新增库存仓位关联及箱唛快照,须先迁移再部署 Lastleg;不回填历史废弃单。
2026-10-16 拉取合并配套结构清单及执行边界见 [数据库变更汇总](20261016-数据库变更汇总.md),已有尾程库使用 `migrations/overseas/baiyue_lastleg/V28__merge_transfer_measurement_and_compact_numbers.sql`;空库初始化海外表数同步为 209。该增量可重复执行,尚未执行到任何实际数据库。
@@ -0,0 +1,21 @@
-- 仅 baiyue_iam,新增客户废弃确认按钮权限并授权默认客户管理员。
-- 自定义客户角色由管理员按需分配;不自动扩大其权限。
-- 可重复执行;授权和持久化安全版本在同一事务提交。
START TRANSACTION;
INSERT INTO iam_permission(id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,deleted)
VALUES (5306183,0,'MERCHANT','BUTTON',5306180,'merchant:overseas-warehouse:discard:confirm','确认废弃完成',NULL,'POST',30,'ACTIVE',1,0)
ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name),status='ACTIVE',deleted=0;
INSERT IGNORE INTO iam_role_permission(tenant_id,role_id,permission_id,created_at)
SELECT 0,r.id,p.id,NOW(3) FROM iam_role r JOIN iam_permission p
ON p.tenant_id=0 AND p.app_code='MERCHANT' AND p.permission_code='merchant:overseas-warehouse:discard:confirm'
WHERE r.tenant_id=0 AND r.app_code='MERCHANT' AND r.role_code='MERCHANT_OWNER' AND r.status='ACTIVE' AND r.deleted=0;
INSERT INTO iam_security_subject_version(auth_user_id,permission_version)
SELECT DISTINCT u.auth_user_id,1 FROM iam_user u
JOIN iam_membership m ON m.user_id=u.id
JOIN iam_membership_role mr ON mr.membership_id=m.id AND mr.tenant_id=m.tenant_id
JOIN iam_role r ON r.id=mr.role_id
WHERE u.auth_user_id IS NOT NULL AND u.status='ACTIVE' AND u.deleted=0
AND m.status='ACTIVE' AND m.deleted=0 AND r.tenant_id=0 AND r.app_code='MERCHANT'
AND r.role_code='MERCHANT_OWNER' AND r.status='ACTIVE' AND r.deleted=0
ON DUPLICATE KEY UPDATE permission_version=permission_version+1;
COMMIT;
@@ -0,0 +1,21 @@
CREATE TABLE IF NOT EXISTS ll_selfop_warehouse_daily_sales (
sales_run_id BIGINT NOT NULL COMMENT '完整销量计算版本标识',
source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号别名,不保存密钥',
warehouse_code VARCHAR(64) NOT NULL COMMENT '来源仓库原始编码',
business_date DATE NOT NULL COMMENT '已核准业务日',
observed_quantity DECIMAL(38,6) NULL COMMENT '该日PCS非负有效出库量之和,无有效行时为空',
invalid_row_count BIGINT NOT NULL COMMENT '该日数量为空或负数或单位非PCS的日事实行数',
source_row_count BIGINT NOT NULL COMMENT '该日参与仓库汇总的SKU日事实总行数',
created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间',
updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间',
PRIMARY KEY (sales_run_id,source_account,warehouse_code,business_date),
CONSTRAINT chk_selfop_warehouse_daily_sales CHECK (source_row_count > 0 AND invalid_row_count >= 0 AND invalid_row_count <= source_row_count AND (observed_quantity IS NULL OR observed_quantity >= 0))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='完整销量版本按仓按日汇总,随SKU日事实批次原子构建';
SET @selfop_warehouse_daily_ready_ddl = IF(
EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_selfop_sync_run' AND column_name='warehouse_daily_ready'),
'SELECT 1',
'ALTER TABLE ll_selfop_sync_run ADD COLUMN warehouse_daily_ready TINYINT NOT NULL DEFAULT 0 COMMENT ''仓库日汇总已完整构建并校验,含完整空版本''');
PREPARE selfop_warehouse_daily_ready_stmt FROM @selfop_warehouse_daily_ready_ddl;
EXECUTE selfop_warehouse_daily_ready_stmt;
DEALLOCATE PREPARE selfop_warehouse_daily_ready_stmt;
@@ -0,0 +1,44 @@
-- Region: OVERSEAS
-- Schema: baiyue_lastleg
-- Purpose: 统一退货进销存 UNION 文本列排序规则,去除查询转换需求
-- Preconditions: 八张退货表已存在;核对字段长度、可空性、默认值及唯一索引无目标规则冲突
-- Postconditions: 指定文本字段及表默认规则统一为 utf8mb4_unicode_ci;不修改数量、状态或业务标识
-- 不重放空库基线;本文件可重复执行。DDL 非事务,失败停止并核对后续执行范围。
ALTER TABLE ll_return_order
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci,
MODIFY COLUMN customer_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID和隔离键',
MODIFY COLUMN overseas_warehouse_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作海外仓ID';
ALTER TABLE ll_return_order_item
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci,
MODIFY COLUMN sku_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'SKU编码',
MODIFY COLUMN product_name_zh VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '中文品名';
ALTER TABLE ll_return_receipt
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci,
MODIFY COLUMN receipt_no VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '签收单号';
ALTER TABLE ll_return_receipt_item
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci,
MODIFY COLUMN sku_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'SKU编码';
ALTER TABLE ll_return_inventory_carton
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci,
MODIFY COLUMN customer_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID和隔离键',
MODIFY COLUMN overseas_warehouse_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '海外仓ID',
MODIFY COLUMN carton_mark VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '退货箱号和箱唛编码';
ALTER TABLE ll_return_inventory_carton_item
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci,
MODIFY COLUMN sku_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'SKU编码',
MODIFY COLUMN product_name_zh VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '中文品名';
ALTER TABLE ll_return_inventory_outbound_movement
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci,
MODIFY COLUMN customer_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '库存所属客户ID和隔离键',
MODIFY COLUMN overseas_warehouse_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '实际出库海外仓ID',
MODIFY COLUMN sku_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '本次出库SKU编码快照',
MODIFY COLUMN product_name_zh VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '中文品名快照',
MODIFY COLUMN outbound_no VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '本次退货出库业务单号';
ALTER TABLE ll_return_sku_conversion
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci,
MODIFY COLUMN customer_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户隔离键',
MODIFY COLUMN overseas_warehouse_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作海外仓ID',
MODIFY COLUMN old_sku_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '换标前SKU',
MODIFY COLUMN new_sku_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '换标后SKU',
MODIFY COLUMN product_name_zh VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '中文品名快照',
MODIFY COLUMN relabel_no VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '换标单号';
@@ -0,0 +1,16 @@
CREATE TABLE IF NOT EXISTS ll_cargo_discard_photo (
id BIGINT NOT NULL,
customer_id VARCHAR(64) NOT NULL,
application_id BIGINT NOT NULL,
pallet_no VARCHAR(128) NOT NULL,
file_id BIGINT NOT NULL,
file_name VARCHAR(255) NOT NULL,
content_type VARCHAR(128) NOT NULL,
size_bytes BIGINT NOT NULL,
operator_id VARCHAR(64) NOT NULL,
created_at DATETIME(6) NOT NULL,
submitted_at DATETIME(6) NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_cargo_discard_photo_file (file_id),
KEY idx_cargo_discard_photo_application (customer_id, application_id, pallet_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -0,0 +1,24 @@
-- 货物废弃支持所有入库来源及散箱全量作业;不回填或修改历史单据。
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='inventory_position_id'),
'ALTER TABLE ll_cargo_discard_item ADD COLUMN inventory_position_id BIGINT NULL COMMENT ''废弃来源库存仓位ID''', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='carton_marks_json'),
'ALTER TABLE ll_cargo_discard_item ADD COLUMN carton_marks_json TEXT NULL COMMENT ''本次废弃所选真实箱唛快照JSON数组''', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND index_name='uk_ll_discard_position_sku'),
'ALTER TABLE ll_cargo_discard_item ADD UNIQUE KEY uk_ll_discard_position_sku(application_id,inventory_position_id,sku_code)', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
ALTER TABLE ll_cargo_discard_item MODIFY COLUMN source_type VARCHAR(32) NOT NULL DEFAULT 'DROPSHIP' COMMENT '库存来源:一件代发DROPSHIP、退货箱RETURN_CARTON、通用库存INVENTORY';
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='carton_scan_required'),
'ALTER TABLE ll_cargo_discard_item ADD COLUMN carton_scan_required TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''是否需要逐箱核对本次所选箱子''', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
ALTER TABLE ll_cargo_discard_item MODIFY COLUMN carton_marks_json TEXT NULL COMMENT '本次废弃所选真实箱唛快照JSON数组';
ALTER TABLE ll_inbound_plan_carton MODIFY COLUMN status VARCHAR(32) NOT NULL COMMENT '箱唛状态:未绑定UNASSIGNED、已绑托ON_PALLET、整票收货BULK_RECEIVED、可用AVAILABLE、待废弃DISCARD_PENDING、已废弃DISCARDED';
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_carton' AND column_name='source_type'),
'ALTER TABLE ll_cargo_discard_carton ADD COLUMN source_type VARCHAR(32) NOT NULL DEFAULT ''DROPSHIP'' COMMENT ''箱子来源:一件代发DROPSHIP、入仓箱INBOUND、转运箱TRANSFER''', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
@@ -0,0 +1,13 @@
-- 整箱废弃后保留SKU明细,剩余数量允许归零,仍禁止负库存。
-- 原子替换约束,可重复执行;不修改业务记录。
SET @ddl = IF(EXISTS (
SELECT 1 FROM information_schema.table_constraints
WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton_item'
AND constraint_name='chk_ll_return_inventory_carton_item_quantity'
),
'ALTER TABLE ll_return_inventory_carton_item DROP CHECK chk_ll_return_inventory_carton_item_quantity, ADD CONSTRAINT chk_ll_return_inventory_carton_item_quantity CHECK(quantity>=0)',
'ALTER TABLE ll_return_inventory_carton_item ADD CONSTRAINT chk_ll_return_inventory_carton_item_quantity CHECK(quantity>=0)'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
@@ -0,0 +1,3 @@
-- 客户确认废弃完结时间,可重复执行;仅结构变更。
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_application' AND column_name='customer_confirmed_at'), 'ALTER TABLE ll_cargo_discard_application ADD COLUMN customer_confirmed_at DATETIME(3) NULL COMMENT ''客户确认废弃完结时间''', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
@@ -0,0 +1,70 @@
-- 2026-10-16 拉取合并及本地恢复配套增量,MySQL 8。
-- 目标:lastleg-service / baiyue_lastleg;前置:已有业务表及此前迁移。
-- 只调整结构,不回填、删除或重算业务数据;执行失败应停止,修复后可重跑。
SET NAMES utf8mb4;
USE baiyue_lastleg;
CREATE TABLE IF NOT EXISTS ll_compact_number_clock (
number_key VARCHAR(16) NOT NULL COMMENT '业务前缀和客户名称缩写',
last_second BIGINT NOT NULL COMMENT '最后分配的Unix逻辑秒',
PRIMARY KEY (number_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外业务短单号分配时钟';
-- 仓储转运逐箱测量与清点;仅增加字段,不修改业务数据。
USE baiyue_lastleg;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='length_cm'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN length_cm DECIMAL(12,2) NULL COMMENT ''箱子实测长度厘米'''));
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='width_cm'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN width_cm DECIMAL(12,2) NULL COMMENT ''箱子实测宽度厘米'''));
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='height_cm'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN height_cm DECIMAL(12,2) NULL COMMENT ''箱子实测高度厘米'''));
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='gross_weight_kg'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN gross_weight_kg DECIMAL(12,2) NULL COMMENT ''箱子实测重量千克'''));
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='counted_quantity'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN counted_quantity BIGINT NULL COMMENT ''箱内SKU实点件数'''));
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='count_idempotency_key'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN count_idempotency_key VARCHAR(255) NULL COMMENT ''箱内清点幂等键'''));
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='count_request_hash'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN count_request_hash VARCHAR(64) NULL COMMENT ''箱内清点请求摘要'''));
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='counted_by'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN counted_by VARCHAR(128) NULL COMMENT ''箱内清点操作人'''));
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='counted_at'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN counted_at DATETIME(3) NULL COMMENT ''箱内清点完成时间'''));
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='discrepancy_reason'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN discrepancy_reason VARCHAR(1000) NULL COMMENT ''实收与预报箱数差异原因'''));
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='transfer_pallet_id'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN transfer_pallet_id BIGINT NULL COMMENT ''签收箱所属转运托盘事实ID'''));
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
-- SKU 申报件数独立于预计箱数;历史未申报件数保持 NULL,实收以 PDA 清点为准。
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_item' AND column_name='declared_quantity'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_item ADD COLUMN declared_quantity BIGINT NULL COMMENT ''SKU申报件数,非预计或实收箱数'''));
PREPARE transfer_quantity_stmt FROM @ddl; EXECUTE transfer_quantity_stmt; DEALLOCATE PREPARE transfer_quantity_stmt;
-- 仓储转运一箱多SKU清点;仅增加结构,不回填历史业务数据。
USE baiyue_lastleg;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='sku_items_json'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN sku_items_json JSON NULL COMMENT ''实收箱内SKU及逐项件数明细'''));
PREPARE transfer_sku_stmt FROM @ddl; EXECUTE transfer_sku_stmt; DEALLOCATE PREPARE transfer_sku_stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='customer_carton_mark'), 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN customer_carton_mark VARCHAR(255) NULL COMMENT ''客户原箱唛,用于换标现场绑定''', 'SELECT 1');
PREPARE merge_schema_stmt FROM @ddl; EXECUTE merge_schema_stmt; DEALLOCATE PREPARE merge_schema_stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND index_name='idx_ll_cargo_discard_return_carton'), 'ALTER TABLE ll_cargo_discard_item ADD KEY idx_ll_cargo_discard_return_carton(customer_id,return_carton_id)', 'SELECT 1');
PREPARE merge_schema_stmt FROM @ddl; EXECUTE merge_schema_stmt; DEALLOCATE PREPARE merge_schema_stmt;
SET @ddl = IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='estimated_arrival_at' AND is_nullable='NO'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN estimated_arrival_at DATETIME(3) NULL COMMENT ''预计入仓时间,库存转运不填写''', 'SELECT 1');
PREPARE merge_schema_stmt FROM @ddl; EXECUTE merge_schema_stmt; DEALLOCATE PREPARE merge_schema_stmt;
SET @ddl = IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='total_volume_cbm' AND is_nullable='NO'), 'ALTER TABLE ll_delivery_order MODIFY COLUMN total_volume_cbm DECIMAL(18,4) NULL COMMENT ''体积(立方米),来源未确认时为空''', 'SELECT 1');
PREPARE merge_schema_stmt FROM @ddl; EXECUTE merge_schema_stmt; DEALLOCATE PREPARE merge_schema_stmt;
SET @ddl = IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_order' AND column_name='volume_cbm' AND is_nullable='NO'), 'ALTER TABLE ll_outbound_order MODIFY COLUMN volume_cbm DECIMAL(18,4) NULL COMMENT ''体积(立方米),来源未确认时为空''', 'SELECT 1');
PREPARE merge_schema_stmt FROM @ddl; EXECUTE merge_schema_stmt; DEALLOCATE PREPARE merge_schema_stmt;
SET @ddl = IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_pallet_item' AND column_name='volume_cbm' AND is_nullable='NO'), 'ALTER TABLE ll_outbound_pallet_item MODIFY COLUMN volume_cbm DECIMAL(18,4) NULL COMMENT ''体积(立方米),来源未确认时为空''', 'SELECT 1');
PREPARE merge_schema_stmt FROM @ddl; EXECUTE merge_schema_stmt; DEALLOCATE PREPARE merge_schema_stmt;
-- 执行后核对本次结构;这不是应用功能验收。
SELECT table_name,column_name,column_type,is_nullable,column_comment FROM information_schema.columns
WHERE table_schema=DATABASE() AND table_name IN ('ll_compact_number_clock','ll_warehouse_transfer_carton','ll_warehouse_transfer_item','ll_warehouse_transfer_order','ll_delivery_order','ll_outbound_order','ll_outbound_pallet_item')
ORDER BY table_name,ordinal_position;
@@ -0,0 +1,2 @@
-- 对应数据库:baiyue_lastleg;可重复执行,不修改原箱号、SKU预报件数和实测数据。
ALTER TABLE ll_warehouse_transfer_carton MODIFY COLUMN transfer_item_id BIGINT NULL COMMENT '实际箱内首项SKU明细ID,预报补箱可为空';
+52 -9
View File
@@ -1,9 +1,27 @@
-- TEST ONLY. Rebuild the explicitly authorized appointment header and item tables.
-- The new command-history table MUST NOT already exist; check this before executing any DROP.
-- If it exists, stop and inspect the prior outcome. Do not clear command history or blindly retry.
-- The command-history and cargo-verification tables MUST NOT already exist; check both before any DROP.
-- If either exists, stop and inspect the prior outcome. Do not clear history or blindly retry.
-- Requires explicit approval; never run against production or as automatic startup SQL.
-- Caller must verify the TEST connection before executing.
USE baiyue_lastleg;
DROP PROCEDURE IF EXISTS assert_test_appointment_reset_preconditions;
DELIMITER $$
CREATE PROCEDURE assert_test_appointment_reset_preconditions()
BEGIN
IF EXISTS(SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE()
AND table_name IN ('ll_fba_appointment_command','ll_appointment_cargo_verification',
'll_appointment_work','ll_dispatch_appointment_allocation','ll_fba_receiving_agreement',
'll_fba_receiving_member','ll_fba_receiving_change','ll_fba_receiving_revision','ll_fba_receiving_plan')) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Stop: appointment history or work/allocation schema already exists; inspect before any reset';
END IF;
IF (SELECT COUNT(*) FROM information_schema.tables WHERE table_schema=DATABASE()
AND table_name IN ('ll_inbound_fba_appointment','ll_inbound_fba_appointment_item')) <> 2 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Stop: expected both legacy appointment tables; inspect partial or new schema';
END IF;
END$$
DELIMITER ;
CALL assert_test_appointment_reset_preconditions();
DROP PROCEDURE assert_test_appointment_reset_preconditions;
DROP TABLE ll_inbound_fba_appointment_item;
DROP TABLE ll_inbound_fba_appointment;
CREATE TABLE ll_inbound_fba_appointment (
@@ -24,12 +42,14 @@ CREATE TABLE ll_inbound_fba_appointment (
appointment_remark VARCHAR(1000) NULL COMMENT '备注或取消原因',
fba_warehouse_code VARCHAR(128) NOT NULL COMMENT 'FBA目的仓编码',
selected_count INT NOT NULL DEFAULT 0 COMMENT '真实货源票数',
replacement_nos_json LONGTEXT NULL COMMENT '分批或合并后的新预约业务号列表,非空时原批次只读',
replacement_keys_json LONGTEXT NULL COMMENT '替代批次不可变键列表,只用于内部业务关联',
repartition_reason VARCHAR(1000) NULL COMMENT '分批或合并原因,原安排及来源明细保留',
created_by VARCHAR(128) NOT NULL COMMENT '登录操作人',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建UTC时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '更新UTC时间',
PRIMARY KEY (id),
UNIQUE KEY uk_ll_fba_appointment_no (appointment_no),
UNIQUE KEY uk_ll_fba_external_confirmation (fba_warehouse_code,confirmation_no),
KEY idx_ll_fba_appointment_list (appointment_at,appointment_status,fba_warehouse_code),
KEY idx_ll_fba_appointment_planned (planned_at,appointment_status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='FBA预约安排与正式确认唯一主记录';
@@ -43,19 +63,42 @@ CREATE TABLE ll_inbound_fba_appointment_item (
order_id BIGINT NULL COMMENT '尚未形成尾程订单时为空',
inbound_code VARCHAR(128) NULL COMMENT '真实入仓码,不伪造',
fba_warehouse_code VARCHAR(128) NOT NULL COMMENT '目的FBA仓编码',
package_count INT NULL COMMENT '所选整票预报箱数,未知保持空',
active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '取消或失败后释放货源占用',
active_source_key VARCHAR(160) GENERATED ALWAYS AS (CASE WHEN active=1 THEN source_key ELSE NULL END) STORED COMMENT '有效货源唯一占用键',
active_order_id BIGINT GENERATED ALWAYS AS (CASE WHEN active=1 THEN order_id ELSE NULL END) STORED COMMENT '有效订单唯一占用键',
package_count INT NULL COMMENT '本批分配箱数,未知保持空;同一原来源总量守恒',
cargo_verification_version INT NULL COMMENT '采用的预约货物核实版本,未核实时为空',
shipment_references_json LONGTEXT NULL COMMENT '本次采用的货件或追踪号核实快照',
active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '当前批次数量分配有效;取消、未约成和完成均保留,仅替代时释放',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建UTC时间',
PRIMARY KEY (id),
UNIQUE KEY uk_ll_fba_item_source (appointment_id,source_key),
UNIQUE KEY uk_ll_fba_active_source (active_source_key),
UNIQUE KEY uk_ll_fba_active_order (active_order_id),
KEY idx_ll_fba_active_source (customer_id,source_key,active),
KEY idx_ll_fba_active_order (customer_id,order_id,active),
KEY idx_ll_fba_item_source_lookup (source_key,customer_id),
KEY idx_ll_fba_item_customer_order (customer_id,order_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='入仓前后共用的预约真实货源';
CREATE TABLE ll_appointment_cargo_verification (
id BIGINT NOT NULL COMMENT '核实记录主键',
customer_id VARCHAR(128) NOT NULL COMMENT '原货源所属客户',
source_key VARCHAR(160) NOT NULL COMMENT '稳定ORDER或BOOKING原来源键',
destination_key VARCHAR(160) NOT NULL COMMENT '核实时的原目的地',
source_version VARCHAR(160) NULL COMMENT '核实时的原来源版本',
source_packages INT NULL COMMENT '核实时的原来源箱数,未知为空',
version INT NOT NULL COMMENT '同一来源的核实版本,从一递增',
package_count INT NULL COMMENT '独立核实箱数,不回写原订单',
shipment_references_json LONGTEXT NOT NULL COMMENT '本票货件号或追踪号列表,不回写原订单',
remark VARCHAR(1000) NULL COMMENT '核实说明',
operator_id VARCHAR(128) NOT NULL COMMENT '登录操作人',
operator_name VARCHAR(200) NULL COMMENT '操作人名称快照',
command_key VARCHAR(64) NOT NULL COMMENT '来源内幂等命令键',
request_hash CHAR(64) NOT NULL COMMENT '来源、操作人及请求摘要',
created_at DATETIME(3) NOT NULL COMMENT '核实UTC时间',
PRIMARY KEY (id),
UNIQUE KEY uk_ll_cargo_verification_version (customer_id,source_key,version),
UNIQUE KEY uk_ll_cargo_verification_command (customer_id,source_key,command_key),
CONSTRAINT chk_ll_cargo_verification_quantity CHECK (package_count IS NULL OR package_count > 0),
CONSTRAINT chk_ll_cargo_verification_version CHECK (version > 0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='预约货物核实资料及不可覆盖的版本历史';
CREATE TABLE ll_fba_appointment_command (
command_key VARCHAR(128) NOT NULL COMMENT '持久化幂等键',
request_hash CHAR(64) NOT NULL COMMENT '操作、操作者和请求摘要',
@@ -0,0 +1,11 @@
-- 自营库存周转后端导出权限;不建表,不修改销量、库存或采集任务。
USE baiyue_iam;
SET NAMES utf8mb4;
START TRANSACTION;
INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,created_at,updated_at,deleted) VALUES
(5118002,0,'PLATFORM','API',5118120,'platform:self-operated-analytics:export','导出平台自营库存周转','/api/platform/v1/files/export-tasks','POST',20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0)
ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name),route_path=VALUES(route_path),http_method=VALUES(http_method),status='ACTIVE',deleted=0,updated_at=CURRENT_TIMESTAMP(3);
INSERT INTO iam_role_permission (role_id,permission_id,tenant_id,created_at)
SELECT 4000001,5118002,0,CURRENT_TIMESTAMP(3)
WHERE NOT EXISTS (SELECT 1 FROM iam_role_permission WHERE role_id=4000001 AND permission_id=5118002 AND tenant_id=0);
COMMIT;
@@ -2,7 +2,7 @@
-- MySQL 8.0+;只允许目标测试环境的服务 schema 为空,已有表立即报错;禁止 --force/遇错继续。
-- 字段、索引、约束及注释直接定义在 CREATE TABLE 内;不回填、不清理、不改造存量。
-- 国内 IAM 管理两区域权限;海外不访问国内库。执行方法及系统种子清单见同目录 README.md。
-- DDL 隐式提交;失败后停止,不从中间续跑,也不把部分初始化当成成功。
-- DDL 隐式提交;失败后停止,不从中间续跑,也不把部分初始化当成成功。utf8mb4_0900_ai_ci
SET NAMES utf8mb4 COLLATE utf8mb4_0900_ai_ci;
SET SESSION sql_mode = 'ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION';
@@ -1422,7 +1422,6 @@ CREATE TABLE fl_consumed_event (
CREATE TABLE fl_container_booking (
id bigint unsigned NOT NULL COMMENT '货柜预订ID(雪花ID)',
booking_no varchar(40) NOT NULL COMMENT '平台唯一货柜预订单号',
container_no varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '真实集装箱号权威快照;同一订舱内唯一,不同提单允许复用;确认前允许修订',
cabinet_no varchar(64) NOT NULL COMMENT '平台唯一内部柜号',
source_type varchar(24) NOT NULL DEFAULT 'SIMULATION' COMMENT '记录来源:SIMULATION内部模拟柜、OCEAN_BOOKING订舱真实柜',
@@ -1477,7 +1476,6 @@ CREATE TABLE fl_container_booking (
updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '货柜资料最后更新时间',
version int unsigned NOT NULL DEFAULT '0' COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_container_booking_no (booking_no),
UNIQUE KEY uk_container_booking_cabinet (cabinet_no),
UNIQUE KEY uk_container_booking_ocean_container (ocean_booking_id,container_no),
KEY idx_container_booking_container_no (container_no,updated_at,id),
@@ -4104,6 +4102,27 @@ CREATE TABLE fl_split_operation (
CONSTRAINT fk_split_operation_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='分柜操作幂等与审计索引';
-- Current schema for actual destination unloading; never an overseas warehouse receipt.
CREATE TABLE fl_direct_delivery_unloading (
container_booking_id BIGINT UNSIGNED NOT NULL,
inbound_id BIGINT UNSIGNED NOT NULL COMMENT 'Immutable physically loaded inbound source',
actual_at DATETIME(3) NOT NULL COMMENT 'Actual destination local time',
timezone VARCHAR(64) NOT NULL COMMENT 'Explicit destination IANA timezone',
received_by VARCHAR(128) NOT NULL COMMENT 'Actual recipient',
evidence_no VARCHAR(128) NOT NULL COMMENT 'Actual delivery receipt reference',
remark VARCHAR(1000) NULL,
version INT NOT NULL DEFAULT 1,
idempotency_key VARCHAR(128) NOT NULL,
request_hash CHAR(64) NOT NULL,
created_by BIGINT UNSIGNED NOT NULL,
created_by_name VARCHAR(64) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (container_booking_id,inbound_id),
UNIQUE KEY uk_direct_unloading_command (container_booking_id,idempotency_key),
CONSTRAINT fk_direct_unloading_container FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='直送目的仓真实卸货凭据';
CREATE TABLE fl_terminal_drayage (
id bigint unsigned NOT NULL COMMENT '目的港码头拖车聚合ID',
container_booking_id bigint unsigned NOT NULL COMMENT '唯一归属物理货柜ID',
@@ -5337,7 +5356,7 @@ CREATE TRIGGER trg_container_booking_before_update BEFORE UPDATE ON fl_container
SELECT MAX(plan_status) INTO current_plan_status
FROM baiyue_firstleg.fl_loading_plan WHERE container_booking_id = OLD.id;
IF current_plan_status IN ('LOADING', 'COMPLETED') AND (
NOT (OLD.id <=> NEW.id) OR NOT (OLD.booking_no <=> NEW.booking_no)
NOT (OLD.id <=> NEW.id)
OR NOT (OLD.cabinet_no <=> NEW.cabinet_no)
OR NOT (OLD.channel_group_code <=> NEW.channel_group_code)
OR NOT (OLD.channel_code <=> NEW.channel_code) OR NOT (OLD.channel_name <=> NEW.channel_name)
@@ -6337,10 +6356,9 @@ INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id,
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107011,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:create','创建海外入仓计划',NULL,'POST',711,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107012,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:edit','编辑海外入仓计划',NULL,'POST',712,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107013,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:fba-appointment','FBA预约',NULL,'POST',713,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107014,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:smart-dispatch','智能排车',NULL,'POST',714,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107015,0,'PLATFORM','BUTTON',5107010,'lastleg:pda:operate','海外仓PDA入仓作业',NULL,'POST',715,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107016,0,'PLATFORM','BUTTON',5107064,'platform:overseas-warehouse:dispatch:edit','编辑海外排车',NULL,'PUT',716,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107017,0,'PLATFORM','BUTTON',5107064,'platform:overseas-warehouse:dispatch:cancel','取消海外排车',NULL,'DELETE',717,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107017,0,'PLATFORM','BUTTON',5107064,'platform:overseas-warehouse:dispatch:cancel','取消海外排车',NULL,'POST',717,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107018,0,'PLATFORM','BUTTON',5107064,'platform:overseas-warehouse:dispatch:transfer-delivery','排车转派送',NULL,'POST',718,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107020,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:relabel-order:list','海外换标单','/overseas-warehouse/relabel-orders','GET',720,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107021,0,'PLATFORM','BUTTON',5107020,'platform:overseas-warehouse:relabel-order:create','创建海外换标单',NULL,'POST',721,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
@@ -6362,6 +6380,9 @@ INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id,
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107062,0,'PLATFORM','BUTTON',5107060,'platform:overseas-warehouse:location:edit','编辑海外库位',NULL,'PUT',762,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107063,0,'PLATFORM','BUTTON',5107060,'platform:overseas-warehouse:location:delete','删除海外库位',NULL,'DELETE',763,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107064,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:dispatch:list','海外排车单','/overseas-warehouse/dispatches','GET',764,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107065,0,'PLATFORM','BUTTON',5107064,'platform:overseas-warehouse:dispatch:create','新增排车单',NULL,'POST',765,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107066,0,'PLATFORM','BUTTON',5107064,'platform:overseas-warehouse:dispatch:loading-complete','确认装车完成',NULL,'POST',766,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107067,0,'PLATFORM','BUTTON',5107064,'platform:overseas-warehouse:dispatch:depart','确认实际发车',NULL,'POST',767,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107070,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:exception-order:list','海外异常订单','/overseas-warehouse/exception-orders','GET',770,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107071,0,'PLATFORM','BUTTON',5107070,'platform:overseas-warehouse:exception-order:create','新增海外异常订单',NULL,'POST',771,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107072,0,'PLATFORM','BUTTON',5107092,'platform:overseas-warehouse:delivery:return','派送单标记退货',NULL,'POST',772,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
@@ -6439,7 +6460,6 @@ INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id,
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5305020,0,'MERCHANT','MENU',5300000,'merchant:finance:account:view','资金账户','/finance/account','GET',50,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5305021,0,'MERCHANT','BUTTON',5305020,'merchant:finance:recharge:create','提交充值申请',NULL,'POST',10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5306000,0,'MERCHANT','MENU',0,'merchant:warehouse:delivery:list','海外仓派送单','/orders/delivery','GET',60,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5306001,0,'MERCHANT','BUTTON',5306000,'merchant:warehouse:delivery:create','创建海外仓派送单',NULL,'POST',10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5306100,0,'MERCHANT','MENU',0,'merchant:cross-border:procurement:list','海外采购','/cross-border/procurement','GET',70,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5306101,0,'MERCHANT','BUTTON',5306100,'merchant:cross-border:procurement:create','创建海外采购单',NULL,'POST',10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5306110,0,'MERCHANT','MENU',0,'merchant:cross-border:domestic-trucking:list','美国本土卡','/cross-border/domestic-trucking','GET',80,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
@@ -6718,7 +6738,6 @@ INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at)
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107011,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107012,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107013,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107014,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107016,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107017,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107018,0,CURRENT_TIMESTAMP(3));
@@ -6742,6 +6761,9 @@ INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at)
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107062,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107063,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107064,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107065,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107066,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107067,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107070,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107071,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107072,0,CURRENT_TIMESTAMP(3));
@@ -7019,6 +7041,9 @@ INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at)
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000004,5107050,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000004,5107060,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000004,5107064,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000004,5107065,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000004,5107066,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000004,5107067,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000004,5107070,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000004,5107071,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000004,5107072,0,CURRENT_TIMESTAMP(3));
@@ -7102,7 +7127,6 @@ INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at)
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5305020,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5305021,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306000,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306001,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306100,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306101,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306110,0,CURRENT_TIMESTAMP(3));
@@ -7123,7 +7147,9 @@ INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at)
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306172,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306180,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306181,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5306183,0,'MERCHANT','BUTTON',5306180,'merchant:overseas-warehouse:discard:confirm','确认废弃完成',NULL,'POST',30,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306182,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306183,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306190,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306191,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306192,0,CURRENT_TIMESTAMP(3));
@@ -8315,4 +8341,33 @@ SELECT 0,r.id,p.id,NOW(3) FROM iam_role r JOIN iam_permission p
WHERE r.tenant_id=0 AND r.app_code='PLATFORM' AND UPPER(r.role_code) IN('PLATFORM_ADMIN','PLATFORM_CUSTOM_ADMIN')
AND r.status='ACTIVE' AND r.deleted=0;
SELECT 'PASS' AS initialization_status, 'CN' AS region, 226 AS initialized_tables;
-- 自营业务菜单资源用于菜单管理;四个页面和所有只读接口统一由 list 授权,采集写权限独立。
USE baiyue_iam;
SET NAMES utf8mb4;
INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,created_at,updated_at,deleted) VALUES
(5118100,0,'PLATFORM','MENU',0,'platform:self-operated:menu','自营业务',NULL,NULL,1100,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0),
(5118140,0,'PLATFORM','MENU',5118100,'platform:self-operated:overview:menu','经营总览','/self-operated/overview','GET',5,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0),
(5118110,0,'PLATFORM','MENU',5118100,'platform:self-operated:sales:menu','销量分析','/self-operated/sales','GET',10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0),
(5118120,0,'PLATFORM','MENU',5118100,'platform:self-operated:inventory:menu','库存分析','/self-operated/inventory','GET',20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0),
(5118130,0,'PLATFORM','MENU',5118100,'platform:self-operated:collection:menu','数据采集','/self-operated/collection','GET',30,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0),
(5118000,0,'PLATFORM','API',5118100,'platform:self-operated-analytics:list','读取平台自营出库销量与库存分析','/api/platform/v1/last-leg/self-operated-analytics','GET',40,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0),
(5118002,0,'PLATFORM','API',5118120,'platform:self-operated-analytics:export','导出平台自营库存周转','/api/platform/v1/files/export-tasks','POST',20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0),
(5118001,0,'PLATFORM','API',5118130,'platform:self-operated-analytics:sync','平台自营采集重试与重采集','/api/platform/v1/last-leg/self-operated-analytics/sync-tasks','POST',10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_role_permission (role_id,permission_id,tenant_id,created_at) VALUES
(4000001,5118000,0,CURRENT_TIMESTAMP(3)),
(4000001,5118001,0,CURRENT_TIMESTAMP(3)),
(4000001,5118002,0,CURRENT_TIMESTAMP(3)),
(4000001,5118100,0,CURRENT_TIMESTAMP(3)),
(4000001,5118140,0,CURRENT_TIMESTAMP(3)),
(4000001,5118110,0,CURRENT_TIMESTAMP(3)),
(4000001,5118120,0,CURRENT_TIMESTAMP(3)),
(4000001,5118130,0,CURRENT_TIMESTAMP(3));
-- Appointment work independent write permissions (current baseline).
USE baiyue_iam;
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107283,0,'PLATFORM','BUTTON',5107280,'platform:appointment:manage-work','维护预约工作安排',NULL,'POST',743,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107283,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102213,0,'PLATFORM','BUTTON',5102200,'platform:firstleg:terminal-drayage:direct-unload:confirm','登记直送目的仓实际卸货',NULL,'POST',60,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102213,0,CURRENT_TIMESTAMP(3));
SELECT 'PASS' AS initialization_status, 'CN' AS region, 227 AS initialized_tables;
File diff suppressed because it is too large Load Diff
+2
View File
@@ -56,3 +56,5 @@
| PDF 标签、审计、跨区双向事件 | `print/`、`audit-service/`、`event-bridge-service/` | 事件模型先读 `common-event-contract/AGENTS.md`;国内真实生产验收 `firstleg/.../TaillegRealProducerAcceptanceTest.java`、海外真实消费验收 `lastleg-service/.../TaillegRealConsumerAcceptanceTest.java`;可靠投递与 CN/OVERSEAS 目标路由读 `event-bridge-service/AGENTS.md` |
散货托盘码与跨区传递:`firstleg/` 的 `LoadingPalletLabelService.java` 生成冻结标签并调用 Print 输出 PDF,`LoadingCompletedOutboxService.java` 传递装柜快照;`lastleg-service/` 通过 `LastlegSourcePalletMapper.java` 保存来源托盘,`InboundPlanService.java` 和 `ContainerInboundPlanConversionCoordinator.java` 消费。标签与资源预留的细节继续读对应服务指引。
平台自营销量、产品库存与采集:正式入口为 ../docs/自营业务/README.md;适配从 integration-service/AGENTS.md 进入,持久化、指标和平台运行查询从 lastleg-service/AGENTS.md 进入 selfoperated/analytics/,权限源为 iam/src/main/resources/db/selfoperated-analytics-permissions.sql。Admin 从 ../apps/admin-web/src/modules/self-operated/AGENTS.md 进入;与客户物流订单和仓储作业库存分开。
Binary file not shown.

After

Width:  |  Height:  |  Size: 236 KiB

+90
View File
@@ -0,0 +1,90 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1800" height="1280" viewBox="0 0 1800 1280">
<defs>
<marker id="arrow" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto"><path d="M0,0 L10,3 L0,6 Z" fill="#335c81"/></marker>
<marker id="greenArrow" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto"><path d="M0,0 L10,3 L0,6 Z" fill="#18865b"/></marker>
<marker id="redArrow" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto"><path d="M0,0 L10,3 L0,6 Z" fill="#c94d4d"/></marker>
<style>
.title{font:700 34px 'Microsoft YaHei','Noto Sans SC',sans-serif;fill:#17324d}
.sub{font:400 17px 'Microsoft YaHei','Noto Sans SC',sans-serif;fill:#547086}
.lane{font:700 20px 'Microsoft YaHei','Noto Sans SC',sans-serif;fill:#17324d}
.boxTitle{font:700 19px 'Microsoft YaHei','Noto Sans SC',sans-serif;fill:#17324d}
.boxText{font:400 16px 'Microsoft YaHei','Noto Sans SC',sans-serif;fill:#36566d}
.small{font:400 14px 'Microsoft YaHei','Noto Sans SC',sans-serif;fill:#547086}
.assert{font:500 16px 'Microsoft YaHei','Noto Sans SC',sans-serif;fill:#23445a}
.line{stroke:#335c81;stroke-width:3;fill:none;marker-end:url(#arrow)}
.gline{stroke:#18865b;stroke-width:3;fill:none;marker-end:url(#greenArrow)}
.rline{stroke:#c94d4d;stroke-width:3;fill:none;marker-end:url(#redArrow)}
</style>
</defs>
<rect width="1800" height="1280" fill="#f6f9fc"/>
<text x="70" y="62" class="title">海外业务|库存变动测试总流程图</text>
<text x="70" y="94" class="sub">测试看库存:先看库存状态,再看数量、位置、流水和幂等;灰色节点只改状态/资源,不直接改可用库存。</text>
<g transform="translate(70,125)">
<rect width="1660" height="58" rx="12" fill="#e9f1f8"/>
<circle cx="28" cy="29" r="10" fill="#18865b"/><text x="50" y="35" class="small">绿色:库存增加/恢复</text>
<circle cx="250" cy="29" r="10" fill="#c94d4d"/><text x="272" y="35" class="small">红色:库存扣减</text>
<circle cx="450" cy="29" r="10" fill="#e19a28"/><text x="472" y="35" class="small">橙色:冻结/释放</text>
<circle cx="690" cy="29" r="10" fill="#7657a8"/><text x="712" y="35" class="small">紫色:SKU/箱唛变化</text>
<circle cx="960" cy="29" r="10" fill="#78909c"/><text x="982" y="35" class="small">灰色:状态/资源变化</text>
<text x="1260" y="35" class="small">所有写操作:租户隔离 + 版本/锁 + 幂等键</text>
</g>
<g transform="translate(70,220)">
<rect width="1660" height="196" rx="16" fill="#edf5ee" stroke="#b7d9c2" stroke-width="2"/>
<text x="24" y="34" class="lane">A. 库存来源与入仓</text>
<rect x="24" y="58" width="240" height="96" rx="12" fill="#fff" stroke="#6ea984" stroke-width="2"/>
<text x="44" y="88" class="boxTitle">头程装柜完成事件</text><text x="44" y="116" class="boxText">生成尾程单 / 冻结明细</text><text x="44" y="140" class="small">在途:不可用库存</text>
<path d="M264 106 H330" class="line"/>
<rect x="330" y="58" width="250" height="96" rx="12" fill="#fff" stroke="#6ea984" stroke-width="2"/>
<text x="350" y="88" class="boxTitle">平台转换入仓计划</text><text x="350" y="116" class="boxText">按入仓单 / 推荐托数建计划</text><text x="350" y="140" class="small">仍未形成可用库存</text>
<path d="M580 106 H646" class="line"/>
<rect x="646" y="58" width="290" height="96" rx="12" fill="#fff" stroke="#18865b" stroke-width="3"/>
<text x="666" y="88" class="boxTitle">PDA 签收 → 实收 → 上架</text><text x="666" y="116" class="boxText">完成后入仓库存入账</text><text x="666" y="140" class="small">AVAILABLE + 位置 + 库存流水</text>
<path d="M936 106 H1002" class="gline"/>
<rect x="1002" y="58" width="280" height="96" rx="12" fill="#e6f6ed" stroke="#18865b" stroke-width="2"/>
<text x="1022" y="88" class="boxTitle">可用库存池</text><text x="1022" y="116" class="boxText">客户 × 海外仓 × 目的仓</text><text x="1022" y="140" class="small">入仓单汇总;托盘只做作业维度</text>
<rect x="1316" y="58" width="310" height="96" rx="12" fill="#f0f2f4" stroke="#9baab4" stroke-width="2"/>
<text x="1336" y="88" class="boxTitle">门禁与失败</text><text x="1336" y="116" class="boxText">照片、仓绑定、客户/柜号</text><text x="1336" y="140" class="small">任一失败:库存与状态整体回滚</text>
</g>
<g transform="translate(70,450)">
<rect width="1660" height="222" rx="16" fill="#fff8ec" stroke="#efd39a" stroke-width="2"/>
<text x="24" y="34" class="lane">B. 从可用库存出发的四类作业</text>
<rect x="24" y="60" width="360" height="126" rx="12" fill="#fff" stroke="#dfa43c" stroke-width="2"/>
<text x="44" y="88" class="boxTitle">排车 / 一件代发拣货</text><text x="44" y="116" class="boxText">AVAILABLE → 冻结 → PICKED</text><text x="44" y="144" class="boxText">发车/出库 → 扣减</text><text x="44" y="170" class="small">整托扫描也按入仓单组成核平</text>
<rect x="430" y="60" width="360" height="126" rx="12" fill="#fff" stroke="#7657a8" stroke-width="2"/>
<text x="450" y="88" class="boxTitle">换标</text><text x="450" y="116" class="boxText">冻结 hold_packages</text><text x="450" y="144" class="boxText">旧 SKU 转出 → 新 SKU 转入</text><text x="450" y="170" class="small">箱唛/托盘绑定变化,箱数守恒</text>
<rect x="836" y="60" width="360" height="126" rx="12" fill="#fff" stroke="#dfa43c" stroke-width="2"/>
<text x="856" y="88" class="boxTitle">仓储转运</text><text x="856" y="116" class="boxText">来源仓冻结/扣减</text><text x="856" y="144" class="boxText">目标仓收货 → 增加</text><text x="856" y="170" class="small">可联动换标;来源/目标核对</text>
<rect x="1242" y="60" width="384" height="126" rx="12" fill="#fff" stroke="#c94d4d" stroke-width="2"/>
<text x="1262" y="88" class="boxTitle">废弃</text><text x="1262" y="116" class="boxText">申请仅锁箱:DISCARD_PENDING</text><text x="1262" y="144" class="boxText">PDA 实际确认 → 扣减 → DISCARDED</text><text x="1262" y="170" class="small">驳回释放;平台结案不再扣一次</text>
</g>
<g transform="translate(70,706)">
<rect width="1660" height="222" rx="16" fill="#eef3fa" stroke="#b7cae2" stroke-width="2"/>
<text x="24" y="34" class="lane">C. 出库后的结果与恢复</text>
<rect x="24" y="60" width="350" height="126" rx="12" fill="#fff" stroke="#c94d4d" stroke-width="2"/>
<text x="44" y="88" class="boxTitle">OUTBOUNDED / 派送中</text><text x="44" y="116" class="boxText">库存已扣减,保留出库事实</text><text x="44" y="144" class="boxText">签收:不再二次扣库存</text><text x="44" y="170" class="small">派送费用与库存动作分开核对</text>
<path d="M374 123 H446" class="line"/>
<rect x="446" y="60" width="380" height="126" rx="12" fill="#fff" stroke="#18865b" stroke-width="3"/>
<text x="466" y="88" class="boxTitle">异常退回入仓</text><text x="466" y="116" class="boxText">只能选原出库拣货事实</text><text x="466" y="144" class="boxText">RETURN_STOCK_IN → 目标托盘/库位</text><text x="466" y="170" class="small">库存恢复 + 异常完成 + 派送 RETURNED</text>
<rect x="900" y="60" width="330" height="126" rx="12" fill="#fff" stroke="#78909c" stroke-width="2"/>
<text x="920" y="88" class="boxTitle">盘点调整</text><text x="920" y="116" class="boxText">实盘数量 − 账面数量 = 差异</text><text x="920" y="144" class="boxText">按正/负生成调整流水</text><text x="920" y="170" class="small">无差异不造流水;需 adjust 权限</text>
<rect x="1270" y="60" width="356" height="126" rx="12" fill="#fff" stroke="#78909c" stroke-width="2"/>
<text x="1290" y="88" class="boxTitle">移库 / 拆合托 / 换绑</text><text x="1290" y="116" class="boxText">同仓位置或托盘组成变化</text><text x="1290" y="144" class="boxText">数量净额 = 0</text><text x="1290" y="170" class="small">记录位置流水;资源锁防并发</text>
</g>
<path d="M1142 416 V450" class="gline"/><path d="M120 638 V706" class="rline"/><path d="M610 638 V706" class="gline"/>
<path d="M1020 638 V706" class="line"/><path d="M1435 638 V706" class="rline"/>
<g transform="translate(70,965)">
<rect width="1660" height="250" rx="16" fill="#ffffff" stroke="#d5e0e8" stroke-width="2"/>
<text x="24" y="36" class="lane">D. 测试统一断言(每个库存场景都要检查)</text>
<text x="42" y="78" class="assert">① 数量守恒:客户 / 海外仓 / 目的仓 / 入仓单 / 托盘组成逐层核平</text>
<text x="42" y="114" class="assert">② 三账一致:库存余额 = 位置明细汇总 = 库存流水结果</text>
<text x="42" y="150" class="assert">③ 幂等并发:重复事件、重复请求、并发锁定不得产生第二笔变动</text>
<text x="42" y="186" class="assert">④ 原子回滚:权限、门禁、资源锁、跨服务失败时,库存/单据/流水一起回滚</text>
<text x="42" y="222" class="assert">⑤ 隔离校验:客户、仓、来源入仓单、原出库事实不可伪造或串用</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 9.2 KiB

@@ -0,0 +1,40 @@
# 海外业务库存变动测试整理
范围依据:`lastleg-service/AGENTS.md`、`docs/backend-business-index.md` 及当前海外仓库存、入仓、出库、换标、转运、废弃、异常退回和 PDA 作业入口。
## 先统一库存口径
- **可用库存**:按客户、海外仓、目的仓、入仓单汇总箱数;SKU、箱唛和托盘是明细快照/作业维度。
- **冻结库存**:拣货、换标、废弃、转运等处理中暂时不可重复使用;冻结不等于已经扣减。
- **库存流水**:每一次入账、冻结、释放、扣减、恢复、SKU 转换和位置变化都应能按业务单号、幂等键追溯。
- **托盘不是库存归属**:合托后一个物理托盘可以包含多个入仓单,出库、换标和退回必须按托盘组成明细核平。
## 测试场景矩阵
| 场景 | 触发与前置 | 库存变动 | 测试重点 |
|---|---|---|---|
| 头程接柜/海外入仓 | 装柜完成事件 → 生成尾程单/入仓计划;PDA 签收、实收、上架、完成 | 在途冻结 → 入仓后 `AVAILABLE` 增加;写库存位置和流水 | 柜号/客户/入仓单隔离;卸柜照片门禁;重复事件不重复入账;失败整事务回滚 |
| 普通排车出库/派送 | 计划已入仓;排车审核、拣货、箱数核平、发车 | `AVAILABLE` → 冻结/已拣;原子出库后可用库存扣减 | 整托/部分托按组成明细核平;未审核、数量不平、车辆门禁时不得出库;同键重放幂等 |
| 一件代发 | 已完成入库且有可用箱唛/SKU;创建 `WAIT_PICK` 出库单 | SKU/箱唛库存冻结 → PDA 拣货 → `OUTBOUNDED` 扣减 | 候选只能来自真实可用库存;一单不得跨客户/跨仓;整托扫描任一明细失败必须整体回滚 |
| 客户换标 | 历史入仓库存可用;客户申请、PDA 扫旧 SKU/箱唛、完工、客户确认 | 旧 SKU/箱唛冻结;换标完成后旧库存转出、新 SKU/箱唛转入;库存总箱数守恒 | 同箱多 SKU 不重复冻结;只移动本单 `hold_packages`;已生成出库单禁止再次拆托;照片/复核/客户确认门禁 |
| 仓储转运 | 来源库存可用;创建转运、测量、换标(可选)、出库、目标仓收货 | 来源仓冻结/扣减 → 目标仓入账;换标时 SKU 转换 | 来源客户、入仓单和箱数核对;部分转运数量守恒;目标仓与来源仓不能串;重复完成不重复入账 |
| 货物废弃 | 一件代发/库存货物申请废弃;审批后由绑定海外 PDA 实际确认 | `AVAILABLE` → `DISCARD_PENDING`;实际确认后 SKU、余额、位置扣减并记 `DISCARDED` | 申请阶段只锁箱不扣库存;驳回释放;批准不是实际扣减;实际数量与逐箱流水一致;失败整批回滚 |
| 异常退回入仓 | 只能选择原出库拣货事实;异常单受理并完成退回 | 原出库来源核验 → `RETURN_STOCK_IN` 恢复到真实目标托盘/库位 | 不能伪造出库来源、借用其他客户主单;退回数量与原实发核平;异常单完成与派送 `RETURNED` 同事务 |
| PDA 盘点调整 | 绑定海外仓;盘点锁定库存位置并提交实盘数量 | 按差异产生增加/减少调整流水;无差异不产生虚假变动 | 需要 `inventory:adjust`;并发版本校验;差异正负方向正确;盘点重复提交幂等 |
| PDA 移库/托盘拆合/换绑 | 同仓有效托盘/库位;资源锁成功 | 数量净额不变,仅位置/托盘组成变化,写位置流水 | 不跨仓;托盘组成明细保留;资源锁释放;出库/换标占用时禁止绕过协调单独移动 |
## 不应误判为库存变动
- 目的仓修改、暂扣/解除、报价、排车预排、外接预约、车辆/司机资料:只改变状态、资格、资源占用或费用快照。
- 海外采购样品履约:按当前契约走 Admin 台账,**不接入 PDA,也不写 WMS 可用库存**。
- 退货申请/废弃申请/转派送建单:申请或建单阶段只形成冻结/候选/出库单,必须到实际收货、实际扣减或实际出库节点才确认数量变化。
## 每条库存用例的统一断言
1. 变动前后按客户、海外仓、目的仓、入仓单、托盘组成核对数量守恒。
2. 查询余额、位置明细和库存流水三处结果一致,且业务单号/幂等键可追溯。
3. 重复请求、重复事件、并发操作不得新增第二笔库存变动。
4. 任一校验、权限、资源锁或跨服务依赖失败时,库存、单据状态和流水一起回滚。
5. 客户越权、跨仓、跨客户、伪造来源入仓单/出库单均应拒绝。
流程图:`overseas-inventory-mutation-flow.svg` / `overseas-inventory-mutation-flow.png`
+5
View File
@@ -1,5 +1,10 @@
# File Service 模块指引
## 数据库访问与中文注释约束
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
## 职责与边界
- Mapper 只由 `FileServiceApplication.java` 对 `com.baiyue.file` 根包按 `@Mapper` 过滤扫描;新增或移动 Mapper 后从后端根运行 `node scripts/mapper-scan-contract.mjs`,不得增加子域扫描配置。
@@ -9,6 +9,8 @@ import java.util.Arrays;
enum BusinessFileCategory {
FLEET_DRIVER_LICENSE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
FLEET_TRIP_OPERATION_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_DISPATCH_VEHICLE_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_DISPATCH_DRIVER_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
FLEET_VEHICLE_RECORD_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
DELIVERY_LOADING_PHOTO(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG),
DELIVERY_BOL(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG),
@@ -31,6 +33,7 @@ enum BusinessFileCategory {
CONTAINER_DELIVERY_ORDER(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
CONTAINER_PICKUP_ORDER(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_UNLOAD_PHOTO(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_CARGO_DISCARD_PHOTO(true, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_RELABEL_PHOTO(true, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_TRANSFER_MEASUREMENT_PHOTO(true, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_RELABEL_TOTAL_DOCUMENT(true, FormatPolicy.PDF, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
@@ -66,7 +69,12 @@ enum BusinessFileCategory {
.orElseThrow(() -> new BizException(ResultCode.BAD_REQUEST, "不支持的附件业务类型"));
}
/** 校验文件归属;跨客户排车证据固定为平台所有,不随货物客户变化。 */
void validateOwner(long tenantId) {
if ((this == OVERSEAS_DISPATCH_VEHICLE_EVIDENCE || this == OVERSEAS_DISPATCH_DRIVER_EVIDENCE)
&& tenantId != 0) {
throw new BizException(ResultCode.BAD_REQUEST, "排车车辆及司机证据必须归属平台");
}
if (this == FLEET_TRIP_OPERATION_EVIDENCE && tenantId != 0) {
throw new BizException(ResultCode.BAD_REQUEST, "出车实测凭证必须归属平台");
}
@@ -21,9 +21,11 @@ import java.time.LocalDateTime;
import java.util.Locale;
import java.util.Map;
import java.util.TreeMap;
import java.util.Set;
@Service
public class ExportTaskService {
private static final String TURNOVER = "SELFOP_INVENTORY_TURNOVER";
@Resource private ExportTaskRepository repository;
@Resource private ExportStorage storage;
@Resource private ObjectMapper objectMapper;
@@ -34,6 +36,7 @@ public class ExportTaskService {
public ExportTaskResponse create(AuthenticatedUser user, CreateExportTaskRequest request) {
requirePlatform(user);
String type = request.exportType().trim().toUpperCase(Locale.ROOT);
if (TURNOVER.equals(type) && user.impersonated()) throw new BizException(ResultCode.FORBIDDEN, "平台自营导出不支持代登录");
String format = request.format().trim().toUpperCase(Locale.ROOT);
var definition = registry.require(type);
if (!"XLSX".equals(format)) throw new BizException(ResultCode.VALIDATION_FAILED, "当前仅支持 XLSX 格式");
@@ -135,6 +138,9 @@ public class ExportTaskService {
}
private void requireExportCostAccess(AuthenticatedUser user, FileExportTask task) {
if (TURNOVER.equals(task.getExportType()) && (user.impersonated() || !user.permissions().contains("platform:self-operated-analytics:export"))) {
throw new BizException(ResultCode.FORBIDDEN, "缺少平台自营导出权限");
}
if (!"TRIP_OPERATIONS_LIST".equals(task.getExportType())) return;
try {
if (objectMapper.readTree(task.getParametersJson()).path("costVisible").asBoolean(false)
@@ -172,11 +178,14 @@ public class ExportTaskService {
}
values.forEach((key, value) -> {
if (key == null || key.isBlank() || key.length() > 64) throw new BizException(ResultCode.VALIDATION_FAILED, "导出筛选键无效");
validateParameterValue(value, 1);
if (TURNOVER.equals(type) && "snapshotContext".equals(key)) {
if (!(value instanceof String text) || text.isBlank() || text.length() > 16000)
throw new BizException(ResultCode.VALIDATION_FAILED, "库存周转查询版本无效,请刷新后重新导出");
} else validateParameterValue(value, 1);
});
try {
int maxBytes = ExportSchemaContracts.DOMESTIC_WAREHOUSE_ORDER_LIST.equals(type)
? 512 * 1024 : 4096;
? 512 * 1024 : TURNOVER.equals(type) ? 64 * 1024 : 4096;
if (objectMapper.writeValueAsBytes(values).length > maxBytes) {
throw new BizException(ResultCode.VALIDATION_FAILED,
"导出筛选条件不能超过 " + (maxBytes / 1024) + "KB");
@@ -210,6 +219,22 @@ public class ExportTaskService {
}
private void validateTypeParameters(String type, Map<String, Object> parameters) {
if (TURNOVER.equals(type)) {
Map<String,Object> values=parameters==null?Map.of():parameters;
if (!values.keySet().containsAll(Set.of("sourceAccount","warehouseCode","endDate","baseWindowDays","snapshotContext","columns","expectedTotal"))
|| !Set.of("sourceAccount","warehouseCode","endDate","baseWindowDays","groupBy","customerCodes","sku","focus","coverageDaysThreshold","turnoverStatus","sortBy","hideZeroInventory","snapshotContext","columns","expectedTotal").containsAll(values.keySet()))
throw new BizException(ResultCode.VALIDATION_FAILED, "库存周转导出筛选无效");
if (!(values.get("columns") instanceof java.util.List<?> columns) || columns.isEmpty() || columns.size()>30
|| columns.stream().anyMatch(v -> !(v instanceof String)) || new java.util.HashSet<>(columns).size()!=columns.size())
throw new BizException(ResultCode.VALIDATION_FAILED, "库存周转导出列无效");
if (values.containsKey("customerCodes") && (!(values.get("customerCodes") instanceof java.util.List<?> owners) || owners.size()>100 || owners.stream().anyMatch(v -> !(v instanceof String))))
throw new BizException(ResultCode.VALIDATION_FAILED, "库存周转货主范围无效");
try {
long count=new java.math.BigDecimal(String.valueOf(values.get("expectedTotal"))).longValueExact();
if(count<0 || count>200000) throw new ArithmeticException();
} catch(RuntimeException e) { throw new BizException(ResultCode.VALIDATION_FAILED, "库存周转导出条数无效"); }
return;
}
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"))) {
+9
View File
@@ -224,6 +224,15 @@ baiyue:
max-rows: 200000
max-attempts: 3
batch-size: 200
SELFOP_INVENTORY_TURNOVER:
provider-service: lastleg-service
provider-path: /internal/exports/v1/SELFOP_INVENTORY_TURNOVER/batches
required-permission: platform:self-operated-analytics:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 100
OVERSEAS_INVENTORY_LEDGER:
provider-service: lastleg-service
provider-path: /internal/exports/v1/OVERSEAS_INVENTORY_LEDGER/batches
@@ -0,0 +1,12 @@
openapi: 3.0.3
info:
title: Cargo discard photo storage ownership contract
version: 1.0.0
description: >-
Uses the existing internal business-file upload, metadata and content endpoints, protected
by X-Internal-Service-Token. businessType is OVERSEAS_CARGO_DISCARD_PHOTO; tenantId is a
positive customer ID and businessId is the positive cargo discard application ID.
File enforces composite ownership and non-empty JPG/PNG bytes with image signature and
10 MiB limit. Lastleg owns the pallet binding, submitted visibility and immutable confirmation.
No anonymous or public file endpoint is introduced.
paths: {}
@@ -0,0 +1,167 @@
openapi: 3.0.3
info:
title: 平台排车车辆与司机证据内部文件契约
version: 1.0.0
description: >-
复用 InternalBusinessFileController 的现有通用内部入口,不新增公开文件路由。
两类证据均固定平台 tenantId=0,businessId 为排车内部正数 Long ID 的十进制表示,
不使用车辆 ID、司机 ID、车牌或客户 ID。车辆与司机证据分别使用
OVERSEAS_DISPATCH_VEHICLE_EVIDENCE、OVERSEAS_DISPATCH_DRIVER_EVIDENCE,不可互换。
File 校验内部服务令牌、平台归属、正数业务 ID、文件非空、最大 10MiB(10485760 字节,包含边界)、
PDF/JPG/PNG MIME 及对应内容签名。元数据和下载按 tenantId、businessType、businessId、fileId
联合查询 ACTIVE 且未删除文件,错车或错类别均视为归属不匹配,下载前完成归属校验。
上传生成不可变文件和真实元数据,不自动绑定证据或通过审核,不返回公开 URL 或存储路径。
Lastleg 负责排车存在性、仓库与角色权限、当前及历史附件引用和审核状态;File 不查询 Lastleg 业务表。
本文只描述排车网关实际消费的上传、元数据和内容三个操作,不改变通用文件接口的其他用途。
统一异常处理使用 HTTP 200 JSON 业务错误,调用方必须检查 code;内容接口错误也返回 JSON,不能当作文件。
security:
- InternalServiceToken: []
paths:
/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}:
parameters:
- {$ref: '#/components/parameters/TenantId'}
- {$ref: '#/components/parameters/BusinessType'}
- {$ref: '#/components/parameters/BusinessId'}
post:
operationId: uploadDispatchEvidence
summary: 上传本排车的车辆或司机证据
parameters:
- name: X-Operator-Id
in: header
required: true
description: Lastleg 已认证操作者的正数 Long ID
schema: {$ref: '#/components/schemas/PositiveLongString'}
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
required: [file]
properties:
file:
type: string
format: binary
description: 非空且不超过10485760字节;application/pdf、image/jpeg或image/png,并校验对应内容签名
responses:
'200': {$ref: '#/components/responses/MetadataOrError'}
/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}/{fileId}:
parameters:
- {$ref: '#/components/parameters/TenantId'}
- {$ref: '#/components/parameters/BusinessType'}
- {$ref: '#/components/parameters/BusinessId'}
- {$ref: '#/components/parameters/FileId'}
get:
operationId: readDispatchEvidenceMetadata
summary: 按本排车与证据类别联合校验并读取元数据
responses:
'200': {$ref: '#/components/responses/MetadataOrError'}
/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}/{fileId}/content:
parameters:
- {$ref: '#/components/parameters/TenantId'}
- {$ref: '#/components/parameters/BusinessType'}
- {$ref: '#/components/parameters/BusinessId'}
- {$ref: '#/components/parameters/FileId'}
get:
operationId: downloadDispatchEvidence
summary: 按完整复合归属读取文件内容
responses:
'200':
description: >-
成功返回真实文件;错误返回 application/json 统一业务错误。
A0400 表示作用域、ID或文件格式无效,A0403 表示内部令牌无效,
A0404 表示文件不存在、非有效文件或复合归属不匹配。
headers:
Cache-Control: {schema: {type: string, enum: [no-store]}}
Content-Disposition: {schema: {type: string}, description: 成功时为attachment及UTF-8文件名}
Content-Length: {schema: {type: integer, format: int64}, description: 成功时为真实文件字节数}
content:
application/pdf: {schema: {type: string, format: binary}}
image/jpeg: {schema: {type: string, format: binary}}
image/png: {schema: {type: string, format: binary}}
application/json: {schema: {$ref: '#/components/schemas/ErrorEnvelope'}}
components:
securitySchemes:
InternalServiceToken: {type: apiKey, in: header, name: X-Internal-Service-Token}
parameters:
TenantId:
name: tenantId
in: path
required: true
description: 两类排车证据只属于平台,客户作用域一律拒绝
schema: {type: string, enum: ['0']}
BusinessType:
name: businessType
in: path
required: true
schema: {$ref: '#/components/schemas/DispatchEvidenceType'}
BusinessId:
name: businessId
in: path
required: true
description: 排车内部正数Long ID,上限9223372036854775807;Lastleg以标准十进制字符串传入
schema: {$ref: '#/components/schemas/PositiveLongString'}
FileId:
name: fileId
in: path
required: true
schema: {$ref: '#/components/schemas/PositiveLongString'}
responses:
MetadataOrError:
description: >-
统一JSON响应,业务成功code为200。A0400为无效平台作用域、ID、操作者或文件;
A0403为内部服务令牌无效;A0404为文件不存在、非ACTIVE、已删除或复合归属不匹配;
S0503为存储不可用,S0500为元数据保存失败等内部异常。
content:
application/json:
schema:
oneOf:
- {$ref: '#/components/schemas/MetadataEnvelope'}
- {$ref: '#/components/schemas/ErrorEnvelope'}
schemas:
PositiveLongString:
type: string
pattern: '^[1-9][0-9]*$'
maxLength: 19
description: 正数Long的十进制字符串,值不得超过9223372036854775807
DispatchEvidenceType:
type: string
enum: [OVERSEAS_DISPATCH_VEHICLE_EVIDENCE, OVERSEAS_DISPATCH_DRIVER_EVIDENCE]
MetadataEnvelope:
type: object
required: [code, data]
properties:
code: {type: string, enum: ['200']}
message: {type: string}
data: {$ref: '#/components/schemas/Metadata'}
requestId: {type: string, nullable: true}
traceId: {type: string, nullable: true}
timestamp: {type: string, format: date-time}
Metadata:
type: object
required: [fileId, ownerScope, tenantId, businessType, businessId, fileName, sha256, contentType, sizeBytes, createdAt]
properties:
fileId: {$ref: '#/components/schemas/PositiveLongString'}
ownerScope: {type: string, enum: [PLATFORM]}
tenantId: {type: string, enum: ['0']}
businessType: {$ref: '#/components/schemas/DispatchEvidenceType'}
businessId: {$ref: '#/components/schemas/PositiveLongString'}
fileName: {type: string, minLength: 1, description: 清理路径和控制字符后的原始文件名}
sha256: {type: string, pattern: '^[0-9a-f]{64}$'}
contentType: {type: string, enum: [application/pdf, image/jpeg, image/png]}
sizeBytes:
type: string
pattern: '^[1-9][0-9]*$'
description: 实际字节数,范围1到10485760;公共Jackson配置将Long输出为字符串
example: '1024'
createdAt: {type: string, description: File真实上传时间,LocalDateTime的ISO本地时间表示,无时区偏移}
ErrorEnvelope:
type: object
required: [code, data]
properties:
code: {type: string, enum: [A0400, A0403, A0404, A0422, S0500, S0503]}
message: {type: string}
data: {type: object, nullable: true, description: 业务错误为null;参数校验错误可携带字段信息}
requestId: {type: string, nullable: true}
traceId: {type: string, nullable: true}
timestamp: {type: string, format: date-time}
@@ -0,0 +1,50 @@
openapi: 3.0.3
info:
title: 库存周转后台导出
version: 1.0.0
description: File 下载中心生成 XLSX,Lastleg 内部 Provider 按固定库存周转版本供数。浏览器不分页取数、不生成文件。
paths:
/api/platform/v1/files/export-tasks:
post:
summary: 提交库存周转 Excel 导出
description: |
仅平台、非代登录身份及 platform:self-operated-analytics:export 权限。
保留列表的 sourceAccount、warehouseCode、endDate、baseWindowDays、groupBy、customerCodes、sku、focus、coverageDaysThreshold、turnoverStatus、sortBy、hideZeroInventory、snapshotContext。
columns 为当前显示列的有序白名单键,服务端决定列名、格式并追加周转率证据列;expectedTotal 为已查询总条数,范围0至200000。
不接受 current/size/includeOverview。snapshotContext 最长16000字符,整组参数最大64KiB。空结果生成只有表头的文件。
后台每批最多100条,沿用固定上下文30分钟有效期;过期、服务重启或历史版本失效须刷新列表重新提交,不自动改用新版本。
版本或条数变化、分页缺失、超行数、生成失败时不提供部分文件。下载中心复用进度、临时失败退避重试、归属校验及过期规则。
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [exportType, format, taskName, parameters]
properties:
exportType: {type: string, enum: [SELFOP_INVENTORY_TURNOVER]}
format: {type: string, enum: [XLSX]}
taskName: {type: string, maxLength: 128}
parameters:
type: object
required: [sourceAccount, warehouseCode, endDate, baseWindowDays, snapshotContext, columns, expectedTotal]
properties:
snapshotContext: {type: string, minLength: 1, maxLength: 16000}
columns: {type: array, minItems: 1, maxItems: 30, uniqueItems: true, items: {type: string}}
expectedTotal: {type: integer, minimum: 0, maximum: 200000}
additionalProperties: true
responses:
'200': {description: ApiResponse 包装下载中心任务;业务错误遵循通用 code 契约}
'403': {description: 无平台自营导出权限或非平台身份}
/api/platform/v1/files/export-tasks/{taskId}/download:
get:
summary: 下载后台生成的库存周转 Excel
description: 校验任务归属、完成状态、有效期,以及当前非代登录身份和平台自营导出权限。
parameters:
- {name: taskId, in: path, required: true, schema: {type: string}}
responses:
'200':
description: XLSX 文件
content:
application/vnd.openxmlformats-officedocument.spreadsheetml.sheet:
schema: {type: string, format: binary}
@@ -13,6 +13,7 @@ class LastlegExportDefinitionContractTest {
Map<?, ?> nacos = definitions(Path.of("../config/nacos/test/file-service.yml"));
Map<String, String> expected = Map.ofEntries(
Map.entry("LAST_MILE_ORDER_LIST", "platform:overseas-warehouse:last-mile-order:export"),
Map.entry("SELFOP_INVENTORY_TURNOVER", "platform:self-operated-analytics:export"),
Map.entry("OVERSEAS_INVENTORY_BALANCE", "platform:overseas-warehouse:inventory:export"),
Map.entry("OVERSEAS_INVENTORY_LEDGER", "platform:overseas-warehouse:inventory:export"),
Map.entry("OVERSEAS_QUOTE_LIST", "platform:overseas-master-data:quote:export"),
@@ -28,7 +29,7 @@ class LastlegExportDefinitionContractTest {
assertEquals("/internal/exports/v1/" + entry.getKey() + "/batches", definition.get("provider-path"));
assertEquals(entry.getValue(), definition.get("required-permission"));
assertEquals("PLATFORM", definition.get("app-scope"));
assertEquals(200, definition.get("batch-size"));
assertEquals(entry.getKey().equals("SELFOP_INVENTORY_TURNOVER") ? 100 : 200, definition.get("batch-size"));
var controlled = new ExportDefinitionRegistry.Definition();
controlled.setProviderService("lastleg-service");
controlled.setProviderPath(String.valueOf(definition.get("provider-path")));
@@ -0,0 +1,25 @@
package com.baiyue.file.service;
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 java.nio.file.Path;
import static org.assertj.core.api.Assertions.*;
class CargoDiscardPhotoStorageTest {
@TempDir Path root;
@Test void discardEvidenceIsCustomerOwnedAndRequiresRealImageSignature() throws Exception {
var category = BusinessFileCategory.require("OVERSEAS_CARGO_DISCARD_PHOTO");
category.validateOwner(7);
assertThatThrownBy(() -> category.validateOwner(0)).isInstanceOf(com.baiyue.common.core.exception.BizException.class);
assertThatThrownBy(() -> category.storageBusinessId("0")).isInstanceOf(com.baiyue.common.core.exception.BizException.class);
var storage = new BusinessFileStorage(); ReflectionTestUtils.setField(storage, "configuredRoot", root); storage.initializeRoot();
byte[] bytes = {(byte)0x89, 0x50, 0x4e, 0x47, 13, 10, 26, 10};
var result = storage.store(7, "11", new MockMultipartFile("file", "discard.png", "image/png", bytes), category);
assertThat(storage.read(result.objectKey())).isEqualTo(bytes);
assertThatThrownBy(() -> storage.store(7, "11", new MockMultipartFile("file", "fake.png", "image/png", "fake".getBytes()), category)).hasMessageContaining("不是有效图片");
assertThatThrownBy(() -> storage.store(7, "11", new MockMultipartFile("file", "doc.pdf", "application/pdf", "%PDF-test".getBytes()), category)).hasMessageContaining("仅支持JPG");
assertThatThrownBy(() -> storage.store(7, "11", new MockMultipartFile("file", "large.png", "image/png", new byte[10*1024*1024+1]), category)).isInstanceOf(com.baiyue.common.core.exception.BizException.class);
}
}
@@ -0,0 +1,245 @@
package com.baiyue.file.service;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.file.controller.InternalBusinessFileController;
import com.baiyue.file.entity.FileObjectEntity;
import com.baiyue.file.mapper.FileObjectMapper;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.multipart.MultipartFile;
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.concurrent.atomic.AtomicLong;
import java.util.regex.Pattern;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class DispatchEvidenceFileTest {
private static final String VEHICLE = "OVERSEAS_DISPATCH_VEHICLE_EVIDENCE";
private static final String DRIVER = "OVERSEAS_DISPATCH_DRIVER_EVIDENCE";
private static final int MAX_BYTES = 10 * 1024 * 1024;
@TempDir Path root;
/** 初始化真实 Lambda 查询列映射,使测试能逐列核对复合归属条件。 */
@BeforeAll static void metadata() {
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), "dispatch-evidence"),
FileObjectEntity.class);
}
/** 两类证据只接受平台与有效排车内部 ID,非法输入不得访问数据库或磁盘。 */
@ParameterizedTest
@ValueSource(strings = {VEHICLE, DRIVER})
void requiresPlatformAndPositiveDispatchIdBeforeAccess(String type) {
var files = mock(FileObjectMapper.class);
var storage = mock(BusinessFileStorage.class);
var service = service(files, storage);
var upload = pdf();
for (long customer : new long[]{-1, 1, 900}) {
assertThatThrownBy(() -> service.upload(customer, type, "71", 7, upload)).isInstanceOf(BizException.class);
assertThatThrownBy(() -> service.metadata(customer, type, "71", 501)).isInstanceOf(BizException.class);
assertThatThrownBy(() -> service.download(customer, type, "71", 501)).isInstanceOf(BizException.class);
}
for (String id : new String[]{"0", "-1", "DISPATCH-71", "../71", "9223372036854775808"}) {
assertThatThrownBy(() -> service.upload(0, type, id, 7, upload)).isInstanceOf(BizException.class);
assertThatThrownBy(() -> service.metadata(0, type, id, 501)).isInstanceOf(BizException.class);
assertThatThrownBy(() -> service.download(0, type, id, 501)).isInstanceOf(BizException.class);
}
assertThatThrownBy(() -> service.upload(0, type, "71", 0, upload)).isInstanceOf(BizException.class);
assertThatThrownBy(() -> service.metadata(0, type, "71", 0)).isInstanceOf(BizException.class);
assertThatThrownBy(() -> service.download(0, type, "71", 0)).isInstanceOf(BizException.class);
verifyNoInteractions(files, storage);
}
/** 真实 PDF、JPG、PNG 均可写入并保持内容;上传不以扩展名代替 MIME 和签名。 */
@ParameterizedTest
@ValueSource(strings = {VEHICLE, DRIVER})
void storesPdfJpegPngAndRejectsEmptyUnsupportedAndSpoofedContent(String type) throws Exception {
var storage = storage();
var category = BusinessFileCategory.require(type);
for (var file : new MockMultipartFile[]{pdf(),
new MockMultipartFile("file", "vehicle.jpg", "image/jpeg", image("jpeg")),
new MockMultipartFile("file", "driver.png", "image/png", image("png"))}) {
var saved = storage.store(0, "71", file, category);
assertThat(storage.read(saved.objectKey())).isEqualTo(file.getBytes());
assertThat(saved.contentType()).isEqualTo(file.getContentType());
assertThat(saved.size()).isEqualTo(file.getSize());
assertThat(saved.sha256()).matches("[0-9a-f]{64}");
}
for (var file : new MockMultipartFile[]{
new MockMultipartFile("file", "empty.pdf", "application/pdf", new byte[0]),
new MockMultipartFile("file", "fake.pdf", "application/pdf", "plain text".getBytes(StandardCharsets.UTF_8)),
new MockMultipartFile("file", "fake.jpg", "image/jpeg", image("png")),
new MockMultipartFile("file", "fake.png", "image/png", image("jpeg")),
new MockMultipartFile("file", "fake.pdf", "text/plain", pdf().getBytes()),
new MockMultipartFile("file", "fake.pdf", "application/octet-stream", pdf().getBytes()),
new MockMultipartFile("file", "missing.pdf", null, pdf().getBytes())}) {
assertThatThrownBy(() -> storage.store(0, "71", file, category)).isInstanceOf(BizException.class);
}
assertThatThrownBy(() -> storage.store(0, "71", null, category)).isInstanceOf(BizException.class);
}
/** 10MiB 为包含边界,超限在读取内容或落盘前拒绝。 */
@ParameterizedTest
@ValueSource(strings = {VEHICLE, DRIVER})
void acceptsExactlyTenMibAndRejectsOneByteOverBeforeReading(String type) throws Exception {
var storage = storage();
var category = BusinessFileCategory.require(type);
byte[] content = Arrays.copyOf(pdf().getBytes(), MAX_BYTES);
var boundary = new MockMultipartFile("file", "boundary.pdf", "application/pdf", content);
var stored = storage.store(0, "71", boundary, category);
assertThat(Files.size(root.resolve(stored.objectKey()))).isEqualTo(MAX_BYTES);
var oversized = mock(MultipartFile.class);
when(oversized.isEmpty()).thenReturn(false);
when(oversized.getSize()).thenReturn((long) MAX_BYTES + 1);
assertThatThrownBy(() -> storage.store(0, "71", oversized, category))
.isInstanceOf(BizException.class).hasMessageContaining("10MB");
verify(oversized, never()).getInputStream();
verify(oversized, never()).transferTo(any(Path.class));
}
/** 上传生成独立不可变对象和真实元数据;替换引用不覆盖原文件。 */
@ParameterizedTest
@ValueSource(strings = {VEHICLE, DRIVER})
void uploadsKeepPlatformDispatchScopeAndImmutableObjects(String type) throws Exception {
var files = mock(FileObjectMapper.class);
var storage = storage();
var service = service(files, storage);
var ids = new AtomicLong(500);
when(files.insert(any(FileObjectEntity.class))).thenAnswer(call -> {
FileObjectEntity row = call.getArgument(0);
row.setId(ids.incrementAndGet());
return 1;
});
var first = service.upload(0, type, "71", 7, pdf());
var secondFile = new MockMultipartFile("file", "updated.pdf", "application/pdf",
"%PDF-1.7\nupdated".getBytes(StandardCharsets.US_ASCII));
var second = service.upload(0, type, "71", 7, secondFile);
var rows = org.mockito.ArgumentCaptor.forClass(FileObjectEntity.class);
verify(files, times(2)).insert(rows.capture());
assertThat(first.ownerScope()).isEqualTo("PLATFORM");
assertThat(first.tenantId()).isZero();
assertThat(first.businessType()).isEqualTo(type);
assertThat(first.businessId()).isEqualTo("71");
assertThat(first.fileId()).isEqualTo(501L);
assertThat(second.fileId()).isEqualTo(502L);
assertThat(first.fileName()).isEqualTo("evidence.pdf");
assertThat(first.contentType()).isEqualTo("application/pdf");
assertThat(first.sizeBytes()).isEqualTo(pdf().getSize());
assertThat(first.sha256()).matches("[0-9a-f]{64}");
assertThat(first.createdAt()).isNotNull().isEqualTo(rows.getAllValues().get(0).getCreatedAt());
assertThat(rows.getAllValues().get(0).getObjectKey()).isNotEqualTo(rows.getAllValues().get(1).getObjectKey());
assertThat(storage.read(rows.getAllValues().get(0).getObjectKey())).isEqualTo(pdf().getBytes());
assertThat(storage.read(rows.getAllValues().get(1).getObjectKey())).isEqualTo(secondFile.getBytes());
verify(files, never()).updateById(any(FileObjectEntity.class));
}
/** 元数据和下载均逐列带全复合键;错车、错类别、错文件不得读取存储内容。 */
@ParameterizedTest
@ValueSource(strings = {VEHICLE, DRIVER})
void metadataAndDownloadEnforceEveryScopeKeyBeforeReading(String type) throws Exception {
var files = mock(FileObjectMapper.class);
var storage = mock(BusinessFileStorage.class);
var service = service(files, storage);
var row = new FileObjectEntity();
row.setId(501L); row.setTenantId(0L); row.setBusinessType(type); row.setBusinessId("71");
row.setOriginalName("evidence.pdf"); row.setContentType("application/pdf");
row.setObjectKey("business-files/0/71/evidence.pdf"); row.setSizeBytes(pdf().getSize());
row.setSha256("a".repeat(64)); row.setCreatedAt(java.time.LocalDateTime.now());
when(files.selectOne(any())).thenAnswer(call -> {
LambdaQueryWrapper<FileObjectEntity> query = call.getArgument(0);
assertThat(boundValue(query, "status")).isEqualTo("ACTIVE");
assertThat(boundValue(query, "deleted")).isEqualTo(false);
return Long.valueOf(0).equals(boundValue(query, "tenant_id"))
&& type.equals(boundValue(query, "business_type"))
&& "71".equals(boundValue(query, "business_id"))
&& Long.valueOf(501).equals(boundValue(query, "id")) ? row : null;
});
when(storage.read(row.getObjectKey())).thenReturn(pdf().getBytes());
var metadata = service.metadata(0, type, "71", 501);
assertThat(metadata.fileId()).isEqualTo(501L);
assertThat(metadata.ownerScope()).isEqualTo("PLATFORM");
assertThat(service.download(0, type, "71", 501).content()).isEqualTo(pdf().getBytes());
verify(storage).read(row.getObjectKey());
clearInvocations(storage);
String otherType = VEHICLE.equals(type) ? DRIVER : VEHICLE;
for (String candidate : new String[]{otherType, "FLEET_DRIVER_LICENSE", "FLEET_TRIP_OPERATION_EVIDENCE"}) {
assertThatThrownBy(() -> service.metadata(0, candidate, "71", 501))
.isInstanceOf(BizException.class).hasMessageContaining("归属不匹配");
assertThatThrownBy(() -> service.download(0, candidate, "71", 501))
.isInstanceOf(BizException.class).hasMessageContaining("归属不匹配");
}
assertThatThrownBy(() -> service.metadata(0, type, "72", 501)).isInstanceOf(BizException.class);
assertThatThrownBy(() -> service.download(0, type, "72", 501)).isInstanceOf(BizException.class);
assertThatThrownBy(() -> service.metadata(0, type, "71", 502)).isInstanceOf(BizException.class);
assertThatThrownBy(() -> service.download(0, type, "71", 502)).isInstanceOf(BizException.class);
verifyNoInteractions(storage);
}
/** 内部服务令牌在业务访问前校验,不对未授权请求暴露元数据或文件。 */
@ParameterizedTest
@ValueSource(strings = {VEHICLE, DRIVER})
void invalidInternalTokenCannotUploadReadMetadataOrDownload(String type) {
var service = mock(InternalBusinessFileService.class);
var controller = new InternalBusinessFileController();
ReflectionTestUtils.setField(controller, "expected", "dispatch-test-token");
ReflectionTestUtils.setField(controller, "service", service);
for (String token : new String[]{null, "", "wrong-token"}) {
assertThatThrownBy(() -> controller.upload(token, 7, 0, type, "71", pdf())).isInstanceOf(BizException.class);
assertThatThrownBy(() -> controller.metadata(token, 0, type, "71", 501)).isInstanceOf(BizException.class);
assertThatThrownBy(() -> controller.download(token, 0, type, "71", 501)).isInstanceOf(BizException.class);
}
verifyNoInteractions(service);
}
/** 从实际 SQL 绑定中取指定列的等值条件,缺少任一隔离条件直接失败。 */
private static Object boundValue(LambdaQueryWrapper<FileObjectEntity> query, String column) {
var matcher = Pattern.compile("(?<![A-Za-z0-9_])" + column
+ " = #\\{ew\\.paramNameValuePairs\\.([^}]+)}").matcher(query.getSqlSegment());
assertThat(matcher.find()).as("复合归属条件 %s", column).isTrue();
return query.getParamNameValuePairs().get(matcher.group(1));
}
/** 创建隔离临时目录中的真实存储,不访问运行环境。 */
private BusinessFileStorage storage() {
var storage = new BusinessFileStorage();
ReflectionTestUtils.setField(storage, "configuredRoot", root);
storage.initializeRoot();
return storage;
}
/** 复用生产服务,数据库边界由 Mapper 替身记录实际查询。 */
private InternalBusinessFileService service(FileObjectMapper files, BusinessFileStorage storage) {
var service = new InternalBusinessFileService();
ReflectionTestUtils.setField(service, "files", files);
ReflectionTestUtils.setField(service, "storage", storage);
return service;
}
/** 提供具有真实 PDF 文件头的最小上传内容。 */
private static MockMultipartFile pdf() {
return new MockMultipartFile("file", "evidence.pdf", "application/pdf",
"%PDF-1.7\nevidence".getBytes(StandardCharsets.US_ASCII));
}
/** 使用图像编码器生成真实 JPG、PNG,避免测试只验证扩展名。 */
private static byte[] image(String format) throws java.io.IOException {
var output = new ByteArrayOutputStream();
var image = new java.awt.image.BufferedImage(2, 2, java.awt.image.BufferedImage.TYPE_INT_RGB);
assertThat(javax.imageio.ImageIO.write(image, format, output)).isTrue();
return output.toByteArray();
}
}
@@ -39,6 +39,23 @@ class ExportTaskServiceTest {
@MockBean private ExportStateService states;
@MockBean private ExportMetrics metrics;
@Resource private ExportTaskService service;
@Test void turnoverKeepsBoundedSignedContextAndRequiresIndependentExportPermission() {
var definition=definition();definition.setRequiredPermission("platform:self-operated-analytics:export");
when(registry.require("SELFOP_INVENTORY_TURNOVER")).thenReturn(definition);
when(states.createConstrained(any(FileExportTask.class))).thenAnswer(c->new ExportStateService.CreationResult(c.getArgument(0),true));
Map<String,Object> parameters=new LinkedHashMap<>();
parameters.put("sourceAccount","demo");parameters.put("warehouseCode","W");parameters.put("endDate","2026-10-14");parameters.put("baseWindowDays",30);
parameters.put("snapshotContext","x".repeat(15000));parameters.put("columns",List.of("sku","quantityTurnoverRate"));parameters.put("expectedTotal",205);
var request=new CreateExportTaskRequest("SELFOP_INVENTORY_TURNOVER","XLSX","库存周转",parameters);
assertThrows(BizException.class,()->service.create(user(1L,Set.of("platform:self-operated-analytics:list")),request));
assertEquals("PENDING",service.create(user(1L,Set.of("platform:self-operated-analytics:export")),request).status());
parameters.put("snapshotContext","x".repeat(16001));
assertThrows(BizException.class,()->service.create(user(1L,Set.of("platform:self-operated-analytics:export")),request));
parameters.put("snapshotContext","fixed");parameters.put("current",2);
assertThrows(BizException.class,()->service.create(user(1L,Set.of("platform:self-operated-analytics:export")),request));
parameters.remove("current");parameters.remove("columns");
assertThrows(BizException.class,()->service.create(user(1L,Set.of("platform:self-operated-analytics:export")),request));
}
@Test
void createsPersistedTaskOnlyWhenSourceExportPermissionIsPresent() {
when(registry.require("CUSTOMER_LIST")).thenReturn(definition());
@@ -94,6 +111,15 @@ class ExportTaskServiceTest {
assertEquals("9", service.get(user(1L, Set.of("platform:download:all")), 9L).id());
}
@Test void turnoverDownloadAndRetryRecheckExportPermissionEvenWithDownloadAll() {
var task=new FileExportTask();task.setId(9L);task.setTenantId(0L);task.setAppCode("PLATFORM");task.setRequestedBy(2L);
task.setExportType("SELFOP_INVENTORY_TURNOVER");task.setStatus("SUCCEEDED");when(repository.find(9L)).thenReturn(task);
var reader=user(1L,Set.of("platform:download:all","platform:self-operated-analytics:list"));
assertEquals(ResultCode.FORBIDDEN,assertThrows(BizException.class,()->service.download(reader,9L)).getResultCode());
assertEquals(ResultCode.FORBIDDEN,assertThrows(BizException.class,()->service.retry(reader,9L)).getResultCode());
verifyNoInteractions(storage);
}
@Test
void rejectsParametersDeeperThanTwoLevels() {
when(registry.require("CUSTOMER_LIST")).thenReturn(definition());
@@ -66,6 +66,24 @@ class GenericXlsxExportRunnerTest {
assertNull(requests.getAllValues().get(0).snapshotToken());assertEquals("snapshot-9",requests.getAllValues().get(1).snapshotToken());
}
@Test void turnoverWorkbookPreservesColumnOrderPercentUnknownAndLiteralSku() throws Exception {
var columns=List.of(new ExportColumn("quantityTurnoverRate","期间周转率",ExportColumnType.STRING,24),
new ExportColumn("sku","SKU",ExportColumnType.STRING,24),new ExportColumn("historyDays","合格历史库存天数",ExportColumnType.STRING,24));
var batch=new ExportBatchResponse(1,"sha256:turnover","fixed",columns,List.of(
Map.of("quantityTurnoverRate","125%","sku","=SUM(1,2)","historyDays","30/30"),
Map.of("quantityTurnoverRate","—","sku","未知商品","historyDays","29/30"),
Map.of("quantityTurnoverRate","0%","sku","零销量","historyDays","30/30")),"3",false,3L);
when(provider.batch(anyString(),eq("SELFOP_INVENTORY_TURNOVER"),any(),any())).thenReturn(ApiResponse.success(batch));
var task=new FileExportTask();task.setId(18L);task.setTenantId(0L);task.setAppCode("PLATFORM");task.setRequestedBy(7L);task.setDispatchVersion(1);
task.setExportType("SELFOP_INVENTORY_TURNOVER");task.setTaskName("库存周转");task.setParametersJson("{\"snapshotContext\":\"fixed\"}");
var definition=new ExportDefinitionRegistry.Definition();definition.setMaxRows(200000);definition.setBatchSize(100);
var result=runner.run(task,definition,new AtomicBoolean(),ignored->{});
try(var workbook=new XSSFWorkbook(storage.load(result.objectKey()).getInputStream())) {
var sheet=workbook.getSheetAt(0);assertEquals("期间周转率",sheet.getRow(0).getCell(0).getStringCellValue());
assertEquals("125%",sheet.getRow(1).getCell(0).getStringCellValue());assertEquals("'=SUM(1,2)",sheet.getRow(1).getCell(1).getStringCellValue());
assertEquals("—",sheet.getRow(2).getCell(0).getStringCellValue());assertEquals("0%",sheet.getRow(3).getCell(0).getStringCellValue());assertEquals(3,result.rows());
}
}
@Test void deletesTheCompletedFileWhenReadingItsFinalSizeFails() throws Exception {
var batch=new ExportBatchResponse(1,"sha256:test","snapshot-9",
List.of(new ExportColumn("text","文本",ExportColumnType.STRING,18)),
+5
View File
@@ -1,5 +1,10 @@
# Finance Service Instructions
## 数据库访问与中文注释约束
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
## 模块定位
- 本模块是独立财务服务,拥有客户结算条款、财务费用来源、CNY 账单与明细、客户资金账户及充值申请、应收款月度统计、客户对账确认、收款及分配、成本及分摊、账单修订、单据编号、幂等记录和财务审计状态。
+8 -1
View File
@@ -1,5 +1,10 @@
# Firstleg Service 模块指引
## 数据库访问与中文注释约束
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
## 职责与边界
- Mapper 统一显式使用 `@Mapper`,仅由 `BaiyueFirstlegApplication.java` 对 `com.baiyue.firstleg` 根包按 `annotationClass = Mapper.class` 扫描;禁止恢复业务子域 Mapper 包白名单。静态门禁见根 `scripts/mapper-scan-contract.mjs`,模块内编译门禁见 `MapperRegistrationArchitectureTest.java`、`BaiyueFirstlegApplicationContractTest.java`。
@@ -54,7 +59,9 @@
| 海外派送完成回写 | 同一内部事件入口、消费者 `service/OverseasDeliveryCompletedConsumer.java`;按来源入仓单、订单、入仓码和 FBA 仓校验后推进当前入仓单 `LOADED→DELIVERED`,同一 Order 全部有效入仓单完成后才经 `OrderFulfillmentCompletionClient` 推进 Order `COMPLETED` |
| 国内货柜履约海运订舱、资料/打印、关联候选与出仓前船期资料 | `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` |
| 目的港码头拖车与还空预警 | `terminaldrayage/controller/PlatformTerminalDrayageController.java`、`service/TerminalDrayageService.java`、`mapper/TerminalDrayageMapper.java`;货柜实际到港后引用 Warehouse `ACTIVE/TRUCKING/OVERSEAS` 海外车队供应商,依次选择车队、预约提柜、确认实际提柜,海外卸柜收货完成后确认还柜;服务端按卸柜完成时间加 72 小时返回报空预警,实际提柜和还柜通过现有 `CarrierScheduleQueryService` 推进 `PICKED_UP/EMPTY_RETURNED`,不复制船节点或车队主档。契约 `src/main/resources/openapi/terminal-drayage-v1.yaml`,基线 `src/main/resources/db/phase3-terminal-drayage-schema.sql` 邮件由 `terminaldrayage/service/TerminalDrayageMailService.java` 入队,`TerminalDrayageMailDispatcher.java` 顺序投递,模板与 SMTP 配置见 `docs/terminal-drayage-mail.md`;首次选队不发信,换队事务内生成取消与请求两封邮件。 |
| 已开船预约事项完整来源 | `portschedule/controller/InternalAppointmentWorkSourceController.java`、`service/AppointmentWorkSourceService.java`、`mapper/AppointmentWorkSourceMapper.java`;按完整来源版本游标提供所有实际开船或后续节点的有效货柜,保留无仓绑定与完成货柜;原始当地时间无时区时不得转UTC;契约 `src/main/resources/openapi/appointment-work-source-v1.yaml` |
| 直送目的仓实际卸货 | `terminaldrayage/controller/PlatformDirectDeliveryController.java`、`service/DirectDeliveryService.java`、`UnloadingEvidenceGuard.java`;按实装入仓来源逐票记录签收人与凭据,必须经 Lastleg 确认 DIRECT 路线且已提柜;不创建海外库存,全柜卸毕才可人工还空;契约 `src/main/resources/openapi/direct-delivery-v1.yaml`,基线 `src/main/resources/db/phase3-direct-delivery-schema.sql` |
| 目的港码头拖车与还空预警 | `terminaldrayage/controller/PlatformTerminalDrayageController.java`、`service/TerminalDrayageService.java`、`mapper/TerminalDrayageMapper.java`;货柜实际到港后引用 Warehouse `ACTIVE/TRUCKING/OVERSEAS` 海外车队供应商,依次选择车队、预约提柜、确认实际提柜,海外仓卸柜或全部直送目的仓实际卸货完成后确认还柜;服务端按卸柜完成时间加 72 小时返回报空预警,实际提柜和还柜通过现有 `CarrierScheduleQueryService` 推进 `PICKED_UP/EMPTY_RETURNED`,不复制船节点或车队主档。契约 `src/main/resources/openapi/terminal-drayage-v1.yaml`,基线 `src/main/resources/db/phase3-terminal-drayage-schema.sql` 邮件由 `terminaldrayage/service/TerminalDrayageMailService.java` 入队,`TerminalDrayageMailDispatcher.java` 顺序投递,模板与 SMTP 配置见 `docs/terminal-drayage-mail.md`;首次选队不发信,换队事务内生成取消与请求两封邮件。 |
| 船节点交接事件 | `portschedule/service/CarrierScheduleNodeOutboxService.java`;实际节点落库后按 `containerBookingId` 柜级发布 `FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED` V1,信封固定使用 `tenantId=0` 平台技术范围且 Payload 不携带客户,复用 `LoadingCompletedOutboxRepository`/Relay 直投 Lastleg `/internal/lastleg/events`;消费见 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java`,ACK/重试按 `../../docs/统一认证与服务通信契约.md` |
| Firstleg 业务单据编号 | `service/FirstlegDocumentNumberGenerator.java`;当前支持 `OB`、`TA`、`CB`、`CAB`、`LP` 前缀,按北京时间每日独立编号;具体业务归属以各调用服务为准 |
| 订舱级拖车与整柜出仓调度 | `controller/PlatformTruckAppointmentController.java` 的 `/candidates`、`service/TruckAppointmentService.java`、`repository/TruckAppointmentRepository.java`、`outbound/dispatch/controller/PlatformOutboundDispatchController.java`;拖车契约 `src/main/resources/openapi/truck-appointments-v1.yaml`。预约只绑定 `RELEASED` 订舱,候选排除缺提单/有效订舱文件或已有非终态预约的订舱;API 不含客户、柜次、装柜计划或真实柜号,每订舱最多一张有效预约,已有完成单不得重建收费;PDA 完成不推进拖车状态,全部要求货柜确认国内离仓后由 `TruckAppointmentCompletionService` 自动完成并经 `TruckAppointmentFeeService` 发布应付 |
@@ -248,3 +248,10 @@ PriceBookQueryMapper -> PriceBookQueryReader + PriceBookQueryRows
- 回滚仅恢复原三文件及消费者类型引用;不涉及 URI、权限、JSON、SQL、表结构、业务状态或环境配置。
- 只读模型仍保留原 Lombok 可变 Bean 形态以维持 MyBatis 映射行为;本轮不顺带改造成不可变领域对象。
- 验证结果:七段 `@Select` SQL 文本逐块完全一致;Node 合同 5/5;`test-compile` 成功;价格簿、ArchUnit、根扫描及三个消费者切片定向测试 78/78;Firstleg 聚合全量 878 个测试,0 失败、0 错误、3 跳过,构建成功。
## 预约工作集成时的精确边界核对
`PlatformContainerShippingNodeController` 归船节点切片,`sailingcalendar` 归订舱船期切片;不再误归共享技术层。既有海外事件适配、实装费用重算和渠道目录读取在 `reviewedClassDependencies` 按具体调用者、目标类型和理由登记,不放开整个切片。
现存全局 service 包同时承载暂扣、装柜费用和订舱文档编排,形成 loading.bridge.service、loading.service、oceanbooking.service、service 四包 SCC。它是尚未拆分的既有包级债务,本轮未宣称消除;`reviewedPackageCycles` 只冻结这一精确集合,新增节点或第二个环仍失败,价格簿查询与客户统计仍必须处于所有环之外。合同有新增消费者与新增环的负向测试。预约新增提供端通过原域适配接口消费事实,不增加该包环。
@@ -3,6 +3,7 @@
import fs from 'node:fs';
import path from 'node:path';
import process from 'node:process';
import { pathToFileURL } from 'node:url';
const moduleRoot = path.resolve(import.meta.dirname, '..');
const sourceRoots = {
@@ -15,8 +16,8 @@ const sliceRules = [
['customer-statistics', /(?:^|\.)(?:customerstatistics)(?:\.|$)/],
['pricebook-query', /(?:^|\.)(?:pricebookquery)(?:\.|$)/],
['finance-integration', /(?:InternalFinance|FinanceFeeSource|FinanceCostSnapshot|FeePaymentVoucher|FeePaymentSnapshot|ContainerFeeSourceSnapshot)/],
['shipping-nodes', /(?:CarrierScheduleQuery|CarrierScheduleNode|CarrierScheduleAdapter|CarrierScheduleAttempt|CarrierScheduleException)/],
['booking-customs-trucking', /(?:^|\.)(?:oceanbooking|customs|terminaldrayage)(?:\.|$)|(?:TruckAppointment|WarehouseSupplier|PortSchedule|DispatchGate)/],
['shipping-nodes', /(?:CarrierScheduleQuery|CarrierScheduleNode|CarrierScheduleAdapter|CarrierScheduleAttempt|CarrierScheduleException|ContainerShippingNode)/],
['booking-customs-trucking', /(?:^|\.)(?:oceanbooking|customs|terminaldrayage|sailingcalendar)(?:\.|$)|(?:TruckAppointment|WarehouseSupplier|PortSchedule|DispatchGate)/],
['outbound', /(?:^|\.)outbound(?:\.|$)|(?:OutboundDispatch|ContainerTransport|OverseasContainerEvent)/],
['split-loading', /(?:^|\.)(?:split|loading)(?:\.|$)|(?:Loading|SmartLoading|Container(?:Booking|Overview|Service|Dtos)|PdaPick)/],
['measurement-pricing', /(?:^|\.)pricing(?:\.|$)|(?:Measurement|MeasureReview|Mojia|Pricing|PriceBook|PriceChannel|PriceOrigin|PriceWarehouse|Fba|Dimension|FeeItem|MerchantInboundBilling|InboundFee)/],
@@ -261,14 +262,28 @@ function renderDot(report) {
return lines.join('\n');
}
function checkContract(report, contractPath) {
const absolute = path.resolve(process.cwd(), contractPath);
const contract = JSON.parse(fs.readFileSync(absolute, 'utf8'));
export function contractViolations(report, contract) {
const allowed = new Set(contract.allowedSliceEdges ?? []);
const actual = report.sliceEdges.map(edge => `${edge.source}->${edge.target}`);
const additions = actual.filter(edge => !allowed.has(edge));
const violations = additions.map(edge => `new slice dependency: ${edge}`);
const violations = [];
const cycles=new Set((contract.reviewedPackageCycles ?? []).filter(c=>c.reason?.trim()
&& c.packages?.length>1 && c.packages.every(p=>!p.includes('*'))).map(c=>[...c.packages].sort().join('|')));
for (const cycle of report.packageSccs ?? []) {
if (!cycles.has([...cycle].sort().join('|'))) violations.push(`unreviewed package cycle: ${cycle.join(' -> ')}`);
}
const reviewed = new Set();
for (const edge of contract.reviewedClassDependencies ?? []) {
if (!edge.source || !edge.target || !edge.reason?.trim() || /[*]/.test(edge.source + edge.target)) {
violations.push('reviewed dependency requires exact classes and a rationale');
continue;
}
reviewed.add(`${edge.source}->${edge.target}`);
}
const classesByName = new Map(report.classes.map(entry => [entry.fqcn, entry]));
for (const edge of report.classDependencies) {
const from=classesByName.get(edge.source)?.slice, to=classesByName.get(edge.target)?.slice;
if (from && to && from!==to && !allowed.has(`${from}->${to}`) && !reviewed.has(`${edge.source}->${edge.target}`))
violations.push(`unreviewed slice dependency: ${from}->${to}: ${edge.source}->${edge.target}`);
}
for (const [fqcn, expectedFile] of Object.entries(contract.requiredClassLocations ?? {})) {
const actualClass = classesByName.get(fqcn);
if (!actualClass) violations.push(`required class missing: ${fqcn}`);
@@ -312,12 +327,19 @@ function checkContract(report, contractPath) {
violations.push(`${slice} incoming dependencies changed: ${incoming.join(', ')}`);
}
}
return violations;
}
function checkContract(report, contractPath) {
const contract=JSON.parse(fs.readFileSync(path.resolve(process.cwd(),contractPath),'utf8'));
const violations=contractViolations(report,contract);
if (violations.length > 0) {
process.stderr.write(`Firstleg dependency contract violations in ${contractPath}:\n${violations.map(item => `- ${item}`).join('\n')}\n`);
process.exitCode = 1;
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
const options = parseArgs(process.argv.slice(2));
const report = buildReport(options.sourceSet);
if (options.check) checkContract(report, options.check);
@@ -328,3 +350,5 @@ const rendered = options.format === 'json'
: renderDot(report);
if (options.output) fs.writeFileSync(path.resolve(process.cwd(), options.output), rendered, 'utf8');
else process.stdout.write(rendered);
}
@@ -1,5 +1,7 @@
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { contractViolations } from './analyze-firstleg-dependencies.mjs';
import path from 'node:path';
import test from 'node:test';
@@ -7,6 +9,17 @@ const moduleRoot = path.resolve(import.meta.dirname, '..');
const analyzer = path.join(moduleRoot, 'scripts', 'analyze-firstleg-dependencies.mjs');
const contract = path.join(moduleRoot, 'src', 'test', 'resources', 'architecture', 'firstleg-slice-dependencies.json');
test('reviewed original-domain calls do not open an entire slice or allow wildcard grants', () => {
const report=analyze(); const baseline=JSON.parse(readFileSync(contract,'utf8'));
assert.deepEqual(contractViolations(report,baseline),[]);
report.classDependencies.push({source:'com.baiyue.firstleg.service.SmartLoadingService',target:'com.baiyue.firstleg.domain.PriceBookChannelCatalog'});
assert.ok(contractViolations(report,baseline).some(message=>message.includes('SmartLoadingService->com.baiyue.firstleg.domain.PriceBookChannelCatalog')));
baseline.reviewedClassDependencies.push({source:'com.baiyue.firstleg.*',target:'com.baiyue.firstleg.service.FbaPricingService',reason:'invalid wildcard'});
assert.ok(contractViolations(report,baseline).some(message=>message.includes('exact classes')));
report.packageSccs.push(['com.baiyue.firstleg.service','com.baiyue.firstleg.pricebookquery.application']);
assert.ok(contractViolations(report,baseline).some(message=>message.includes('unreviewed package cycle')));
});
function analyze() {
return JSON.parse(execFileSync(process.execPath, [analyzer, '--source-set', 'main', '--format', 'json'], {
cwd: moduleRoot,
@@ -53,7 +66,7 @@ test('customer statistics application layer only depends inward', () => {
assert.deepEqual(outwardDependencies, []);
});
test('price book query is a leaf slice and removes the package cycle', () => {
test('price book query remains a leaf slice outside reviewed pre-existing package cycles', () => {
const report = analyze();
const classes = report.classes.filter(entry => entry.slice === 'pricebook-query');
assert.deepEqual(classes.map(entry => entry.fqcn).sort(), [
@@ -67,7 +80,9 @@ test('price book query is a leaf slice and removes the package cycle', () => {
assert.deepEqual(report.sliceEdges.filter(edge => edge.target === 'pricebook-query')
.map(edge => edge.source).sort(), ['booking-customs-trucking', 'measurement-pricing', 'split-loading']);
assert.equal(report.sliceSccs.some(component => component.includes('pricebook-query')), false);
assert.deepEqual(report.packageSccs, []);
assert.equal(report.packageSccs.some(cycle=>cycle.some(pkg=>pkg.startsWith('com.baiyue.firstleg.pricebookquery'))),false);
const reviewed=JSON.parse(readFileSync(contract,'utf8')).reviewedPackageCycles.map(c=>c.packages);
assert.deepEqual(report.packageSccs,reviewed);
});
test('price book query application and consumers respect ports', () => {
@@ -0,0 +1,12 @@
package com.baiyue.firstleg.client;
import com.baiyue.common.core.api.ApiResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.*;
@FeignClient(name="lastleg-service",contextId="appointmentWorkRouteClient",url="${LASTLEG_SERVICE_URL:${baiyue.clients.lastleg.url:}}")
public interface AppointmentWorkRouteClient {
record Route(String flow) { }
@GetMapping("/internal/lastleg/appointment-work/containers/{id}/route")
ApiResponse<Route> route(@RequestHeader("X-Internal-Service-Token") String token,@PathVariable("id") long id);
}
@@ -13,7 +13,7 @@ public final class ContainerOverviewDtos {
public record Overview(ContainerBookingEntityView container, Long loadingPlanId, PdaSummary pda,
List<Document> documents, ExceptionState exception, List<ExceptionHistory> exceptionHistory,
List<TimelineEntry> timeline) {}
public record ContainerBookingEntityView(Long id, String bookingNo, String cabinetNo, String containerNo,
public record ContainerBookingEntityView(Long id, String cabinetNo, String containerNo,
String status, Long warehouseId, String warehouseName, String voyageNo, String vesselName,
String billOfLadingNo, Long shippingCompanyId, String shippingCompanyName,
String departurePortCode, String departurePortName, String destinationPortCode,
@@ -10,7 +10,8 @@ public final class MerchantWorkbenchFilters {
private MerchantWorkbenchFilters() {}
public record Request(@NotNull @Positive Long tenantId,
@Pattern(regexp = "WAIT_MEASURE|WAIT_PALLETIZE|WAIT_SPLIT|LOADING|LOADED|DELIVERED") String mainStatus) {}
@Pattern(regexp = "WAIT_MEASURE|WAIT_PALLETIZE|WAIT_SPLIT|LOADING|LOADED|DELIVERED") String mainStatus,
@jakarta.validation.constraints.Size(min=1,max=200) List<@NotNull @Positive Long> orderIds) {}
public record Response(List<Long> matchingStatusOrderIds, List<Long> feeOrderIds,
List<Long> customsOrderIds, List<Progress> progress) {}
@@ -48,7 +48,7 @@ public final class SmartLoadingDtos { private SmartLoadingDtos(){}
if (size == 0) size = 20;
}
}
public record WorkbenchContainer(Long bookingId, String bookingNo, String cabinetNo, String containerNo,
public record WorkbenchContainer(Long bookingId, String cabinetNo, String containerNo,
Long warehouseId, String warehouseName, String channelGroupCode, String channelCode, String channelName, String containerType,
BigDecimal capacityCbm, BigDecimal maxWeightKg,
Long planId, String planNo, String planStatus, Integer version) {}
@@ -19,7 +19,6 @@ import java.time.LocalDateTime;
@TableName("fl_container_booking")
public class ContainerBookingEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private String bookingNo;
private String containerNo;
private String cabinetNo;
private String sourceType;
@@ -65,6 +65,7 @@ public interface MerchantWorkbenchFilterMapper {
List<Long> pendingCustoms(@Param("tenantId") long tenantId);
/** Only physically loaded fragments can expose container milestones to the customer. */
@Select("""
<script>
SELECT o.order_id, CASE WHEN owned.container_booking_id IS NULL THEN 0
WHEN EXISTS (SELECT 1 FROM fl_carrier_schedule_node_fact n
WHERE n.container_booking_id=owned.container_booking_id
@@ -76,6 +77,11 @@ public interface MerchantWorkbenchFilterMapper {
LEFT JOIN (
SELECT DISTINCT i.inbound_id, i.account_id, p.id AS loading_plan_id, p.container_booking_id
FROM fl_loading_plan_item i
<if test="orderIds != null">
JOIN fl_inbound_order selected ON selected.id=i.inbound_id AND selected.tenant_id=i.account_id
AND selected.deleted=0 AND selected.order_id IN
<foreach collection="orderIds" item="id" open="(" separator="," close=")">#{id}</foreach>
</if>
JOIN fl_loading_plan p ON p.id=i.loading_plan_id AND p.plan_status='COMPLETED'
JOIN fl_loading_pick_task t ON t.loading_plan_id=p.id AND t.task_status='COMPLETED'
JOIN fl_loading_pick_item picked ON picked.pick_task_id=t.id AND picked.loading_plan_item_id=i.id
@@ -84,8 +90,12 @@ public interface MerchantWorkbenchFilterMapper {
AND picked.item_status IN ('PICKED','PARTIAL') AND picked.picked_package_count>0
) owned ON owned.inbound_id=o.id AND owned.account_id=o.tenant_id
WHERE o.tenant_id=#{tenantId} AND o.deleted=0 AND o.order_id IS NOT NULL
AND o.process_status <> 'CANCELLED'
AND o.process_status &lt;&gt; 'CANCELLED'
<if test="orderIds != null">
AND o.order_id IN <foreach collection="orderIds" item="id" open="(" separator="," close=")">#{id}</foreach>
</if>
ORDER BY o.order_id
</script>
""")
List<com.baiyue.firstleg.dto.MerchantWorkbenchFilters.Fragment> progressFragments(@Param("tenantId") long tenantId);
List<com.baiyue.firstleg.dto.MerchantWorkbenchFilters.Fragment> progressFragments(@Param("tenantId") long tenantId, @Param("orderIds") List<Long> orderIds);
}
@@ -335,7 +335,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba
@Select("""
<script>
SELECT b.id bookingId,b.booking_no bookingNo,b.cabinet_no cabinetNo,b.container_no containerNo,
SELECT b.id bookingId,b.cabinet_no cabinetNo,b.container_no containerNo,
b.warehouse_id warehouseId,b.warehouse_name warehouseName,b.channel_group_code channelGroupCode,b.channel_code channelCode,
b.channel_name channelName,b.container_type containerType,b.capacity_cbm capacityCbm,
b.max_weight_kg maxWeightKg,p.id planId,p.plan_no planNo,
@@ -0,0 +1,36 @@
package com.baiyue.firstleg.portschedule.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.firstleg.portschedule.dto.AppointmentWorkSourceDtos.*;
import com.baiyue.firstleg.portschedule.service.AppointmentWorkSourceService;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.*;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.List;
@RestController
@RequestMapping("/internal/firstleg/appointment-work")
public class InternalAppointmentWorkSourceController {
private final AppointmentWorkSourceService service;
private final String token;
public InternalAppointmentWorkSourceController(AppointmentWorkSourceService service,
@Value("${baiyue.security.internal-service-token:}") String token) { this.service=service; this.token=token; }
@GetMapping("/containers")
public ApiResponse<SourcePage> discover(@RequestHeader(value="X-Internal-Service-Token",required=false) String supplied,
@RequestParam(required=false) String after,@RequestParam(defaultValue="200") int size) {
authorize(supplied); return ApiResponse.success(service.discover(after,size));
}
@GetMapping("/containers/{id}")
public ApiResponse<ContainerSource> container(@RequestHeader(value="X-Internal-Service-Token",required=false) String supplied,
@PathVariable long id) { authorize(supplied); return ApiResponse.success(service.container(id)); }
@PostMapping("/facts")
public ApiResponse<List<Fact>> facts(@RequestHeader(value="X-Internal-Service-Token",required=false) String supplied,
@RequestBody List<FactRequest> requests) { authorize(supplied); return ApiResponse.success(service.facts(requests)); }
private void authorize(String supplied) {
if(token==null || token.isBlank() || supplied==null || !MessageDigest.isEqual(token.getBytes(StandardCharsets.UTF_8),supplied.getBytes(StandardCharsets.UTF_8)))
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED,"内部身份无效");
}
}
@@ -0,0 +1,29 @@
package com.baiyue.firstleg.portschedule.dto;
import java.time.*;
import java.util.List;
/** Internal wire values. Unzoned source timestamps are never converted using the server zone. */
public final class AppointmentWorkSourceDtos {
private AppointmentWorkSourceDtos() { }
public record Reference(String kind, String key, String businessNo, String label) { }
public record SourceReference(String customerId,String sourceKey,String destinationKey) { }
public record Requirement(String requirementKey, String batchKey, String batchNo, String destinationKind,
String originName, String destinationName, String timezone, List<Reference> references, List<String> replacesRequirementKeys,
List<SourceReference> sources) { }
public record ContainerSource(String containerBookingId, String containerNo, Instant departedAt,
LocalDateTime departedLocalAt, LocalDateTime sailedEvidenceLocalAt, String sourceVersion, String establishedFlow, String timezone, List<Requirement> requirements) { }
public record SourcePage(List<ContainerSource> containers, String nextCursor, boolean complete, String scopeVersion) { }
public record ContainerReference(String containerBookingId, String bookingNo, String containerNo) { }
public record FactRequest(String workItemKey, String type, String flow, String containerBookingId,
String requirementKey, String batchKey, String tripKey, List<String> batchKeys, List<ContainerReference> containers,List<SourceReference> sources) { }
public record Slot(LocalDate date, LocalTime startTime, LocalDate endDate, LocalTime endTime, String timezone) { }
public record Actual(Instant occurredAt, LocalDateTime localOccurredAt, String timezone) { }
public record Target(String kind, String containerBookingId) { }
public record Action(String code, String label, String targetWorkItemKey, Target target) { }
public record Responsibility(String kind, String id, String name) { }
public record Fact(String workItemKey, String fingerprint, String impactToken, boolean planExists, String planStatus,
String executionStatus, Slot target, Slot confirmed, Actual actual, String timezone, String businessNo, String tripKey,
List<String> readPermissions, List<String> actionPermissions, Action action, List<String> blockReasons,
List<String> missingFields, List<Reference> references, Responsibility responsibility) { }
}
@@ -2,6 +2,6 @@ package com.baiyue.firstleg.portschedule.dto;
import java.time.LocalDateTime;
public record ArrivalCalendarEntry(Long containerBookingId, String bookingNo, String containerNo,
public record ArrivalCalendarEntry(Long containerBookingId, String cabinetNo, String containerNo,
String vesselName, String voyageNo, String destinationPortName, String destinationWarehouseCode,
LocalDateTime arrivalAt, String arrivalType) {}
@@ -0,0 +1,64 @@
package com.baiyue.firstleg.portschedule.mapper;
import org.apache.ibatis.annotations.*;
import java.time.LocalDateTime;
import java.util.List;
@Mapper
public interface AppointmentWorkSourceMapper {
record Source(long id, String cabinetNo, String containerNo, String departurePortName,
String destinationWarehouseName, LocalDateTime departedLocalAt, LocalDateTime sailedEvidenceLocalAt, int version, long factVersion, long deliveryVersion) { }
record Cargo(long inboundId, long customerId, long orderId, String orderNo, String inboundCode,
String destinationKind, String destinationCode, String destinationName, int packageCount) { }
record ContainerCargo(long containerBookingId,long inboundId,long customerId,long orderId,String orderNo,String inboundCode,
String destinationKind,String destinationCode,String destinationName,int packageCount) {
public Cargo cargo() { return new Cargo(inboundId,customerId,orderId,orderNo,inboundCode,destinationKind,destinationCode,destinationName,packageCount); }
}
String SOURCE_ROWS = """
SELECT b.id,b.cabinet_no,b.container_no,b.departure_port_name,b.destination_warehouse_name,
MIN(CASE WHEN f.standard_node='DEPARTED_ORIGIN_PORT' THEN f.actual_at END) departed_local_at,
MIN(f.actual_at) sailed_evidence_local_at,
b.version,MAX(f.id) fact_version,
(SELECT COUNT(*) FROM fl_direct_delivery_unloading d WHERE d.container_booking_id=b.id) delivery_version
FROM fl_container_booking b JOIN fl_carrier_schedule_node_fact f ON f.container_booking_id=b.id
WHERE b.record_status='ACTIVE' AND f.actual_at IS NOT NULL
AND f.standard_node IN ('DEPARTED_ORIGIN_PORT','IN_TRANSIT','ARRIVED_DESTINATION_PORT',
'DISCHARGED_FROM_VESSEL','PICKUP_READY','PICKED_UP','EMPTY_RETURNED')
""";
String SOURCE_ORDER = """
GROUP BY b.id,b.cabinet_no,b.container_no,b.departure_port_name,b.destination_warehouse_name,b.version
ORDER BY b.id
""";
// A complete identity/version manifest also detects deletions or edits between cursor pages.
@Select(SOURCE_ROWS + SOURCE_ORDER)
List<Source> sources();
/** Same live eligibility and aggregate facts as the manifest, restricted before aggregation. */
@Select("<script>" + SOURCE_ROWS + """
<choose>
<when test="ids != null and !ids.isEmpty()">
AND b.id IN <foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach>
</when>
<otherwise>AND 1=0</otherwise>
</choose>
""" + SOURCE_ORDER + "</script>")
List<Source> sourcesByIds(@Param("ids") List<Long> ids);
@Select("""
<script>
SELECT p.container_booking_id,i.inbound_id, i.account_id customer_id, i.order_id, i.order_no,i.inbound_code,
o.destination_type destination_kind,i.destination_warehouse_code destination_code,
i.destination_warehouse_name destination_name,SUM(i.package_count) package_count
FROM fl_loading_plan p JOIN fl_loading_plan_item i ON i.loading_plan_id=p.id AND i.deleted=0
JOIN fl_inbound_order o ON o.id=i.inbound_id
WHERE p.container_booking_id IN
<foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach>
AND p.plan_status='COMPLETED'
GROUP BY p.container_booking_id,i.inbound_id,i.account_id,i.order_id,i.order_no,i.inbound_code,o.destination_type,
i.destination_warehouse_code,i.destination_warehouse_name
ORDER BY p.container_booking_id,i.inbound_id
</script>
""")
List<ContainerCargo> cargo(@Param("ids") List<Long> ids);
}
@@ -15,6 +15,15 @@ import java.util.List;
@Mapper
public interface CarrierScheduleQueryMapper extends BaseMapper<CarrierScheduleQueryEntity> {
@Select("""
SELECT container_booking_id FROM fl_carrier_schedule_node_fact
WHERE standard_node='PICKED_UP' AND actual_at IS NOT NULL
GROUP BY container_booking_id
HAVING MAX(actual_at)>=#{from} AND MAX(actual_at)<#{to}
ORDER BY MAX(actual_at),container_booking_id LIMIT 1001
""")
List<Long> selectPickupCalendarContainers(@Param("from") LocalDateTime from,@Param("to") LocalDateTime to);
@Select("""
<script>
SELECT * FROM fl_carrier_schedule_query
@@ -12,7 +12,7 @@ import org.apache.ibatis.annotations.Update;
@Mapper
public interface PortScheduleMapper extends BaseMapper<PortScheduleEntity> {
@Select("""
SELECT c.id containerBookingId,c.booking_no bookingNo,c.container_no containerNo,
SELECT c.id containerBookingId,c.cabinet_no cabinetNo,c.container_no containerNo,
c.vessel_name vesselName,c.voyage_no voyageNo,c.destination_port_name destinationPortName,
c.destination_warehouse_code destinationWarehouseCode,
COALESCE(n.actual_at,c.actual_port_arrival_at,c.estimated_port_arrival_at) arrivalAt,
@@ -49,6 +49,9 @@ public class CarrierScheduleQueryRepository {
public List<ContainerActualFacts> containerActualFacts(List<Long> containerBookingIds) {
return queries.selectContainerActualFacts(containerBookingIds);
}
public List<Long> pickupCalendarContainers(LocalDateTime from, LocalDateTime to) {
return queries.selectPickupCalendarContainers(from, to);
}
public List<CarrierScheduleQueryEntity> due(LocalDateTime now, int limit) { return queries.selectDue(now, limit); }
public boolean claimLease(long id, String owner, LocalDateTime now, LocalDateTime leaseUntil) {
return queries.claimLease(id, owner, now, leaseUntil) == 1;
@@ -0,0 +1,90 @@
package com.baiyue.firstleg.portschedule.service;
import com.baiyue.firstleg.portschedule.dto.AppointmentWorkSourceDtos.*;
import com.baiyue.firstleg.portschedule.mapper.AppointmentWorkSourceMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
import java.security.MessageDigest;
import java.util.*;
@Service
public class AppointmentWorkSourceService {
public interface OriginalFacts {
Map<Long,String> establishedFlows(List<Long> containerBookingIds);
List<Fact> read(List<FactRequest> requests, Map<Long,ContainerSource> sources);
}
private final AppointmentWorkSourceMapper mapper;
private final OriginalFacts original;
private final ObjectMapper json;
public AppointmentWorkSourceService(AppointmentWorkSourceMapper mapper,OriginalFacts original,ObjectMapper json) {
this.mapper=mapper; this.original=original; this.json=json;
}
@Transactional(readOnly=true, isolation=org.springframework.transaction.annotation.Isolation.REPEATABLE_READ)
public SourcePage discover(String after,int size) {
if(size<1 || size>200) throw bad("分页大小必须为1至200");
var rows=mapper.sources();
String scope=hash(rows); long last=0;
if(after!=null) {
String[] parts=after.split(":",-1);
if(parts.length!=2) throw bad("来源游标无效");
if(!scope.equals(parts[0])) throw new ResponseStatusException(HttpStatus.CONFLICT,"已开船来源已变化,请重新同步");
last=id(parts[1]);
}
final long cursor=last;
var page=rows.stream().filter(row->row.id()>cursor).limit(size+1L).toList();
boolean complete=page.size()<=size;
var selected=page.stream().limit(size).toList();
return new SourcePage(sources(selected),complete?null:scope+":"+selected.get(selected.size()-1).id(),complete,scope);
}
public ContainerSource container(long id) {
var row=mapper.sources().stream().filter(candidate->candidate.id()==id).findFirst()
.orElseThrow(()->new ResponseStatusException(HttpStatus.NOT_FOUND,"未找到有效已开船柜次"));
return sources(List.of(row)).get(0);
}
private List<ContainerSource> sources(List<AppointmentWorkSourceMapper.Source> rows) {
if(rows.isEmpty()) return List.of();
var ids=rows.stream().map(AppointmentWorkSourceMapper.Source::id).toList();
var cargo=new HashMap<Long,List<AppointmentWorkSourceMapper.Cargo>>();
for(var item:mapper.cargo(ids)) cargo.computeIfAbsent(item.containerBookingId(),ignored->new ArrayList<>()).add(item.cargo());
var flows=original.establishedFlows(ids);
return rows.stream().map(row->source(row,cargo.getOrDefault(row.id(),List.of()),flows.get(row.id()))).toList();
}
private ContainerSource source(AppointmentWorkSourceMapper.Source row,List<AppointmentWorkSourceMapper.Cargo> cargo,String flow) {
if(flow==null) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"柜次路线事实读取不完整");
var requirements=cargo.stream().map(c->new Requirement("INBOUND:"+c.inboundId(),null,null,
c.destinationKind(),row.departurePortName(),c.destinationName(),null,
List.of(new Reference("CUSTOMER",Long.toString(c.customerId()),null,"所属客户"),
new Reference("ORDER",Long.toString(c.orderId()),c.orderNo(),"原订单"),
new Reference("INBOUND",Long.toString(c.inboundId()),c.inboundCode(),"入仓单"),
new Reference("CONTAINER",Long.toString(row.id()),row.cabinetNo(),"柜次")),List.of(),List.of())).toList();
return new ContainerSource(Long.toString(row.id()),row.containerNo(),null,row.departedLocalAt(),row.sailedEvidenceLocalAt(),
hash(List.of(row,cargo)),flow,null,requirements);
}
public List<Fact> facts(List<FactRequest> requests) {
if(requests==null || requests.isEmpty() || requests.size()>200) throw bad("事实请求须包含1至200项");
Set<String> keys=new HashSet<>(); var ids=new LinkedHashSet<Long>();
for(var request:requests) {
if(request==null || request.workItemKey()==null || request.workItemKey().isBlank() || !keys.add(request.workItemKey())
|| !("PICKUP".equals(request.type()) || "EMPTY_RETURN".equals(request.type()) || "DELIVERY".equals(request.type()) && "DIRECT".equals(request.flow())))
throw bad("事项身份或事实类型无效");
ids.add(id(request.containerBookingId()));
}
var selected=mapper.sourcesByIds(List.copyOf(ids));
if(selected.size()!=ids.size()) throw new ResponseStatusException(HttpStatus.NOT_FOUND,"未找到有效已开船柜次");
var sources=new LinkedHashMap<Long,ContainerSource>();
sources(selected).forEach(source->sources.put(Long.parseLong(source.containerBookingId()),source));
return original.read(requests,sources);
}
public String hash(Object value) {
try {return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json.writeValueAsBytes(value)));}
catch(Exception error){throw new IllegalStateException("不能生成来源版本",error);}
}
private static long id(String value) {
try {long id=Long.parseLong(value); if(id>0)return id;} catch(RuntimeException ignored) { }
throw bad("柜次或游标无效");
}
private static ResponseStatusException bad(String message){return new ResponseStatusException(HttpStatus.BAD_REQUEST,message);}
}
@@ -68,6 +68,7 @@ public class CarrierScheduleQueryService {
@Resource private CarrierScheduleAdapterRegistry adapters;
@Resource private CarrierScheduleNodeOutboxService nodeOutbox;
@Resource private ObjectMapper objectMapper;
@Resource private EmptyReturnPrerequisite emptyReturnPrerequisite;
@Value("${baiyue.carrier-schedule.scheduler.zone:Asia/Shanghai}")
private String scheduleZone = "Asia/Shanghai";
private Clock clock = Clock.systemDefaultZone();
@@ -81,6 +82,14 @@ public class CarrierScheduleQueryService {
source.page(), source.size(), source.total(), source.pages());
}
public List<Long> pickupCalendarContainers(LocalDateTime from, LocalDateTime to) {
if (from == null || to == null || !to.isAfter(from)
|| java.time.Duration.between(from, to).compareTo(java.time.Duration.ofDays(42)) > 0) {
throw badRequest("提柜日历查询范围必须为1至42天");
}
return repository.pickupCalendarContainers(from, to);
}
public List<ContainerActualFacts> containerActualFacts(List<Long> containerBookingIds) {
if (containerBookingIds == null || containerBookingIds.isEmpty() || containerBookingIds.size() > 200) {
throw badRequest("containerBookingIds数量必须在1到200之间");
@@ -223,6 +232,7 @@ public class CarrierScheduleQueryService {
CarrierScheduleQueryEntity value = requiredLockedVersion(id, request.expectedVersion());
CarrierScheduleNode node = CarrierScheduleNode.require(request.standardNode());
requireManualActualAdvance(value, node, request.actualAt());
requireEmptyReturnEvidence(value.getContainerBookingId(), node, request.actualAt());
CarrierScheduleQueryEntity before = copy(value);
String providerEventId = sha256("MANUAL|" + id + '|' + key);
CarrierScheduleNodeFactEntity fact = addNode(value, "MANUAL", providerEventId, node.name(), node,
@@ -266,6 +276,7 @@ public class CarrierScheduleQueryService {
CarrierScheduleQueryEntity query = found == null ? null : requiredLocked(found.getId());
String providerEventId = sha256("CONTAINER_MANUAL|" + containerBookingId + '|' + key);
String payloadHash = hash("CONTAINER_MANUAL_NODE", containerBookingId, request);
requireEmptyReturnEvidence(containerBookingId, node, request.actualAt());
CarrierScheduleNodeFactEntity fact = addNode(query == null ? null : query.getId(), containerBookingId,
"MANUAL", providerEventId,
node.name(), node, request.observedAt(), request.estimatedAt(), request.actualAt(),
@@ -331,6 +342,11 @@ public class CarrierScheduleQueryService {
}
}
private void requireEmptyReturnEvidence(long containerBookingId,CarrierScheduleNode node,LocalDateTime actualAt) {
if(node==CarrierScheduleNode.EMPTY_RETURNED && actualAt!=null)
emptyReturnPrerequisite.requireUnloaded(containerBookingId,actualAt);
}
public boolean hasOutboundDepartureFact(long customerId, long oceanBookingId, long containerBookingId) {
CarrierScheduleQueryEntity query = repository.findByBusinessScope(customerId, oceanBookingId,
containerBookingId);
@@ -0,0 +1,9 @@
package com.baiyue.firstleg.portschedule.service;
import java.time.LocalDateTime;
/** Actual unloading evidence owned by its original domain; not a work-item completion flag. */
public interface EmptyReturnPrerequisite {
LocalDateTime completedUnloading(long containerBookingId);
void requireUnloaded(long containerBookingId, LocalDateTime returnedAt);
}
@@ -42,7 +42,7 @@ public class ContainerOverviewService {
.toList();
PdaSummary pda = task == null ? null : pda(task);
List<TimelineEntry> timeline = timeline(id, task == null ? null : task.id(), exceptionHistory);
return new Overview(new ContainerBookingEntityView(booking.getId(), booking.getBookingNo(),
return new Overview(new ContainerBookingEntityView(booking.getId(),
booking.getCabinetNo(), booking.getContainerNo(), booking.getPlanStatus(), booking.getWarehouseId(),
booking.getWarehouseName(), booking.getVoyageNo(), booking.getVesselName(), booking.getBillOfLadingNo(),
booking.getShippingCompanyId(), booking.getShippingCompanyName(), booking.getDeparturePortCode(),
@@ -63,7 +63,7 @@ public class ContainerService {
.map(ChannelRow::getChannelCode).anyMatch(channelGroup.channelCodes()::contains);
if (!activeGroup) throw conflict("所选物流渠道分组在当前仓库已失效,请重新选择");
ContainerBookingEntity value = new ContainerBookingEntity(); value.setId(IdWorker.getId());
value.setBookingNo(documentNumbers.next("CB")); value.setCabinetNo(documentNumbers.nextCabinetNo());
value.setCabinetNo(documentNumbers.nextCabinetNo());
value.setSourceType("SIMULATION"); value.setRecordStatus("ACTIVE");
value.setChannelGroupCode(channelGroup.code()); value.setChannelCode(null); value.setChannelName(null);
value.setWarehouseId(warehouse.id()); value.setWarehouseName(warehouse.warehouseName());
@@ -19,7 +19,7 @@ import java.util.Set;
public class FirstlegDocumentNumberGenerator {
private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyMMdd", Locale.ROOT);
private static final Set<String> SUPPORTED_PREFIXES = Set.of("OB", "TA", "CB", "LP", "DP");
private static final Set<String> SUPPORTED_PREFIXES = Set.of("OB", "TA", "LP", "DP");
private static final long MAX_DAILY_SEQUENCE = 9_999L;
private final JdbcTemplate jdbc;
@@ -12,11 +12,15 @@ public class MerchantWorkbenchFilterService {
@org.springframework.transaction.annotation.Transactional(readOnly=true)
public MerchantWorkbenchFilters.Response read(MerchantWorkbenchFilters.Request request) {
if (request.orderIds() != null) {
return new MerchantWorkbenchFilters.Response(null, java.util.List.of(), java.util.List.of(),
progress(mapper.progressFragments(request.tenantId(), request.orderIds())));
}
return new MerchantWorkbenchFilters.Response(
request.mainStatus() == null ? null : mapper.byProcessStatuses(request.tenantId(),
unified.internalStatuses(request.mainStatus())),
mapper.pendingFees(request.tenantId()), mapper.pendingCustoms(request.tenantId()),
progress(mapper.progressFragments(request.tenantId())));
progress(mapper.progressFragments(request.tenantId(), null)));
}
static java.util.List<MerchantWorkbenchFilters.Progress> progress(java.util.List<MerchantWorkbenchFilters.Fragment> facts) {
@@ -0,0 +1,28 @@
package com.baiyue.firstleg.terminaldrayage.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import com.baiyue.firstleg.terminaldrayage.service.ContainerCalendarSourceService;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
@RestController
@RequestMapping("/api/platform/v1/terminal-drayages")
public class ContainerCalendarSourceController {
private final ContainerCalendarSourceService service;
/** 装配只读日历业务服务。 */
public ContainerCalendarSourceController(ContainerCalendarSourceService service) { this.service = service; }
/** 预约列表可读基础柜信息,还柜事实继续核验原业务读取权限。 */
@GetMapping("/calendar-sources")
@PreAuthorize("hasAuthority('platform:appointment:list')")
public ApiResponse<ContainerCalendarSourceService.Result> calendar(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.calendar(from, to, user.permissions()));
}
}
@@ -0,0 +1,30 @@
package com.baiyue.firstleg.terminaldrayage.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import com.baiyue.firstleg.terminaldrayage.service.DirectDeliveryService;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/platform/v1/terminal-drayages/{containerBookingId}/direct-deliveries")
public class PlatformDirectDeliveryController {
private final DirectDeliveryService service;
public PlatformDirectDeliveryController(DirectDeliveryService service){this.service=service;}
@GetMapping
@PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:list')")
public ApiResponse<List<DirectDeliveryService.View>> list(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable long containerBookingId) {
FirstlegScopeGuard.platform(user); return ApiResponse.success(service.list(containerBookingId));
}
@PostMapping("/{inboundId}")
@PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:direct-unload:confirm')")
public ApiResponse<DirectDeliveryService.View> record(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable long containerBookingId,
@PathVariable long inboundId,@RequestHeader("Idempotency-Key") String key,@Valid @RequestBody DirectDeliveryService.Request request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.record(containerBookingId,inboundId,user.userId(),user.username(),key,request));
}
}
@@ -0,0 +1,8 @@
package com.baiyue.firstleg.terminaldrayage.dto;
import java.time.LocalDateTime;
/** 四类日历使用的柜级来源,日期均保留原业务当地值。 */
public record ContainerCalendarSource(Long containerBookingId, String cabinetNo, String containerNo,
String vesselName, String voyageNo, String destinationPortName, String destinationWarehouseCode,
LocalDateTime arrivalAt, String arrivalType) { }
@@ -3,6 +3,6 @@ package com.baiyue.firstleg.terminaldrayage.dto;
import java.time.LocalDateTime;
/** Original pickup appointment wall time; the source does not establish a port time zone. */
public record PickupCalendarEntry(Long containerBookingId, String bookingNo, String containerNo,
public record PickupCalendarEntry(Long containerBookingId, String cabinetNo, String containerNo,
String destinationPortName, String destinationWarehouseCode, String fleetSupplierName,
LocalDateTime pickupAppointmentAt, LocalDateTime actualPickupAt) { }
@@ -0,0 +1,44 @@
package com.baiyue.firstleg.terminaldrayage.mapper;
import com.baiyue.firstleg.terminaldrayage.dto.ContainerCalendarSource;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.time.LocalDateTime;
import java.util.List;
@Mapper
public interface ContainerCalendarSourceMapper {
/** 同时选出到港、缺日期和可能还柜的有效出仓柜,最终日期由业务服务复核。 */
@Select("""
SELECT c.id containerBookingId,c.cabinet_no cabinetNo,c.container_no containerNo,
c.vessel_name vesselName,c.voyage_no voyageNo,c.destination_port_name destinationPortName,
c.destination_warehouse_code destinationWarehouseCode,
COALESCE(n.actual_at,c.actual_port_arrival_at,c.estimated_port_arrival_at) arrivalAt,
CASE WHEN COALESCE(n.actual_at,c.actual_port_arrival_at) IS NOT NULL THEN 'ACTUAL' ELSE 'ESTIMATED' END arrivalType
FROM fl_container_booking c
LEFT JOIN fl_terminal_drayage t ON t.container_booking_id=c.id
LEFT JOIN fl_carrier_schedule_node_fact n ON n.id=(
SELECT f.id FROM fl_carrier_schedule_node_fact f WHERE f.container_booking_id=c.id
AND f.standard_node='ARRIVED_DESTINATION_PORT' AND f.actual_at IS NOT NULL
ORDER BY f.observed_at DESC,f.id DESC LIMIT 1)
WHERE c.record_status='ACTIVE'
AND EXISTS(SELECT 1 FROM fl_outbound_dispatch d WHERE d.container_booking_id=c.id
AND d.confirmed_at IS NOT NULL AND d.reversed_at IS NULL AND d.status='WAITING_FOR_LOADING')
AND (
COALESCE(n.actual_at,c.actual_port_arrival_at,c.estimated_port_arrival_at) IS NULL
OR (COALESCE(n.actual_at,c.actual_port_arrival_at,c.estimated_port_arrival_at)>=#{arrivalFrom}
AND COALESCE(n.actual_at,c.actual_port_arrival_at,c.estimated_port_arrival_at)<#{to})
OR (t.actual_empty_return_at>=#{from} AND t.actual_empty_return_at<#{to})
OR (c.unloading_completed_at>=#{returnFrom} AND c.unloading_completed_at<#{to})
OR EXISTS(SELECT 1 FROM fl_carrier_schedule_node_fact r WHERE r.container_booking_id=c.id
AND r.standard_node='EMPTY_RETURNED' AND r.actual_at>=#{from} AND r.actual_at<#{to})
OR EXISTS(SELECT 1 FROM fl_direct_delivery_unloading u WHERE u.container_booking_id=c.id
AND u.actual_at>=#{returnFrom} AND u.actual_at<#{to})
)
ORDER BY c.id LIMIT 2001
""")
List<ContainerCalendarSource> sources(@Param("from") LocalDateTime from,
@Param("arrivalFrom") LocalDateTime arrivalFrom, @Param("returnFrom") LocalDateTime returnFrom,
@Param("to") LocalDateTime to);
}
@@ -0,0 +1,77 @@
package com.baiyue.firstleg.terminaldrayage.mapper;
import org.apache.ibatis.annotations.*;
import java.time.LocalDateTime;
import java.util.List;
@Mapper
public interface DirectDeliveryMapper {
record Delivery(long inboundId,String inboundCode,String orderNo,String destinationName,int packageCount,
int version,LocalDateTime actualAt,String timezone,String receivedBy,String evidenceNo,String remark,
String idempotencyKey,String requestHash) { }
record Container(long id,String recordStatus,LocalDateTime warehouseUnloadedAt) { }
record FlowEvidence(long id,LocalDateTime warehouseUnloadedAt,long evidenceCount) { }
record ContainerDelivery(long containerBookingId,long inboundId,String inboundCode,String orderNo,String destinationName,int packageCount,
int version,LocalDateTime actualAt,String timezone,String receivedBy,String evidenceNo,String remark,
String idempotencyKey,String requestHash) {
public Delivery delivery(){return new Delivery(inboundId,inboundCode,orderNo,destinationName,packageCount,version,
actualAt,timezone,receivedBy,evidenceNo,remark,idempotencyKey,requestHash);}
}
@Select("""
<script>
SELECT c.id,c.unloading_completed_at warehouse_unloaded_at,COUNT(d.inbound_id) evidence_count
FROM fl_container_booking c LEFT JOIN fl_direct_delivery_unloading d ON d.container_booking_id=c.id
WHERE c.id IN <foreach collection="ids" item="id" open="(" separator="," close=")">#{id}</foreach>
GROUP BY c.id,c.unloading_completed_at
</script>
""")
List<FlowEvidence> flowEvidence(@Param("ids") List<Long> ids);
@Select("""
<script>SELECT id,record_status,unloading_completed_at warehouse_unloaded_at FROM fl_container_booking
WHERE id IN <foreach collection="ids" item="id" open="(" separator="," close=")">#{id}</foreach></script>
""")
List<Container> containers(@Param("ids") List<Long> ids);
@Select("""
<script>
SELECT p.container_booking_id,i.inbound_id,MAX(i.inbound_code) inbound_code,MAX(i.order_no) order_no,
MAX(i.destination_warehouse_name) destination_name,SUM(i.package_count) package_count,
COALESCE(d.version,0) version,d.actual_at,d.timezone,d.received_by,d.evidence_no,d.remark,d.idempotency_key,d.request_hash
FROM fl_loading_plan p JOIN fl_loading_plan_item i ON i.loading_plan_id=p.id AND i.deleted=0
LEFT JOIN fl_direct_delivery_unloading d ON d.container_booking_id=p.container_booking_id AND d.inbound_id=i.inbound_id
WHERE p.container_booking_id IN <foreach collection="ids" item="id" open="(" separator="," close=")">#{id}</foreach>
AND p.plan_status='COMPLETED'
GROUP BY p.container_booking_id,i.inbound_id,d.version,d.actual_at,d.timezone,d.received_by,d.evidence_no,d.remark,d.idempotency_key,d.request_hash
ORDER BY p.container_booking_id,i.inbound_id
</script>
""")
List<ContainerDelivery> deliveriesBatch(@Param("ids") List<Long> ids);
@Select("SELECT id,record_status,unloading_completed_at warehouse_unloaded_at FROM fl_container_booking WHERE id=#{id} FOR UPDATE")
@Options(flushCache=Options.FlushCachePolicy.TRUE,useCache=false)
Container lockContainer(@Param("id") long id);
@Select("SELECT id,record_status,unloading_completed_at warehouse_unloaded_at FROM fl_container_booking WHERE id=#{id}")
Container container(@Param("id") long id);
@Select("""
SELECT i.inbound_id,MAX(i.inbound_code) inbound_code,MAX(i.order_no) order_no,
MAX(i.destination_warehouse_name) destination_name,SUM(i.package_count) package_count,
COALESCE(d.version,0) version,d.actual_at,d.timezone,d.received_by,d.evidence_no,d.remark,d.idempotency_key,d.request_hash
FROM fl_loading_plan p JOIN fl_loading_plan_item i ON i.loading_plan_id=p.id AND i.deleted=0
LEFT JOIN fl_direct_delivery_unloading d ON d.container_booking_id=p.container_booking_id AND d.inbound_id=i.inbound_id
WHERE p.container_booking_id=#{id} AND p.plan_status='COMPLETED'
GROUP BY i.inbound_id,d.version,d.actual_at,d.timezone,d.received_by,d.evidence_no,d.remark,d.idempotency_key,d.request_hash
ORDER BY i.inbound_id
""")
List<Delivery> deliveries(@Param("id") long id);
@Select("SELECT COUNT(*) FROM fl_direct_delivery_unloading WHERE container_booking_id=#{id}")
int evidenceCount(@Param("id") long id);
@Select("SELECT COUNT(*) FROM fl_direct_delivery_unloading WHERE container_booking_id=#{id} AND idempotency_key=#{key}")
int keyCount(@Param("id") long id,@Param("key") String key);
@Insert("""
INSERT INTO fl_direct_delivery_unloading(container_booking_id,inbound_id,actual_at,timezone,received_by,
evidence_no,remark,version,idempotency_key,request_hash,created_by,created_by_name)
VALUES(#{id},#{inboundId},#{actualAt},#{timezone},#{receivedBy},#{evidenceNo},#{remark},1,#{key},#{hash},#{operatorId},#{operatorName})
""")
int insert(@Param("id") long id,@Param("inboundId") long inboundId,@Param("actualAt") LocalDateTime actualAt,
@Param("timezone") String timezone,@Param("receivedBy") String receivedBy,@Param("evidenceNo") String evidenceNo,
@Param("remark") String remark,@Param("key") String key,@Param("hash") String hash,
@Param("operatorId") long operatorId,@Param("operatorName") String operatorName);
}
@@ -15,7 +15,7 @@ import java.util.List;
@Mapper
public interface TerminalDrayageMapper extends BaseMapper<TerminalDrayageEntity> {
@Select("""
SELECT c.id containerBookingId,c.booking_no bookingNo,c.container_no containerNo,
SELECT c.id containerBookingId,c.cabinet_no cabinetNo,c.container_no containerNo,
c.destination_port_name destinationPortName,c.destination_warehouse_code destinationWarehouseCode,
t.fleet_supplier_name fleetSupplierName,t.pickup_appointment_at pickupAppointmentAt,NULL actualPickupAt
FROM fl_terminal_drayage t JOIN fl_container_booking c ON c.id=t.container_booking_id
@@ -27,6 +27,21 @@ public interface TerminalDrayageMapper extends BaseMapper<TerminalDrayageEntity>
List<com.baiyue.firstleg.terminaldrayage.dto.PickupCalendarEntry> calendar(
@Param("from") LocalDateTime from,@Param("to") LocalDateTime to,@Param("limit") int limit);
@Select("""
<script>
SELECT c.id containerBookingId,c.cabinet_no cabinetNo,c.container_no containerNo,
c.destination_port_name destinationPortName,c.destination_warehouse_code destinationWarehouseCode,
t.fleet_supplier_name fleetSupplierName,t.pickup_appointment_at pickupAppointmentAt,NULL actualPickupAt
FROM fl_container_booking c LEFT JOIN fl_terminal_drayage t ON t.container_booking_id=c.id
WHERE c.record_status='ACTIVE' AND c.id IN
<foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach>
AND EXISTS(SELECT 1 FROM fl_outbound_dispatch d WHERE d.container_booking_id=c.id
AND d.confirmed_at IS NOT NULL AND d.reversed_at IS NULL AND d.status='WAITING_FOR_LOADING')
ORDER BY c.id
</script>
""")
List<com.baiyue.firstleg.terminaldrayage.dto.PickupCalendarEntry> calendarByContainers(@Param("ids") List<Long> ids);
@Select("""
SELECT b.id AS containerBookingId,b.container_no AS containerNo,b.ocean_booking_id AS oceanBookingId,
b.record_status AS recordStatus,b.unloading_completed_at AS unloadingCompletedAt
@@ -99,6 +114,16 @@ public interface TerminalDrayageMapper extends BaseMapper<TerminalDrayageEntity>
ORDER BY created_at DESC,id DESC
""")
List<History> histories(@Param("containerBookingId") long containerBookingId);
record PickupChanges(long containerBookingId,long schedules,long fleets) { }
@Select("""
<script>SELECT container_booking_id,
SUM(CASE WHEN action='SCHEDULE_PICKUP' THEN 1 ELSE 0 END) schedules,
SUM(CASE WHEN action='SELECT_FLEET' THEN 1 ELSE 0 END) fleets
FROM fl_terminal_drayage_history WHERE container_booking_id IN
<foreach collection="ids" item="id" open="(" separator="," close=")">#{id}</foreach>
GROUP BY container_booking_id</script>
""")
List<PickupChanges> pickupChanges(@Param("ids") List<Long> ids);
@Insert("""
INSERT INTO fl_terminal_drayage_history(id,drayage_id,container_booking_id,action,from_status,to_status,
@@ -0,0 +1,19 @@
package com.baiyue.firstleg.terminaldrayage.repository;
import com.baiyue.firstleg.terminaldrayage.dto.ContainerCalendarSource;
import com.baiyue.firstleg.terminaldrayage.mapper.ContainerCalendarSourceMapper;
import org.springframework.stereotype.Repository;
import java.time.LocalDate;
import java.util.List;
@Repository
public class ContainerCalendarSourceRepository {
private final ContainerCalendarSourceMapper mapper;
/** 使用本服务 Mapper 读取日历所需柜级来源。 */
public ContainerCalendarSourceRepository(ContainerCalendarSourceMapper mapper) { this.mapper = mapper; }
/** 到港前移两天覆盖默认到仓,直送还柜候选额外留一天覆盖夏令时。 */
public List<ContainerCalendarSource> sources(LocalDate from, LocalDate to) {
return mapper.sources(from.atStartOfDay(), from.minusDays(2).atStartOfDay(),
from.minusDays(4).atStartOfDay(), to.atStartOfDay());
}
}
@@ -0,0 +1,127 @@
package com.baiyue.firstleg.terminaldrayage.service;
import com.baiyue.firstleg.portschedule.dto.AppointmentWorkSourceDtos.*;
import com.baiyue.firstleg.portschedule.service.AppointmentWorkSourceService.OriginalFacts;
import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerActualFacts;
import com.baiyue.firstleg.terminaldrayage.entity.TerminalDrayageEntity;
import com.baiyue.firstleg.terminaldrayage.mapper.DirectDeliveryMapper;
import com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Component;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
import java.time.*;
import java.security.MessageDigest;
import java.util.*;
/** The terminal domain adapts its facts to a wire port; the work service never imports its persistence. */
@Component
public class AppointmentWorkTerminalFacts implements OriginalFacts {
private static final String READ="platform:firstleg:terminal-drayage:list";
private final TerminalDrayageMapper terminal;
private final DirectDeliveryMapper direct;
private final CarrierScheduleQueryService nodes;
private final UnloadingEvidenceGuard unloading;
private final ObjectMapper json;
public AppointmentWorkTerminalFacts(TerminalDrayageMapper terminal,DirectDeliveryMapper direct,
CarrierScheduleQueryService nodes,UnloadingEvidenceGuard unloading,ObjectMapper json) {
this.terminal=terminal;this.direct=direct;this.nodes=nodes;this.unloading=unloading;this.json=json;
}
public Map<Long,String> establishedFlows(List<Long> ids) {
var result=new HashMap<Long,String>();
if(ids.isEmpty()) return result;
for(var row:direct.flowEvidence(ids)) {
boolean transfer=row.warehouseUnloadedAt()!=null;
boolean delivery=row.evidenceCount()>0;
if(transfer && delivery) throw new ResponseStatusException(HttpStatus.CONFLICT,"货柜存在冲突的中转和直送执行事实");
result.put(row.id(),transfer?"TRANSFER":delivery?"DIRECT":"UNKNOWN");
}
return result;
}
public List<Fact> read(List<FactRequest> requests,Map<Long,ContainerSource> sources) {
var ids=requests.stream().map(r->Long.parseLong(r.containerBookingId())).distinct().toList();
if(ids.isEmpty()) return List.of();
var actuals=new HashMap<Long,ContainerActualFacts>();
for(var row:nodes.containerActualFacts(ids)) actuals.put(row.containerBookingId(),row);
var plans=new HashMap<Long,TerminalDrayageEntity>();
for(var row:terminal.findAll(ids)) plans.put(row.getContainerBookingId(),row);
var pickupIds=requests.stream().filter(r->"PICKUP".equals(r.type())).map(r->Long.parseLong(r.containerBookingId())).distinct().toList();
var changes=new HashMap<Long,TerminalDrayageMapper.PickupChanges>();
if(!pickupIds.isEmpty()) for(var row:terminal.pickupChanges(pickupIds)) changes.put(row.containerBookingId(),row);
var deliveryIds=requests.stream().filter(r->"DELIVERY".equals(r.type())).map(r->Long.parseLong(r.containerBookingId())).distinct().toList();
var deliveries=new HashMap<Long,List<DirectDeliveryMapper.Delivery>>();
if(!deliveryIds.isEmpty()) for(var row:direct.deliveriesBatch(deliveryIds))
deliveries.computeIfAbsent(row.containerBookingId(),ignored->new ArrayList<>()).add(row.delivery());
var returnIds=requests.stream().filter(r->"EMPTY_RETURN".equals(r.type())).map(r->Long.parseLong(r.containerBookingId())).distinct()
.filter(id->(actuals.get(id)==null || actuals.get(id).emptyReturnedAt()==null)
&& (plans.get(id)==null || plans.get(id).getActualEmptyReturnAt()==null)).toList();
var completed=returnIds.isEmpty()?Map.<Long,LocalDateTime>of():unloading.completedUnloading(returnIds);
return requests.stream().map(request->{
long id=Long.parseLong(request.containerBookingId());
return read(request,sources.get(id),actuals.get(id),plans.get(id),changes.get(id),
deliveries.getOrDefault(id,List.of()),completed.get(id));
}).toList();
}
private Fact read(FactRequest request,ContainerSource source,ContainerActualFacts node,TerminalDrayageEntity plan,
TerminalDrayageMapper.PickupChanges changes,List<DirectDeliveryMapper.Delivery> deliveries,LocalDateTime completedUnloading) {
var references=new ArrayList<Reference>();
source.requirements().forEach(r->r.references().forEach(ref->{if(!references.contains(ref))references.add(ref);}));
var blocks=new ArrayList<String>(); var missing=new ArrayList<String>();
LocalDateTime at=null; String zone=null; Slot confirmed=null; boolean exists=false;
String code=null,label=null,permission=null; Responsibility responsibility=null;
String businessNo=references.stream().filter(r->"CONTAINER".equals(r.kind())).map(Reference::businessNo).filter(Objects::nonNull).findFirst().orElse(null);
String impact="BASELINE";
switch(request.type()) {
case "PICKUP" -> {
at=node==null?null:node.pickedUpAt();
if(plan!=null && plan.getPickupAppointmentAt()!=null) {
var time=plan.getPickupAppointmentAt(); confirmed=new Slot(time.toLocalDate(),time.toLocalTime(),null,null,null); exists=true;
}
if(plan!=null && plan.getFleetSupplierId()!=null)
responsibility=new Responsibility("SUPPLIER",plan.getFleetSupplierId().toString(),plan.getFleetSupplierName());
// The initial arrangement/assignment establishes a baseline. Only later changes invalidate dependants.
long reschedules=Math.max(0,(changes==null?0:changes.schedules())-1);
long reassignments=Math.max(0,(changes==null?0:changes.fleets())-1);
impact=reschedules+":"+reassignments;
if(at==null) {
if(node==null || node.arrivedDestinationPortAt()==null) blocks.add("实际到港后才能安排提柜");
else if(plan==null || plan.getFleetSupplierId()==null) {code="SAVE_PLAN";label="选择码头车队";permission="platform:firstleg:terminal-drayage:fleet:edit";}
else if(confirmed==null) {code="REQUEST_BOOKING";label="预约提柜";permission="platform:firstleg:terminal-drayage:pickup-appointment:edit";}
else {code="RECORD_EXECUTION";label="记录实际提柜";permission="platform:firstleg:port-schedule-query:manual";}
}
}
case "EMPTY_RETURN" -> {
at=node==null?null:node.emptyReturnedAt();
if(at==null && plan!=null) at=plan.getActualEmptyReturnAt();
if(at==null && completedUnloading==null) blocks.add("海外仓卸柜或全部直送目的仓实际卸货后才能还空");
else if(at==null) {code="RECORD_EXECUTION";label="确认实际还空";permission="platform:firstleg:terminal-drayage:empty-return:confirm";}
// Return responsibility belongs to the work handoff, not the historical pickup carrier.
}
case "DELIVERY" -> {
var requirement=source.requirements().stream().filter(r->Objects.equals(r.requirementKey(),request.requirementKey())).findFirst()
.orElseThrow(()->new ResponseStatusException(HttpStatus.CONFLICT,"直送需求与原柜来源不一致"));
references.clear(); references.addAll(requirement.references());
var row=deliveries.stream().filter(d->("INBOUND:"+d.inboundId()).equals(request.requirementKey())).findFirst()
.orElseThrow(()->new ResponseStatusException(HttpStatus.CONFLICT,"直送需求与实际装柜来源不一致"));
at=row.actualAt();zone=row.timezone();businessNo=row.inboundCode();
if(at==null && (node==null || node.pickedUpAt()==null)) blocks.add("实际提柜后才能记录直送卸货");
else if(at==null) {code="RECORD_EXECUTION";label="登记目的仓实际卸货";permission="platform:firstleg:terminal-drayage:direct-unload:confirm";}
}
default -> throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"不支持的Firstleg事实类型");
}
if((at!=null || confirmed!=null) && zone==null) missing.add("SOURCE_TIMEZONE");
Actual actual=at==null?null:new Actual(zone==null?null:at.atZone(ZoneId.of(zone)).toInstant(),at,zone);
Action action=code==null?null:new Action(code,label,null,new Target("FIRSTLEG_CONTAINER",source.containerBookingId()));
var fact=new Fact(request.workItemKey(),"pending",impact,exists,confirmed==null?"UNPLANNED":"CONFIRMED",
actual!=null?"COMPLETED":"PENDING",null,confirmed,actual,zone,businessNo,null,List.of(READ),
permission==null?List.of():List.of(permission),action,List.copyOf(blocks),List.copyOf(missing),List.copyOf(references),responsibility);
return new Fact(fact.workItemKey(),hash(fact),fact.impactToken(),fact.planExists(),fact.planStatus(),fact.executionStatus(),
fact.target(),fact.confirmed(),fact.actual(),fact.timezone(),fact.businessNo(),fact.tripKey(),fact.readPermissions(),
fact.actionPermissions(),fact.action(),fact.blockReasons(),fact.missingFields(),fact.references(),fact.responsibility());
}
private String hash(Object value) {
try{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json.writeValueAsBytes(value)));}
catch(Exception error){throw new IllegalStateException(error);}
}
}
@@ -0,0 +1,46 @@
package com.baiyue.firstleg.terminaldrayage.service;
import com.baiyue.firstleg.terminaldrayage.dto.ContainerCalendarSource;
import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.View;
import com.baiyue.firstleg.terminaldrayage.repository.ContainerCalendarSourceRepository;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.*;
import org.springframework.web.server.ResponseStatusException;
@Service
public class ContainerCalendarSourceService {
public record Result(List<ContainerCalendarSource> containers, List<View> emptyReturns,
boolean emptyReturnsReadable) { }
private final ContainerCalendarSourceRepository repository;
private final TerminalDrayageService drayages;
/** 复用真实还柜业务的截止时间和完成事实,不在日历另算还柜状态。 */
public ContainerCalendarSourceService(ContainerCalendarSourceRepository repository, TerminalDrayageService drayages) {
this.repository = repository; this.drayages = drayages;
}
/** 按日期范围读取到港、默认到仓来源及可见的还柜记录。 */
public Result calendar(LocalDate from, LocalDate to, Set<String> permissions) {
if (from == null || to == null || !to.isAfter(from) || ChronoUnit.DAYS.between(from, to) > 42)
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "日历查询范围必须为1至42天");
var sources = repository.sources(from, to);
if (sources.size() > 2000)
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "日历来源超过2000柜,请缩小范围或补齐未定日期");
boolean readable = permissions != null && permissions.contains("platform:firstleg:terminal-drayage:list");
var returns = new ArrayList<View>();
if (readable) {
var ids = sources.stream().map(ContainerCalendarSource::containerBookingId).toList();
for (int start = 0; start < ids.size(); start += 200) {
for (var row : drayages.authorize(drayages.views(ids.subList(start, Math.min(start + 200, ids.size()))), permissions)) {
var at = row.actualEmptyReturnAt() != null ? row.actualEmptyReturnAt() : row.emptyReturnWarning().deadlineAt();
if (at != null && !at.toLocalDate().isBefore(from) && at.toLocalDate().isBefore(to)) returns.add(row);
}
}
}
// 未授权还柜读取时,也不能通过仅因还柜日期命中的旧柜候选推断受保护事实。
var visibleSources = readable ? sources : sources.stream().filter(row -> row.arrivalAt() == null
|| !row.arrivalAt().toLocalDate().isBefore(from.minusDays(2)) && row.arrivalAt().toLocalDate().isBefore(to)).toList();
return new Result(List.copyOf(visibleSources), List.copyOf(returns), readable);
}
}
@@ -0,0 +1,22 @@
package com.baiyue.firstleg.terminaldrayage.service;
import com.baiyue.firstleg.client.AppointmentWorkRouteClient;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
@Service
public class DirectDeliveryRouteGuard {
private final AppointmentWorkRouteClient client;
private final String token;
public DirectDeliveryRouteGuard(AppointmentWorkRouteClient client,@Value("${baiyue.security.internal-service-token:}") String token){this.client=client;this.token=token;}
public void requireDirect(long id) {
if(token==null || token.isBlank()) throw unavailable();
com.baiyue.common.core.api.ApiResponse<AppointmentWorkRouteClient.Route> response;
try {response=client.route(token,id);} catch(RuntimeException error){throw unavailable();}
if(response==null || !response.successful() || response.data()==null) throw unavailable();
if(!"DIRECT".equals(response.data().flow())) throw new ResponseStatusException(HttpStatus.CONFLICT,"请先在预约工作中确认整柜直送路线");
}
private ResponseStatusException unavailable(){return new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"无法核实已确认路线,请稍后重试");}
}
@@ -0,0 +1,77 @@
package com.baiyue.firstleg.terminaldrayage.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService;
import com.baiyue.firstleg.terminaldrayage.mapper.DirectDeliveryMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.validation.constraints.*;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.security.MessageDigest;
import java.time.*;
import java.util.*;
/** Actual direct delivery evidence. It does not call warehouse receipt or create stock. */
@Service
public class DirectDeliveryService {
public record Request(@Min(0) int expectedVersion,@NotNull LocalDateTime actualAt,@NotBlank String timezone,
@NotBlank @Size(max=128) String receivedBy,@NotBlank @Size(max=128) String evidenceNo,@Size(max=1000) String remark) { }
public record View(String requirementKey,String inboundCode,String orderNo,String destinationName,int packageCount,
int version,LocalDateTime actualAt,String timezone,String receivedBy,String evidenceNo,String remark) { }
private final DirectDeliveryMapper mapper;
private final CarrierScheduleQueryService nodes;
private final ObjectMapper json;
private final DirectDeliveryRouteGuard routes;
public DirectDeliveryService(DirectDeliveryMapper mapper,CarrierScheduleQueryService nodes,ObjectMapper json,DirectDeliveryRouteGuard routes) {
this.mapper=mapper;this.nodes=nodes;this.json=json;this.routes=routes;
}
public List<View> list(long id) {
var container=mapper.container(id);
if(container==null || !"ACTIVE".equals(container.recordStatus())) throw new BizException(ResultCode.NOT_FOUND,"货柜不存在");
return mapper.deliveries(id).stream().map(this::view).toList();
}
@Transactional
public View record(long id,long inboundId,long operatorId,String operatorName,String key,Request request) {
if(key==null || key.isBlank() || key.length()>128 || request==null) throw bad("请提供有效幂等键及卸货记录");
var container=mapper.lockContainer(id);
if(container==null || !"ACTIVE".equals(container.recordStatus())) throw new BizException(ResultCode.NOT_FOUND,"货柜不存在");
var current=mapper.deliveries(id).stream().filter(row->row.inboundId()==inboundId).findFirst()
.orElseThrow(()->bad("该入仓来源不在本柜实际装柜清单内"));
String hash=hash(request);
if(key.equals(current.idempotencyKey())) {
if(!hash.equals(current.requestHash())) throw conflict("同一幂等键对应的卸货内容已变化");
return view(current);
}
if(mapper.keyCount(id,key)>0) throw conflict("幂等键已被其他卸货记录使用");
if(current.version()!=request.expectedVersion() || current.actualAt()!=null) throw conflict("实际卸货已确认,请刷新核对");
if(container.warehouseUnloadedAt()!=null) throw conflict("已有海外仓卸柜事实,不能改记为直送");
routes.requireDirect(id);
ZoneId zone;
try {if(!ZoneId.getAvailableZoneIds().contains(request.timezone())) throw new IllegalArgumentException(); zone=ZoneId.of(request.timezone());} catch(RuntimeException e){throw bad("请选择有效目的地时区");}
if(request.actualAt()==null || zone.getRules().getValidOffsets(request.actualAt()).size()!=1)
throw bad("实际卸货时间缺失或处于夏令时歧义区间,请核对");
if(request.actualAt().atZone(zone).toInstant().isAfter(Instant.now())) throw bad("实际卸货时间不能在未来");
if(request.receivedBy()==null || request.receivedBy().isBlank() || request.evidenceNo()==null || request.evidenceNo().isBlank())
throw bad("请填写真实签收人及签收凭据号");
var facts=nodes.containerActualFacts(List.of(id));
var fact=facts.stream().filter(f->f.containerBookingId()==id).findFirst().orElse(null);
if(fact==null || fact.pickedUpAt()==null) throw conflict("实际提柜后才能记录直送卸货");
if(fact.emptyReturnedAt()!=null) throw conflict("货柜已实际还空,不能新增卸货事实");
// Pickup has no source zone. Its local clock cannot order a destination-zone actual;
// require the real pickup fact, without inventing an offset for it.
mapper.insert(id,inboundId,request.actualAt(),zone.getId(),request.receivedBy().trim(),request.evidenceNo().trim(),
request.remark(),key,hash,operatorId,operatorName);
return mapper.deliveries(id).stream().filter(row->row.inboundId()==inboundId).findFirst().map(this::view).orElseThrow();
}
private View view(DirectDeliveryMapper.Delivery row) {
return new View("INBOUND:"+row.inboundId(),row.inboundCode(),row.orderNo(),row.destinationName(),row.packageCount(),
row.version(),row.actualAt(),row.timezone(),row.receivedBy(),row.evidenceNo(),row.remark());
}
private String hash(Object value) {
try{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json.writeValueAsBytes(value)));}
catch(Exception e){throw new IllegalStateException(e);}
}
private static BizException bad(String message){return new BizException(ResultCode.BAD_REQUEST,message);}
private static BizException conflict(String message){return new BizException(ResultCode.CONFLICT,message);}
}
@@ -23,11 +23,18 @@ public class TerminalDrayageCalendarService {
@Value("${baiyue.security.internal-service-token}") String token) {this.mapper=mapper;this.warehouses=warehouses;this.shipping=shipping;this.token=token;}
public record Calendar(List<PickupCalendarEntry> records,boolean truncated,int unclassifiedCount,
String scopeBasis,String timeBasis) { }
@org.springframework.transaction.annotation.Transactional(readOnly=true)
public Calendar calendar(LocalDate from,LocalDate to) {
if(from==null || to==null || !to.isAfter(from) || ChronoUnit.DAYS.between(from,to)>42)
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"日历查询范围必须为1至42天");
var rows=mapper.calendar(from.atStartOfDay(),to.atStartOfDay(),1001);
if(rows.isEmpty()) return result(List.of(),false,0);
var planned=mapper.calendar(from.atStartOfDay(),to.atStartOfDay(),1001);
var actualIds=shipping.pickupCalendarContainers(from.atStartOfDay(),to.atStartOfDay());
var candidates=new LinkedHashMap<Long,PickupCalendarEntry>();
planned.stream().limit(1000).forEach(row->candidates.put(row.containerBookingId(),row));
if(!actualIds.isEmpty()) mapper.calendarByContainers(actualIds.stream().limit(1000).toList())
.forEach(row->candidates.put(row.containerBookingId(),row));
boolean truncated=planned.size()>1000 || actualIds.size()>1000;
if(candidates.isEmpty()) return result(List.of(),truncated,0);
final Map<String,String> countries=new HashMap<>();
try {
var response=warehouses.catalog(token);
@@ -36,7 +43,7 @@ public class TerminalDrayageCalendarService {
countries.put(warehouse.warehouseCode(),warehouse.countryCode());
} catch(RuntimeException error) {throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"提柜目的仓范围暂不可核实",error);}
var records=new ArrayList<PickupCalendarEntry>();int unknown=0;
for(var row:rows.stream().limit(1000).toList()) {
for(var row:candidates.values()) {
var country=countries.get(row.destinationWarehouseCode());
if(country==null || country.isBlank()) unknown++;
else if("US".equals(country)) records.add(row);
@@ -47,9 +54,14 @@ public class TerminalDrayageCalendarService {
for(var fact:shipping.containerActualFacts(ids.subList(start,Math.min(start+200,ids.size()))))
if(fact.pickedUpAt()!=null) actual.put(fact.containerBookingId(),fact.pickedUpAt());
}
var enriched=records.stream().map(row->new PickupCalendarEntry(row.containerBookingId(),row.bookingNo(),row.containerNo(),
row.destinationPortName(),row.destinationWarehouseCode(),row.fleetSupplierName(),row.pickupAppointmentAt(),actual.get(row.containerBookingId()))).toList();
return result(enriched,rows.size()>1000,unknown);
var enriched=records.stream().map(row->new PickupCalendarEntry(row.containerBookingId(),row.cabinetNo(),row.containerNo(),
row.destinationPortName(),row.destinationWarehouseCode(),row.fleetSupplierName(),row.pickupAppointmentAt(),actual.get(row.containerBookingId())))
.filter(row->{var at=occurrence(row);return at!=null && !at.isBefore(from.atStartOfDay()) && at.isBefore(to.atStartOfDay());})
.sorted(Comparator.comparing(TerminalDrayageCalendarService::occurrence).thenComparing(PickupCalendarEntry::containerBookingId)).toList();
return result(enriched.stream().limit(1000).toList(),truncated || enriched.size()>1000,unknown);
}
private static java.time.LocalDateTime occurrence(PickupCalendarEntry row) {
return row.actualPickupAt()!=null?row.actualPickupAt():row.pickupAppointmentAt();
}
private Calendar result(List<PickupCalendarEntry> records,boolean truncated,int unknown) {
return new Calendar(List.copyOf(records),truncated,unknown,"US_DESTINATION_WAREHOUSE","SOURCE_LOCAL_UNSPECIFIED");
@@ -48,6 +48,7 @@ public class TerminalDrayageService {
private final CarrierScheduleQueryService carrierSchedules;
private final ObjectMapper objectMapper;
private final TerminalDrayageMailService mails;
private final UnloadingEvidenceGuard unloadingEvidence;
private Clock clock = Clock.systemDefaultZone();
public View detail(long containerBookingId) {
@@ -145,10 +146,10 @@ public class TerminalDrayageService {
TerminalDrayageEntity value = mapper.lock(containerBookingId);
requireVersion(value, request.expectedVersion());
LocalDateTime pickupAt = actualPickupAt(context, value);
if (context.base().unloadingCompletedAt() == null) throw conflict("海外卸柜收货完成后才能确认还柜");
LocalDateTime earliest = pickupAt != null && pickupAt.isAfter(context.base().unloadingCompletedAt())
? pickupAt : context.base().unloadingCompletedAt();
if (request.actualAt().isBefore(earliest)) throw badRequest("实际还柜时间不能早于提柜或卸柜完成时间");
if (completedUnloading(context) == null) throw conflict("海外仓卸柜或全部直送目的仓实际卸货完成后才能确认还柜");
LocalDateTime earliest = context.base().unloadingCompletedAt();
if (pickupAt != null && (earliest == null || pickupAt.isAfter(earliest))) earliest = pickupAt;
if (earliest != null && request.actualAt().isBefore(earliest)) throw badRequest("实际还柜时间不能早于提柜或卸柜完成时间");
if (actualEmptyReturnAt(context, value) != null) throw conflict("实际还柜时间已确认");
advanceNode(context, CarrierScheduleNode.EMPTY_RETURNED, request.actualAt(), action, key,
request.remark(), operatorId, operatorName);
@@ -229,23 +230,25 @@ public class TerminalDrayageService {
}
private void applyAuthoritativeDeadline(WorkContext context, TerminalDrayageEntity value) {
if (context.base().unloadingCompletedAt() != null) {
value.setEstimatedEmptyReturnAt(context.base().unloadingCompletedAt().plusHours(EMPTY_RETURN_WARNING_HOURS));
if (completedUnloading(context) != null) {
value.setEstimatedEmptyReturnAt(returnDeadline(context));
}
}
private View view(WorkContext context, TerminalDrayageEntity value) {
String status = status(context, value);
LocalDateTime deadline = context.base().unloadingCompletedAt() == null ? null
: context.base().unloadingCompletedAt().plusHours(EMPTY_RETURN_WARNING_HOURS);
LocalDateTime deadline = returnDeadline(context);
Duration remaining = deadline == null ? null : context.directCompletion() == null
? Duration.between(LocalDateTime.now(clock), deadline)
: Duration.between(clock.instant(), context.directCompletion().instant().plus(Duration.ofHours(EMPTY_RETURN_WARNING_HOURS)));
LocalDateTime returnedAt = actualEmptyReturnAt(context, value);
String warningStatus;
long overdueHours = 0;
if (returnedAt != null) warningStatus = "RESOLVED";
else if (deadline == null) warningStatus = "NOT_APPLICABLE";
else if (LocalDateTime.now(clock).isAfter(deadline)) {
else if (remaining.isNegative()) {
warningStatus = "OVERDUE";
overdueHours = Math.max(0, Duration.between(deadline, LocalDateTime.now(clock)).toHours());
overdueHours = Math.max(0, remaining.negated().toHours());
} else warningStatus = "PENDING";
Warning warning = new Warning(warningStatus, deadline, overdueHours, overdueHours / 24);
return new View(context.base().containerBookingId(), context.base().containerNo(), context.base().oceanBookingId(), status,
@@ -253,14 +256,14 @@ public class TerminalDrayageService {
value == null ? null : value.getFleetSupplierName(),
value == null ? null : value.getPickupAppointmentAt(),
actualPickupAt(context, value), deadline, returnedAt,
context.base().unloadingCompletedAt(), warning, allowedActions(context, value), context.facts(),
completedUnloading(context), warning, allowedActions(context, value), context.facts(),
value == null ? null : value.getUpdatedAt());
}
private List<String> allowedActions(WorkContext context, TerminalDrayageEntity value) {
List<String> result = new ArrayList<>();
if (value != null && value.getFleetSupplierId() != null && actualPickupAt(context, value) == null
&& actualEmptyReturnAt(context, value) == null && context.base().unloadingCompletedAt() == null) result.add("SEND_PICKUP_EMAIL");
&& actualEmptyReturnAt(context, value) == null && completedUnloading(context) == null) result.add("SEND_PICKUP_EMAIL");
CarrierScheduleNode current = currentNode(context);
if (current != null && current.sequence() >= CarrierScheduleNode.ARRIVED_DESTINATION_PORT.sequence()
&& current.sequence() < CarrierScheduleNode.PICKED_UP.sequence()
@@ -268,14 +271,14 @@ public class TerminalDrayageService {
result.add("SELECT_FLEET");
if (value != null && value.getFleetSupplierId() != null) result.add("SCHEDULE_PICKUP");
}
if (context.base().unloadingCompletedAt() != null
if (completedUnloading(context) != null
&& actualEmptyReturnAt(context, value) == null) result.add("RECORD_EMPTY_RETURN");
return List.copyOf(result);
}
private String status(WorkContext context, TerminalDrayageEntity value) {
if (actualEmptyReturnAt(context, value) != null) return "EMPTY_RETURNED";
if (context.base().unloadingCompletedAt() != null) return "UNLOADED";
if (completedUnloading(context) != null) return "UNLOADED";
if (actualPickupAt(context, value) != null) return "PICKED_UP";
if (value != null && value.getPickupAppointmentAt() != null) return "PICKUP_SCHEDULED";
CarrierScheduleNode current = currentNode(context);
@@ -300,6 +303,14 @@ public class TerminalDrayageService {
return context.facts() == null ? null : node(context.facts().latestActualNode());
}
private LocalDateTime completedUnloading(WorkContext context) {
return context.completedUnloadingAt();
}
private LocalDateTime returnDeadline(WorkContext context) {
if(context.directCompletion()!=null) return context.directCompletion().deadline(EMPTY_RETURN_WARNING_HOURS);
return completedUnloading(context)==null?null:completedUnloading(context).plusHours(EMPTY_RETURN_WARNING_HOURS);
}
private LocalDateTime actualPickupAt(WorkContext context, TerminalDrayageEntity value) {
if (context.facts() != null && context.facts().pickedUpAt() != null) return context.facts().pickedUpAt();
return context.base().unloadingCompletedAt();
@@ -334,10 +345,12 @@ public class TerminalDrayageService {
private WorkContext context(Context base, ContainerActualFacts facts) {
if (base == null || !"ACTIVE".equals(base.recordStatus())) return null;
return new WorkContext(base, facts);
var direct=base.unloadingCompletedAt()==null?unloadingEvidence.directCompletion(base.containerBookingId()):null;
return new WorkContext(base, facts,base.unloadingCompletedAt()!=null?base.unloadingCompletedAt():direct==null?null:direct.localAt(),direct);
}
private record WorkContext(Context base, ContainerActualFacts facts) {}
private record WorkContext(Context base, ContainerActualFacts facts,LocalDateTime completedUnloadingAt,
UnloadingEvidenceGuard.DirectCompletion directCompletion) {}
private TerminalDrayageEntity requiredAggregate(long id) {
TerminalDrayageEntity value = mapper.lock(id);
@@ -0,0 +1,63 @@
package com.baiyue.firstleg.terminaldrayage.service;
import com.baiyue.firstleg.portschedule.service.EmptyReturnPrerequisite;
import com.baiyue.firstleg.terminaldrayage.mapper.DirectDeliveryMapper;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import org.springframework.stereotype.Service;
import java.time.*;
import java.util.*;
@Service
public class UnloadingEvidenceGuard implements EmptyReturnPrerequisite {
private final DirectDeliveryMapper mapper;
public UnloadingEvidenceGuard(DirectDeliveryMapper mapper){this.mapper=mapper;}
public record DirectCompletion(LocalDateTime localAt,String timezone) {
public Instant instant(){return localAt.atZone(ZoneId.of(timezone)).toInstant();}
public LocalDateTime deadline(int hours){return instant().plus(Duration.ofHours(hours)).atZone(ZoneId.of(timezone)).toLocalDateTime();}
}
public DirectCompletion directCompletion(long id) {
return directCompletion(mapper.deliveries(id));
}
private DirectCompletion directCompletion(List<DirectDeliveryMapper.Delivery> rows) {
if(rows.isEmpty() || rows.stream().anyMatch(row->row.actualAt()==null)) return null;
return rows.stream().map(row->new DirectCompletion(row.actualAt(),row.timezone()))
.max(java.util.Comparator.comparing(DirectCompletion::instant)).orElse(null);
}
public Map<Long,LocalDateTime> completedUnloading(List<Long> ids) {
var result=new HashMap<Long,LocalDateTime>();
if(ids.isEmpty()) return result;
var pending=new ArrayList<Long>();
for(var container:mapper.containers(ids)) {
if(!"ACTIVE".equals(container.recordStatus())) continue;
if(container.warehouseUnloadedAt()!=null) result.put(container.id(),container.warehouseUnloadedAt());
else pending.add(container.id());
}
if(!pending.isEmpty()) {
var deliveries=new HashMap<Long,List<DirectDeliveryMapper.Delivery>>();
for(var row:mapper.deliveriesBatch(pending)) deliveries.computeIfAbsent(row.containerBookingId(),ignored->new ArrayList<>()).add(row.delivery());
for(long id:pending) {
var completed=directCompletion(deliveries.getOrDefault(id,List.of()));
if(completed!=null) result.put(id,completed.localAt());
}
}
return result;
}
public LocalDateTime completedUnloading(long id) {
var container=mapper.container(id);
if(container==null || !"ACTIVE".equals(container.recordStatus())) return null;
if(container.warehouseUnloadedAt()!=null) return container.warehouseUnloadedAt();
var direct=directCompletion(id);
return direct==null?null:direct.localAt();
}
public void requireUnloaded(long id,LocalDateTime returnedAt) {
var container=mapper.lockContainer(id);
if(container==null) throw new BizException(ResultCode.NOT_FOUND,"货柜不存在");
LocalDateTime completed=completedUnloading(id);
if(completed==null) throw new BizException(ResultCode.CONFLICT,"海外仓卸柜或全部直送目的仓实际卸货完成后才能确认还柜");
// Original return nodes have no zone. Preserve the existing warehouse-local check,
// but do not compare that unzoned value with another destination's wall clock.
if(container.warehouseUnloadedAt()!=null && returnedAt.isBefore(completed))
throw new BizException(ResultCode.BAD_REQUEST,"实际还柜时间不能早于卸货完成时间");
}
}
@@ -94,7 +94,7 @@ BEGIN
SELECT MAX(plan_status) INTO current_plan_status
FROM fl_loading_plan WHERE container_booking_id = OLD.id;
IF current_plan_status IN ('LOADING', 'COMPLETED') AND (
NOT (OLD.id <=> NEW.id) OR NOT (OLD.booking_no <=> NEW.booking_no)
NOT (OLD.id <=> NEW.id)
OR NOT (OLD.cabinet_no <=> NEW.cabinet_no)
OR NOT (OLD.channel_group_code <=> NEW.channel_group_code)
OR NOT (OLD.channel_code <=> NEW.channel_code) OR NOT (OLD.channel_name <=> NEW.channel_name)
@@ -0,0 +1,19 @@
-- Current schema for actual destination unloading; never an overseas warehouse receipt.
CREATE TABLE IF NOT EXISTS fl_direct_delivery_unloading (
container_booking_id BIGINT UNSIGNED NOT NULL,
inbound_id BIGINT UNSIGNED NOT NULL COMMENT 'Immutable physically loaded inbound source',
actual_at DATETIME(3) NOT NULL COMMENT 'Actual destination local time',
timezone VARCHAR(64) NOT NULL COMMENT 'Explicit destination IANA timezone',
received_by VARCHAR(128) NOT NULL COMMENT 'Actual recipient',
evidence_no VARCHAR(128) NOT NULL COMMENT 'Actual delivery receipt reference',
remark VARCHAR(1000) NULL,
version INT NOT NULL DEFAULT 1,
idempotency_key VARCHAR(128) NOT NULL,
request_hash CHAR(64) NOT NULL,
created_by BIGINT UNSIGNED NOT NULL,
created_by_name VARCHAR(64) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (container_booking_id,inbound_id),
UNIQUE KEY uk_direct_unloading_command (container_booking_id,idempotency_key),
CONSTRAINT fk_direct_unloading_container FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='直送目的仓真实卸货凭据';
@@ -35,7 +35,6 @@ CREATE TABLE IF NOT EXISTS fl_document_sequence (
CREATE TABLE IF NOT EXISTS fl_container_booking (
id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID(雪花ID)',
booking_no VARCHAR(40) NOT NULL COMMENT '平台唯一货柜预订单号',
container_no VARCHAR(64) NULL COMMENT '国内柜次真实集装箱号权威快照;ISO 6346标准格式;同一订舱内唯一,不同提单允许复用;订舱关联可选写入、提柜后可原子补录、预装纠错命令可修改',
cabinet_no VARCHAR(64) NOT NULL COMMENT '平台唯一内部柜号',
source_type VARCHAR(24) NOT NULL DEFAULT 'SIMULATION' COMMENT '记录来源:SIMULATION内部模拟柜、OCEAN_BOOKING订舱真实柜',
@@ -90,7 +89,6 @@ CREATE TABLE IF NOT EXISTS fl_container_booking (
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '货柜资料最后更新时间',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_container_booking_no (booking_no),
UNIQUE KEY uk_container_booking_cabinet (cabinet_no),
UNIQUE KEY uk_container_booking_ocean_container (ocean_booking_id,container_no),
KEY idx_container_booking_container_no (container_no,updated_at,id),
@@ -895,7 +893,7 @@ BEGIN
SELECT MAX(plan_status) INTO current_plan_status
FROM fl_loading_plan WHERE container_booking_id = OLD.id;
IF current_plan_status IN ('LOADING', 'COMPLETED') AND (
NOT (OLD.id <=> NEW.id) OR NOT (OLD.booking_no <=> NEW.booking_no)
NOT (OLD.id <=> NEW.id)
OR NOT (OLD.cabinet_no <=> NEW.cabinet_no)
OR NOT (OLD.channel_code <=> NEW.channel_code)
OR NOT (OLD.channel_name <=> NEW.channel_name)
@@ -1542,9 +1540,9 @@ BEGIN
END IF;
IF (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_container_booking'
AND column_name IN ('booking_no', 'cabinet_no', 'warehouse_id', 'bill_of_lading_no',
AND column_name IN ('cabinet_no', 'warehouse_id', 'bill_of_lading_no',
'vessel_name', 'voyage_no', 'container_type', 'capacity_cbm',
'max_weight_kg', 'terminal_trucking_company_id', 'terminal_trucking_company')) <> 11 THEN
'max_weight_kg', 'terminal_trucking_company_id', 'terminal_trucking_company')) <> 10 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:货柜原型字段缺失';
END IF;
IF (SELECT COUNT(*) FROM information_schema.columns
@@ -0,0 +1,55 @@
openapi: 3.0.3
info: {title: 四类日历柜级来源, version: 1.0.0}
security: [{bearerAuth: []}]
paths:
/api/platform/v1/terminal-drayages/calendar-sources:
get:
summary: 查询日历到港、默认到仓来源和还柜事实
description: >-
仅平台,platform:appointment:list。返回已确认国内出仓、未撤销且有效的柜次。
到港取最新已观测实际到港节点、柜次实际到港、预计到港,按此顺序选一个日期。
到港候选前移两天覆盖计划到仓默认日期;缺到港日期的柜次同时返回,不能伪造日期。
还柜候选含原截止日期与真实完成日期,最终由原码头拖车业务复核,完成后只按实际还柜日期归日历。
没有platform:firstleg:terminal-drayage:list时emptyReturns为空且emptyReturnsReadable=false;不把缺权限当没有安排。
最多2000个候选,超出返回400;来源读取失败返回错误,不以空列表代替。
x-permissions: [platform:appointment:list]
parameters:
- {name: from, in: query, required: true, schema: {type: string, format: date}, description: 包含的原业务当地日期}
- {name: to, in: query, required: true, schema: {type: string, format: date}, description: 不包含的原业务当地日期,范围最多42天}
responses:
'200':
description: 成功业务响应,code为字符串200
content:
application/json:
schema:
type: object
properties:
code: {type: string}
data:
type: object
required: [containers, emptyReturns, emptyReturnsReadable]
properties:
emptyReturnsReadable: {type: boolean}
containers:
type: array
items:
type: object
properties:
containerBookingId: {type: string}
cabinetNo: {type: string, nullable: true}
containerNo: {type: string, nullable: true}
vesselName: {type: string, nullable: true}
voyageNo: {type: string, nullable: true}
destinationPortName: {type: string, nullable: true}
destinationWarehouseCode: {type: string, nullable: true}
arrivalAt: {type: string, format: date-time, nullable: true}
arrivalType: {type: string, enum: [ACTUAL, ESTIMATED]}
emptyReturns:
type: array
description: 沿terminal-drayage-v1的View结构,allowedActions按当前权限过滤;含actualEmptyReturnAt及emptyReturnWarning.deadlineAt
items: {$ref: './terminal-drayage-v1.yaml#/components/schemas/TerminalDrayageView'}
'400': {description: 范围无效或候选过多}
'403': {description: 非平台或没有预约读取权限}
components:
securitySchemes:
bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT}
@@ -0,0 +1,178 @@
openapi: 3.0.3
info: {title: Firstleg appointment work authoritative sources, version: 1.0.0}
security: [{InternalServiceToken: []}]
paths:
/internal/firstleg/appointment-work/containers:
get:
summary: Complete actual-sailed container scope
description: Includes missing warehouses, private destinations and stopped tracking tasks. An actual departure or later maritime milestone is required. GET never creates work. Scope-versioned cursors reject changes between pages with 409; consumers restart a full scan. Original local times without a source timezone are never converted to UTC.
parameters:
- {name: after, in: query, schema: {type: string, maxLength: 128}}
- {name: size, in: query, schema: {type: integer, minimum: 1, maximum: 200, default: 200}}
responses:
'200':
description: ApiResponse
content:
application/json:
schema:
type: object
properties:
data: {$ref: '#/components/schemas/SourcePage'}
'401': {description: Invalid internal service identity}
'409': {description: Scope changed; restart from the first page}
/internal/firstleg/appointment-work/containers/{id}:
get:
summary: Read one authoritative sailed container and physical cargo lineage
parameters: [{name: id, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}}]
responses:
'200':
description: ApiResponse
content:
application/json:
schema:
type: object
properties: {data: {$ref: '#/components/schemas/ContainerSource'}}
'401': {description: Invalid internal service identity}
'404': {description: No active actual-sailed source}
/internal/firstleg/appointment-work/facts:
post:
summary: Read pickup, direct unloading and empty-return facts; no writes
requestBody:
required: true
content:
application/json:
schema: {type: array, minItems: 1, maxItems: 200, items: {$ref: '#/components/schemas/FactRequest'}}
responses:
'200':
description: Exactly one result per requested work key, never a partial success
content:
application/json:
schema:
type: object
properties: {data: {type: array, items: {$ref: '#/components/schemas/Fact'}}}
'400': {description: Invalid type or duplicate identity}
'401': {description: Invalid internal service identity}
'409': {description: Direct requirement does not match physical cargo}
components:
securitySchemes:
InternalServiceToken: {type: apiKey, in: header, name: X-Internal-Service-Token}
schemas:
Reference:
type: object
required: [kind, key, label]
properties:
kind: {type: string}
key: {type: string, description: Internal association only}
businessNo: {type: string, nullable: true}
label: {type: string}
SourceReference:
type: object
properties:
customerId: {type: string}
sourceKey: {type: string}
destinationKey: {type: string, nullable: true}
Requirement:
type: object
required: [requirementKey, references, replacesRequirementKeys, sources]
properties:
requirementKey: {type: string, description: 'Immutable INBOUND:<physical inbound id>'}
batchKey: {type: string, nullable: true}
batchNo: {type: string, nullable: true}
destinationKind: {type: string, nullable: true}
originName: {type: string, nullable: true}
destinationName: {type: string, nullable: true}
timezone: {type: string, nullable: true}
references: {type: array, items: {$ref: '#/components/schemas/Reference'}}
replacesRequirementKeys: {type: array, items: {type: string}}
sources: {type: array, items: {$ref: '#/components/schemas/SourceReference'}, description: Empty until Lastleg resolves its canonical source; domestic ORDER references are not tail-leg ORDER keys}
ContainerSource:
type: object
required: [containerBookingId, sourceVersion, establishedFlow, requirements]
properties:
containerBookingId: {type: string}
containerNo: {type: string, nullable: true}
departedAt: {type: string, format: date-time, nullable: true}
departedLocalAt: {type: string, nullable: true, description: Exact actual departure only; source local timestamp without offset}
sailedEvidenceLocalAt: {type: string, nullable: true, description: Actual departure-or-later node proving sailed eligibility; never presented as an actual departure time}
sourceVersion: {type: string}
establishedFlow: {type: string, enum: [UNKNOWN, DIRECT, TRANSFER]}
timezone: {type: string, nullable: true}
requirements: {type: array, items: {$ref: '#/components/schemas/Requirement'}}
SourcePage:
type: object
required: [containers, complete, scopeVersion]
properties:
containers: {type: array, items: {$ref: '#/components/schemas/ContainerSource'}}
nextCursor: {type: string, nullable: true}
complete: {type: boolean}
scopeVersion: {type: string}
ContainerReference:
type: object
properties:
containerBookingId: {type: string}
bookingNo: {type: string, nullable: true}
containerNo: {type: string, nullable: true}
FactRequest:
type: object
required: [workItemKey, type, flow, containerBookingId]
properties:
workItemKey: {type: string}
type: {type: string, enum: [PICKUP, EMPTY_RETURN, DELIVERY]}
flow: {type: string, enum: [TRANSFER, DIRECT, UNKNOWN]}
containerBookingId: {type: string}
requirementKey: {type: string, nullable: true}
batchKey: {type: string, nullable: true}
tripKey: {type: string, nullable: true}
batchKeys: {type: array, items: {type: string}}
containers: {type: array, items: {$ref: '#/components/schemas/ContainerReference'}}
sources: {type: array, items: {$ref: '#/components/schemas/SourceReference'}}
Slot:
type: object
properties:
date: {type: string, format: date}
startTime: {type: string, nullable: true}
endDate: {type: string, format: date, nullable: true}
endTime: {type: string, nullable: true}
timezone: {type: string, nullable: true, description: Null for unzoned original plans; source-local date is retained}
Actual:
type: object
description: At least one time is required. Unzoned source facts contain localOccurredAt only.
properties:
occurredAt: {type: string, format: date-time, nullable: true}
localOccurredAt: {type: string, nullable: true}
timezone: {type: string, nullable: true}
Fact:
type: object
required: [workItemKey, fingerprint, impactToken, planExists, planStatus, executionStatus, readPermissions, actionPermissions, blockReasons, missingFields, references]
properties:
workItemKey: {type: string}
fingerprint: {type: string}
impactToken: {type: string, description: First scheduling and first carrier assignment do not increase semantic impact}
planExists: {type: boolean}
planStatus: {type: string, enum: [UNPLANNED, TARGET_SET, AWAITING_REPLY, CONFIRMED, NOT_REQUIRED]}
executionStatus: {type: string, enum: [PENDING, IN_PROGRESS, COMPLETED]}
target: {$ref: '#/components/schemas/Slot'}
confirmed: {$ref: '#/components/schemas/Slot'}
actual: {$ref: '#/components/schemas/Actual'}
timezone: {type: string, nullable: true}
businessNo: {type: string, nullable: true}
tripKey: {type: string, nullable: true}
readPermissions: {type: array, items: {type: string}}
actionPermissions: {type: array, items: {type: string}}
blockReasons: {type: array, items: {type: string}}
missingFields: {type: array, items: {type: string}}
references: {type: array, items: {$ref: '#/components/schemas/Reference'}}
responsibility:
type: object
nullable: true
properties: {kind: {type: string}, id: {type: string, nullable: true}, name: {type: string}}
action:
type: object
nullable: true
properties:
code: {type: string}
label: {type: string}
targetWorkItemKey: {type: string, nullable: true}
target:
type: object
properties: {kind: {type: string, enum: [FIRSTLEG_CONTAINER]}, containerBookingId: {type: string}}
@@ -28,7 +28,7 @@ paths:
type: object
properties:
containerBookingId: {type: string}
bookingNo: {type: string}
cabinetNo: {type: string}
containerNo: {type: string, nullable: true}
vesselName: {type: string, nullable: true}
voyageNo: {type: string, nullable: true}
@@ -0,0 +1,70 @@
openapi: 3.0.3
info: {title: Direct destination unloading evidence, version: 1.0.0}
paths:
/api/platform/v1/terminal-drayages/{containerBookingId}/direct-deliveries:
get:
summary: Physical loaded inbound sources and immutable direct unloading evidence
x-permission: platform:firstleg:terminal-drayage:list
parameters: [{$ref: '#/components/parameters/Container'}]
responses:
'200':
description: ApiResponse
content:
application/json:
schema:
type: object
properties: {data: {type: array, items: {$ref: '#/components/schemas/View'}}}
'403': {description: Requires platform identity and list permission}
/api/platform/v1/terminal-drayages/{containerBookingId}/direct-deliveries/{inboundId}:
post:
summary: Record actual unloading for one complete physically loaded source
description: Locks the physical container. Requires Lastleg's persisted confirmed DIRECT route, actual pickup, a real recipient and receipt reference, an unambiguous IANA local time, and no warehouse unloading or empty-return fact. Route source failure returns 503. Exact retries replay; changed retries conflict. Does not create warehouse stock. Every loaded source must complete before manual empty return. Unzoned pickup/return timestamps are not compared to destination wall-clock times; cross-location chronology cannot be proven without the source zone. Direct completion and its 72-hour reminder use the latest evidence Instant.
x-permission: platform:firstleg:terminal-drayage:direct-unload:confirm
parameters:
- {$ref: '#/components/parameters/Container'}
- {name: inboundId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}}
- {name: Idempotency-Key, in: header, required: true, schema: {type: string, maxLength: 128}}
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [expectedVersion, actualAt, timezone, receivedBy, evidenceNo]
properties:
expectedVersion: {type: integer, minimum: 0}
actualAt: {type: string, description: Local YYYY-MM-DDTHH:mm:ss without offset}
timezone: {type: string, description: Explicit destination IANA zone}
receivedBy: {type: string, minLength: 1, maxLength: 128}
evidenceNo: {type: string, minLength: 1, maxLength: 128}
remark: {type: string, maxLength: 1000, nullable: true}
responses:
'200':
description: ApiResponse
content:
application/json:
schema:
type: object
properties: {data: {$ref: '#/components/schemas/View'}}
'400': {description: Invalid source, time or evidence}
'403': {description: Requires platform identity and independent unloading permission}
'409': {description: Invalid business prerequisites, version or replay content}
'503': {description: Cannot verify authoritative route}
components:
parameters:
Container: {name: containerBookingId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}}
schemas:
View:
type: object
properties:
requirementKey: {type: string, description: 'INBOUND:<id>, association only, not a visible business number'}
inboundCode: {type: string, nullable: true}
orderNo: {type: string, nullable: true}
destinationName: {type: string, nullable: true}
packageCount: {type: integer}
version: {type: integer, description: Zero until first actual unloading}
actualAt: {type: string, nullable: true}
timezone: {type: string, nullable: true}
receivedBy: {type: string, nullable: true}
evidenceNo: {type: string, nullable: true}
remark: {type: string, nullable: true}
@@ -355,7 +355,7 @@ paths:
/internal/firstleg/v1/inbounds/unified-orders/workbench-filters:
post:
summary: 查询单一客户工作台履约筛选标识(Order内部)
description: 只读返回命中状态、最新待确认费用及待上传资料的订单标识。客户必须明确且为正数;不创建报关聚合,不跨Schema读取。
description: 只读返回命中状态、最新待确认费用及待上传资料的订单标识。客户必须明确且为正数;不创建报关聚合,不跨Schema读取。传入orderIds为精确进度模式,仅计算progress,matchingStatusOrderIds为null,feeOrderIds和customsOrderIds为空;mainStatus在该模式不参与查询。
security: [{internalServiceToken: []}]
requestBody:
required: true
@@ -367,6 +367,13 @@ paths:
properties:
tenantId: {type: integer, format: int64, minimum: 1}
mainStatus: {type: string, nullable: true, enum: [WAIT_MEASURE, WAIT_PALLETIZE, WAIT_SPLIT, LOADING, LOADED, DELIVERED]}
orderIds:
type: array
nullable: true
minItems: 1
maxItems: 200
items: {type: integer, format: int64, minimum: 1}
description: 省略或null查询当前客户全部进度;传入时仅在SQL内查询这些来源Order订单,空数组拒绝,不允许扩大到全客户。
responses:
'200':
description: 按响应code判断,标识只供Order在自身客户范围内过滤、计数和分页
@@ -2323,13 +2330,12 @@ components:
allowedActions: {type: array, items: {type: string, enum: [SELECT_FLEET, SCHEDULE_PICKUP, RECORD_EMPTY_RETURN, SEND_PICKUP_EMAIL]}}
ContainerBooking:
type: object
required: [id, bookingNo, cabinetNo, sourceType, recordStatus, warehouseId, warehouseName, channelGroupCode, containerType, capacityCbm, maxWeightKg, planStatus, exceptionActive, destinationClearanceDocumentStatus, customsDeclarationDocumentStatus, deliveryOrderDocumentStatus, version]
required: [id, cabinetNo, sourceType, recordStatus, warehouseId, warehouseName, channelGroupCode, containerType, capacityCbm, maxWeightKg, planStatus, exceptionActive, destinationClearanceDocumentStatus, customsDeclarationDocumentStatus, deliveryOrderDocumentStatus, version]
properties:
id: {type: string, pattern: '^[1-9][0-9]*$'}
currentPortScheduleId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, readOnly: true, description: 当前运输读模型由 latestForDispatch 选中的船期记录 ID;无匹配时为空,仅用于定位关联船期,不作为业务编号展示}
planId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: 关联智能装柜计划ID;未创建计划时为空}
planNo: {type: string, nullable: true, pattern: '^LP-[0-9]{6}-[0-9]{4}$', example: LP-260818-0001, description: 预装与确认装柜清单共用的稳定编号;未创建计划时为空}
bookingNo: {type: string, pattern: '^CB-[0-9]{6}-[0-9]{4}$', example: CB-260818-0001, description: 平台内部货柜登记号}
containerNo: {type: string, maxLength: 64, nullable: true}
cabinetNo: {type: string, pattern: '^D[0-9]{4,}$', example: D0001, description: 平台全局连续且不按日期重置的稳定柜次号}
sourceType: {type: string, enum: [SIMULATION, OCEAN_BOOKING], description: SIMULATION 为内部模拟柜;OCEAN_BOOKING 为订舱登记直接生成的真实柜}
@@ -2606,11 +2612,10 @@ components:
pages: {type: string, pattern: '^[0-9]+$'}
SmartLoadingWorkbenchContainer:
type: object
required: [bookingId, bookingNo, cabinetNo, warehouseId, warehouseName, channelGroupCode,
required: [bookingId, cabinetNo, warehouseId, warehouseName, channelGroupCode,
containerType, capacityCbm, maxWeightKg, planStatus, version]
properties:
bookingId: {type: string, pattern: '^[1-9][0-9]*$', description: containerBookingId 技术关联}
bookingNo: {type: string, description: 货柜登记业务号}
cabinetNo: {type: string, description: 稳定柜次业务号}
containerNo: {type: string, nullable: true, description: 真实集装箱号,允许在装柜前后补}
warehouseId: {type: string, pattern: '^[1-9][0-9]*$'}
@@ -74,7 +74,7 @@ components:
exception: {$ref: '#/components/schemas/ExceptionState'}
exceptionHistory: {type: array, items: {$ref: '#/components/schemas/ExceptionHistory'}}
timeline: {type: array, items: {$ref: '#/components/schemas/TimelineEntry'}}
Container: {type: object, properties: {id: {type: integer, format: int64}, bookingNo: {type: string, pattern: '^CB-[0-9]{6}-[0-9]{4}$', example: CB-260818-0001}, cabinetNo: {type: string, pattern: '^D[0-9]{4,}$', example: D0001}, containerNo: {type: string}, status: {type: string}, warehouseId: {type: integer, format: int64}, warehouseName: {type: string}, voyageNo: {type: string}, vesselName: {type: string}, pickupCode: {type: string, pattern: '^[0-9]{1,64}$', nullable: true}, pickupCodeUpdatedBy: {type: integer, format: int64, nullable: true}, pickupCodeUpdatedByName: {type: string, nullable: true}, pickupCodeUpdatedAt: {type: string, format: date-time, nullable: true}, version: {type: integer, minimum: 0}}}
Container: {type: object, properties: {id: {type: integer, format: int64}, cabinetNo: {type: string, pattern: '^D[0-9]{4,}$', example: D0001}, containerNo: {type: string}, status: {type: string}, warehouseId: {type: integer, format: int64}, warehouseName: {type: string}, voyageNo: {type: string}, vesselName: {type: string}, pickupCode: {type: string, pattern: '^[0-9]{1,64}$', nullable: true}, pickupCodeUpdatedBy: {type: integer, format: int64, nullable: true}, pickupCodeUpdatedByName: {type: string, nullable: true}, pickupCodeUpdatedAt: {type: string, format: date-time, nullable: true}, version: {type: integer, minimum: 0}}}
PdaSummary: {type: object, properties: {taskId: {type: integer, format: int64}, loadingPlanId: {type: integer, format: int64}, containerBookingId: {type: integer, format: int64}, taskStatus: {type: string, description: '持久化任务态:LOADING/COMPLETED'}, workflowStatus: {type: string, enum: [IN_PROGRESS, AWAITING_REVIEW, COMPLETED], description: 'PDA作业展示态'}, taskVersion: {type: integer}, itemCount: {type: integer}, completedItemCount: {type: integer}, unresolvedItemCount: {type: integer}, photoCounts: {type: array, items: {$ref: '#/components/schemas/PhotoCount'}}, photos: {type: array, items: {$ref: '#/components/schemas/Photo'}}, exceptionReasons: {type: array, items: {type: string}}}}
PhotoCount: {type: object, properties: {evidenceType: {type: string}, count: {type: integer}}}
Photo: {type: object, properties: {evidenceId: {type: integer, format: int64}, tenantId: {type: integer, format: int64}, inboundId: {type: integer, format: int64}, evidenceType: {type: string}, fileId: {type: integer, format: int64}, fileName: {type: string}, status: {type: string, enum: [ACTIVE, VOID]}, downloadPath: {type: string, nullable: true, description: '仅 ACTIVE 照片提供鉴权下载路径;VOID 历史照片为空'}, uploadedAt: {type: string, format: date-time}}}
@@ -4,7 +4,7 @@ paths:
/api/platform/v1/terminal-drayages/calendar:
get:
summary: 美国目的仓相关的提柜预约日历
description: 平台身份,同时要求预约列表和码头拖车列表权限。按原业务预约提柜日期查询,不将实际提柜或预计到仓替代预约时间。美国范围按启用目的仓国家确认,不代表已确认港口所在国。原字段未提供港口时区,timeBasis明确待核实,禁止当作UTC或套用收货仓时区。最多42天、读取1001条以检测截断;返回最多1000条中的美国范围记录,未知目的仓计入unclassifiedCount,不能将缺失数据当零。
description: 平台身份,同时要求预约列表和码头拖车列表权限。已提柜按船节点权威实际提柜日期、尚未提柜按原预约日期查询,改期不移动已执行事实;原预约与实际时间分别返回,不以预计到仓替代。美国范围按启用目的仓国家确认,不代表已确认港口所在国。原字段未提供港口时区,timeBasis明确待核实,禁止当作UTC或套用收货仓时区。最多42天,原预约和船节点实际日期候选分别读取1001条检测截断,经各自所属服务读取并按柜去重;返回最多1000条美国范围记录,未知目的仓计入unclassifiedCount,不能将缺失数据当零。
x-permissions: [platform:appointment:list, 'platform:firstleg:terminal-drayage:list']
parameters:
- {name: from, in: query, required: true, schema: {type: string, format: date}, description: 包含的原业务当地日期}
@@ -33,12 +33,12 @@ paths:
type: object
properties:
containerBookingId: {type: string, description: 仅用于来源详情定位}
bookingNo: {type: string}
cabinetNo: {type: string}
containerNo: {type: string, nullable: true}
destinationPortName: {type: string, nullable: true}
destinationWarehouseCode: {type: string, nullable: true}
fleetSupplierName: {type: string, nullable: true}
pickupAppointmentAt: {type: string, description: 原业务当地时间,无时区偏移,不是UTC时间点}
pickupAppointmentAt: {type: string, nullable: true, description: 原业务当地预约时间,无时区偏移;仅有实际提柜事实时可为空}
actualPickupAt: {type: string, nullable: true}
'400': {description: 日期范围非法}
'403': {description: 非平台身份或权限不足}
@@ -82,8 +82,8 @@ paths:
responses: {'200': {$ref: '#/components/responses/ViewResponse'}}
/api/platform/v1/terminal-drayages/{containerBookingId}/empty-return:
post:
summary: 海外卸柜收货完成后确认实际还柜并推进EMPTY_RETURNED节点
description: 以海外卸柜完成事实开放还柜;无需已有车队、预约或实际提柜记录。无拖车记录时提交 expectedVersion=0,由命令创建记录,不补造提柜时间。实际还柜时间不得早于卸柜完成时间及已有实际提柜时间。
summary: 海外仓卸柜或全部直送目的仓实际卸货完成后确认实际还柜并推进EMPTY_RETURNED节点
description: 海外仓卸柜或全部直送目的仓实际卸货完成后开放还柜。无需已有车队或预约;海外仓卸柜可无实际提柜记录。无拖车记录时提交 expectedVersion=0,不补造提柜时间。保留原海外仓卸柜及已有船节点实际提柜的当地时间先后校验;直送目的仓时区不能套用于缺少时区的还柜时间,不比较不同地点的当地钟面值。
x-permission: platform:firstleg:terminal-drayage:empty-return:confirm
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ActualTimeRequest'}}}}
@@ -173,7 +173,7 @@ components:
required: [status, overdueHours, overdueDays]
properties:
status: {type: string, enum: [NOT_APPLICABLE, PENDING, OVERDUE, RESOLVED]}
deadlineAt: {type: string, format: date-time, nullable: true, description: 卸柜完成时间加72小时}
deadlineAt: {type: string, format: date-time, nullable: true, description: 原海外仓卸柜时间加72小时;直送按最晚真实卸货Instant加72小时后保留该目的地当地时间}
overdueHours: {type: integer, format: int64, minimum: 0}
overdueDays: {type: integer, format: int64, minimum: 0}
TerminalDrayageView:
@@ -189,7 +189,7 @@ components:
fleetSupplierName: {type: string, nullable: true}
pickupAppointmentAt: {type: string, format: date-time, nullable: true}
actualPickupAt: {type: string, format: date-time, nullable: true, description: 优先使用实际船节点提柜时间;缺失时在海外PDA确认整柜拆柜完成后使用卸柜完成时间}
estimatedEmptyReturnAt: {type: string, format: date-time, nullable: true, description: 服务端权威值,卸柜完成时间加72小时}
estimatedEmptyReturnAt: {type: string, format: date-time, nullable: true, description: 服务端权威值;直送72小时按绝对时长计算,时区不明的原海外仓事实保留原规则}
actualEmptyReturnAt: {type: string, format: date-time, nullable: true}
unloadingCompletedAt: {type: string, format: date-time, nullable: true}
emptyReturnWarning: {$ref: '#/components/schemas/Warning'}
@@ -128,7 +128,7 @@ class ContainerControllerContractTest {
.contains("required: [oceanBookingId, requirementId, expectedVersion]")
.contains("containerNo: {type: string, nullable: true")
.doesNotContain("required: [oceanBookingId, requirementId, containerNo, expectedVersion]")
.contains("required: [id, bookingNo, cabinetNo, sourceType, recordStatus")
.contains("required: [id, cabinetNo, sourceType, recordStatus")
.contains("sourceType: {type: string, enum: [SIMULATION, OCEAN_BOOKING]")
.contains("recordStatus: {type: string, enum: [ACTIVE, MERGED]")
.contains("mergedIntoContainerBookingId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true");
@@ -45,7 +45,7 @@ class InternalInboundOrderReferenceControllerTest {
private InternalInboundOrderController controller;
@Test void workbenchFiltersRejectMissingAndWrongInternalIdentity() {
var request = new com.baiyue.firstleg.dto.MerchantWorkbenchFilters.Request(11L, "LOADED");
var request = new com.baiyue.firstleg.dto.MerchantWorkbenchFilters.Request(11L, "LOADED", null);
assertThatThrownBy(() -> controller.workbenchFilters(null, request)).isInstanceOf(BizException.class);
assertThatThrownBy(() -> controller.workbenchFilters("wrong", request)).isInstanceOf(BizException.class);
org.mockito.Mockito.verifyNoInteractions(workbenchFilters);
@@ -143,4 +143,18 @@ class InternalInboundOrderReferenceControllerTest {
carrierId == null ? null : "承运商", null, null, null, null,
null, null, null, null, null, null);
}
@Test void optionalExactScopeRejectsEmptyOversizeNullOrNonPositiveItems() {
try(var factory=jakarta.validation.Validation.buildDefaultValidatorFactory()) {
var validator=factory.getValidator();
for(java.util.List<Long> ids: java.util.List.of(java.util.List.<Long>of(),java.util.List.of(0L),
java.util.Arrays.asList(1L,null),java.util.stream.LongStream.rangeClosed(1,201).boxed().toList())) {
assertThat(validator.validate(new com.baiyue.firstleg.dto.MerchantWorkbenchFilters.Request(11L,null,ids))).isNotEmpty();
}
for(java.util.List<Long> ids: java.util.Arrays.asList(null,java.util.List.of(1L),java.util.stream.LongStream.rangeClosed(1,200).boxed().toList())) {
assertThat(validator.validate(new com.baiyue.firstleg.dto.MerchantWorkbenchFilters.Request(11L,null,ids))).isEmpty();
}
}
}
}
@@ -74,4 +74,16 @@ class UnifiedInboundOpenApiContractTest {
.containsOnlyKeys("standardNode","nodeSequence","providerCode","estimatedAt","actualAt","locationCode","locationName");
}
@Test @SuppressWarnings("unchecked") void documentsBoundedOptionalExactOrderScope() throws Exception {
var document=(java.util.Map<String,Object>)new org.yaml.snakeyaml.Yaml().load(
new org.springframework.core.io.ClassPathResource("openapi/firstleg-v1.yaml").getContentAsString(java.nio.charset.StandardCharsets.UTF_8));
var paths=(java.util.Map<String,Object>)document.get("paths");
var operation=(java.util.Map<String,Object>)((java.util.Map<String,Object>)paths.get("/internal/firstleg/v1/inbounds/unified-orders/workbench-filters")).get("post");
var content=(java.util.Map<String,Object>)((java.util.Map<String,Object>)operation.get("requestBody")).get("content");
var schema=(java.util.Map<String,Object>)((java.util.Map<String,Object>)content.get("application/json")).get("schema");
var ids=(java.util.Map<String,Object>)((java.util.Map<String,Object>)schema.get("properties")).get("orderIds");
assertThat(ids).containsEntry("minItems",1).containsEntry("maxItems",200).containsEntry("nullable",true);
assertThat((java.util.List<String>)schema.get("required")).doesNotContain("orderIds");
}
}
@@ -0,0 +1,42 @@
package com.baiyue.firstleg.portschedule.mapper;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.MybatisSqlSessionFactoryBuilder;
import org.apache.ibatis.mapping.Environment;
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.api.Test;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
class AppointmentWorkSourceMapperTest {
@Test void actualSailedScopeHasNoWarehouseDestinationOrOpenQueryFilter() throws Exception {
var ds=new JdbcDataSource();ds.setURL("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1");
try(var c=ds.getConnection();var s=c.createStatement()) {
s.execute("CREATE TABLE fl_container_booking(id BIGINT,cabinet_no VARCHAR,container_no VARCHAR,departure_port_name VARCHAR,destination_warehouse_name VARCHAR,version INT,record_status VARCHAR)");
s.execute("CREATE TABLE fl_carrier_schedule_node_fact(id BIGINT,container_booking_id BIGINT,standard_node VARCHAR,actual_at TIMESTAMP)");
s.execute("CREATE TABLE fl_direct_delivery_unloading(container_booking_id BIGINT)");
s.execute("INSERT INTO fl_container_booking VALUES(1,'D1',NULL,'上海',NULL,1,'ACTIVE'),(2,'D2','C2','上海','私人仓',1,'ACTIVE'),(3,'D3','C3','上海',NULL,1,'ACTIVE'),(4,'D4','C4','上海',NULL,1,'MERGED')");
s.execute("INSERT INTO fl_carrier_schedule_node_fact VALUES(10,1,'DEPARTED_ORIGIN_PORT','2026-10-01'),(20,2,'EMPTY_RETURNED','2026-10-03'),(30,3,'LOADED_ON_VESSEL','2026-10-04'),(40,4,'DEPARTED_ORIGIN_PORT','2026-10-01'),(50,3,'DEPARTED_ORIGIN_PORT',NULL)");
s.execute("INSERT INTO fl_direct_delivery_unloading VALUES(2),(2)");
s.execute("INSERT INTO fl_carrier_schedule_node_fact VALUES(60,1,'IN_TRANSIT','2026-10-02'),(70,1,'ARRIVED_DESTINATION_PORT','2026-10-03'),(80,2,'EMPTY_RETURNED',NULL)");
}
var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);
config.setEnvironment(new Environment("test",new JdbcTransactionFactory(),ds));config.addMapper(AppointmentWorkSourceMapper.class);
try(var session=new MybatisSqlSessionFactoryBuilder().build(config).openSession()) {
var mapper=session.getMapper(AppointmentWorkSourceMapper.class);
var rows=mapper.sources();
assertEquals(2,rows.size());assertEquals(1,rows.get(0).id());assertEquals(2,rows.get(1).id());
assertNull(rows.get(0).destinationWarehouseName());assertEquals(2,rows.get(1).deliveryVersion());
assertNull(rows.get(1).departedLocalAt());assertNotNull(rows.get(1).sailedEvidenceLocalAt());
assertEquals(70,rows.get(0).factVersion());assertEquals(20,rows.get(1).factVersion());
// The optimized path must equal the former full-manifest + Java filter, including
// dates, versions, null names, unloading counts, duplicate IDs and ineligible sources.
for(var ids:java.util.List.of(java.util.List.of(2L,1L),java.util.List.of(1L,1L),
java.util.List.of(2L,3L,4L,999L),java.util.List.of(3L,4L),java.util.List.<Long>of())) {
assertEquals(rows.stream().filter(row->ids.contains(row.id())).toList(),mapper.sourcesByIds(ids));
}
assertTrue(mapper.sourcesByIds(null).isEmpty());
}
}
}
@@ -0,0 +1,37 @@
package com.baiyue.firstleg.portschedule.mapper;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.MybatisSqlSessionFactoryBuilder;
import org.apache.ibatis.mapping.Environment;
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import static org.junit.jupiter.api.Assertions.assertEquals;
class ArrivalCalendarMapperTest {
@Test
void readsTheExistingCabinetNumberWithoutASecondContainerNumberColumn() throws Exception {
var ds = new JdbcDataSource();
ds.setURL("jdbc:h2:mem:arrival-cabinet-number;MODE=MySQL;DB_CLOSE_DELAY=-1");
try (var connection = ds.getConnection(); var sql = connection.createStatement()) {
sql.execute("CREATE TABLE fl_container_booking(id BIGINT,cabinet_no VARCHAR,container_no VARCHAR," +
"vessel_name VARCHAR,voyage_no VARCHAR,destination_port_name VARCHAR,destination_warehouse_code VARCHAR," +
"actual_port_arrival_at TIMESTAMP,estimated_port_arrival_at TIMESTAMP,record_status VARCHAR)");
sql.execute("CREATE TABLE fl_carrier_schedule_node_fact(id BIGINT,container_booking_id BIGINT," +
"standard_node VARCHAR,actual_at TIMESTAMP,observed_at TIMESTAMP)");
sql.execute("INSERT INTO fl_container_booking VALUES(1,'D0007','TEST-CONT','Vessel','Voyage','Port'," +
"'US-W',NULL,'2026-11-25 08:00:00','ACTIVE')");
}
var config = new MybatisConfiguration();
config.setEnvironment(new Environment("test", new JdbcTransactionFactory(), ds));
config.addMapper(PortScheduleMapper.class);
try (var session = new MybatisSqlSessionFactoryBuilder().build(config).openSession()) {
var rows = session.getMapper(PortScheduleMapper.class).arrivalCalendar(
LocalDateTime.parse("2026-11-01T00:00:00"), LocalDateTime.parse("2026-12-01T00:00:00"));
assertEquals(1, rows.size());
assertEquals("D0007", rows.get(0).cabinetNo());
assertEquals(1L, rows.get(0).containerBookingId());
}
}
}
@@ -0,0 +1,34 @@
package com.baiyue.firstleg.portschedule.mapper;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.MybatisSqlSessionFactoryBuilder;
import org.apache.ibatis.mapping.Environment;
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
class PickupActualCalendarMapperTest {
@Test void selectsCanonicalActualPickupOnceInAHalfOpenRangeAndDetectsTruncation() throws Exception {
var ds=new JdbcDataSource();ds.setURL("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1");
try(var connection=ds.getConnection();var sql=connection.createStatement()) {
sql.execute("CREATE TABLE fl_carrier_schedule_node_fact(container_booking_id BIGINT,standard_node VARCHAR,actual_at TIMESTAMP)");
sql.execute("INSERT INTO fl_carrier_schedule_node_fact VALUES(1,'PICKED_UP','2026-09-30 23:59:59'),(2,'PICKED_UP','2026-10-01 00:00:00'),(3,'PICKED_UP','2026-10-31 23:59:59'),(4,'PICKED_UP','2026-11-01 00:00:00'),(5,'ARRIVED_DESTINATION_PORT','2026-10-10'),(6,'PICKED_UP',NULL)");
sql.execute("INSERT INTO fl_carrier_schedule_node_fact VALUES(2,'PICKED_UP','2026-10-01 00:00:00'),(4,'PICKED_UP','2026-10-10')");
}
var config=new MybatisConfiguration();config.setEnvironment(new Environment("test",new JdbcTransactionFactory(),ds));config.addMapper(CarrierScheduleQueryMapper.class);
try(var session=new MybatisSqlSessionFactoryBuilder().build(config).openSession()) {
var mapper=session.getMapper(CarrierScheduleQueryMapper.class);
var from=LocalDateTime.parse("2026-10-01T00:00:00");var to=LocalDateTime.parse("2026-11-01T00:00:00");
assertEquals(List.of(2L,3L),mapper.selectPickupCalendarContainers(from,to));
try(var connection=ds.getConnection();var sql=connection.prepareStatement("INSERT INTO fl_carrier_schedule_node_fact VALUES(?,'PICKED_UP','2026-10-15')")) {
for(long id=10;id<1012;id++){sql.setLong(1,id);sql.addBatch();}sql.executeBatch();
}
session.clearCache();
assertEquals(1001,mapper.selectPickupCalendarContainers(from,to).size());
}
}
}

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