Compare commits

..
86 changed files with 358 additions and 25095 deletions
@@ -1,23 +0,0 @@
{
"deploy/sql/README.md": "4c7475566d5b3ab7ebaeb26a9369c3b2daa2911adf648bd9df1cae348de9f5c1",
"deploy/sql/头程数据库统一增量-MySQL8.sql": "55c9736c6998f59d48a76c5ba60e2a801dd0b8acbf209fcf0dae4830fb3fb485",
"deploy/sql/海外区域统一增量-MySQL8.sql": "3fda99ff623735dc42844a50d9a80090ab478aaf916ebdf5fa21e5a6d054117a",
"firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/controller/ContainerCalendarSourceController.java": "74f1f56fb224798004382e0f4b36140cf81a073f50a1ac212bc393e0d2dde159",
"firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/dto/ContainerCalendarSource.java": "58edea28fff5a8357aa662b794d23983d7da987f7f11f9b4bc64a8b406e466c5",
"firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/mapper/ContainerCalendarSourceMapper.java": "76556dc0b615c19d8b3aa8c4453af56dce72bb8782343fce6a09b8a36163a467",
"firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/repository/ContainerCalendarSourceRepository.java": "881f000a5f9a2ebe10858ee4ab97fb2595189aef641bb90dc73b9c37bc6d1d7b",
"firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/ContainerCalendarSourceService.java": "44c3927abb813c5d6f5c8d553795c8bf50fb945e9862984f1fe011da20d3695d",
"firstleg/src/main/resources/openapi/appointment-calendar-sources-v1.yaml": "8fffd13df663c6eaaf7eaffaa063b24d531f33241d7adb94850ee146af6d22dd",
"firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/mapper/ContainerCalendarSourceMapperTest.java": "f38e11e2509a030854c0b6f40f8f9577ed729bcbbff0666b180504f38aa93c0b",
"firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/ContainerCalendarSourceServiceTest.java": "87a0fbe6f7b9a271e331641c7c8da27282a6f1df2ae9b3167d543bf78119f7e6",
"lastleg-service/src/main/java/com/baiyue/lastleg/calendar/WarehouseCalendarController.java": "17102d4a32640ccfd92a7e7e738a248d4c1f5ae8906b8b965f455eb9be572603",
"lastleg-service/src/main/java/com/baiyue/lastleg/calendar/WarehouseCalendarMapper.java": "80f3a96bee0fa7630afff100ddb051977e283902c4e7ef247b8152fb37c2400f",
"lastleg-service/src/main/java/com/baiyue/lastleg/calendar/WarehouseCalendarModels.java": "88824f1a88d24b9bd34c9e7023aaa00d2653849a9131bf619d7df0f5fd3a7a0f",
"lastleg-service/src/main/java/com/baiyue/lastleg/calendar/WarehouseCalendarRepository.java": "4395cce32c574ebee0b4437f6ec8bbe0a5aee62c19a6a451ee0f243eab7be558",
"lastleg-service/src/main/java/com/baiyue/lastleg/calendar/WarehouseCalendarService.java": "886e9e5460e47962f5701ceeb5e94ba9d2a123c4c83f2791b739178c8bfe874a",
"lastleg-service/src/main/resources/openapi/appointment-calendar-warehouse-v1.yaml": "06965771d34d761b869f90ee6ff3f2dca7589121c67a5adbe2081677ce5a563c",
"lastleg-service/src/test/java/com/baiyue/lastleg/calendar/WarehouseCalendarMapperTest.java": "a17e359b39000036fd9bd80d6b0744018541fc7bbe846a0585ece629ff09dc6c",
"lastleg-service/src/test/java/com/baiyue/lastleg/calendar/WarehouseCalendarServiceTest.java": "9ed34bea7163166d0f79f882f12b23033dae13ad13bd4eedc7e202aed5defb8f",
"lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripApiContractTest.java": "c3a4cedac09f16f37442a6709b9d704a607f2e0238e9997687e44153c378f27a",
"lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripAuthorizationTest.java": "5d9e8aded16aa4de3338d65edd1a2e6b4e42961498f22d99c702e0f8d491bcd6"
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-256
View File
@@ -1,256 +0,0 @@
{
"capturedAt": "2026-10-19T23:34:24.6121293+08:00",
"frozenManifest": ".logs/dispatch-merged-final-inputs.json",
"frozenManifestSha256": "55373c9dd7d5a2cc9ab0756893650ab27d5691c6b7cc4086c03140b76b86c136",
"firstPostRunAudit": {
"files": 65,
"unchanged": 65,
"drift": 0
},
"currentAudit": {
"files": 65,
"unchanged": 64,
"drift": [
{
"path": "lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/FbaAppointmentRepartitionPersistenceTest.java",
"frozen": "66071beb3fe6787894055c74f236fb4c72fffdb75565ed0ccb2932f546903cda",
"actual": "f2f7174dd6e52f5d9caae36f8fc24aa2dc78145c1c390e8e16857bb066948a86"
}
]
},
"affectedRun": {
"finishedAt": "2026-10-19T23:01:03+08:00",
"log": ".logs/dispatch-merged-final-affected-consumers.maven.log",
"total": 111,
"passed": 110,
"failures": 1,
"errors": 0,
"skipped": 0,
"result": "FAILURE",
"classes": [
{
"class": "com.baiyue.lastleg.controller.Phase4BrowserApiBoundaryTest",
"total": 7,
"failures": 0,
"errors": 0,
"skipped": 0
},
{
"class": "com.baiyue.lastleg.dispatch.service.DispatchMergedRelabelPersistenceTest",
"total": 11,
"failures": 0,
"errors": 0,
"skipped": 0
},
{
"class": "com.baiyue.lastleg.dispatch.service.DispatchRepairWorkflowTest",
"total": 9,
"failures": 0,
"errors": 0,
"skipped": 0
},
{
"class": "com.baiyue.lastleg.dispatch.service.DispatchServiceTest",
"total": 5,
"failures": 0,
"errors": 0,
"skipped": 0
},
{
"class": "com.baiyue.lastleg.dispatch.service.ReturnDispatchCandidatePersistenceTest",
"total": 1,
"failures": 0,
"errors": 0,
"skipped": 0
},
{
"class": "com.baiyue.lastleg.inboundplan.service.FbaAppointmentRepartitionPersistenceTest",
"total": 8,
"failures": 1,
"errors": 0,
"skipped": 0
},
{
"class": "com.baiyue.lastleg.inboundplan.service.InboundPlanPaginationTest",
"total": 3,
"failures": 0,
"errors": 0,
"skipped": 0
},
{
"class": "com.baiyue.lastleg.inboundplan.work.AppointmentWorkDomainMapperTest",
"total": 1,
"failures": 0,
"errors": 0,
"skipped": 0
},
{
"class": "com.baiyue.lastleg.relabel.service.RelabelDispatchSourcePersistenceTest",
"total": 16,
"failures": 0,
"errors": 0,
"skipped": 0
},
{
"class": "com.baiyue.lastleg.transfer.service.WarehouseTransferServiceTest",
"total": 50,
"failures": 0,
"errors": 0,
"skipped": 0
}
],
"failure": "FbaAppointmentRepartitionPersistenceTest.existingTransportAndConcurrentRepartitionCannotDuplicateWork",
"rootCause": "Fixture allocation dispatch_item_id=11/plan_pallet_id=12 did not match inserted item id=1/plan_pallet_id=NULL; current production join correctly requires matching dispatch, item, customer and plan pallet. Parent owns correction; original assertions retained."
},
"priorFullModule": {
"total": 3075,
"passed": 3056,
"failures": 6,
"errors": 4,
"skipped": 9,
"log": ".logs/dispatch-merged-final-full-module.maven.log"
},
"pending": {
"nativeCancellation": "Local backend implementation and directly affected regressions complete: 94/94 directed; Lastleg full 3072 pass, 9 conditional skips, 0 failure/error; File full 242/242",
"environment": "Deployment, test database, real HTTP and four UI browser acceptance newly authorized; parent coordinates execution, no environment verification performed by this chat"
},
"cancellationAffectedRun": {
"finishedAt": "2026-10-19T23:18:32+08:00",
"log": ".logs/dispatch-native-cancellation-affected.maven.log",
"result": "SUCCESS",
"total": 94,
"passed": 94,
"failures": 0,
"errors": 0,
"skipped": 0,
"inputManifest": ".logs/dispatch-native-cancellation-inputs.json",
"frozenFiles": 74,
"postRunDrift": 0,
"classes": [
{
"class": "DispatchDeliveryArrangementPersistenceTest",
"total": 3
},
{
"class": "DispatchDeliveryFeesTest",
"total": 6
},
{
"class": "DispatchDeliveryServiceTest",
"total": 6
},
{
"class": "DeliveryTransferServiceTest",
"total": 59
},
{
"class": "ReturnOutboundClosureTest",
"total": 11
},
{
"class": "DispatchDeliveryCompletionWorkerTest",
"total": 1
},
{
"class": "FbaAppointmentRepartitionPersistenceTest",
"total": 8
}
],
"inputManifestSha256": "187ef121ef3c75686717c2dc009604943462889dff63b9b2ac4441e7e765591b"
},
"finalFullModule": {
"result": "SUCCESS",
"finishedAt": "2026-10-19T23:25:33+08:00",
"log": ".logs/dispatch-final-full-module-closed.maven.log",
"inputManifest": ".logs/dispatch-final-full-reactor-inputs.json",
"frozenFiles": 1706,
"preRunDrift": 0,
"postRunDrift": 0,
"total": 3081,
"passed": 3072,
"failures": 0,
"errors": 0,
"skipped": 9,
"skippedCases": [
{
"class": "com.baiyue.lastleg.discard.CargoDiscardMysqlAcceptanceTest",
"name": "createApprovalRejectionPendingAndInsufficientStockScenarios",
"reason": "System property [cargo.discard.acceptance] does not exist"
},
{
"class": "com.baiyue.lastleg.service.TaillegRealConsumerAcceptanceTest",
"name": "consumesRealOutboxAndCreatesEveryTaillegOrderType",
"reason": "System property [tailleg.real.consumer] does not exist"
},
{
"class": "com.baiyue.lastleg.selfoperated.analytics.outbound.OutboundCandidateBuildMySqlAcceptanceTest",
"name": "realCandidateBuildOnIndependentSyntheticFacts",
"reason": "Environment variable [SELFOP_TEST_MYSQL_CANDIDATE_BUILD] does not exist"
},
{
"class": "com.baiyue.lastleg.selfoperated.analytics.inventory.InventoryPublicationMySqlAcceptanceTest",
"name": "crossMidnightPublicationHasNoDailyMarkerAndCannotBackfill",
"reason": "Environment variable [SELFOP_TEST_MYSQL_PUBLICATION] does not exist"
},
{
"class": "com.baiyue.lastleg.selfoperated.analytics.inventory.InventoryPublicationMySqlAcceptanceTest",
"name": "successfulPublicationAndPublishedRecoveryKeepExactCommittedResult",
"reason": "Environment variable [SELFOP_TEST_MYSQL_PUBLICATION] does not exist"
},
{
"class": "com.baiyue.lastleg.selfoperated.analytics.inventory.InventoryPublicationMySqlAcceptanceTest",
"name": "laterSameDayPublicationPreservesFirstRepresentativeAndItsRows",
"reason": "Environment variable [SELFOP_TEST_MYSQL_PUBLICATION] does not exist"
},
{
"class": "com.baiyue.lastleg.selfoperated.analytics.inventory.InventoryPublicationMySqlAcceptanceTest",
"name": "pointerRunTaskSupersededAndDailyElectionRollBackAfterRealSqlFault",
"reason": "Environment variable [SELFOP_TEST_MYSQL_PUBLICATION] does not exist"
},
{
"class": "com.baiyue.lastleg.returnreceipt.service.ReturnSkuConversionPersistenceTest",
"name": "mysqlPageLocksConvertedSkuUntilConfirmationWithoutChangingPhysicalUnit(String)",
"reason": "System property [returnSku.test.mysql] does not exist"
},
{
"class": "com.baiyue.lastleg.returnreceipt.service.ReturnSkuConversionPersistenceTest",
"name": "mysqlPageCollatesCaseAndAccentsWithoutJoiningOtherCustomerWarehouseOrSku(String)",
"reason": "System property [returnSku.test.mysql] does not exist"
}
],
"inputManifestSha256": "4ac18b73c5b9e6599a8c7f9729ab4a2a34f8145aeddb6d72c8935efaf7287cc2"
},
"fileProviderRun": {
"result": "SUCCESS",
"finishedAt": "2026-10-19T23:33:52+08:00",
"log": ".logs/dispatch-evidence-file-final-full.maven.log",
"inputManifest": ".logs/dispatch-evidence-file-final-inputs.json",
"inputManifestSha256": "f49a0707b7ea2241d7c11c90311d25b9a50eec0310fc192d445bc351c9ba78ae",
"frozenFiles": 289,
"preRunDrift": 0,
"postRunDrift": 0,
"total": 242,
"passed": 242,
"failures": 0,
"errors": 0,
"skipped": 0,
"scope": [
{
"path": "file/AGENTS.md",
"sha256": "c3236c7e89151f8f7b1da540ed8b591982e1200aafb89f45cb49230815d320c7"
},
{
"path": "file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java",
"sha256": "b274532a0e4a263c56558eae68eb5717182791dec8e399e21bd459f6dffd32ff"
},
{
"path": "file/src/main/resources/openapi/dispatch-evidence-v1.yaml",
"sha256": "b3bc23d9f25513b258f0282886743e4323cb65b89c0203d4d2921ac89da08d14"
},
{
"path": "file/src/test/java/com/baiyue/file/service/DispatchEvidenceFileTest.java",
"sha256": "bba87946457fb1290960218127087552de95b59f443eb6d5dacf354084d04a19"
}
]
}
}
-339
View File
@@ -1,339 +0,0 @@
{
"capturedAt": "2026-10-19T22:57:12.4378746+08:00",
"log": ".logs/dispatch-merged-final-affected-consumers.maven.log",
"fullModulePrior": {
"total": 3075,
"passed": 3056,
"failures": 6,
"errors": 4,
"skipped": 9,
"log": ".logs/dispatch-merged-final-full-module.maven.log"
},
"files": [
{
"group": "dispatch",
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchPlanningModels.java",
"sha256": "47489d22ab81913ab23c319c04561371cbdf1e1c43034fe5b1d24fe94038137e"
},
{
"group": "dispatch",
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchViews.java",
"sha256": "d34d5a810ea7658ffd2623f53970435ecee116663bd969339de84432e38cd573"
},
{
"group": "dispatch",
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchContracts.java",
"sha256": "5086523d9df7c7d00840d391e9608b1685e4edb71d19154b1608339afe7421e2"
},
{
"group": "dispatch",
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchPalletLock.java",
"sha256": "2988eee05074b78728878fef6dd45d0df3dba8cc7c219ebfd583bf7650a379b3"
},
{
"group": "dispatch",
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchPhysicalPalletLock.java",
"sha256": "3044dc6d8780f96b4ff7d6303a2b15ed458c201496788a98c3b28cad7449e0f8"
},
{
"group": "dispatch",
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchItemSource.java",
"sha256": "02dc868bef1646a02143969f7aad13b4e92cd04d4a8d69c8c71ffc07f7a50360"
},
{
"group": "dispatch",
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchSourceMapper.java",
"sha256": "1bec0687e9d1d3a2404d25a37aad092d18b7134eab605594f352a5432360ac76"
},
{
"group": "dispatch",
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchPhysicalClaimMapper.java",
"sha256": "9ae01c8669beb2fcf5e359d2e26e13e07c2a496bbe22036a408f3dd49d2ec2fa"
},
{
"group": "dispatch",
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchItemSourceMapper.java",
"sha256": "8d6b4bf84e27c18b3c8faad15dbf73018485996ca5d4dbb00bdf9ba2ab158d2d"
},
{
"group": "dispatch",
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchExecutionReadMapper.java",
"sha256": "54666dbd5968ebd9d2cb8fc9678a8a385e60591f62c90a850be513f93546906d"
},
{
"group": "dispatch",
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java",
"sha256": "7c0269c3e5179add0ebb772cd7674ddf899039afad179bff06f7524017934573"
},
{
"group": "dispatch",
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/ReturnDispatchCandidateMapper.java",
"sha256": "a99080e851a6d22c5ecb1229e761382d846d1f5e4b92a3c69d30716af125746d"
},
{
"group": "dispatch",
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchSourceRepository.java",
"sha256": "04b5d5160614b17a31030f23167d9e32aa89e13be7ef2e33c9d91359ef684348"
},
{
"group": "dispatch",
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchPhysicalClaimRepository.java",
"sha256": "27f37b5e683b92d82d9cf63a88b2135ada5f597e652474eef52d7c985b870b31"
},
{
"group": "dispatch",
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchItemSourceRepository.java",
"sha256": "2bcef5d9952750235ecabcf429e4eab9b9a9c0a3cac0b3e02b083120f85df28e"
},
{
"group": "dispatch",
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java",
"sha256": "c95a0d4075df7628e4443f87be4d2d906aee5cbb95148d5fd3869d6ec7932465"
},
{
"group": "dispatch",
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java",
"sha256": "ec0ce406d4244a0abf34ee9ede97b788675b39234fbb8e6d0ff9579dde360145"
},
{
"group": "dispatch",
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPickingService.java",
"sha256": "17a461a599cc0702b519d1333c5e136860eff7b638d739dde7e0bd203bcfc4ec"
},
{
"group": "dispatch",
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPdaQueryService.java",
"sha256": "0f59acc9c3ed6fd2995c1ed59810146c0056d1462e4e23d85f52198a1ffe0296"
},
{
"group": "dispatch",
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchDepartureService.java",
"sha256": "fc9440c6755316c58572f3fed5a576c5721098a3d5d7a036eecd3d6d752f1ea0"
},
{
"group": "dispatch",
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPhysicalClaimService.java",
"sha256": "063b557cd488bf2281516df744f12123473269be12f56a5155facad018e6408e"
},
{
"group": "dispatch",
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchItemSourceService.java",
"sha256": "ee42766e7f86f896df1839e15987382c8aebc3484449b2104312378eaababbba"
},
{
"group": "dispatch",
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaPickOrder.java",
"sha256": "3d66df6566b467580c89aa5f9f585aba610b05ca2de07ad28a3d6a6f30ab10e3"
},
{
"group": "dispatch",
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionService.java",
"sha256": "83947030468892ee1d4a02b354e855d7977f55fcc17de440f324cf24940a10d8"
},
{
"group": "dispatch",
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchWholePdaReadMapper.java",
"sha256": "4df97c47b2ca481eb5f8f32e7735bb15a80aa4e561bea7ab349f09352ca9db1e"
},
{
"group": "sharedDraft",
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchDraftService.java",
"sha256": "a2682e87f8bbf47a799d8d54ae05b8acdef52414016cab116eb9a40fe1364e04"
},
{
"group": "sharedDraft",
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchDraftRepository.java",
"sha256": "f28b7aff08afdf634387e6abb7919a807fd6a2ff54ff7869b3e1e4d453f2c92b"
},
{
"group": "sharedDraft",
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchDraftClaimMapper.java",
"sha256": "32dd25dc737d7c36f3a35f1a97bd2865cf433368aaccce2162585a30d6b07641"
},
{
"group": "tests",
"path": "lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchMergedRelabelPersistenceTest.java",
"sha256": "1c36d64a389bf12e55cfb8cbb29b7dd333bc952f66e8bb88d441ddf42f83c653"
},
{
"group": "tests",
"path": "lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java",
"sha256": "8f5907aa180db30a2b56fb6b251f9ae7a94fa550666239947b660cb5431fe978"
},
{
"group": "tests",
"path": "lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPickingPersistenceTest.java",
"sha256": "0971e32ae147a03a730c16f1a75f0576e3a9122f92c5c65608db0cb9a76a3510"
},
{
"group": "tests",
"path": "lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPhysicalClaimPersistenceTest.java",
"sha256": "02bd34b71fdb237566e99fab0b748c3ed333ace4ae65042aaf315b06695299f6"
},
{
"group": "tests",
"path": "lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchWholeSourcePersistenceTest.java",
"sha256": "0143045a52df440dc8be72706d69cbc45a9d210ddfb4ce77762423533dcc1a55"
},
{
"group": "tests",
"path": "lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPdaReadPersistenceTest.java",
"sha256": "9b4402d0dd3f0eefa8b6b9bfe65e20f42f3e60619ac6989b18c9a74cbebad0ad"
},
{
"group": "tests",
"path": "lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAppointmentCancellationPersistenceTest.java",
"sha256": "0be4d1f344374147149f248bbc2aaac486484ad4a0e9ba1681295aa6fd5ae21d"
},
{
"group": "tests",
"path": "lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionServiceTest.java",
"sha256": "ec3ab8341cec7ae692501ba264178d5d520923b7c2dcf20dfaa620313c9d03d4"
},
{
"group": "tests",
"path": "lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchDraftPersistenceTest.java",
"sha256": "35b7c411e452dce2189c22f8cf31d77083f8854062435a9223e306a1a9d44a6a"
},
{
"group": "tests",
"path": "lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchItemSourcePersistenceTest.java",
"sha256": "24e1b3f0dba4d4f9c1f724840c7a263ff0d1561cb5cb509fb0c410be8187afb0"
},
{
"group": "tests",
"path": "lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchRepairWorkflowTest.java",
"sha256": "4a4d01424e91fd1b55b0af4721e02c4acad065e958ff4a0b9726cf726691fbdf"
},
{
"group": "related",
"path": "lastleg-service/src/main/resources/db/lastleg.sql",
"sha256": "61946a41d773e856e3d3f9463f7ff5bfe0e904856598e4a64c8cbd0936ef55b5"
},
{
"group": "related",
"path": "deploy/sql/海外区域统一增量-MySQL8.sql",
"sha256": "4f31c8731dbb54955555d13f027c24a537aee8a317f744f7bf50b59cb22694b1"
},
{
"group": "related",
"path": "lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryHoldConcurrencyPersistenceTest.java",
"sha256": "e146136299db24b1cf618faf2161fa284d9eec15a26a75e8352548cd7dbe7e94"
},
{
"group": "related",
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationService.java",
"sha256": "c3a4ceba06b722d5a0bba810eaa948bd364a09ff5599c3176207d5c28b39b7fc"
},
{
"group": "related",
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/palletops/mapper/PdaPalletOperationMapper.java",
"sha256": "960bb99f8432524e5a4bc76a61ccde8273929ad9de5e21ae0b44d941edeb6c9d"
},
{
"group": "related",
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/palletops/repository/PdaPalletOperationRepository.java",
"sha256": "55d243f6f419f112d3f89005ce37af0d6446e7b21d3b00f5931b56f3ea556302"
},
{
"group": "related",
"path": "lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationServiceTest.java",
"sha256": "eae67e13f9d1d3e8ff8a76b30a8a72d445e54b3cd6a30394c5ea41f763f48a7c"
},
{
"group": "related",
"path": "lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletDispatchFreezePersistenceTest.java",
"sha256": "a8ef7e364e4d0fb97f83515dd1d3b983cdac4f367a9335fe181c39a47719720f"
},
{
"group": "related",
"path": "../docs/海外仓业务/排车列表接口与数据设计.md",
"sha256": "521169378de4ee51c9af4f8cbe331284f35bf60f137253d9751f2ba3480e95ea"
},
{
"group": "final-related",
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/relabel/port/RelabelDispatchSourcePort.java",
"sha256": "af771fd474faf6c411543b077c646479e6368b98ab3cab875c4cd6c0b3b24978"
},
{
"group": "final-related",
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelDispatchSourceService.java",
"sha256": "949ea59036d710af0cc0b81848a77e17da0da3de049e44124a36be141ee81fe0"
},
{
"group": "final-related",
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/relabel/repository/RelabelDispatchSourceRepository.java",
"sha256": "12b9577391f0908d94e4e2b307f7572e4825f556307145f8a83d0294516a2c46"
},
{
"group": "final-related",
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelDispatchSourceMapper.java",
"sha256": "6dd3902b7f64d40f8db5ae1f3dd3640e91a69cadd6c895b1f3a81ac48b93ebf0"
},
{
"group": "final-related",
"path": "lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelDispatchSourcePersistenceTest.java",
"sha256": "6eb43b35de92364884e9ffbd36c18c3921ac8c21c959602383f9ca9cc8c4ece5"
},
{
"group": "final-related",
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/inventory/port/PhysicalPalletSerialPort.java",
"sha256": "4aa97a0c9bf0f4a9138ec15ac32e512ff13224e9a6f0e0817c5b0852f9635746"
},
{
"group": "final-related",
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/inventory/service/PhysicalPalletSerialService.java",
"sha256": "4876ed0fc6267d9b5986fde0f2e8096ec01db35cd2f0cadbaeb3ebad2e03b76b"
},
{
"group": "final-related",
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/inventory/repository/PhysicalPalletSerialRepository.java",
"sha256": "0489e8fa5806fd0faa4a61c4a0c7a9bf89d086537752f218dcbcaed08475487e"
},
{
"group": "final-related",
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/PhysicalPalletSerialMapper.java",
"sha256": "237fe76c88d572b0d70eb25d84d191dcc0d2fdbc3a031e728e6bbbf88a8aa25f"
},
{
"group": "final-related",
"path": "lastleg-service/src/test/java/com/baiyue/lastleg/inventory/service/PhysicalPalletSerialPersistenceTest.java",
"sha256": "444bd8d361f4d90fbdaf1e4d1d205906b5452096670f57bd3459906716e36c7e"
},
{
"group": "final-related",
"path": "lastleg-service/src/test/java/com/baiyue/lastleg/controller/Phase4BrowserApiBoundaryTest.java",
"sha256": "4fbf6bab69cfaff409d0d228a1169c63db0f092e9c1c6e2f70bb956f1d3ed70f"
},
{
"group": "final-related",
"path": "lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java",
"sha256": "a9a2e25ff22e326925104e48ff1cfed34fec3350dfb569f1e7f19a4e186925c7"
},
{
"group": "final-related",
"path": "lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/ReturnDispatchCandidatePersistenceTest.java",
"sha256": "4170a35b6f57d9058577941ef665130e0f3a3faeef37c92c9e93a69dd9f0f60a"
},
{
"group": "final-related",
"path": "lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/FbaAppointmentRepartitionPersistenceTest.java",
"sha256": "66071beb3fe6787894055c74f236fb4c72fffdb75565ed0ccb2932f546903cda"
},
{
"group": "final-related",
"path": "lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanPaginationTest.java",
"sha256": "b9196d489830c2f4bf24ebcaf3e94adf4e0484f414f373c300b0f4a2a538679b"
},
{
"group": "final-related",
"path": "lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDomainMapperTest.java",
"sha256": "5fbe3b3be8be194d1f31006e4e05187cd629236c376e01da2d291a2593e70e0a"
},
{
"group": "final-related",
"path": "lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java",
"sha256": "787e78907d019458e3533325ed2bfe8cc09eb8de7ca1db2c0b98a54239a56fe5"
}
]
}
-247
View File
@@ -1,247 +0,0 @@
{
"capturedAt": "2026-10-19T22:39:26.3372438+08:00",
"command": "mvn -pl lastleg-service -am test",
"log": ".logs/dispatch-merged-final-full-module.maven.log",
"files": [
{
"group": "dispatch",
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchPlanningModels.java",
"sha256": "47489d22ab81913ab23c319c04561371cbdf1e1c43034fe5b1d24fe94038137e"
},
{
"group": "dispatch",
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchViews.java",
"sha256": "d34d5a810ea7658ffd2623f53970435ecee116663bd969339de84432e38cd573"
},
{
"group": "dispatch",
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchContracts.java",
"sha256": "5086523d9df7c7d00840d391e9608b1685e4edb71d19154b1608339afe7421e2"
},
{
"group": "dispatch",
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchPalletLock.java",
"sha256": "2988eee05074b78728878fef6dd45d0df3dba8cc7c219ebfd583bf7650a379b3"
},
{
"group": "dispatch",
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchPhysicalPalletLock.java",
"sha256": "3044dc6d8780f96b4ff7d6303a2b15ed458c201496788a98c3b28cad7449e0f8"
},
{
"group": "dispatch",
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchItemSource.java",
"sha256": "02dc868bef1646a02143969f7aad13b4e92cd04d4a8d69c8c71ffc07f7a50360"
},
{
"group": "dispatch",
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchSourceMapper.java",
"sha256": "1bec0687e9d1d3a2404d25a37aad092d18b7134eab605594f352a5432360ac76"
},
{
"group": "dispatch",
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchPhysicalClaimMapper.java",
"sha256": "9ae01c8669beb2fcf5e359d2e26e13e07c2a496bbe22036a408f3dd49d2ec2fa"
},
{
"group": "dispatch",
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchItemSourceMapper.java",
"sha256": "8d6b4bf84e27c18b3c8faad15dbf73018485996ca5d4dbb00bdf9ba2ab158d2d"
},
{
"group": "dispatch",
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchExecutionReadMapper.java",
"sha256": "54666dbd5968ebd9d2cb8fc9678a8a385e60591f62c90a850be513f93546906d"
},
{
"group": "dispatch",
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java",
"sha256": "7c0269c3e5179add0ebb772cd7674ddf899039afad179bff06f7524017934573"
},
{
"group": "dispatch",
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/ReturnDispatchCandidateMapper.java",
"sha256": "a99080e851a6d22c5ecb1229e761382d846d1f5e4b92a3c69d30716af125746d"
},
{
"group": "dispatch",
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchSourceRepository.java",
"sha256": "04b5d5160614b17a31030f23167d9e32aa89e13be7ef2e33c9d91359ef684348"
},
{
"group": "dispatch",
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchPhysicalClaimRepository.java",
"sha256": "27f37b5e683b92d82d9cf63a88b2135ada5f597e652474eef52d7c985b870b31"
},
{
"group": "dispatch",
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchItemSourceRepository.java",
"sha256": "2bcef5d9952750235ecabcf429e4eab9b9a9c0a3cac0b3e02b083120f85df28e"
},
{
"group": "dispatch",
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java",
"sha256": "c95a0d4075df7628e4443f87be4d2d906aee5cbb95148d5fd3869d6ec7932465"
},
{
"group": "dispatch",
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java",
"sha256": "ec0ce406d4244a0abf34ee9ede97b788675b39234fbb8e6d0ff9579dde360145"
},
{
"group": "dispatch",
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPickingService.java",
"sha256": "17a461a599cc0702b519d1333c5e136860eff7b638d739dde7e0bd203bcfc4ec"
},
{
"group": "dispatch",
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPdaQueryService.java",
"sha256": "0f59acc9c3ed6fd2995c1ed59810146c0056d1462e4e23d85f52198a1ffe0296"
},
{
"group": "dispatch",
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchDepartureService.java",
"sha256": "fc9440c6755316c58572f3fed5a576c5721098a3d5d7a036eecd3d6d752f1ea0"
},
{
"group": "dispatch",
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPhysicalClaimService.java",
"sha256": "063b557cd488bf2281516df744f12123473269be12f56a5155facad018e6408e"
},
{
"group": "dispatch",
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchItemSourceService.java",
"sha256": "ee42766e7f86f896df1839e15987382c8aebc3484449b2104312378eaababbba"
},
{
"group": "dispatch",
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaPickOrder.java",
"sha256": "3d66df6566b467580c89aa5f9f585aba610b05ca2de07ad28a3d6a6f30ab10e3"
},
{
"group": "dispatch",
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionService.java",
"sha256": "83947030468892ee1d4a02b354e855d7977f55fcc17de440f324cf24940a10d8"
},
{
"group": "dispatch",
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchWholePdaReadMapper.java",
"sha256": "4df97c47b2ca481eb5f8f32e7735bb15a80aa4e561bea7ab349f09352ca9db1e"
},
{
"group": "sharedDraft",
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchDraftService.java",
"sha256": "a2682e87f8bbf47a799d8d54ae05b8acdef52414016cab116eb9a40fe1364e04"
},
{
"group": "sharedDraft",
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchDraftRepository.java",
"sha256": "f28b7aff08afdf634387e6abb7919a807fd6a2ff54ff7869b3e1e4d453f2c92b"
},
{
"group": "sharedDraft",
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchDraftClaimMapper.java",
"sha256": "32dd25dc737d7c36f3a35f1a97bd2865cf433368aaccce2162585a30d6b07641"
},
{
"group": "tests",
"path": "lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchMergedRelabelPersistenceTest.java",
"sha256": "1c36d64a389bf12e55cfb8cbb29b7dd333bc952f66e8bb88d441ddf42f83c653"
},
{
"group": "tests",
"path": "lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java",
"sha256": "8f5907aa180db30a2b56fb6b251f9ae7a94fa550666239947b660cb5431fe978"
},
{
"group": "tests",
"path": "lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPickingPersistenceTest.java",
"sha256": "0971e32ae147a03a730c16f1a75f0576e3a9122f92c5c65608db0cb9a76a3510"
},
{
"group": "tests",
"path": "lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPhysicalClaimPersistenceTest.java",
"sha256": "02bd34b71fdb237566e99fab0b748c3ed333ace4ae65042aaf315b06695299f6"
},
{
"group": "tests",
"path": "lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchWholeSourcePersistenceTest.java",
"sha256": "0143045a52df440dc8be72706d69cbc45a9d210ddfb4ce77762423533dcc1a55"
},
{
"group": "tests",
"path": "lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPdaReadPersistenceTest.java",
"sha256": "9b4402d0dd3f0eefa8b6b9bfe65e20f42f3e60619ac6989b18c9a74cbebad0ad"
},
{
"group": "tests",
"path": "lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAppointmentCancellationPersistenceTest.java",
"sha256": "0be4d1f344374147149f248bbc2aaac486484ad4a0e9ba1681295aa6fd5ae21d"
},
{
"group": "tests",
"path": "lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionServiceTest.java",
"sha256": "ec3ab8341cec7ae692501ba264178d5d520923b7c2dcf20dfaa620313c9d03d4"
},
{
"group": "tests",
"path": "lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchDraftPersistenceTest.java",
"sha256": "35b7c411e452dce2189c22f8cf31d77083f8854062435a9223e306a1a9d44a6a"
},
{
"group": "tests",
"path": "lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchItemSourcePersistenceTest.java",
"sha256": "24e1b3f0dba4d4f9c1f724840c7a263ff0d1561cb5cb509fb0c410be8187afb0"
},
{
"group": "tests",
"path": "lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchRepairWorkflowTest.java",
"sha256": "4a4d01424e91fd1b55b0af4721e02c4acad065e958ff4a0b9726cf726691fbdf"
},
{
"group": "related",
"path": "lastleg-service/src/main/resources/db/lastleg.sql",
"sha256": "61946a41d773e856e3d3f9463f7ff5bfe0e904856598e4a64c8cbd0936ef55b5"
},
{
"group": "related",
"path": "deploy/sql/海外区域统一增量-MySQL8.sql",
"sha256": "4f31c8731dbb54955555d13f027c24a537aee8a317f744f7bf50b59cb22694b1"
},
{
"group": "related",
"path": "lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryHoldConcurrencyPersistenceTest.java",
"sha256": "e146136299db24b1cf618faf2161fa284d9eec15a26a75e8352548cd7dbe7e94"
},
{
"group": "related",
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationService.java",
"sha256": "c3a4ceba06b722d5a0bba810eaa948bd364a09ff5599c3176207d5c28b39b7fc"
},
{
"group": "related",
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/palletops/mapper/PdaPalletOperationMapper.java",
"sha256": "960bb99f8432524e5a4bc76a61ccde8273929ad9de5e21ae0b44d941edeb6c9d"
},
{
"group": "related",
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/palletops/repository/PdaPalletOperationRepository.java",
"sha256": "55d243f6f419f112d3f89005ce37af0d6446e7b21d3b00f5931b56f3ea556302"
},
{
"group": "related",
"path": "lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationServiceTest.java",
"sha256": "eae67e13f9d1d3e8ff8a76b30a8a72d445e54b3cd6a30394c5ea41f763f48a7c"
},
{
"group": "related",
"path": "lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletDispatchFreezePersistenceTest.java",
"sha256": "a8ef7e364e4d0fb97f83515dd1d3b983cdac4f367a9335fe181c39a47719720f"
},
{
"group": "related",
"path": "../docs/海外仓业务/排车列表接口与数据设计.md",
"sha256": "e3986c47f1b0a093a9dfd53038328211d96194eeb0abf0374df0a45d3090ebe6"
}
]
}
File diff suppressed because one or more lines are too long
@@ -1,302 +0,0 @@
{
"capturedAt": "2026-10-19T23:12:11.1283856+08:00",
"log": ".logs/dispatch-native-cancellation-affected.maven.log",
"files": [
{
"path": "../docs/海外仓业务/排车列表接口与数据设计.md",
"sha256": "521169378de4ee51c9af4f8cbe331284f35bf60f137253d9751f2ba3480e95ea"
},
{
"path": "deploy/sql/海外区域统一增量-MySQL8.sql",
"sha256": "4f31c8731dbb54955555d13f027c24a537aee8a317f744f7bf50b59cb22694b1"
},
{
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/delivery/repository/DeliveryCancellationRepository.java",
"sha256": "52409141461491b48ac5828c3d85a8ee5d578678d2d670c87cc73a707225bcdb"
},
{
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java",
"sha256": "bb036a0cfb01324eba14bb36b623047367bf31c2727be82b6d3728635fb591ce"
},
{
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchItemSource.java",
"sha256": "02dc868bef1646a02143969f7aad13b4e92cd04d4a8d69c8c71ffc07f7a50360"
},
{
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchPalletLock.java",
"sha256": "2988eee05074b78728878fef6dd45d0df3dba8cc7c219ebfd583bf7650a379b3"
},
{
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchPhysicalPalletLock.java",
"sha256": "3044dc6d8780f96b4ff7d6303a2b15ed458c201496788a98c3b28cad7449e0f8"
},
{
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java",
"sha256": "7c0269c3e5179add0ebb772cd7674ddf899039afad179bff06f7524017934573"
},
{
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchDraftClaimMapper.java",
"sha256": "32dd25dc737d7c36f3a35f1a97bd2865cf433368aaccce2162585a30d6b07641"
},
{
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchExecutionReadMapper.java",
"sha256": "54666dbd5968ebd9d2cb8fc9678a8a385e60591f62c90a850be513f93546906d"
},
{
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchItemSourceMapper.java",
"sha256": "8d6b4bf84e27c18b3c8faad15dbf73018485996ca5d4dbb00bdf9ba2ab158d2d"
},
{
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchPhysicalClaimMapper.java",
"sha256": "9ae01c8669beb2fcf5e359d2e26e13e07c2a496bbe22036a408f3dd49d2ec2fa"
},
{
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchSourceMapper.java",
"sha256": "1bec0687e9d1d3a2404d25a37aad092d18b7134eab605594f352a5432360ac76"
},
{
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/ReturnDispatchCandidateMapper.java",
"sha256": "a99080e851a6d22c5ecb1229e761382d846d1f5e4b92a3c69d30716af125746d"
},
{
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaPickOrder.java",
"sha256": "3d66df6566b467580c89aa5f9f585aba610b05ca2de07ad28a3d6a6f30ab10e3"
},
{
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchWholePdaReadMapper.java",
"sha256": "4df97c47b2ca481eb5f8f32e7735bb15a80aa4e561bea7ab349f09352ca9db1e"
},
{
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionService.java",
"sha256": "83947030468892ee1d4a02b354e855d7977f55fcc17de440f324cf24940a10d8"
},
{
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchContracts.java",
"sha256": "5086523d9df7c7d00840d391e9608b1685e4edb71d19154b1608339afe7421e2"
},
{
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchPlanningModels.java",
"sha256": "47489d22ab81913ab23c319c04561371cbdf1e1c43034fe5b1d24fe94038137e"
},
{
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchViews.java",
"sha256": "d34d5a810ea7658ffd2623f53970435ecee116663bd969339de84432e38cd573"
},
{
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchDraftRepository.java",
"sha256": "f28b7aff08afdf634387e6abb7919a807fd6a2ff54ff7869b3e1e4d453f2c92b"
},
{
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchItemSourceRepository.java",
"sha256": "2bcef5d9952750235ecabcf429e4eab9b9a9c0a3cac0b3e02b083120f85df28e"
},
{
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchPhysicalClaimRepository.java",
"sha256": "27f37b5e683b92d82d9cf63a88b2135ada5f597e652474eef52d7c985b870b31"
},
{
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchSourceRepository.java",
"sha256": "04b5d5160614b17a31030f23167d9e32aa89e13be7ef2e33c9d91359ef684348"
},
{
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java",
"sha256": "ec0ce406d4244a0abf34ee9ede97b788675b39234fbb8e6d0ff9579dde360145"
},
{
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchDepartureService.java",
"sha256": "fc9440c6755316c58572f3fed5a576c5721098a3d5d7a036eecd3d6d752f1ea0"
},
{
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchDraftService.java",
"sha256": "a2682e87f8bbf47a799d8d54ae05b8acdef52414016cab116eb9a40fe1364e04"
},
{
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchItemSourceService.java",
"sha256": "ee42766e7f86f896df1839e15987382c8aebc3484449b2104312378eaababbba"
},
{
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPdaQueryService.java",
"sha256": "0f59acc9c3ed6fd2995c1ed59810146c0056d1462e4e23d85f52198a1ffe0296"
},
{
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPhysicalClaimService.java",
"sha256": "063b557cd488bf2281516df744f12123473269be12f56a5155facad018e6408e"
},
{
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPickingService.java",
"sha256": "17a461a599cc0702b519d1333c5e136860eff7b638d739dde7e0bd203bcfc4ec"
},
{
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java",
"sha256": "c95a0d4075df7628e4443f87be4d2d906aee5cbb95148d5fd3869d6ec7932465"
},
{
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/PhysicalPalletSerialMapper.java",
"sha256": "237fe76c88d572b0d70eb25d84d191dcc0d2fdbc3a031e728e6bbbf88a8aa25f"
},
{
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/inventory/port/PhysicalPalletSerialPort.java",
"sha256": "4aa97a0c9bf0f4a9138ec15ac32e512ff13224e9a6f0e0817c5b0852f9635746"
},
{
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/inventory/repository/PhysicalPalletSerialRepository.java",
"sha256": "0489e8fa5806fd0faa4a61c4a0c7a9bf89d086537752f218dcbcaed08475487e"
},
{
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/inventory/service/PhysicalPalletSerialService.java",
"sha256": "4876ed0fc6267d9b5986fde0f2e8096ec01db35cd2f0cadbaeb3ebad2e03b76b"
},
{
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/palletops/mapper/PdaPalletOperationMapper.java",
"sha256": "960bb99f8432524e5a4bc76a61ccde8273929ad9de5e21ae0b44d941edeb6c9d"
},
{
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/palletops/repository/PdaPalletOperationRepository.java",
"sha256": "55d243f6f419f112d3f89005ce37af0d6446e7b21d3b00f5931b56f3ea556302"
},
{
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationService.java",
"sha256": "c3a4ceba06b722d5a0bba810eaa948bd364a09ff5599c3176207d5c28b39b7fc"
},
{
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelDispatchSourceMapper.java",
"sha256": "6dd3902b7f64d40f8db5ae1f3dd3640e91a69cadd6c895b1f3a81ac48b93ebf0"
},
{
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/relabel/port/RelabelDispatchSourcePort.java",
"sha256": "af771fd474faf6c411543b077c646479e6368b98ab3cab875c4cd6c0b3b24978"
},
{
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/relabel/repository/RelabelDispatchSourceRepository.java",
"sha256": "12b9577391f0908d94e4e2b307f7572e4825f556307145f8a83d0294516a2c46"
},
{
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelDispatchSourceService.java",
"sha256": "949ea59036d710af0cc0b81848a77e17da0da3de049e44124a36be141ee81fe0"
},
{
"path": "lastleg-service/src/main/resources/db/lastleg.sql",
"sha256": "61946a41d773e856e3d3f9463f7ff5bfe0e904856598e4a64c8cbd0936ef55b5"
},
{
"path": "lastleg-service/src/test/java/com/baiyue/lastleg/controller/Phase4BrowserApiBoundaryTest.java",
"sha256": "4fbf6bab69cfaff409d0d228a1169c63db0f092e9c1c6e2f70bb956f1d3ed70f"
},
{
"path": "lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryDispatchFixtures.java",
"sha256": "1dc488f6d614d0038a54ea81ecf1ca15c40887bcf951f5f96df436511a8cec86"
},
{
"path": "lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DispatchDeliveryArrangementPersistenceTest.java",
"sha256": "b989d8db720bd10aa4c928297ed387486353a06922d043d298b234bbc3c26b29"
},
{
"path": "lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DispatchDeliveryFeesTest.java",
"sha256": "57928866a589890689ff729a234a5a1b1a93e39e2ad6a11dae7c05bb312261e4"
},
{
"path": "lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DispatchDeliveryServiceTest.java",
"sha256": "cd2b7753a44aeb34f5b2f88bae8349ecb8eeea5c96ecd3796391570a8662b9ca"
},
{
"path": "lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java",
"sha256": "6e37ddaf197138e06b6b0d2d3ca908c58e23f8a2e01fb18791cb38b8ff1fd5be"
},
{
"path": "lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/ReturnOutboundClosureTest.java",
"sha256": "178d6781adf4c6f08be8569ec869566ea97391ead064ca460b9b3e812ca9e585"
},
{
"path": "lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionServiceTest.java",
"sha256": "ec3ab8341cec7ae692501ba264178d5d520923b7c2dcf20dfaa620313c9d03d4"
},
{
"path": "lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java",
"sha256": "8f5907aa180db30a2b56fb6b251f9ae7a94fa550666239947b660cb5431fe978"
},
{
"path": "lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAppointmentCancellationPersistenceTest.java",
"sha256": "0be4d1f344374147149f248bbc2aaac486484ad4a0e9ba1681295aa6fd5ae21d"
},
{
"path": "lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchDeliveryCompletionWorkerTest.java",
"sha256": "cf2812520b6c1025b3b8ab8084b9d21064427811dd62f12a734cf90c4c035c12"
},
{
"path": "lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchDraftPersistenceTest.java",
"sha256": "35b7c411e452dce2189c22f8cf31d77083f8854062435a9223e306a1a9d44a6a"
},
{
"path": "lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchItemSourcePersistenceTest.java",
"sha256": "24e1b3f0dba4d4f9c1f724840c7a263ff0d1561cb5cb509fb0c410be8187afb0"
},
{
"path": "lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchMergedRelabelPersistenceTest.java",
"sha256": "1c36d64a389bf12e55cfb8cbb29b7dd333bc952f66e8bb88d441ddf42f83c653"
},
{
"path": "lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPdaReadPersistenceTest.java",
"sha256": "9b4402d0dd3f0eefa8b6b9bfe65e20f42f3e60619ac6989b18c9a74cbebad0ad"
},
{
"path": "lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPhysicalClaimPersistenceTest.java",
"sha256": "02bd34b71fdb237566e99fab0b748c3ed333ace4ae65042aaf315b06695299f6"
},
{
"path": "lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPickingPersistenceTest.java",
"sha256": "0971e32ae147a03a730c16f1a75f0576e3a9122f92c5c65608db0cb9a76a3510"
},
{
"path": "lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchRepairWorkflowTest.java",
"sha256": "4a4d01424e91fd1b55b0af4721e02c4acad065e958ff4a0b9726cf726691fbdf"
},
{
"path": "lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java",
"sha256": "a9a2e25ff22e326925104e48ff1cfed34fec3350dfb569f1e7f19a4e186925c7"
},
{
"path": "lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchWholeSourcePersistenceTest.java",
"sha256": "0143045a52df440dc8be72706d69cbc45a9d210ddfb4ce77762423533dcc1a55"
},
{
"path": "lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/ReturnDispatchCandidatePersistenceTest.java",
"sha256": "4170a35b6f57d9058577941ef665130e0f3a3faeef37c92c9e93a69dd9f0f60a"
},
{
"path": "lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/FbaAppointmentRepartitionPersistenceTest.java",
"sha256": "f2f7174dd6e52f5d9caae36f8fc24aa2dc78145c1c390e8e16857bb066948a86"
},
{
"path": "lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanPaginationTest.java",
"sha256": "b9196d489830c2f4bf24ebcaf3e94adf4e0484f414f373c300b0f4a2a538679b"
},
{
"path": "lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDomainMapperTest.java",
"sha256": "5fbe3b3be8be194d1f31006e4e05187cd629236c376e01da2d291a2593e70e0a"
},
{
"path": "lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryHoldConcurrencyPersistenceTest.java",
"sha256": "e146136299db24b1cf618faf2161fa284d9eec15a26a75e8352548cd7dbe7e94"
},
{
"path": "lastleg-service/src/test/java/com/baiyue/lastleg/inventory/service/PhysicalPalletSerialPersistenceTest.java",
"sha256": "444bd8d361f4d90fbdaf1e4d1d205906b5452096670f57bd3459906716e36c7e"
},
{
"path": "lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletDispatchFreezePersistenceTest.java",
"sha256": "a8ef7e364e4d0fb97f83515dd1d3b983cdac4f367a9335fe181c39a47719720f"
},
{
"path": "lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationServiceTest.java",
"sha256": "eae67e13f9d1d3e8ff8a76b30a8a72d445e54b3cd6a30394c5ea41f763f48a7c"
},
{
"path": "lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelDispatchSourcePersistenceTest.java",
"sha256": "6eb43b35de92364884e9ffbd36c18c3921ac8c21c959602383f9ca9cc8c4ece5"
},
{
"path": "lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java",
"sha256": "787e78907d019458e3533325ed2bfe8cc09eb8de7ca1db2c0b98a54239a56fe5"
}
]
}
-167
View File
@@ -1,167 +0,0 @@
{
"createdAtUtc": "2026-10-19T11:12:23.053116+00:00",
"scope": "C52 route pruning only: actual origin, supported old stops retain relative order, new cargo stops append, active external booking stops required; invalid commitments fail atomically",
"sources": [
{
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingReservations.java",
"sha256": "84906c28ccaf94125a41c03a30c9df392400af74569ed26c2da8abd5c116144c",
"modifiedAtUtc": "2026-10-19T11:06:24.471699+00:00"
},
{
"path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripService.java",
"sha256": "28eca09b87337b07a04955039a50c3c55805289955c24db75fe9df6fbf0b3bcc",
"modifiedAtUtc": "2026-10-19T11:06:24.472698+00:00"
},
{
"path": "lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetDispatchRoutePersistenceTest.java",
"sha256": "687248640255a40456f4197cab28dd32e8a16f97a833858fc868e171dcf2b62c",
"modifiedAtUtc": "2026-10-19T11:08:07.884938+00:00"
}
],
"aggregateMethod": "normalized path + space + lowercase SHA256, Unicode lexicographic path sort, LF joined, no trailing LF",
"aggregateSha256": "9fab733033d4fffaeb716b4cb2720dd72b26c14ece1b97d6bd6d2e30db502aa2",
"compileCommand": [
"./scripts/local-check.ps1",
"-WorkingDirectory",
".",
"-Label",
"Dispatch route test-compile",
"-Command",
[
"mvn",
"-pl",
"lastleg-service",
"-am",
"-DskipTests",
"test-compile"
]
],
"testCommand": [
"./scripts/local-check.ps1",
"-WorkingDirectory",
".",
"-Label",
"Dispatch route regression",
"-Command",
[
"mvn",
"-pl",
"lastleg-service",
"-am",
"-Dtest=FleetDispatchRoutePersistenceTest,DispatchAggregatePersistenceTest,FleetTripPersistenceTest,FleetReceiptCapacityTest,FleetTripMatchedStopsTest",
"-Dsurefire.failIfNoSpecifiedTests=false",
"test"
]
],
"compileExit": 0,
"testExit": 0,
"jvmPolicy": "local-check defaults; JAVA_TOOL_OPTIONS ActiveProcessorCount=2 Xmx1536m; no memory override",
"logs": [
{
"path": ".logs/dispatch-route-test-compile.log",
"sha256": "d52d00bd3b3d16b0ced516f4649367a94b012fb8ed831a1ca933b1d4ef6acb39"
},
{
"path": ".logs/dispatch-route-test-compile.maven.log",
"sha256": "9071174d9e9248a6655a1ca815043d17e607adb1d4f1ab8b5f914c2e51fc9d4b"
},
{
"path": ".logs/dispatch-route-regression.log",
"sha256": "1edb3d4afb3b03e35f4192b855ec80c607b4ae5a88e9261a431a79e97dbaa68a"
},
{
"path": ".logs/dispatch-route-regression.maven-tail.log",
"sha256": "b17e27d2466c4b63d9c4a976bea598b6af45a50f2b53fb387bb8fdbbb48bdfab"
}
],
"results": [
{
"class": "com.baiyue.lastleg.dispatch.service.DispatchAggregatePersistenceTest",
"tests": 11,
"failures": 0,
"errors": 0,
"skipped": 0,
"xmlSha256": "edb055fa5c2d9ddaa446aec702a00f17db76e8e95c3c5d4ddb411e0df40e2573",
"testCases": [
"incompleteAttributionCannotPassLoadingThroughMatchingHeaderTotals",
"reviewsUseActualRootRoundAndImmutableCommandsBeforeAndAfterLoading",
"operationItemsAndRouteUseActualIdsAndPersistedStopOrder",
"emptyCreateHasOneRealTruckAndNoCustomerHeader",
"multiCustomerSelectionCreatesOneTruckAndKeepsAllDocumentsAndBatchReads",
"realPickBlocksReturnEvenWhenHeaderCountersAreZero",
"laterCargoConflictRollsBackTruckResourcesEarlierLocksAndItems",
"loadingUsesActualSourcesMeasurementsAndNeverAutoCompletesFromCounters",
"cancelledOutboundHistoryDoesNotBlockZeroPickCancellationButAnActiveDocumentDoes",
"updateSavesCurrentVehiclePlanAndReplayWithoutAnyVehicleAppointmentField",
"removalCancellationAndOldCommandRecoveryNeverReleaseNewTruckClaims"
]
},
{
"class": "com.baiyue.lastleg.fleet.trip.FleetDispatchRoutePersistenceTest",
"tests": 7,
"failures": 0,
"errors": 0,
"skipped": 0,
"xmlSha256": "12404cd4a8e98eecd01e18b7ea12f915032a4f5f175d2d1e0b26daebc4766b8d",
"testCases": [
"failureAfterTripRouteWriteRollsBackMainClaimsQuantitiesAndLedgerThenTheSameKeyCanSucceed",
"missingRequiredBookingStopRejectsTheWholeRemovalAndDoesNotInventAStop",
"lastPalletRemovalPrunesOnlyItsStopPreservesOrderAndAppendsNewDestinations",
"activeBookingKeepsItsOriginalStopAndCancelledBookingsDoNotLeaveGhostStops(String)[1]",
"activeBookingKeepsItsOriginalStopAndCancelledBookingsDoNotLeaveGhostStops(String)[2]",
"activeBookingKeepsItsOriginalStopAndCancelledBookingsDoNotLeaveGhostStops(String)[3]",
"anotherPalletAtTheSameDestinationKeepsTheStopAndAnEmptyTruckKeepsOnlyItsOrigin"
]
},
{
"class": "com.baiyue.lastleg.fleet.trip.FleetReceiptCapacityTest",
"tests": 5,
"failures": 0,
"errors": 0,
"skipped": 0,
"xmlSha256": "a86f409a17999c90c88791bbc64e98c0e6fcd69db6e1f52c5aada0676ab8b53b",
"testCases": [
"firstReceiptOrMissingPreviousReceiptStillRequiresValidOwnPlan",
"receivedReconfirmationFitsWithoutCreatingAnOwnCargoPlan",
"otherConfirmedReservationsStillConsumeCapacity",
"nonemptyStaleOwnPlanRemainsBlocked",
"remeasuredExternalCargoStillCannotExceedVehicleWeight"
]
},
{
"class": "com.baiyue.lastleg.fleet.trip.FleetTripMatchedStopsTest",
"tests": 3,
"failures": 0,
"errors": 0,
"skipped": 0,
"xmlSha256": "0f1d101f1d479c0f8d86fd3ec0d06649addc999bacfb3fa43c39361a42691b4c",
"testCases": [
"rejectsMissingIdentityWithoutUsingDisplayNameAsKey",
"replacesOnlyInitialPlaceholderAndDeduplicatesByIdentity",
"preservesExistingStopIndexesAndAppendsDifferentIdentityEvenWhenNamesMatch"
]
},
{
"class": "com.baiyue.lastleg.fleet.trip.FleetTripPersistenceTest",
"tests": 7,
"failures": 0,
"errors": 0,
"skipped": 0,
"xmlSha256": "2f5eba7a2776a9c19829fba55ed379a84e19d6a5d7095ec7f2ec6f4dfccfba67",
"testCases": [
"capacityFailureOrStaleFingerprintDoesNotWritePlanOrHistory",
"boundCorePlanAndResourcesMustUseTheCommonMainVersionAndReview",
"externalVehicleCapacityPlanUsesActualWholeTruckStopsAndSnapshots",
"oneWholeTruckProjectsBothReviewsFromTheRealMain",
"laterFullSignatureReturnDoesNotReopenOriginalTransport",
"partialSignatureCannotCloseCargoOrEndExternalTasks",
"externalCargoCompletionAndTwoTaskEndsAreIndependentAndReplayable"
]
}
],
"tests": 33,
"failures": 0,
"errors": 0,
"skipped": 0,
"limitations": "H2 service/mapper/transaction regression with controlled catalogue/source providers and seeded persisted bookings; no environment HTTP, production SQL, deployment, UI or real PDA acceptance; no 501 rerun"
}
File diff suppressed because it is too large Load Diff
-2
View File
@@ -1,7 +1,5 @@
# 单测试环境数据库空库初始化
费用项目默认价格结构:Firstleg 源基线 `phase2-fee-item-and-inbound-detail.sql` 与国内统一空库基线均增加可空的 `fl_fee_item.default_price DECIMAL(12,2)`。字段业务规则见工作区 `docs/入库测量与计费/README.md`;需与匹配的 Firstleg 和 Admin 一起发布。本次仅修改基线,不表示 SQL 已执行,也不能向已有测试库重放空库脚本。
排车整车作业结构同步于 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`保存真实作业轮次,不能把扫码引起的库存和申请状态变化误当作换标轮次变化。排车、草稿和仓内调用必须使用匹配的公共锁实现;本条只说明空库基线结构,不表示已执行或已部署。
@@ -1,17 +0,0 @@
-- Region: OVERSEAS
-- Schema: baiyue_lastleg
-- Purpose: 补齐预约分批/合并后的替代预约业务号快照,供入仓计划查询过滤原预约。
-- 可重复执行;不修改既有预约数据。
SET @ddl = IF(
EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'll_inbound_fba_appointment'
AND column_name = 'replacement_nos_json'
),
'SELECT 1',
'ALTER TABLE ll_inbound_fba_appointment ADD COLUMN replacement_nos_json LONGTEXT NULL COMMENT ''分批或合并后的新预约业务号列表,非空时原批次只读'' AFTER selected_count'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
@@ -1995,7 +1995,6 @@ CREATE TABLE fl_fee_item (
fee_code varchar(50) NOT NULL COMMENT '稳定费用编码;系统费用使用固定编码',
fee_type_name varchar(128) NOT NULL COMMENT '费用类型名称',
supplier_category varchar(16) DEFAULT NULL COMMENT 'PAYABLE供应商类别约束;为空表示任意启用供应商',
default_price decimal(12,2) DEFAULT NULL COMMENT '默认价格CNY;仅选费带入,实际费用可修改',
pickup_payable tinyint(1) NOT NULL DEFAULT '0' COMMENT '提柜应付是否参与',
pickup_receivable tinyint(1) NOT NULL DEFAULT '0' COMMENT '提柜应收是否参与',
warehouse_payable tinyint(1) NOT NULL DEFAULT '0' COMMENT '仓储应付是否参与',
+96 -45
View File
@@ -2,7 +2,8 @@
## 数据库访问与中文注释约束
- 强制继承 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则;该节为分层、SQL 执行和方法/核心逻辑中文注释的唯一维护处。根规则已在上下文时不重复读取,本服务不另设例外。
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
## 职责与边界
@@ -11,55 +12,105 @@
- 订单主数据归 `order/`,托盘/库位归 `warehouse/`,文件对象归 `file/`,OCR 供应商归 `integration-service/`;通过客户端 DTO 和内部令牌协作。
- 入库、分柜、装柜、PDA 和出仓能力均在本模块按对应服务端契约协作;`WAIT_SPLIT + APPROVED + CONFIRMED` 是入库链路状态节点,不是全量验收的范围终点。财务状态仍归 `finance`,本模块只生产不可变费用来源和柜次成本快照。
## 快速定位与读取边界
- 先按下表定位目标类,已知文件则直接进入;数据库分层与中文注释遵循后端根规则,已提供的指引不重复加载。
- 代码包路径相对 `src/main/java/com/baiyue/firstleg/`;`src/`、`docs/`、`scripts/`、`../` 和 `../../` 路径相对本服务目录。裸类名在所属包用 `rg --files` 定位。
- [业务详细索引](docs/business-index.md) 是本服务业务细则的维护处;用 `rg -n -F '任务词或类名' firstleg/docs/business-index.md` 从后端根读取命中段。代码定位、纯文案或不改变业务语义的局部重构不默认加载全文。
- 涉及状态、字段、金额、权限、库存资源或事件时,修改前必须核对该索引对应入口及相关“修改约束”“国内货柜履约装柜与 PDA 资源结算”条款;只找到了类名不能替代业务规则核对。
- 主表只维护关键词、首个代码入口和细则检索词,不复制 DTO、状态机、算法或验收进度;业务规则只在详细索引或其指定正式契约修改。
## 模块入口
| 目标 | 首个代码入口与细则检索词 |
预约日历到港只读:`portschedule/controller/PlatformPortScheduleController.java` 的 `/arrival-calendar`,沿 `PortScheduleService`、`PortScheduleMapper` 查询有效柜次及实际到港节点;合同 `src/main/resources/openapi/arrival-calendar-v1.yaml`,权限 `platform:appointment:list`。不在日历维护第二套船节点。
表内 `controller/`、`service/`、`mapper/`、`loading/` 等业务包路径相对 `src/main/java/com/baiyue/firstleg/`;`src/`、`docs/`、`scripts/` 和 `../` 开头路径相对本服务目录。裸文件名沿同一条目最近业务包定位,必要时在该包用 `rg --files` 查找,不从全仓猜路径。
事件人工重试:`controller/EventOperationsController.java` 提供内部 `/internal/firstleg/event-operations/{entryId}` 查询及 `/retry` 写入,只允许装柜完成与船节点事件;独立 `EVENT_OPERATIONS_TOKEN` 保护。原事件不可修改,DEAD→PENDING 与 `event_delivery_retry_audit` 同事务提交,脚本见根 `scripts/event-outbox-operations.mjs`。
柜次号与散货托盘码:`service/FirstlegDocumentNumberGenerator.java` 为新柜生成全局连续且不按日期重置的 `D0001`、`D0002` 柜次号;`service/LoadingPalletLabelService.java`、`mapper/LoadingPalletLabelMapper.java`、`controller/PlatformLoadingListController.java` 负责散货托盘码,契约为 `src/main/resources/openapi/loading-lists-v1.yaml` 的 `/pallet-labels/prepare` 与 `/pallet-labels/print`。仅在计划 `LOADING/COMPLETED` 且真实柜号有效时,为非一件代发的 FBA 明细按平台柜次号、FBA 地址内部编号、三位序号生成冻结标签;托盘码不依赖可跨提单复用的真实柜号,内部编号从 Warehouse 启用主数据读取,缺失或同柜重复时拒绝生成。重复生成须核对已有标签与明细一致,冲突直接拒绝。全部或按仓指定张数打印统一调用 Print `/internal/print/v1/pdfs/fba-pallet-labels`,模板 `FBA_PALLET_A4_LANDSCAPE_V1` 每托一页 297×210 mm,顶部为 SCK8 等 FBA 仓码,条码和底部文字为完整托盘号,与海外补打共用版式;装柜完成通过 `LoadingCompletedOutboxService.java` 传递来源托盘。
| 目标 | 入口 |
|---|---|
| 履约分配、入库查询、客户工作台 | `controller/PlatformFulfillmentAssignmentController.java`、`service/InboundOrderQueryService.java`、`service/MerchantWorkbenchFilterService.java`;查“履约任务/分配”“客户工作台筛选范围” |
| 入库单扣货、履约门禁与凭证 | `service/InboundHoldService.java`、`service/InboundHoldGuard.java`;查“入库单扣货” |
| Order 申报同步与渠道变更履约门禁 | `service/OrderDeclarationSyncService.java`、`controller/InternalInboundOrderController.java`;查“Order 申报同步” |
| 测量/OCR、墨家设备 | `service/InboundMeasurementService.java`、`service/MojiaMeasurementOpenApiService.java`;查“平台测量/OCR”“墨家设备测量上传” |
| 箱唛箱码生成、打印与测量绑定 | `service/InboundCartonLabelService.java`、`service/InboundCartonLabelPrintService.java`;查“稳定箱码”“箱唛箱码” |
| 一件代发清点组托 | `service/DropshipPalletizationService.java`;查“一件代发测量后 PDA 清点组托”“fl_inbound_carton_label_sku” |
| 称重确认、整票库位块、部分拣货结算 | `service/MeasurementLocationOccupationService.java`、`service/MeasurementSubmissionService.java`;查“首张称重图片”“删除称重图片”“PDA 部分拣货” |
| 审核与初始计价 | `service/MeasurementReviewService.java`、`service/InitialInboundPricingService.java`;查“测量审核通过” |
| 客户四档箱规与默认算法 | `service/CustomerDimensionConfigService.java`、`service/CustomerDimensionWeightingService.java`;查“客户箱规配置” |
| FBA 计价、客户专属价格簿、柜级阶梯运费 | `service/FbaPricingService.java`、`service/PlatformPriceBookService.java`、`pricing/ContainerTierAmountAllocator.java`;查“客户专属价格簿、柜级阶梯运费与调价边界” |
| 费用项目、价格簿查询内部切片 | `controller/PlatformFeeItemController.java`、`pricebookquery/application/PriceBookQueryService.java`;查“价格簿查询内部切片” |
| 入库费用、客户确认、直接已付 | `service/InboundFeeDetailService.java`、`service/MerchantInboundBillingService.java`、`service/FeePaymentSnapshotValidator.java`;查“客户入库费用”“DIRECT_PAID” |
| 柜次费用、跨客户分摊与财务快照 | `service/ContainerFeeDetailService.java`、`controller/InternalFinanceFeeSourceController.java`、`loading/service/FinanceCostSnapshotService.java`;查“柜次费用明细与跨客户分摊组” |
| 分柜、预装、正式装柜清单生命周期 | `service/LoadingListService.java`、`split/controller/PlatformSplitPreloadController.java`;查“分柜、预装”“预装进入” |
| 同柜不可拆组 | `loading/coload/service/ColoadGroupService.java`;查“同柜不可拆组”及资源结算中的整组原子规则 |
| 货柜、全局智能排柜与候选 | `service/SmartLoadingService.java`、`controller/PlatformSmartLoadingController.java`;查“货柜与全局智能排柜”“新增模拟柜” |
| PDA 拣货装柜、证据上传 | `controller/PlatformLoadingPdaController.java`、`loading/service/CabinetLoadingEvidenceService.java`;查“PDA 拣货与装柜”“PDA 普通散箱拣货” |
| 货柜资料、散货托盘码、业务单据编号 | `service/LoadingPalletLabelService.java`、`service/FirstlegDocumentNumberGenerator.java`、`mapper/ContainerOverviewMapper.java`;查“柜次号与散货托盘码”“Firstleg 业务单据编号” |
| 装柜完成、船节点直投及海外回写 | `service/LoadingCompletedOutboxRelay.java`、`portschedule/service/CarrierScheduleNodeOutboxService.java`、`controller/InternalOverseasContainerEventController.java`;查“装柜完成直连事件”“海外派送完成回写” |
| 订舱资料、订舱级拖车与整柜出仓 | `oceanbooking/service/OceanBookingService.java`、`service/TruckAppointmentService.java`、`outbound/dispatch/service/OutboundDispatchService.java`;查“订舱级拖车”“供应商必须通过” |
| 船节点查询、到港日历与预约来源 | `portschedule/service/CarrierScheduleQueryService.java`、`portschedule/service/AppointmentWorkSourceService.java`、`portschedule/controller/PlatformPortScheduleController.java`;查“船节点定时查询”“已开船预约事项完整来源”“预约日历到港只读” |
| 直送目的仓实际卸货、码头拖车与还空 | `terminaldrayage/service/DirectDeliveryService.java`、`terminaldrayage/service/TerminalDrayageService.java`;查“直送目的仓实际卸货”“目的港码头拖车与还空预警” |
| 整柜出口报关、买单明细导出 | `customs/container/service/ContainerCustomsService.java`、`customs/container/controller/LoadingListBuyDeclarationExportController.java`;查“整柜出口报关”“买单订单明细导出 Provider” |
| 首页指标、客户统计与在途柜量 | `dashboard/PlatformDashboardMetricsController.java`、`customerstatistics/application/CustomerTransitStatisticsService.java`;查“首页实时指标” |
| 跨模型桥接、供应商快照与服务客户端 | `loading/bridge/controller/PlatformLoadingModelBridgeController.java`、`service/WarehouseSupplierSnapshotService.java`、`client/`;查“跨模型桥接”“仓库公共供应商快照” |
| 事件人工重试 | `controller/EventOperationsController.java`;查“事件人工重试”,仅内部运维身份操作 |
| SQL、OpenAPI、模块化基线 | `src/main/resources/db/`、`src/main/resources/openapi/`、`docs/firstleg-internal-modularization-baseline.md`;查“SQL/OpenAPI”“内部模块化基线与依赖分析” |
| 内部模块化基线与依赖分析 | `docs/firstleg-internal-modularization-baseline.md`、`scripts/analyze-firstleg-dependencies.mjs`、`scripts/analyze-firstleg-dependencies.test.mjs`、`src/test/resources/architecture/firstleg-slice-dependencies.json`;只约束本服务内部包依赖,不改变部署单元或数据库边界 |
| 履约任务/分配 | `controller/PlatformFulfillmentAssignmentController.java`、`service/FulfillmentTaskService.java` |
| 客户工作台筛选范围 | `service/MerchantWorkbenchFilterService.java`、`mapper/MerchantWorkbenchFilterMapper.java`、`dto/MerchantWorkbenchFilters.java`;内部入口 `InternalInboundOrderController` 的 `/unified-orders/workbench-filters`,按客户只读计算履约、费用待确认与最新已装柜计划的报关待上传订单范围,Order 负责最终筛选和分页 |
| 入库单查询/聚合 | `controller/PlatformInboundOrderController.java`、`service/InboundOrderQueryService.java` |
| 入库单扣货、履约门禁与凭证 | `service/InboundHoldService.java`、`InboundHoldGuard.java`、`InboundHoldEvidenceService.java`、`client/InboundHoldFileClient.java`;凭证固定按客户、入库单和 `INBOUND_HOLD_EVIDENCE` 精确归属,只有进入不可变扣货历史的文件可下载 |
| Order 申报同步与渠道变更履约门禁 | `controller/InternalInboundOrderController.java` 的 `/order-channel-changes/validate`、`/order-declaration-updated`,`service/OrderDeclarationSyncService.java`、`mapper/OrderDeclarationSyncMapper.java`、`dto/OrderDeclarationSync.java`;契约 `src/main/resources/openapi/firstleg-v1.yaml`,只接受内部服务令牌 |
| 平台测量/OCR 与箱规计费快照 | `controller/PlatformInboundMeasurementController.java`、`service/InboundMeasurementService.java`、`service/CartonMeasurementService.java`、`service/CustomerDimensionWeightingService.java`;手工单箱测量保存实际尺寸、可选外箱修正、`chargeRuleId` 与 `coefficientMode`,计算结果以服务端返回值为准 |
| 箱唛箱码生成、打印与测量绑定 | 审核建单生成钩子 `controller/InternalInboundOrderController.java`;生成/解析 `service/InboundCartonLabelService.java`、`repository/InboundCartonLabelRepository.java`;平台与客户打印 `controller/PlatformInboundOrderController.java`、`MerchantInboundCartonLabelController.java`、`service/InboundCartonLabelPrintService.java`。一件代发箱唛以 `fl_inbound_carton_label` 表示物理箱,`fl_inbound_carton_label_sku` 冻结箱内一条或多条 SKU;生成时经 `client/OrderCargoClient.java` 获取。缺 SKU 时禁止生成;所有头程箱唛打印面均不显示 SKU,SKU 只保留在业务快照及一件代发 A4 托盘信息单;`box_code` 与二维码内容仍只携带稳定箱码。基线/升级 SQL 为 `src/main/resources/db/phase2-inbound-carton-label.sql`、`phase2-carton-label-versioning-migration.sql`、`../deploy/sql/头程数据库统一增量-MySQL8.sql` |
| 一件代发测量后 PDA 清点组托 | `controller/PlatformDropshipPalletizationController.java`、`service/DropshipPalletizationService.java`、`mapper/DropshipPalletizationMapper.java`;测量审核通过进入 `WAIT_PALLETIZE`,按预计托数预生成 `DP` 托盘码并下发独立任务,不创建国内库存;托盘信息单固定为每托一页 A4,包含托盘 Code 128 一维码、入仓码、总箱数与 SKU/商品名称/箱数/实点数量,不展示作业状态。PDA 逐箱清点,支持先扫箱或先扫托、归托、逐托封托和在未进入装柜计划前解除封托;合托解封时同步恢复所有参与任务及入仓单。预计托数与已生成托盘数不一致时禁止作业,全部箱和实际参与托盘完成后进入 `WAIT_SPLIT`。契约 `src/main/resources/openapi/dropship-palletization-v1.yaml` |
| 墨家设备测量上传 | `controller/OpenApiInboundMeasurementController.java`、`dto/MojiaMeasurementDtos.java`、`service/MojiaMeasurementOpenApiService.java`、`security/MeasurementRequestAuthenticator.java`;接口契约见 `src/main/resources/openapi/firstleg-v1.yaml` 与 `../../docs/墨家设备测量上传接口说明-V3.md` |
| 称重确认与整票库位块 | `service/MeasurementLocationOccupationService.java` 的 `ensureForConfirmedWeight/ensureForSubmission`、`InboundMeasurementService.java`、`MeasurementSubmissionService.java`、`client/WarehousePalletLocationClient.java`;Warehouse 块服务见 `../warehouse/src/main/java/com/baiyue/warehouse/service/LocationBlockService.java`,接口与上架引导见 `src/main/resources/openapi/firstleg-v1.yaml` |
| 审核与初始计价 | `controller/PlatformInboundMeasurementReviewController.java`、`service/MeasurementReviewService.java`、`service/InitialInboundPricingService.java`、`dto/MeasurementApprovalResponse.java` |
| 客户四档箱规与默认算法 | `controller/PlatformCustomerDimensionConfigController.java`、`service/CustomerDimensionConfigService.java`、`repository/CustomerDimensionConfigRepository.java`;固定 `SMALL/MEDIUM/LARGE/EXTRA_LARGE`,默认算法只允许 `ADD/MULTIPLY` |
| FBA 主数据/计费与客户规则 | `controller/PlatformFbaMasterDataController.java`、`PlatformFbaPricingController.java`、`service/FbaPricingService.java`、`repository/FbaPricingRepository.java`;客户规则可保存多个版本,但每个客户仅一条默认规则,计算请求可提交已确认的重量/体积选择值 |
| 客户专属价格簿、柜级阶梯运费与调价边界 | `controller/PlatformPriceBookController.java`、`service/PlatformPriceBookService.java`、`pricing/ActivePriceBookEstimator.java`、`service/FbaPricingService.java`、`pricing/ContainerTierAmountAllocator.java`;公共价格簿固定 `customerId=0`,客户专属版本按客户独立发布与替换,计价优先专属 ACTIVE、缺失才回退公共 ACTIVE。`REGION_WEIGHT_TIER` 只对同柜同客户中当前费用快照同为区域重量阶梯的有效成员聚合实际计费量命中一个阶梯,再按这些成员的计费量守恒分摊;同柜允许与 `FBA_WAREHOUSE_RATE` 等固定模型混装,固定模型成员不得参与阶梯聚合或被重算。装柜成员变化调用 `recalculateUnlockedContainerCustomerFees`,已确认/已发送或客户已确认的版本不得改写。人工调整以系统基准量为中心限制上下 10%(含边界),保存版本、原因和二次确认;一件代发 `WAIT_PALLETIZE` 可在预装确认前试算,`markInboundCalculated` 只更新费用并保留该托盘化状态 |
| 入库费用明细 | `controller/PlatformInboundFeeDetailController.java`、`service/InboundFeeDetailService.java`、`dto/InboundFeeDetailDtos.java`;`PRIVATE_LAST_MILE_DELIVERY` 是独立私人仓尾程派送费,只允许 `destinationType=PRIVATE`,按稳定费用代码校验而不按中文名判断;Admin 消费端见 `../../apps/admin-web/src/modules/unified-orders/api/inboundFeeDetails.ts` |
| 入库/柜次直接已付快照与凭证校验 | `service/FeePaymentSnapshotValidator.java`、`client/FeePaymentVoucherFileClient.java`;写入入口分别为 `InboundFeeDetailService.java`、`ContainerFeeDetailService.java`,File 业务类型固定 `FINANCE_VOUCHER` |
| 首页实时指标、客户运营统计/在途柜量 | 首页物理在途柜 TEU 与待清关票数从 `dashboard/PlatformDashboardMetricsController.java` 进入,契约见 `src/main/resources/openapi/dashboard-metrics-v1.yaml`;客户维度统计为 `customerstatistics/adapter/in/web/PlatformCustomerTransitStatisticsController.java`、`customerstatistics/application/CustomerTransitStatisticsService.java`,契约见 `src/main/resources/openapi/customer-statistics-v1.yaml` |
| 费用项目与价格簿 | `controller/PlatformFeeItemController.java`、`PlatformPriceBookController.java`、`MerchantPriceBookController.java` |
| 客户入库结果与头程费用确认 | `controller/MerchantInboundBillingController.java`、`service/MerchantInboundBillingService.java`、`FbaPricingService.java`;查询与确认接口为 `/api/merchant/v1/first-leg/orders/{orderId}/inbound-billing`、`/confirm`,契约 `src/main/resources/openapi/firstleg-v1.yaml` |
| 价格簿查询内部切片 | `pricebookquery/application/port/in/PriceBookQueryUseCase.java`、`application/PriceBookQueryService.java`、`application/port/out/PriceBookQueryReader.java`、`adapter/out/persistence/PriceBookQueryMapper.java`;查询行模型位于 `application/model/PriceBookQueryRows.java` |
| 财务费用来源快照 | `controller/InternalFinanceFeeSourceController.java`;入库费用与柜次费用分别按来源 ID、版本向 Finance 提供不可变快照 |
| 柜次费用明细与跨客户分摊组 | `controller/PlatformContainerFeeDetailController.java`、`service/ContainerFeeDetailService.java`、`dto/ContainerFeeDetailDtos.java`;跨客户公共柜费使用 `allocation-groups` 按柜内真实客户和订单原子分摊,入仓 ID/入仓码同时填写或同时留空;各行金额合计必须等于原始柜费,审核、驳回、调整、作废均整组处理,单行入口拒绝分摊组;契约 `src/main/resources/openapi/container-fees-v1.yaml`;Admin 消费端见 `../../apps/admin-web/src/modules/loading-lists/api/containerFees.ts` |
| 财务整柜成本分摊快照 | `loading/controller/InternalFinanceCostSnapshotController.java`、`loading/service/FinanceCostSnapshotService.java` |
| 分柜、预装、真实柜号后补与正式装柜清单生命周期 | `split/controller/PlatformSplitPreloadController.java`、`controller/PlatformLoadingListController.java`、`service/LoadingListService.java`;契约 `src/main/resources/openapi/loading-lists-v1.yaml`,详情阶段先查 `/loading-plans/{planId}/lifecycle`。创建柜次时原子生成唯一预装计划,加入入仓码明细时同步创建入库预装聚合与关联,不设推送步骤;同一 `planId` 在 `EDITING/LOADING` 返回 `PRELOAD`,`COMPLETED` 返回 `LOADING_LIST`。订单协作备注以 Order 为权威,`OrderRemarksSyncService` 负责订单列表与全部未冻结预装明细双向同步,完成装柜后保留冻结快照。订舱关联、预装确认和 PDA task 创建不依赖真实集装箱号;可在 `EDITING/LOADING` 首次补录,最终 PDA 完成前必须存在;已有值纠正仅限 `EDITING` 且任务未领取、无物理作业事实 |
| 同柜不可拆组 | `loading/coload/controller/PlatformColoadGroupController.java`、`service/ColoadGroupService.java`、`persistence/ColoadGroupMapper.java`;契约 `src/main/resources/openapi/coload-groups-v1.yaml`。请求按订单 ID 建组,服务端允许待审核、待测量/测量中、待分柜及装柜中订单,冻结每票全部有效入仓片段与满箱数;成员全部未进柜时创建未锁定组,成员已满箱进入同一装柜计划时直接创建锁定组,部分占用或跨柜时拒绝;仅要求同国内仓、同渠道,允许跨客户及不同目的仓港;分配、移除、取消均保持整组原子 |
| 货柜与全局智能排柜 | `controller/PlatformContainerController.java`、`PlatformSmartLoadingController.java`、`service/SmartLoadingService.java`、`repository/SmartLoadingRepository.java`、`client/PendingReviewOrderClient.java`、`loading/`;全局候选合并本服务履约投影与 Order `/internal/order/v1/pending-review-orders` 权威待审核页,工作台柜次从 `/inbound-candidates`、`/containers` 进入;普通箱数、待审核占位、同柜组和全部未拣移除分别走 `/inbound-assignments`、`/pending-review-assignments`、`/group-assignments`、`/unpicked-items`,明细规格拆分留在服务端 |
| 货柜资料、PDA 证据与业务总览 | `controller/PlatformContainerController.java` 的 `/{id}/overview`、`/{id}/documents/{documentType}`,聚合 `mapper/ContainerOverviewMapper.java`;契约 `src/main/resources/openapi/phase3-container-overview-v1.yaml`,四类资料固定为目的港清关、国内出口报关、DO、PU 的当前唯一版本 |
| PDA 拣货与装柜、证据幂等上传 | `controller/PlatformLoadingPdaController.java`、`loading/controller/PlatformLoadingDeliveryController.java`、`loading/service/CabinetLoadingEvidenceService.java`;证据写入和 multipart 上传均先以任务内幂等键匹配请求哈希,上传哈希覆盖仓库、版本、证据类型、锁码、备注、文件名、媒体类型、大小与文件内容;完全重放返回当前视图且不得再次上传 File,相同键不同内容返回冲突 |
| 装柜完成直连事件 | `service/LoadingCompletedOutboxService.java`、`LoadingCompletedOutboxRelay.java`、`repository/LoadingCompletedOutboxRepository.java`、`mapper/LoadingCartonManifestMapper.java`、`loading/mapper/CabinetLoadingEvidenceMapper.java`、`client/LastlegEventClient.java`;当前发布 V3,从 `fl_inbound_order.destination_type` 冻结 `orders[].destinationType`(`FBA` 或 `OVERSEAS`),OVERSEAS 按最终实装与稳定箱码冻结,FBA 可仅冻结箱数,并冻结物理柜五类有效装柜照片引用;真实生产验收 `src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java`;事件模型见 `../common-event-contract/AGENTS.md` |
| 海外卸柜完成事件回写 | 内部入口 `controller/InternalOverseasContainerEventController.java`、消费者 `service/OverseasContainerEventConsumer.java`、Inbox `entity/ContainerEventInboxEntity.java` 与 `mapper/ContainerEventInboxMapper.java`;事件模型见 `../common-event-contract/AGENTS.md` |
| 海外暂扣状态回写 | 同一内部事件入口、消费者 `service/OverseasInboundOrderDetainedConsumer.java`;按来源入仓单 ID 更新 `fl_inbound_order.hold_flag/current_hold_reason`,复用跨区 Inbox 并写 `fl_inbound_hold_record` |
| 海外派送完成回写 | 同一内部事件入口、消费者 `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` |
| 已开船预约事项完整来源 | `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` 发布应付 |
| 整柜出口报关 | `customs/container/controller/PlatformContainerCustomsController.java`、`customs/container/controller/MerchantContainerCustomsController.java`、`customs/container/service/ContainerCustomsService.java`、`customs/container/mapper/ContainerCustomsMapper.java`;装柜完成后按柜收集退税资料,多对多覆盖入仓码,开始报关冻结,完成文件逐资料一对一回传,全买单柜由平台确认并通过导出中心取明细;正式申报批次与海关结果在同一模块独立记录,不改报关四态、PDA 或船期事实;契约 `src/main/resources/openapi/customs-v1.yaml`,基线 `src/main/resources/db/phase3-container-customs-schema.sql`;消费者为 Admin `../../apps/admin-web/src/modules/container-loading/components/ContainerCustomsPanel.vue` 和 Client `../../apps/client-web/src/modules/first-leg/components/CustomsDocumentsDialog.vue` |
| 买单订单明细导出 Provider | `customs/container/controller/LoadingListBuyDeclarationExportController.java`、`service/LoadingListBuyDeclarationExportService.java`;只为 File 导出中心提供 `LOADING_LIST_BUY_DECLARATION_ITEMS` 的已完成装柜实际买单行,不在 Firstleg 生成文件 |
| 跨模型桥接 | `loading/bridge/controller/PlatformLoadingModelBridgeController.java` |
| 跨服务客户端 | `client/OrderCargoClient.java`、`WarehousePalletLocationClient.java`、`MeasurementImageClient.java`、`LoadingEvidenceFileClient.java`、`LastlegEventClient.java` |
| 仓库公共供应商快照 | `client/WarehouseSupplierClient.java`、`service/WarehouseSupplierSnapshotService.java`;报关、订舱和拖车分别消费 `customs-v1.yaml`、`ocean-bookings-v1.yaml`、`truck-appointments-v1.yaml` 中的供应商 ID,并由 Warehouse 返回服务端名称快照 |
| SQL/OpenAPI | `src/main/resources/db/phase2-*.sql`、`src/main/resources/db/phase3-*.sql`、`src/main/resources/openapi/`;部署统一 `../deploy/sql/头程数据库统一增量-MySQL8.sql` |
| 测试/验证 | `src/test/java/com/baiyue/firstleg/`;`mvn -pl firstleg -am test` |
## 修改边界与最低验证
## 修改约束
- 平台接口核对目标客户、权限、版本、幂等、历史与审计;客户查询只使用认证客户。不得跨服务读库或在本模块复制 Order、Warehouse、Finance、File 的权威事实。
- Mapper 显式 `@Mapper`,只用启动类根包扫描;新增/移动 Mapper 或扫描配置变化时,从后端根执行 `node scripts/mapper-scan-contract.mjs`。
- Java 签名与行为变更分别遵循后端根的测试编译及直接受影响测试门禁;本模块测试从后端根执行 `mvn -pl firstleg -am test`。
- 移包或跨切片依赖变化时,从后端根执行 `node --test firstleg/scripts/analyze-firstleg-dependencies.test.mjs` 并核对内部模块化基线;禁止用基线更新掩盖临时循环依赖。
- 对外 API、数据库、公共签名、事件或权限变化按后端根对应门禁核对直接消费者,不为文档整理启动 Java、数据库、HTTP 或部署。
- 本文件或详细索引变更后,统一执行一次 `./scripts/agents-index-contract.ps1`;详细条款迁移不得丢失业务语义,不提高读取预算掩盖增长。
- 移动包、拆分端口/适配器或引入新的跨切片依赖时,先核对内部模块化基线并运行 `node --test scripts/analyze-firstleg-dependencies.test.mjs`;确需更新依赖基线时必须说明稳定边界变化,不能把临时循环依赖登记为长期豁免。
- 平台接口必须校验目标客户、权限、乐观锁、幂等键、历史和审计;商户查询只能使用登录态客户。
- 价格簿查询消费者依赖 `PriceBookQueryUseCase` 或稳定查询端口,不得反向依赖持久化 Mapper;移动旧查询类时同步更新生产调用、测试、Mock 与 import,避免同一职责在根 `repository/mapper` 和 `pricebookquery/` 双写。
- 第三方回传必须签名、时间窗、防重放、请求哈希,并绑定订单货物和 File 证据;不得信任外部客户字段。
- 稳定箱码映射以入库快照和 Order 货物明细为事实;订单审核创建入库单时必须幂等生成,打印接口只复用或补齐同一映射,用户是否下载箱唛不得决定箱码有效性。手工测量、墨家设备和 PDA 扫码统一通过 `InboundCartonLabelService` 校验客户、入库单、货物明细、总箱数/下标和有效批次,不得在测量时临时伪造箱码或接受跨客户、跨入库箱码。
- 一件代发以 `fl_inbound_carton_label_sku` 作为物理箱内不可变 SKU 快照;一个稳定 `box_code` 可以对应多条 SKU,PDA 扫箱后必须一次清点全部 SKU,不得将 SKU 塞入二维码或以 SKU 替代箱码。实际打托后的查询必须能按“现场托盘 -> 客户 -> SKU -> 箱数”聚合;同一国内作业仓的多票允许共用一个开放托盘,单票全部归托不自动封托,只有参与该票装箱的开放托盘阻止该票完成,预生成但未使用的空托盘不得阻塞;禁止按货名、箱规或箱号猜测 SKU。修改本链路时同步检查 `OrderCargoClient`、Order 内部货物接口及 PDA 打托实现。
- 测量审核通过后直接进入 `WAIT_SPLIT`,费用仅作为独立初算和人工维护结果,不得阻塞或回退订单主流程;计费量、单价和附加费调整必须版本化并由服务端重算金额。状态或 DTO 变更时逐个检查 Order/Warehouse/File/Integration 消费者。
- 客户箱规配置必须恰好保持四档且默认算法在四行一致;`ADD` 以实测尺寸为基准对三个维度分别增加命中箱规的尺寸值,`MULTIPLY` 使用当前客户唯一默认计费规则,并由服务端按规则中的规格档位选择系数。测量行必须保存实际尺寸、选用规则和算法快照,页面不得自行重算后覆盖服务端结果;计价使用用户确认的重量/体积选择值并纳入幂等请求哈希。
- 报关、订舱和拖车引用的供应商必须通过 `WarehouseSupplierSnapshotService` 按 ID 向 Warehouse 校验:报关使用 `CUSTOMS`,订舱使用 `BOOKING`(不区分国内/国外 scope),国内拖车使用 `TRUCKING + truckingScope=DOMESTIC`,清关提柜/目的港码头拖车使用 `TRUCKING + truckingScope=OVERSEAS`;供应商必须为 `ACTIVE`。请求中的名称只能作为一致性提示,持久化名称必须来自 Warehouse 快照,禁止用自由文本绕过 ID、类别或启用状态校验。修改任一供应商引用 DTO、OpenAPI 或内部客户端时,必须同步检查三条业务链、Warehouse `supplier-v1.yaml`、数据库快照字段和对应契约测试。
- Order 渠道变更门禁必须检查装柜或后续状态、PDA task、预装、分柜及同柜组兼容性;任何不确定结果均失败关闭。`ORDER_DECLARATION_UPDATED_V1` 消费必须校验订单号/入仓码归属,永久保留 `eventId + payload hash` 幂等历史并按版本单调更新申报投影;不得覆盖仓库实测、箱码、测量审核或已确认费用。
- 订舱状态固定为 `DRAFT → RELEASED`:草稿允许资料不完整并可替换或删除,放舱时校验完整持久化聚合,放舱后永久只读。创建草稿和放舱都只维护订舱聚合、三类参与方及柜型需求,不创建真实 `containerNo`、货柜行或分配关系;真实货柜只在后续提柜关联时占用已放舱柜型需求。
- 订舱柜型需求至少一项、最多 20 项,数量必须为正整数,容量和限重必须匹配平台柜型规格。起运仓必须是启用的国内中转仓;物流渠道必须来自该仓当前 ACTIVE 价格版本,服务端保存渠道名称快照,渠道失效时失败关闭。
- 订舱号统一使用 `OB-yyMMdd-####`,按北京时间创建日每日独立递增且单日最多 9999;不得以实体 ID 拼接生成。提单号在订舱主表全局唯一,柜次表只保存快照;真实集装箱号只在同一订舱内唯一,不同提单允许复用。放舱不要求真实柜号,也不得为满足放舱校验伪造柜号。
- 订舱列表 `containerCount` 固定为该订舱全部柜型需求 `quantity` 之和,与真实柜关联/解除无关;关联候选只返回 `RELEASED`、剩余数量大于 0 且 `warehouseId + channelCode + containerType` 精确匹配的需求,由服务端集合查询 ACTIVE 分配量。前端不得扇出详情计算或用已关联柜数覆盖该字段,最终关联命令仍须重验目标货柜可修改性。
- 拖车预约是平台级订舱聚合,不是客户或柜次资源:请求、实体、查询、响应和附件不得恢复 `customerId/customerName`,公开 DTO 也不得出现 `containerBookingId/loadingPlanId/cabinetNo/containerNo`。创建候选只返回有提单号、ACTIVE `BOOKING_CONFIRMATION` 或 `BILL_OF_LADING` 且没有非终态拖车单的 `RELEASED` 订舱;创建或编辑只冻结订舱仓库/提单与 TRUCKING/DOMESTIC 国内拖车供应商快照。每订舱通过查询锁与唯一约束最多一张非终态预约,重复创建返回现有派车单号;PDA 装柜完成不得改变拖车状态;只有订舱全部柜型需求均已关联且每柜全部入仓绑定确认国内离仓,才自动完成(含草稿/晚提交)。离仓、拖车创建/编辑/操作统一先锁订舱,完成状态、`AUTO_COMPLETE` 历史、按实装体积守恒分摊的柜次应付及 Finance Outbox 同事务落库;取消/异常单不自动恢复,已有有效或完成单不得重复创建。应付拖车费仅人民币 RMB(财务快照为 CNY),固定使用 `FEE_TYPE_108`,关联 ACTIVE/TRUCKING/DOMESTIC 供应商,零金额不生成费用;正金额缺归属、有效体积或费用配置时失败关闭,不丢费、不伪造均分。手工 DEPART/COMPLETE 动作已移除。附件使用 `tenantId=0 + TRUCK_APPOINTMENT_ATTACHMENT + appointmentId`,提单文件继续归订舱。
- 海外仓业务船节点任务从确认柜子离开国内仓库后启用,必须绑定真实订舱和柜次;十个标准节点、预计/实际时间、异常事实、供应商缺失节点和还柜停止规则统一以 `../../docs/海外仓业务/船节点标准化契约.md` 为准。`CarrierScheduleQuery` 的正常业务查询槽位固定为 `Asia/Shanghai` 每日 09:00、12:30、16:00;每分钟任务只扫描已到期 `next_allowed_at`,不是每分钟请求船司。成功、无变化、需人工或永久失败进入下一固定槽位;仅技术性 `RETRYABLE_FAILURE` 在当前槽内按15/30分钟退避,含首次最多3次且不得跨下一槽,实际 `EMPTY_RETURNED` 后停止。错过的正常槽位直接顺延,不在服务恢复时补跑;单轮按受控批次持续领取并受最大任务数保护。租约、调度尝试和内部停止入口不得暴露为平台页面 API,外部失败不得推进业务节点,不得在国内货柜履约另建查询任务。
- 国内出口报关在装柜完成后启用,不阻塞物理 `LOADED`。整柜 `fl_customs_container.status=COMPLETED` 才能推进“待发区”门禁;报关完成后以系统身份、确定性幂等键立即尝试确认待发区,其他物理/运输事实未齐时由受控定时补偿重试。待发区之后才推进离仓、进港和船期;甩柜/改配只在原柜船期上人工修改船信息并保留历史,不回写报关资料、回执或客户确认,禁止部分入仓成功或重复创建离仓/船司查询事实。
- 国内出仓确认以真实 `containerBookingId` 聚合全部入仓绑定并锁定校验;装柜证据按物理货柜只计算一次,PDA `taskId` 仅用于来源审计和任务授权,File 所有权仍固定 `tenantId=0 + containerBookingId`;拖车状态和独立船期只作参考。全部门禁在同一事务通过后推进所有技术行、记录一条可见历史并幂等创建 `CarrierScheduleQuery/DEPARTED_WAREHOUSE`,不允许单入仓局部成功。
- 手工船节点的预计时间只追加事实,不推进 `latestStandardNode`;携带 `actualAt` 时标准节点序号必须严格大于最新实际节点,重复、相同或回退节点返回冲突。已审核实际时间修正只能更新同一事实的时间/证据,不得制造第二条同节点实际事实或绕过单调推进。
- 海外卸柜完成事件只接受 `OVERSEAS -> CN` 的 `OVERSEAS_CONTAINER_UNLOADED` V1,并通过内部服务令牌进入。消费者必须校验信封、Payload 哈希、来源货柜和时间顺序,以 event ID + Payload 哈希幂等,使用事件聚合版本阻止乱序覆盖;预计报空时间固定按卸柜完成时间加 72 小时计算,实际报空时间可为空。
- Firstleg 只拥有业务费用及履约事实;Finance 拥有费用入池、账期、账单、收款、成本分摊结果和财务状态。修改费用来源版本、发布条件、柜次归属或体积快照时同步检查 `../finance/src/main/java/com/baiyue/finance/client/FirstlegFeeSourceClient.java`、同目录 `FirstlegCostSnapshotClient.java` 及双方契约测试,不在 Firstleg 复制财务状态机。
- `DIRECT_PAID` 仅适用于 `PAYABLE`,必须同时保存 `paidAt`、合法 `paymentMethod`,并至少提供 `paymentReferenceNo` 或一个当前客户范围内的 `FINANCE_VOUCHER`;`RECEIVABLE` 与 `ON_ACCOUNT` 不得残留付款字段。创建、调整和审核前均通过 `FeePaymentSnapshotValidator` 失败关闭复核。
## 国内货柜履约装柜与 PDA 资源结算
- 新增模拟柜只保存智能装柜的 9 类计划渠道 channel_group_code;未关联已放舱订舱时 channel_code/channel_name 为空,关联后才冻结订舱的实际渠道。智能装柜全局工作台和订舱关联候选均按计划渠道组匹配;服务端把业务渠道分组映射为实际渠道,并分别分页查询候选与 `WAIT_LOADING/EDITING/LOADING` 柜次;候选支持按创建日期和预计到货日期闭区间筛选,状态筛选使用最多四项的 `orderStatuses`,未传表示 `PENDING_REVIEW/WAIT_MEASURE/WAIT_SPLIT/LOADING` 全部未完成状态。状态只用于搜索和展示,能否加入以响应 `canAssign` 为准,由有效柜次占用、剩余箱数、容量、组锁和版本等事实决定;人工加入允许订单国内仓及渠道与柜次不同,智能推荐允许跨国内仓但仍只选择柜次渠道。容积和重量均允许达到柜型额定值的 150%,超过即拒绝。待审核事实必须经 `PendingReviewOrderClient` 重新核验 Order 权威 `SUBMITTED` 订单,待审核或待测量可先占位,但确认预装和启动 PDA 前仍必须进入 `WAIT_SPLIT`。计划明细通过 `totalPackageCount/remainingPackageCount` 返回可调总量和余量:测量前取 Order 原始申报箱数,进入 `WAIT_SPLIT/LOADING` 后取最新已审核测量箱数;只读补充失败时保守回退当前已分配数,调整写入仍重新查询并校验权威数量。同柜不可拆组只有全部成员命中状态集合时才返回,且必须成员完整、满箱并作为单个分页与分配单元,不能跨页或拆分。一次性移除未拣明细时,普通未拣项直接移除;同柜组仅全组未拣时整组移除,任一成员已拣则整组保留,单事务只递增一次计划版本。推荐顺序按入库时间先进先出。真实集装箱号允许在关联已放舱订舱时暂缺,但确认预装及创建 PDA 任务前必须补齐;历史已建但缺号的 PDA 任务可在预装详情首次补录,补录后按柜次当前真实集装箱号恢复作业。装柜计划状态为 `EDITING -> LOADING -> COMPLETED`,PDA 全部明细处理完成后自动完成。
- 预装进入 `CONFIRMING/CONFIRMED` 只固化确认快照,不得锁死尚未完成柜次的智能装柜分配;装柜计划仍处于 `EDITING/LOADING` 时可继续新增,未产生实物拣货的明细可调整或移除,已拣明细保持锁定。一件代发智能装柜候选以每个未被有效柜次占用的已封托托盘为独立分页、展示和分配单位;同一入仓单有多托时必须逐托返回,其中一托已分配不得隐藏或禁用其余未分配托盘。智能推荐必须逐个调用整托分配,容量不足时跳过整托,不得回退按箱分配;全局工作台传入当前 `customerId/inboundCode` 筛选范围时,服务端只能推荐同时命中的候选,同一次推荐按受影响客户统一重算费用,不得逐托重复重算。单行删除必须按该行 `palletId` 整托移除。装柜完成事件必须按计划项 `palletId` 精确冻结每托箱唛与 SKU 清单,不得仅按入仓单或货物明细关联而造成多托交叉重复。
- 客户入库费用只在登录客户范围、测量审核通过、`feeStatus=SENT` 且存在已发布明细时可见;确认必须具备 `merchant:order:edit`,携带当前 `expectedFeeVersion` 与幂等键。成功后记录确认成员和时间;客户未确认或该票尚未装柜完成时,平台仍可修改箱规规则、生成新费用版本并重新发送,新版本必须清空旧版客户确认快照。仅当“客户已确认且该票已装柜完成”同时成立时最终冻结;装柜完成不得代替客户自动确认。旧版本、未发送或重复异参确认均返回冲突,不做兼容覆盖。
- PDA 普通散箱拣货以 `fl_inbound_carton_label.box_code` 对应的稳定箱唛箱码为扫描身份;审核建单、重复打印、手工测量和墨家测量复用同一映射,版本字段只用于并发控制,不进入扫码文本。一个有效且未重复的箱码扫描表示拣出一箱,不得以 SKU、商品件数、入库单号或页面展示字段替代箱码。一件代发已封托的 `SEALED_PALLET` 是不可拆整托单元,PDA 可扫描计划快照中的 `palletNo`、用该托任一成员 `boxCode` 反查所属整托,也可在明细卡片手工确认整托;三种入口最终都必须携带权威 `palletNo` 以 `PICK_PALLET` 一次性提交,不能按成员箱逐箱累计、部分拣货或跳拣。PDA 首页从 Warehouse 主档选择启用国内仓,一件代发与装柜待办列表必须按所选 `warehouseId` 服务端过滤;装柜任务内部仍允许跨国内仓,仓库与渠道限制统一由上游装柜编排负责,PDA 只按已生成任务读取各入仓单自身的 `CONFIRMED` 库位资源,不得再用柜次仓库过滤任务明细、库存快照或拣货写入。任务下发与提交契约见 `src/main/resources/openapi/phase3-pda-convergence-v1.yaml`。
- PDA 部分拣货生成二级子入库单时,数量必须守恒:`已拣数量 + 子单数量 = 拣货前主单可用数量`。子单只允许一层,不能继续拆分。
- 首张称重图片确认时按 `max(已确认称重图片数, ceil(有效体积 / 默认托盘容量))` 尽力预留并确认整票库位块;有效体积先取入库单已落地的正数实测体积,否则对最新测量批次的正数 `totalVolumeCbm/originalVolumeCbm` 取较大值,再无有效值才查询 Order 最新客户申报体积,全部无效则拒绝。后续确认绑定最小空槽,所需槽数增长时通过 Warehouse `/blocks/expand` 原子追加,既有 `blockId/slotNo/reservationId/locationId/sharedOverflow` 不得变化。库位指引失败只记录警告,不阻断称重确认;测量提交后以最新已确认图片调用 `ensureForSubmission` 再次补齐和校验,失败同样不回滚已提交的测量主流程。Firstleg 必须显式保存 `weightResultId -> blockId/slotNo/reservationId/locationId/locationCode`,按 `confirmedAt + weightResultId` 稳定顺序绑定,禁止按列表下标推断;普通连续位不足的新增槽由 Warehouse 放入共享 A0,不阻断确认。
- 删除称重图片只解除图片和托盘快照绑定,将槽位恢复为 `RESERVED_EMPTY`;槽位和整票块继续归当前入仓码。解绑失败时不得调用文件删除,文件删除失败时本地解绑和重量结果删除必须回滚。
- 头程快照必须保存 Warehouse `blockId + slotNo + reservationId`;A0 可被多个整票块共享 `locationId`,业务资源身份始终使用 `reservationId`,不得把 `locationId` 当作唯一标识。
- PDA 部分拣货不得释放块内单槽;存在余量子单时通过 `/internal/warehouse/v1/pallet-locations/blocks/transfer` 将整段转移给子单,源单与余量子单可各自保留同一块槽及称重快照,因此 `fl_inbound_pallet_allocation` 的槽位与称重唯一键必须包含 `inbound_id`。整票全部拣完时只通过 `/internal/warehouse/v1/pallet-locations/blocks/release` 一次性释放全部普通位和 A0 预约,禁止恢复逐槽 `settlements/release-selected` 协议。
- 数据库字段变更直接维护当前模块基线与 `../deploy/sql/头程数据库统一增量-MySQL8.sql`;已有 `phase3-pda-release-allocation-migration.sql` 只作为当前结构来源,不为历史开发数据再新增兼容迁移。
## 全量验收导航
本服务覆盖测量计费、订单编排、装柜出仓及直接事件链。验收按受影响链路进入详细索引和正式业务域文档;PDA 设备证据与服务端合同分别核对,不以历史规划终点限制联调。仅作索引调整时运行离线门禁。
本模块承载订单履约、入库测量计费、订单编排、柜次、排柜、订舱、拖车、PDA 和 `LOADED` 门禁等全量链路;按本次验收范围读取对应业务域文档、Controller、DTO、OpenAPI、当前 SQL 定义和调用方,不以历史规划终点阻断联调。PDA 按 uni-app 在线更新安排开发与验收,不另设设备型号门禁。
-107
View File
@@ -1,107 +0,0 @@
# Firstleg 业务详细索引
本文件保存当前入口、业务边界与修改细则,作为 `../AGENTS.md` 的按需补充,不是历史归档。状态、权限、计价、资源或事件任务必须核对对应条款;不要求普通定位任务全文读取。
所有代码、SQL、脚本及 `../`、`../../` 路径均以 `firstleg/` 服务目录起算;业务包简写相对 `src/main/java/com/baiyue/firstleg/`,不相对于本文件所在的 `docs/`。从后端根用 `rg -n -F '类名或业务词' firstleg/docs/business-index.md` 读取命中行和相关规则段。
维护时只修改所属条款,主 AGENTS 表只维护路由;接口以服务端合同为准,架构及运行状态沿工作区根指定主契约核对。
## 模块入口
预约日历到港只读:`portschedule/controller/PlatformPortScheduleController.java` 的 `/arrival-calendar`,沿 `PortScheduleService`、`PortScheduleMapper` 查询有效柜次及实际到港节点;合同 `src/main/resources/openapi/arrival-calendar-v1.yaml`,权限 `platform:appointment:list`。不在日历维护第二套船节点。
表内 `controller/`、`service/`、`mapper/`、`loading/` 等业务包路径相对 `src/main/java/com/baiyue/firstleg/`;`src/`、`docs/`、`scripts/` 和 `../` 开头路径相对本服务目录。裸文件名沿同一条目最近业务包定位,必要时在该包用 `rg --files` 查找,不从全仓猜路径。
事件人工重试:`controller/EventOperationsController.java` 提供内部 `/internal/firstleg/event-operations/{entryId}` 查询及 `/retry` 写入,只允许装柜完成与船节点事件;独立 `EVENT_OPERATIONS_TOKEN` 保护。原事件不可修改,DEAD→PENDING 与 `event_delivery_retry_audit` 同事务提交,脚本见根 `scripts/event-outbox-operations.mjs`。
柜次号与散货托盘码:`service/FirstlegDocumentNumberGenerator.java` 为新柜生成全局连续且不按日期重置的 `D0001`、`D0002` 柜次号;`service/LoadingPalletLabelService.java`、`mapper/LoadingPalletLabelMapper.java`、`controller/PlatformLoadingListController.java` 负责散货托盘码,契约为 `src/main/resources/openapi/loading-lists-v1.yaml` 的 `/pallet-labels/prepare` 与 `/pallet-labels/print`。仅在计划 `LOADING/COMPLETED` 且真实柜号有效时,为非一件代发的 FBA 明细按平台柜次号、FBA 地址内部编号、三位序号生成冻结标签;托盘码不依赖可跨提单复用的真实柜号,内部编号从 Warehouse 启用主数据读取,缺失或同柜重复时拒绝生成。重复生成须核对已有标签与明细一致,冲突直接拒绝。全部或按仓指定张数打印统一调用 Print `/internal/print/v1/pdfs/fba-pallet-labels`,模板 `FBA_PALLET_A4_LANDSCAPE_V1` 每托一页 297×210 mm,顶部为 SCK8 等 FBA 仓码,条码和底部文字为完整托盘号,与海外补打共用版式;装柜完成通过 `LoadingCompletedOutboxService.java` 传递来源托盘。
| 目标 | 入口 |
|---|---|
| 内部模块化基线与依赖分析 | `docs/firstleg-internal-modularization-baseline.md`、`scripts/analyze-firstleg-dependencies.mjs`、`scripts/analyze-firstleg-dependencies.test.mjs`、`src/test/resources/architecture/firstleg-slice-dependencies.json`;只约束本服务内部包依赖,不改变部署单元或数据库边界 |
| 履约任务/分配 | `controller/PlatformFulfillmentAssignmentController.java`、`service/FulfillmentTaskService.java` |
| 客户工作台筛选范围 | `service/MerchantWorkbenchFilterService.java`、`mapper/MerchantWorkbenchFilterMapper.java`、`dto/MerchantWorkbenchFilters.java`;内部入口 `InternalInboundOrderController` 的 `/unified-orders/workbench-filters`,按客户只读计算履约、费用待确认与最新已装柜计划的报关待上传订单范围,Order 负责最终筛选和分页 |
| 入库单查询/聚合 | `controller/PlatformInboundOrderController.java`、`service/InboundOrderQueryService.java` |
| 入库单扣货、履约门禁与凭证 | `service/InboundHoldService.java`、`InboundHoldGuard.java`、`InboundHoldEvidenceService.java`、`client/InboundHoldFileClient.java`;凭证固定按客户、入库单和 `INBOUND_HOLD_EVIDENCE` 精确归属,只有进入不可变扣货历史的文件可下载 |
| Order 申报同步与渠道变更履约门禁 | `controller/InternalInboundOrderController.java` 的 `/order-channel-changes/validate`、`/order-declaration-updated`,`service/OrderDeclarationSyncService.java`、`mapper/OrderDeclarationSyncMapper.java`、`dto/OrderDeclarationSync.java`;契约 `src/main/resources/openapi/firstleg-v1.yaml`,只接受内部服务令牌 |
| 平台测量/OCR 与箱规计费快照 | `controller/PlatformInboundMeasurementController.java`、`service/InboundMeasurementService.java`、`service/CartonMeasurementService.java`、`service/CustomerDimensionWeightingService.java`;手工单箱测量保存实际尺寸、可选外箱修正、`chargeRuleId` 与 `coefficientMode`,计算结果以服务端返回值为准 |
| 箱唛箱码生成、打印与测量绑定 | 审核建单生成钩子 `controller/InternalInboundOrderController.java`;生成/解析 `service/InboundCartonLabelService.java`、`repository/InboundCartonLabelRepository.java`;平台与客户打印 `controller/PlatformInboundOrderController.java`、`MerchantInboundCartonLabelController.java`、`service/InboundCartonLabelPrintService.java`。一件代发箱唛以 `fl_inbound_carton_label` 表示物理箱,`fl_inbound_carton_label_sku` 冻结箱内一条或多条 SKU;生成时经 `client/OrderCargoClient.java` 获取。缺 SKU 时禁止生成;所有头程箱唛打印面均不显示 SKU,SKU 只保留在业务快照及一件代发 A4 托盘信息单;`box_code` 与二维码内容仍只携带稳定箱码。基线/升级 SQL 为 `src/main/resources/db/phase2-inbound-carton-label.sql`、`phase2-carton-label-versioning-migration.sql`、`../deploy/sql/头程数据库统一增量-MySQL8.sql` |
| 一件代发测量后 PDA 清点组托 | `controller/PlatformDropshipPalletizationController.java`、`service/DropshipPalletizationService.java`、`mapper/DropshipPalletizationMapper.java`;测量审核通过进入 `WAIT_PALLETIZE`,按预计托数预生成 `DP` 托盘码并下发独立任务,不创建国内库存;托盘信息单固定为每托一页 A4,包含托盘 Code 128 一维码、入仓码、总箱数与 SKU/商品名称/箱数/实点数量,不展示作业状态。PDA 逐箱清点,支持先扫箱或先扫托、归托、逐托封托和在未进入装柜计划前解除封托;合托解封时同步恢复所有参与任务及入仓单。预计托数与已生成托盘数不一致时禁止作业,全部箱和实际参与托盘完成后进入 `WAIT_SPLIT`。契约 `src/main/resources/openapi/dropship-palletization-v1.yaml` |
| 墨家设备测量上传 | `controller/OpenApiInboundMeasurementController.java`、`dto/MojiaMeasurementDtos.java`、`service/MojiaMeasurementOpenApiService.java`、`security/MeasurementRequestAuthenticator.java`;接口契约见 `src/main/resources/openapi/firstleg-v1.yaml` 与 `../../docs/墨家设备测量上传接口说明-V3.md` |
| 称重确认与整票库位块 | `service/MeasurementLocationOccupationService.java` 的 `ensureForConfirmedWeight/ensureForSubmission`、`InboundMeasurementService.java`、`MeasurementSubmissionService.java`、`client/WarehousePalletLocationClient.java`;Warehouse 块服务见 `../warehouse/src/main/java/com/baiyue/warehouse/service/LocationBlockService.java`,接口与上架引导见 `src/main/resources/openapi/firstleg-v1.yaml` |
| 审核与初始计价 | `controller/PlatformInboundMeasurementReviewController.java`、`service/MeasurementReviewService.java`、`service/InitialInboundPricingService.java`、`dto/MeasurementApprovalResponse.java` |
| 客户四档箱规与默认算法 | `controller/PlatformCustomerDimensionConfigController.java`、`service/CustomerDimensionConfigService.java`、`repository/CustomerDimensionConfigRepository.java`;固定 `SMALL/MEDIUM/LARGE/EXTRA_LARGE`,默认算法只允许 `ADD/MULTIPLY` |
| FBA 主数据/计费与客户规则 | `controller/PlatformFbaMasterDataController.java`、`PlatformFbaPricingController.java`、`service/FbaPricingService.java`、`repository/FbaPricingRepository.java`;客户规则可保存多个版本,但每个客户仅一条默认规则,计算请求可提交已确认的重量/体积选择值 |
| 客户专属价格簿、柜级阶梯运费与调价边界 | `controller/PlatformPriceBookController.java`、`service/PlatformPriceBookService.java`、`pricing/ActivePriceBookEstimator.java`、`service/FbaPricingService.java`、`pricing/ContainerTierAmountAllocator.java`;公共价格簿固定 `customerId=0`,客户专属版本按客户独立发布与替换,计价优先专属 ACTIVE、缺失才回退公共 ACTIVE。`REGION_WEIGHT_TIER` 只对同柜同客户中当前费用快照同为区域重量阶梯的有效成员聚合实际计费量命中一个阶梯,再按这些成员的计费量守恒分摊;同柜允许与 `FBA_WAREHOUSE_RATE` 等固定模型混装,固定模型成员不得参与阶梯聚合或被重算。装柜成员变化调用 `recalculateUnlockedContainerCustomerFees`,已确认/已发送或客户已确认的版本不得改写。人工调整以系统基准量为中心限制上下 10%(含边界),保存版本、原因和二次确认;一件代发 `WAIT_PALLETIZE` 可在预装确认前试算,`markInboundCalculated` 只更新费用并保留该托盘化状态 |
| 入库费用明细 | `controller/PlatformInboundFeeDetailController.java`、`service/InboundFeeDetailService.java`、`dto/InboundFeeDetailDtos.java`;`PRIVATE_LAST_MILE_DELIVERY` 是独立私人仓尾程派送费,只允许 `destinationType=PRIVATE`,按稳定费用代码校验而不按中文名判断;Admin 消费端见 `../../apps/admin-web/src/modules/unified-orders/api/inboundFeeDetails.ts` |
| 入库/柜次直接已付快照与凭证校验 | `service/FeePaymentSnapshotValidator.java`、`client/FeePaymentVoucherFileClient.java`;写入入口分别为 `InboundFeeDetailService.java`、`ContainerFeeDetailService.java`,File 业务类型固定 `FINANCE_VOUCHER` |
| 首页实时指标、客户运营统计/在途柜量 | 首页物理在途柜 TEU 与待清关票数从 `dashboard/PlatformDashboardMetricsController.java` 进入,契约见 `src/main/resources/openapi/dashboard-metrics-v1.yaml`;客户维度统计为 `customerstatistics/adapter/in/web/PlatformCustomerTransitStatisticsController.java`、`customerstatistics/application/CustomerTransitStatisticsService.java`,契约见 `src/main/resources/openapi/customer-statistics-v1.yaml` |
| 费用项目与价格簿 | `controller/PlatformFeeItemController.java`、`PlatformPriceBookController.java`、`MerchantPriceBookController.java` |
| 客户入库结果与头程费用确认 | `controller/MerchantInboundBillingController.java`、`service/MerchantInboundBillingService.java`、`FbaPricingService.java`;查询与确认接口为 `/api/merchant/v1/first-leg/orders/{orderId}/inbound-billing`、`/confirm`,契约 `src/main/resources/openapi/firstleg-v1.yaml` |
| 价格簿查询内部切片 | `pricebookquery/application/port/in/PriceBookQueryUseCase.java`、`application/PriceBookQueryService.java`、`application/port/out/PriceBookQueryReader.java`、`adapter/out/persistence/PriceBookQueryMapper.java`;查询行模型位于 `application/model/PriceBookQueryRows.java` |
| 财务费用来源快照 | `controller/InternalFinanceFeeSourceController.java`;入库费用与柜次费用分别按来源 ID、版本向 Finance 提供不可变快照 |
| 柜次费用明细与跨客户分摊组 | `controller/PlatformContainerFeeDetailController.java`、`service/ContainerFeeDetailService.java`、`dto/ContainerFeeDetailDtos.java`;跨客户公共柜费使用 `allocation-groups` 按柜内真实客户和订单原子分摊,入仓 ID/入仓码同时填写或同时留空;各行金额合计必须等于原始柜费,审核、驳回、调整、作废均整组处理,单行入口拒绝分摊组;契约 `src/main/resources/openapi/container-fees-v1.yaml`;Admin 消费端见 `../../apps/admin-web/src/modules/loading-lists/api/containerFees.ts` |
| 财务整柜成本分摊快照 | `loading/controller/InternalFinanceCostSnapshotController.java`、`loading/service/FinanceCostSnapshotService.java` |
| 分柜、预装、真实柜号后补与正式装柜清单生命周期 | `split/controller/PlatformSplitPreloadController.java`、`controller/PlatformLoadingListController.java`、`service/LoadingListService.java`;契约 `src/main/resources/openapi/loading-lists-v1.yaml`,详情阶段先查 `/loading-plans/{planId}/lifecycle`。创建柜次时原子生成唯一预装计划,加入入仓码明细时同步创建入库预装聚合与关联,不设推送步骤;同一 `planId` 在 `EDITING/LOADING` 返回 `PRELOAD`,`COMPLETED` 返回 `LOADING_LIST`。订单协作备注以 Order 为权威,`OrderRemarksSyncService` 负责订单列表与全部未冻结预装明细双向同步,完成装柜后保留冻结快照。订舱关联、预装确认和 PDA task 创建不依赖真实集装箱号;可在 `EDITING/LOADING` 首次补录,最终 PDA 完成前必须存在;已有值纠正仅限 `EDITING` 且任务未领取、无物理作业事实 |
| 同柜不可拆组 | `loading/coload/controller/PlatformColoadGroupController.java`、`service/ColoadGroupService.java`、`persistence/ColoadGroupMapper.java`;契约 `src/main/resources/openapi/coload-groups-v1.yaml`。请求按订单 ID 建组,服务端允许待审核、待测量/测量中、待分柜及装柜中订单,冻结每票全部有效入仓片段与满箱数;成员全部未进柜时创建未锁定组,成员已满箱进入同一装柜计划时直接创建锁定组,部分占用或跨柜时拒绝;仅要求同国内仓、同渠道,允许跨客户及不同目的仓港;分配、移除、取消均保持整组原子 |
| 货柜与全局智能排柜 | `controller/PlatformContainerController.java`、`PlatformSmartLoadingController.java`、`service/SmartLoadingService.java`、`repository/SmartLoadingRepository.java`、`client/PendingReviewOrderClient.java`、`loading/`;全局候选合并本服务履约投影与 Order `/internal/order/v1/pending-review-orders` 权威待审核页,工作台柜次从 `/inbound-candidates`、`/containers` 进入;普通箱数、待审核占位、同柜组和全部未拣移除分别走 `/inbound-assignments`、`/pending-review-assignments`、`/group-assignments`、`/unpicked-items`,明细规格拆分留在服务端 |
| 货柜资料、PDA 证据与业务总览 | `controller/PlatformContainerController.java` 的 `/{id}/overview`、`/{id}/documents/{documentType}`,聚合 `mapper/ContainerOverviewMapper.java`;契约 `src/main/resources/openapi/phase3-container-overview-v1.yaml`,四类资料固定为目的港清关、国内出口报关、DO、PU 的当前唯一版本 |
| PDA 拣货与装柜、证据幂等上传 | `controller/PlatformLoadingPdaController.java`、`loading/controller/PlatformLoadingDeliveryController.java`、`loading/service/CabinetLoadingEvidenceService.java`;证据写入和 multipart 上传均先以任务内幂等键匹配请求哈希,上传哈希覆盖仓库、版本、证据类型、锁码、备注、文件名、媒体类型、大小与文件内容;完全重放返回当前视图且不得再次上传 File,相同键不同内容返回冲突 |
| 装柜完成直连事件 | `service/LoadingCompletedOutboxService.java`、`LoadingCompletedOutboxRelay.java`、`repository/LoadingCompletedOutboxRepository.java`、`mapper/LoadingCartonManifestMapper.java`、`loading/mapper/CabinetLoadingEvidenceMapper.java`、`client/LastlegEventClient.java`;当前发布 V3,从 `fl_inbound_order.destination_type` 冻结 `orders[].destinationType`(`FBA` 或 `OVERSEAS`),OVERSEAS 按最终实装与稳定箱码冻结,FBA 可仅冻结箱数,并冻结物理柜五类有效装柜照片引用;真实生产验收 `src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java`;事件模型见 `../common-event-contract/AGENTS.md` |
| 海外卸柜完成事件回写 | 内部入口 `controller/InternalOverseasContainerEventController.java`、消费者 `service/OverseasContainerEventConsumer.java`、Inbox `entity/ContainerEventInboxEntity.java` 与 `mapper/ContainerEventInboxMapper.java`;事件模型见 `../common-event-contract/AGENTS.md` |
| 海外暂扣状态回写 | 同一内部事件入口、消费者 `service/OverseasInboundOrderDetainedConsumer.java`;按来源入仓单 ID 更新 `fl_inbound_order.hold_flag/current_hold_reason`,复用跨区 Inbox 并写 `fl_inbound_hold_record` |
| 海外派送完成回写 | 同一内部事件入口、消费者 `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` |
| 已开船预约事项完整来源 | `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` 发布应付 |
| 整柜出口报关 | `customs/container/controller/PlatformContainerCustomsController.java`、`customs/container/controller/MerchantContainerCustomsController.java`、`customs/container/service/ContainerCustomsService.java`、`customs/container/mapper/ContainerCustomsMapper.java`;装柜完成后按柜收集退税资料,多对多覆盖入仓码,开始报关冻结,完成文件逐资料一对一回传,全买单柜由平台确认并通过导出中心取明细;正式申报批次与海关结果在同一模块独立记录,不改报关四态、PDA 或船期事实;契约 `src/main/resources/openapi/customs-v1.yaml`,基线 `src/main/resources/db/phase3-container-customs-schema.sql`;消费者为 Admin `../../apps/admin-web/src/modules/container-loading/components/ContainerCustomsPanel.vue` 和 Client `../../apps/client-web/src/modules/first-leg/components/CustomsDocumentsDialog.vue` |
| 买单订单明细导出 Provider | `customs/container/controller/LoadingListBuyDeclarationExportController.java`、`service/LoadingListBuyDeclarationExportService.java`;只为 File 导出中心提供 `LOADING_LIST_BUY_DECLARATION_ITEMS` 的已完成装柜实际买单行,不在 Firstleg 生成文件 |
| 跨模型桥接 | `loading/bridge/controller/PlatformLoadingModelBridgeController.java` |
| 跨服务客户端 | `client/OrderCargoClient.java`、`WarehousePalletLocationClient.java`、`MeasurementImageClient.java`、`LoadingEvidenceFileClient.java`、`LastlegEventClient.java` |
| 仓库公共供应商快照 | `client/WarehouseSupplierClient.java`、`service/WarehouseSupplierSnapshotService.java`;报关、订舱和拖车分别消费 `customs-v1.yaml`、`ocean-bookings-v1.yaml`、`truck-appointments-v1.yaml` 中的供应商 ID,并由 Warehouse 返回服务端名称快照 |
| SQL/OpenAPI | `src/main/resources/db/phase2-*.sql`、`src/main/resources/db/phase3-*.sql`、`src/main/resources/openapi/`;部署统一 `../deploy/sql/头程数据库统一增量-MySQL8.sql` |
| 测试/验证 | `src/test/java/com/baiyue/firstleg/`;`mvn -pl firstleg -am test` |
## 修改约束
- 移动包、拆分端口/适配器或引入新的跨切片依赖时,先核对内部模块化基线并运行 `node --test scripts/analyze-firstleg-dependencies.test.mjs`;确需更新依赖基线时必须说明稳定边界变化,不能把临时循环依赖登记为长期豁免。
- 平台接口必须校验目标客户、权限、乐观锁、幂等键、历史和审计;商户查询只能使用登录态客户。
- 价格簿查询消费者依赖 `PriceBookQueryUseCase` 或稳定查询端口,不得反向依赖持久化 Mapper;移动旧查询类时同步更新生产调用、测试、Mock 与 import,避免同一职责在根 `repository/mapper` 和 `pricebookquery/` 双写。
- 第三方回传必须签名、时间窗、防重放、请求哈希,并绑定订单货物和 File 证据;不得信任外部客户字段。
- 稳定箱码映射以入库快照和 Order 货物明细为事实;订单审核创建入库单时必须幂等生成,打印接口只复用或补齐同一映射,用户是否下载箱唛不得决定箱码有效性。手工测量、墨家设备和 PDA 扫码统一通过 `InboundCartonLabelService` 校验客户、入库单、货物明细、总箱数/下标和有效批次,不得在测量时临时伪造箱码或接受跨客户、跨入库箱码。
- 一件代发以 `fl_inbound_carton_label_sku` 作为物理箱内不可变 SKU 快照;一个稳定 `box_code` 可以对应多条 SKU,PDA 扫箱后必须一次清点全部 SKU,不得将 SKU 塞入二维码或以 SKU 替代箱码。实际打托后的查询必须能按“现场托盘 -> 客户 -> SKU -> 箱数”聚合;同一国内作业仓的多票允许共用一个开放托盘,单票全部归托不自动封托,只有参与该票装箱的开放托盘阻止该票完成,预生成但未使用的空托盘不得阻塞;禁止按货名、箱规或箱号猜测 SKU。修改本链路时同步检查 `OrderCargoClient`、Order 内部货物接口及 PDA 打托实现。
- 测量审核通过后直接进入 `WAIT_SPLIT`,费用仅作为独立初算和人工维护结果,不得阻塞或回退订单主流程;计费量、单价和附加费调整必须版本化并由服务端重算金额。状态或 DTO 变更时逐个检查 Order/Warehouse/File/Integration 消费者。
- 客户箱规配置必须恰好保持四档且默认算法在四行一致;`ADD` 以实测尺寸为基准对三个维度分别增加命中箱规的尺寸值,`MULTIPLY` 使用当前客户唯一默认计费规则,并由服务端按规则中的规格档位选择系数。测量行必须保存实际尺寸、选用规则和算法快照,页面不得自行重算后覆盖服务端结果;计价使用用户确认的重量/体积选择值并纳入幂等请求哈希。
- 报关、订舱和拖车引用的供应商必须通过 `WarehouseSupplierSnapshotService` 按 ID 向 Warehouse 校验:报关使用 `CUSTOMS`,订舱使用 `BOOKING`(不区分国内/国外 scope),国内拖车使用 `TRUCKING + truckingScope=DOMESTIC`,清关提柜/目的港码头拖车使用 `TRUCKING + truckingScope=OVERSEAS`;供应商必须为 `ACTIVE`。请求中的名称只能作为一致性提示,持久化名称必须来自 Warehouse 快照,禁止用自由文本绕过 ID、类别或启用状态校验。修改任一供应商引用 DTO、OpenAPI 或内部客户端时,必须同步检查三条业务链、Warehouse `supplier-v1.yaml`、数据库快照字段和对应契约测试。
- Order 渠道变更门禁必须检查装柜或后续状态、PDA task、预装、分柜及同柜组兼容性;任何不确定结果均失败关闭。`ORDER_DECLARATION_UPDATED_V1` 消费必须校验订单号/入仓码归属,永久保留 `eventId + payload hash` 幂等历史并按版本单调更新申报投影;不得覆盖仓库实测、箱码、测量审核或已确认费用。
- 订舱状态固定为 `DRAFT → RELEASED`:草稿允许资料不完整并可替换或删除,放舱时校验完整持久化聚合,放舱后永久只读。创建草稿和放舱都只维护订舱聚合、三类参与方及柜型需求,不创建真实 `containerNo`、货柜行或分配关系;真实货柜只在后续提柜关联时占用已放舱柜型需求。
- 订舱柜型需求至少一项、最多 20 项,数量必须为正整数,容量和限重必须匹配平台柜型规格。起运仓必须是启用的国内中转仓;物流渠道必须来自该仓当前 ACTIVE 价格版本,服务端保存渠道名称快照,渠道失效时失败关闭。
- 订舱号统一使用 `OB-yyMMdd-####`,按北京时间创建日每日独立递增且单日最多 9999;不得以实体 ID 拼接生成。提单号在订舱主表全局唯一,柜次表只保存快照;真实集装箱号只在同一订舱内唯一,不同提单允许复用。放舱不要求真实柜号,也不得为满足放舱校验伪造柜号。
- 订舱列表 `containerCount` 固定为该订舱全部柜型需求 `quantity` 之和,与真实柜关联/解除无关;关联候选只返回 `RELEASED`、剩余数量大于 0 且 `warehouseId + channelCode + containerType` 精确匹配的需求,由服务端集合查询 ACTIVE 分配量。前端不得扇出详情计算或用已关联柜数覆盖该字段,最终关联命令仍须重验目标货柜可修改性。
- 拖车预约是平台级订舱聚合,不是客户或柜次资源:请求、实体、查询、响应和附件不得恢复 `customerId/customerName`,公开 DTO 也不得出现 `containerBookingId/loadingPlanId/cabinetNo/containerNo`。创建候选只返回有提单号、ACTIVE `BOOKING_CONFIRMATION` 或 `BILL_OF_LADING` 且没有非终态拖车单的 `RELEASED` 订舱;创建或编辑只冻结订舱仓库/提单与 TRUCKING/DOMESTIC 国内拖车供应商快照。每订舱通过查询锁与唯一约束最多一张非终态预约,重复创建返回现有派车单号;PDA 装柜完成不得改变拖车状态;只有订舱全部柜型需求均已关联且每柜全部入仓绑定确认国内离仓,才自动完成(含草稿/晚提交)。离仓、拖车创建/编辑/操作统一先锁订舱,完成状态、`AUTO_COMPLETE` 历史、按实装体积守恒分摊的柜次应付及 Finance Outbox 同事务落库;取消/异常单不自动恢复,已有有效或完成单不得重复创建。应付拖车费仅人民币 RMB(财务快照为 CNY),固定使用 `FEE_TYPE_108`,关联 ACTIVE/TRUCKING/DOMESTIC 供应商,零金额不生成费用;正金额缺归属、有效体积或费用配置时失败关闭,不丢费、不伪造均分。手工 DEPART/COMPLETE 动作已移除。附件使用 `tenantId=0 + TRUCK_APPOINTMENT_ATTACHMENT + appointmentId`,提单文件继续归订舱。
- 海外仓业务船节点任务从确认柜子离开国内仓库后启用,必须绑定真实订舱和柜次;十个标准节点、预计/实际时间、异常事实、供应商缺失节点和还柜停止规则统一以 `../../docs/海外仓业务/船节点标准化契约.md` 为准。`CarrierScheduleQuery` 的正常业务查询槽位固定为 `Asia/Shanghai` 每日 09:00、12:30、16:00;每分钟任务只扫描已到期 `next_allowed_at`,不是每分钟请求船司。成功、无变化、需人工或永久失败进入下一固定槽位;仅技术性 `RETRYABLE_FAILURE` 在当前槽内按15/30分钟退避,含首次最多3次且不得跨下一槽,实际 `EMPTY_RETURNED` 后停止。错过的正常槽位直接顺延,不在服务恢复时补跑;单轮按受控批次持续领取并受最大任务数保护。租约、调度尝试和内部停止入口不得暴露为平台页面 API,外部失败不得推进业务节点,不得在国内货柜履约另建查询任务。
- 国内出口报关在装柜完成后启用,不阻塞物理 `LOADED`。整柜 `fl_customs_container.status=COMPLETED` 才能推进“待发区”门禁;报关完成后以系统身份、确定性幂等键立即尝试确认待发区,其他物理/运输事实未齐时由受控定时补偿重试。待发区之后才推进离仓、进港和船期;甩柜/改配只在原柜船期上人工修改船信息并保留历史,不回写报关资料、回执或客户确认,禁止部分入仓成功或重复创建离仓/船司查询事实。
- 国内出仓确认以真实 `containerBookingId` 聚合全部入仓绑定并锁定校验;装柜证据按物理货柜只计算一次,PDA `taskId` 仅用于来源审计和任务授权,File 所有权仍固定 `tenantId=0 + containerBookingId`;拖车状态和独立船期只作参考。全部门禁在同一事务通过后推进所有技术行、记录一条可见历史并幂等创建 `CarrierScheduleQuery/DEPARTED_WAREHOUSE`,不允许单入仓局部成功。
- 手工船节点的预计时间只追加事实,不推进 `latestStandardNode`;携带 `actualAt` 时标准节点序号必须严格大于最新实际节点,重复、相同或回退节点返回冲突。已审核实际时间修正只能更新同一事实的时间/证据,不得制造第二条同节点实际事实或绕过单调推进。
- 海外卸柜完成事件只接受 `OVERSEAS -> CN` 的 `OVERSEAS_CONTAINER_UNLOADED` V1,并通过内部服务令牌进入。消费者必须校验信封、Payload 哈希、来源货柜和时间顺序,以 event ID + Payload 哈希幂等,使用事件聚合版本阻止乱序覆盖;预计报空时间固定按卸柜完成时间加 72 小时计算,实际报空时间可为空。
- Firstleg 只拥有业务费用及履约事实;Finance 拥有费用入池、账期、账单、收款、成本分摊结果和财务状态。修改费用来源版本、发布条件、柜次归属或体积快照时同步检查 `../finance/src/main/java/com/baiyue/finance/client/FirstlegFeeSourceClient.java`、同目录 `FirstlegCostSnapshotClient.java` 及双方契约测试,不在 Firstleg 复制财务状态机。
- `DIRECT_PAID` 仅适用于 `PAYABLE`,必须同时保存 `paidAt`、合法 `paymentMethod`,并至少提供 `paymentReferenceNo` 或一个当前客户范围内的 `FINANCE_VOUCHER`;`RECEIVABLE` 与 `ON_ACCOUNT` 不得残留付款字段。创建、调整和审核前均通过 `FeePaymentSnapshotValidator` 失败关闭复核。
## 国内货柜履约装柜与 PDA 资源结算
- 新增模拟柜只保存智能装柜的 9 类计划渠道 channel_group_code;未关联已放舱订舱时 channel_code/channel_name 为空,关联后才冻结订舱的实际渠道。智能装柜全局工作台和订舱关联候选均按计划渠道组匹配;服务端把业务渠道分组映射为实际渠道,并分别分页查询候选与 `WAIT_LOADING/EDITING/LOADING` 柜次;候选支持按创建日期和预计到货日期闭区间筛选,状态筛选使用最多四项的 `orderStatuses`,未传表示 `PENDING_REVIEW/WAIT_MEASURE/WAIT_SPLIT/LOADING` 全部未完成状态。状态只用于搜索和展示,能否加入以响应 `canAssign` 为准,由有效柜次占用、剩余箱数、容量、组锁和版本等事实决定;人工加入允许订单国内仓及渠道与柜次不同,智能推荐允许跨国内仓但仍只选择柜次渠道。容积和重量均允许达到柜型额定值的 150%,超过即拒绝。待审核事实必须经 `PendingReviewOrderClient` 重新核验 Order 权威 `SUBMITTED` 订单,待审核或待测量可先占位,但确认预装和启动 PDA 前仍必须进入 `WAIT_SPLIT`。计划明细通过 `totalPackageCount/remainingPackageCount` 返回可调总量和余量:测量前取 Order 原始申报箱数,进入 `WAIT_SPLIT/LOADING` 后取最新已审核测量箱数;只读补充失败时保守回退当前已分配数,调整写入仍重新查询并校验权威数量。同柜不可拆组只有全部成员命中状态集合时才返回,且必须成员完整、满箱并作为单个分页与分配单元,不能跨页或拆分。一次性移除未拣明细时,普通未拣项直接移除;同柜组仅全组未拣时整组移除,任一成员已拣则整组保留,单事务只递增一次计划版本。推荐顺序按入库时间先进先出。真实集装箱号允许在关联已放舱订舱时暂缺,但确认预装及创建 PDA 任务前必须补齐;历史已建但缺号的 PDA 任务可在预装详情首次补录,补录后按柜次当前真实集装箱号恢复作业。装柜计划状态为 `EDITING -> LOADING -> COMPLETED`,PDA 全部明细处理完成后自动完成。
- 预装进入 `CONFIRMING/CONFIRMED` 只固化确认快照,不得锁死尚未完成柜次的智能装柜分配;装柜计划仍处于 `EDITING/LOADING` 时可继续新增,未产生实物拣货的明细可调整或移除,已拣明细保持锁定。一件代发智能装柜候选以每个未被有效柜次占用的已封托托盘为独立分页、展示和分配单位;同一入仓单有多托时必须逐托返回,其中一托已分配不得隐藏或禁用其余未分配托盘。智能推荐必须逐个调用整托分配,容量不足时跳过整托,不得回退按箱分配;全局工作台传入当前 `customerId/inboundCode` 筛选范围时,服务端只能推荐同时命中的候选,同一次推荐按受影响客户统一重算费用,不得逐托重复重算。单行删除必须按该行 `palletId` 整托移除。装柜完成事件必须按计划项 `palletId` 精确冻结每托箱唛与 SKU 清单,不得仅按入仓单或货物明细关联而造成多托交叉重复。
- 客户入库费用只在登录客户范围、测量审核通过、`feeStatus=SENT` 且存在已发布明细时可见;确认必须具备 `merchant:order:edit`,携带当前 `expectedFeeVersion` 与幂等键。成功后记录确认成员和时间;客户未确认或该票尚未装柜完成时,平台仍可修改箱规规则、生成新费用版本并重新发送,新版本必须清空旧版客户确认快照。仅当“客户已确认且该票已装柜完成”同时成立时最终冻结;装柜完成不得代替客户自动确认。旧版本、未发送或重复异参确认均返回冲突,不做兼容覆盖。
- PDA 普通散箱拣货以 `fl_inbound_carton_label.box_code` 对应的稳定箱唛箱码为扫描身份;审核建单、重复打印、手工测量和墨家测量复用同一映射,版本字段只用于并发控制,不进入扫码文本。一个有效且未重复的箱码扫描表示拣出一箱,不得以 SKU、商品件数、入库单号或页面展示字段替代箱码。一件代发已封托的 `SEALED_PALLET` 是不可拆整托单元,PDA 可扫描计划快照中的 `palletNo`、用该托任一成员 `boxCode` 反查所属整托,也可在明细卡片手工确认整托;三种入口最终都必须携带权威 `palletNo` 以 `PICK_PALLET` 一次性提交,不能按成员箱逐箱累计、部分拣货或跳拣。PDA 首页从 Warehouse 主档选择启用国内仓,一件代发与装柜待办列表必须按所选 `warehouseId` 服务端过滤;装柜任务内部仍允许跨国内仓,仓库与渠道限制统一由上游装柜编排负责,PDA 只按已生成任务读取各入仓单自身的 `CONFIRMED` 库位资源,不得再用柜次仓库过滤任务明细、库存快照或拣货写入。任务下发与提交契约见 `src/main/resources/openapi/phase3-pda-convergence-v1.yaml`。
- PDA 部分拣货生成二级子入库单时,数量必须守恒:`已拣数量 + 子单数量 = 拣货前主单可用数量`。子单只允许一层,不能继续拆分。
- 首张称重图片确认时按 `max(已确认称重图片数, ceil(有效体积 / 默认托盘容量))` 尽力预留并确认整票库位块;有效体积先取入库单已落地的正数实测体积,否则对最新测量批次的正数 `totalVolumeCbm/originalVolumeCbm` 取较大值,再无有效值才查询 Order 最新客户申报体积,全部无效则拒绝。后续确认绑定最小空槽,所需槽数增长时通过 Warehouse `/blocks/expand` 原子追加,既有 `blockId/slotNo/reservationId/locationId/sharedOverflow` 不得变化。库位指引失败只记录警告,不阻断称重确认;测量提交后以最新已确认图片调用 `ensureForSubmission` 再次补齐和校验,失败同样不回滚已提交的测量主流程。Firstleg 必须显式保存 `weightResultId -> blockId/slotNo/reservationId/locationId/locationCode`,按 `confirmedAt + weightResultId` 稳定顺序绑定,禁止按列表下标推断;普通连续位不足的新增槽由 Warehouse 放入共享 A0,不阻断确认。
- 删除称重图片只解除图片和托盘快照绑定,将槽位恢复为 `RESERVED_EMPTY`;槽位和整票块继续归当前入仓码。解绑失败时不得调用文件删除,文件删除失败时本地解绑和重量结果删除必须回滚。
- 头程快照必须保存 Warehouse `blockId + slotNo + reservationId`;A0 可被多个整票块共享 `locationId`,业务资源身份始终使用 `reservationId`,不得把 `locationId` 当作唯一标识。
- PDA 部分拣货不得释放块内单槽;存在余量子单时通过 `/internal/warehouse/v1/pallet-locations/blocks/transfer` 将整段转移给子单,源单与余量子单可各自保留同一块槽及称重快照,因此 `fl_inbound_pallet_allocation` 的槽位与称重唯一键必须包含 `inbound_id`。整票全部拣完时只通过 `/internal/warehouse/v1/pallet-locations/blocks/release` 一次性释放全部普通位和 A0 预约,禁止恢复逐槽 `settlements/release-selected` 协议。
- 数据库字段变更直接维护当前模块基线与 `../deploy/sql/头程数据库统一增量-MySQL8.sql`;已有 `phase3-pda-release-allocation-migration.sql` 只作为当前结构来源,不为历史开发数据再新增兼容迁移。
@@ -35,28 +35,6 @@ public class PlatformDropshipPalletizationController {
@Resource private DropshipPalletizationService service;
@Resource private InboundCartonLabelPrintService printService;
/** 跨票识别箱码只读预览,清点仍按原任务领取并提交。 */
@GetMapping("/cartons/resolve")
@PreAuthorize("hasAuthority('platform:firstleg:pda:view')")
public ApiResponse<DropshipPalletizationDtos.ScanContext> resolveWorkspaceCarton(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam @Positive long warehouseId,
@RequestParam @jakarta.validation.constraints.NotBlank @Size(max = 80) String boxCode) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.resolveWorkspaceCarton(warehouseId, boxCode, user.userId()));
}
/** 获取当前实物托盘及客户组成,不要求托盘已与当前票绑定。 */
@GetMapping("/pallets/resolve")
@PreAuthorize("hasAuthority('platform:firstleg:pda:view')")
public ApiResponse<DropshipPalletizationDtos.PalletWorkspace> workspacePallet(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam @Positive long warehouseId,
@RequestParam @jakarta.validation.constraints.Pattern(regexp = "DP-[0-9]{6}-[0-9]{4}") String palletNo) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.workspacePallet(warehouseId, palletNo));
}
@GetMapping("/tasks/pending")
@PreAuthorize("hasAuthority('platform:firstleg:pda:view')")
public ApiResponse<List<DropshipPalletizationDtos.PendingTask>> pending(
@@ -18,15 +18,6 @@ public final class DropshipPalletizationDtos {
public record ClaimRequest(@Positive long warehouseId) {}
/** 跨票只读识别结果;确认清点前仍须领取对应任务。 */
public record ScanContext(Task task, Carton carton) {}
/** 当前实物托盘及逐客户、逐票组成。 */
public record PalletWorkspace(Pallet pallet, List<PalletMember> members) {}
public record PalletMember(Long taskId, Long inboundId, String inboundCode,
Long customerId, String customerName, Integer cartonCount) {}
public enum DiscrepancyAction { MATCH, ACCEPT_VARIANCE }
public record CountCartonRequest(@Positive long warehouseId,
@@ -4,9 +4,6 @@ import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Digits;
import java.math.BigDecimal;
/** 费用项目请求模型。 */
public final class FeeItemDtos {
@@ -24,9 +21,26 @@ public final class FeeItemDtos {
@NotNull Boolean warehousePayable,
@NotNull Boolean warehouseReceivable,
@NotNull Boolean allocationFee,
@Size(max = 500) String description,
@DecimalMin("0.00") @Digits(integer = 10, fraction = 2) BigDecimal defaultPrice
@Size(max = 500) String description
) {
public SaveRequest(String category, String tradeScope, String feeCode,
String feeTypeName, Boolean pickupPayable,
Boolean pickupReceivable, Boolean warehousePayable,
Boolean warehouseReceivable, Boolean allocationFee,
String description) {
this(category, tradeScope, feeCode, null, feeTypeName, pickupPayable,
pickupReceivable, warehousePayable, warehouseReceivable,
allocationFee, description);
}
/** Backward-compatible constructor for callers predating the stable fee code. */
public SaveRequest(String category, String tradeScope, String feeTypeName,
Boolean pickupPayable, Boolean pickupReceivable,
Boolean warehousePayable, Boolean warehouseReceivable,
Boolean allocationFee, String description) {
this(category, tradeScope, null, null, feeTypeName, pickupPayable, pickupReceivable,
warehousePayable, warehouseReceivable, allocationFee, description);
}
}
public record StatusRequest(
@@ -1,14 +1,11 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baiyue.common.core.model.BaseEntity;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
/** 费用项目主数据。 */
@Getter
@@ -23,9 +20,6 @@ public class FeeItemEntity extends BaseEntity {
private String feeCode;
private String supplierCategory;
private String feeTypeName;
/** 业务选费时带入的默认价格,可清空且不限制实际成交金额。 */
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private BigDecimal defaultPrice;
private Boolean pickupPayable;
private Boolean pickupReceivable;
private Boolean warehousePayable;
@@ -15,45 +15,6 @@ import java.util.List;
/** 一件代发逐箱清点、待绑托与封托事实。 */
@Mapper
public interface DropshipPalletizationMapper {
/** 在指定仓库按稳定箱码识别任务,多个匹配由业务层拒绝。 */
@Select("""
SELECT DISTINCT t.id FROM fl_dropship_pallet_task t
JOIN fl_inbound_order o ON o.id=t.inbound_id AND o.tenant_id=t.tenant_id AND o.deleted=0
JOIN fl_inbound_carton_label l ON l.inbound_id=t.inbound_id AND l.tenant_id=t.tenant_id
WHERE t.warehouse_id=#{warehouseId} AND l.box_code=#{boxCode} AND l.batch_status='ACTIVE'
AND o.fulfillment_type='ONE_CLICK_DROPSHIP'
""")
List<Long> workspaceTaskIds(@Param("warehouseId") long warehouseId,@Param("boxCode") String boxCode);
/** 扫码预览为只读查询,不能把未知或已封托托盘默认成开放。 */
@Select("SELECT * FROM fl_dropship_pallet WHERE warehouse_id=#{warehouseId} AND pallet_no=#{palletNo}")
PalletRow workspacePallet(@Param("warehouseId") long warehouseId,@Param("palletNo") String palletNo);
/** 按实际成员汇总客户与票,空的预生成托盘不虚构成员。 */
@Select("""
SELECT c.task_id,c.inbound_id,o.inbound_code,c.tenant_id customer_id,o.customer_name,COUNT(*) carton_count
FROM fl_dropship_pallet_carton c
JOIN fl_inbound_order o ON o.id=c.inbound_id AND o.tenant_id=c.tenant_id
WHERE c.pallet_id=#{palletId}
GROUP BY c.task_id,c.inbound_id,o.inbound_code,c.tenant_id,o.customer_name
ORDER BY o.inbound_code,c.task_id
""")
List<com.baiyue.firstleg.dto.DropshipPalletizationDtos.PalletMember> workspaceMembers(@Param("palletId") long palletId);
/** 混托成员必须与当前票同渠道、同目的仓,客户可以不同。 */
@Select("""
SELECT COUNT(*) FROM fl_dropship_pallet_carton c
JOIN fl_inbound_order o ON o.id=c.inbound_id AND o.tenant_id=c.tenant_id
JOIN fl_dropship_pallet_task t ON t.id=#{taskId}
JOIN fl_inbound_order target ON target.id=t.inbound_id AND target.tenant_id=t.tenant_id
WHERE c.pallet_id=#{palletId} AND (
NOT (o.channel_code <=> target.channel_code)
OR NOT (o.destination_type <=> target.destination_type)
OR NOT (o.destination_warehouse_id <=> target.destination_warehouse_id)
OR NOT (o.destination_code <=> target.destination_code))
""")
int workspaceIncompatibleMembers(@Param("taskId") long taskId,@Param("palletId") long palletId);
@Insert("""
INSERT IGNORE INTO fl_dropship_pallet_task
(id,tenant_id,inbound_id,warehouse_id,task_status,total_cartons,counted_cartons,allocated_cartons,discrepancy_cartons,version,created_at,updated_at)
@@ -1,39 +0,0 @@
package com.baiyue.firstleg.repository;
import com.baiyue.firstleg.dto.DropshipPalletizationDtos.PalletMember;
import com.baiyue.firstleg.mapper.DropshipPalletizationMapper;
import com.baiyue.firstleg.mapper.DropshipPalletizationMapper.PalletRow;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
import java.util.List;
/** 仓内跨票扫码与实物托盘组成查询,客户归属始终从箱唛事实取得。 */
@Repository
public class DropshipWorkspaceRepository {
@Resource private DropshipPalletizationMapper mapper;
/** 按仓库和稳定箱码查找作业任务,不接受客户端指定客户归属。 */
public List<Long> taskIdsForBox(long warehouseId, String boxCode) {
return mapper.workspaceTaskIds(warehouseId, boxCode);
}
/** 只读查询托盘,不因扫描托盘产生绑定或状态变化。 */
public PalletRow pallet(long warehouseId, String palletNo) {
return mapper.workspacePallet(warehouseId, palletNo);
}
/** 保留每票客户与任务身份,供混托核对和按原任务移箱。 */
public List<PalletMember> members(long palletId) {
return mapper.workspaceMembers(palletId);
}
/** 归托前锁定实物托盘,与封托互斥。 */
public PalletRow lockPallet(String palletNo) {
return mapper.palletByNo(palletNo);
}
/** 按原始入仓快照检查运输兼容性,不接受客户端声明。 */
public boolean compatible(long taskId, long palletId) {
return mapper.workspaceIncompatibleMembers(taskId, palletId) == 0;
}
}
@@ -21,34 +21,6 @@ import java.util.*;
public class DropshipPalletizationService {
@Resource private DropshipPalletizationMapper mapper;
@Resource private FirstlegDocumentNumberGenerator documentNumbers;
@Resource private com.baiyue.firstleg.repository.DropshipWorkspaceRepository workspaceRepository;
/** 只读识别其他客户的箱码;不自动领取、清点或绑定托盘。 */
public ScanContext resolveWorkspaceCarton(long warehouseId,String boxCode,long operatorId){
String code=normalize(boxCode);
if(code==null) throw invalid("箱码不能为空");
List<Long> ids=workspaceRepository.taskIdsForBox(warehouseId,code);
if(ids.isEmpty()) throw new BizException(ResultCode.NOT_FOUND,"当前仓库未找到该一件代发箱码");
if(ids.size()!=1) throw conflict("箱码匹配多个任务,请联系后台核对");
Task target=task(ids.get(0),warehouseId,operatorId);
Carton carton=resolveCarton(target.taskId(),warehouseId,code,operatorId);
// 未领取任务仅允许预览,员工确认后由正式领取与清点接口再次检查权限和状态。
if("PENDING".equals(target.taskStatus()) && Boolean.TRUE.equals(target.palletSetupReady())
&& "请先领取任务".equals(carton.blockReason())){
carton=new Carton(carton.cartonId(),carton.boxCode(),carton.inboundId(),carton.inboundCode(),
carton.skuLines(),carton.countStatus(),carton.palletId(),carton.palletNo(),true,null);
}
return new ScanContext(target,carton);
}
/** 按仓库返回真实托盘状态及全部成员,跨客户不改变每箱的原归属。 */
public PalletWorkspace workspacePallet(long warehouseId,String palletNo){
String code=normalize(palletNo);
if(code==null) throw invalid("托盘码不能为空");
PalletRow row=workspaceRepository.pallet(warehouseId,code);
if(row==null) throw new BizException(ResultCode.NOT_FOUND,"当前仓库未找到该托盘码");
return new PalletWorkspace(palletView(row,null),workspaceRepository.members(row.getId()));
}
@Transactional
public void createTask(InboundOrderEntity inbound,long operatorId,String operatorName) {
@@ -223,13 +195,11 @@ public class DropshipPalletizationService {
.map(p->palletView(p,task.getInboundCode())).toList();
}
/** 跨客户归托仍须同仓、同渠道、同目的仓,并锁定开放托盘。 */
private PalletRow requireAssignablePallet(TaskRow task,String palletNo){
PalletRow existing=workspaceRepository.lockPallet(palletNo);
PalletRow existing=mapper.palletByNo(palletNo);
if(existing==null) throw new BizException(ResultCode.NOT_FOUND,"托盘码不存在,请扫描测量审核后生成的 DP 托盘码");
if(!Objects.equals(existing.getWarehouseId(),task.getWarehouseId())) throw conflict("托盘码不属于当前作业仓库");
if(!"OPEN".equals(existing.getPalletStatus())) throw conflict("托盘已封托,不能继续绑定箱");
if(!workspaceRepository.compatible(task.getId(),existing.getId())) throw conflict("托内货物与当前票的渠道或目的仓不同,不能混托");
return existing;
}
@@ -101,7 +101,6 @@ public class FeeItemService {
}
}
/** 保存项目属性及可清空的默认价格,实际费用仍以业务录入金额为准。 */
private static void copy(FeeItemEntity value, SaveRequest request) {
boolean payable = Boolean.TRUE.equals(request.pickupPayable())
|| Boolean.TRUE.equals(request.warehousePayable());
@@ -112,7 +111,6 @@ public class FeeItemService {
value.setSupplierCategory(payable
? FeeItemSupplierCategory.normalize(request.supplierCategory()) : null);
value.setFeeTypeName(request.feeTypeName().trim());
value.setDefaultPrice(request.defaultPrice());
value.setPickupPayable(request.pickupPayable());
value.setPickupReceivable(request.pickupReceivable());
value.setWarehousePayable(request.warehousePayable());
@@ -9,7 +9,6 @@ CREATE TABLE IF NOT EXISTS fl_fee_item (
fee_code VARCHAR(50) NOT NULL COMMENT '稳定费用编码;平台公共主数据固定编码',
supplier_category VARCHAR(16) NULL COMMENT 'PAYABLE供应商类别约束;为空时可选任意ACTIVE供应商',
fee_type_name VARCHAR(128) NOT NULL COMMENT '费用类型名称',
default_price DECIMAL(12,2) NULL COMMENT '默认价格CNY;仅选费带入,实际费用可修改',
pickup_payable TINYINT(1) NOT NULL DEFAULT 0 COMMENT '提柜应付是否参与',
pickup_receivable TINYINT(1) NOT NULL DEFAULT 0 COMMENT '提柜应收是否参与',
warehouse_payable TINYINT(1) NOT NULL DEFAULT 0 COMMENT '仓储应付是否参与',
@@ -4,26 +4,6 @@ info:
version: 3.0.0
description: 测量环节录入预计托数,审核通过后预生成全部 DP 托盘码并可合并打印。PDA 支持先扫箱或先扫托,同一国内作业仓可跨票共用开放托盘,实物装满后再封托。
paths:
/api/platform/v1/first-leg/pda-dropship/cartons/resolve:
get:
summary: 同仓跨客户识别箱码并预览原任务
description: 只读,不领取或清点;返回 task 与 carton,确认时仍须领取原任务后按原 taskId 清点。拒绝其他作业员已领取任务及箱码歧义。
x-required-authority: platform:firstleg:pda:view
parameters:
- {$ref: '#/components/parameters/WarehouseId'}
- {name: boxCode, in: query, required: true, schema: {type: string, minLength: 1, maxLength: 80}}
responses:
'200': {description: 原任务和箱内 SKU 预览}
/api/platform/v1/first-leg/pda-dropship/pallets/resolve:
get:
summary: 查询当前仓库真实托盘状态与逐客户逐票组成
description: 扫描不产生归托;返回 pallet 与 members,成员保留 taskId、inboundId、inboundCode、customerId、customerName、cartonCount。不存在或跨仓的托盘拒绝,已封托返回真实状态。
x-required-authority: platform:firstleg:pda:view
parameters:
- {$ref: '#/components/parameters/WarehouseId'}
- {name: palletNo, in: query, required: true, schema: {type: string, pattern: 'DP-[0-9]{6}-[0-9]{4}'}}
responses:
'200': {description: 实物托盘状态和成员汇总}
/api/platform/v1/first-leg/pda-dropship/tasks/pending:
get:
summary: 查询待领取或本人已领取任务
@@ -102,7 +102,6 @@ components:
feeCode: {type: string, maxLength: 50, pattern: '^[A-Za-z0-9_-]+$'}
supplierCategory: {type: string, enum: [BOOKING, CUSTOMS, TRUCKING, CERTIFICATE], nullable: true, description: PAYABLE供应商类别约束;为空时可选任意ACTIVE Warehouse Supplier}
feeTypeName: {type: string, maxLength: 128}
defaultPrice: {type: number, nullable: true, minimum: 0, maximum: 9999999999.99, multipleOf: 0.01, description: 默认价格CNY;为空不自动带价,业务录入时可修改,不追改已有费用}
pickupPayable: {type: boolean}
pickupReceivable: {type: boolean}
warehousePayable: {type: boolean}
@@ -119,7 +118,6 @@ components:
feeCode: {type: string}
supplierCategory: {type: string, enum: [BOOKING, CUSTOMS, TRUCKING, CERTIFICATE], nullable: true, description: PAYABLE供应商类别约束;为空时可选任意ACTIVE Warehouse Supplier}
feeTypeName: {type: string}
defaultPrice: {type: number, nullable: true, minimum: 0, maximum: 9999999999.99, multipleOf: 0.01, description: 默认价格CNY;仅用于业务选择费用项目时带入}
pickupPayable: {type: boolean}
pickupReceivable: {type: boolean}
warehousePayable: {type: boolean}
@@ -96,10 +96,6 @@ class FeeManagementContractTest {
Map<String, Object> schemas = stringMap(stringMap(document.get("components")).get("schemas"));
assertThat(schemas).containsKeys("FeeItemSaveRequest", "FeeItemResource", "FeeItemPage",
"FeeItemApiResponse", "FeeItemPageApiResponse", "VoidApiResponse");
for (String schema : List.of("FeeItemSaveRequest", "FeeItemResource")) {
assertThat(stringMap(properties(schemas, schema).get("defaultPrice")))
.containsEntry("nullable", true).containsEntry("minimum", 0);
}
for (String response : List.of("FeeItemApiResponse", "FeeItemPageApiResponse", "VoidApiResponse")) {
assertThat(list(stringMap(schemas.get(response)).get("required"))).contains("timestamp");
assertThat(properties(schemas, response)).containsKeys(
@@ -24,56 +24,7 @@ class DropshipPalletizationServiceTest {
private final FirstlegDocumentNumberGenerator numbers=mock(FirstlegDocumentNumberGenerator.class);
private final DropshipPalletizationMapper mapper=mock(DropshipPalletizationMapper.class);
private final DropshipPalletizationService service=new DropshipPalletizationService();
private final com.baiyue.firstleg.repository.DropshipWorkspaceRepository workspace=mock(com.baiyue.firstleg.repository.DropshipWorkspaceRepository.class);
/** 使用正式仓储入口的等价锁定查询,保留既有扫码测试的事实准备。 */
DropshipPalletizationServiceTest(){ ReflectionTestUtils.setField(service,"mapper",mapper); ReflectionTestUtils.setField(service,"documentNumbers",numbers); ReflectionTestUtils.setField(service,"workspaceRepository",workspace); when(workspace.lockPallet(anyString())).thenAnswer(call -> mapper.palletByNo(call.getArgument(0))); when(workspace.compatible(anyLong(),anyLong())).thenReturn(true); when(mapper.labelSkus(anyLong(),anyLong())).thenAnswer(call -> java.util.List.of(labelSku())); when(mapper.cartonSkus(anyLong())).thenAnswer(call -> java.util.List.of(cartonSku())); }
/** 跨客户预览不领取任务,不写入清点事实。 */
@Test void workspaceResolvesPendingCustomerWithoutMutating(){
ReflectionTestUtils.setField(service,"workspaceRepository",workspace);
TaskRow other=task(); other.setTenantId(2L); other.setTaskStatus("PENDING"); other.setAssigneeId(null);
when(workspace.taskIdsForBox(100L,"BOX-1")).thenReturn(java.util.List.of(10L));
when(mapper.task(10L,100L)).thenReturn(other);
when(mapper.label(10L,"BOX-1")).thenReturn(label());
when(mapper.palletCountByInbound(2L,30L)).thenReturn(other.getExpectedPalletCount());
var result=service.resolveWorkspaceCarton(100L," BOX-1 ",7L);
assertThat(result.task().tenantId()).isEqualTo(2L);
assertThat(result.carton().canCount()).isTrue();
verify(mapper,never()).claim(anyLong(),anyLong(),anyLong(),anyString());
verify(mapper,never()).addCounted(anyLong(),anyLong(),anyInt(),anyInt());
}
/** 不允许从跨票扫码接管其他作业员的任务。 */
@Test void workspaceRejectsOtherOperatorsTaskAndAmbiguousBox(){
ReflectionTestUtils.setField(service,"workspaceRepository",workspace);
when(workspace.taskIdsForBox(100L,"BOX-1")).thenReturn(java.util.List.of(10L));
TaskRow other=task(); other.setAssigneeId(9L); when(mapper.task(10L,100L)).thenReturn(other);
assertThatThrownBy(()->service.resolveWorkspaceCarton(100L,"BOX-1",7L)).isInstanceOf(BizException.class).hasMessageContaining("其他人员");
when(workspace.taskIdsForBox(100L,"BOX-1")).thenReturn(java.util.List.of(10L,11L));
assertThatThrownBy(()->service.resolveWorkspaceCarton(100L,"BOX-1",7L)).isInstanceOf(BizException.class).hasMessageContaining("多个任务");
}
/** 托盘预览必须返回已封托的真实状态,跨仓不存在时拒绝。 */
@Test void workspacePalletNeverInventsOpenState(){
ReflectionTestUtils.setField(service,"workspaceRepository",workspace);
when(workspace.pallet(100L,"DP-260911-0001")).thenReturn(sealedPallet());
when(workspace.members(20L)).thenReturn(java.util.List.of());
when(mapper.cartons(20L)).thenReturn(java.util.List.of());
assertThat(service.workspacePallet(100L,"DP-260911-0001").pallet().status()).isEqualTo("SEALED");
assertThatThrownBy(()->service.workspacePallet(101L,"DP-260911-0001")).isInstanceOf(BizException.class).hasMessageContaining("未找到");
}
/** 即使不同客户允许混托,运输渠道或目的仓不同仍须拒绝。 */
@Test void incompatibleDestinationCannotBeMixed(){
when(mapper.task(10L,100L)).thenReturn(task());
when(mapper.palletCountByInbound(1L,30L)).thenReturn(2);
when(mapper.label(10L,"BOX-1")).thenReturn(label());
when(mapper.palletByNo("DP-260911-0001")).thenReturn(pallet());
when(workspace.compatible(10L,20L)).thenReturn(false);
assertThatThrownBy(()->service.countCarton(10L,new CountCartonRequest(100L,"BOX-1",java.util.List.of(new CountSkuLine(40L,"SKU-1",12,DiscrepancyAction.MATCH,null)),"DP-260911-0001","wrong-destination"),7L,"张三"))
.isInstanceOf(BizException.class).hasMessageContaining("渠道或目的仓不同");
verify(mapper,never()).addCounted(anyLong(),anyLong(),anyInt(),anyInt());
}
DropshipPalletizationServiceTest(){ ReflectionTestUtils.setField(service,"mapper",mapper); ReflectionTestUtils.setField(service,"documentNumbers",numbers); when(mapper.labelSkus(anyLong(),anyLong())).thenAnswer(call -> java.util.List.of(labelSku())); when(mapper.cartonSkus(anyLong())).thenAnswer(call -> java.util.List.of(cartonSku())); }
@Test void sharedPalletCountsDistinctSkuCodesWithoutMergingInboundDetails(){
when(mapper.inboundCode(1L,30L)).thenReturn("IN-1");
@@ -110,7 +110,6 @@ class FeeManagementServiceTest {
assertThat(created.getId()).isEqualTo(11L);
assertThat(created.getAllocationFee()).isTrue();
assertThat(created.getDefaultPrice()).isEqualByComparingTo("60.00");
assertThat(created.getWarehouseReceivable()).isTrue();
assertThat(created.getWarehousePayable()).isTrue();
assertThat(created.getSupplierCategory()).isNull();
@@ -123,7 +122,7 @@ class FeeManagementServiceTest {
@DisplayName("自定义费用编码统一转为大写并检查客户内唯一性")
void normalizesCustomFeeCode() {
FeeItemDtos.SaveRequest request = new FeeItemDtos.SaveRequest("仓储成本", "INTERNATIONAL",
"storage_fee", null, "仓库租赁费", false, false, true, true, true, "应付仓库租赁费", null);
"storage_fee", "仓库租赁费", false, false, true, true, true, "应付仓库租赁费");
FeeItemEntity created = feeItems.create(9L, "admin", request);
@@ -135,7 +134,7 @@ class FeeManagementServiceTest {
@DisplayName("并发创建相同费用编码返回业务冲突")
void rejectsConcurrentDuplicateFeeCode() {
FeeItemDtos.SaveRequest request = new FeeItemDtos.SaveRequest("仓储成本", "INTERNATIONAL",
"STORAGE_FEE", null, "仓库租赁费", false, false, true, true, true, null, null);
"STORAGE_FEE", "仓库租赁费", false, false, true, true, true, null);
doThrow(new DuplicateKeyException("duplicate fee code")).when(repository).insertItem(any());
assertThatThrownBy(() -> feeItems.create(9L, "admin", request))
@@ -157,38 +156,6 @@ class FeeManagementServiceTest {
verify(repository, never()).itemCodeExists("STORAGE_FEE", 11L);
}
/** 清空默认价只修改公共项目,不触碰已生成的费用。 */
@Test
void clearsDefaultPrice() {
FeeItemEntity existing = item(true, true);
existing.setDefaultPrice(new BigDecimal("60.00"));
when(repository.findItem(11L)).thenReturn(existing);
when(repository.updateItem(any())).thenReturn(1);
FeeItemDtos.SaveRequest request = new FeeItemDtos.SaveRequest("仓储成本", "INTERNATIONAL",
null, null, "仓库租赁费", false, false, true, true, true, null, null);
assertThat(feeItems.update(11L, 9L, "admin", request).getDefaultPrice()).isNull();
verify(repository).updateItem(existing);
}
/** 请求价格允许空值和零元,拒绝负数、过多小数和超出字段范围的金额。 */
@Test
void validatesDefaultPrice() {
try (var factory = jakarta.validation.Validation.buildDefaultValidatorFactory()) {
var validator = factory.getValidator();
for (String price : List.of("0", "60.25", "9999999999.99")) {
var request = new FeeItemDtos.SaveRequest("仓储成本", "DOMESTIC", null, null,
"操作费", false, true, false, false, false, null, new BigDecimal(price));
assertThat(validator.validate(request)).isEmpty();
}
for (String price : List.of("-1", "60.255", "10000000000")) {
var request = new FeeItemDtos.SaveRequest("仓储成本", "DOMESTIC", null, null,
"操作费", false, true, false, false, false, null, new BigDecimal(price));
assertThat(validator.validate(request)).isNotEmpty();
}
}
}
@Test
@DisplayName("任一客户引用公共费用项目后都禁止删除")
void rejectsDeletingItemBoundByAnyCustomer() {
@@ -511,10 +478,9 @@ class FeeManagementServiceTest {
eq(10001L), eq(21L), eq(31L), eq(1), any(LocalDateTime.class));
}
/** 构造带默认价格的公共费用项目。 */
private static FeeItemDtos.SaveRequest itemRequest() {
return new FeeItemDtos.SaveRequest("仓储成本", "INTERNATIONAL", null, null, "仓库租赁费",
false, false, true, true, true, "应付仓库租赁费", new BigDecimal("60.00"));
return new FeeItemDtos.SaveRequest("仓储成本", "INTERNATIONAL", "仓库租赁费",
false, false, true, true, true, "应付仓库租赁费");
}
private static InboundFeeDetailDtos.SaveRequest detailRequest(String direction) {
+69 -9
View File
@@ -2,7 +2,8 @@
## 数据库访问与中文注释约束
- 强制继承 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则;该节为分层、SQL 执行和方法/核心逻辑中文注释的唯一维护处。根规则已在上下文时不重复读取,本服务不另设例外。
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
## 职责与边界
@@ -84,7 +85,29 @@
## 海外仓全流程开发路由
本服务覆盖尾程入仓、库存、库内作业、排车/PDA、派送、异常、采购和车队。状态、数量、资金、资源或跨单联动变化前,须查 `docs/business-index.md` 本节条款;同柜/车/托不合并客户、仓库或来源,页面资格只取服务端结果。
- 车次成本:`fleet/trip/FleetTripCostService.java`、`fleet-trip-costs-v1.yaml`;按DEPART实测及外接实收分摊,不产生客户账单/付款。
- 报价/统计:`fleet/quote/`、`fleet/reporting/`,合同`fleet-quotes-v1.yaml`、`fleet-operations-v1.yaml`;实测凭证按车次归属,仅当前/历史版本引用可下载。
- 外接选价合同:`fleet-quote-candidates-v1.yaml`、`fleet-trips-v1.yaml`;来源快照与成交价分开,确认承运复验。
- 在途交接:`fleet/exception/`、`fleet-handovers-v1.yaml`;后台确认交接,旧资源分别结束任务,DEPART不重写。
- 车辆履约投影:`fleet/trip/PlatformVehicleFulfillmentController.java` → `VehicleFulfillmentService` / `VehicleFulfillmentMapper`,合同`fleet-vehicle-fulfillment-v1.yaml`。派送同时要求vehicle:list与delivery:list,更换历史要求vehicle:list与trip:list;客户与排车双键关联,不跨库。按不可变DEPART/ASSIGN关联车辆,首次分配不算更换,不按当前车牌猜历史。只读重复读事务分页;不返回内部ID或整份客户快照。
- 海外仓全流程连续覆盖尾程订单、入仓计划/PDA 入仓、换标、产品与箱唛快照、库存、转运、废弃、采购、本土卡车、本土散板、排车查询/智能预排、排车编辑/取消/发车、PDA 拣货与出库、一件代发、转派送、客户派送单、异常退回和客户回访。
- 服务端正式入口包括 `dispatch/controller/DispatchController.java`、`dispatch/pda/controller/DispatchPdaController.java`、`delivery/controller/DeliveryTransferController.java`、`delivery/controller/OutboundPdaController.java`、`delivery/controller/MerchantDeliveryController.java`、`exceptionorder/` 与 `customervisit/`;设备页面是否可用以海外 PDA 正式路由和能力开关为准,未由 PDA 承载的操作由 Admin/Client 完成。
- 平台一件代发出库候选由 `/outbound-orders/one-click-dropship/candidates` 统一返回已完成入库且仍有可用箱唛、SKU 汇总和库存仓位绑定的托盘明细;查询不强制先传 `customerId`,未传时跨客户展示供页面分页筛选,创建时必须从所选候选解析为唯一客户和唯一海外仓,禁止一张出库单跨客户或跨仓。创建后出库单按 `WAIT_PICK → PICKING → PICKED → OUTBOUNDED` 推进;海外 PDA 扫描托盘码时通过整托命令原子拣取该托盘在当前任务内全部剩余箱件和 SKU,任一库存明细失败须整托回滚,逐箱拣货入口继续保留。
- 排车主流程以工作区 `docs/海外仓业务/排车列表统一作业改造契约.md` 为正式入口:一张排车单代表一趟车,装车完成后、实际发车前由后台安排派送,按客户与目的仓拆分派送单。`DispatchPdaWorkflowService` 按公开 `status`、`stage`、`recordVersion` 和 `actions` 返回作业事实与资格,Admin/PDA 共用服务端规则;BOL 与签收资料归拆分后的派送单,费用按其来源去重,不以旧转派送阶段或前端推算代替资格校验。
- 排车主单允许同一起运仓的跨客户货物同车,每条来源保留客户、库存与费用归属;不再要求用户先建独立车次再关联多张客户排车。Fleet 的资源、司机证照、容量、历史与附加能力继续沿 `fleet/trip/` 定位;计划或资源变化对审核、实载确认的影响按正式契约及服务端结果核对,已拣货事实不能因改派或取消被直接抹除,实际发车后不得伪造资源释放。
- 实物装载按仓库托盘去重计量,客户货量与物理占位分别保存;重量/体积必须来自实际确认,不用计费材积重回填。装车前真实拣货须核平,后台不能补录拣货数量绕过;装车、安排派送和发车不重复执行已完成的库存扣减。来源、审核、实载与资源版本变化按正式契约重新校验,保留操作历史;双端使用同一版本与原请求键核对结果,任何失败均不得伪造完成事实。
- 车次结案只消费派送事实:实签须signedAt/signedPackages,实退须原来源入账流水/RETURN_COMPLETED;实签加本趟实退等于原实发且余量为零。拒收、待退回、取消不算完成,时间取最后实际终态。结案不改客户状态/费用、不释放占用;车/司机分别记录实际结束时间地点,车辆状态与释放同事务。过期证照不阻止旧任务结束、不恢复新任务资格;原键重放不影响后续车次。
- 已签收后另发起退货保留原运输的签收事实,后续退货不重开原车次、不被车次结案改写;未签收货物仍按实际退回完成核对。`DeliveryDocumentService.save` 首次保存签收资料触发完成时要求关联出库已OUTBOUNDED,未出库可以准备BOL/装车资料但不能提前完成派送。
- 排车执行与 `delivery/` Controller 一同注册;平台排车查询和智能预排入口分别沿 `lastleg.dispatch-query-api.enabled`、`lastleg.smart-dispatch-api.enabled` 配置定位,启用现状以配置和实际路由核对。平台查询按全局排车号及来源归属加载跨客户同车明细,客户接口保持本客户隔离;预排创建逐项校验同一起运仓及各来源客户,不能强制整张排车只含一个客户。草稿托盘锁仍按操作人与草稿标识持有,释放、失效及转正式锁由服务端原子处理。
- 仓库 ID、类型、状态、地址和能力唯一权威归国内 `warehouse-service`;海外库位、实物托盘和资源锁归 `warehouse-overseas-service/` 且只引用同一国内 `warehouseId`。Lastleg PDA 通过 Warehouse Overseas 内部资源入口校验该国内共享收货仓,不得要求海外数据库重复建仓;浏览器 API 统一经 `gateway/`。
- 外接预订以父车次行锁串行确认容量;DRAFT不占位,CONFIRMED占位,CANCELLED释放,创建/确认/取消保留请求键与内容摘要。报价与记录快照进入既有车次历史。重新核对自有货计划不得占用已确认外接容量,换小车须能容纳两者;同车只换司机不额外要求先重新核对容量。CONFIRMED和RECEIVED尚未绑定实际发运时阻止整车发车;通过真实来货实测、运费确认及来源核对后转为BOUND,解除未开工排车关联回RECEIVED。报价或登记来货不冒充库存/已出库;取消未执行预订仅释放承运容量,来货及已入库货物保留。
- 外接约定运费由`fleet/trip/FleetBookingFreight`按排车来源、绑定车次、目的地和完整实收量复核;`DeliveryTransferService`在原转派送事务内发布到对应目的地派送单的`OVERSEAS_DELIVERY_FEE`,通过既有费用Outbox进入Finance。预览只读且使用dispatch:list;确认原货物/报价摘要后写入,全部外接货禁止再填派送应收,额外装车/买约仍独立。明确零约定留操作事实不发布虚构零费用。首版确认承运币种限现行费用支持的CNY/USD,原头程费用覆盖按下条处理。
- 原运费覆盖由`delivery/service/DeliveryFeeCoverageService`与`DispatchFeeCargoMapper`按实际库存组成及本次已拣数量还原,核平每条排车箱数;不可用MIN展示入仓码替代整托全部来源。`FinanceFeeCoverageClient`批量只读Finance,平台转派送按费用ID/版本/hash和当前货物/目的地摘要复核,只有全部相关货物已覆盖的服务才免新增应收,供应商应付独立。客户接口禁止自行声明覆盖。确认快照保存到`ll_outbound_order.fee_coverage_json`,原费用不复制、不抵扣金额、不生成伪零费用;详情按本派送货物过滤只读快照,之后财务更正不自动改写旧发运。普通无引用请求保持原新增收费要求。
- 派送出库取消按dispatch→outbound加锁,核对排车未拣货/发车并先解除车次关联;取消出库不代替取消排车占用。来源订单按本次转派送历史的原状态恢复,显式清空出库/派送关联并增加版本,不统一写RECEIVED。只对本派送当前已审核有效的DELIVERY_FEE应收申请作废,其他费用保留核对,完整取消原因分段保存在派送操作记录。
- `financefee/state/OverseasFeeCancellation`从不可变费用Outbox批量读取voidStatus;请求/拒绝/未知结果保留ACTIVE,只有Finance匹配APPLIED/DUPLICATE后,在投递审计同事务中落VOIDED。撤销候选必须等待原发布成功,未满足前置的PENDING撤销在LIMIT前排除,避免堵住后续投递;过期PROCESSING仍按租约恢复。费用调整使用READ_COMMITTED并在来源行锁后检查未决撤销,禁止旧快照漏检。前端与API区分待财务确认、待财务处理和确认作废,不能以物流取消或HTTP请求发出认定账务已撤销。
## 数据库表索引与注释约束
@@ -96,16 +119,53 @@
## 事件建单规则
校验事件身份、客户/来源、版本/hash、Inbox 幂等与事务;装柜事件不自动完成入仓。修改前查 `docs/business-index.md` 本节条款,ACK/重试按统一通信契约。
- 只接受冻结契约支持的事件类型和版本;校验事件 ID、租户、区域、Payload 哈希及必要字段。
- 同一实际集装箱 `containerNo` 生成一个共享业务尾程单号;`payload.orders` 每项按 `sourceInboundId` 独立落一条雪花 ID `ll_order`,并保存对应明细和初始状态历史。整柜操作不使用 `cabinetNo`。
- `ONE_CLICK_DROPSHIP` 每个来源入仓单必须携带一个或多个不可变来源托盘快照;每托按 SKU 携带箱数、数量和箱唛。校验订单汇总一致、托盘号/SKU/箱唛唯一后,按托建立档案及 SKU 库存,并建立箱唛和流水。仓库实物托盘由 Warehouse API 分配,不得覆盖来源托盘快照。
- 其他订单类型建立入仓单维度的尾程订单与冻结明细,并保留头程推荐托数;尾程入仓计划至少按推荐托数生成计划托盘,PDA 再把箱级事实绑定到海外仓实物托盘。
- 整个事件消费在一个事务内完成;任意订单失败时不得留下部分订单。
- 相同事件重复投递返回既有结果,不重新生成单号或插入明细。来源入仓单唯一约束是最终防线。
- 装柜完成事件只把尾程订单建立为 `PENDING_ASSIGNMENT`。`FIRST_LEG` 在途库存使用不可变来源入仓码作为计划生成前的 `inbound_order_no` 展示快照;平台人工转换成功后,同一事务将订单推进为 `INBOUND_PLANNED`,并把对应库存余额的该快照收口为新 `planNo`。
- 内部接口先执行常量时间令牌校验;缺失或错误令牌不得进入消费事务。
- 通用 `/internal/lastleg/events` 只白名单接收装柜完成、实际船节点与资金结果事件;未知或IAM安全事件必须拒绝。
- PDA 入仓任务完成前必须先通过 `/unload-photos` 保存非空且不重复的卸柜照片文件 ID;照片上传时间由服务端记录,预计报空时间固定为照片上传时间加两天。完成入仓时,库存入账、计划/任务状态推进与 `OVERSEAS_CONTAINER_UNLOADED` V1 Outbox 必须处于同一事务;事件绑定来源头程货柜身份,不得从前端请求拼装或回查头程数据库。
- 海外 PDA 柜号签收只允许平台 `OVERSEAS_WAREHOUSE_PDA_OPERATOR` 使用 `lastleg:pda:operate`;国内头程 PDA 角色不得进入。PDA 不接收 `customerId`,候选按来源柜跨客户聚合,同柜允许多个客户;选柜会话、三类照片及整柜完成事实只绑定 `sourceContainerBookingId + containerNo`,文件与柜级跨区事件使用 `tenantId=0` 平台技术范围。签收后按每个入仓计划自身客户生成任务,扫码、SKU 实收、托盘和库存始终使用入仓码所属票的真实客户。未完成会话按平台操作账号和柜号分别全局唯一;平台管理员和客户账号不得进入该作业入口。
## 查询与租户约束
查询、详情、导出与写入保持一致客户隔离、访问域和绑定仓,不信任请求归属。采购、废弃、主/子状态变化前查 `docs/business-index.md` 本节条款。
- 平台查询范围由可信身份/平台权限决定;商户查询只能使用认证身份中的客户 ID。
- 尾程平台只读查询接口的 `customerId` 为可选筛选条件:平台身份省略时查询全部客户数据,传入时按指定客户过滤;商户身份忽略客户端传入值并始终按 Token 中的自身客户隔离。创建、编辑、审核等需要明确目标客户的写接口仍必须传入 `customerId`。
- 常规入仓库存余额以客户、海外仓、目的仓和入仓单为唯一业务维度并汇总箱数;SKU 继续保存在箱唛、实收和流水快照中,余额 SKU 仅作单 SKU 展示或以 `MIXED` 标识多 SKU,不参与当前唯一维度。
- 托盘是 PDA 出库的作业单位,但不是库存归属单位:`ll_inventory_position` 通过余额和托盘保存“托盘包含哪些入仓单、各多少箱”的组成明细。合托后的同一实物托盘允许对应多个入仓单,换标冻结和后续出库核算必须按这些明细分别记账;整托出库可扫描托盘一次完成,部分出库先拆托或按明细核平,不得用 FIFO 猜测来源入仓单。普通尾程入库只确认某入仓单在托盘上的箱数,没有逐箱唛到托盘的快照;贴标下单由客户填写我方箱唛,库存仓位只作为冻结记账锚点和现场大概位置,不能据此声称箱唛位于某个具体托盘。贴标任务以我方箱唛为主键,同箱多 SKU 只存为子明细且不重复冻结。
- 换标箱唛经现场确认后执行 `pallet-operations/relabel-disassemble`:允许仅移动该换标单对应的 `hold_packages`,不得解冻为普通可用库存;事务内同步更新 `ll_relabel_item.source_inventory_position_id`。若客户确认后已生成换标出库单,还必须在拣货开始前同步拆分或改写 `ll_outbound_pallet_item` 的托盘、箱唛、箱数与来源仓位快照,使 PDA 后续按新旧托盘分别扫描且每个指定箱唛只出库一次。
- `FIRST_LEG` 在途、计划转换与 PDA 入账统一使用尾程订单冻结的实际接柜海外仓 ID/编码/名称,禁止使用国内来源仓或 FBA 目的仓冒充;接柜仓变更必须在同一事务内修订尚未形成实物仓位的在途余额并写零差额审计流水。余额分页、汇总和 CSV 默认排除所有数量桶均为零的维度,跨仓入账清空的无仓位来源余额必须原子删除。
- 列表、详情、导出和更新必须使用同一租户边界,不能仅在列表过滤而让详情或更新越权。
- 目的仓变更必须写状态/操作历史或审计证据,并使用版本控制防止并发覆盖。
- 一件代发废弃:申请只锁箱 `AVAILABLE→DISCARD_PENDING`;平台批准(`lastleg:cargo-discard:approve`)只授权。绑定仓的海外 PDA(`lastleg:pda:operate`)实际确认时,同事务调用 discard adjustment,扣 SKU 件数及库存余额/位置,实际箱转 `DISCARDED` 并写唯一逐箱流水,数量须守恒;未废弃预留箱释放,失败整体回滚。按实际数量汇总费用,平台结案不再扣减。终态同完整载荷重放不新增流水/历史,异数量拒绝;驳回只释放锁箱。完整流程见 `../../docs/头程到尾程分阶段流程/阶段五-海外接柜库存与库内服务.md` 第9.5节。
- 海外采购样品履约按 `../../docs/海外仓业务/采购订单样品履约流程变更需求.md` 执行:通用编辑仅限内部备注,实收、装箱、面单、两段交寄和资金走独立节点。采购使用 Admin 履约台账,不接入 PDA、不写 WMS 可用库存。
- 派车状态只允许 PDA 拣货、完成和出库动作推进,普通派车编辑接口不得修改状态。PDA 写操作要求 `lastleg:pda:operate`;后台库存表单操作要求 `lastleg:inventory:manage`,盘点调整另要求 `lastleg:inventory:adjust`,转仓库存操作要求 `lastleg:warehouse-transfer:manage`。
- `ll_order.current_status` 只保存尾程主流程状态;换标、转仓、出库和派送等子单精确状态保存到各自单据并汇总到 `sub_order_status`,不得再用子单状态覆盖主流程状态。PDA 入仓及库存落账完成后,主流程状态固定推进为 `INBOUND_COMPLETED`。
## 修改与验证
- 后端根数据库分层、中文注释、API、Mapper、事件和权限门禁全部适用,SQL遵循本文件数据库约束。
- 换标/托盘/返工、客户状态、锁与并发行为变化前,须查 `docs/business-index.md` 本节及对应条款。
- 跨子域依赖变化核对 `docs/lastleg-internal-modularization-baseline.md`,从后端根运行 `node --test lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs`;不以基线掩盖循环依赖。
- 定向验证:从后端根执行 `mvn -pl lastleg-service -am test`;事件回归覆盖重复/来源唯一性、回滚、并发单号、内部身份及客户越权。
- 指引/索引变更统一运行 `./scripts/agents-index-contract.ps1`,不以离线检查代替部署/验收。
- `RelabelBindingGuard` 校验已保存组托历史与当前库存托盘一致,箱唛/混合换标正常完工、客户确认与排车提交均复验;纯 SKU 不要求移托,异常手动完工仍须在客户确认前补齐绑定。创建申请按库存余额串行检查箱唛既有申请,拒绝重复申请;历史扫码全局唯一约束尚未改为多轮复用。
- 已确认箱唛改绑复用 `PdaPalletOperationService.relabelDisassemble` 不带 `actualSource` 的精确来源分支,按现有绑定移动 HOLD 库存,不重新分配冻结锚点;成功同样记录 `PALLET_BIND` 以展示最新目标。`requireUnallocatedRelabel` 在入口与事务锁单后限制排车/出库占用,已创建出库单也不可直接改绑。回归 `PdaPalletOperationServiceTest`。
- PDA旧SKU单扫:`PdaRelabelService.scanSku` 和 `/relabel-tasks/{relabelNo}/skus/scan`,按箱itemId+skuIndex核对旧SKU,服务端取新SKU/数量,逐条记录现有 `SKU_SCAN` 历史,版本、幂等、认领和仓库校验不变;同箱全部SKU完成再推进SKU阶段,混合单需两阶段才生成一条箱事实。无新增字段;前端按历史当前轮次隐藏已完成子项,重复旧SKU须由操作员选实际箱。
- 数量拆托成功在现有 `ll_relabel_pda_history` 写 `PALLET_BIND`,保存本轮、箱唛和目标托盘;同轮已组托箱从候选排除并在事务内防重复,返工使用新的 reviewedAt 轮次。PDA 通过详情 history 展示组托记录;不需要新增库存表字段。
- 换标按数量拆合托:`palletops/PdaPalletOperationService.relabelSources` 返回原入仓库存池的托盘组成,不能视为箱唛定位;`relabel-disassemble` 的 `actualSource` 携带仓位版本及现场箱数,`cartonMarks` 为本批目标绑定确认。实际托盘与冻结锚点不同时,在同余额/批次内仅重新分配本单冻结桶后合托,库存余额及原入仓单归属不变,保留逐仓位流水、物理资源补偿及幂等。每次一条实际来源仓位,可分批多托;已生成出库单禁止此重分配。认领人处理中即可新增托盘,不需客户确认;此能力不等于已具备真机打印或整单强制组托门禁。
- 换标申请生成的 `RELABEL_APPLICATION` 尾程主单使用 `WAIT_RELABEL`(待换标),不进入普通入库计划池;子状态仍区分处理中/待客户确认。客户确认后统一为 `WAIT_OUTBOUND`(待出库),卡车分支按换标申请的 `WAIT_PRE_DISPATCH` 资格参与排车,不重复入库。存量修正脚本 `../scripts/db/relabel-wait-status-incremental.sql` 仅调整尚未生成入库单的遗留待分配换标主单。
- 混合换标 `CARTON_AND_SKU` 使用现有 PDA 历史保存 `MIXED_CARTON/MIXED_SKU` 分项事实(itemId + 退回轮次);同一项防重复,两项全部完成才写一条箱事实并增加整箱计数。详情返回 `cartonProcessedPackages/skuProcessedPackages`;不得将单项完成当作整箱完成,返工后旧轮次不计入当前分项进度。
- 换标复核闭环:`PdaRelabelService.complete` 直接提交待客户确认,`RelabelApplicationService.confirmCompletion` 原子完成 PDA 复核;平台 `RelabelApplicationController` 的 `/review-task`、`/rework` 按状态权限提供带版本的退回。退回备注必填,上一轮事实及照片引用写入 `REWORK_ARCHIVE` 历史,原事实标记 `REWORK_ARCHIVED` 并释放扫描唯一键,当前计数归零;客户已确认不可退回。PDA 不再自行批准复核。
- 移动包、拆分端口/适配器或引入新的跨子域依赖时,先核对内部模块化基线并运行 `node --test scripts/analyze-lastleg-dependencies.test.mjs`;确需更新依赖基线时必须说明稳定边界变化,不能把临时循环依赖登记为长期豁免。
- 表字段、注释和索引同步维护基线 SQL 与统一增量 SQL;生产/存量库只执行增量脚本。
- 事件契约变化同时检查 `common-event-contract`、直接生产者/消费者、Outbox/Inbox 和 DeliveryAck 的 eventId/payloadHash;按 `../../docs/统一认证与服务通信契约.md` 的逐事件表验证,不引入旧 Payload 兼容。
- 定向验证:从后端根执行 `mvn -pl lastleg-service -am test`。
- 事件链路变更至少覆盖重复事件、相同来源入仓单、事务回滚、并发单号、内部令牌和租户越权测试。
@@ -1,30 +0,0 @@
package com.baiyue.lastleg.delivery.repository;
import com.baiyue.lastleg.delivery.entity.OutboundOrder;
import com.baiyue.lastleg.delivery.mapper.OutboundPalletItemMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
import java.util.HashSet;
/** 出库查询按实物箱汇总,退货混装 SKU 不重复计箱。 */
@Repository
public class OutboundQuantityRepository {
@Resource private OutboundPalletItemMapper pallets;
/** 废弃退货来源依据已冻结箱唛计数,读取已有单据也不会把件数当箱数。 */
public Integer boxes(OutboundOrder outbound) {
if (!"DISCARD_OUTBOUND".equals(outbound.getOutboundType())) return outbound.getPackageCount();
var lines = pallets.findByOutbound(outbound.getCustomerId(), outbound.getId());
if (lines.isEmpty()) return outbound.getPackageCount();
var returnBoxes = new HashSet<String>();
int count = 0;
for (var line : lines) {
if (line.getReturnSkuQuantity() != null && line.getCartonMarks() != null && !line.getCartonMarks().isBlank()) {
returnBoxes.add(line.getInboundCode() + ":" + line.getCartonMarks());
} else {
count = Math.addExact(count, java.util.Objects.requireNonNullElse(line.getPackageCount(), 0));
}
}
return Math.addExact(count, returnBoxes.size());
}
}
@@ -1,26 +0,0 @@
package com.baiyue.lastleg.discard.model;
import com.baiyue.lastleg.discard.entity.CargoDiscardItem;
import java.util.HashSet;
import java.util.List;
/** 废弃单汇总使用物理箱数,退货明细的数量仍保留为 SKU 件数。 */
public final class CargoDiscardQuantities {
/** 数量规则不需要实例化。 */
private CargoDiscardQuantities() { }
/** 退货混装箱按箱 ID 去重,其余来源累加已选择的物理箱数。 */
public static int boxes(List<CargoDiscardItem> lines, boolean actual) {
var returnBoxes = new HashSet<Long>();
int total = 0;
for (var line : lines) {
int quantity = actual ? java.util.Objects.requireNonNullElse(line.getActualDiscardPackages(), 0) : line.getDiscardPackages();
if (line.getReturnCartonId() != null) {
if (quantity > 0) returnBoxes.add(line.getReturnCartonId());
} else {
total = Math.addExact(total, quantity);
}
}
return Math.addExact(total, returnBoxes.size());
}
}
@@ -19,9 +19,8 @@ public final class PdaCargoDiscardDtos {
List<@NotBlank String> scannedCartonMarks, Boolean confirmAllCartons) {
public Confirm(List<ActualLine> lines, List<Long> photoFileIds) { this(lines, photoFileIds, null, false); }
}
/** 明细数量按来源标识单位:退货库存为件,其余箱级库存为箱。 */
public record Sku(Long itemId, String skuCode, String productName, int plannedPackages,
Integer actualPackages, String status, String quantityUnit) { }
Integer actualPackages, String status) { }
public record PalletTask(String discardNo, String customerId, String overseasWarehouseId,
String palletNo, Long profileId, String status, List<Sku> skus, String outboundNo,
String scanMode, List<String> cartonMarks) { }
@@ -117,8 +117,8 @@ public class PdaCargoDiscardService {
.forEach(cartonId -> adjustments.finishReturnCarton(application.getCustomerId(), cartonId));
if (items.listApplication(application.getCustomerId(), application.getId()).stream()
.allMatch(i -> "COMPLETED".equals(i.getPdaStatus()))) {
int actualTotal = com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(
items.listApplication(application.getCustomerId(), application.getId()), true);
int actualTotal = items.listApplication(application.getCustomerId(), application.getId()).stream()
.mapToInt(i -> i.getActualDiscardPackages() == null ? 0 : i.getActualDiscardPackages()).sum();
application.setTotalPackages(actualTotal);
application.setTotalFee(application.getFeePerPackage().multiply(java.math.BigDecimal.valueOf(actualTotal)));
discardService.updateLinkedStatus(application.getCustomerId(), application, CargoDiscardService.APPROVED,
@@ -160,20 +160,16 @@ public class PdaCargoDiscardService {
}
private static final String LOOSE = "散箱";
/** 按实托汇总本单所选箱唛,退货件数与物理箱数分别呈现。 */
private List<PdaCargoDiscardDtos.PalletTask> palletTasks(CargoDiscardApplication application) {
var allItems = items.listApplication(application.getCustomerId(), application.getId());
var grouped = allItems.stream().collect(Collectors.groupingBy(i -> physicalCode(application, i), LinkedHashMap::new, Collectors.toList()));
return grouped.entrySet().stream().map(entry -> {
CargoDiscardItem first = entry.getValue().get(0);
var skus = entry.getValue().stream().map(i -> new PdaCargoDiscardDtos.Sku(i.getId(), i.getSkuCode(), i.getProductName(),
i.getDiscardPackages(), i.getActualDiscardPackages(), i.getPdaStatus() == null ? "PENDING" : i.getPdaStatus(),
isReturnItem(i) ? "件" : "箱")).toList();
i.getDiscardPackages(), i.getActualDiscardPackages(), i.getPdaStatus() == null ? "PENDING" : i.getPdaStatus())).toList();
String status = skus.stream().allMatch(i -> "COMPLETED".equals(i.status())) ? "COMPLETED" : "PENDING";
var marks = entry.getValue().stream().flatMap(i -> cartonMarks(application, i).stream()).distinct().sorted().toList();
// 退货箱只代表本单选择的箱子,不能因为有托盘号就当成整托废弃。
boolean cartonScan = LOOSE.equals(entry.getKey()) || entry.getValue().stream()
.anyMatch(i -> isReturnItem(i) || Boolean.TRUE.equals(i.getCartonScanRequired()));
boolean cartonScan = LOOSE.equals(entry.getKey()) || entry.getValue().stream().anyMatch(i -> Boolean.TRUE.equals(i.getCartonScanRequired()));
if (cartonScan && marks.isEmpty()) throw conflict("散箱废弃任务缺少箱唛");
return new PdaCargoDiscardDtos.PalletTask(application.getDiscardNo(), application.getCustomerId(), application.getOverseasWarehouseId(),
entry.getKey(), first.getInventoryPositionId() != null ? first.getInventoryPositionId() : isReturnItem(first) ? first.getReturnCartonId() : first.getProfileId(),
@@ -1,19 +0,0 @@
package com.baiyue.lastleg.discard.repository;
import com.baiyue.lastleg.discard.entity.CargoDiscardApplication;
import com.baiyue.lastleg.discard.mapper.CargoDiscardItemMapper;
import com.baiyue.lastleg.discard.model.CargoDiscardQuantities;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
/** 从申请明细读取物理箱汇总,不修改历史库存或件数。 */
@Repository
public class CargoDiscardQuantityRepository {
@Resource private CargoDiscardItemMapper items;
/** 列表和详情均按所选实物箱展示;空明细保留申请自身快照。 */
public int boxes(CargoDiscardApplication application) {
var lines = items.listApplication(application.getCustomerId(), application.getId());
return lines.isEmpty() ? application.getTotalPackages() : CargoDiscardQuantities.boxes(lines, false);
}
}
@@ -1,66 +0,0 @@
package com.baiyue.lastleg.discard.repository;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.discard.entity.CargoDiscardApplication;
import com.baiyue.lastleg.discard.entity.CargoDiscardItem;
import com.baiyue.lastleg.inventory.entity.InventoryLedger;
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper;
import com.baiyue.lastleg.inventory.mapper.InventoryLedgerMapper;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryBalanceMapper;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper;
import jakarta.annotation.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Repository;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDateTime;
/** 废弃实物扣减对应的退货余额和普通库存总账。 */
@Repository
public class CargoDiscardStockLedgerRepository {
@Resource private ReturnInventoryBalanceMapper returnBalances;
@Resource private ReturnInventoryCartonMapper returnCartons;
@Resource private InventoryBalanceMapper balances;
@Resource private InventoryLedgerMapper ledgers;
/** 箱内 SKU 扣减时同步扣汇总,只有该 SKU 在箱内耗尽才减一箱。 */
public void deductReturn(String customerId, CargoDiscardApplication application, CargoDiscardItem item,
long before, long quantity) {
var carton = returnCartons.lockId(item.getReturnCartonId());
if (carton == null || !customerId.equals(carton.getCustomerId())
|| !application.getOverseasWarehouseId().equals(carton.getOverseasWarehouseId())
|| !"DISCARD_PENDING".equals(carton.getStatus())) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "退货废弃箱归属或状态已变化");
}
long boxes = before == quantity && !"PARCEL".equals(carton.getSourceUnitType()) ? 1 : 0;
var balance = returnBalances.lockSku(customerId, carton.getOverseasWarehouseId(), item.getSkuCode());
if (balance == null || returnBalances.deductConversion(customerId, carton.getOverseasWarehouseId(),
item.getSkuCode(), quantity, boxes) != 1) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "退货库存汇总数量不足,不能完成废弃");
}
}
/** 一件代发已扣实物仓位后,按同事务余额快照追加库存总账。 */
public void recordDropship(String customerId, CargoDiscardApplication application, CargoDiscardItem item,
InventoryPosition position, long quantity, String operator, LocalDateTime at) {
var balance = balances.selectById(position.getBalanceId());
if (balance == null || !customerId.equals(balance.getCustomerId())) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "废弃库存余额不存在");
}
var fact = new InventoryLedger();
fact.setId(IdWorker.getId()); fact.setCustomerId(customerId); fact.setCustomerName(balance.getCustomerName());
fact.setBalanceId(balance.getId()); fact.setPositionId(position.getId());
fact.setOverseasWarehouseId(balance.getOverseasWarehouseId()); fact.setDestinationWarehouseCode(balance.getDestinationWarehouseCode());
fact.setInboundOrderId(balance.getInboundOrderId()); fact.setInboundCode(balance.getInboundCode());
fact.setInboundOrderNo(balance.getInboundOrderNo()); fact.setPalletId(position.getPalletId());
fact.setPalletNo(position.getPalletNo()); fact.setBatchNo(position.getBatchNo());
fact.setWarehouseLocationCode(position.getWarehouseLocationCode()); fact.setSkuCode(item.getSkuCode());
fact.setBusinessType("DISCARD"); fact.setBusinessOrderId(application.getId()); fact.setBusinessOrderNo(application.getDiscardNo());
fact.setIdempotencyKey("CARGO_DISCARD:" + application.getId() + ":" + item.getId() + ":STOCK");
fact.setStatusFrom("AVAILABLE"); fact.setStatusTo("DISCARDED");
fact.setBeforePackages(balance.getAvailablePackages() + quantity); fact.setDeltaPackages(-quantity);
fact.setAfterPackages(balance.getAvailablePackages()); fact.setOperatorId(operator);
fact.setRemark("货物废弃"); fact.setOccurredAt(at); fact.setCreatedAt(at);
if (ledgers.insert(fact) != 1) throw new IllegalStateException("废弃库存流水写入失败");
}
}
@@ -33,7 +33,6 @@ import java.util.Map;
/** Final inventory mutation for a warehouse-approved cargo discard. */
@Service
public class CargoDiscardInventoryAdjustmentService {
@Resource private com.baiyue.lastleg.discard.repository.CargoDiscardStockLedgerRepository stockLedger;
@Resource private CargoDiscardInventoryMapper inventory;
@Resource private CargoDiscardStockService stockService;
@Resource private DropshipCartonLedgerMapper cartonLedgers;
@@ -45,7 +44,6 @@ public class CargoDiscardInventoryAdjustmentService {
@Resource private ReturnInventoryCartonMapper returnCartons;
@Resource private ReturnInventoryCartonItemMapper returnCartonItems;
/** 实物废弃、来源库存扣减与流水在同一事务内完成。 */
@Transactional(propagation = Propagation.MANDATORY)
public AdjustmentResult discardPalletItem(String customerId, CargoDiscardApplication application,
CargoDiscardItem item, List<CargoDiscardCarton> links,
@@ -55,7 +53,7 @@ public class CargoDiscardInventoryAdjustmentService {
stockService.discard(customerId, operatorId, application, item, actualPackages);
return new AdjustmentResult(item.getDiscardPackages(), actualPackages, 0);
}
if (isReturnItem(item)) return discardReturnItem(customerId, application, item, actualPackages);
if (isReturnItem(item)) return discardReturnItem(customerId, item, actualPackages);
if (actualPackages < 0 || actualPackages > links.size()) throw conflict("实际废弃数量超出该托盘申请数量");
DropshipSkuInventory sku = inventory.lockSkuInventory(customerId, item.getSkuInventoryId());
if (sku == null) throw conflict("托盘 SKU 库存不存在: " + item.getSkuCode());
@@ -99,12 +97,10 @@ public class CargoDiscardInventoryAdjustmentService {
}
long after = value(inventory.skuPackages(customerId, sku.getId()));
if (after != before - actualPackages) throw conflict("托盘 SKU 库存扣减不守恒");
stockLedger.recordDropship(customerId, application, item, position, actualPackages, operatorId, occurredAt);
return new AdjustmentResult(before, actualPackages, after);
}
/** 退货箱按件扣减,同时同步库存余额;完成事实由明细的 PDA 完成快照提供。 */
private AdjustmentResult discardReturnItem(String customerId, CargoDiscardApplication application, CargoDiscardItem item, int actualPackages) {
private AdjustmentResult discardReturnItem(String customerId, CargoDiscardItem item, int actualPackages) {
if (actualPackages < 0 || actualPackages > item.getDiscardPackages()) {
throw conflict("实际废弃数量超出退货库存申请数量");
}
@@ -114,7 +110,6 @@ public class CargoDiscardInventoryAdjustmentService {
if (before < actualPackages || (actualPackages > 0 && returnCartonItems.deduct(sku.getId(), actualPackages) != 1)) {
throw conflict("退货库存数量不足");
}
if (actualPackages > 0) stockLedger.deductReturn(customerId, application, item, before, actualPackages);
return new AdjustmentResult(before, actualPackages, before - actualPackages);
}
@@ -39,7 +39,7 @@ public class CargoDiscardOutboundService {
return outbound == null ? null : outbound.getOutboundNo();
}
/** 审核事务持有申请锁时创建出库投影,汇总箱数与退货件数分开保存。 */
// The caller holds the application lock and owns the approval transaction.
public void create(CargoDiscardApplication application, String operatorId) {
if (outbounds.findByIdempotency(application.getCustomerId(), key(application)) != null) return;
var lines = items.listApplication(application.getCustomerId(), application.getId());
@@ -51,13 +51,12 @@ public class CargoDiscardOutboundService {
outbound.setOutboundNo(orderNoGenerator.next("OUTBOUND", application.getOverseasWarehouseId(), outbound.getId()));
outbound.setOverseasWarehouseId(application.getOverseasWarehouseId()); outbound.setOutboundType(TYPE);
outbound.setLxCodes(null); outbound.setRecipientInfo("废弃单 " + application.getDiscardNo());
outbound.setStatus("WAIT_PICK"); outbound.setPackageCount(com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(lines, false));
outbound.setStatus("WAIT_PICK"); outbound.setPackageCount(application.getTotalPackages());
outbound.setIdempotencyKey(key(application)); outbound.setRequestHash(key(application));
outbound.setVolumeWeightKg(BigDecimal.ZERO); outbound.setVolumeCbm(BigDecimal.ZERO);
outbound.setCreatedBy(operatorId); outbound.setCreatedAt(at); outbound.setUpdatedAt(at);
var snapshots = new java.util.ArrayList<OutboundPalletItem>();
var palletCodes = new java.util.HashSet<String>();
var returnBoxIds = new java.util.HashSet<Long>();
for (var line : lines) {
boolean returned = line.getReturnCartonId() != null || "RETURN_CARTON".equals(line.getSourceType());
String palletNo;
@@ -84,12 +83,11 @@ public class CargoDiscardOutboundService {
}
if (palletNo != null && !palletNo.isBlank()) palletCodes.add(palletNo);
OutboundPalletItem snapshot = new OutboundPalletItem();
// 出库明细与申请明细一一对应,仅作投影,不通过此表扣减库存。
// Stable one-to-one projection key in a separate table, never used to deduct stock.
snapshot.setId(line.getId()); snapshot.setCustomerId(application.getCustomerId()); snapshot.setOutboundOrderId(outbound.getId());
snapshot.setPalletNo(palletNo); snapshot.setSkuCode(line.getSkuCode()); snapshot.setInboundCode(line.getInboundCode());
snapshot.setOrderRefs(application.getDiscardNo()); snapshot.setCartonMarks(cartonMarks);
snapshot.setPackageCount(returned ? (returnBoxIds.add(line.getReturnCartonId()) ? 1 : 0) : line.getDiscardPackages());
snapshot.setPickedPackageCount(0); snapshot.setStatus("WAIT_PICK");
snapshot.setPackageCount(line.getDiscardPackages()); snapshot.setPickedPackageCount(0); snapshot.setStatus("WAIT_PICK");
if (returned) snapshot.setReturnSkuQuantity((long) line.getDiscardPackages());
snapshot.setVolumeWeightKg(BigDecimal.ZERO); snapshot.setVolumeCbm(BigDecimal.ZERO); snapshot.setCreatedAt(at);
snapshots.add(snapshot);
@@ -98,21 +96,15 @@ public class CargoDiscardOutboundService {
snapshots.forEach(pallets::insert);
}
/** 同步实物箱的拣货进度,退货 SKU 件数不进入箱数汇总。 */
public void sync(CargoDiscardApplication application) {
OutboundOrder outbound = outbounds.lockByIdempotency(application.getCustomerId(), key(application));
if (outbound == null) throw conflict("废弃出库单不存在,请联系平台核对审核结果");
var lines = items.listApplication(application.getCustomerId(), application.getId());
var returnBoxIds = new java.util.HashSet<Long>();
for (var snapshot : pallets.findByOutbound(application.getCustomerId(), outbound.getId())) {
var line = lines.stream().filter(i -> Objects.equals(i.getId(), snapshot.getId())).findFirst()
.orElseThrow(() -> conflict("废弃出库明细关联失效"));
boolean returned = line.getReturnCartonId() != null;
int boxes = returned ? (returnBoxIds.add(line.getReturnCartonId()) ? 1 : 0) : line.getDiscardPackages();
snapshot.setPackageCount(boxes);
if ("COMPLETED".equals(line.getPdaStatus())) {
snapshot.setPickedPackageCount(returned ? (Objects.requireNonNullElse(line.getActualDiscardPackages(), 0) > 0 ? boxes : 0)
: Objects.requireNonNullElse(line.getActualDiscardPackages(), 0));
snapshot.setPickedPackageCount(Objects.requireNonNullElse(line.getActualDiscardPackages(), 0));
snapshot.setStatus("PICKED");
}
if (CargoDiscardService.CANCELLED.equals(application.getStatus())) snapshot.setStatus("CANCELLED");
@@ -125,7 +117,7 @@ public class CargoDiscardOutboundService {
};
outbound.setStatus(status);
outbound.setLxCodes(null);
outbound.setPackageCount(com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(lines, "OUTBOUNDED".equals(status)));
if ("OUTBOUNDED".equals(status)) outbound.setPackageCount(application.getTotalPackages());
outbound.setUpdatedAt(LocalDateTime.now()); outbounds.updateById(outbound);
}
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
@@ -50,7 +50,6 @@ import java.util.Set;
@Service
public class CargoDiscardService {
@Resource private com.baiyue.lastleg.discard.repository.CargoDiscardQuantityRepository quantities;
@Resource private com.baiyue.lastleg.service.LastlegOrderNoGenerator orderNoGenerator;
public static final String APPROVAL_PERMISSION = "platform:overseas-warehouse:discard:approve";
public static final String PENDING_REVIEW = "PENDING_REVIEW";
@@ -139,7 +138,6 @@ public class CargoDiscardService {
supported ? null : "当前库存缺少完整箱唛,可按全部库存废弃");
}
/** 校验并预留所选库存,物理箱汇总与退货 SKU 件数分别保留。 */
@Transactional
public CargoDiscardResponses.Detail create(String customerId, String operatorId,
CargoDiscardRequests.Create request) {
@@ -200,8 +198,7 @@ public class CargoDiscardService {
} else if (!warehouseId.equals(itemWarehouseId)) {
throw conflict("One cargo discard application cannot span operating warehouses");
}
// 退货明细为件数,整箱只在全部 SKU 校验完成后按箱 ID 计一次。
if (!returnSource) total = Math.addExact(total, input.discardPackages());
total = Math.addExact(total, input.discardPackages());
}
for (Long cartonId : returnCartonCache.keySet()) {
@@ -212,7 +209,6 @@ public class CargoDiscardService {
.map(i -> i.skuCode().trim()).collect(java.util.stream.Collectors.toSet());
if (!availableSkus.equals(requestedSkus)) throw conflict("目前不支持部分废弃,必须选择退货箱内全部 SKU 库存");
}
total = Math.addExact(total, returnCartonCache.size());
LocalDateTime now = LocalDateTime.now();
if (!StringUtils.hasText(warehouseName)) warehouseName = warehouseNames().get(warehouseId);
CargoDiscardApplication application = new CargoDiscardApplication();
@@ -510,12 +506,10 @@ public class CargoDiscardService {
return names;
}
/** 废弃列表与详情按物理箱汇总,并按每箱单价计算应展示费用。 */
private CargoDiscardResponses.Summary summary(CargoDiscardApplication value, Map<String, String> names) {
int boxes = quantities.boxes(value);
return new CargoDiscardResponses.Summary(value.getId(), value.getCustomerId(), value.getDiscardNo(), value.getInboundCodes(),
value.getOverseasWarehouseId(), StringUtils.hasText(value.getOverseasWarehouseName()) ? value.getOverseasWarehouseName() : names.get(value.getOverseasWarehouseId()), boxes,
value.getFeePerPackage() == null ? value.getTotalFee() : value.getFeePerPackage().multiply(BigDecimal.valueOf(boxes)), value.getCurrency(), value.getStatus(), value.getCreatedBy(), value.getCreatedAt(),
value.getOverseasWarehouseId(), StringUtils.hasText(value.getOverseasWarehouseName()) ? value.getOverseasWarehouseName() : names.get(value.getOverseasWarehouseId()), value.getTotalPackages(),
value.getTotalFee(), value.getCurrency(), value.getStatus(), value.getCreatedBy(), value.getCreatedAt(),
value.getReviewedAt(), outboundService.outboundNo(value), value.getCustomerConfirmedAt());
}
@@ -369,7 +369,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
var row=lockPlatform(actor,number);var identity=identity(actor,number,Action.SUBMIT_REVIEW,request.recordVersion(),request.idempotencyKey(),request);
var original=commands.replay(actor,number,identity);if(original.isPresent())return original.get();
requireEditing(row,request.recordVersion());
if(!Set.of(ReviewState.DRAFT.name(),ReviewState.DISPATCH_REJECTED.name(),ReviewState.LOADING_REJECTED.name()).contains(row.getReviewState()))throw conflict("当前审核已提交,请刷新核对");
if(!canSubmitReview(row.getReviewState()))throw conflict("当前审核已提交,请刷新核对");
verifyReviewResources(row);
if(n(row.getPackageCount())<=0 || repository.cargo(row.getId()).isEmpty())throw bad("空排车不能提交审核");
long round=row.getReviewRound()+1;String basis=basis(row);
@@ -739,6 +739,12 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
if(!Status.ACTIVE.name().equals(row.getStatus()) || !Stage.BEFORE_LOADING.name().equals(row.getStage()) || n(row.getPickedPackageCount())>0 || n(row.getShippedPackageCount())>0) throw conflict("仅装车前且未扫码的有效排车可以调整货物");
}
/** 提交资格和写入共用可重提状态,已进入两级待审的整车不得再次显示提交入口。 */
private static boolean canSubmitReview(String reviewState) {
return ReviewState.DRAFT.name().equals(reviewState) || ReviewState.DISPATCH_REJECTED.name().equals(reviewState)
|| ReviewState.LOADING_REJECTED.name().equals(reviewState);
}
/** 动作快照只反映实际当前资格,写入仍须再次加锁校验。 */
Map<Action,Gate> actions(Actor actor,DispatchAggregate row) {
var gates=new EnumMap<Action,Gate>(Action.class); boolean active=Status.ACTIVE.name().equals(row.getStatus());
@@ -746,7 +752,8 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
for(var action:Action.values()) {
boolean state=switch(action) {
case CREATE -> actor.mode()==ActorMode.PLATFORM;
case EDIT,ADD_CARGO,REMOVE_CARGO,CANCEL,SUBMIT_REVIEW -> active && stage==Stage.BEFORE_LOADING && n(row.getPickedPackageCount())==0;
case EDIT,ADD_CARGO,REMOVE_CARGO,CANCEL -> active && stage==Stage.BEFORE_LOADING && n(row.getPickedPackageCount())==0;
case SUBMIT_REVIEW -> active && stage==Stage.BEFORE_LOADING && n(row.getPickedPackageCount())==0 && canSubmitReview(row.getReviewState());
case DISPATCH_REVIEW -> active && review==ReviewState.DISPATCH_PENDING;
case LOADING_REVIEW -> active && review==ReviewState.LOADING_PENDING;
case RETURN_TO_REVIEW -> active && stage==Stage.LOADING && n(row.getPickedPackageCount())==0;
@@ -26,10 +26,7 @@ public final class ReturnInventoryResponses {
int skuTypes, long totalQuantity, BigDecimal length, BigDecimal width, BigDecimal height,
BigDecimal weight, LocalDateTime receivedAt, List<CartonItem> items,
String locationCode, String palletCode, LocalDateTime putawayAt, List<MergeHistory> mergeHistory,
long relabelledQuantity, long relabelReservedQuantity, List<SkuConversion> skuConversions,
List<DiscardHistory> discardHistory) { }
public record DiscardHistory(String discardNo, String skuCode, String productNameZh, long beforeQuantity,
long quantity, long afterQuantity, String operatorId, LocalDateTime occurredAt) { }
long relabelledQuantity, long relabelReservedQuantity, List<SkuConversion> skuConversions) { }
public record SkuConversion(String relabelNo, String cartonMark, String oldSkuCode, String newSkuCode,
long quantity, String status, LocalDateTime completedAt) {
public static SkuConversion from(com.baiyue.lastleg.returnreceipt.entity.ReturnSkuConversion row) {
@@ -1,27 +0,0 @@
package com.baiyue.lastleg.returnreceipt.mapper;
import com.baiyue.lastleg.returnreceipt.dto.ReturnInventoryResponses;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
/** 从不可重复执行的 PDA 完成快照读取退货废弃事实,兼容已完成的历史单据。 */
@Mapper
public interface ReturnDiscardMovementMapper {
String FACTS = " FROM ll_cargo_discard_item i JOIN ll_cargo_discard_application a " +
"ON a.id=i.application_id AND a.customer_id=i.customer_id " +
"JOIN ll_return_inventory_carton c ON c.id=i.return_carton_id AND c.customer_id=i.customer_id " +
"AND c.overseas_warehouse_id=a.overseas_warehouse_id " +
"WHERE i.pda_status='COMPLETED' AND i.actual_discard_packages>0 ";
String MOVEMENTS = "SELECT i.customer_id customerId,a.overseas_warehouse_id overseasWarehouseId,i.sku_code skuCode,i.product_name productNameZh," +
"'DISCARD' movementType,a.discard_no referenceNo,-i.actual_discard_packages totalDelta,-i.actual_discard_packages availableDelta,i.pda_completed_at occurredAt" + FACTS;
/** 按客户与实物箱读取每次废弃的件数、操作者和操作时间。 */
@Select("SELECT a.discard_no discardNo,i.sku_code skuCode,i.product_name productNameZh," +
"i.available_packages_snapshot beforeQuantity,i.actual_discard_packages quantity," +
"i.available_packages_snapshot-i.actual_discard_packages afterQuantity," +
"i.pda_operator_id operatorId,i.pda_completed_at occurredAt" + FACTS +
"AND i.customer_id=#{customerId} AND i.return_carton_id=#{cartonId} ORDER BY i.pda_completed_at DESC,i.id DESC")
List<ReturnInventoryResponses.DiscardHistory> cartonHistory(@Param("customerId") String customerId, @Param("cartonId") long cartonId);
}
@@ -22,8 +22,7 @@ public interface ReturnInventoryMovementMapper extends com.baomidou.mybatisplus.
" UNION ALL SELECT customer_id,overseas_warehouse_id,old_sku_code,product_name_zh,'SKU_CONVERSION_OUT',relabel_no,-quantity,-quantity,completed_at FROM ll_return_sku_conversion" +
" UNION ALL SELECT customer_id,overseas_warehouse_id,new_sku_code,product_name_zh,'SKU_CONVERSION_IN',relabel_no,quantity,quantity,completed_at FROM ll_return_sku_conversion" +
" UNION ALL SELECT customer_id,overseas_warehouse_id,old_sku_code,product_name_zh,'SKU_CONVERSION_REVERSE_IN',relabel_no,quantity,quantity,reversed_at FROM ll_return_sku_conversion WHERE status='REVERSED'" +
" UNION ALL SELECT customer_id,overseas_warehouse_id,new_sku_code,product_name_zh,'SKU_CONVERSION_REVERSE_OUT',relabel_no,-quantity,-quantity,reversed_at FROM ll_return_sku_conversion WHERE status='REVERSED'" +
" UNION ALL " + ReturnDiscardMovementMapper.MOVEMENTS;
" UNION ALL SELECT customer_id,overseas_warehouse_id,new_sku_code,product_name_zh,'SKU_CONVERSION_REVERSE_OUT',relabel_no,-quantity,-quantity,reversed_at FROM ll_return_sku_conversion WHERE status='REVERSED'";
@Select("<script>SELECT COUNT(*) FROM (" + MOVEMENTS + ") m WHERE 1=1 " +
"<if test='customerId != null and customerId != \"\"'>AND m.customerId=#{customerId} </if>" +
@@ -1,18 +0,0 @@
package com.baiyue.lastleg.returnreceipt.repository;
import com.baiyue.lastleg.returnreceipt.dto.ReturnInventoryResponses;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnDiscardMovementMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
import java.util.List;
/** 退货箱废弃历史的持久化查询入口。 */
@Repository
public class ReturnDiscardMovementRepository {
@Resource private ReturnDiscardMovementMapper movements;
/** 仅返回指定客户实物箱已经完成的废弃事实。 */
public List<ReturnInventoryResponses.DiscardHistory> cartonHistory(String customerId, long cartonId) {
return movements.cartonHistory(customerId, cartonId);
}
}
@@ -63,7 +63,6 @@ public class ReturnInventoryService {
@Resource private ReturnInventoryOperationMapper operations;
@Resource private ReturnInventoryQueryMapper queries;
@Resource private ReturnInventoryMovementMapper movements;
@Resource private com.baiyue.lastleg.returnreceipt.repository.ReturnDiscardMovementRepository discardMovements;
@Resource private LastlegOrderNoGenerator numbers;
@Resource private com.baiyue.lastleg.returnreceipt.mapper.ReturnOrderMapper orders;
@Resource private com.baiyue.lastleg.returnreceipt.mapper.ReturnOrderItemMapper orderItems;
@@ -1243,7 +1242,6 @@ public class ReturnInventoryService {
return "PENDING".equals(carton.getCountStatus()) && AVAILABLE.equals(carton.getStatus()) ? "PENDING_COUNT" : carton.getStatus();
}
/** 组装箱内余量及合箱、换标、废弃操作历史。 */
private ReturnInventoryResponses.CartonDetail cartonDetail(ReturnInventoryCarton carton) {
List<ReturnInventoryResponses.CartonItem> items = cartonItems.listCarton(carton.getId()).stream()
.map(item -> new ReturnInventoryResponses.CartonItem(item.getSkuCode(), item.getProductNameZh(), item.getQuantity(),
@@ -1255,8 +1253,7 @@ public class ReturnInventoryService {
return new ReturnInventoryResponses.CartonDetail(carton.getCartonMark(), carton.getCustomerId(), carton.getOverseasWarehouseId(), carton.getSourceBatchNo(), carton.getSourceReturnNo(),
displayStatus(carton), carton.getFbaCartonMark(), carton.getDestinationWarehouseCode(), carton.getDestinationWarehouseName(), carton.getRelabelNo(), relabelProgressStatus(carton), carton.getSkuTypes() == null ? 0 : carton.getSkuTypes(), value(carton.getTotalQuantity()), carton.getLength(), carton.getWidth(), carton.getHeight(), carton.getWeight(), carton.getCreatedAt(), items, carton.getLocationCode(), carton.getPalletCode(), carton.getPutawayAt(), packingTrace.listCartonHistory(carton.getCustomerId(), carton.getCartonMark()),
completedRelabelQuantity(carton), cartons.relabelReservedQuantity(carton.getId()),
skuConversions.listCarton(carton.getCustomerId(), carton.getId()).stream().map(ReturnInventoryResponses.SkuConversion::from).toList(),
discardMovements.cartonHistory(carton.getCustomerId(), carton.getId()));
skuConversions.listCarton(carton.getCustomerId(), carton.getId()).stream().map(ReturnInventoryResponses.SkuConversion::from).toList());
}
private String relabelProgressStatus(ReturnInventoryCarton carton) {
@@ -190,9 +190,7 @@ public class WarehouseTransferService {
private List<WarehouseTransferResponses.SourceCarton> unreservedSourceCartons(String customerId,
String warehouseId, String source, List<WarehouseTransferResponses.SourceCarton> cartons) {
if (inventorySources == null) return cartons;
var occupiedMarks = inventorySources.occupiedMarks(customerId, warehouseId, source);
if (occupiedMarks == null || occupiedMarks.isEmpty()) return cartons;
Set<String> occupied = occupiedMarks.stream()
Set<String> occupied = inventorySources.occupiedMarks(customerId, warehouseId, source).stream()
.filter(StringUtils::hasText).map(mark -> mark.trim().toUpperCase(java.util.Locale.ROOT))
.collect(Collectors.toSet());
return cartons.stream().filter(box -> !occupied.contains(box.cartonMark().trim()
@@ -485,6 +483,7 @@ public class WarehouseTransferService {
// 所有同源提交锁定同一入仓记录;取锁后按最新可用箱校验并保存逐箱占用快照。
if (inventorySources != null && inventorySources.lockSource(customerId, transfer.getInboundOrderId()) == null)
throw conflict("库存来源已失效,请重新选择来源单");
if (inventorySources != null) reserveSourceCartons(customerId, transfer);
}
if (Boolean.TRUE.equals(transfer.getNeedRelabel())) {
@@ -500,11 +499,6 @@ public class WarehouseTransferService {
}
}
// 来源箱归属校验通过后再写入占用快照,保证错误箱号返回明确提示。
if (!transfer.getTransferNo().equals(transfer.getInboundCode()) && inventorySources != null) {
reserveSourceCartons(customerId, transfer);
}
LastlegOrder order = requireTailOrder(customerId, transfer);
LocalDateTime now = LocalDateTime.now();
transfer.setStatus(PENDING_REVIEW);
@@ -2,7 +2,7 @@ openapi: 3.0.3
info:
title: 退货合箱计划与箱级上架
version: 1.0.0
description: 包裹可包含多个SKU并拥有独立标签;整包裹/箱可直接出库,部分数量须精确选择来源包裹/箱合箱成新标签箱。托盘仅为可选载体。合箱不新增订单货量。SKU编码换标完工时原子扣旧SKU、加新SKU,总件数守恒;客户确认前保持RELABEL_PENDING占用,退回重做冲回本轮转换。CartonDetail返回skuConversions(换标单号、完工识别码、旧新SKU、实际数量、APPLIED/CONFIRMED/REVERSED状态和时间),items返回当前quantity、availableQuantity、relabelledQuantity、remainingRelabelQuantity;新SKU已换标件数不可二次换标。CartonDetail新增discardHistory,包含discardNo、skuCode、productNameZh、beforeQuantity、quantity、afterQuantity、operatorId、occurredAt;数量均为件。退货进销存包含DISCARD业务类型,referenceNo为废弃单号,totalDelta和availableDelta为实际废弃件数的负数,发生时间为PDA完成时间,按客户和实际海外仓隔离;已完成历史单据同样可查询。
description: 包裹可包含多个SKU并拥有独立标签;整包裹/箱可直接出库,部分数量须精确选择来源包裹/箱合箱成新标签箱。托盘仅为可选载体。合箱不新增订单货量。SKU编码换标完工时原子扣旧SKU、加新SKU,总件数守恒;客户确认前保持RELABEL_PENDING占用,退回重做冲回本轮转换。CartonDetail返回skuConversions(换标单号、完工识别码、旧新SKU、实际数量、APPLIED/CONFIRMED/REVERSED状态和时间),items返回当前quantity、availableQuantity、relabelledQuantity、remainingRelabelQuantity;新SKU已换标件数不可二次换标。
security: [{ bearerAuth: [] }]
paths:
/api/platform/v1/last-leg/return-packing-plans:
@@ -1,81 +0,0 @@
package com.baiyue.lastleg.discard;
import com.baiyue.lastleg.discard.entity.*;
import com.baiyue.lastleg.discard.repository.CargoDiscardStockLedgerRepository;
import com.baiyue.lastleg.inventory.entity.*;
import com.baiyue.lastleg.inventory.mapper.*;
import com.baiyue.lastleg.returnreceipt.entity.*;
import com.baiyue.lastleg.returnreceipt.mapper.*;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.mockito.*;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDateTime;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/** 校验按件、按箱扣减与总账事实的数量口径。 */
@ExtendWith(MockitoExtension.class)
class CargoDiscardStockLedgerRepositoryTest {
@Mock ReturnInventoryBalanceMapper returnBalances;
@Mock ReturnInventoryCartonMapper returnCartons;
@Mock InventoryBalanceMapper balances;
@Mock InventoryLedgerMapper ledgers;
CargoDiscardStockLedgerRepository repository;
/** 装配真实仓储逻辑及受控数据库边界。 */
@BeforeEach
void setup() {
repository = new CargoDiscardStockLedgerRepository();
ReflectionTestUtils.setField(repository, "returnBalances", returnBalances);
ReflectionTestUtils.setField(repository, "returnCartons", returnCartons);
ReflectionTestUtils.setField(repository, "balances", balances);
ReflectionTestUtils.setField(repository, "ledgers", ledgers);
}
/** 部分废弃保留箱数,整 SKU 耗尽减箱,包裹不占箱数。 */
@ParameterizedTest
@CsvSource({"BOX,10,5,0", "BOX,5,5,1", "PARCEL,5,5,0"})
void returnBalanceUsesPiecesAndRemainingSku(String unit, long before, long count, long boxes) {
var app = new CargoDiscardApplication(); app.setOverseasWarehouseId("W");
var item = new CargoDiscardItem(); item.setReturnCartonId(1L); item.setSkuCode("SKU");
var carton = new ReturnInventoryCarton(); carton.setCustomerId("C"); carton.setOverseasWarehouseId("W");
carton.setStatus("DISCARD_PENDING"); carton.setSourceUnitType(unit);
when(returnCartons.lockId(1L)).thenReturn(carton);
when(returnBalances.lockSku("C", "W", "SKU")).thenReturn(new ReturnInventoryBalance());
when(returnBalances.deductConversion("C", "W", "SKU", count, boxes)).thenReturn(1);
repository.deductReturn("C", app, item, before, count);
verify(returnBalances).deductConversion("C", "W", "SKU", count, boxes);
}
/** 其他客户的退货箱不能扣当前客户库存。 */
@Test
void rejectsForeignCartonBeforeBalanceMutation() {
var item = new CargoDiscardItem(); item.setReturnCartonId(1L);
var carton = new ReturnInventoryCarton(); carton.setCustomerId("OTHER");
when(returnCartons.lockId(1L)).thenReturn(carton);
assertThrows(ResponseStatusException.class, () -> repository.deductReturn("C", new CargoDiscardApplication(), item, 5, 5));
verifyNoInteractions(returnBalances);
}
/** 一件代发混装箱只按实物箱数记总账,并关联废弃业务单。 */
@Test
void dropshipLedgerRecordsPhysicalBoxesAndBusinessReference() {
var app = new CargoDiscardApplication(); app.setId(10L); app.setDiscardNo("CD-1");
var item = new CargoDiscardItem(); item.setId(20L); item.setSkuCode("MIXED");
var position = new InventoryPosition(); position.setId(30L); position.setBalanceId(40L); position.setPalletNo("P1");
var balance = new InventoryBalance(); balance.setId(40L); balance.setCustomerId("C"); balance.setAvailablePackages(9L);
when(balances.selectById(40L)).thenReturn(balance); when(ledgers.insert(any(InventoryLedger.class))).thenReturn(1);
repository.recordDropship("C", app, item, position, 1, "operator", LocalDateTime.now());
var fact = ArgumentCaptor.forClass(InventoryLedger.class); verify(ledgers).insert(fact.capture());
assertEquals(10L, fact.getValue().getBeforePackages()); assertEquals(-1L, fact.getValue().getDeltaPackages());
assertEquals(9L, fact.getValue().getAfterPackages()); assertEquals("CD-1", fact.getValue().getBusinessOrderNo());
assertEquals("DISCARD", fact.getValue().getBusinessType()); assertEquals("P1", fact.getValue().getPalletNo());
}
}
@@ -1,55 +0,0 @@
package com.baiyue.lastleg.discard;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnDiscardMovementMapper;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryMovementMapper;
import org.apache.ibatis.mapping.Environment;
import org.apache.ibatis.session.Configuration;
import org.apache.ibatis.session.SqlSessionFactoryBuilder;
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
/** 用真实 SQL 验证完成事实、历史兼容及客户箱号隔离。 */
class ReturnDiscardMovementMapperTest {
/** 历史已完成废弃展示负件数;未完成、其他客户及其他仓记录均排除。 */
@Test
void completedFactsAreVisibleInMovementsAndCartonHistory() {
var source = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
var db = new JdbcTemplate(source);
db.execute("CREATE TABLE ll_cargo_discard_application(id BIGINT,customer_id VARCHAR(20),overseas_warehouse_id VARCHAR(20),discard_no VARCHAR(30))");
db.execute("CREATE TABLE ll_return_inventory_carton(id BIGINT,customer_id VARCHAR(20),overseas_warehouse_id VARCHAR(20))");
db.execute("ALTER TABLE ll_return_inventory_carton ADD carton_mark VARCHAR(30), ADD source_batch_no VARCHAR(30), ADD created_at TIMESTAMP");
db.execute("CREATE TABLE ll_return_inventory_carton_item(carton_id BIGINT,sku_code VARCHAR(30),product_name_zh VARCHAR(30),quantity BIGINT)");
db.execute("CREATE TABLE ll_return_receipt(id BIGINT,return_order_id BIGINT,receipt_no VARCHAR(30),signed_at TIMESTAMP)");
db.execute("CREATE TABLE ll_return_order(id BIGINT,customer_id VARCHAR(20),overseas_warehouse_id VARCHAR(20))");
db.execute("CREATE TABLE ll_return_receipt_item(receipt_id BIGINT,sku_code VARCHAR(30),return_order_item_id BIGINT,received_quantity BIGINT)");
db.execute("CREATE TABLE ll_return_order_item(id BIGINT,product_name_zh VARCHAR(30))");
db.execute("CREATE TABLE ll_return_inventory_outbound_movement(customer_id VARCHAR(20),overseas_warehouse_id VARCHAR(20),sku_code VARCHAR(30),product_name_zh VARCHAR(30),outbound_no VARCHAR(30),quantity BIGINT,occurred_at TIMESTAMP)");
db.execute("CREATE TABLE ll_return_carton_stock_receipt(customer_id VARCHAR(20),overseas_warehouse_id VARCHAR(20),sku_code VARCHAR(30),product_name_zh VARCHAR(30),receipt_no VARCHAR(30),quantity BIGINT,received_at TIMESTAMP)");
db.execute("CREATE TABLE ll_return_sku_conversion(customer_id VARCHAR(20),overseas_warehouse_id VARCHAR(20),old_sku_code VARCHAR(30),new_sku_code VARCHAR(30),product_name_zh VARCHAR(30),relabel_no VARCHAR(30),quantity BIGINT,completed_at TIMESTAMP,reversed_at TIMESTAMP,status VARCHAR(30))");
db.execute("CREATE TABLE ll_cargo_discard_item(id BIGINT,application_id BIGINT,customer_id VARCHAR(20),return_carton_id BIGINT,sku_code VARCHAR(30),product_name VARCHAR(30),pda_status VARCHAR(20),available_packages_snapshot INT,actual_discard_packages INT,pda_operator_id VARCHAR(20),pda_completed_at TIMESTAMP)");
db.update("INSERT INTO ll_cargo_discard_application VALUES(1,'C','W','CD-1'),(2,'OTHER','W','CD-2'),(3,'C','OTHER-W','CD-3')");
db.update("INSERT INTO ll_return_inventory_carton(id,customer_id,overseas_warehouse_id) VALUES(10,'C','W'),(20,'OTHER','W')");
db.update("INSERT INTO ll_cargo_discard_item VALUES(1,1,'C',10,'SKU','货物','COMPLETED',10,5,'OP',CURRENT_TIMESTAMP),(2,1,'C',10,'SKU','货物','PENDING',10,5,NULL,NULL),(3,2,'OTHER',20,'SKU','货物','COMPLETED',5,5,'OP',CURRENT_TIMESTAMP),(4,3,'C',10,'SKU','货物','COMPLETED',5,5,'OP',CURRENT_TIMESTAMP)");
var config = new Configuration(new Environment("test", new JdbcTransactionFactory(), source));
config.addMapper(ReturnDiscardMovementMapper.class);
config.addMapper(ReturnInventoryMovementMapper.class);
try (var session = new SqlSessionFactoryBuilder().build(config).openSession()) {
var mapper = session.getMapper(ReturnDiscardMovementMapper.class);
var history = mapper.cartonHistory("C", 10L);
assertEquals(1, history.size()); assertEquals("CD-1", history.get(0).discardNo());
assertEquals(5, history.get(0).quantity()); assertEquals(5, history.get(0).afterQuantity());
assertTrue(mapper.cartonHistory("OTHER", 10L).isEmpty());
assertEquals(2, db.queryForObject("SELECT COUNT(*) FROM (" + ReturnDiscardMovementMapper.MOVEMENTS + ") m", Integer.class));
var inventory = session.getMapper(ReturnInventoryMovementMapper.class);
assertEquals(1, inventory.platformCount("C", "W", "SKU"));
var movement = inventory.platformPage("C", "W", "SKU", 0L, 20L).get(0);
assertEquals("DISCARD", movement.movementType()); assertEquals("CD-1", movement.referenceNo());
assertEquals(-5, movement.totalDelta()); assertEquals(-5, movement.availableDelta());
assertEquals(0, inventory.platformCount("C", "W", "MISSING-SKU"));
}
}
}
@@ -189,28 +189,6 @@ class PdaCargoDiscardServiceTest {
verify(photoService).confirm(same(application), eq("散箱"), eq(List.of(101L)), eq(false));
}
/** 退货一箱五件按指定箱作业,不得按五箱或整托处理。 */
@Test void returnCartonUsesPieceUnitAndRequiresSelectedCartonEvenWhenBoundToPallet() {
item.setSourceType("RETURN_CARTON"); item.setReturnCartonId(31L); item.setDiscardPackages(5);
var carton = new com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton();
carton.setCustomerId("CUSTOMER"); carton.setPalletCode("PALLET-1"); carton.setCartonMark("RETURN-BOX-1");
when(returnCartons.selectById(31L)).thenReturn(carton);
when(applications.selectList(any())).thenReturn(List.of(application));
var task = service.resolve(identity, "PALLET-1", "CD-1");
assertEquals("CARTON", task.scanMode());
assertEquals(List.of("RETURN-BOX-1"), task.cartonMarks());
assertEquals(5, task.skus().get(0).plannedPackages());
assertEquals("件", task.skus().get(0).quantityUnit());
assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1", request(5)));
verifyNoInteractions(adjustments);
var confirm = new PdaCargoDiscardDtos.Confirm(List.of(new PdaCargoDiscardDtos.ActualLine(11L, 5)),
List.of(101L), List.of("RETURN-BOX-1"), false);
service.confirm(identity, "CD-1", "PALLET-1", confirm);
assertEquals(1, application.getTotalPackages());
assertEquals(new BigDecimal("5.00"), application.getTotalFee());
verify(adjustments).discardPalletItem(eq("CUSTOMER"), same(application), same(item), anyList(), eq(5), eq("OP"), any());
}
@Test void partialBoundPalletOnlyExposesSelectedBoxesAndRequiresTheirScan() {
looseTask(); item.setCartonScanRequired(true);
var stockService = (com.baiyue.lastleg.discard.service.CargoDiscardStockService) ReflectionTestUtils.getField(service, "stockService");
@@ -50,15 +50,11 @@ class CargoDiscardInventoryAdjustmentServiceTest {
@Mock DropshipOrderProfileMapper profiles;
@Mock InventoryPositionMapper positions;
@Mock InventoryBalanceMapper balances;
@Mock com.baiyue.lastleg.discard.repository.CargoDiscardStockLedgerRepository stockLedger;
@Mock com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper returnItems;
private CargoDiscardInventoryAdjustmentService service;
@BeforeEach
void setUp() {
service = new CargoDiscardInventoryAdjustmentService();
ReflectionTestUtils.setField(service, "stockLedger", stockLedger);
ReflectionTestUtils.setField(service, "returnCartonItems", returnItems);
ReflectionTestUtils.setField(service, "inventory", inventory);
ReflectionTestUtils.setField(service, "cartonLedgers", ledgers);
ReflectionTestUtils.setField(service, "cartonSkus", cartonSkus);
@@ -68,21 +64,6 @@ class CargoDiscardInventoryAdjustmentServiceTest {
ReflectionTestUtils.setField(service, "balances", balances);
}
/** 退货来源按 SKU 件数同时扣箱明细和客户库存汇总。 */
@Test
void returnDiscardSynchronizesCartonAndBalance() {
var app = application(1);
var line = item(5); line.setSourceType("RETURN_CARTON"); line.setReturnCartonId(31L);
var content = new com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCartonItem();
content.setId(32L); content.setCustomerId("100"); content.setQuantity(5L);
when(returnItems.lockSku(31L, "SKU-1")).thenReturn(content);
when(returnItems.deduct(32L, 5)).thenReturn(1);
var result = service.discardPalletItem("100", app, line, List.of(), 5, "OP", LocalDateTime.now());
assertEquals(5, result.discardedPackages()); assertEquals(0, result.afterPackages());
verify(stockLedger).deductReturn("100", app, line, 5L, 5L);
org.mockito.Mockito.verifyNoInteractions(positions, balances, ledgers);
}
@Test
void pdaDiscardDeductsEverySkuInsideOnePhysicalCartonOnce() {
CargoDiscardApplication application = application(1);
@@ -115,7 +96,6 @@ class CargoDiscardInventoryAdjustmentServiceTest {
verify(skuInventories).deduct("100", 201L, "SKU-A", 1, 2);
verify(skuInventories).deduct("100", 201L, "SKU-B", 1, 3);
verify(positions).deductAvailable("100", 801L, 1);
verify(stockLedger).recordDropship(eq("100"), eq(application), eq(item), eq(position), eq(1L), eq("operator"), any());
}
@Test
@@ -104,23 +104,4 @@ class CargoDiscardOutboundServiceTest {
assertThrows(ResponseStatusException.class, () -> service.create(application, "OP"));
verify(outbounds, never()).insert(any(OutboundOrder.class)); verify(pallets, never()).insert(any(OutboundPalletItem.class));
}
/** 同一退货箱含多个 SKU 时箱数只记一次,件数仍逐 SKU 保留。 */
@Test void returnCartonProjectionSeparatesOneBoxFromFivePiecesAndSyncsCompletion() {
first.setReturnCartonId(31L); first.setSourceType("RETURN_CARTON"); first.setDiscardPackages(2);
second.setReturnCartonId(31L); second.setSourceType("RETURN_CARTON"); second.setDiscardPackages(3);
var carton = new ReturnInventoryCarton(); carton.setCustomerId("C1"); carton.setOverseasWarehouseId("99");
carton.setCartonMark("BOX-31"); carton.setPalletCode("PALLET-1");
when(returnCartons.selectById(31L)).thenReturn(carton);
service.create(application, "OP");
assertEquals(1, document.getPackageCount());
assertEquals(1, snapshots.stream().mapToInt(OutboundPalletItem::getPackageCount).sum());
assertEquals(5, snapshots.stream().mapToLong(OutboundPalletItem::getReturnSkuQuantity).sum());
first.setPdaStatus("COMPLETED"); first.setActualDiscardPackages(2);
second.setPdaStatus("COMPLETED"); second.setActualDiscardPackages(3);
application.setStatus("PDA_COMPLETED");
service.sync(application);
assertEquals(1, document.getPackageCount());
assertEquals(1, snapshots.stream().mapToInt(OutboundPalletItem::getPickedPackageCount).sum());
}
}
@@ -99,9 +99,6 @@ class CargoDiscardServiceTest {
@BeforeEach
void setUp() {
service = new CargoDiscardService();
var quantities = new com.baiyue.lastleg.discard.repository.CargoDiscardQuantityRepository();
ReflectionTestUtils.setField(quantities, "items", items);
ReflectionTestUtils.setField(service, "quantities", quantities);
ReflectionTestUtils.setField(service, "warehouseDirectory", warehouseDirectory);
lenient().when(warehouseDirectory.receivingWarehouses(any())).thenReturn(com.baiyue.common.core.api.ApiResponse.success(List.of(
new com.baiyue.lastleg.client.DomesticWarehouseDirectoryClient.ReceivingWarehouse(99L, "LAX", "洛杉矶仓", "US", "ACTIVE"))));
@@ -230,32 +227,6 @@ class CargoDiscardServiceTest {
assertEquals("SKU-RETURN", result.get(0).skus().get(0).skuCode());
}
/** 创建和已有单据查询均按退货箱去重,五件不可计为五箱。 */
@Test void returnMixedSkuCartonCreatesOneBoxAndExistingSummaryReadsOneBox() {
var carton = new ReturnInventoryCarton(); carton.setId(901L); carton.setCustomerId("100");
carton.setOverseasWarehouseId("99"); carton.setSourceReturnNo("RT-1"); carton.setCartonMark("BOX-1");
carton.setStatus("AVAILABLE");
var skuA = new ReturnInventoryCartonItem(); skuA.setSkuCode("A"); skuA.setQuantity(2L);
var skuB = new ReturnInventoryCartonItem(); skuB.setSkuCode("B"); skuB.setQuantity(3L);
when(returnCartons.lockId(901L)).thenReturn(carton);
when(returnCartons.selectById(901L)).thenReturn(carton);
when(returnCartonItems.lockSku(901L, "A")).thenReturn(skuA);
when(returnCartonItems.lockSku(901L, "B")).thenReturn(skuB);
when(returnCartonItems.lockCarton(901L)).thenReturn(List.of(skuA, skuB));
when(returnCartons.markDiscardPending(901L, "100")).thenReturn(1);
var detail = service.create("100", "OP", new CargoDiscardRequests.Create(List.of(
new CargoDiscardRequests.Item(null, 901L, "A", 2),
new CargoDiscardRequests.Item(null, 901L, "B", 3)), "return-five-pieces", null));
assertEquals(1, storedApplication.get().getTotalPackages());
assertEquals(new BigDecimal("5.00"), storedApplication.get().getTotalFee());
assertEquals(1, detail.summary().totalPackages());
assertEquals(5, storedItems.stream().mapToInt(CargoDiscardItem::getDiscardPackages).sum());
storedApplication.get().setTotalPackages(5); storedApplication.get().setTotalFee(new BigDecimal("25.00"));
var existing = service.detail("100", storedApplication.get().getDiscardNo());
assertEquals(1, existing.summary().totalPackages());
assertEquals(new BigDecimal("5.00"), existing.summary().totalFee());
}
@Test
void approvalPushesToPdaWithoutDeductingInventory() {
CargoDiscardApplication application = pendingApplication();
@@ -140,7 +140,7 @@ public class DispatchAggregatePersistenceTest {
/** 真实司机Service继续执行当前/历史附件关联检查,不由排车测试替身伪装通过。 */
DriverEvidenceFixture() throws Exception {
f=new Fixture();driver.setId(71L);driver.setLicenseFileId(81L);when(driverRows.selectById(71L)).thenReturn(driver);
var drivers=new DriverService(driverRows,history,files,f.json,Clock.systemUTC(),mock(FleetWarehouseDirectory.class));
var drivers=new DriverService(driverRows,history,files,f.json,mock(FleetWarehouseDirectory.class));
org.springframework.test.util.ReflectionTestUtils.setField(f.fleet,"drivers",drivers);
dispatchFiles=(com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway)org.springframework.test.util.ReflectionTestUtils.getField(f.service,"evidence");
}
@@ -287,6 +287,64 @@ public class DispatchAggregatePersistenceTest {
assertThatThrownBy(()->f.tx.execute(s->f.service.loadingComplete(f.actor,created.dispatchNo(),new DispatchCommands.LoadingComplete(approved.recordVersion(),"missing-rejected",preview.manifestHash(),List.of(),null)))).isInstanceOf(BizException.class).hasMessageContaining("核平");
assertThat(f.db.queryForObject("SELECT stage FROM ll_dispatch_order",String.class)).isEqualTo("LOADING");
}
/** 已提交根的公开资格关闭;新键重复提交不改变事实,原键仍先于当前状态恢复结果。 */
@Test void pendingSubmissionGateRejectsNewKeyWithoutMutationAndRestoresOriginalCommand() throws Exception {
var security=org.springframework.security.core.context.SecurityContextHolder.getContext();var previous=security.getAuthentication();
security.setAuthentication(new org.springframework.security.authentication.TestingAuthenticationToken("operator-test","",
"platform:overseas-warehouse:dispatch:list","platform:overseas-warehouse:dispatch:edit"));
try {
var f=new Fixture();var source=f.source(1,"101",501,"ONT8",3);
var created=f.tx.execute(s->f.service.create(f.actor,f.configured("submission-gate-create",List.of(source))));
var request=new DispatchCommands.SubmitReview(created.recordVersion(),"submission-gate-original",null);
var submitted=f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),request));
var detail=f.tx.execute(s->f.service.detail(f.actor,created.dispatchNo()));
assertThat(detail.reviewState()).isEqualTo(ReviewState.DISPATCH_PENDING);
assertThat(detail.actions().get(Action.SUBMIT_REVIEW).allowed()).isFalse();
var before=f.json.writeValueAsString(f.repository.find(created.dispatchNo()));
var commands=f.db.queryForList("SELECT id,idempotency_key,record_version,command_hash FROM ll_dispatch_command ORDER BY id");
assertThatThrownBy(()->f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),
new DispatchCommands.SubmitReview(detail.recordVersion(),"submission-gate-duplicate",null))))
.isInstanceOf(BizException.class).hasMessageContaining("当前审核已提交");
assertThat(f.json.writeValueAsString(f.repository.find(created.dispatchNo()))).isEqualTo(before);
assertThat(f.db.queryForList("SELECT id,idempotency_key,record_version,command_hash FROM ll_dispatch_command ORDER BY id")).isEqualTo(commands);
var replay=f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),request));
assertThat(replay.replayed()).isTrue();assertThat(replay.recordVersion()).isEqualTo(submitted.recordVersion());
assertThat(replay.result()).isEqualTo(submitted.result());
assertThat(f.json.writeValueAsString(f.repository.find(created.dispatchNo()))).isEqualTo(before);
assertThat(f.db.queryForList("SELECT id,idempotency_key,record_version,command_hash FROM ll_dispatch_command ORDER BY id")).isEqualTo(commands);
} finally { security.setAuthentication(previous); }
}
/** 真实两级审核流证明三种可重提状态开放,而两种待审状态均关闭提交资格。 */
@Test void submissionActionMatchesDraftRejectedAndPendingReviewStates() throws Exception {
var security=org.springframework.security.core.context.SecurityContextHolder.getContext();var previous=security.getAuthentication();
security.setAuthentication(new org.springframework.security.authentication.TestingAuthenticationToken("operator-test","",
"platform:overseas-warehouse:dispatch:list","platform:overseas-warehouse:dispatch:edit",
"platform:fleet:trip:dispatch-review","platform:fleet:trip:loading-review"));
try {
var f=new Fixture();var source=f.source(1,"101",501,"ONT8",3);
var created=f.tx.execute(s->f.service.create(f.actor,f.configured("submission-states-create",List.of(source))));
assertSubmissionGate(f,created.dispatchNo(),ReviewState.DRAFT,true);
var submitted=f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(created.recordVersion(),"submission-states-1",null)));
assertSubmissionGate(f,created.dispatchNo(),ReviewState.DISPATCH_PENDING,false);
var rejected=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(submitted.recordVersion(),"submission-states-dispatch-rejected",Decision.REJECTED,"核对调度")));
assertSubmissionGate(f,created.dispatchNo(),ReviewState.DISPATCH_REJECTED,true);
var resubmitted=f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(rejected.recordVersion(),"submission-states-2",null)));
var approved=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(resubmitted.recordVersion(),"submission-states-dispatch-approved",Decision.APPROVED,null)));
assertSubmissionGate(f,created.dispatchNo(),ReviewState.LOADING_PENDING,false);
var loadingRejected=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),true,new DispatchCommands.ReviewCommand(approved.recordVersion(),"submission-states-loading-rejected",Decision.REJECTED,"核对装载")));
assertSubmissionGate(f,created.dispatchNo(),ReviewState.LOADING_REJECTED,true);
f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(loadingRejected.recordVersion(),"submission-states-3",null)));
assertSubmissionGate(f,created.dispatchNo(),ReviewState.DISPATCH_PENDING,false);
} finally { security.setAuthentication(previous); }
}
/** 从真实详情DTO读取动作资格,避免只测内部枚举或权限缺失导致的假阴性。 */
private static void assertSubmissionGate(Fixture f,String number,ReviewState state,boolean allowed) {
var detail=f.tx.execute(s->f.service.detail(f.actor,number));
assertThat(detail.reviewState()).isEqualTo(state);assertThat(detail.actions().get(Action.SUBMIT_REVIEW).allowed()).isEqualTo(allowed);
}
/** 两级顺序、驳回重提、原结果恢复和零扫码退回均实际提交到同一整车。 */
@Test void reviewsUseActualRootRoundAndImmutableCommandsBeforeAndAfterLoading() throws Exception {
var f=new Fixture();var source=f.source(1,"101",501,"ONT8",3);
@@ -88,7 +88,6 @@ class ReturnInventoryServiceTest {
@BeforeEach
void setUp() {
service = new ReturnInventoryService();
ReflectionTestUtils.setField(service, "discardMovements", org.mockito.Mockito.mock(com.baiyue.lastleg.returnreceipt.repository.ReturnDiscardMovementRepository.class));
ReflectionTestUtils.setField(service, "skuConversions", org.mockito.Mockito.mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnSkuConversionMapper.class));
ReflectionTestUtils.setField(service, "cartons", cartons);
ReflectionTestUtils.setField(service, "cartonItems", cartonItems);
@@ -513,7 +513,7 @@ class WarehouseTransferServiceTest {
verifyNoInteractions(transfers);
}
/** 先按原订单来源快照拒绝外部换标箱,不进入后续选箱占用。 */
/** 来源箱快照先通过占用校验,再单独拒绝草稿中的外部换标箱。 */
@Test
void submittingOldDraftRechecksRelabelCartonOwnership() {
var transfer = transfer(WarehouseTransferService.DRAFT);
@@ -521,6 +521,9 @@ class WarehouseTransferServiceTest {
transfer.setInboundCode("IN-1");
transfer.setNeedRelabel(true);
transfer.setPackageCount(1);
var selected = new WarehouseTransferCarton();
selected.setId(51L); selected.setCustomerCartonMark("CURRENT-BOX");
when(transferCartons.listOrder("TENANT-A", transfer.getId())).thenReturn(List.of(selected));
when(inboundSnapshots.sourceCartons("TENANT-A", "WH-1", "IN-1"))
.thenReturn(List.of(new InboundSnapshotMapper.SourceCarton("CURRENT-BOX", "SKU-1", null, null, null, null, null)));
when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer);
@@ -81,9 +81,7 @@ public final class OrderRequests {
@Schema(description = "Selected customer pickup address ID; required for PICKUP / 已选客户取货地址 ID;PICKUP 时必填")
@Min(1) Long senderAddressId,
@Schema(description = "Selected private destination address ID; required for PRIVATE / 已选私人目的仓地址 ID;PRIVATE 时必填")
@Min(1) Long privateDestinationAddressId,
@Schema(description = "Optional customer courier tracking number for SELF_EXPRESS / 快递自提选填快递单号;其他取货方式不保存", maxLength = 128)
@Size(max = 128) String expressTrackingNo
@Min(1) Long privateDestinationAddressId
) {
public SaveRequest(Long customerId, String customerName, String transportMode, String tradeTerm,
String destinationCountryCode, Long destinationWarehouseId,
@@ -100,7 +98,7 @@ public final class OrderRequests {
sender, receiver, cargoes, pickupMethod, requestedPickupAt, receivingWarehouseId,
receivingWarehouseName, channelCode, channelName, carrierId, carrierName, taxMode,
pricingTaxMode, containerMode, customsInspection, destinationType, amazonWarehouseCode,
shipmentId, referenceId, inboundCode, fulfillmentType, null, null, null, null);
shipmentId, referenceId, inboundCode, fulfillmentType, null, null, null);
}
public SaveRequest(Long customerId, String customerName, String transportMode, String tradeTerm,
@@ -118,7 +116,7 @@ public final class OrderRequests {
sender, receiver, cargoes, pickupMethod, requestedPickupAt, receivingWarehouseId,
receivingWarehouseName, channelCode, channelName, carrierId, carrierName, taxMode,
pricingTaxMode, containerMode, customsInspection, destinationType, amazonWarehouseCode,
shipmentId, referenceId, inboundCode, null, null, null, null, null);
shipmentId, referenceId, inboundCode, null, null, null, null);
}
public SaveRequest(Long customerId, String customerName, String transportMode, String tradeTerm,
@@ -135,7 +133,7 @@ public final class OrderRequests {
sender, receiver, cargoes, pickupMethod, requestedPickupAt, receivingWarehouseId,
receivingWarehouseName, channelCode, channelName, carrierId, carrierName, taxMode,
pricingTaxMode, containerMode, customsInspection, destinationType, amazonWarehouseCode,
shipmentId, referenceId, null, null, null, null, null, null);
shipmentId, referenceId, null, null, null, null, null);
}
public SaveRequest(Long customerId, String customerName, String transportMode, String tradeTerm,
@@ -152,7 +150,7 @@ public final class OrderRequests {
sender, receiver, cargoes, pickupMethod, null, receivingWarehouseId,
receivingWarehouseName, channelCode, channelName, carrierId, carrierName, taxMode,
pricingTaxMode, containerMode, customsInspection, destinationType, amazonWarehouseCode,
shipmentId, referenceId, null, null, null, null, null, null);
shipmentId, referenceId, null, null, null, null, null);
}
@AssertTrue(message = "承运商 ID 和名称必须同时填写或同时留空")
@@ -64,8 +64,6 @@ public class OrderEntity extends BaseEntity {
private Long rootOrderId;
private Integer splitSequence;
private String expressCompany;
// 客户清空单号或改用其他交货方式时,必须把 NULL 写入数据库。
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private String expressTrackingNo;
private LocalDateTime submittedAt;
private LocalDateTime completedAt;
@@ -642,7 +642,6 @@ public class OrderApplicationService {
default -> false;
};
}
// 保存客户下单资料;快递单号仅属于快递自提,切换方式时清空避免误带。
private void apply(OrderEntity order, long tenantId, OrderRequests.SaveRequest request,
OrderMasterDataGuard.Snapshots snapshots, AddressSnapshots addresses,
boolean allowLegacyInboundCode) {
@@ -663,10 +662,6 @@ public class OrderApplicationService {
order.setExpectedArrivalAt(request.expectedShipDate() == null ? null : request.expectedShipDate().atStartOfDay());
order.setCustomerRemark(request.customerRemark());
order.setPickupMethod(request.pickupMethod());
if (order.getStatus() == OrderStatus.DRAFT || order.getStatus() == OrderStatus.REJECTED) {
order.setExpressTrackingNo("SELF_EXPRESS".equals(request.pickupMethod())
? normalizeRemark(request.expressTrackingNo()) : null);
}
order.setRequestedPickupAt("PICKUP".equals(request.pickupMethod()) ? request.requestedPickupAt() : null);
order.setReceivingWarehouseId(request.receivingWarehouseId());
order.setReceivingWarehouseName(snapshots.receiving().warehouse().warehouseName());
@@ -162,7 +162,7 @@ public class PlatformLegacyOrderImportService {
"买单".equals(required(row, 5, "买单/退税")) ? "BUY_DECLARATION" : "TAX_REFUND",
channel.channelName().contains("包税") && !channel.channelName().contains("不包税") ? "TAX_INCLUDED" : "TAX_EXCLUDED",
"LCL", false, destinationType, amazonCode, shipmentId, referenceId, inboundCode, fulfillmentType,
"ONE_CLICK_DROPSHIP".equals(fulfillmentType) ? referenceId : null, null, null, null);
"ONE_CLICK_DROPSHIP".equals(fulfillmentType) ? referenceId : null, null, null);
}
private Customer fixedCustomer() {
@@ -1451,7 +1451,6 @@ components:
pricingTaxMode: {$ref: '#/components/schemas/PricingTaxMode'}
senderAddressId: {type: string, pattern: '^[1-9][0-9]*$', description: PICKUP 时必填;必须是本客户 PICKUP 地址}
privateDestinationAddressId: {type: string, pattern: '^[1-9][0-9]*$', description: PRIVATE 时必填;必须是本客户 PRIVATE_DESTINATION 地址}
expressTrackingNo: {type: string, nullable: true, maxLength: 128, description: 快递自提(SELF_EXPRESS)时选填;留空清空单号,其他取货方式不保存。客户仅能在下单或编辑草稿时填写}
customsInspection: {type: boolean, nullable: true, description: 非木制品可空;任一货物 woodenProduct=true 时必须为 true}
cargoes:
type: array
@@ -52,24 +52,6 @@ class MerchantOrderCommandBindingTest {
}).build();
}
/** 客户创建入口绑定选填单号,并拒绝超过持久化长度的输入。 */
@Test
void courierTrackingIsOptionalAndLengthBoundedOnCreate() throws Exception {
String createPath = "/api/merchant/v1/orders/first-leg";
for (String tracking : new String[] {null, "", "SF123456789"}) {
String body = tracking == null ? DRAFT : DRAFT.replace("\"customerName\"", "\"expressTrackingNo\":\"" + tracking + "\",\"customerName\"");
mvc.perform(post(createPath).header("Idempotency-Key", "courier-create").contentType(MediaType.APPLICATION_JSON).content(body))
.andExpect(status().isOk()).andExpect(jsonPath("$.code").value("200"));
verify(service).createDraft(eq(11L), eq(7L), eq("operator"),
argThat(request -> java.util.Objects.equals(tracking, request.expressTrackingNo())), eq("courier-create"));
}
clearInvocations(service);
String invalid = DRAFT.replace("\"customerName\"", "\"expressTrackingNo\":\"" + "A".repeat(129) + "\",\"customerName\"");
mvc.perform(post(createPath).header("Idempotency-Key", "courier-create").contentType(MediaType.APPLICATION_JSON).content(invalid))
.andExpect(status().isBadRequest());
verifyNoInteractions(service);
}
@Test
void platformEditRequiresVersionEnvelopeAndKey() throws Exception {
String path = "/api/platform/v1/orders/first-leg/1?tenantId=11";
@@ -44,30 +44,6 @@ class MerchantOrderCommandTransactionTest {
private OrderRequests.SaveRequest draft;
private com.baiyue.order.client.FirstlegInboundClient firstleg;
/** 通过真实 MyBatis 和内存数据库验证单号更新及清空,避免空值被更新策略忽略。 */
@Test
void courierTrackingCanBeClearedAndSwitchingPickupMethodClearsItInDatabase() {
var fields = (com.fasterxml.jackson.databind.node.ObjectNode) json.valueToTree(draft);
fields.remove("carrierSnapshotComplete");
fields.put("expressTrackingNo", " SF-ORIGINAL ");
var filled = json.convertValue(fields, OrderRequests.SaveRequest.class);
long id = service.createDraft(11L, 7L, "operator", filled, "courier-create");
assertThat(db.queryForObject("SELECT express_tracking_no FROM ord_order WHERE id=?", String.class, id))
.isEqualTo("SF-ORIGINAL");
fields.remove("expressTrackingNo");
var blank = json.convertValue(fields, OrderRequests.SaveRequest.class);
service.updateDraft(11L, 7L, id, new OrderRequests.DraftUpdateRequest(0, blank), "courier-clear");
assertThat(db.queryForObject("SELECT express_tracking_no FROM ord_order WHERE id=?", String.class, id)).isNull();
service.updateDraft(11L, 7L, id, new OrderRequests.DraftUpdateRequest(1, filled), "courier-restore");
fields.put("pickupMethod", "SELF_TRUCK");
fields.put("expressTrackingNo", "SPOOFED");
var truck = json.convertValue(fields, OrderRequests.SaveRequest.class);
service.updateDraft(11L, 7L, id, new OrderRequests.DraftUpdateRequest(2, truck), "courier-switch");
assertThat(db.queryForObject("SELECT express_tracking_no FROM ord_order WHERE id=?", String.class, id)).isNull();
}
@BeforeEach
void prepare() throws Exception {
JdbcDataSource source = new JdbcDataSource();
@@ -111,58 +111,6 @@ class OrderApplicationServiceStateTest {
&& order.getInboundCode() == null && "STANDARD".equals(order.getFulfillmentType())));
}
/** 快递草稿支持填写或留空单号,非快递方式不能携带单号。 */
@Test
void courierDraftPersistsOptionalTrackingNumber() {
for (String pickupMethod : List.of("SELF_EXPRESS", "SELF_TRUCK")) {
for (String tracking : List.of(" SF123456789 ", "", " ")) {
var json = new ObjectMapper().valueToTree(request(null, pickupMethod, null, null, "OVERSEAS"));
((com.fasterxml.jackson.databind.node.ObjectNode) json).put("expressTrackingNo", tracking);
((com.fasterxml.jackson.databind.node.ObjectNode) json).remove("carrierSnapshotComplete");
var draft = new ObjectMapper().convertValue(json, OrderRequests.SaveRequest.class);
OrderRepository repository = mock(OrderRepository.class);
OrderMasterDataGuard masterData = mock(OrderMasterDataGuard.class);
when(masterData.validate(10001L, draft)).thenReturn(snapshots());
when(repository.completeIdempotency(anyLong(), any(), anyLong())).thenReturn(1);
service(repository, mock(OrderEligibilityGuard.class), masterData)
.createDraft(10001L, 2L, draft, "courier-draft");
String expected = "SELF_EXPRESS".equals(pickupMethod) && !tracking.isBlank()
? tracking.trim() : null;
verify(repository).insertOrder(argThat(saved -> java.util.Objects.equals(expected, saved.getExpressTrackingNo())));
}
}
}
/** 修改草稿允许更换、清空单号,切换交货方式后不保留旧单号。 */
@Test
void editingCourierDraftReplacesOrClearsTrackingNumber() {
for (String pickupMethod : List.of("SELF_EXPRESS", "SELF_TRUCK")) {
for (String tracking : List.of(" SF-NEW ", "")) {
var json = new ObjectMapper().valueToTree(request(null, pickupMethod, null, null, "OVERSEAS"));
((com.fasterxml.jackson.databind.node.ObjectNode) json).put("expressTrackingNo", tracking);
((com.fasterxml.jackson.databind.node.ObjectNode) json).remove("carrierSnapshotComplete");
var draft = new ObjectMapper().convertValue(json, OrderRequests.SaveRequest.class);
var order = persistedOrder(OrderStatus.DRAFT);
order.setExpressTrackingNo("SF-OLD");
OrderRepository repository = mock(OrderRepository.class);
OrderMasterDataGuard masterData = mock(OrderMasterDataGuard.class);
when(repository.findOwned(10001L, 1L)).thenReturn(order);
when(repository.saveOrder(10001L, order)).thenReturn(1);
when(repository.completeIdempotency(anyLong(), any(), anyLong())).thenReturn(1);
when(masterData.validate(10001L, draft)).thenReturn(snapshots());
service(repository, mock(OrderEligibilityGuard.class), masterData).updateDraft(10001L, 2L, 1L,
new OrderRequests.DraftUpdateRequest(0, draft), "courier-edit");
assertThat(order.getExpressTrackingNo()).isEqualTo(
"SELF_EXPRESS".equals(pickupMethod) && !tracking.isBlank() ? tracking.trim() : null);
verify(repository).saveOrder(10001L, order);
}
}
}
@Test
void creatingDraftPreservesProductImageDimensionsAndRemark() {
OrderRepository repository = mock(OrderRepository.class);
-1
View File
@@ -10,7 +10,6 @@
| 类别 | 正式入口 | 管理边界 |
|---|---|---|
| 本机重型检查限流 | `local-check.ps1` | 所有工作目录、对话共用一个用户级执行名额;PowerShell 7,状态/超时/并发与 Job Object 回收规则见外层 `docs/AI工程执行与协作约束.md`;只读状态不申请重型名额 |
| 测试/灰度单票验收 | `prod-release-acceptance.mjs`、`prod-release-delivery-stage.mjs`、内部 `prod-release-order-stage.mjs` | 显式 test/gray、统一 Gateway/accessToken;同一票义乌到洛杉矶、前置同时核对东莞;full-flow 串行覆盖买单FBA正常链至签收回传及车次资源释放,保留分阶段入口与独立检查点;不调用测试造数模块,不创建账号/主档或删除数据;配置和边界见 README |
| Admin 全量 HTTP 验证 | `admin-test-env-acceptance.mjs` | 通过显式环境文件运行统一 TEST Gateway/会话,拒绝旧海外网关参数;默认模块覆盖平台全链路,定向验证使用 `--modules`,模块注册和依赖以总入口及 `admin-test-env-modules/README.md` 为准 |
| Client 全量 HTTP 验证 | `client-test-env-acceptance.mjs` | 通过显式环境文件运行统一 TEST Gateway/会话,拒绝旧海外网关参数;覆盖客户工作台完整链路,`client-phase2-test-env-acceptance.mjs` 只是入库测量与计费专项,不替代全量入口 |
-2
View File
@@ -1,7 +1,5 @@
# 后端脚本管理与全量 HTTP 验收索引
`local-check.ps1`:Windows / PowerShell 7 本机重型检查统一入口,适用于前后端测试、类型检查、构建、安装依赖及 Maven。示例:`& ./scripts/local-check.ps1 -WorkingDirectory ../apps/admin-web -Label 'Admin typecheck' -Command @('pnpm', 'typecheck')`;`-Status` 查看占用。当前用户所有工作目录共用一个文件句柄锁,默认排队900秒、执行1200秒;超时分别退出75/124,其余透传命令退出码。Vitest workers=2,Node堆2048MB,Java堆1536MB/处理器2;直接 Maven 自动单线程/单fork,直接 Node 内置测试自动并发2。Windows Job Object 仅回收本次后代,不接管旧进程;嵌套调用拒绝,批次内部执行原始命令。无凭据、不主动联网或写业务数据;被包装命令的环境授权不变。状态和最近结果仅写 `%LOCALAPPDATA%/Chongchong/LocalChecks/`,不存命令参数或输出;不自动缓存成功结果。失败保留最近摘要,确认输入后重新排队;锁文件无需手工删除,状态不是验收报告。完整规则见 `../../docs/AI工程执行与协作约束.md` 的“本机重型检查与多对话限流”;离线行为测试为 `local-check.test.mjs`。
`local-transfer-pickup-acceptance.mjs`:仅本机 LOCAL 仓储转运取货造数与 HTTP 验收,拒绝非 localhost/127.0.0.1 Gateway。通过 `node --env-file=<本轮明确的本地配置> scripts/local-transfer-pickup-acceptance.mjs` 注入 `ADMIN_TEST_GATEWAY`、`ADMIN_TEST_USERNAME`、`ADMIN_TEST_PASSWORD`、`LOCAL_TRANSFER_PDA_PASSWORD`,不猜测或读取其他环境文件。创建16张明确 TEST 标记的业务单、专用PDA账号、司机车辆和多状态取货车次,含客户下单、跨客户共车、取消改派、资源占用、凭证隔离、送仓门禁与完整PDA测量。所有业务均走正式 Gateway HTTP,不用业务 SQL;文件是合成测试 PNG,不代表实际拍照或有效证照。报告默认 `reports/PICKUP-<新批次>.json`,可用 `PICKUP_ACCEPTANCE_OUTPUT` 指定;写入前保存脱敏意图与幂等键,逐阶段落盘。失败立即停止并保留已成功数据及报告,未知结果先核对原请求,不自动重发;重新执行会创建新批次,不自动续跑或清理旧记录。人工核实原请求结果后,可显式用 `PICKUP_ACCEPTANCE_RESUME=<本轮报告>` 沿已知单号继续验收;已成功批次禁止重复执行。脚本记录每次执行范围与结果,旧失败不删除;本机缺失的正式车次取消/结案/释放权限仅补权限资源并授权新建专用验收角色,不修改现有角色授权。账号和单据保留供本地页面验收,不能作为远端 TEST/生产或视觉验收结论。
`db/fleet-transfer-pickup-20261017.sql`:本功能 MySQL8 可重复执行结构增量,显式指向 `baiyue_lastleg`。为原车次增加 `trip_purpose`,新增取货计划摘要、原单任务、命令幂等、上传归属及历史五表;不写业务数据。结构同步于模块 `lastleg.sql` 和统一部署增量。需在已授权环境用运行者显式数据库凭据执行;失败保留诊断,修正后可重复执行,不删旧结构或业务数据。
+1 -2
View File
@@ -11,7 +11,7 @@ const OPTIONAL_MODULES = ['phase5-root-fulfillment', 'phase4-overseas-return-fol
'manual-pda-ready-fixture', 'manual-pda-ready-verify',
'manual-pda-batch-fixture', 'manual-pda-batch-verify',
'manual-loading-complete-fixture', 'manual-loading-complete-verify',
'coload-groups', 'dropship-pallet-matrix', 'dropship-mixed-palletization', 'dropship-physical-carton-chain', 'dropship-browser-fixture', 'mixed-container-receiving', 'pda-loading-link-regression', 'fleet-vehicle-care', 'fleet-driver-qualification']
'coload-groups', 'dropship-pallet-matrix', 'dropship-physical-carton-chain', 'dropship-browser-fixture', 'mixed-container-receiving', 'pda-loading-link-regression', 'fleet-vehicle-care', 'fleet-driver-qualification']
const AVAILABLE_MODULES = new Set([...CONFIGURED_MODULES, ...OPTIONAL_MODULES])
const A0_LOCATION_PAGE_SIZE = 200
const A0_MAX_LOCATION_MUTATIONS = 8
@@ -61,7 +61,6 @@ const MODULE_DEPENDENCIES = new Map([
['pda-loading-link-regression', ['merchant-qualification-fixture']],
['phase4-overseas-return-followups', ['phase4-overseas-return-receipt']],
['dropship-pallet-matrix', ['merchant-qualification-fixture']],
['dropship-mixed-palletization', ['merchant-qualification-fixture']],
['dropship-physical-carton-chain', ['merchant-qualification-fixture']],
['dropship-browser-fixture', ['merchant-qualification-fixture']],
['mixed-container-receiving', ['merchant-qualification-fixture']],
-2
View File
@@ -172,8 +172,6 @@ export async function run(context) {
`dropship-browser-fixture.mjs` 通过总入口 `--modules dropship-browser-fixture` 执行,自动依赖客户资质准备;复用矩阵生产者创建一票四箱两托,完成国内装柜、船节点和按柜生成海外入库计划,明确停在 `READY_FOR_BROWSER_RECEIVING`。只准备新测试数据,不调用海外 PDA 收货或出库、不创建海外操作员;后续由浏览器页面验收。报告仍保存于 `createdResources.dropshipPalletMatrix`,准备通过不代表全流程通过,不自动删除数据。
`dropship-mixed-palletization.mjs` 通过总入口 `--modules dropship-mixed-palletization` 执行,自动依赖客户资质准备。经正式 HTTP 创建两客户三票一件代发(2/3/1 个物理箱,每箱两个 SKU),完成下单审核和测量,交替调用跨客户箱码预览、领取、清点、先箱后托批绑、移回/归回、六箱混托封托、解封和重新封托。核对客户/入仓单/SKU/数量守恒、只读识别无写入、同键重放、未知码/跨仓拒绝及逐票待分柜;结果保存在 `createdResources.dropshipMixedPalletization`。另外保留四票已测量、未领取未组托样本:同客户两票(2/1 箱)和跨客户两票(2/3 箱),逐票核对 Admin `WAIT_PALLETIZE` 列表可见,保存 `manualRecords` 和 `manualStage=WAIT_PALLETIZE_VISIBLE`。显式启用 `orders` 的 `orderFixtureOnly` 来源生产范围,保留审核收敛、重放与受理启动,默认订单全量验收仍覆盖申报修改;称重复用当前仓已有托盘,不要求固定编号。使用显式目标环境文件,开发机获授权时 Gateway 必须显式指向本机。会创建并保留客户、订单、测量与托盘数据,不自动清理;失败必须检查报告,重跑创建新批次。终点为 `MIXED_PALLET_SEALED_VERIFIED`,不代表装柜、海外履约或 Android 真机验收通过。离线合同为 `dropship-mixed-palletization-contract.test.mjs`。
`dropship-pallet-matrix.mjs` 验证下述四场景整托手工加入、整托移除、智能推荐、独立订舱与文件放舱、真实柜号关联、预装确认、PDA 整托拣货和五类证据上传;异步装柜完成必须等到任务 `COMPLETED` 并逐票回查 `LOADED`。智能装柜的托盘汇总与装柜详情逐票组成分别核对,不能把共托汇总行的首票编号当作全托归属。随后经正式报关、国内离仓及人工船节点接口推进至 `PICKUP_READY`,再按柜生成逐票海外入库计划,检查来源托盘完整绑定、共享托盘身份一致和同键重放。海外收货使用本轮创建并绑定接柜仓的 PDA 操作员,上传三类照片、签收、逐实物托盘收货、逐票箱数核平和完成入库,同键重放及同托新键扫码均不得重复入账。随后从本轮逐票库存候选创建一件代发专用出库,携带原始箱唛由 PDA 拣货、完成,核对终态、幂等和可用库存扣减;finally 停用本轮操作员。出库重放按业务身份和数据库毫秒时间精度核对。离线合同为 `dropship-pallet-matrix-contract.test.mjs`;脚本覆盖不等于真实 HTTP 已通过,须检查当轮报告和库存审计。
`dropship-physical-carton-chain.mjs` 复用同一条正式链路,专门覆盖多 SKU 共用物理箱:20 箱×2 SKU/2 托、5 箱×3 SKU/1 托、7 箱×2 SKU/预计 2 托后合为 1 托。每个场景从新订单、测量、箱唛冻结和 PDA 清点开始,按去重箱唛核对预装、装柜、尾程计划、海外整托收货、库存候选和一件代发出库;每箱必须保留全部 SKU 明细,但任何汇总不得按 SKU 行重复累计箱数。使用 `--modules dropship-physical-carton-chain`,会保留本轮业务数据和 JSON 报告,不自动清理。
@@ -1,33 +0,0 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
const source = readFileSync(new URL('./dropship-mixed-palletization.mjs', import.meta.url), 'utf8')
const orders = readFileSync(new URL('./orders.mjs', import.meta.url), 'utf8')
test('mixed pallet acceptance uses formal producers and fails on any failed preparation assertion', () => {
assert.match(source, /await createOrder\(context\)/)
assert.match(source, /await measure\(context\)/)
assert.match(source, /!context.steps.slice\(start\).some\(\(step\) => !step.passed\)/)
assert.match(source, /if \(!passed\) throw new Error/)
assert.match(source, /context.context.allowAnyExistingPallet = true/)
})
test('retained manual fixtures cover same and different customers and remain visible and unclaimed', () => {
assert.match(source, /\[2, 1, 2, 3\].entries\(\)/)
assert.match(source, /index < 3 \? savedContext.merchantQualificationFixture : secondCustomer/)
assert.match(source, /index < 2 \? 'SAME_CUSTOMER' : 'CROSS_CUSTOMER'/)
assert.match(source, /mainStatus=WAIT_PALLETIZE&inboundCode=/)
assert.match(source, /task.taskStatus === 'PENDING' && task.countedCartons === 0 && task.boundCartons === 0/)
assert.match(source, /result.manualStage = 'WAIT_PALLETIZE_VISIBLE'/)
})
test('explicit order producer scope preserves approval convergence and default declaration acceptance', () => {
const branch = orders.indexOf('if (context.context.orderFixtureOnly === true)')
assert.ok(branch > orders.indexOf('const inboundResult = await waitForInbound'))
assert.ok(branch > orders.indexOf('await verifyOrderApprovalReplay(context'))
assert.ok(branch < orders.indexOf('const editableDetailResponse'))
assert.match(orders.slice(branch, orders.indexOf('const editableDetailResponse')), /await finishOrderLifecycle\(context/)
assert.match(orders, /申报修改保持订单号入仓码和渠道且版本递增/)
assert.match(orders, /申报修改异步同步到Firstleg入库事实/)
})
@@ -1,164 +0,0 @@
import { run as createOrder } from './orders.mjs'
import { run as measure } from './inbound-measurement.mjs'
import { run as createCustomer } from './merchant-qualification-fixture.mjs'
const MODULE = 'dropship-mixed-palletization'
const BASE = '/api/platform/v1/first-leg/pda-dropship'
const OPTIONS = { scenario: MODULE, deploymentBlocker: true }
// 正式下单和测量产生两客户三票,再用 PDA 公网接口逐箱混托并保留全部证据。
export async function run(context) {
const originalMarker = context.marker
const savedContext = { ...context.context }
const originalManualCount = context.options.prepareDropshipManualCount
const records = []
let secondCustomer = null
const result = { marker: originalMarker, stage: 'PREPARING', records, fullFlowVerified: false }
context.createdResources.dropshipMixedPalletization = result
function check(name, response, passed, details = '') {
context.assert(MODULE, name, response, passed, details, OPTIONS)
if (!passed) throw new Error(name)
}
async function command(name, method, path, body, headers = {}) {
const response = await context.request(method, path, body, undefined, headers)
const value = context.apiSuccess(MODULE, name, response, '', OPTIONS)
if (String(response?.body?.code) !== '200') throw new Error(`${name}: ${response?.body?.code}: ${response?.body?.message}`)
return value
}
async function rejected(name, method, path, body) {
const response = await context.request(method, path, body)
check(name, response, response.http === 200 && ['A0404', 'A0409'].includes(String(response.body?.code)), response.body?.message)
}
async function readTask(record) {
return command(`${record.label}回查任务`, 'GET', `${BASE}/tasks/${record.source.taskId}?warehouseId=${record.source.warehouseId}`)
}
try {
context.context.orderFixtureOnly = true
context.context.allowAnyExistingPallet = true
check('存在本轮已审核客户', null, Boolean(context.context.merchantQualificationFixture?.tenantId))
for (const [index, count] of [2, 3, 1].entries()) {
const label = ['A-2BOX', 'B-3BOX', 'B-1BOX'][index]
context.marker = `${originalMarker}-${label}`
if (index === 1) { await createCustomer(context, { publishEnvironment: false }); secondCustomer = context.context.merchantQualificationFixture }
for (const key of ['orders', 'inboundMeasurement', 'dropshipManualCount']) delete context.createdResources[key]
for (const key of ['inboundWeightOcr', 'ocrInbound', 'testInbound', 'createdInbound']) delete context.context[key]
context.context.orderDestinationType = 'OVERSEAS'
context.context.orderFulfillmentType = 'ONE_CLICK_DROPSHIP'
context.context.orderProfile = { taxMode: 'BUY_DECLARATION', pickupMethod: 'SELF_EXPRESS', containerMode: 'LCL' }
context.context.waitSplitVariant = { skuCount: 2, packageCountPerSku: count, packageCounts: [count, count],
totalPackageCount: count, palletCount: 1, skuCodes: [`MIX-SAME-${originalMarker}`.slice(-60), `MIX-SECOND-${originalMarker}`.slice(-60)] }
if (records.length) context.context.waitSplitRoute = { originWarehouseName: records[0].order.receivingWarehouseName,
channelCode: records[0].order.channelCode, destinationIndex: 0 }
context.options.prepareDropshipManualCount = true
const start = context.steps.length
await createOrder(context)
await measure(context)
const source = context.createdResources.dropshipManualCount
check(`${label}真实下单审核测量全部通过`, null, Boolean(source?.taskId)
&& !context.steps.slice(start).some((step) => !step.passed))
records.push({ label, order: { ...context.createdResources.orders }, measurement: { ...context.createdResources.inboundMeasurement }, source: { ...source } })
check(`${label}实测物理箱数正确`, null, source.totalCartons === count)
}
check('两客户三票保持独立归属', null, records.length === 3 && new Set(records.map((r) => r.order.tenantId)).size === 2)
check('三票同仓同渠道同目的仓', null, records.every((r) => r.source.warehouseId === records[0].source.warehouseId
&& r.order.channelCode === records[0].order.channelCode && r.order.destinationWarehouseId === records[0].order.destinationWarehouseId))
context.marker = originalMarker
const warehouseId = records[0].source.warehouseId
const query = `warehouseId=${warehouseId}`
const generated = await command('读取第一票预生成托盘', 'GET', `${BASE}/inbound-orders/${records[0].source.inboundId}/pallets?accountId=${records[0].order.tenantId}`)
const palletNo = generated?.[0]?.palletNo
check('取得空的预生成DP托盘', null, Boolean(palletNo) && generated[0].status === 'OPEN' && generated[0].cartonCount === 0)
result.palletNo = palletNo; result.warehouseId = warehouseId
const palletPath = `${BASE}/pallets/resolve?${query}&palletNo=${encodeURIComponent(palletNo)}`
const initial = await command('先扫空托盘只读识别', 'GET', palletPath)
check('空托盘不会虚构客户组成', null, initial.pallet.cartonCount === 0 && initial.members.length === 0)
await rejected('无效托盘码被拒绝', 'GET', `${BASE}/pallets/resolve?${query}&palletNo=DP-000000-0000`)
await rejected('跨仓托盘码被拒绝', 'GET', `${BASE}/pallets/resolve?warehouseId=1&palletNo=${encodeURIComponent(palletNo)}`)
await rejected('无效箱码被拒绝', 'GET', `${BASE}/cartons/resolve?${query}&boxCode=NO-SUCH-${originalMarker}`)
result.stage = 'COUNTING'
const counted = []
// 交替扫描客户 A/B,证明页面无需先手工切换入仓单。
const scans = records.flatMap((record) => Array.from({ length: record.source.totalCartons }, (_, index) => ({ record, boxCode: `${record.source.inboundCode}#${record.source.totalCartons}-${index + 1}` })))
scans.sort((a, b) => Number(a.boxCode.split('-').at(-1)) - Number(b.boxCode.split('-').at(-1)))
for (const [index, { record, boxCode }] of scans.entries()) {
const resolved = await command('跨客户只读扫码预览', 'GET', `${BASE}/cartons/resolve?${query}&boxCode=${encodeURIComponent(boxCode)}`)
check('箱码返回原客户和原入仓任务', null, String(resolved.task.taskId) === record.source.taskId
&& String(resolved.task.tenantId) === record.order.tenantId && resolved.carton.skuLines.length === 2)
const before = await readTask(record)
check('读取箱码未改变清点和归托事实', null, before.countedCartons === counted.filter((x) => x.taskId === record.source.taskId).length)
if (before.taskStatus === 'PENDING') await command('确认时领取原任务', 'POST', `${BASE}/tasks/${record.source.taskId}/claim`, { warehouseId })
const body = { warehouseId, boxCode, palletNo: index === 0 ? null : palletNo,
skuLines: resolved.carton.skuLines.map((line) => ({ inboundDetailId: line.inboundDetailId, skuCode: line.skuCode,
actualQuantity: line.expectedQuantity, discrepancyAction: 'MATCH' })), operationId: `${originalMarker}-count-${index}` }
const submitted = await command('按原任务确认逐箱实收', 'POST', `${BASE}/tasks/${record.source.taskId}/cartons/count`, body)
const replayed = await command('清点同键重放不重复入账', 'POST', `${BASE}/tasks/${record.source.taskId}/cartons/count`, body)
check('重放保持箱身份和清点数量', null, submitted.carton.cartonId === replayed.carton.cartonId && submitted.countedCartons === replayed.countedCartons)
counted.push({ taskId: record.source.taskId, boxCode, cartonId: submitted.carton.cartonId, inboundId: record.source.inboundId, skuLines: body.skuLines })
if (index === 0) await command('先扫箱再扫托盘批量归托', 'POST', `${BASE}/tasks/${record.source.taskId}/pallets/bind-pending`, { warehouseId, palletNo, operationId: `${originalMarker}-bind` })
}
const beforeSeal = await command('封托前逐客户汇总核对', 'GET', palletPath)
check('六个实物箱两客户三票汇总守恒', null, beforeSeal.pallet.cartonCount === 6 && beforeSeal.members.length === 3
&& new Set(beforeSeal.members.map((m) => String(m.customerId))).size === 2
&& beforeSeal.members.every((m) => m.cartonCount === records.find((r) => r.source.taskId === String(m.taskId))?.source.totalCartons))
const moved = counted.find((item) => item.taskId === records[1].source.taskId)
await command('按客户B原任务移回待绑托', 'POST', `${BASE}/tasks/${moved.taskId}/cartons/${moved.cartonId}/move`, { warehouseId, targetPalletNo: null, operationId: `${originalMarker}-move-out` })
const movedTask = await readTask(records[1])
check('只改变原票归托数量', null, movedTask.unboundCartons === 1 && movedTask.countedCartons === 3)
await command('客户B已点箱归回当前混托', 'POST', `${BASE}/tasks/${moved.taskId}/cartons/${moved.cartonId}/move`, { warehouseId, targetPalletNo: palletNo, operationId: `${originalMarker}-move-back` })
const open = await command('归回后读取权威托盘', 'GET', palletPath)
const sealPath = `${BASE}/tasks/${records[0].source.taskId}/pallets/${open.pallet.palletId}/seal`
await command('混托整托封托', 'POST', sealPath, { warehouseId, expectedVersion: open.pallet.version, operationId: `${originalMarker}-seal` })
let sealed = await command('回查已封托状态', 'GET', palletPath)
check('托盘已封托且六箱完整', null, sealed.pallet.status === 'SEALED' && sealed.pallet.cartonCount === 6)
for (const record of records) {
record.task = await readTask(record)
const inbound = await command('封托后回查原票待分柜', 'GET', `/api/platform/inbounds/${record.source.inboundId}?tenantId=${record.order.tenantId}`)
check('参与票全部完成且进入待分柜', null, record.task.taskStatus === 'COMPLETED' && inbound.summary.processStatus === 'WAIT_SPLIT')
}
const actual = sealed.pallet.cartons
check('每箱客户归属和双SKU实收未合并', null, actual.length === counted.length && actual.every((carton) => {
const source = counted.find((item) => item.boxCode === carton.boxCode)
return source && String(carton.inboundId) === source.inboundId && carton.skuLines.length === 2
&& carton.skuLines.every((line) => source.skuLines.some((expected) => String(line.inboundDetailId) === String(expected.inboundDetailId) && line.actualQuantity === expected.actualQuantity))
}))
await rejected('已封托禁止移箱', 'POST', `${BASE}/tasks/${moved.taskId}/cartons/${moved.cartonId}/move`, { warehouseId, targetPalletNo: null, operationId: `${originalMarker}-sealed-move` })
await command('未入装柜计划允许解除封托', 'POST', `${BASE}/tasks/${records[0].source.taskId}/pallets/${sealed.pallet.palletId}/unseal`, { warehouseId, expectedVersion: sealed.pallet.version, operationId: `${originalMarker}-unseal` })
for (const record of records) check('解封同步恢复所有参与任务', null, (await readTask(record)).taskStatus === 'CLAIMED')
const reopened = await command('回查解封后开放状态', 'GET', palletPath)
await command('核对后重新封托', 'POST', sealPath, { warehouseId, expectedVersion: reopened.pallet.version, operationId: `${originalMarker}-reseal` })
sealed = await command('最终托盘事实', 'GET', palletPath)
result.pallet = sealed.pallet; result.members = sealed.members; result.counted = counted
result.stage = 'MIXED_PALLET_SEALED_VERIFIED'
for (const record of records) record.task = await readTask(record)
// 保留同客户两票和跨客户两票独立样本,均停在未领取、未组托状态。
result.manualRecords = []
for (const [index, count] of [2, 1, 2, 3].entries()) {
context.marker = `${originalMarker}-MANUAL-${index + 1}`
context.context.merchantQualificationFixture = index < 3 ? savedContext.merchantQualificationFixture : secondCustomer
for (const key of ['orders', 'inboundMeasurement', 'dropshipManualCount']) delete context.createdResources[key]
for (const key of ['inboundWeightOcr', 'ocrInbound', 'testInbound', 'createdInbound']) delete context.context[key]
context.context.orderDestinationType = 'OVERSEAS'
context.context.orderFulfillmentType = 'ONE_CLICK_DROPSHIP'
context.context.waitSplitVariant = { skuCount: 2, packageCountPerSku: count, packageCounts: [count, count],
totalPackageCount: count, palletCount: 1, skuCodes: [`MANUAL-COMMON-${originalMarker}`.slice(-60), `MANUAL-SECOND-${originalMarker}`.slice(-60)] }
context.options.prepareDropshipManualCount = true
const start = context.steps.length
await createOrder(context)
await measure(context)
const source = context.createdResources.dropshipManualCount
check('人工打托样本正式下单测量通过', null, Boolean(source?.taskId) && !context.steps.slice(start).some((step) => !step.passed))
const order = { ...context.createdResources.orders }
const list = await command('回查一件代发打托列表可见', 'GET', `/api/platform/v1/orders?mainStatus=WAIT_PALLETIZE&inboundCode=${encodeURIComponent(source.inboundCode)}&page=1&size=20`)
const row = list.records?.find((item) => String(item.orderId) === order.orderId)
check('人工样本出现在用户的一件代发打托页签', null, Boolean(row) && row.mainStatus === 'WAIT_PALLETIZE')
const task = await readTask({ label: `MANUAL-${index + 1}`, source })
check('人工样本未领取未清点未归托', null, task.taskStatus === 'PENDING' && task.countedCartons === 0 && task.boundCartons === 0)
result.manualRecords.push({ label: `MANUAL-${index + 1}`, group: index < 2 ? 'SAME_CUSTOMER' : 'CROSS_CUSTOMER',
order, source: { ...source }, measurement: { ...context.createdResources.inboundMeasurement } })
}
result.manualStage = 'WAIT_PALLETIZE_VISIBLE'
} finally {
context.marker = originalMarker; context.context = savedContext
context.options.prepareDropshipManualCount = originalManualCount
}
}
@@ -1,8 +1,7 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import { createHash } from 'node:crypto'
import { verifyFleetCrossCustomer, fixtureContract, continueFleetCrossCustomerAfterLoading, afterLoadingContinuationContract } from './fleet-cross-customer-acceptance.mjs'
import { verifyFleetCrossCustomer, fixtureContract } from './fleet-cross-customer-acceptance.mjs'
function model(fault = '') {
const copy = value => value == null ? value : structuredClone(value), calls = [], keys = new Map()
@@ -16,7 +15,7 @@ function model(fault = '') {
loadingMeasurements: { marker: 'RUN', environment: 'TEST', dispatchNo: 'DS-ONE', pallets: members.map((m,i) => ({ warehousePalletId: m.palletId,
palletSpaces: 1, deliveryIndex: i+1, evidence: [actualEvidence('palletSpaces',1,i)] })) } }
const trip = { trip: { tripNo: 'TRIP', warehouseId: 'WH', status: 'PLANNED', recordVersion: '0', vehicleId: 'V', driverId: 'D', stops }, dispatches: [{ dispatchNo: 'DS-ONE' }] }
let nativeVersion = 5, stage = 'LOADING', arranged = false, confirmedLoad = null, stopAtArrangement = false, aggregationReads = 4
let nativeVersion = 5, stage = 'LOADING', arranged = false
const state = members.map(m => ({ picked: 0, delivery: null, outbound: null,
stock: { scope: { marker: 'RUN', customerId: m.customerId, warehouseId: 'WH', coverage: 'CUSTOMER_WAREHOUSE_COMPLETE' },
balances: [{ id: m.balanceId, customerId: m.customerId, overseasWarehouseId: 'WH', availablePackages: '1', holdPackages: '0', allocatedPackages: '0', pickedPackages: '0', receivingPackages: '0', inTransitPackages: '0', procurementPackages: '0' }],
@@ -34,32 +33,19 @@ function model(fault = '') {
counts: counts(), idempotencyKey: body.idempotencyKey, result: payload, replayed })
const response = data => ({ http: 200, body: { code: '200', data: copy(data) }, requestId: 'request', traceId: 'trace' })
const reject = message => ({ http: 200, body: { code: 'A0409', message }, requestId: 'request', traceId: 'trace' })
const forbidden = () => ({ http: 200, body: { code: 'A0403', message: '该请求标识不属于当前操作员', requestId: 'request', traceId: 'trace' }, requestId: 'request', traceId: 'trace' })
const remember = (body,payload,actor,action) => { const value = {...result(body,payload),...(action?{action}:{})}; keys.set(body.idempotencyKey,{body:copy(body),result:copy(value),actor});return response(value) }
const remember = (body,payload) => { const value = result(body,payload); keys.set(body.idempotencyKey,{body:copy(body),result:copy(value)});return response(value) }
const context = { marker: 'RUN', token: 'admin', createdResources: { fleetTrips: [tripProof] }, assert(_m,label,_r,condition) { assert.ok(condition,label) },
apiSuccess(_m,_l,r) { return r.body.data },
async request(method,raw,body,token) {
calls.push({method,raw,body:copy(body),token}); const url = new URL(raw,'https://offline.invalid'), path = url.pathname
if (method === 'POST' && keys.has(body?.idempotencyKey)) {
const prior = keys.get(body.idempotencyKey); assert.deepEqual(body,prior.body)
if(token!==prior.actor) {
if(fault==='foreign departure actor accepted'&&path.endsWith('/depart'))return response({...prior.result,replayed:true})
if(fault==='foreign departure actor mutates'&&path.endsWith('/depart'))state[0].stock.ledger.push({...state[0].stock.ledger[0],id:'FOREIGN-ACTOR-MUTATION'})
return forbidden()
}
return response({...prior.result,replayed:true})
const prior = keys.get(body.idempotencyKey); assert.deepEqual(body,prior.body); return response({...prior.result,replayed:true})
}
if (path === '/api/platform/v1/last-leg/fleet/trips/TRIP') return response(trip)
if (path === '/api/platform/v1/last-leg/dispatches/DS-ONE/items') return response({dispatchNo:'DS-ONE',recordVersion:nativeVersion,items:items()})
if (path === '/api/platform/v1/last-leg/dispatches/DS-ONE') {
if(method==='PUT')return reject('已经真实扫码,不能编辑资源')
if(fault==='aggregation delayed'&&state.every(s=>s.delivery?.order.status==='DELIVERED')) {
if(aggregationReads===0)stage='DELIVERED';else aggregationReads--
}
return response(detail())
}
if (path === '/api/platform/v1/last-leg/dispatches/DS-ONE') return method === 'PUT' ? reject('已经真实扫码,不能编辑资源') : response(detail())
if (path.endsWith('/loading') && method === 'GET') return response({dispatchNo:'DS-ONE',status:'ACTIVE',stage,recordVersion:nativeVersion,manifestHash:'REAL-MANIFEST',
pickingReconciled:state.every(s=>s.picked===1),confirmedLoad,loadingComplete:{allowed:state.every(s=>s.picked===1)},pallets:members.map((m,i)=>({warehousePalletId:m.palletId,palletNo:m.palletNo,
pickingReconciled:state.every(s=>s.picked===1),loadingComplete:{allowed:state.every(s=>s.picked===1)},pallets:members.map((m,i)=>({warehousePalletId:m.palletId,palletNo:m.palletNo,
scheduledPackages:1,pickedPackages:state[i].picked,palletSpaces:null,weightKg:100,volumeCbm:1,deliveryIndex:i+1,
evidence:[actualEvidence('weightKg',100,i),actualEvidence('volumeCbm',1,i)]}))})
if (path.endsWith('/loading-complete')) {
@@ -68,9 +54,7 @@ function model(fault = '') {
return reject(fault==='unrelated gate rejection'?'车次版本已变化':'逐项真实拣货尚未核平')
}
assert.equal(body.recordVersion,nativeVersion); assert.equal(body.measurements.length,2)
stage='AFTER_LOADING';nativeVersion++
confirmedLoad={manifestHash:body.manifestHash,pallets:copy(body.measurements),operatorId:'PDA-ACTOR',confirmedAt:'2026-10-19T01:00:00Z'}
return remember(body,{confirmedLoad,loadingCompletionHash:'LOADED'},token,'LOADING_COMPLETE')
stage='AFTER_LOADING';nativeVersion++;return remember(body,{confirmedLoad:{manifestHash:body.manifestHash,pallets:body.measurements}})
}
if (path.endsWith('/pick')) {
const index=members.findIndex(m=>path.includes(`/items/${m.dispatchItemId}/`)),m=members[index],row=state[index]
@@ -78,21 +62,18 @@ function model(fault = '') {
row.stock.positions[0].availablePackages='0';row.stock.balances[0].availablePackages='0'
row.stock.ledger.push(...['ALLOCATE','PICK','SHIP'].map(type=>({id:`${type}${index}`,customerId:m.customerId,overseasWarehouseId:'WH',positionId:m.positionId,businessOrderNo:'DS-ONE',businessType:type,deltaPackages:type==='SHIP'?'-1':'1'})))
if(fault==='duplicate ship')row.stock.ledger.push({...row.stock.ledger.at(-1),id:'EXTRA'})
return remember(body,{dispatchItemId:m.dispatchItemId,pickedPackages:1},token)
return remember(body,{dispatchItemId:m.dispatchItemId,pickedPackages:1})
}
if (path === '/api/pda/v1/last-leg/dispatches/DS-ONE') return response({dispatchNo:'DS-ONE',warehouseId:'WH',status:'ACTIVE',stage,recordVersion:nativeVersion,counts:counts(),pickingReconciled:state.every(s=>s.picked===1),
items:items().map((item,i)=>({...item,positions:fault==='missing PDA position'?[]:[{positionId:members[i].positionId,balanceId:members[i].balanceId,availablePackages:state[i].stock.positions[0].availablePackages}]}))})
if (path.endsWith('/delivery-arrangement')) {
if(stopAtArrangement)throw Error('Explicit test checkpoint after confirmed loading')
return response({dispatchNo:'DS-ONE',recordVersion:nativeVersion,status:'ACTIVE',stage,cargoHash:'CARGO',loadingCompletionHash:'LOADED',arrange:{allowed:true},arrangement:arranged?{status:'ARRANGED'}:null,
if (path.endsWith('/delivery-arrangement')) return response({dispatchNo:'DS-ONE',recordVersion:nativeVersion,status:'ACTIVE',stage,cargoHash:'CARGO',loadingCompletionHash:'LOADED',arrange:{allowed:true},
groups:members.map((m,i)=>({groupKey:`GROUP${i}`,customerId:m.customerId,groupCargoHash:`GROUP-HASH${i}`,destination:{key:i?'FBA:B':'FBA:A'},cargo:[{customerId:m.customerId,warehousePalletId:m.palletId}],blockReasons:[],requiredServices:['DELIVERY_FEE']}))})
}
if (path.endsWith('/transfer-delivery')) {
assert.equal(stage,'AFTER_LOADING');assert.equal(body.recordVersion,nativeVersion);assert.equal(body.groups.length,2)
assert.ok(body.groups.every(g=>g.fees.length===1&&g.fees[0].amount===30))
for(const [i,s] of state.entries()) { const m=members[i];s.outbound={outboundNo:`OUT${i}`,dispatchNo:'DS-ONE',overseasWarehouseId:'WH',customerId:m.customerId,status:'WAIT_OUTBOUND'}
s.delivery={order:{deliveryNo:`DL${i}`,customerId:fault==='foreign delivery'?'OTHER':m.customerId,dispatchNo:'DS-ONE',outboundNo:`OUT${i}`,status:'WAIT_DELIVERY',version:0}} }
arranged=true;nativeVersion++;return remember(body,{dispatchNo:'DS-ONE',outbounds:state.map((s,i)=>({...s.outbound,packageCount:1})),deliveries:deliveryRefs()},token)
arranged=true;nativeVersion++;return remember(body,{dispatchNo:'DS-ONE',outbounds:state.map((s,i)=>({...s.outbound,packageCount:1})),deliveries:deliveryRefs()})
}
if (path.endsWith('/depart')) {
if(!arranged) {
@@ -103,7 +84,7 @@ function model(fault = '') {
assert.equal(body.recordVersion,nativeVersion);stage='DELIVERING';nativeVersion++;trip.trip.status='IN_TRANSIT'
for(const [i,s]of state.entries())if(!(fault==='partial departure'&&i===1)){s.outbound.status='OUTBOUNDED';s.delivery.order.status='DELIVERING'}
if(fault==='depart deducts again'){state[0].stock.balances[0].availablePackages='-1';state[0].stock.positions[0].availablePackages='-1'}
return remember(body,{shippedPackages:2,departedAt:'2026-10-19T01:00:00Z',deliveries:deliveryRefs(),outbounds:state.map(s=>s.outbound)},token)
return remember(body,{shippedPackages:2,departedAt:'2026-10-19T01:00:00Z',deliveries:deliveryRefs(),outbounds:state.map(s=>s.outbound)})
}
const index=members.findIndex((_m,i)=>path.includes(`/OUT${i}`)||path.includes(`/DL${i}`)),row=state[index]
if(path.includes('/outbound-orders/'))return response({outbound:row.outbound,deliveries:[row.delivery.order]})
@@ -111,14 +92,14 @@ function model(fault = '') {
if(method==='GET')return response({version:row.delivery.order.version,categories:[]})
row.delivery.order.status='DELIVERED';row.delivery.order.version++
if(fault==='signs both')state[1-index].delivery.order.status='DELIVERED'
if(state.every(s=>s.delivery.order.status==='DELIVERED')&&!['aggregation delayed','aggregation never completes'].includes(fault))stage='DELIVERED'
if(state.every(s=>s.delivery.order.status==='DELIVERED'))stage='DELIVERED'
return response({version:row.delivery.order.version})
}
if(path.includes('/delivery-orders/')) {assert.equal(url.searchParams.get('customerId'),members[index].customerId);return response(row.delivery)}
throw new Error(`Unexpected ${method} ${path}`)
},async requestMultipart(method,path) {calls.push({method,raw:path,multipart:true});return response({fileId:`FILE-${path.includes('DL0')?0:1}`})} }
const readInventory=async scope=>{const value=copy(state[members.findIndex(m=>m.customerId===scope.customerId)].stock);if(fault==='wrong stock scope')value.positions[0].customerId='OTHER';return value}
return {context,fixture,readInventory,calls,state,stopAfterLoading(){stopAtArrangement=true},resumeAfterLoading(){stopAtArrangement=false}}
return {context,fixture,readInventory,calls,state}
}
test('default call and import do not request, create or activate anything',async()=>{
let called=false;const result=await verifyFleetCrossCustomer({request(){called=true}},{})
@@ -127,7 +108,7 @@ test('default call and import do not request, create or activate anything',async
test('current DTO-shaped two-customer trip rejects unready gate, departs once and signs independently',async()=>{
const m=model();const proof=await verifyFleetCrossCustomer(m.context,m.fixture,{execute:true,readInventory:m.readInventory})
assert.equal(proof.status,'VERIFIED');assert.equal(proof.gateProof,'PREREQUISITE_REJECTION_VERIFIED');assert.equal(proof.midTransactionRollback,'NOT_TESTED')
assert.equal(m.calls.filter(c=>c.raw.endsWith('/depart')).length,4)
assert.equal(m.calls.filter(c=>c.raw.endsWith('/depart')).length,3)
assert.deepEqual(m.state.map(s=>s.delivery.order.status),['DELIVERED','DELIVERED'])
assert.ok(m.calls.filter(c=>c.raw.includes('/api/pda/')).every(c=>c.token===m.fixture.pdaToken))
assert.ok(!JSON.stringify(proof).includes(m.fixture.pdaToken))
@@ -167,7 +148,7 @@ for(const failure of ['unverified','throws','mutates'])test(`rollback verificati
assert.equal(m.calls.filter(call=>call.raw.endsWith('/depart')).length,1)
assert.ok(m.state.every(row=>row.delivery.order.status==='WAIT_DELIVERY'))
})
for(const fault of ['wrong stock scope','missing PDA position','duplicate ship','gate mutates second customer','gate reorders route','outage disguised as rejection','unrelated gate rejection','partial departure','depart deducts again','signs both','foreign delivery','foreign departure actor accepted','foreign departure actor mutates']) {
for(const fault of ['wrong stock scope','missing PDA position','duplicate ship','gate mutates second customer','gate reorders route','outage disguised as rejection','unrelated gate rejection','partial departure','depart deducts again','signs both','foreign delivery']) {
test(`fails closed without widening the claimed evidence: ${fault}`,async()=>{
const m=model(fault);await assert.rejects(verifyFleetCrossCustomer(m.context,m.fixture,{execute:true,readInventory:m.readInventory}),
fault==='gate reorders route'?/Rejected departure leaves both complete inventories\/outbounds\/deliveries\/trip unchanged/:undefined)
@@ -186,150 +167,3 @@ test('module contains no autonomous runner or SQL/environment/credential plumbin
assert.doesNotMatch(source,/process\.env|fetch\(|execSync|INSERT INTO|UPDATE ll_|createPicker|createMerchant/)
assert.match(source,/confirmFleetLoading/)
})
async function afterLoadingModel(fault = '') {
const m = model(fault)
m.stopAfterLoading()
await assert.rejects(verifyFleetCrossCustomer(m.context,m.fixture,{execute:true,readInventory:m.readInventory}),/Explicit test checkpoint after confirmed loading/)
const priorProof = structuredClone(m.context.createdResources.fleetCrossCustomer)
assert.equal(priorProof.status,'FAILED')
assert.equal(priorProof.gateProof,'PREREQUISITE_REJECTION_VERIFIED')
assert.equal(m.fixture.tripProof.loadingCommand.result.stage,'AFTER_LOADING')
delete m.context.createdResources.fleetCrossCustomer
m.fixture.stage = afterLoadingContinuationContract.stage
m.resumeAfterLoading()
m.calls.length = 0
return {...m,priorProof}
}
const continueLoaded = (m,extra = {}) => continueFleetCrossCustomerAfterLoading(m.context,m.fixture,
{execute:true,readInventory:m.readInventory,priorProof:m.priorProof,completionPolling:{maxAttempts:8,intervalMs:1,deadlineMs:1000},...extra})
const writes = m => m.calls.filter(call=>!['GET','HEAD','OPTIONS'].includes(call.method))
function alterRead(m,match,alter) {
const original = m.context.request
m.context.request = async function(...args) {
const response = await original.apply(this,args)
if(args[0]==='GET'&&match(args[1]))alter(response.body.data)
return response
}
}
test('explicit after-loading import/default stays offline and is not a fallback of the fresh entry',async()=>{
let called=false
assert.equal(await continueFleetCrossCustomerAfterLoading({request(){called=true}},{}),afterLoadingContinuationContract)
assert.equal(called,false)
const m=await afterLoadingModel()
await assert.rejects(verifyFleetCrossCustomer(m.context,m.fixture,{execute:true,readInventory:m.readInventory}))
assert.equal(m.calls.length,0)
})
test('explicit after-loading continuation reconciles original proof and never repeats picking or loading',async()=>{
const m=await afterLoadingModel(),original=structuredClone(m.priorProof),loading=structuredClone(m.fixture.tripProof.loadingCommand)
const proof=await continueLoaded(m)
assert.equal(proof.status,'VERIFIED');assert.equal(proof.continued,true)
assert.equal(proof.continuation.mode,'EXPLICIT_AFTER_LOADING')
assert.equal(proof.continuation.gateProofSource,'PRIOR_PROOF')
assert.match(proof.continuation.priorProofSHA256,/^[a-f0-9]{64}$/)
const normalize=value=>Array.isArray(value)?value.map(normalize):value&&typeof value==='object'?Object.fromEntries(Object.keys(value).sort().map(key=>[key,normalize(value[key])])):value
assert.equal(proof.continuation.priorProofSHA256,createHash('sha256').update(JSON.stringify(normalize(original))).digest('hex'))
assert.deepEqual(m.priorProof,original);assert.deepEqual(m.fixture.tripProof.loadingCommand,loading)
assert.equal(proof.snapshots.AFTER_PICK_1,undefined,'no historical loading snapshot is fabricated in the new proof')
assert.ok(proof.snapshots.CONTINUATION_INITIAL);assert.deepEqual(proof.continuation.originalAfterPickSnapshot,original.snapshots.AFTER_PICK_1)
assert.equal(writes(m).some(call=>call.raw.endsWith('/pick')||call.raw.endsWith('/loading-complete')),false)
assert.equal(writes(m).filter(call=>call.raw.endsWith('/transfer-delivery')).length,2,'one arrangement plus the exact original-key replay')
const departures=writes(m).filter(call=>call.raw.endsWith('/depart'))
assert.deepEqual(departures.map(call=>call.token),[m.fixture.pdaToken,m.fixture.pdaToken,m.context.token])
assert.deepEqual(m.state.map(row=>row.delivery.order.status),['DELIVERED','DELIVERED'])
assert.equal(JSON.stringify(proof).includes(m.fixture.pdaToken),false)
})
test('after-loading accepts the official NON_NULL preview when the unarranged field is omitted',async()=>{
const m=await afterLoadingModel()
let omitted=false
alterRead(m,path=>path.endsWith('/delivery-arrangement'),value=>{
delete value.arrangement
omitted=!Object.hasOwn(value,'arrangement')
})
const proof=await continueLoaded(m)
assert.equal(omitted,true);assert.equal(proof.status,'VERIFIED');assert.equal(proof.continued,true)
assert.equal(writes(m).some(call=>call.raw.endsWith('/pick')||call.raw.endsWith('/loading-complete')),false)
})
test('after-loading rejects even an empty existing arrangement object before any business write',async()=>{
const m=await afterLoadingModel()
alterRead(m,path=>path.endsWith('/delivery-arrangement'),value=>{value.arrangement={}})
await assert.rejects(continueLoaded(m),/Continuation has no prior arrangement/)
assert.equal(writes(m).length,0)
})
for(const failure of ['missing prior','marker','gate','source identity','prior picking','unknown prior write','loading result','loading manifest','loading pallet','loading version']) {
test(`after-loading refuses incomplete or foreign source proof before any request: ${failure}`,async()=>{
const m=await afterLoadingModel()
if(failure==='missing prior')m.priorProof=undefined
if(failure==='marker')m.priorProof.marker='OTHER-RUN'
if(failure==='gate')m.priorProof.gateProof='NOT_RUN'
if(failure==='source identity')m.priorProof.members[0].positionId='FOREIGN-POSITION'
if(failure==='prior picking')m.priorProof.snapshots.AFTER_PICK_1.dispatch.counts.pickedPackageCount=1
if(failure==='unknown prior write')m.priorProof.requests.push({method:'POST',path:'/unconfirmed-command',status:'UNKNOWN_RESULT'})
if(failure==='loading result')delete m.fixture.tripProof.loadingCommand.result
if(failure==='loading manifest')m.fixture.tripProof.loadingCommand.body.manifestHash='OTHER-MANIFEST'
if(failure==='loading pallet')m.fixture.tripProof.loadingCommand.result.result.confirmedLoad.pallets[0].warehousePalletId='OTHER-PALLET'
if(failure==='loading version')m.fixture.tripProof.loadingCommand.body.recordVersion='1'
await assert.rejects(continueLoaded(m));assert.equal(m.calls.length,0)
})
}
for(const failure of ['inventory drift','current main version','current shipped','existing outbound','current source identity','manifest drift','confirmed load drift','existing arrangement']) {
test(`after-loading refuses changed current facts before arranging or departing: ${failure}`,async()=>{
const m=await afterLoadingModel()
if(failure==='inventory drift')m.state[0].stock.ledger.push({...m.state[0].stock.ledger[0],id:'UNEXPECTED-MOVEMENT'})
if(failure==='current main version')alterRead(m,path=>path.endsWith('/DS-ONE'),value=>{value.recordVersion=999})
if(failure==='current shipped')alterRead(m,path=>path.endsWith('/DS-ONE'),value=>{value.counts.shippedPackageCount=1})
if(failure==='existing outbound')alterRead(m,path=>path.endsWith('/DS-ONE'),value=>{if(value.documents)value.documents.outbounds.push({outboundNo:'EXISTING'})})
if(failure==='current source identity')alterRead(m,path=>path.endsWith('/items'),value=>{value.items[0].warehousePalletId='FOREIGN'})
if(failure==='manifest drift')alterRead(m,path=>path.endsWith('/loading'),value=>{value.manifestHash='OTHER'})
if(failure==='confirmed load drift')alterRead(m,path=>path.endsWith('/loading'),value=>{value.confirmedLoad.pallets[0].weightKg=999})
if(failure==='existing arrangement')alterRead(m,path=>path.endsWith('/delivery-arrangement'),value=>{value.arrangement={status:'ARRANGED'}})
await assert.rejects(continueLoaded(m));assert.equal(writes(m).length,0)
})
}
test('read-only aggregation polling accepts delayed completion without repeating receipt uploads or signatures',async()=>{
const m=await afterLoadingModel('aggregation delayed')
const proof=await continueLoaded(m)
assert.equal(proof.completionPolling.status,'COMPLETED')
assert.ok(proof.completionPolling.attempts.length>1&&proof.completionPolling.attempts.length<=8)
assert.ok(proof.completionPolling.attempts.every(entry=>entry.status==='RESPONDED'))
assert.equal(m.calls.filter(call=>call.multipart).length,2)
assert.equal(m.calls.filter(call=>call.method==='PUT'&&call.raw.includes('/documents?')).length,2)
})
test('read-only aggregation polling times out as a real failure without changing the dispatch stage',async()=>{
const m=await afterLoadingModel('aggregation never completes')
await assert.rejects(continueLoaded(m,{completionPolling:{maxAttempts:3,intervalMs:1,deadlineMs:1000}}),/Automatic delivery completion/)
const proof=m.context.createdResources.fleetCrossCustomer
assert.equal(proof.status,'FAILED');assert.equal(proof.completionPolling.status,'TIMED_OUT')
assert.equal(proof.completionPolling.attempts.length,3)
assert.ok(proof.completionPolling.attempts.every(entry=>entry.stage==='DELIVERING'))
assert.equal(m.calls.filter(call=>call.multipart).length,2)
assert.equal(m.calls.filter(call=>call.method==='PUT'&&call.raw.includes('/documents?')).length,2)
})
for(const failure of ['permission','structure'])test(`completion polling terminates immediately on ${failure} errors`,async()=>{
const m=await afterLoadingModel(),original=m.context.request
let reads=0
m.context.request=async function(...args){
const response=await original.apply(this,args)
if(args[0]==='GET'&&args[1]==='/api/platform/v1/last-leg/dispatches/DS-ONE'
&&m.context.createdResources.fleetCrossCustomer?.completionPolling?.status==='WAITING') {
reads++
if(failure==='permission')return {http:403,body:{code:'A0403'},requestId:'request',traceId:'trace'}
response.body.data.documents={outbounds:[],deliveries:[]}
}
return response
}
await assert.rejects(continueLoaded(m))
assert.equal(reads,1);assert.equal(m.context.createdResources.fleetCrossCustomer.completionPolling.status,'FAILED')
assert.equal(m.calls.filter(call=>call.multipart).length,2)
assert.equal(m.calls.filter(call=>call.method==='PUT'&&call.raw.includes('/documents?')).length,2)
})
@@ -1,4 +1,3 @@
import { createHash } from 'node:crypto'
import { confirmFleetLoading } from './fleet-trip-fulfillment.mjs'
const MODULE = 'fleet-cross-customer-acceptance'
@@ -28,29 +27,9 @@ export const fixtureContract = Object.freeze({
boundary: 'Prerequisite rejection is separate from an explicitly injected real mid-transaction rollback. Actual loading needs actual evidence; absent measurement fields block. No environment loader, SQL or implicit account lifecycle.',
})
export const afterLoadingContinuationContract = Object.freeze({
...fixtureContract,
stage: 'TWO_OWNED_CUSTOMERS_ONE_DISPATCH_AFTER_LOADING_NOT_ARRANGED',
preparation: 'Explicit continuation only: prior failed proof with verified gate and complete AFTER_PICK_1; original successful loadingCommand/confirmedLoad; current AFTER_LOADING with no arrangement or execution documents.',
boundary: 'Reconcile original identities, complete picked inventory and original loading receipt through current reads before any new write. Never repeat picking/loading or rewrite the prior proof.',
})
export async function verifyFleetCrossCustomer(context, fixture, { execute = false, readInventory, verifyDepartureRollback, completionPolling } = {}) {
export async function verifyFleetCrossCustomer(context, fixture, { execute = false, readInventory, verifyDepartureRollback } = {}) {
if (!execute) return fixtureContract
return runCrossCustomerCore(context, fixture, { readInventory, verifyDepartureRollback, completionPolling })
}
export async function continueFleetCrossCustomerAfterLoading(context, fixture, { execute = false, readInventory, verifyDepartureRollback, priorProof, completionPolling } = {}) {
if (!execute) return afterLoadingContinuationContract
if (!priorProof) throw new Error('Explicit prior cross-customer proof is required for AFTER_LOADING continuation')
return runCrossCustomerCore(context, { ...fixture, members: structuredClone(fixture?.members) },
{ readInventory, verifyDepartureRollback, priorProof, completionPolling })
}
async function runCrossCustomerCore(context, fixture, { readInventory, verifyDepartureRollback, priorProof, completionPolling }) {
const continuation = priorProof !== undefined
const contract = continuation ? afterLoadingContinuationContract : fixtureContract
if (!context?.marker || fixture?.marker !== context.marker || fixture.stage !== contract.stage
if (!context?.marker || fixture?.marker !== context.marker || fixture.stage !== fixtureContract.stage
|| !fixture.dispatchNo || !fixture.tripNo || fixture.tripProof?.dispatchNo !== fixture.dispatchNo || fixture.tripProof.tripNo !== fixture.tripNo
|| !context.createdResources?.fleetTrips?.includes(fixture.tripProof)
|| !fixture.warehouseId || !fixture.pdaToken || typeof readInventory !== 'function' || fixture.members?.length !== 2
@@ -60,11 +39,6 @@ async function runCrossCustomerCore(context, fixture, { readInventory, verifyDep
throw new Error('Requires two distinct current-run customers and one reviewed native dispatch with complete raw inventory evidence')
for (const field of ['incomingNo', 'dispatchItemId', 'positionId', 'palletId', 'balanceId'])
if (new Set(fixture.members.map(row => id(row[field]))).size !== 2) throw new Error(`Cross-customer fixture reuses ${field}`)
const polling = { maxAttempts: 41, intervalMs: 500, deadlineMs: 20000, ...completionPolling }
if (!Number.isSafeInteger(polling.maxAttempts) || polling.maxAttempts < 1 || polling.maxAttempts > 121
|| !Number.isSafeInteger(polling.intervalMs) || polling.intervalMs < 1 || polling.intervalMs > 2000
|| !Number.isSafeInteger(polling.deadlineMs) || polling.deadlineMs < 1 || polling.deadlineMs > 60000)
throw new Error('Finite completion polling bounds are required')
context.createdResources ??= {}
if (context.createdResources.fleetCrossCustomer) throw new Error('Proof already exists; retain failed run and reconcile explicitly')
const proof = context.createdResources.fleetCrossCustomer = { marker: context.marker, dispatchNo: fixture.dispatchNo, tripNo: fixture.tripNo,
@@ -84,9 +58,7 @@ async function runCrossCustomerCore(context, fixture, { readInventory, verifyDep
Object.assign(entry, { status: 'RESPONDED', http: response?.http, code: response?.body?.code, requestId: response?.requestId, traceId: response?.traceId })
await context.checkpoint?.()
const denied = response?.http === 409 || (response?.http === 200 && id(response.body?.code) === 'A0409')
const actorDenied = [200,403].includes(response?.http) && id(response.body?.code) === 'A0403'
check(label, Boolean(response?.requestId && response?.traceId)
&& (rejection === 'ACTOR_FORBIDDEN' ? actorDenied : rejection ? denied && rejection(id(response.body?.message)) : ok(response)))
check(label, Boolean(response?.requestId && response?.traceId) && (rejection ? denied && rejection(id(response.body?.message)) : ok(response)))
return response.body?.data
}
const stock = async member => {
@@ -161,86 +133,7 @@ async function runCrossCustomerCore(context, fixture, { readInventory, verifyDep
equal(after, await snapshot(`PICK_REPLAY_${index}`), 'Pick replay changes neither customer nor any execution record')
}
try {
let cargoBasis, originalLoadingHash
if (continuation) {
const prior = priorProof.snapshots?.AFTER_PICK_1, command = fixture.tripProof.loadingCommand
const confirmed = command?.result?.result?.confirmedLoad
check('Continuation has the original failed owned proof and verified prerequisite gate', priorProof.status === 'FAILED'
&& priorProof.marker === context.marker && priorProof.dispatchNo === fixture.dispatchNo && priorProof.tripNo === fixture.tripNo
&& id(priorProof.warehouseId) === id(fixture.warehouseId) && priorProof.gateProof === 'PREREQUISITE_REJECTION_VERIFIED'
&& priorProof.members?.length === 2 && !priorProof.arrangeRequest && !priorProof.departure
&& !priorProof.members.some(member => member.outboundNo || member.deliveryNo)
&& Array.isArray(priorProof.requests) && priorProof.requests.every(entry => ['GET','HEAD','OPTIONS'].includes(entry.method) || entry.status === 'RESPONDED'))
const identityFields = ['marker','customerId','incomingNo','dispatchNo','dispatchItemId','balanceId','positionId','palletId','palletNo']
check('Continuation preserves each original customer, source, item, balance, position and physical pallet', fixture.members.every((member,index) =>
identityFields.every(field => id(member[field]) === id(priorProof.members[index]?.[field]))))
check('Prior AFTER_PICK_1 is a complete reviewed picked but not shipped native snapshot', prior?.dispatch?.dispatchNo === fixture.dispatchNo
&& prior.dispatch.status === 'ACTIVE' && prior.dispatch.stage === 'LOADING' && prior.dispatch.reviewState === 'APPROVED'
&& prior.dispatch.reviews?.dispatchReview?.status === 'APPROVED' && prior.dispatch.reviews?.loadingReview?.status === 'APPROVED'
&& integer(prior.dispatch.counts?.packageCount) === 2 && integer(prior.dispatch.counts?.pickedPackageCount) === 2
&& integer(prior.dispatch.counts?.shippedPackageCount) === 0 && prior.task?.pickingReconciled === true
&& prior.task.dispatchNo === fixture.dispatchNo && id(prior.task.warehouseId) === id(fixture.warehouseId)
&& prior.task.stage === 'LOADING' && id(prior.task.recordVersion) === id(prior.dispatch.recordVersion)
&& prior.items?.dispatchNo === fixture.dispatchNo && id(prior.items.recordVersion) === id(prior.dispatch.recordVersion)
&& prior.items.items?.length === 2 && prior.task.items?.length === 2
&& prior.members?.length === 2 && prior.members.every(row => row.stock && !row.outbound && !row.delivery)
&& prior.dispatch.documents?.outbounds?.length === 0 && prior.dispatch.documents?.deliveries?.length === 0)
check('Original loading command has a successful explicit receipt for this main and both exact physical pallets', command?.path === `${pdaPath}/loading-complete`
&& command.body?.idempotencyKey && command.body.manifestHash && command.result?.dispatchNo === fixture.dispatchNo
&& command.result.action === 'LOADING_COMPLETE' && command.result.idempotencyKey === command.body.idempotencyKey
&& command.result.status === 'ACTIVE' && command.result.stage === 'AFTER_LOADING' && command.result.result?.loadingCompletionHash
&& id(command.body.recordVersion) === id(prior.dispatch.recordVersion)
&& /^(0|[1-9]\d*)$/.test(id(command.result.recordVersion))
&& /^(0|[1-9]\d*)$/.test(id(command.body.recordVersion))
&& BigInt(id(command.result.recordVersion)) > BigInt(id(command.body.recordVersion))
&& integer(command.result.counts?.packageCount) === 2 && integer(command.result.counts?.pickedPackageCount) === 2
&& integer(command.result.counts?.shippedPackageCount) === 0
&& confirmed?.manifestHash === command.body.manifestHash && confirmed.operatorId && Number.isFinite(Date.parse(confirmed.confirmedAt))
&& canonical(confirmed) === canonical(fixture.tripProof.confirmedLoad)
&& Array.isArray(command.body.measurements) && command.body.measurements.length === 2
&& canonical(command.body.measurements) === canonical(confirmed.pallets)
&& new Set(confirmed.pallets.map(row => id(row.warehousePalletId))).size === 2
&& fixture.members.every(member => confirmed.pallets.some(row => id(row.warehousePalletId) === member.palletId
&& Number.isSafeInteger(row.palletSpaces) && row.palletSpaces > 0
&& ['weightKg','volumeCbm'].every(field => ['number','string'].includes(typeof row[field])
&& /^(?:0|[1-9]\d*)(?:\.\d+)?$/.test(String(row[field])) && Number.isFinite(Number(row[field])) && Number(row[field]) > 0)
&& Number.isSafeInteger(row.deliveryIndex) && row.deliveryIndex > 0 && fixture.tripProof.stops?.[row.deliveryIndex]?.destinationKey)))
proof.continued = true
proof.continuation = { mode: 'EXPLICIT_AFTER_LOADING', priorProofSHA256: createHash('sha256').update(canonical(priorProof)).digest('hex'),
priorStatus: priorProof.status, gateProofSource: 'PRIOR_PROOF', originalAfterPickSnapshot: structuredClone(prior),
originalLoadingCommand: structuredClone(command), originalConfirmedLoad: structuredClone(confirmed) }
proof.gateProof = priorProof.gateProof
await context.checkpoint?.()
const initial = await snapshot('CONTINUATION_INITIAL')
check('Current main is exactly the unarranged confirmed loading version with two picked and zero shipped packages', initial.dispatch.status === 'ACTIVE'
&& initial.dispatch.stage === 'AFTER_LOADING' && initial.task.stage === 'AFTER_LOADING' && initial.trip.trip.status === 'PLANNED'
&& id(initial.dispatch.recordVersion) === id(command.result.recordVersion) && initial.task.pickingReconciled === true
&& initial.dispatch.reviewState === 'APPROVED' && initial.dispatch.reviews?.dispatchReview?.status === 'APPROVED'
&& initial.dispatch.reviews?.loadingReview?.status === 'APPROVED' && integer(initial.dispatch.counts.packageCount) === 2
&& integer(initial.dispatch.counts.pickedPackageCount) === 2 && integer(initial.dispatch.counts.shippedPackageCount) === 0
&& initial.dispatch.documents?.outbounds?.length === 0 && initial.dispatch.documents?.deliveries?.length === 0
&& id(initial.trip.trip.vehicleId) === id(fixture.tripProof.vehicleId) && id(initial.trip.trip.driverId) === id(fixture.tripProof.driverId)
&& id(prior.trip?.trip?.vehicleId) === id(fixture.tripProof.vehicleId) && id(prior.trip?.trip?.driverId) === id(fixture.tripProof.driverId)
&& canonical(initial.trip.trip.stops) === canonical(fixture.tripProof.stops)
&& initial.items.items.every(item => integer(item.pickedPackages) === 1) && initial.task.items.every(item => integer(item.pickedPackages) === 1))
equal(prior.members.map(row => row.stock), initial.members.map(row => row.stock), 'Continuation current complete inventory equals original AFTER_PICK_1 inventory')
const sourceItems = items => items.map(item => Object.fromEntries(['dispatchItemId','customerId','sourceKey','sourceVersion','sourceType',
'planPalletId','returnReceiptId','relabelNo','warehousePalletId','palletNo','orderNo','inboundCode','destination','scheduledPackages','pickedPackages']
.map(field => [field,item[field]]))).sort((a,b) => id(a.dispatchItemId).localeCompare(id(b.dispatchItemId)))
equal(sourceItems(prior.items.items), sourceItems(initial.items.items), 'Continuation preserves both picked source items and destinations')
const loading = await request('GET', `${pdaPath}/loading`, undefined, 'Reconcile current native manifest against original successful loading receipt', true)
check('Current loading preview preserves the original manifest, confirmed receipt and two reconciled physical pallets', loading.dispatchNo === fixture.dispatchNo
&& loading.status === 'ACTIVE' && loading.stage === 'AFTER_LOADING' && id(loading.recordVersion) === id(command.result.recordVersion)
&& loading.pickingReconciled === true && loading.manifestHash === confirmed.manifestHash
&& canonical(loading.confirmedLoad) === canonical(confirmed) && loading.pallets?.length === 2
&& new Set(loading.pallets.map(row => id(row.warehousePalletId))).size === 2
&& fixture.members.every(member => loading.pallets.some(row => id(row.warehousePalletId) === member.palletId
&& row.palletNo === member.palletNo && integer(row.scheduledPackages) === 1 && integer(row.pickedPackages) === 1)))
proof.continuation.currentLoadingPreview = loading
cargoBasis = initial
originalLoadingHash = command.result.result.loadingCompletionHash
} else {
const initial = await snapshot('INITIAL')
const initial = await snapshot('INITIAL')
check('Fresh native fixture is reviewed LOADING with both customer receipts untouched', initial.trip.trip.status === 'PLANNED'
&& initial.dispatch.status === 'ACTIVE' && initial.dispatch.stage === 'LOADING' && initial.dispatch.reviewState === 'APPROVED'
&& initial.dispatch.reviews?.dispatchReview?.status === 'APPROVED' && initial.dispatch.reviews?.loadingReview?.status === 'APPROVED'
@@ -262,17 +155,13 @@ async function runCrossCustomerCore(context, fixture, { readInventory, verifyDep
equal(rejectedBefore, await snapshot('GATE_AFTER'), 'Rejected departure leaves both complete inventories/outbounds/deliveries/trip unchanged')
proof.gateProof = 'PREREQUISITE_REJECTION_VERIFIED'
await pick(proof.members[1], 1)
await confirmFleetLoading(context, MODULE, fixture.tripProof, fixture.pdaToken, fixture.loadingMeasurements)
cargoBasis = proof.snapshots.AFTER_PICK_1
}
await confirmFleetLoading(context, MODULE, fixture.tripProof, fixture.pdaToken, fixture.loadingMeasurements)
const arrangement = await request('GET', `${path}/delivery-arrangement`, undefined, 'Read native loaded per-customer delivery arrangement')
if (continuation) check('Continuation has no prior arrangement and retains the original loading completion hash', arrangement.arrangement == null
&& arrangement.loadingCompletionHash === originalLoadingHash && id(arrangement.recordVersion) === id(fixture.tripProof.loadingCommand.result.recordVersion))
check('Loaded arrangement groups cover exactly both real customers', arrangement.dispatchNo === fixture.dispatchNo && arrangement.status === 'ACTIVE'
&& arrangement.stage === 'AFTER_LOADING' && arrangement.arrange?.allowed === true && arrangement.cargoHash && arrangement.loadingCompletionHash
&& arrangement.groups?.length === 2 && proof.members.every(m => arrangement.groups.some(g => id(g.customerId) === m.customerId && g.cargo?.length
&& g.cargo.every(c => id(c.customerId) === m.customerId && id(c.warehousePalletId) === m.palletId) && g.blockReasons?.length === 0
&& g.destination?.key === cargoBasis.items.items.find(i => id(i.customerId) === m.customerId)?.destination?.key)))
&& g.destination?.key === proof.snapshots.AFTER_PICK_1.items.items.find(i => id(i.customerId) === m.customerId)?.destination?.key)))
const arrangeBody = { recordVersion: arrangement.recordVersion, idempotencyKey: key('ARRANGE'), cargoHash: arrangement.cargoHash,
loadingCompletionHash: arrangement.loadingCompletionHash, groups: arrangement.groups.map(g => ({ groupKey: g.groupKey, groupCargoHash: g.groupCargoHash,
plannedDeliveryAt: new Date(Date.now() + 21600000).toISOString(), deliveryMethod: 'TRUCK_PALLET', pickupMethod: 'WAREHOUSE_PICKUP',
@@ -311,13 +200,10 @@ async function runCrossCustomerCore(context, fixture, { readInventory, verifyDep
check('Both customer outbounds completed and main delivering', after.trip.trip.status === 'IN_TRANSIT' && after.dispatch.stage === 'DELIVERING'
&& after.members.every(row => row.outbound.outbound.status === 'OUTBOUNDED' && row.delivery.order.status === 'DELIVERING'))
equal(ready.members.map(row => row.stock), after.members.map(row => row.stock), 'Departure does not deduct either inventory again')
const replay = await request('POST', departPath, departBody, 'Original PDA actor exact replay of native departure', true)
const replay = await request('POST', `${path}/depart`, departBody, 'Platform exact replay of PDA native departure')
check('Shared departure replay is explicit and returns original result', replay.replayed === true && id(replay.recordVersion) === id(departed.recordVersion)
&& canonical(replay.result) === canonical(departed.result))
const replayed = await snapshot('DEPART_REPLAY')
equal(after, replayed, 'Departure replay preserves both complete inventories and execution facts')
await request('POST', `${path}/depart`, departBody, 'Different platform actor cannot reuse the PDA departure command key', false, 'ACTOR_FORBIDDEN')
equal(replayed, await snapshot('FOREIGN_ACTOR_DEPART_REJECTED'), 'Different actor rejection preserves both complete inventories and execution facts')
equal(after, await snapshot('DEPART_REPLAY'), 'Departure replay preserves both complete inventories and execution facts')
for (const [index, member] of proof.members.entries()) {
const before = await snapshot(`BEFORE_SIGN_${index}`), documentPath = scoped(`${ROOT}/delivery-orders/${encode(member.deliveryNo)}/documents`, member.customerId)
const docs = await request('GET', documentPath, undefined, 'Read this customer delivery document version')
@@ -337,43 +223,8 @@ async function runCrossCustomerCore(context, fixture, { readInventory, verifyDep
equal(before.members[1-index].delivery, signed.members[1-index].delivery, 'Other customer delivery is not signed or changed implicitly')
equal(before.members.map(row => row.stock), signed.members.map(row => row.stock), 'Signing does not mutate either customer inventory')
}
const started = Date.now()
proof.completionPolling = { ...polling, status: 'WAITING', attempts: [] }
for (let attempt = 1; attempt <= polling.maxAttempts; attempt++) {
if (Date.now() - started >= polling.deadlineMs) break
const entry = { attempt, elapsedMs: Date.now() - started, status: 'READING' }
proof.completionPolling.attempts.push(entry)
const current = await request('GET', path, undefined, 'Read bounded automatic delivery completion progress')
check('Completion progress retains the exact normal main, counts and both execution-document owners', current.dispatchNo === fixture.dispatchNo
&& current.status === 'ACTIVE' && ['DELIVERING','DELIVERED'].includes(current.stage)
&& /^(0|[1-9]\d*)$/.test(id(current.recordVersion)) && integer(current.counts?.packageCount) === 2
&& integer(current.counts?.pickedPackageCount) === 2 && integer(current.counts?.shippedPackageCount) === 2
&& current.documents?.outbounds?.length === 2 && current.documents?.deliveries?.length === 2
&& proof.members.every(member => current.documents.outbounds.some(row => id(row.customerId) === member.customerId && row.outboundNo === member.outboundNo)
&& current.documents.deliveries.some(row => id(row.customerId) === member.customerId && row.deliveryNo === member.deliveryNo && row.outboundNo === member.outboundNo)))
Object.assign(entry, { status: 'RESPONDED', stage: current.stage, recordVersion: current.recordVersion, elapsedMs: Date.now() - started })
if (entry.elapsedMs >= polling.deadlineMs) break
if (current.stage === 'DELIVERED') { proof.completionPolling.status = 'COMPLETED'; break }
await context.checkpoint?.()
const remaining = polling.deadlineMs - (Date.now() - started)
if (attempt < polling.maxAttempts && remaining > 0) await new Promise(resolve => setTimeout(resolve, Math.min(polling.intervalMs, remaining)))
}
proof.completionPolling.elapsedMs = Date.now() - started
if (proof.completionPolling.status !== 'COMPLETED') {
proof.completionPolling.status = 'TIMED_OUT'
await context.checkpoint?.()
throw new Error('Automatic delivery completion did not reach DELIVERED within the explicit read-only polling bounds')
}
const final = await snapshot('FINAL')
check('All normal customer signatures aggregate native delivery completion', final.dispatch.status === 'ACTIVE' && final.dispatch.stage === 'DELIVERED')
proof.status = 'VERIFIED'; return proof
} catch (error) {
proof.status = 'FAILED'; proof.error = error.message
if (proof.completionPolling?.status === 'WAITING') {
proof.completionPolling.status = 'FAILED'; proof.completionPolling.error = error.message
const attempt = proof.completionPolling.attempts.at(-1)
if (attempt?.status === 'READING') attempt.status = 'FAILED'
}
throw error
}
} catch (error) { proof.status = 'FAILED'; proof.error = error.message; throw error }
}
@@ -32,13 +32,12 @@ test('real Jackson long counts accept safe decimal strings without changing pall
})
function server(fault = '') {
const calls = [], assertions = [], saved = new Map(), loadingActors = new Map()
const calls = [], assertions = [], saved = new Map()
const detail = { dispatchNo: 'DP-A', status: 'ACTIVE', stage: 'BEFORE_LOADING', reviewState: 'DRAFT', recordVersion: '9007199254740998',
counts: { packageCount: 3, pickedPackageCount: 0, shippedPackageCount: 0 }, reviews: {},
dispatchInfo: { estimatedArrivalAt: '2026-10-21T10:00:00Z', fbaAppointments: [{ appointmentNo: 'FBA-ORIGINAL' }] }, vehicleInfo: {} }
const trip = { tripNo: 'TR-A', status: 'PLANNED', warehouseId: '92', recordVersion: 4, tripPurpose: 'DELIVERY', stops }
let driverVersion = 0, uploadComplete = false, driverReviewed = false, nextPlate = '', checkpointError = false, nextTrip, nextDetail
let mutatedItems = false, mutatedLoading = false
const claims = new Map([['VEHICLE', 'TR-A'], ['DRIVER', 'TR-A']])
const bump = () => detail.recordVersion = String(BigInt(detail.recordVersion) + 1n)
const result = (action, body, extra = {}) => ({ dispatchNo: 'DP-A', status: detail.status, stage: detail.stage, recordVersion: detail.recordVersion,
@@ -59,7 +58,7 @@ function server(fault = '') {
calls.push({ method, path, body: structuredClone(body), actor: token ? 'PDA' : 'PLATFORM' })
if (fault === 'unknown-edit' && method === 'PUT' && path === D) throw new Error('transport result unknown')
if (path.endsWith('/master-data/vehicles')) {
assert.equal(body.status, 'IN_SERVICE'); assert.match(body.remark, /TEST/); nextPlate = body.plateNumber.toUpperCase()
assert.equal(body.status, 'IN_SERVICE'); assert.match(body.remark, /TEST/); nextPlate = body.plateNumber
return ok({ id: VID })
}
if (path.endsWith('/fleet/drivers')) return ok({ id: DRIVER, recordVersion: 0 })
@@ -70,10 +69,6 @@ function server(fault = '') {
if (path.startsWith('/api/platform/v1/last-leg/fleet/trips?')) {
const rows = fault === 'missing-trip' ? [] : [trip, ...(nextTrip ? [nextTrip] : [])]
if (fault === 'duplicate-trip') rows.push({ ...trip, tripNo: 'TR-B' })
const wrong = { 'wrong-plate': { vehiclePlate: `${trip.vehiclePlate}-OTHER` },
'wrong-vehicle': { vehicleId: 'OTHER-VEHICLE' }, 'wrong-driver': { driverId: 'OTHER-DRIVER' },
'wrong-warehouse': { warehouseId: 'OTHER-WAREHOUSE' } }[fault]
if (wrong) rows[0] = { ...rows[0], ...wrong }
return ok({ records: rows, page: 1, size: 200, total: rows.length, pages: 1 })
}
if (method === 'GET' && path === D) return ok(detail)
@@ -96,10 +91,10 @@ function server(fault = '') {
nextDetail.status = 'CANCELLED'; nextDetail.recordVersion++; claims.clear()
return ok(nextDetail)
}
if (method === 'GET' && path === `${D}/items`) return ok({ dispatchNo: 'DP-A', recordVersion: detail.recordVersion, items: [{ warehousePalletId: PALLET, pickedPackages: detail.counts.pickedPackageCount, customerId: mutatedItems ? 'OTHER-CUSTOMER' : 'CUSTOMER-A' }] })
if (method === 'GET' && path === `${D}/items`) return ok({ dispatchNo: 'DP-A', recordVersion: detail.recordVersion, items: [{ warehousePalletId: PALLET, pickedPackages: detail.counts.pickedPackageCount, customerId: 'CUSTOMER-A' }] })
if (method === 'GET' && [D, P].some(prefix => path === `${prefix}/loading`)) {
return ok({ dispatchNo: 'DP-A', status: detail.status, stage: detail.stage, recordVersion: detail.recordVersion, pickingReconciled: detail.counts.pickedPackageCount === 3,
manifestHash: mutatedLoading ? 'CHANGED-MANIFEST' : 'REAL-MANIFEST', loadingComplete: { allowed: detail.counts.pickedPackageCount === 3, blockReason: '拣货未核平' },
manifestHash: 'REAL-MANIFEST', loadingComplete: { allowed: detail.counts.pickedPackageCount === 3, blockReason: '拣货未核平' },
pallets: [pallet()], missingFields: ['PLT-A:palletSpaces'] })
}
if (method === 'PUT' && path === D) {
@@ -129,14 +124,6 @@ function server(fault = '') {
if (method === 'POST' && [D, P].some(prefix => path === `${prefix}/loading-complete`)) {
if (saved.has(body.idempotencyKey)) {
assert.deepEqual(body, saved.get(body.idempotencyKey).body)
if (loadingActors.get(body.idempotencyKey) !== (token ? 'PDA' : 'PLATFORM')) {
if (fault === 'cross-actor-unknown') throw new Error('cross actor result unknown')
if (fault === 'cross-actor-mutates-main') bump()
if (fault === 'cross-actor-mutates-items') mutatedItems = true
if (fault === 'cross-actor-mutates-loading') mutatedLoading = true
if (fault !== 'cross-actor-accepted') return { http: 200, body: { code: 'A0403',
message: fault === 'cross-actor-generic-forbidden' ? '没有装车权限' : '该请求标识不属于当前操作员' } }
}
const replay = { ...saved.get(body.idempotencyKey).result, replayed: true }
if (fault === 'replay-new-version') replay.recordVersion = String(BigInt(replay.recordVersion) + 1n)
return ok(replay)
@@ -144,7 +131,6 @@ function server(fault = '') {
assert.equal(detail.stage, 'LOADING'); assert.equal(detail.counts.pickedPackageCount, 3)
assert.equal(body.recordVersion, detail.recordVersion); assert.equal(body.manifestHash, 'REAL-MANIFEST')
assert.deepEqual(body.measurements, [{ warehousePalletId: PALLET, palletSpaces: 2, weightKg: '187.25', volumeCbm: '1.375', deliveryIndex: 1 }])
loadingActors.set(body.idempotencyKey, token ? 'PDA' : 'PLATFORM')
detail.stage = 'AFTER_LOADING'; bump(); return remember('LOADING_COMPLETE', body, { confirmedLoad: { manifestHash: body.manifestHash, pallets: body.measurements } })
}
if (method === 'GET' && path.startsWith(`${F}/resource-occupancy?`)) {
@@ -213,7 +199,7 @@ test('missing or malformed zero counts cannot create resources before preparatio
test('native preparation edits existing master then submits both reviews, discovers the real sole automatic Fleet record', async () => {
const s = server(), proof = await prepare(s)
assert.equal(proof.tripNo, 'TR-A'); assert.equal(proof.preparation, 'VERIFIED'); assert.equal(proof.vehicleId, VID)
assert.equal(proof.vehicle.plate, proof.marker.toUpperCase()); assert.equal(proof.driver.phone, '19900000000')
assert.equal(proof.vehicle.plate, proof.marker); assert.equal(proof.driver.phone, '19900000000')
assert.equal(proof.recordVersion, '9007199254741002'); assert.equal(proof.fleetRecordVersion, 4)
const writes = s.calls.filter(c => ['POST', 'PUT'].includes(c.method))
assert.deepEqual(writes.slice(-4).map(c => c.path), [D, `${D}/submit-review`, `${D}/dispatch-review`, `${D}/loading-review`])
@@ -230,27 +216,7 @@ test('zero-scan LOADING must explicitly return to review before editing and pass
assert.equal(proof.returnRequest.recordVersion, '9007199254740998'); assert.equal(proof.preparation, 'VERIFIED')
})
test('authoritative uppercase vehicle plate discovers both the prepared and subsequent native dispatch without changing marker', async () => {
const s = server(), request = s.context.request, marker = 'FT-1d529b3e-d15'
s.context.request = async (...args) => {
// 固定本轮随机标识以精确复现真实HTTP大小写回执;首次GET仍在任何车辆写入之前。
if (args[0] === 'GET' && args[1] === D && !s.context.createdResources.fleetTrips[0].vehicleId)
s.context.createdResources.fleetTrips[0].marker = marker
return request(...args)
}
const proof = await prepare(s)
assert.equal(proof.marker, marker); assert.equal(proof.vehicle.plate, 'FT-1D529B3E-D15')
assert.equal(proof.tripNo, 'TR-A')
s.trip.status = 'IN_TRANSIT'
const closure = await finishFleetTrip(s.context, 'native', proof)
assert.equal(closure.nextDispatch.marker, marker); assert.equal(closure.nextDispatch.vehicle, undefined)
assert.equal(closure.nextDispatch.tripNo, 'TR-NEXT'); assert.equal(closure.nextDispatch.finalStatus, 'CANCELLED')
const queries = s.calls.filter(c => c.path.startsWith('/api/platform/v1/last-leg/fleet/trips?'))
assert.equal(queries.length, 2)
for (const call of queries) assert.equal(new URL(`https://unused.test${call.path}`).searchParams.get('keyword'), 'FT-1D529B3E-D15')
})
for (const fault of ['missing-trip', 'duplicate-trip', 'wrong-binding', 'wrong-plate', 'wrong-vehicle', 'wrong-driver', 'wrong-warehouse']) test(`automatic Fleet identity fails closed: ${fault}`, async () => {
for (const fault of ['missing-trip', 'duplicate-trip', 'wrong-binding']) test(`automatic Fleet identity fails closed: ${fault}`, async () => {
const s = server(fault); await assert.rejects(prepare(s), /唯一|绑定/)
assert.equal(s.context.createdResources.fleetTrips[0].preparation, 'CHECKING')
})
@@ -299,20 +265,15 @@ test('extra or inconsistent producer measurements cannot alter another pallet or
assert.throws(() => controlledPalletMeasurements([pallet()], stops, [...receipt().pallets, { warehousePalletId: 'OTHER' }]), /本车以外/)
})
test('PDA original-actor replay restores loading while another platform actor is rejected without mutation', async () => {
test('PDA actual loading and platform exact replay share one native version after proving scanned edit refusal', async () => {
const s = server(), proof = await prepare(s); s.pick()
const beforeCount = s.detail.counts.pickedPackageCount
const v = await confirmFleetLoading(s.context, 'native', proof, 'PDA-IN-MEMORY-ONLY', receipt())
assert.equal(v, '9007199254741004'); assert.equal(proof.reassignment.status, 'REJECTED_WITHOUT_MUTATION')
assert.equal(s.detail.counts.pickedPackageCount, beforeCount); assert.equal(s.detail.stage, 'AFTER_LOADING')
const writes = s.calls.filter(c => c.path.endsWith('/loading-complete'))
assert.equal(writes.length, 3)
assert.deepEqual(writes.map(c => c.body), [writes[0].body, writes[0].body, writes[0].body])
assert.deepEqual(writes.map(c => [c.path, c.actor]), [[`${P}/loading-complete`, 'PDA'], [`${P}/loading-complete`, 'PDA'], [`${D}/loading-complete`, 'PLATFORM']])
assert.equal(proof.loadingCommand.replay.replayed, true); assert.equal(proof.loadingCommand.platformRejection.code, 'A0403')
assert.equal(proof.loadingCommand.platformPositiveLoading, 'PENDING_SEPARATE_SCENARIO')
assert.deepEqual(proof.loadingCommand.beforeReplay, proof.loadingCommand.afterReplay)
assert.equal(proof.loadingCommand.platformReplay, undefined)
assert.equal(writes.length, 2); assert.deepEqual(writes[0].body, writes[1].body)
assert.equal(writes[0].actor, 'PDA'); assert.equal(writes[1].actor, 'PLATFORM')
assert.equal(writes[0].body.measurements[0].weightKg, '187.25')
assert.ok(s.calls.every(c => !/\/fleet\/trips\/.*\/(?:assignment|loading-review|loading)$/.test(c.path)))
const count = s.calls.length
@@ -320,27 +281,6 @@ test('PDA original-actor replay restores loading while another platform actor is
assert.equal(s.calls.length, count)
})
test('platform positive loading remains executable in its own scenario with original-actor replay', async () => {
const s = server(), proof = await prepare(s); s.pick()
await confirmFleetLoading(s.context, 'native', proof, undefined, receipt())
const writes = s.calls.filter(c => c.path.endsWith('/loading-complete'))
assert.deepEqual(writes.map(c => [c.path, c.actor]), [[`${D}/loading-complete`, 'PLATFORM'], [`${D}/loading-complete`, 'PLATFORM']])
assert.equal(proof.loadingCommand.replay.replayed, true)
assert.equal(proof.loadingCommand.platformPositiveLoading, 'VERIFIED'); assert.equal(proof.loadingCommand.platformRejection, undefined)
assert.equal(s.detail.stage, 'AFTER_LOADING')
})
for (const fault of ['cross-actor-accepted', 'cross-actor-generic-forbidden', 'cross-actor-mutates-main', 'cross-actor-mutates-items', 'cross-actor-mutates-loading', 'cross-actor-unknown']) test(`cross-actor loading cannot claim isolation: ${fault}`, async () => {
const s = server(fault), proof = await prepare(s); s.pick()
await assert.rejects(confirmFleetLoading(s.context, 'native', proof, 'PDA', receipt()), /失败|unknown/)
assert.equal(proof.loadingCommand.platformPositiveLoading, undefined)
assert.equal(s.calls.filter(c => c.path.endsWith('/loading-complete')).length, 3)
if (fault === 'cross-actor-unknown') assert.equal(s.context.createdResources.fleetTripCommands.at(-1).status, 'UNKNOWN')
const count = s.calls.length
await assert.rejects(confirmFleetLoading(s.context, 'native', proof, 'PDA', receipt()), /已开始/)
assert.equal(s.calls.length, count)
})
for (const fault of ['edit-mutates-on-rejection', 'replay-new-version']) test(`loading verification rejects native drift: ${fault}`, async () => {
const s = server(fault), proof = await prepare(s); s.pick()
await assert.rejects(confirmFleetLoading(s.context, 'native', proof, 'PDA', receipt()), /失败/)
@@ -18,7 +18,7 @@ function check(context, module, label, condition, evidence = '') {
if (!condition) throw new Error(`${module}: ${label}失败`)
}
// 写前冻结原请求;未知结果停写,不能换键重造或自动重发。
async function command(context, module, method, path, body, label, token, expectedRejection) {
async function command(context, module, method, path, body, label, token) {
let entry
if (method !== 'GET') {
context.createdResources ??= {}; context.createdResources.fleetTripCommands ??= []
@@ -32,12 +32,6 @@ async function command(context, module, method, path, body, label, token, expect
Object.assign(entry, { status: 'RESPONDED', http: response?.http, code: response?.body?.code, requestId: response?.body?.requestId, traceId: response?.body?.traceId })
await context.checkpoint?.()
}
if (expectedRejection) {
check(context, module, label, response?.http === 200 && String(response?.body?.code) === expectedRejection.code
&& response?.body?.message === expectedRejection.message)
return { http: response.http, code: response.body.code, message: response.body.message,
requestId: response.body.requestId, traceId: response.body.traceId }
}
const data = context.apiSuccess(module, label, response, '', OPTIONS)
if (response?.http !== 200 || String(response?.body?.code) !== '200' || !data) throw new Error(`${module}: ${label}失败`)
return data
@@ -45,11 +39,9 @@ async function command(context, module, method, path, body, label, token, expect
// 排车公开详情未提供tripNo,按本轮唯一新车牌分页,再核对真实资源及唯一绑定。
async function discoverTrip(context, module, proof) {
// 正式车辆主档将车牌转为大写;保留原marker及命令键,仅按该权威规则生成精确查询身份。
const vehiclePlate = proof.marker.toUpperCase()
const rows = [], seen = new Set(); let total
for (let page = 1; ; page++) {
const query = new URLSearchParams({ page: String(page), size: '200', keyword: vehiclePlate, warehouseId: proof.warehouseId })
const query = new URLSearchParams({ page: String(page), size: '200', keyword: proof.marker, warehouseId: proof.warehouseId })
const data = await command(context, module, 'GET', `${BASE}/trips?${query}`, undefined, '按本轮唯一车牌读取自动资源记录')
if (!Array.isArray(data.records) || !Number.isSafeInteger(Number(data.total)) || Number(data.total) < 0
|| Number(data.page) !== page || Number(data.size) !== 200) throw new Error('车次分页不完整,不能证明唯一关联')
@@ -62,7 +54,7 @@ async function discoverTrip(context, module, proof) {
if (rows.length >= total) break
}
if (rows.length !== total) throw new Error('车次分页总数不一致')
const matches = rows.filter(row => row.vehiclePlate === vehiclePlate && id(row.vehicleId) === proof.vehicleId
const matches = rows.filter(row => row.vehiclePlate === proof.marker && id(row.vehicleId) === proof.vehicleId
&& id(row.driverId) === proof.driverId && id(row.warehouseId) === proof.warehouseId && row.tripPurpose === 'DELIVERY')
const bound = []
for (const candidate of matches) {
@@ -242,26 +234,12 @@ export async function confirmFleetLoading(context, module, proof, pdaToken, actu
&& r.palletSpaces === m.palletSpaces && decimal(r.weightKg) === decimal(m.weightKg)
&& decimal(r.volumeCbm) === decimal(m.volumeCbm) && r.deliveryIndex === m.deliveryIndex)))
proof.loadingCommand.result = confirmed; proof.confirmedLoad = confirmed.result.confirmedLoad
const snapshot = async label => ({
main: await command(context, module, 'GET', path, undefined, `${label}主单`),
items: await command(context, module, 'GET', `${path}/items`, undefined, `${label}货物`),
loading: await command(context, module, 'GET', `${path}/loading`, undefined, `${label}装车事实`),
})
proof.loadingCommand.beforeReplay = await snapshot('装车原键验证前回读')
const replay = await command(context, module, 'POST', proof.loadingCommand.path, proof.loadingCommand.body, '原操作员原端原键重放装车', pdaToken)
check(context, module, '原操作员装车重放恢复原结果且不增加版本', replay.replayed === true && replay.dispatchNo === confirmed.dispatchNo
&& version(replay.recordVersion) === version(confirmed.recordVersion) && JSON.stringify(replay.result) === JSON.stringify(confirmed.result))
proof.loadingCommand.replay = replay
if (pdaToken) {
// 当前场景使用独立平台/PDA操作者;由账本的精确所有者拒绝证明隔离,不能按Token外观猜同一账号。
proof.loadingCommand.platformRejection = await command(context, module, 'POST', `${path}/loading-complete`, proof.loadingCommand.body,
'另一平台操作员不能重用PDA装车请求标识', undefined, { code: 'A0403', message: '该请求标识不属于当前操作员' })
const replay = await command(context, module, 'POST', `${path}/loading-complete`, proof.loadingCommand.body, '平台原键原载荷重放PDA装车')
check(context, module, '双端装车共用原结果且不增加版本', replay.replayed === true && replay.dispatchNo === confirmed.dispatchNo
&& version(replay.recordVersion) === version(confirmed.recordVersion) && JSON.stringify(replay.result) === JSON.stringify(confirmed.result))
proof.loadingCommand.platformReplay = replay
}
proof.loadingCommand.afterReplay = await snapshot('装车原键验证后回读')
check(context, module, '原键重放及操作者拒绝后主单货物装车事实不变',
JSON.stringify(proof.loadingCommand.beforeReplay) === JSON.stringify(proof.loadingCommand.afterReplay)
&& version(proof.loadingCommand.afterReplay.main.recordVersion) === version(confirmed.recordVersion))
proof.loadingCommand.platformPositiveLoading = pdaToken ? 'PENDING_SEPARATE_SCENARIO' : 'VERIFIED'
proof.recordVersion = confirmed.recordVersion; await context.checkpoint?.(); return proof.recordVersion
}
@@ -53,9 +53,7 @@ test('fulfillment checks PDF signature, platform replay and immutable pallets af
assert.ok(source.includes(value), value)
const helper = await read('fleet-trip-fulfillment.mjs')
for (const value of ['state.itemsBefore', 'JSON.stringify(state.itemsBefore) === JSON.stringify(state.itemsAfter)',
'proof.loadingCommand.replay = replay', 'proof.loadingCommand.platformRejection = await command',
"{ code: 'A0403', message: '该请求标识不属于当前操作员' }", 'JSON.stringify(replay.result) === JSON.stringify(confirmed.result)',
'JSON.stringify(proof.loadingCommand.beforeReplay) === JSON.stringify(proof.loadingCommand.afterReplay)'])
'proof.loadingCommand.platformReplay = replay', 'JSON.stringify(replay.result) === JSON.stringify(confirmed.result)'])
assert.ok(helper.includes(value), value)
})
@@ -57,7 +57,7 @@ for (const [status, history] of [['PROCESSING', ['APPROVED', 'ACCEPTED', 'PROCES
test('approved stop runs after full approval replay, inbound readiness and shared measurement handoff', () => {
const source = readFileSync(new URL('./orders.mjs', import.meta.url), 'utf8')
const run = source.slice(source.indexOf('export async function run('))
const finish = run.lastIndexOf('await finishOrderLifecycle(')
const finish = run.indexOf('await finishOrderLifecycle(')
for (const fragment of ['await verifyOrderApprovalReplay(', '隔离入库单 marker 来源与可测量状态正确',
'context.createdResources.orders.inboundId = inboundId', 'context.context.createdInbound =', '履约任务与本轮订单和客户一致']) {
assert.ok(run.indexOf(fragment) >= 0 && run.indexOf(fragment) < finish, fragment)
-19
View File
@@ -1110,25 +1110,6 @@ export async function run(context) {
await verifyOrderApprovalReplay(context, { tenantId, orderId, inboundId,
inboundCode: id(inboundResult.detail?.summary?.inboundCode) })
// 专项造单仅生产已审核的真实入库来源;默认全量验收仍继续验证申报修改。
if (context.context.orderFixtureOnly === true) {
const inbound = inboundResult.detail
Object.assign(context.createdResources.orders, {
inboundId, inboundCode: inbound.summary.inboundCode,
customerRemark: inbound.publicRemark,
internalRemark: `${context.marker} Admin 订单链路验收`,
cargoRemark: payload.cargoes?.[0]?.cargoRemark || null,
processStatus: inbound.summary.processStatus, taskId: id(inboundResult.task.id),
fixtureOnly: true,
})
const source = { tenantId, inboundId, marker: context.marker }
context.createdResources.ocrInbound = source
context.createdResources.testInbound = source
for (const key of ['inboundWeightOcr', 'ocrInbound', 'testInbound', 'createdInbound']) context.context[key] = source
await finishOrderLifecycle(context, { tenantId, orderId, firstLegDetailPath })
return
}
const editableDetailResponse = await context.request('GET', firstLegDetailPath)
const editableDetail = context.apiSuccess(MODULE, 'Admin 读取审核后客户申报资料', editableDetailResponse)
const declarationDetail = editableDetail?.order?.order ? editableDetail.order : editableDetail
+21 -67
View File
@@ -94,23 +94,14 @@ Assert-MarkdownTableUnique $businessIndexPath '## 业务快速路由表' 0 '详
Assert-MarkdownTableUnique $businessIndexPath '## 常用说法与歧义分流' 0 '业务歧义分流表'
Assert-Contains $businessIndex '## 业务快速路由表' '详细业务表存在'
Assert-Contains $businessIndex '## 常用说法与歧义分流' '业务歧义分流存在'
$adminEntry = Read-RequiredText $adminAgentsPath
$adminIndexPath = Join-Path $workspaceRoot 'apps/admin-web/docs/business-index.md'
$adminIndex = Read-RequiredText $adminIndexPath
$adminAgents = $adminEntry + "`n" + $adminIndex
$adminAgents = Read-RequiredText $adminAgentsPath
$adminOverseasAgentsPath = Join-Path $workspaceRoot 'apps/admin-web/src/modules/overseas-warehouse/AGENTS.md'
$adminOverseasAgents = Read-RequiredText $adminOverseasAgentsPath
Assert-Contains $adminAgents 'src/modules/overseas-warehouse/AGENTS.md' 'Admin 海外仓按需指引入口'
Assert-MarkdownTableUnique $adminOverseasAgentsPath '## 页面与业务快速索引' 0 'Admin 海外仓模块表'
$clientEntry = Read-RequiredText $clientAgentsPath
$clientIndexPath = Join-Path $workspaceRoot 'apps/client-web/docs/business-index.md'
$clientIndex = Read-RequiredText $clientIndexPath
$clientAgents = $clientEntry + "`n" + $clientIndex
$clientAgents = Read-RequiredText $clientAgentsPath
$pdaAgents = Read-RequiredText $pdaAgentsPath
$overseasPdaEntry = Read-RequiredText $overseasPdaAgentsPath
$overseasPdaIndexPath = Join-Path $workspaceRoot 'apps/pda-overseas-uniapp/docs/business-index.md'
$overseasPdaIndex = Read-RequiredText $overseasPdaIndexPath
$overseasPdaAgents = $overseasPdaEntry + "`n" + $overseasPdaIndex
$overseasPdaAgents = Read-RequiredText $overseasPdaAgentsPath
$backendAgents = Read-RequiredText $backendAgentsPath
$backendIndexPath = Join-Path $backendRoot 'docs/backend-business-index.md'
$lastlegIndexPath = Join-Path $backendRoot 'lastleg-service/docs/business-index.md'
@@ -119,64 +110,30 @@ $lastlegIndex = Read-RequiredText $lastlegIndexPath
Assert-Contains $backendAgents 'docs/backend-business-index.md' '后端按需索引入口'
$lastlegEntry = Read-RequiredText (Join-Path $backendRoot 'lastleg-service/AGENTS.md')
Assert-Contains $lastlegEntry 'docs/business-index.md' 'Lastleg 按需索引入口'
$firstlegEntryPath = Join-Path $backendRoot 'firstleg/AGENTS.md'
$firstlegIndexPath = Join-Path $backendRoot 'firstleg/docs/business-index.md'
$firstlegEntry = Read-RequiredText $firstlegEntryPath
$firstlegIndex = Read-RequiredText $firstlegIndexPath
Assert-Contains $firstlegEntry 'docs/business-index.md' 'Firstleg 按需索引入口'
Assert-Contains $firstlegEntry '修改前必须核对该索引对应入口' 'Firstleg 业务变更细则读取边界'
Assert-Contains $firstlegIndex '以 `firstleg/` 服务目录起算' 'Firstleg 详细索引路径起点'
Assert-Contains $adminEntry 'docs/business-index.md' 'Admin 按需索引入口'
Assert-Contains $clientEntry 'docs/business-index.md' 'Client 按需索引入口'
Assert-Contains $overseasPdaEntry 'docs/business-index.md' '海外 PDA 按需索引入口'
foreach ($applicationDetailIndex in @(
@{ Name = 'Admin'; Text = $adminIndex; Base = 'apps/admin-web' },
@{ Name = 'Client'; Text = $clientIndex; Base = 'apps/client-web' },
@{ Name = '海外 PDA'; Text = $overseasPdaIndex; Base = 'apps/pda-overseas-uniapp' }
)) {
Assert-Matches $applicationDetailIndex.Text '\[[^\]]+\]\(\.\./AGENTS\.md\)' "$($applicationDetailIndex.Name) 详细索引链接应用主指引"
Assert-Path "$($applicationDetailIndex.Base)/docs/../AGENTS.md"
}
Assert-MarkdownTableUnique $adminAgentsPath '## 模块快速路由' 0 'Admin 短入口表'
Assert-MarkdownTableUnique $adminIndexPath '## 模块快速路由' 0 'Admin 详细索引表'
Assert-MarkdownTableUnique $clientAgentsPath '## 模块快速查询' 0 'Client 短入口表'
Assert-MarkdownTableUnique $clientIndexPath '## 模块快速查询' 0 'Client 详细索引表'
Assert-MarkdownTableUnique $overseasPdaAgentsPath '## 页面与能力快速索引' 0 '海外 PDA 短入口表'
Assert-MarkdownTableUnique $overseasPdaIndexPath '## 页面与能力快速索引' 0 '海外 PDA 详细索引表'
foreach ($readingBoundary in @($adminEntry, $clientEntry, $overseasPdaEntry)) {
Assert-Contains $readingBoundary '涉及业务行为变更时必须核对对应细则' '终端业务细则读取边界'
}
foreach ($budget in @(
@{ Name = '后端根'; Text = $backendAgents; Limit = 13000 },
@{ Name = 'Firstleg'; Text = $firstlegEntry; Limit = 8000 },
@{ Name = 'Lastleg'; Text = $lastlegEntry; Limit = 9000 },
@{ Name = 'Admin'; Text = $adminEntry; Limit = 8000 },
@{ Name = 'Client'; Text = $clientEntry; Limit = 8000 },
@{ Name = '海外 PDA'; Text = $overseasPdaEntry; Limit = 8000 }
@{ Name = 'Lastleg'; Text = $lastlegEntry; Limit = 18000 }
)) {
$script:checked++
$normalizedLength = ($budget.Text -replace "`r`n", "`n").Length
if ($normalizedLength -gt $budget.Limit) { Add-Failure "$($budget.Name) 指引超出字符预算 $($budget.Limit),请将细节按需下沉" }
if ($budget.Text.Length -gt $budget.Limit) { Add-Failure "$($budget.Name) 指引超出字符预算 $($budget.Limit),请将细节按需下沉" }
}
Assert-MarkdownTableUnique $backendIndexPath '## 服务路由(按关键词定位)' 0 '后端详细索引'
Assert-MarkdownTableUnique $lastlegIndexPath '## 快速索引' 0 'Lastleg 详细索引'
Assert-MarkdownTableUnique $firstlegEntryPath '## 模块入口' 0 'Firstleg 首个代码入口'
Assert-MarkdownTableUnique $firstlegIndexPath '## 模块入口' 0 'Firstleg 详细索引'
# 保留全部业务语义断言,只将事实读取范围扩展到明确链接的按需文档。
$backendAgents += "`n" + $backendIndex
# 固定样本只检查目标索引行和真实文件,不递归搜索;字符数是读取量指标,不是耗时或 Token 实测。
$lookupSamples = @(
@{ Key = '客户专属价格簿'; Text = $adminEntry; Entry = 'modules/fba-pricing/carrier-rates/pages/CarrierRateDetailView.vue'; Base = 'apps/admin-web/src' },
@{ Key = '入库扣货、解除与凭证历史'; Text = $adminEntry; Entry = 'modules/unified-orders/pages/UnifiedOrdersView.vue'; Base = 'apps/admin-web/src' },
@{ Key = '国内/海外 PDA 独立人员'; Text = $adminEntry; Entry = 'modules/iam/pages/InternalUsersView.vue'; Base = 'apps/admin-web/src' },
@{ Key = '客户专属价格簿'; Text = $adminAgents; Entry = 'modules/fba-pricing/carrier-rates/pages/CarrierRateDetailView.vue'; Base = 'apps/admin-web/src' },
@{ Key = '入库扣货、解除与凭证历史'; Text = $adminAgents; Entry = 'modules/unified-orders/pages/UnifiedOrdersView.vue'; Base = 'apps/admin-web/src' },
@{ Key = '国内/海外 PDA 独立人员'; Text = $adminAgents; Entry = 'modules/iam/pages/InternalUsersView.vue'; Base = 'apps/admin-web/src' },
@{ Key = '尾程订单控制'; Text = $adminOverseasAgents; Entry = 'pages/LastMileOrdersView.vue'; Base = 'apps/admin-web/src/modules/overseas-warehouse' },
@{ Key = '排车查询'; Text = $adminOverseasAgents; Entry = 'pages/DispatchesView.vue'; Base = 'apps/admin-web/src/modules/overseas-warehouse' },
@{ Key = '海外出库与派送'; Text = $adminOverseasAgents; Entry = 'pages/OutboundsView.vue'; Base = 'apps/admin-web/src/modules/overseas-warehouse' },
@{ Key = '异常退回与客户回访'; Text = $adminOverseasAgents; Entry = 'pages/ExceptionOrdersView.vue'; Base = 'apps/admin-web/src/modules/overseas-warehouse' },
@{ Key = '库存余额'; Text = $adminOverseasAgents; Entry = 'pages/InventoryView.vue'; Base = 'apps/admin-web/src/modules/overseas-warehouse' },
@{ Key = '退货入库签收与合箱'; Text = $overseasPdaEntry; Entry = 'src/pages/return-receipt/return-receipt.vue'; Base = 'apps/pda-overseas-uniapp' },
@{ Key = '退货入库签收与合箱'; Text = $overseasPdaEntry; Entry = 'src/pages/return-packing/return-packing.vue'; Base = 'apps/pda-overseas-uniapp' }
@{ Key = '退货入库签收与合箱'; Text = $overseasPdaAgents; Entry = 'src/pages/return-receipt/return-receipt.vue'; Base = 'apps/pda-overseas-uniapp' },
@{ Key = '退货入库签收与合箱'; Text = $overseasPdaAgents; Entry = 'src/pages/return-packing/return-packing.vue'; Base = 'apps/pda-overseas-uniapp' }
)
$lookupCharacters = 0
foreach ($alias in @(
@@ -321,7 +278,7 @@ Assert-Path 'baiyue-firstleg/scripts/README.md'
Assert-Path 'baiyue-firstleg/scripts/script-documentation-contract.test.mjs'
Assert-Contains $mapperScanContract 'annotationClass = Mapper.class' 'Mapper 根包注解过滤合同'
Assert-Contains $mapperScanContract '禁止分散维护 MapperScan' 'Mapper 单一扫描入口合同'
$firstlegAgents = $firstlegEntry + "`n" + $firstlegIndex
$firstlegAgents = Read-RequiredText (Join-Path $backendRoot 'firstleg/AGENTS.md')
$lastlegAgents = $lastlegEntry + "`n" + $lastlegIndex
$warehouseAgents = Read-RequiredText (Join-Path $backendRoot 'warehouse/AGENTS.md')
$coloadOpenApi = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/resources/openapi/coload-groups-v1.yaml')
@@ -425,9 +382,7 @@ Assert-Path 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/pricing/
# 海外异常处理必须贯通证据、原出库来源和库存恢复,不能退化为仅修改派送状态。
$exceptionOrderService = Read-RequiredText (Join-Path $backendRoot 'lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java')
$inventoryCommandService = Read-RequiredText (Join-Path $backendRoot 'lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java')
Assert-Contains $businessIndex '海外出库、排车、派送、异常与回访' '根海外异常库存恢复路由'
Assert-Contains $businessIndex 'exceptionorder/' '根海外异常服务入口'
Assert-Contains $lastlegIndex '退回海外仓须把实收按原出库来源恢复到服务端校验的真实托盘/库位' 'Lastleg 海外异常库存恢复规则'
Assert-Contains $businessIndex '退回海外仓时先把实收恢复到指定真实托盘/库位' '根海外异常库存恢复路由'
Assert-Contains $adminOverseasAgents '库存恢复、异常完成与派送 `RETURNED` 由服务端原子推进' 'Admin 海外异常原子边界'
Assert-Contains $backendAgents '海外异常退回、库存恢复与客户回访' '后端海外异常库存恢复路由'
Assert-Contains $lastlegAgents 'ExceptionEvidenceFileGateway.java' 'Lastleg 异常证据入口'
@@ -765,12 +720,15 @@ Assert-Contains $overseasPdaAgents '排车拣货与发车' '海外 PDA 排车入
Assert-Contains $overseasPdaAgents '普通/一件代发出库' '海外 PDA 出库入口'
# 排车新主流程按单号、公开版本和动作资格定位,不再指向独立车次发车请求。
Assert-Contains $overseasPdaEntry 'departDispatch' '海外 PDA 当前发车调用入口'
Assert-Contains $overseasPdaIndex 'actions.PICK/LOADING_COMPLETE/DEPART' '海外 PDA 服务端动作资格'
Assert-Contains $overseasPdaIndex 'recordVersion' '海外 PDA 排车公开版本'
# 仅为排车断言定向读取 PDA 详细指引,不依赖其他应用的通用索引拆分。
$dispatchPdaIndex = Read-RequiredText (Join-Path $workspaceRoot 'apps/pda-overseas-uniapp/docs/business-index.md')
$dispatchPdaGuide = $overseasPdaAgents + "`n" + $dispatchPdaIndex
Assert-Contains $overseasPdaAgents 'departDispatch' '海外 PDA 当前发车调用入口'
Assert-Contains $dispatchPdaIndex 'actions.PICK/LOADING_COMPLETE/DEPART' '海外 PDA 服务端动作资格'
Assert-Contains $dispatchPdaIndex 'recordVersion' '海外 PDA 排车公开版本'
Assert-Contains $lastlegIndex 'docs/海外仓业务/排车列表统一作业改造契约.md' 'Lastleg 当前排车主契约'
Assert-NotContains $overseasPdaAgents 'tripDepartureRequest' '海外 PDA 旧车次发车入口退出'
Assert-NotContains $overseasPdaAgents 'tripRecordVersion' '海外 PDA 旧车次版本退出'
Assert-NotContains $dispatchPdaGuide 'tripDepartureRequest' '海外 PDA 旧车次发车入口退出'
Assert-NotContains $dispatchPdaGuide 'tripRecordVersion' '海外 PDA 旧车次版本退出'
Assert-Contains $lastlegEntry 'dispatch/controller/DispatchAggregateController.java' 'Lastleg 主指引平台整车入口'
Assert-Contains $lastlegEntry 'dispatch/pda/controller/DispatchPdaController.java' 'Lastleg 主指引 PDA 整车入口'
Assert-Contains $lastlegEntry 'DispatchDepartureService.java' 'Lastleg 主指引共用发车服务'
@@ -865,7 +823,7 @@ Assert-Contains $carrierScheduleOpenApi 'estimated facts never advance actual pr
Assert-Contains $adminContainerDetail 'confirmDeparture' 'Admin 货柜详情确认离仓'
# 装柜完成后的整柜报关必须保持客户退税资料、平台冻结/完成与 File 二进制边界一致。
$firstlegAgents = $firstlegEntry + "`n" + $firstlegIndex
$firstlegAgents = Read-RequiredText (Join-Path $backendRoot 'firstleg/AGENTS.md')
$containerCustomsOpenApi = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/resources/openapi/customs-v1.yaml')
$adminContainerCustomsApi = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/container-loading/api/platformContainerCustoms.ts')
$clientContainerCustomsApi = Read-RequiredText (Join-Path $workspaceRoot 'apps/client-web/src/modules/first-leg/api/customs.ts')
@@ -1311,10 +1269,6 @@ $indexedAgentFiles = @(
$backendAgentsPath,
$backendIndexPath,
$lastlegIndexPath,
$firstlegIndexPath,
$adminIndexPath,
$clientIndexPath,
$overseasPdaIndexPath,
(Join-Path $backendRoot 'firstleg/AGENTS.md'),
(Join-Path $backendRoot 'finance/AGENTS.md'),
(Join-Path $backendRoot 'lastleg-service/AGENTS.md'),
+1 -5
View File
@@ -5,10 +5,6 @@ param(
)
$ErrorActionPreference = "Stop"
if (-not $DryRun -and -not $env:CHONGCHONG_LOCAL_CHECK_OWNER) {
& (Join-Path $PSScriptRoot 'local-check.ps1') -Label 'Backend full regression' -Command @('pwsh', '-NoProfile', '-File', $PSCommandPath, '-MavenCommand', $MavenCommand)
exit $LASTEXITCODE
}
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
$pom = Join-Path $repoRoot "pom.xml"
$mapperScanContract = Join-Path $repoRoot "scripts/mapper-scan-contract.mjs"
@@ -43,7 +39,7 @@ $requiredTests = @(
"warehouse/src/test/java/com/baiyue/warehouse/repository/OverseasStorageFeeSqlContractTest.java",
"warehouse/src/test/java/com/baiyue/warehouse/controller/PlatformOverseasStorageFeeControllerContractTest.java"
)
$mavenArgs = @('-T', '1', '-DforkCount=1', '-DreuseForks=true', "-f", $pom, "test")
$mavenArgs = @("-f", $pom, "test")
Write-Host "Backend regression root: $repoRoot"
Write-Host "Scope: complete root POM reactor (all active modules and their tests)"
-2
View File
@@ -2161,8 +2161,6 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab
PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_fba_appointment' AND column_name='selected_count' AND COALESCE(column_comment,'') <> '已选明细数量'), 'ALTER TABLE ll_inbound_fba_appointment MODIFY COLUMN selected_count INT NOT NULL DEFAULT 0 COMMENT ''已选明细数量''', 'SELECT 1'));
PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025;
SET @ddl = (SELECT IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_fba_appointment' AND column_name='replacement_nos_json'), 'ALTER TABLE ll_inbound_fba_appointment ADD COLUMN replacement_nos_json LONGTEXT NULL COMMENT ''分批或合并后的新预约业务号列表,非空时原批次只读'' AFTER selected_count', 'SELECT 1'));
PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_fba_appointment' AND column_name='created_by' AND COALESCE(column_comment,'') <> '创建人ID'), 'ALTER TABLE ll_inbound_fba_appointment MODIFY COLUMN created_by VARCHAR(128) NULL COMMENT ''创建人ID''', 'SELECT 1'));
PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_fba_appointment' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_inbound_fba_appointment MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1'));
+2 -17
View File
@@ -7,29 +7,14 @@ param(
)
$ErrorActionPreference = 'Stop'
if (-not $DryRun -and -not $env:CHONGCHONG_LOCAL_CHECK_OWNER) {
$batch = @('pwsh', '-NoProfile', '-File', $PSCommandPath)
foreach ($flag in @('SkipAdmin', 'SkipClient', 'SkipPda')) {
if ($PSBoundParameters[$flag]) { $batch += "-$flag" }
}
& (Join-Path $PSScriptRoot 'local-check.ps1') -Label 'Frontend full regression' -Command $batch
exit $LASTEXITCODE
}
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '../..')).Path
function Invoke-FrontendStep {
param([string]$Name, [string]$Directory, [string]$Task)
$display = "pnpm --dir `"$Directory`" $Task"
if ($Task -eq 'test' -and (Split-Path -Leaf $Directory) -eq 'client-web') {
$display = "pnpm --dir `"$Directory`" exec node --test --test-concurrency=2 src/**/*.test.mjs"
}
Write-Host " [$Name] $display"
if ($DryRun) { return }
if ($Task -eq 'test' -and (Split-Path -Leaf $Directory) -eq 'client-web') {
& pnpm --dir $Directory exec node --test --test-concurrency=2 'src/**/*.test.mjs'
} else {
& pnpm --dir $Directory $Task
}
& pnpm --dir $Directory $Task
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
}
@@ -38,7 +23,7 @@ function Invoke-PdaTests {
$pattern = Join-Path $Directory 'tests/*.test.mjs'
Write-Host " [test] node --test `"$pattern`""
if ($DryRun) { return }
& node --test --test-concurrency=2 $pattern
& node --test $pattern
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
}
@@ -22,7 +22,7 @@ test('frontend full regression includes domestic and overseas PDA node tests by
test('backend full regression uses the entire root reactor and preserves critical file/container coverage', () => {
const args = backendScript.match(/\$mavenArgs\s*=\s*@\(([^\r\n]+)\)/)?.[1]
assert.equal(args, '\'-T\', \'1\', \'-DforkCount=1\', \'-DreuseForks=true\', "-f", $pom, "test"')
assert.equal(args, '"-f", $pom, "test"')
assert.match(backendScript, /\$pom = Join-Path \$repoRoot "pom.xml"/)
assert.doesNotMatch(args, /-pl|-rf|-N|skipTests|maven.test.skip/)
assert.match(backendScript, /complete root POM reactor/)
-199
View File
@@ -1,199 +0,0 @@
[CmdletBinding()]
param(
[string[]]$Command,
[string]$WorkingDirectory = (Get-Location).Path,
[string]$Label = 'local check',
[ValidateRange(0, 86400)][int]$WaitSeconds = 900,
[ValidateRange(1, 86400)][int]$TimeoutSeconds = 1200,
[switch]$Status
)
$ErrorActionPreference = 'Stop'
if (-not $IsWindows -or $PSVersionTable.PSVersion.Major -lt 7) {
throw 'local-check requires Windows and PowerShell 7 (pwsh).'
}
# 所有工作目录使用同一用户级锁;句柄随进程退出关闭,不依赖删除过期锁文件。
$stateDirectory = Join-Path $env:LOCALAPPDATA 'Chongchong/LocalChecks'
[IO.Directory]::CreateDirectory($stateDirectory) | Out-Null
$lockPath = Join-Path $stateDirectory 'heavy.lock'
$ownerPath = Join-Path $stateDirectory 'owner.json'
$resultPath = Join-Path $stateDirectory 'last-result.json'
function Read-Owner {
try { return [IO.File]::ReadAllText($ownerPath) } catch { return 'Owner information is being updated.' }
}
function Open-Slot {
try { return [IO.File]::Open($lockPath, 'OpenOrCreate', 'ReadWrite', 'None') }
catch [IO.IOException] {
if (($_.Exception.HResult -band 0xffff) -notin @(32, 33)) { throw }
return $null
}
}
if ($Status) {
$probe = Open-Slot
if ($probe) { $probe.Dispose(); Write-Host 'IDLE: heavy check slot is free.' }
else { Write-Host 'BUSY:'; Write-Host (Read-Owner) }
if (Test-Path -LiteralPath $resultPath) { Write-Host 'Last result:'; Write-Host ([IO.File]::ReadAllText($resultPath)) }
exit 0
}
if (-not $Command -or -not $Command[0]) { throw 'Pass -Command as an array, for example @("pnpm", "typecheck").' }
$directory = (Resolve-Path -LiteralPath $WorkingDirectory).Path
if (-not (Test-Path -LiteralPath $directory -PathType Container)) { throw 'WorkingDirectory must be a directory.' }
if ($env:CHONGCHONG_LOCAL_CHECK_OWNER) {
# 拒绝嵌套申请,避免外层等待内层、内层等待同一锁。
throw 'Already inside local-check. Run the original command directly inside this checked batch.'
}
$timer = [Diagnostics.Stopwatch]::StartNew()
$slot = $null
$job = $null
$child = $null
$gatePath = Join-Path $stateDirectory ("gate-{0}.tmp" -f [guid]::NewGuid())
$owner = $null
$exitCode = 1
try {
$nextNotice = 0
while (-not ($slot = Open-Slot)) {
if ($timer.Elapsed.TotalSeconds -ge $WaitSeconds) {
Write-Host "QUEUE TIMEOUT after $WaitSeconds seconds. No check was started."
exit 75
}
if ($timer.Elapsed.TotalSeconds -ge $nextNotice) {
Write-Host "WAITING for the shared heavy check slot: $(Read-Owner)"
$nextNotice = $timer.Elapsed.TotalSeconds + 15
}
Start-Sleep -Milliseconds 250
}
$owner = [ordered]@{ label = $Label; pid = $PID; directory = $directory; tool = $Command[0]; startedAt = [DateTimeOffset]::Now.ToString('o') }
[IO.File]::WriteAllText($ownerPath, ($owner | ConvertTo-Json -Compress))
Write-Host "RUNNING [$Label] in $directory (one shared slot; timeout ${TimeoutSeconds}s)"
# Job Object 在包装器被强制结束时也回收本次子进程树,不影响其他 Node/Java。
if (-not ('LocalCheckJob' -as [type])) {
Add-Type -TypeDefinition @'
using System;
using System.ComponentModel;
using System.Runtime.InteropServices;
public static class LocalCheckJob {
[StructLayout(LayoutKind.Sequential)] struct BasicLimits {
public long PerProcessUserTimeLimit, PerJobUserTimeLimit;
public uint LimitFlags;
public UIntPtr MinimumWorkingSetSize, MaximumWorkingSetSize;
public uint ActiveProcessLimit;
public UIntPtr Affinity;
public uint PriorityClass, SchedulingClass;
}
[StructLayout(LayoutKind.Sequential)] struct IoCounters {
public ulong ReadOperationCount, WriteOperationCount, OtherOperationCount;
public ulong ReadTransferCount, WriteTransferCount, OtherTransferCount;
}
[StructLayout(LayoutKind.Sequential)] struct ExtendedLimits {
public BasicLimits BasicLimitInformation;
public IoCounters IoInfo;
public UIntPtr ProcessMemoryLimit, JobMemoryLimit, PeakProcessMemoryUsed, PeakJobMemoryUsed;
}
[StructLayout(LayoutKind.Sequential)] struct Accounting {
public long UserTime, KernelTime, PeriodUserTime, PeriodKernelTime;
public uint PageFaults, TotalProcesses, ActiveProcesses, TerminatedProcesses;
}
[DllImport("kernel32.dll", CharSet=CharSet.Unicode, SetLastError=true)] static extern IntPtr CreateJobObject(IntPtr attributes, string name);
[DllImport("kernel32.dll", SetLastError=true)] static extern bool SetInformationJobObject(IntPtr job, int infoClass, ref ExtendedLimits info, uint length);
[DllImport("kernel32.dll", SetLastError=true)] static extern bool AssignProcessToJobObject(IntPtr job, IntPtr process);
[DllImport("kernel32.dll", SetLastError=true)] static extern bool TerminateJobObject(IntPtr job, uint code);
[DllImport("kernel32.dll", SetLastError=true)] static extern bool QueryInformationJobObject(IntPtr job, int infoClass, out Accounting info, uint length, IntPtr returnedLength);
[DllImport("kernel32.dll")] public static extern bool CloseHandle(IntPtr handle);
public static IntPtr Create() {
var job = CreateJobObject(IntPtr.Zero, null);
if (job == IntPtr.Zero) throw new Win32Exception();
var limits = new ExtendedLimits();
limits.BasicLimitInformation.LimitFlags = 0x2000; // KILL_ON_JOB_CLOSE
if (!SetInformationJobObject(job, 9, ref limits, (uint)Marshal.SizeOf<ExtendedLimits>())) {
int error = Marshal.GetLastWin32Error(); CloseHandle(job); throw new Win32Exception(error);
}
return job;
}
public static void Attach(IntPtr job, IntPtr process) {
if (!AssignProcessToJobObject(job, process)) throw new Win32Exception();
}
public static void Stop(IntPtr job) {
if (!TerminateJobObject(job, 124)) throw new Win32Exception();
// 后代全部退出再归还执行名额,避免退出瞬间下一任务与遗留 worker 重叠。
Accounting info;
do {
if (!QueryInformationJobObject(job, 1, out info, (uint)Marshal.SizeOf<Accounting>(), IntPtr.Zero)) throw new Win32Exception();
if (info.ActiveProcesses != 0) System.Threading.Thread.Sleep(50);
} while (info.ActiveProcesses != 0);
}
}
'@
}
$job = [LocalCheckJob]::Create()
$payload = @{ command = $Command; gate = $gatePath } | ConvertTo-Json -Compress
$payload64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($payload))
$bootstrap = @'
$ErrorActionPreference = 'Stop'
$payload = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($env:CHONGCHONG_LOCAL_CHECK_PAYLOAD)) | ConvertFrom-Json
while (-not (Test-Path -LiteralPath $payload.gate)) { Start-Sleep -Milliseconds 50 }
$env:CHONGCHONG_LOCAL_CHECK_PAYLOAD = $null
$commandArgs = @($payload.command | Select-Object -Skip 1)
$executable = [string]$payload.command[0]
if ([IO.Path]::GetFileNameWithoutExtension($executable) -in @('mvn', 'mvnw')) {
if (@($commandArgs | Where-Object { $_ -match '^-T|^--threads|^-D(forkCount|reuseForks|parallel)=' }).Count) {
throw 'Do not override Maven concurrency in local-check; -T 1 and forkCount=1 are supplied automatically.'
}
$commandArgs = @('-T', '1', '-DforkCount=1', '-DreuseForks=true') + $commandArgs
}
if ([IO.Path]::GetFileNameWithoutExtension($executable) -eq 'node' -and $commandArgs -contains '--test') {
if (@($commandArgs | Where-Object { $_ -match '^--test-concurrency' }).Count) { throw 'local-check supplies --test-concurrency=2.' }
$commandArgs = @('--test-concurrency=2') + $commandArgs
}
$global:LASTEXITCODE = 0
& $executable @commandArgs
if (-not $?) { if ($LASTEXITCODE) { exit $LASTEXITCODE }; exit 1 }
exit $LASTEXITCODE
'@
$startInfo = [Diagnostics.ProcessStartInfo]::new()
$startInfo.FileName = (Get-Process -Id $PID).Path
foreach ($arg in @('-NoLogo', '-NoProfile', '-NonInteractive', '-OutputFormat', 'Text', '-EncodedCommand', [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($bootstrap)))) { $startInfo.ArgumentList.Add($arg) }
$startInfo.WorkingDirectory = $directory
$startInfo.UseShellExecute = $false
$startInfo.Environment['CHONGCHONG_LOCAL_CHECK_OWNER'] = [string]$PID
$startInfo.Environment['CHONGCHONG_LOCAL_CHECK_PAYLOAD'] = $payload64
$startInfo.Environment['VITEST_MAX_WORKERS'] = '2'
$startInfo.Environment['NODE_OPTIONS'] = (($env:NODE_OPTIONS -replace '--max-old-space-size(?:=|\s+)\S+', '') + ' --max-old-space-size=2048').Trim()
$startInfo.Environment['JAVA_TOOL_OPTIONS'] = (($env:JAVA_TOOL_OPTIONS -replace '-XX:ActiveProcessorCount=\S+|-Xmx\S+', '') + ' -XX:ActiveProcessorCount=2 -Xmx1536m').Trim()
# Maven 启动器可能在 JAVA_TOOL_OPTIONS 之后应用 MAVEN_OPTS,末尾再限定一次。
$startInfo.Environment['MAVEN_OPTS'] = (($env:MAVEN_OPTS -replace '-XX:ActiveProcessorCount=\S+|-Xmx\S+', '') + ' -XX:ActiveProcessorCount=2 -Xmx1536m').Trim()
$child = [Diagnostics.Process]::Start($startInfo)
[LocalCheckJob]::Attach($job, $child.Handle)
# 先纳入 Job 再放行,防止短命包装器提前产生未受管理的后代。
[IO.File]::WriteAllText($gatePath, '')
$running = [Diagnostics.Stopwatch]::StartNew()
while (-not $child.WaitForExit(250)) {
if ($running.Elapsed.TotalSeconds -ge $TimeoutSeconds) {
Write-Host "EXECUTION TIMEOUT [$Label]; stopping only this check's process tree."
$exitCode = 124
break
}
}
if ($exitCode -ne 124) { $exitCode = $child.ExitCode }
} finally {
if ($job) {
try { [LocalCheckJob]::Stop($job) }
finally { [LocalCheckJob]::CloseHandle($job) | Out-Null }
}
if ($child) {
if (-not $child.HasExited) { $child.Kill($true); $child.WaitForExit() }
$child.Dispose()
}
if (Test-Path -LiteralPath $gatePath) { Remove-Item -LiteralPath $gatePath -Force }
if ($slot) {
try {
if ($owner) {
$owner['finishedAt'] = [DateTimeOffset]::Now.ToString('o')
$owner['exitCode'] = $exitCode
[IO.File]::WriteAllText($resultPath, ($owner | ConvertTo-Json -Compress))
}
} finally { $slot.Dispose() }
}
}
Write-Host "FINISHED [$Label] exit=$exitCode"
exit $exitCode
-112
View File
@@ -1,112 +0,0 @@
import assert from 'node:assert/strict'
import { spawn } from 'node:child_process'
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
const launcher = fileURLToPath(new URL('./local-check.ps1', import.meta.url))
const quote = value => `'${String(value).replaceAll("'", "''")}'`
const delay = ms => new Promise(resolve => setTimeout(resolve, ms))
function run(state, args, { cwd = os.tmpdir(), timeout = 15, wait = 15 } = {}) {
const script = `& ${quote(launcher)} -WorkingDirectory ${quote(cwd)} -TimeoutSeconds ${timeout} -WaitSeconds ${wait} -Command @(${args.map(quote).join(',')}); exit $LASTEXITCODE`
const env = { ...process.env, LOCALAPPDATA: state }
delete env.CHONGCHONG_LOCAL_CHECK_OWNER
const child = spawn('pwsh', ['-NoProfile', '-NonInteractive', '-OutputFormat', 'Text', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')], { env })
let output = ''
child.stdout.on('data', chunk => { output += chunk })
child.stderr.on('data', chunk => { output += chunk })
const done = new Promise((resolve, reject) => {
child.on('error', reject)
child.on('close', code => resolve({ code, output }))
})
return { child, done, output: () => output }
}
async function until(check, message) {
const deadline = Date.now() + 12000
while (Date.now() < deadline) {
if (await check()) return
await delay(100)
}
assert.fail(message)
}
test('local-check serializes different directories, handles failures and recovers process trees', { skip: process.platform !== 'win32', timeout: 90000 }, async t => {
const state = await mkdtemp(path.join(os.tmpdir(), 'local-check-test-'))
const processes = []
const start = (...args) => { const result = run(state, ...args); processes.push(result); return result }
try {
await t.test('literal arguments, environment caps and exit codes are preserved', async () => {
const result = await start([process.execPath, '-e', 'console.log(JSON.stringify([process.argv[1],process.env.VITEST_MAX_WORKERS,process.env.NODE_OPTIONS,process.env.JAVA_TOOL_OPTIONS]));process.exit(7)', 'space quote \' $() ` ; 中文']).done
assert.equal(result.code, 7, result.output)
assert.ok(result.output.includes(JSON.stringify('space quote \' $() ` ; 中文')))
assert.match(result.output, /"2","--max-old-space-size=2048"/)
assert.match(result.output, /ActiveProcessorCount=2 -Xmx1536m/)
})
await t.test('a running check excludes another directory and queue timeout starts nothing', async () => {
const first = start([process.execPath, '-e', 'console.log("HOLDER_READY");setTimeout(()=>{},4000)'])
await until(() => first.output().includes('HOLDER_READY'), first.output())
const timedOut = await start([process.execPath, '-e', 'console.log("SHOULD_NOT_RUN")'], { wait: 0, cwd: path.dirname(launcher) }).done
assert.equal(timedOut.code, 75, timedOut.output)
assert.doesNotMatch(timedOut.output, /SHOULD_NOT_RUN/)
const second = start([process.execPath, '-e', 'console.log("SECOND_READY")'], { cwd: path.dirname(launcher) })
await until(() => second.output().includes('WAITING'), second.output())
assert.doesNotMatch(second.output(), /SECOND_READY/)
assert.equal((await first.done).code, 0)
const result = await second.done
assert.equal(result.code, 0, result.output)
assert.match(result.output, /SECOND_READY/)
})
await t.test('execution timeout kills descendants and frees the slot', async () => {
const pidFile = path.join(state, 'descendant.pid')
const code = `const {spawn}=require('child_process');const fs=require('fs');const c=spawn(process.execPath,['-e','setInterval(()=>{},1000)'],{stdio:'ignore'});fs.writeFileSync(${JSON.stringify(pidFile)},String(c.pid));setInterval(()=>{},1000)`
const result = await start([process.execPath, '-e', code], { timeout: 3 }).done
assert.equal(result.code, 124, result.output)
const descendant = Number(await readFile(pidFile, 'utf8'))
await until(() => { try { process.kill(descendant, 0); return false } catch { return true } }, 'descendant survived timeout')
assert.equal((await start([process.execPath, '-e', 'process.exit(0)']).done).code, 0)
})
await t.test('abrupt wrapper termination kills its descendants and releases the file lock', async () => {
const pidFile = path.join(state, 'abrupt.pid')
const code = `require('fs').writeFileSync(${JSON.stringify(pidFile)},String(process.pid));console.log('ABRUPT_READY');setInterval(()=>{},1000)`
const active = start([process.execPath, '-e', code])
await until(() => active.output().includes('ABRUPT_READY'), active.output())
const descendant = Number(await readFile(pidFile, 'utf8'))
active.child.kill()
await active.done
await until(() => { try { process.kill(descendant, 0); return false } catch { return true } }, 'descendant survived wrapper termination')
assert.equal((await start([process.execPath, '-e', 'process.exit(0)']).done).code, 0)
})
await t.test('missing commands fail and do not leave the slot occupied', async () => {
const result = await start(['chongchong-missing-command-for-test']).done
assert.notEqual(result.code, 0, result.output)
assert.equal((await start([process.execPath, '-e', 'process.exit(0)']).done).code, 0)
})
await t.test('Maven arguments are limited without launching Maven', async () => {
const fakeMaven = path.join(state, 'mvn.ps1')
await writeFile(fakeMaven, 'ConvertTo-Json -InputObject @($args) -Compress\n')
const result = await start([fakeMaven, '-pl', 'example', 'test']).done
assert.equal(result.code, 0, result.output)
assert.match(result.output, /\["-T","1","-DforkCount=1","-DreuseForks=true","-pl","example","test"\]/)
const override = await start([fakeMaven, '-T4', 'test']).done
assert.notEqual(override.code, 0, override.output)
assert.match(override.output, /Do not override Maven concurrency/)
})
await t.test('direct Node test concurrency is supplied and nested locks are rejected', async () => {
const nodeTest = path.join(state, 'probe.test.mjs')
await writeFile(nodeTest, `import assert from 'node:assert/strict'; assert.ok(process.execArgv.includes('--test-concurrency=2'));\n`)
const result = await start([process.execPath, '--test', nodeTest]).done
assert.equal(result.code, 0, result.output)
const nested = await start(['pwsh', '-NoProfile', '-File', launcher, '-Command', 'node']).done
assert.notEqual(nested.code, 0, nested.output)
assert.match(nested.output, /Already inside local-check/)
})
} finally {
for (const item of processes) if (item.child.exitCode === null) item.child.kill()
await Promise.all(processes.map(item => item.done))
await rm(state, { recursive: true, force: true })
}
})