Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
135ffa662d | ||
|
|
a8037fd97d | ||
|
|
d7616899d8 |
@@ -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"
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import subprocess, shutil, xml.etree.ElementTree as ET
|
||||
from pathlib import Path
|
||||
root=Path.cwd()
|
||||
report=root/'lastleg-service/target/surefire-reports/TEST-com.baiyue.lastleg.dispatch.service.DispatchAggregatePersistenceTest.xml'
|
||||
properties=ET.parse(report).getroot().find('properties')
|
||||
classpath=next(item.attrib['value'] for item in properties if item.attrib['name']=='java.class.path')
|
||||
sources=[str(root/'lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip'/name) for name in ['FleetTripCalendarServiceTest.java','FleetTripCalendarMapperTest.java']]
|
||||
subprocess.run(['javac','-encoding','UTF-8','--release','17','-cp',classpath,'-d',str(root/'lastleg-service/target/test-classes'),*sources],check=True)
|
||||
result=subprocess.run([shutil.which('mvn'),' -T'.strip(),'1','-DforkCount=1','-DreuseForks=true','-pl','lastleg-service','surefire:test','-Dtest=FleetTripCalendarServiceTest,FleetTripCalendarMapperTest'],check=False)
|
||||
raise SystemExit(result.returncode)
|
||||
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
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
@@ -108,14 +108,14 @@
|
||||
|
||||
## 数据库访问与中文代码注释(所有服务及公共模块强制)
|
||||
|
||||
- 本节适用于全部后端服务和公共模块;新增代码遵守下列规范,存量分层仅在影响本次正确性或用户明确要求时整改,不因局部修改迁移整条链路;以上为目标规范,不代表存量均已达标。
|
||||
- 本节适用于全部后端服务和公共模块;新增、修改的方法必须执行,触及的存量代码同步整改。登记本约束不代表存量代码已全部完成迁移和注释补齐。
|
||||
- 生产代码的关系型数据库读写统一经 MyBatis/MyBatis-Plus Mapper 执行;Controller、Service、定时任务、消费者、编号生成器和公共基础组件不得直接使用 `JdbcTemplate`、`NamedParameterJdbcTemplate`、原生 JDBC/Connection/Statement 或其他通道执行 SQL。仅改成 Repository 包装 JDBC 不算整改,SQL 必须进入 Mapper;无数据库职责的模块不得为满足本条新增数据库访问。
|
||||
- 正常业务数据库访问路径统一为 `Controller → Service → Repository → Mapper`,四层职责明确,不得省略 Repository,也不得由 Controller 直接调用 Repository/Mapper,或由 Service 直接调用 Mapper。定时任务、消费者等非 HTTP 入口从 Service 进入,同样经 Repository → Mapper 完成持久化访问。
|
||||
- Controller 负责请求接收、参数校验、入口权限和响应转换;Service 负责业务规则、编排及业务事务;Repository 负责领域数据访问封装、查询组合与持久化模型转换,对 Service 提供有业务含义的数据操作;Mapper 负责 SQL 执行及数据库记录映射。Repository 不承载业务状态流转,不直接用 JDBC 执行 SQL;Service 不继承 MyBatis-Plus `ServiceImpl` 等方式绕过 Repository。
|
||||
- 复杂查询、批量处理、原子计数、锁定读取、Outbox 和数据库监控查询同样经 Repository → Mapper,不以 SQL 特殊或实现方便为由跳层。公共模块需要数据库能力时,按现有模块边界定义 Repository/持久化接口,由所属服务的 Repository → Mapper 实现,禁止借此跨服务读库。
|
||||
- 迁移数据库操作须保留客户隔离条件、事务传播、行锁、幂等和并发语义;`LAST_INSERT_ID()` 等连接级操作必须在同一事务连接完成,并明确关闭相关读取缓存,不能仅机械搬移 SQL。Mapper 注册及扫描仍遵守下文统一规范。
|
||||
- 测试代码可用 `JdbcTemplate` 创建隔离测试表、准备数据及核对结果;被测生产路径仍必须调用实际 Mapper,不得把测试造数做法带入生产。数据库基线和迁移脚本按现有部署契约管理。
|
||||
- 本次新增或修改的手写方法及构造器须有简短中文业务注释,通常 1—3 行;接口与 Mapper 方法优先用中文 Javadoc,覆盖方法同样适用,关键规则就地说明。不补齐未修改或自动生成方法的注释。
|
||||
- 每个手写方法及构造器均须添加简短中文注释,通常 1—3 行说明业务用途;接口方法和 Mapper 方法优先使用中文 Javadoc。覆盖方法同样须有简短中文说明,实现中的关键规则仍须就地注释;自动生成的方法不手工逐个补注释。
|
||||
- 核心业务、复杂 SQL、状态流转、资金与库存变更、权限隔离、事务、锁、幂等、并发、重试和补偿逻辑,须在关键代码前增加适量中文注释,解释业务规则、处理原因、约束边界及失败影响;不逐行翻译代码,不添加无信息量或与实现不符的注释。
|
||||
- 新增和修改的代码说明注释统一使用中文;必要的技术标识、协议名、类名可保留原文,第三方许可证和生成标记保持原样。修改方法时同步更新相关存量英文或过期说明;审查时同时核对 Controller → Service → Repository → Mapper 分层及中文方法、核心逻辑注释。
|
||||
|
||||
@@ -132,11 +132,7 @@
|
||||
- 数据库只访问所属服务库。模块表结构位于 `<module>/src/main/resources/db/`;开发环境按最新模块基线和 `deploy/sql/头程数据库统一增量-MySQL8.sql` 收敛,不为历史开发数据新增兼容迁移。已有名称含 `migration` 的脚本仅作结构来源和追溯,部署说明见 `deploy/sql/README.md`;生产升级另按明确发布规则处理。
|
||||
- 后端同样遵守根指引中的“开发数据和上传文件可丢弃”边界:不得为现有开发库孤儿文件、旧文件归属或历史部分失败新增删除补偿接口、兼容复用、回填或清理链路;只修复当前正式上传与持久化路径,既有开发文件由人工重新上传。
|
||||
- 通用配置在 `config/nacos/test/`;服务日志、上传文件、`target/`、`reports/` 不属于源码。
|
||||
- 后端本地验证默认使用 `scripts/backend-check.ps1`,必须显式指定 `-Modules`,不传模块直接拒绝;命令及参数见 `scripts/README.md` 的“后端分层检查”。入口自动经 `local-check.ps1` 限流,`-DryRun` 只检查选择范围,不代表测试通过。
|
||||
- 局部逻辑使用 `-Mode Focused -Tests <精确测试类名>`,覆盖修改行为及必要边界即可交付,不因收尾再跑 `Module`。Java 签名变化须覆盖生产与测试源码编译;定向测试已编译时不重复跑 `-Mode Compile`。只有影响扩及服务内多个行为且定向覆盖不足才选 `Module`,先说明原因。跨服务 DTO、接口或事件变化显式列入生产者和实际消费者;`-am` 仅补上游构建依赖,不自动包含下游或 HTTP/事件消费者。
|
||||
- 仅注释/文档调整不触发 Java 回归;脚本调整执行受影响脚本测试和登记合同。API、Mapper、AGENTS 等静态合同仍按各自触发条件执行。纯重构也应覆盖受影响行为,不能只用编译宣称回归通过。`-Dmaven.test.skip=true` 跳过测试编译,不得作为签名变更交付验证。
|
||||
- 全仓回归仅用于已确认影响广泛且定向覆盖不足的公共依赖/聚合拓扑/认证/序列化/基础模型变化,或用户要求的全量/跨域集成验收;先说明具体影响,不能因未查清范围而全跑。根 POM、依赖或测试配置改动先按实际影响选编译或测试,不一律全仓。入口为 `scripts/backend-full-regression.ps1`,CI 全量保留。普通提交、单服务任务或对话收尾不触发;有效结果复用,输入变化只补跑受影响范围。
|
||||
- 不以缩短耗时为由删除金额、库存、权限隔离、状态流转、幂等和并发测试;冗余测试须根据重复场景及维护成本单独评估。不要在本地启动 Java/Nacos/数据库,除非用户明确要求。
|
||||
- Java 签名变更的最低编译门禁是 `mvn -pl <module> -am test-compile`,它必须在部署前完成并覆盖测试源码;有行为变化时继续运行 `mvn -pl <module> -am test`。`-DskipTests` 可跳过测试执行但仍须保留测试编译,`-Dmaven.test.skip=true` 会跳过测试编译,不得作为签名变更的交付验证。全量验证仍使用 `mvn -DskipTests validate`、`mvn test`;不要在本地启动 Java/Nacos/数据库,除非用户明确要求。
|
||||
- 同一批 `AGENTS.md` 索引修改完成后从后端根统一运行一次 `./scripts/agents-index-contract.ps1`;脚本只读源码目录和指定指引,不访问 HTTP、数据库或部署环境。
|
||||
- Mapper 注册静态合同为 `scripts/mapper-scan-contract.mjs`、`mapper-scan-contract.test.mjs`:自动发现聚合模块、生产 `*Mapper` 接口、服务启动类和分散扫描配置,不启动 Spring、不连接数据库;遗漏注解、扫描根漂移或重新引入子域扫描时直接失败并输出类与文件路径。
|
||||
- 单服务镜像构建/部署从 `Jenkinsfile.service`、`deploy/docker/Dockerfile`、`deploy/scripts/deploy-built-service.sh` 进入;新镜像启动失败或健康检查失败时只允许回滚到同服务先前镜像,并保持任务失败,离线合同见 `scripts/deploy-built-service-rollback.test.mjs`。实际部署仍须当前对话明确授权。
|
||||
|
||||
Vendored
+2
-35
@@ -67,8 +67,6 @@ def parseAutoBuildState(String description, List<String> allowedServices) {
|
||||
List<String> failedServices = []
|
||||
if (state.startsWith('RESULTS')) {
|
||||
String summary = state.substring('RESULTS'.length()).trim()
|
||||
int metadataIndex = summary.indexOf(' | ')
|
||||
if (metadataIndex >= 0) summary = summary.substring(0, metadataIndex).trim()
|
||||
summary.split(',').collect { it.trim() }.findAll { it }.each { item ->
|
||||
int separator = item.indexOf('=')
|
||||
if (separator > 0) {
|
||||
@@ -105,25 +103,6 @@ def previousAutoBuildState(def previousBuild, List<String> allowedServices) {
|
||||
return null
|
||||
}
|
||||
|
||||
// 测试范围必须相对成功门禁,而非失败/取消轮次;否则补跑可能遗漏前次未通过的变更。
|
||||
def previousSuccessfulTestCommit(def previousBuild, List<String> requiredServices) {
|
||||
def candidate = previousBuild
|
||||
int inspected = 0
|
||||
while (candidate != null && inspected < 30) {
|
||||
String description = candidate.description ?: ''
|
||||
String marker = ' | TESTS=PASSED '
|
||||
int markerIndex = description.lastIndexOf(marker)
|
||||
if (candidate.result == 'SUCCESS' && markerIndex >= 0) {
|
||||
List metadata = description.substring(markerIndex + marker.length()).trim().split(' ')
|
||||
if (metadata.size() == 2 && metadata[0] ==~ /[0-9a-fA-F]{7,40}/ &&
|
||||
requiredServices.every { metadata[1].split(',').contains(it) }) return metadata[0]
|
||||
}
|
||||
candidate = candidate.previousBuild
|
||||
inspected++
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
def environmentChoicesForJob(String jobBaseName) {
|
||||
return [fixedEnvironmentForJob(jobBaseName) ?: 'test']
|
||||
}
|
||||
@@ -182,9 +161,7 @@ pipeline {
|
||||
choice(name: 'ENVIRONMENT', choices: environmentChoicesForJob(env.JOB_BASE_NAME), description: '部署环境')
|
||||
booleanParam(name: 'DEPLOY', defaultValue: false, description: '各服务构建成功后是否独立部署')
|
||||
booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: '是否跳过测试执行(仍编译测试源码)')
|
||||
choice(name: 'TEST_SCOPE', choices: ['AUTO', 'MODULE'], description: 'AUTO 对已支持的局部变更执行关联测试;公共/未知变更自动完整模块回归;MODULE 显式完整模块回归')
|
||||
// 地址由当前 Job 配置提供;参数声明刷新时保留已配置值,不将环境地址写入源码。
|
||||
string(name: 'IMAGE_REPOSITORY', defaultValue: params.IMAGE_REPOSITORY ?: '', description: '沿用当前 Job 已配置的镜像仓库地址;首次配置或更换仓库时填写 host[:port]/owner/image')
|
||||
string(name: 'IMAGE_REPOSITORY', defaultValue: '', description: '必须填写已核实的共享镜像仓库地址 host[:port]/owner/image')
|
||||
string(name: 'DOCKER_BASE_IMAGE', defaultValue: 'eclipse-temurin:17-jre-alpine', description: 'Java 17 基础镜像')
|
||||
choice(name: 'DOWNSTREAM_JOB_PREFIX', choices: downstreamJobPrefixChoicesForJob(env.JOB_BASE_NAME), description: '由当前总控 Job 固定的单服务 Job 前缀')
|
||||
}
|
||||
@@ -291,7 +268,6 @@ pipeline {
|
||||
selected = selected.unique()
|
||||
|
||||
env.SELECTED_SERVICES = selected.join(',')
|
||||
env.TEST_BASE_COMMIT = previousSuccessfulTestCommit(currentBuild.previousBuild, selected)
|
||||
env.NO_SERVICE_CHANGE = selected ? 'false' : 'true'
|
||||
if (!selected) {
|
||||
currentBuild.description = params.BUILD_MODE == 'AUTO'
|
||||
@@ -358,22 +334,16 @@ pipeline {
|
||||
.findAll { it }
|
||||
.join(',')
|
||||
echo "统一测试本次选中的服务及其公共依赖: ${selectedModules}"
|
||||
String testPlan = "${pwd(tmp: true)}/ci-test-plan.json"
|
||||
lock(resource: 'firstleg-test-maven') {
|
||||
timeout(time: 50, unit: 'MINUTES') {
|
||||
sh """
|
||||
test \"\$(git rev-parse HEAD)\" = \"${env.SOURCE_COMMIT}\"
|
||||
find . -type f -path '*/target/surefire-reports/TEST-*.xml' -delete
|
||||
node deploy/scripts/ci-test-scope.mjs plan --modules '${selectedModules}' --base '${env.TEST_BASE_COMMIT ?: ''}' --mode '${env.RESOLVED_ENVIRONMENT == 'test' ? (params.TEST_SCOPE ?: 'AUTO') : 'MODULE'}' --plan '${testPlan}'
|
||||
node deploy/scripts/ci-test-scope.mjs checks --plan '${testPlan}'
|
||||
test_arguments=\"\$(node deploy/scripts/ci-test-scope.mjs args --plan '${testPlan}')\"
|
||||
sh deploy/scripts/run-ci-maven.sh --settings deploy/maven/settings-ci.xml --batch-mode --errors -pl ${selectedModules} -am test \$test_arguments
|
||||
node deploy/scripts/ci-test-scope.mjs verify --plan '${testPlan}'
|
||||
sh deploy/scripts/run-ci-maven.sh --settings deploy/maven/settings-ci.xml --batch-mode --errors -pl ${selectedModules} -am test
|
||||
"""
|
||||
}
|
||||
}
|
||||
echo "统一测试门禁通过: ${env.SOURCE_COMMIT};下游单服务任务不再重复执行测试"
|
||||
env.TEST_GATE_PASSED = 'true'
|
||||
}
|
||||
}
|
||||
post {
|
||||
@@ -430,9 +400,6 @@ pipeline {
|
||||
currentBuild.description = params.BUILD_MODE == 'AUTO'
|
||||
? "AUTO ${env.IMAGE_TAG} | RESULTS ${summary}"
|
||||
: "${env.IMAGE_TAG} | ${summary}"
|
||||
if (env.TEST_GATE_PASSED == 'true') {
|
||||
currentBuild.description += " | TESTS=PASSED ${env.SOURCE_COMMIT} ${env.SELECTED_SERVICES}"
|
||||
}
|
||||
if (failed) {
|
||||
unstable("部分服务执行失败,但其他服务已独立完成:${failed.join(', ')}")
|
||||
}
|
||||
|
||||
+1
-2
@@ -49,8 +49,7 @@ pipeline {
|
||||
booleanParam(name: 'ALLOW_SAME_CONTRACT_ROLLBACK', defaultValue: false, description: '仅确认相同协议与Schema时允许回滚;架构切换保持关闭')
|
||||
booleanParam(name: 'DEPLOY', defaultValue: false, description: '构建成功后是否部署本服务')
|
||||
booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: '是否跳过测试')
|
||||
// 独立构建沿用本 Job 配置,总控调度时保留其明确传入的地址。
|
||||
string(name: 'IMAGE_REPOSITORY', defaultValue: params.IMAGE_REPOSITORY ?: '', description: '沿用当前 Job 已配置的镜像仓库基础路径,不包含服务名和标签;首次配置或更换仓库时填写')
|
||||
string(name: 'IMAGE_REPOSITORY', defaultValue: '', description: '必须填写已核实的共享镜像仓库基础路径,不包含服务名和标签')
|
||||
string(name: 'DOCKER_BASE_IMAGE', defaultValue: 'eclipse-temurin:17-jre-alpine', description: 'Java 17 基础镜像')
|
||||
}
|
||||
|
||||
|
||||
@@ -23,16 +23,11 @@
|
||||
## 本地构建
|
||||
|
||||
```powershell
|
||||
# 日常按受影响模块检查;跨服务变更显式列入消费者。
|
||||
.\scripts\backend-check.ps1 -Modules order -Mode Compile
|
||||
.\scripts\backend-check.ps1 -Modules order
|
||||
# 根构建/拓扑变化等全量触发条件见 AGENTS.md。
|
||||
.\scripts\local-check.ps1 -Command @('node', '--test', 'scripts/java-topology-contract.test.mjs')
|
||||
.\scripts\backend-full-regression.ps1
|
||||
mvn -DskipTests validate
|
||||
node --test scripts/java-topology-contract.test.mjs
|
||||
mvn test
|
||||
```
|
||||
|
||||
开发中可通过 `backend-check.ps1 -Mode Focused -Modules <模块> -Tests <精确测试类名>` 快速反馈;参数、DryRun 和结果边界见 [后端分层检查](scripts/README.md#后端分层检查)。单服务修改收尾执行模块回归,不默认执行上面的全仓命令。
|
||||
|
||||
GitLab 在独立 Node 镜像中自动执行拓扑合同,随后才允许服务制品打包。Maven `verify` 会为执行过测试且产生覆盖率数据的模块生成 `target/site/jacoco/index.html` 与 `jacoco.xml`;当前只生成报告,不设置覆盖率阈值,也不改变测试选择。Jenkins 的 `java-builder` 尚未在仓库中声明 Node 运行时,因此暂不重复执行该 Node 合同,以 GitLab 门禁和上述本地命令为准。
|
||||
|
||||
2026-07-30 订单与客户前端联调缺口已完成真实回归:19 个 Reactor 项目构建成功,75 项单元及组件测试、68 项认证与 IAM 真实 API、37 项订单与仓库真实 API 全部通过。详细证据见 `docs/phase1-business-maturity-regression-report.md`。
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
## 数据库访问与中文注释约束
|
||||
|
||||
- 强制继承 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则;该节是分层、SQL 执行和方法/核心逻辑中文注释的唯一维护处,本模块不另设例外。
|
||||
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
|
||||
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
|
||||
|
||||
## 职责边界
|
||||
|
||||
|
||||
+2
-1
@@ -2,7 +2,8 @@
|
||||
|
||||
## 数据库访问与中文注释约束
|
||||
|
||||
- 强制继承 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则;该节是分层、SQL 执行和方法/核心逻辑中文注释的唯一维护处,本模块不另设例外。
|
||||
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
|
||||
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
|
||||
|
||||
## 职责与边界
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
## 数据库访问与中文注释约束
|
||||
|
||||
- 强制继承 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则;该节是分层、SQL 执行和方法/核心逻辑中文注释的唯一维护处,本模块不另设例外。
|
||||
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
|
||||
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
|
||||
|
||||
## 职责边界
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
## 数据库访问与中文注释约束
|
||||
|
||||
- 强制继承 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则;该节是分层、SQL 执行和方法/核心逻辑中文注释的唯一维护处,本模块不另设例外。
|
||||
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
|
||||
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
|
||||
|
||||
## 职责边界
|
||||
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
package com.baiyue.common.core.number;
|
||||
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import java.time.Clock;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.Locale;
|
||||
import java.util.function.ToLongFunction;
|
||||
|
||||
/** 统一业务单号格式;序列仍由各服务自己的 Repository/Mapper 分配。 */
|
||||
public final class DailyDocumentNumber {
|
||||
public static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
|
||||
|
||||
/** 工具类不允许实例化。 */
|
||||
private DailyDocumentNumber() {}
|
||||
|
||||
/** 先固定北京时间业务日,再使用前缀和日期分配跨客户、跨仓库日流水。 */
|
||||
public static String next(String prefix, Clock clock, ToLongFunction<String> sequences) {
|
||||
requirePrefix(prefix);
|
||||
LocalDate date = LocalDate.now(clock.withZone(BUSINESS_ZONE));
|
||||
String dateText = date.format(DateTimeFormatter.BASIC_ISO_DATE);
|
||||
long value = sequences.applyAsLong(prefix + ":" + dateText);
|
||||
return format(prefix, date, value);
|
||||
}
|
||||
|
||||
/** 严格四位流水,不允许溢出后产生五位号码或退回随机编号。 */
|
||||
public static String format(String prefix, LocalDate date, long value) {
|
||||
requirePrefix(prefix);
|
||||
if (value < 1 || value > 9999) {
|
||||
throw new BizException(ResultCode.CONFLICT, "当日" + prefix + "单据四位流水号已用尽或分配失败");
|
||||
}
|
||||
return prefix + date.format(DateTimeFormatter.BASIC_ISO_DATE)
|
||||
+ String.format(Locale.ROOT, "%04d", value);
|
||||
}
|
||||
|
||||
/** 业务类型只能使用两至四位大写字母。 */
|
||||
private static void requirePrefix(String prefix) {
|
||||
if (prefix == null || !prefix.matches("[A-Z]{2,4}")) {
|
||||
throw new IllegalArgumentException("业务单据前缀必须为2至4位大写字母");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
package com.baiyue.common.core.number;
|
||||
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.time.*;
|
||||
import java.util.HashMap;
|
||||
import java.util.Locale;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
class DailyDocumentNumberTest {
|
||||
/** 午夜前后取号使用北京时间日期,与主机和 Clock 的时区无关。 */
|
||||
@Test void rollsAtShanghaiMidnightAndKeepsPrefixesIndependent() {
|
||||
var values = new HashMap<String, Long>();
|
||||
java.util.function.ToLongFunction<String> allocate = key -> values.merge(key, 1L, Long::sum);
|
||||
var before = Clock.fixed(Instant.parse("2026-10-19T15:59:59Z"), ZoneOffset.UTC);
|
||||
var after = Clock.fixed(Instant.parse("2026-10-19T16:00:00Z"), ZoneId.of("America/Los_Angeles"));
|
||||
assertThat(DailyDocumentNumber.next("FL", before, allocate)).isEqualTo("FL202610190001");
|
||||
assertThat(DailyDocumentNumber.next("FL", after, allocate)).isEqualTo("FL202610200001");
|
||||
assertThat(DailyDocumentNumber.next("FL", after, allocate)).isEqualTo("FL202610200002");
|
||||
assertThat(DailyDocumentNumber.next("ZGRK", after, allocate)).isEqualTo("ZGRK202610200001");
|
||||
}
|
||||
|
||||
/** 字符集和流水上限为硬边界,未知输入不能生成近似号码。 */
|
||||
@Test void rejectsInvalidPrefixAndSequence() {
|
||||
for (String prefix : new String[]{"F", "ABCDE", "Fl", "F1", " FL", ""}) {
|
||||
assertThatThrownBy(() -> DailyDocumentNumber.format(prefix, LocalDate.of(2026, 10, 20), 1))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
for (long value : new long[]{-1, 0, 10000}) {
|
||||
assertThatThrownBy(() -> DailyDocumentNumber.format("FL", LocalDate.of(2026, 10, 20), value))
|
||||
.isInstanceOf(BizException.class);
|
||||
}
|
||||
assertThat(DailyDocumentNumber.format("FL", LocalDate.of(2026, 10, 20), 9999)).isEqualTo("FL202610209999");
|
||||
}
|
||||
|
||||
/** 全局 Locale 不得让数字变为本地化字符。 */
|
||||
@Test void alwaysUsesAsciiDigits() {
|
||||
Locale old = Locale.getDefault();
|
||||
try {
|
||||
Locale.setDefault(Locale.forLanguageTag("ar"));
|
||||
assertThat(DailyDocumentNumber.format("FL", LocalDate.of(2026, 10, 20), 1)).isEqualTo("FL202610200001");
|
||||
} finally { Locale.setDefault(old); }
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
## 数据库访问与中文注释约束
|
||||
|
||||
- 强制继承 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则;该节是分层、SQL 执行和方法/核心逻辑中文注释的唯一维护处,本模块不另设例外。
|
||||
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
|
||||
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
|
||||
|
||||
## 职责边界
|
||||
|
||||
|
||||
@@ -2,11 +2,15 @@ package com.baiyue.common.event;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
/** 跨服务业务事实使用的真实集装箱号,只规范化空白和大小写,不校验 ISO 格式。 */
|
||||
/** Canonical physical container number used by cross-region business facts. */
|
||||
public final class ContainerNumbers {
|
||||
private static final int[] LETTER_VALUES = {
|
||||
10, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 23, 24,
|
||||
25, 26, 27, 28, 29, 30, 31, 32, 34, 35, 36, 37, 38
|
||||
};
|
||||
|
||||
private ContainerNumbers() { }
|
||||
|
||||
/** 柜号必须非空;统一移除格式空白并转大写,保留业务录入值。 */
|
||||
public static String normalizeRequired(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new IllegalArgumentException("containerNo is required");
|
||||
@@ -15,13 +19,21 @@ public final class ContainerNumbers {
|
||||
value.codePoints().filter(codePoint -> !Character.isWhitespace(codePoint)
|
||||
&& !Character.isSpaceChar(codePoint)).forEach(compact::appendCodePoint);
|
||||
String normalized = compact.toString().toUpperCase(Locale.ROOT);
|
||||
if (normalized.isEmpty()) {
|
||||
throw new IllegalArgumentException("containerNo is required");
|
||||
if (!normalized.matches("[A-Z]{3}[UJZ][0-9]{7}")) {
|
||||
throw new IllegalArgumentException("containerNo must comply with ISO 6346");
|
||||
}
|
||||
int sum = 0;
|
||||
for (int index = 0; index < 10; index++) {
|
||||
char current = normalized.charAt(index);
|
||||
int number = Character.isDigit(current) ? current - '0' : LETTER_VALUES[current - 'A'];
|
||||
sum += number * (1 << index);
|
||||
}
|
||||
if (normalized.charAt(10) - '0' != sum % 11 % 10) {
|
||||
throw new IllegalArgumentException("containerNo must comply with ISO 6346 check digit");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/** 冻结事实必须已规范化,避免生产与消费端的快照或哈希发生漂移。 */
|
||||
public static String requireCanonical(String value) {
|
||||
String normalized = normalizeRequired(value);
|
||||
if (!normalized.equals(value)) {
|
||||
|
||||
+2
-11
@@ -1,13 +1,4 @@
|
||||
package com.baiyue.common.event;
|
||||
|
||||
/** 成功扣款凭据;资金业务身份独立于费用对应的实际服务订单。 */
|
||||
public record WalletSettlementReference(String commandEventId, String holdId, String ledgerId,
|
||||
@com.fasterxml.jackson.annotation.JsonInclude(com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL)
|
||||
String walletBusinessType,
|
||||
@com.fasterxml.jackson.annotation.JsonInclude(com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL)
|
||||
String walletBusinessNo) {
|
||||
/** 原有资金身份与服务订单一致的来源继续使用原三项凭据。 */
|
||||
public WalletSettlementReference(String commandEventId, String holdId, String ledgerId) {
|
||||
this(commandEventId, holdId, ledgerId, null, null);
|
||||
}
|
||||
}
|
||||
/** Finance-issued identity of a successful consumption, carried by its immutable fee source. */
|
||||
public record WalletSettlementReference(String commandEventId, String holdId, String ledgerId) { }
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
/**
|
||||
* 国内与海外业务单元共享的稳定事件契约。
|
||||
*
|
||||
* <p>海外费用的 walletSettlement 引用成功扣款的 commandEventId、holdId、ledgerId。
|
||||
* 独立资金流程另外携带 walletBusinessType、walletBusinessNo:人工费用为
|
||||
* OVERSEAS_MANUAL_FEE / 来源费用ID-冻结次数,仓租为 STORAGE_RENT / 出库预占ID。
|
||||
* 外层 businessType、businessNo 始终表示实际服务订单,不能替换为资金流程编号。
|
||||
* Finance 同时核对冻结、实扣流水、成功指令结果及客户金额,一个 hold 只能关联一个费用来源。
|
||||
* 资金身份与服务订单一致的既有引用省略这两个字段,保持既有快照哈希。</p>
|
||||
*/
|
||||
package com.baiyue.common.event;
|
||||
|
||||
+6
-27
@@ -1,41 +1,20 @@
|
||||
package com.baiyue.common.event;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.NullAndEmptySource;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class ContainerNumbersTest {
|
||||
/** 规范化仍保持确定性,事件只能接受已冻结的规范值。 */
|
||||
@Test
|
||||
void normalizesWhitespaceAndCaseButCanonicalFactsRequireExactValue() {
|
||||
void normalizesWhitespaceAndCaseButCanonicalFactsRequireExactIso6346Value() {
|
||||
assertThat(ContainerNumbers.normalizeRequired(" zcsu 7881970 ")).isEqualTo("ZCSU7881970");
|
||||
assertThat(ContainerNumbers.requireCanonical("ZCSU7881970")).isEqualTo("ZCSU7881970");
|
||||
assertThatThrownBy(() -> ContainerNumbers.requireCanonical("zcsu7881970"))
|
||||
.hasMessageContaining("normalized");
|
||||
assertThatThrownBy(() -> ContainerNumbers.requireCanonical(" JZXH10231043 "))
|
||||
.hasMessageContaining("normalized");
|
||||
}
|
||||
|
||||
/** 非 ISO 柜号可以作为事件事实,不在下游重新阻断。 */
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"JZXH10231043", "ZCSU7881971", "MSCA6639870", "ABC", "ABCU123456789012", "LOCAL-2026-001"})
|
||||
void acceptsNonIsoNumber(String containerNo) {
|
||||
assertThat(ContainerNumbers.normalizeRequired(" " + containerNo.toLowerCase(java.util.Locale.ROOT) + " "))
|
||||
.isEqualTo(containerNo);
|
||||
assertThat(ContainerNumbers.requireCanonical(containerNo)).isEqualTo(containerNo);
|
||||
}
|
||||
|
||||
/** 规范化前后均不得将空白柜号作为业务事实。 */
|
||||
@ParameterizedTest
|
||||
@NullAndEmptySource
|
||||
@ValueSource(strings = {" ", "\u00a0\u3000"})
|
||||
void rejectsMissingNumber(String containerNo) {
|
||||
assertThatThrownBy(() -> ContainerNumbers.normalizeRequired(containerNo))
|
||||
.isInstanceOf(IllegalArgumentException.class).hasMessageContaining("required");
|
||||
assertThatThrownBy(() -> ContainerNumbers.requireCanonical(containerNo))
|
||||
.isInstanceOf(IllegalArgumentException.class).hasMessageContaining("required");
|
||||
assertThatThrownBy(() -> ContainerNumbers.normalizeRequired("ZCSU7881971"))
|
||||
.hasMessageContaining("check digit");
|
||||
assertThatThrownBy(() -> ContainerNumbers.normalizeRequired("CAB-260831-0001"))
|
||||
.hasMessageContaining("ISO 6346");
|
||||
}
|
||||
}
|
||||
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
package com.baiyue.common.event;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class WalletSettlementReferenceTest {
|
||||
private final ObjectMapper json = new ObjectMapper();
|
||||
|
||||
@Test
|
||||
void oldReferencesRetainExactHashShape() throws Exception {
|
||||
String original = "{\"commandEventId\":\"c1\",\"holdId\":\"9\",\"ledgerId\":\"72\"}";
|
||||
var reference = json.readValue(original, WalletSettlementReference.class);
|
||||
assertThat(json.readTree(json.writeValueAsString(reference))).isEqualTo(json.readTree(original));
|
||||
}
|
||||
|
||||
@Test
|
||||
void dedicatedFundingIdentitySurvivesRoundTrip() throws Exception {
|
||||
var reference = new WalletSettlementReference("c1", "9", "72", "STORAGE_RENT", "445");
|
||||
assertThat(json.readValue(json.writeValueAsString(reference), WalletSettlementReference.class)).isEqualTo(reference);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
## 数据库访问与中文注释约束
|
||||
|
||||
- 强制继承 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则;该节是分层、SQL 执行和方法/核心逻辑中文注释的唯一维护处,本模块不另设例外。
|
||||
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
|
||||
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
|
||||
|
||||
## 职责边界
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
## 数据库访问与中文注释约束
|
||||
|
||||
- 强制继承 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则;该节是分层、SQL 执行和方法/核心逻辑中文注释的唯一维护处,本模块不另设例外。
|
||||
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
|
||||
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
|
||||
|
||||
## 职责边界
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
## 数据库访问与中文注释约束
|
||||
|
||||
- 强制继承 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则;该节是分层、SQL 执行和方法/核心逻辑中文注释的唯一维护处,本模块不另设例外。
|
||||
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
|
||||
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
|
||||
|
||||
## 职责边界
|
||||
|
||||
|
||||
@@ -40,14 +40,8 @@ public class GlobalExceptionHandler {
|
||||
@Autowired(required = false)
|
||||
private MeterRegistry meterRegistry;
|
||||
|
||||
/** 系统类业务失败必须记录链路和异常,普通业务校验仍只返回统一响应。 */
|
||||
@ExceptionHandler(BizException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleBusiness(BizException exception, HttpServletRequest request) {
|
||||
if (exception.getResultCode().httpStatus().is5xxServerError()) {
|
||||
log.error("系统业务请求失败 method={} path={} code={} requestId={} traceId={}",
|
||||
request.getMethod(), request.getRequestURI(), exception.getResultCode().code(),
|
||||
CorrelationContext.requestId(request), CorrelationContext.traceId(request), exception);
|
||||
}
|
||||
return failure(exception.getResultCode(), exception.getMessage(), request);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,9 +5,6 @@ import com.baiyue.common.core.exception.BizException;
|
||||
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.springframework.boot.test.system.CapturedOutput;
|
||||
import org.springframework.boot.test.system.OutputCaptureExtension;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.core.MethodParameter;
|
||||
import org.springframework.validation.BeanPropertyBindingResult;
|
||||
@@ -42,37 +39,6 @@ class GlobalExceptionHandlerTest {
|
||||
assertThat(response.getBody().requestId()).isEqualTo("r1");
|
||||
}
|
||||
|
||||
/** 系统类业务失败留下路径、关联标识和根因,但不打印请求参数或令牌。 */
|
||||
@Test
|
||||
@ExtendWith(OutputCaptureExtension.class)
|
||||
void systemBusinessFailureLogsCorrelationAndCause(CapturedOutput output) {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/platform/v1/orders/status-counts");
|
||||
request.setAttribute(CorrelationContext.REQUEST_ID, "status-count-request");
|
||||
request.setAttribute(CorrelationContext.TRACE_ID, "status-count-trace");
|
||||
request.setQueryString("keyword=private-query");
|
||||
request.addHeader("X-Internal-Service-Token", "private-service-token");
|
||||
BizException exception = new BizException(ResultCode.SERVICE_UNAVAILABLE, "统计暂不可用");
|
||||
exception.initCause(new IllegalStateException("缺少尾程来源"));
|
||||
|
||||
var response = handler.handleBusiness(exception, request);
|
||||
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||
assertThat(response.getBody().code()).isEqualTo("S0503");
|
||||
assertThat(response.getBody().message()).isEqualTo("统计暂不可用");
|
||||
assertThat(output.getOut()).contains("ERROR", "GET", "/api/platform/v1/orders/status-counts",
|
||||
"S0503", "status-count-request", "status-count-trace", "缺少尾程来源")
|
||||
.doesNotContain("private-query", "private-service-token");
|
||||
}
|
||||
|
||||
/** 预期的业务冲突不应升级为系统错误日志。 */
|
||||
@Test
|
||||
@ExtendWith(OutputCaptureExtension.class)
|
||||
void normalBusinessFailureDoesNotLogSystemError(CapturedOutput output) {
|
||||
handler.handleBusiness(new BizException(ResultCode.CONFLICT, "normal-business-conflict"),
|
||||
new MockHttpServletRequest());
|
||||
assertThat(output.getAll()).doesNotContain("系统业务请求失败", "normal-business-conflict");
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidQueryParameterTypeMapsToBadRequest() throws Exception {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
|
||||
@@ -5,8 +5,7 @@ spring:
|
||||
password: ${FINANCE_DB_PASSWORD}
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
baiyue.clients.warehouse.url: ${WAREHOUSE_SERVICE_URL:}
|
||||
# TEST 流程验收复用 Client 的示例图片,图片标注不可付款且不含真实收款信息。
|
||||
baiyue.finance.recharge.qr-code-url: ${FINANCE_RECHARGE_QR_CODE_URL:/customer-recharge-qr.svg}
|
||||
baiyue.finance.recharge.qr-code-url: ${FINANCE_RECHARGE_QR_CODE_URL:}
|
||||
baiyue.finance.wallet.default-credit-limit: ${FINANCE_WALLET_DEFAULT_CREDIT_LIMIT:0.00}
|
||||
baiyue.event-delivery.operations-token: ${EVENT_OPERATIONS_TOKEN:}
|
||||
baiyue.event-delivery.connect-timeout-ms: ${EVENT_CONNECT_TIMEOUT_MS:3000}
|
||||
|
||||
@@ -1,226 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
import { readFileSync, readdirSync, writeFileSync, statSync } from 'node:fs'
|
||||
import { resolve, relative, basename } from 'node:path'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const moduleName = 'lastleg-service'
|
||||
const testClass = /(?:^Test.+|.+(?:Test|Tests|TestCase))\.java$/
|
||||
const words = source => new Set(source.match(/[A-Za-z_$][A-Za-z0-9_$]*/g) || [])
|
||||
const tables = source => new Set(source.match(/\bll_[a-z0-9_]+\b/g) || [])
|
||||
const writtenTables = source => new Set([...source.matchAll(/\b(?:UPDATE|INSERT\s+(?:IGNORE\s+)?INTO|DELETE\s+FROM)\s+(ll_[a-z0-9_]+)/gi)].map(match => match[1]))
|
||||
|
||||
// 按完整注解比较 SQL,避免只改 SELECT 时把同一 Mapper 内未改的全部写语句扩大为全域回归。
|
||||
function writeStatements(source) {
|
||||
const result = []
|
||||
for (const match of source.matchAll(/@(?:Insert|Update|Delete)\s*\(/g)) {
|
||||
let depth = 1, quote = '', index = match.index + match[0].length
|
||||
for (; index < source.length && depth; index++) {
|
||||
if (quote) {
|
||||
if (source.startsWith(quote, index)) { index += quote.length - 1; quote = '' }
|
||||
else if (source[index] === '\\' && quote.length === 1) index++
|
||||
} else if (source.startsWith('"""', index)) { quote = '"""'; index += 2 }
|
||||
else if (source[index] === '"' || source[index] === "'") quote = source[index]
|
||||
else if (source[index] === '(') depth++
|
||||
else if (source[index] === ')') depth--
|
||||
}
|
||||
if (depth) throw new Error('Cannot parse SQL annotation; use MODULE test scope')
|
||||
result.push(source.slice(match.index, index))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function changedWrites(node) {
|
||||
if (node.previousSource === undefined) return writtenTables(node.source)
|
||||
const before = writeStatements(node.previousSource), after = writeStatements(node.source)
|
||||
const changed = [...before.filter(sql => !after.includes(sql)), ...after.filter(sql => !before.includes(sql))]
|
||||
const oldRest = before.reduce((text, sql) => text.replace(sql, ''), node.previousSource)
|
||||
const newRest = after.reduce((text, sql) => text.replace(sql, ''), node.source)
|
||||
if (oldRest !== newRest) changed.push(oldRest, newRest)
|
||||
return writtenTables(changed.join('\n'))
|
||||
}
|
||||
|
||||
/** 宁可扩大范围,不用文件名猜测唯一测试;未识别的输入保留完整模块门禁。 */
|
||||
export function planTests({ modules, mode = 'AUTO', changes = [], sources = [], baseline = '' }) {
|
||||
const architectureScript = `${moduleName}/scripts/analyze-lastleg-dependencies`
|
||||
const staticChecks = changes.some(path => path.startsWith(architectureScript))
|
||||
? [`${architectureScript}.test.mjs`] : []
|
||||
const full = reason => ({ scope: 'MODULE', reason, modules, baseline, tests: [], staticChecks })
|
||||
if (mode !== 'AUTO' && mode !== 'MODULE') throw new Error('TEST_SCOPE must be AUTO or MODULE')
|
||||
if (mode === 'MODULE') return full('显式完整模块回归')
|
||||
if (!baseline) return full('没有成功测试基线')
|
||||
if (modules.length !== 1 || modules[0] !== moduleName) return full('多服务或尚未核对的服务采用完整模块回归')
|
||||
const prefix = `${moduleName}/`
|
||||
const relevant = changes.filter(path => path.startsWith(prefix))
|
||||
const shared = changes.some(path => path !== 'scripts/agents-index-contract.ps1' && !path.startsWith(prefix)
|
||||
&& /^(?:common-[^/]+\/|platform-bom\/|pom\.xml$|Jenkinsfile|deploy\/|config\/|scripts\/.*\.(?:mjs|sh|ps1)$)/.test(path))
|
||||
if (shared) return full('公共能力、发布或测试基础设施变更')
|
||||
if (!relevant.length) return full('补跑服务没有可验证的业务差异')
|
||||
const java = `${prefix}src/main/java/com/baiyue/lastleg/`
|
||||
const supported = path => path.startsWith(`${prefix}src/test/java/`)
|
||||
|| path.startsWith(`${prefix}src/main/resources/openapi/`)
|
||||
|| path.startsWith(architectureScript)
|
||||
|| (path.startsWith(java) && path.slice(java.length).includes('/')
|
||||
&& !/^(?:config|security|entity|dto|model|mapper|repository|service|client)\//.test(path.slice(java.length)))
|
||||
|| /\.(?:md)$/.test(path)
|
||||
if (relevant.some(path => !supported(path))) return full('共享模型、配置、SQL、资源或未识别路径变更')
|
||||
|
||||
const nodes = sources.map(({ path, source, previousSource }) => ({ path, source, previousSource,
|
||||
symbol: basename(path, '.java'), tokens: words(source), tables: tables(source),
|
||||
test: path.startsWith(`${prefix}src/test/java/`),
|
||||
name: `${source.match(/\bpackage\s+([\w.]+)\s*;/)?.[1] || ''}.${basename(path, '.java')}` }))
|
||||
const byPath = new Map(nodes.map(node => [node.path, node]))
|
||||
const bySymbol = new Map()
|
||||
for (const node of nodes) {
|
||||
if (!bySymbol.has(node.symbol)) bySymbol.set(node.symbol, [])
|
||||
bySymbol.get(node.symbol).push(node)
|
||||
}
|
||||
const reverse = new Map(nodes.map(node => [node.path, new Set()]))
|
||||
for (const node of nodes) {
|
||||
// 包含反射字符串、静态 import、同包引用及测试夹具;重名类全部纳入。
|
||||
for (const token of node.tokens) for (const dependency of bySymbol.get(token) || []) {
|
||||
if (dependency !== node) reverse.get(dependency.path).add(node.path)
|
||||
}
|
||||
// 实现变化也要覆盖只依赖接口/基类的调用者,不能仅沿显式 import 反查。
|
||||
for (const match of node.source.matchAll(/\b(?:implements|extends)\s+([^{};]+)/g)) {
|
||||
for (const token of words(match[1])) for (const contract of bySymbol.get(token) || []) {
|
||||
if (contract !== node) reverse.get(node.path).add(contract.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
const seeds = new Set()
|
||||
const changedTables = new Set()
|
||||
for (const path of relevant.filter(path => path.endsWith('.java'))) {
|
||||
const node = byPath.get(path)
|
||||
if (node) {
|
||||
seeds.add(path)
|
||||
// 读查询变化不改变其他查询的数据;潜在写入变化则纳入同表读写消费者。
|
||||
if (!node.test) for (const table of changedWrites(node)) changedTables.add(table)
|
||||
} else {
|
||||
// 删除/移动旧类型时,仍按旧类名查现存消费者。
|
||||
const symbol = basename(path, '.java')
|
||||
const consumers = nodes.filter(consumer => consumer.tokens.has(symbol))
|
||||
if (!consumers.length) return full('删除类型缺少可追踪的现存消费者')
|
||||
for (const consumer of consumers) seeds.add(consumer.path)
|
||||
}
|
||||
}
|
||||
if (!seeds.size) return full('没有可追踪的 Java 行为差异')
|
||||
for (const node of nodes) if ([...node.tables].some(table => changedTables.has(table))) seeds.add(node.path)
|
||||
const affected = new Set(seeds)
|
||||
const queue = [...seeds]
|
||||
for (let index = 0; index < queue.length; index++) {
|
||||
for (const consumer of reverse.get(queue[index]) || []) if (!affected.has(consumer)) {
|
||||
affected.add(consumer); queue.push(consumer)
|
||||
}
|
||||
}
|
||||
const allTests = nodes.filter(node => node.test && testClass.test(basename(node.path))
|
||||
&& /@(?:Test|ParameterizedTest|RepeatedTest|TestFactory|TestTemplate)\b/.test(node.source))
|
||||
const contractChange = relevant.some(path => path.startsWith(`${prefix}src/main/resources/openapi/`))
|
||||
const candidates = allTests.filter(node => affected.has(node.path)
|
||||
|| /(?:Security|Isolation|MapperRegistrationArchitecture|QueryBinding)/.test(node.symbol)
|
||||
|| (contractChange && /(?:Contract|OpenApi|Schema|Architecture|Permission)/.test(node.symbol)))
|
||||
// 显式依赖真实环境的类仍由 MODULE/专项授权入口负责,不能把它们默认跳过算作执行成功。
|
||||
const optIn = node => /@(?:EnabledIf(?:SystemProperty|EnvironmentVariable)|Disabled(?:IfSystemProperty|IfEnvironmentVariable)?\b)/
|
||||
.test(node.source.slice(0, node.source.indexOf(`class ${node.symbol}`)))
|
||||
const tests = candidates.filter(node => !optIn(node))
|
||||
if (!tests.some(node => affected.has(node.path))) return full('没有匹配到行为测试,拒绝零测试通过')
|
||||
if (tests.some(node => !/^com\.[\w.$]+$/.test(node.name))) return full('测试类名无法安全传入 Maven')
|
||||
return { scope: 'AFFECTED', reason: 'Java 反向引用、接口实现、测试夹具、共享 SQL 表和相关静态合同',
|
||||
modules, baseline, tests: tests.map(node => node.name).sort(), totalTests: allTests.length, staticChecks,
|
||||
optInTests: candidates.filter(optIn).map(node => node.name).sort() }
|
||||
}
|
||||
|
||||
function walk(root) {
|
||||
const files = []
|
||||
for (const item of readdirSync(root, { withFileTypes: true })) {
|
||||
const path = resolve(root, item.name)
|
||||
if (item.isDirectory()) files.push(...walk(path))
|
||||
else if (item.isFile() && item.name.endsWith('.java')) files.push(path)
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
/** 每个所选类必须有本轮实际执行的报告;允许上游零匹配不能变成整个门禁零测试。 */
|
||||
export function verifyReports(plan, reports) {
|
||||
if (plan.scope !== 'AFFECTED') return
|
||||
for (const name of plan.tests) {
|
||||
const matches = reports.filter(report => report.name === name || report.name.startsWith(`${name}$`))
|
||||
if (!matches.length || matches.some(report => report.failures || report.errors
|
||||
|| ['tests', 'skipped', 'failures', 'errors'].some(key => !Number.isInteger(report[key]) || report[key] < 0)
|
||||
|| report.skipped > report.tests)
|
||||
|| matches.reduce((sum, report) => sum + report.tests - report.skipped, 0) <= 0) {
|
||||
throw new Error(`Missing, skipped or failed fresh test report: ${name}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function main(args) {
|
||||
const [action, ...options] = args
|
||||
const values = {}
|
||||
for (let i = 0; i < options.length; i += 2) {
|
||||
if (!options[i].startsWith('--') || options[i + 1] === undefined) throw new Error('Expected --name value')
|
||||
values[options[i].slice(2)] = options[i + 1]
|
||||
}
|
||||
const root = process.cwd()
|
||||
const planFile = values.plan
|
||||
if (!planFile) throw new Error('--plan is required')
|
||||
if (action === 'plan') {
|
||||
const modules = (values.modules || '').split(',')
|
||||
if (modules.some(module => !/^[a-z][a-z0-9-]+$/.test(module))) throw new Error('Invalid modules')
|
||||
let baseline = values.base || ''
|
||||
let changes = []
|
||||
if (baseline) {
|
||||
if (!/^[0-9a-f]{7,40}$/i.test(baseline)) throw new Error('Invalid baseline commit')
|
||||
try {
|
||||
execFileSync('git', ['merge-base', '--is-ancestor', baseline, 'HEAD'], { stdio: 'pipe' })
|
||||
changes = execFileSync('git', ['diff', '--name-only', '-z', baseline, 'HEAD'], { encoding: 'utf8' }).split('\0').filter(Boolean)
|
||||
} catch { baseline = '' }
|
||||
}
|
||||
const sources = modules.length === 1 && modules[0] === moduleName
|
||||
? ['main', 'test'].flatMap(set => walk(resolve(root, moduleName, 'src', set, 'java')))
|
||||
.map(path => {
|
||||
const key = relative(root, path).replaceAll('\\', '/')
|
||||
const entry = { path: key, source: readFileSync(path, 'utf8') }
|
||||
if (baseline && changes.includes(key)) {
|
||||
try { entry.previousSource = execFileSync('git', ['show', `${baseline}:${key}`], { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }) }
|
||||
catch { entry.previousSource = '' }
|
||||
}
|
||||
return entry
|
||||
}) : []
|
||||
const plan = { ...planTests({ modules, mode: values.mode || 'AUTO', baseline, changes, sources }), startedAt: Date.now() }
|
||||
writeFileSync(planFile, JSON.stringify(plan))
|
||||
console.log(`CI test scope: ${plan.scope}; ${plan.reason}`)
|
||||
if (plan.scope === 'AFFECTED') console.log(`Selected ${plan.tests.length}/${plan.totalTests} test classes:\n${plan.tests.join('\n')}`)
|
||||
if (plan.optInTests?.length) console.log(`Explicit environment tests remain opt-in (not counted as passed): ${plan.optInTests.join(',')}`)
|
||||
return
|
||||
}
|
||||
const plan = JSON.parse(readFileSync(planFile, 'utf8'))
|
||||
if (action === 'args') {
|
||||
if (plan.scope === 'AFFECTED') process.stdout.write(`-Dtest=${plan.tests.join(',')} -Dsurefire.failIfNoSpecifiedTests=false -Dci.sql-semantic.skip=true`)
|
||||
} else if (action === 'checks') {
|
||||
for (const check of plan.staticChecks || []) {
|
||||
if (check !== `${moduleName}/scripts/analyze-lastleg-dependencies.test.mjs`) throw new Error('Unsupported static check')
|
||||
execFileSync(process.execPath, ['--test', '--test-concurrency=2', check], { stdio: 'inherit' })
|
||||
}
|
||||
} else if (action === 'verify') {
|
||||
const reports = []
|
||||
if (plan.scope === 'AFFECTED') for (const module of plan.modules) {
|
||||
const directory = resolve(root, module, 'target/surefire-reports')
|
||||
for (const filename of readdirSync(directory)) {
|
||||
if (!/^TEST-.*\.xml$/.test(filename)) continue
|
||||
const path = resolve(directory, filename)
|
||||
if (statSync(path).mtimeMs < plan.startedAt - 2000) continue
|
||||
const xml = readFileSync(path, 'utf8').match(/<testsuite\b([^>]+)>/)?.[1]
|
||||
if (!xml) continue
|
||||
const attrs = Object.fromEntries([...xml.matchAll(/([\w-]+)="([^"]*)"/g)].map(match => [match[1], match[2]]))
|
||||
reports.push({ name: attrs.name || '', ...Object.fromEntries(['tests', 'failures', 'errors', 'skipped'].map(key => [key, Number(attrs[key] || 0)])) })
|
||||
}
|
||||
}
|
||||
verifyReports(plan, reports)
|
||||
console.log(`CI test reports verified: ${plan.scope}`)
|
||||
} else throw new Error(`Unknown action: ${action}`)
|
||||
}
|
||||
|
||||
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
try { main(process.argv.slice(2)) } catch (error) { console.error(error.message); process.exitCode = 1 }
|
||||
}
|
||||
@@ -10,7 +10,7 @@
|
||||
| warehouse-overseas-service | `baiyue_lastlag_warehouse` | 仅业务代码变化,无新增表或字段,无需 SQL;数据库名称保留正式的 lastlag 拼写 |
|
||||
| 其他服务 | 各服务原数据库 | 本次比较范围内无 SQL 结构新增,无需 SQL |
|
||||
|
||||
已有尾程库使用:[V28 可重复执行 SQL](migrations/overseas/baiyue_lastleg/V28__merge_transfer_measurement_and_compact_numbers.sql)。由单 schema 执行器选定 `baiyue_lastleg`,文件内不切库,不访问其他服务数据库。2026-10-20 按尚未执行候选的边界移除内部切库语句,其余结构内容不变;本次未执行此候选。
|
||||
已有尾程库使用:[V28 可重复执行 SQL](migrations/overseas/baiyue_lastleg/V28__merge_transfer_measurement_and_compact_numbers.sql)。文件显式指定 `USE baiyue_lastleg`,不访问其他服务数据库。
|
||||
|
||||
## 新表与字段
|
||||
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
# 海外费用与仓租数据库定义汇总
|
||||
|
||||
本次功能的数据库定义已统一到下面两份 SQL,五份重复版本脚本和两份专项汇总 SQL 已移除。仅整理文件,未连接数据库、执行 SQL 或部署。
|
||||
|
||||
## 两个统一入口
|
||||
|
||||
| 文件 | 相关数据库 | 本次内容 |
|
||||
|---|---|---|
|
||||
| [头程数据库统一增量-MySQL8.sql](头程数据库统一增量-MySQL8.sql) | `baiyue_iam` | 海外费用管理菜单及默认平台管理员授权;保留既有财务编辑、审核按钮和授权 |
|
||||
| [海外区域统一增量-MySQL8.sql](海外区域统一增量-MySQL8.sql) | `baiyue_lastleg` | 资金与仓租四表、费用关联及换标快照、审核人名称、采购退款请求幂等字段与唯一索引 |
|
||||
|
||||
两份文件虽然名称包含“增量”,仍只用于对应区域的空库初始化。新增列、索引和约束直接维护在最终 `CREATE TABLE` 中,菜单及授权使用初始化种子;不追加存量改造、权限版本回填或动态迁移段。目标 schema 必须为空,按文件原有初始化流程执行;不能向已有业务库重放。
|
||||
|
||||
本次不再提供这项功能的独立升级脚本。已有库升级必须按实际结构和明确授权另行准备、审阅受控增量,不能把删除重复文件理解为已有库已完成升级。
|
||||
|
||||
## 海外表和字段
|
||||
|
||||
| 类型 | 表 / 字段 | 用途 |
|
||||
|---|---|---|
|
||||
| 新表 | `ll_finance_fee_funding` | 人工费用冻结、审核扣款、释放及财务流水引用 |
|
||||
| 新表 | `ll_storage_rent_lot` | 实际入库计租原批次、数量及入库时间 |
|
||||
| 新表 | `ll_storage_rent_inbound` | 头程每次实收凭证去重,关联同单同仓计租批次 |
|
||||
| 新表 | `ll_storage_rent_share` | 库存与原计租批次份额关联,保留原库龄 |
|
||||
| 新表 | `ll_storage_rent_preparation` | 出库计租快照、汇率、资金预占及实际出库记录 |
|
||||
| 可空字段 | `ll_destuff_pallet_cost_allocation.fee_source_id` | `BIGINT NULL`,允许人工审核后关联海外费用来源 |
|
||||
| 字段 | `ll_return_sku_conversion.storage_rent_shares_json` | `LONGTEXT NULL`,退货 SKU 换标的原仓租份额快照 |
|
||||
| 字段 | `ll_finance_fee_source.reviewer_name` | `VARCHAR(255) NULL`,审核人名称快照 |
|
||||
| 字段 | `ll_overseas_procurement_fee.idempotency_key` | `VARCHAR(128) NULL`,采购退款请求幂等键 |
|
||||
| 唯一索引 | `uk_ll_procurement_refund_request(order_id,purpose,idempotency_key)` | 同一采购订单、用途和退款请求去重 |
|
||||
|
||||
## 国内菜单和权限
|
||||
|
||||
海外业务 → 海外费用管理,菜单 ID `5117200`、权限 `platform:overseas-fee:list`、路由 `/overseas-business/fees`;默认仅授予 `PLATFORM_ADMIN`。编辑和审核分别复用 `platform:finance:fee-source:edit` 与 `platform:finance:fee-source:review`,列表权限不包含写操作。国内基线已包含两项既有按钮及管理员授权。
|
||||
|
||||
已有库的 [费用写权限修复](overseas-fee-canonical-write-permissions-repair.sql) 是单独的定向补丁,继续保留原适用条件和授权边界,不是结构升级或菜单初始化入口。其他独立修复脚本与历史迁移不在本次清理范围。
|
||||
|
||||
费用项目继续使用 Firstleg 现有目录及 `FEE_TYPE_077 / 仓储费`,不重复插入项目、不批量覆盖客户专价、不回填历史仓租或费用、不修改钱包余额。
|
||||
|
||||
配套服务、页面、配置及验收边界见 [首版发布验收](../../lastleg-service/docs/overseas-fees-storage-rent-release.md)。
|
||||
+3
-41
@@ -1,29 +1,5 @@
|
||||
# 单测试环境数据库空库初始化
|
||||
|
||||
车辆与司机档案照片复用:先执行 `lastleg-fleet-resource-photos.sql` 新增照片版本表,再配套发布 File、Lastleg 和 Admin。车辆详情/司机详情维护照片,排车保存时复制缺失照片为当趟证据;已有照片不被后续档案替换覆盖。驾驶证统一接受 PDF/JPG/PNG。源基线与海外空库基线已同步;本次未执行环境 SQL 或部署。
|
||||
|
||||
放舱后预计到港窄维护需要配套更新 `trg_ocean_booking_before_update`:仅从冻结比较中移除 `estimated_arrival_at`,其余运输、预报、关系方及柜型冻结不变。定义已同步 `firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql` 与国内统一基线;已有 TEST 需另行审阅并授权替换该触发器,不重放空库文件,不回填数据。本轮只准备定义,未执行 SQL 或部署。
|
||||
|
||||
货柜关联已放舱订舱时不再限制 ISO 柜号格式,Firstleg 模块基线与国内统一空库基线已移除 `chk_container_booking_iso6346`,保留同订舱柜号唯一键。既有 TEST 发布前需核对并单独授权移除该约束:`ALTER TABLE baiyue_firstleg.fl_container_booking DROP CHECK chk_container_booking_iso6346;`。配套发布 Firstleg 与 Admin;不重放空库初始化、不修改业务数据。本次仅准备源码与 SQL 定义,未执行环境 SQL 或部署。
|
||||
|
||||
`overseas-storage-rent-zero-fee-repair.sql` 为免费仓租的定向结构修复,固定 `baiyue_lastleg.ll_finance_fee_source`:先检查列类型、既有金额约束及存量行,再以单次原子 ALTER 仅放行自动应收仓租的原币及结算双零金额和空汇率,其余费用仍须正额及有效汇率。不更新费用、余额或库存;禁止重放空库初始化。源码基线与海外统一空库基线同步。脚本待审阅执行,文件存在不代表已执行。
|
||||
|
||||
`overseas-fee-remark-add.sql` 为海外人工费用说明的独立结构增量,固定目标 `baiyue_lastleg.ll_finance_fee_source`,仅在缺失时增加允许为空的 `fee_remark VARCHAR(500)`,保留旧费用与审核记录,不回填、不扣款、不改菜单。Lastleg 源基线与海外统一空库基线已同步;已有开发库须逐项授权执行本脚本后才能加载配套 Lastleg 版本。文件准备不表示已执行。
|
||||
|
||||
`finance-overseas-fee-hash-width-repair.sql` 用于明确授权的本机开发环境海外费用推送修复,固定目标 `baiyue_finance`。先核对 `fin_inbox_event.payload_hash`、`fin_fee_source.source_hash` 两列;仅将不足71位的长度扩至71位,兼容 `sha256:` 前缀,保留空值约束和字符集排序规则,不更新业务数据。缺列、异常类型或小于64位时停止;已有足够长度则跳过。使用独立连接、遇错停止,执行后回查结构,再通过原费用事件重试验证,禁止重新生成费用或扣款。脚本包含执行后移除的专用校验过程;创建或执行失败须停止,不扩大修复范围。文件准备不表示已执行。
|
||||
|
||||
`warehouse-supplier-query-structure-repair.sql` 用于明确授权的本机开发环境供应商查询结构修复,固定目标 `baiyue_warehouse`:仅在缺失时新增 `wh_supplier.trucking_scope`,并按当前模块基线创建缺失的 `wh_supplier_carrier` 空表。结构存在则跳过,不更新供应商类别、费用或资金,不推断历史拖车的国内/海外归属。使用独立连接、遇错停止,执行后回查结构并通过正式供应商列表 API 验证;不需要重启服务。文件准备不表示已获授权或已执行。
|
||||
|
||||
仓储转运到仓取货、本土卡车接入现有排车的**可重复执行汇总SQL**为 `lastleg-dispatch-transport-tasks.sql`,文件内固定 `USE baiyue_lastleg`,对已存在字段、表安全跳过。新增 `ll_dispatch_order.task_type` 字段、`ll_dispatch_truck_task`、`ll_dispatch_transport_create`、`ll_dispatch_transport_command` 三张表,并确保既有正式排车单号日流水表存在;不回填或删除业务数据。前置是目标库已具备现有整车排车主表、FleetTrip、取货任务及本土卡车原单结构。按序先执行此 SQL,再配套发布 Lastleg 与 Admin;取消释放有效原单关联、完成保留关联。`migrations/overseas/baiyue_lastleg/V32__dispatch_transport_tasks.sql` 是迁移系统中的新增结构记录。本次仅在已授权本机执行并验收,不代表远端已部署。
|
||||
|
||||
排车单号使用`PCyyyyMMdd0001`,日期取`Asia/Shanghai`,全平台跨客户/仓库共用独立日流水。`ll_dispatch_daily_sequence`同步Lastleg源基线与海外空库基线;已有结构的定向新增入口为`lastleg-dispatch-daily-sequence.sql`,只建序列表,不改已有业务号、不回填历史、不重放空库初始化。源码和DDL须配套发布;文件存在不表示已执行或部署。流水在独立短事务分配,回滚或取消不复用,四位范围每日0001至9999,准确单量以主单统计为准。
|
||||
|
||||
自有业务运营视图的结构在 Lastleg 源基线及海外统一空库基线新增四张附加表:`ll_fleet_owned_business_ownership/record/command/history`,分别保存原排车装车完成时的车辆归属证据、运营信息、命令与不可变历史;不新增排车主单或第二套运输状态。权限为 IAM `lastleg-permissions.sql` 中的自有业务 list/edit/upload,并同步国内统一空库基线。业务唯一规则见工作区 `docs/海外仓业务/自有业务首版开发契约.md`。本次仅准备源码基线,无 SQL 执行、存量回填或部署;旧已装车 TEST 数据不倒推历史归属,匹配版本按新原生样本验收。
|
||||
|
||||
客户“确认计费重”统一使用 `fl_inbound_fee` 最新已推送费用版本及其客户确认记录,不再维护独立重量确认表或接口。源建表定义、旧创建脚本与空库基线已移除;已有 TEST 先发布移除旧引用的 Firstleg 版本,再执行 `migrations/cn/baiyue_firstleg/V9__drop_chargeable_weight_confirmation.sql` 删除 `fl_chargeable_weight_confirmation`,不修改费用、订单或测量数据。脚本存在不表示已执行或部署。
|
||||
|
||||
费用项目默认价格结构: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`保存真实作业轮次,不能把扫码引起的库存和申请状态变化误当作换标轮次变化。排车、草稿和仓内调用必须使用匹配的公共锁实现;本条只说明空库基线结构,不表示已执行或已部署。
|
||||
@@ -66,10 +42,6 @@
|
||||
|
||||
换标直接出库的实物体积快照在 `ll_outbound_order.volume_cbm`、`ll_outbound_pallet_item.volume_cbm`、`ll_delivery_order.total_volume_cbm` 三列均允许 NULL,表示来源尚未确认;已有实盘尺寸按箱计算,不能用默认 0 代替未知,也不按 SKU 件数重复累计。两份初始化基线同步此定义,已有 TEST 只能在核对结构和授权后精确调整这三列,不重放空库文件或回填历史行。
|
||||
|
||||
排车内部散板需求发布选项由 `lastleg-service/src/main/resources/db/dispatch-capacity-demand.sql` 提供精确结构变更:仅向 `ll_dispatch_order` 新增默认勾选的 `publish_looseboard_demand` 列,Lastleg与海外空库基线同步。该文件不是自动启动脚本;执行须单独授权,不能重放完整空库文件,不新增业务数据回填或清理。实际执行与部署状态仍归测试环境主契约。
|
||||
|
||||
订舱委/实资料在 `firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql` 与国内统一空库基线同步维护:订舱主表增加条款、包装、品名、唛头和委/实三项货量,关系方增加邮箱、税号;已放舱主表触发器仅允许实际货量及更新审计元数据变化,运输、预报和创建信息冻结。匹配发布单元为 Firstleg 与 Admin;Print 继续使用现有模板。本轮只准备初始化定义,不执行 SQL、不回填测试存量、不提供新旧兼容。已有测试库须另行审阅、授权结构更新,不能重放完整空库基线。
|
||||
|
||||
## 唯一入口与区域边界
|
||||
|
||||
V13 收货覆盖由 `lastleg-service/src/main/resources/db/appointment-receiving.sql` 提供四表:独立收货预约、当前/历史客户成员、待确认变化和不可变覆盖版本;同步 Lastleg 与海外空库基线。有效外部约号唯一性归收货预约的目的仓与约号,批次头表不再独立占用约号。部署前必须核对四表及旧头表唯一索引;本批只准备基线,不提供测试存量回填或自动重置。旧 `reset-test-fba-appointments.sql` 已拒绝存在这些新表的环境,不能用于重放 V13 初始化。实际 SQL 与部署须逐项授权。
|
||||
@@ -78,8 +50,8 @@ V13 收货覆盖由 `lastleg-service/src/main/resources/db/appointment-receiving
|
||||
|
||||
| 区域 | 完整入口 | 服务 schema | 表数 |
|
||||
|---|---|---|---:|
|
||||
| 国内 | `头程数据库统一增量-MySQL8.sql` | `baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_finance` | 229 |
|
||||
| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 238 |
|
||||
| 国内 | `头程数据库统一增量-MySQL8.sql` | `baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_finance` | 227 |
|
||||
| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 228 |
|
||||
|
||||
|
||||
- 所有服务位于同一测试环境;两份文件只分组组织服务 schema,不代表两个部署区域。`baiyue_lastlag_warehouse` 保留当前正式的 `lastlag` 拼写。
|
||||
@@ -102,7 +74,7 @@ V13 收货覆盖由 `lastleg-service/src/main/resources/db/appointment-receiving
|
||||
5. DDL 隐式提交,不可整体回滚。失败后停止并保留错误;经另行授权准备空库后从头执行,不针对半初始化库追加迁移或从失败行续跑。
|
||||
6. Firstleg 测量项与拆分项互为外键:建空表期间暂设会话 `foreign_key_checks=0`,随后立即恢复 `1`,再插入种子。失败时关闭连接。两边外键完整保留在 CREATE TABLE 内。
|
||||
|
||||
成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=229/238`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。
|
||||
成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=227/228`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。
|
||||
|
||||
## 平台管理员密码
|
||||
|
||||
@@ -198,13 +170,3 @@ node --test scripts/admin-test-env-modules/initialization-baseline-contract.test
|
||||
货物废弃通用库存与散箱字段迁移:`migrations/overseas/baiyue_lastleg/V25__cargo_discard_generic_inventory.sql`。新增库存仓位关联及箱唛快照,须先迁移再部署 Lastleg;不回填历史废弃单。
|
||||
|
||||
2026-10-16 拉取合并配套结构清单及执行边界见 [数据库变更汇总](20261016-数据库变更汇总.md),已有尾程库使用 `migrations/overseas/baiyue_lastleg/V28__merge_transfer_measurement_and_compact_numbers.sql`;空库初始化海外表数同步为 209。该增量可重复执行,尚未执行到任何实际数据库。
|
||||
|
||||
## 统一业务单号
|
||||
|
||||
编号规则见 `../../../docs/业务单据编号契约.md`。Order、Warehouse、Lastleg 空库基线新增各自的 `ord_document_sequence`、`wh_document_sequence`、`ll_document_sequence`;Firstleg 与 Finance 复用本库编号表。Lastleg 空库基线移除无生产调用的客户日序列、逻辑秒时钟及旧尾程日序列表,保留排车日序列。采购费用新增可空 `idempotency_key` 及订单/用途/请求键唯一约束,用于退款重放,业务号统一使用 PF。此处只准备基线,不执行 SQL、回填或删除测试存量。
|
||||
|
||||
海外费用与仓租首版统一维护在 `头程数据库统一增量-MySQL8.sql` 的 IAM 菜单/管理员授权种子及 `海外区域统一增量-MySQL8.sql` 的最终建表定义中。海外基线包含资金与仓租四表、拆柜成本可空费用引用、退货 SKU 换标仓租快照、审核人名称,以及采购退款请求幂等字段和唯一索引;同步模块源基线、国内权限种子及海外空库 238 表,复用现有 `FEE_TYPE_077 / 仓储费`,不新增费用项目种子。发布须同时更新 Firstleg、Lastleg、Admin 与海外 PDA;规则、配置及资金验收见 [首版发布验收](../../lastleg-service/docs/overseas-fees-storage-rent-release.md)。仅整理文件,未执行 SQL 或部署。
|
||||
|
||||
本次重复的五份版本脚本和两份专项汇总 SQL 已移除,内容清单见 [20261022-海外费用仓租数据库变更汇总](20261022-海外费用仓租数据库变更汇总.md)。两份统一文件仍只用于空库初始化,不能向已有业务库重放;本次不再提供这项功能的独立升级脚本,已有库升级须另按实际结构和明确授权准备、审阅增量。其他独立修复补丁及历史迁移保持各自适用边界。
|
||||
|
||||
采购方向扩展:`migrations/overseas/baiyue_lastleg/V34__procurement_direction.sql` 先于匹配 Lastleg 版本执行,为采购单增加 `procurement_direction`;历史订单默认海外采购回国内,不修改既有实物和资金记录。国内采购通过既有国内仓目录选起运收货仓;源基线与统一空库基线同步。本地验证不表示测试库已执行或服务已发布。
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
-- 本机开发环境海外费用推送结构修复。固定目标:baiyue_finance。
|
||||
-- 仅将两个既有摘要字段不足 71 位的长度扩至 71;不改数据、费用、账单或余额。
|
||||
-- 保留原字段的空值约束、字符集及排序规则;缺表、缺列或异常字段类型时拒绝执行。
|
||||
-- 执行须单独授权。使用独立连接、遇错停止;不得以本文件代替完整数据库初始化。
|
||||
USE baiyue_finance;
|
||||
|
||||
-- 执行前只读核对;sha256: 前缀 7 位加摘要 64 位,共 71 位。
|
||||
SELECT TABLE_SCHEMA, TABLE_NAME, COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE,
|
||||
CHARACTER_SET_NAME, COLLATION_NAME
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = 'baiyue_finance'
|
||||
AND ((TABLE_NAME = 'fin_inbox_event' AND COLUMN_NAME = 'payload_hash')
|
||||
OR (TABLE_NAME = 'fin_fee_source' AND COLUMN_NAME = 'source_hash'));
|
||||
|
||||
DELIMITER $$
|
||||
CREATE PROCEDURE finance_fee_hash_width_repair_20261023()
|
||||
BEGIN
|
||||
DECLARE checked_columns INT DEFAULT 0;
|
||||
DECLARE column_index INT DEFAULT 0;
|
||||
DECLARE target_table VARCHAR(64);
|
||||
DECLARE target_column VARCHAR(64);
|
||||
DECLARE current_length BIGINT;
|
||||
DECLARE nullable_value VARCHAR(3);
|
||||
DECLARE charset_value VARCHAR(64);
|
||||
DECLARE collation_value VARCHAR(64);
|
||||
DECLARE comment_value TEXT;
|
||||
|
||||
IF DATABASE() <> 'baiyue_finance' THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '目标必须是 baiyue_finance';
|
||||
END IF;
|
||||
SELECT COUNT(*) INTO checked_columns
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = 'baiyue_finance' AND DATA_TYPE IN ('char', 'varchar')
|
||||
AND CHARACTER_MAXIMUM_LENGTH >= 64
|
||||
AND COLUMN_DEFAULT IS NULL AND EXTRA = ''
|
||||
AND ((TABLE_NAME = 'fin_inbox_event' AND COLUMN_NAME = 'payload_hash')
|
||||
OR (TABLE_NAME = 'fin_fee_source' AND COLUMN_NAME = 'source_hash'));
|
||||
IF checked_columns <> 2 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '摘要字段缺失或类型异常,停止修复';
|
||||
END IF;
|
||||
|
||||
WHILE column_index < 2 DO
|
||||
SET target_table = IF(column_index = 0, 'fin_inbox_event', 'fin_fee_source');
|
||||
SET target_column = IF(column_index = 0, 'payload_hash', 'source_hash');
|
||||
SELECT CHARACTER_MAXIMUM_LENGTH, IS_NULLABLE, CHARACTER_SET_NAME, COLLATION_NAME, COLUMN_COMMENT
|
||||
INTO current_length, nullable_value, charset_value, collation_value, comment_value
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = 'baiyue_finance' AND TABLE_NAME = target_table
|
||||
AND COLUMN_NAME = target_column;
|
||||
IF current_length < 71 THEN
|
||||
SET @finance_fee_hash_width_ddl = CONCAT(
|
||||
'ALTER TABLE baiyue_finance.`', target_table, '` MODIFY COLUMN `', target_column,
|
||||
'` VARCHAR(71) CHARACTER SET ', charset_value, ' COLLATE ', collation_value,
|
||||
IF(nullable_value = 'YES', ' NULL', ' NOT NULL'),
|
||||
' COMMENT ', QUOTE(comment_value));
|
||||
PREPARE finance_fee_hash_width_stmt FROM @finance_fee_hash_width_ddl;
|
||||
EXECUTE finance_fee_hash_width_stmt;
|
||||
DEALLOCATE PREPARE finance_fee_hash_width_stmt;
|
||||
END IF;
|
||||
SET column_index = column_index + 1;
|
||||
END WHILE;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
|
||||
CALL finance_fee_hash_width_repair_20261023();
|
||||
DROP PROCEDURE finance_fee_hash_width_repair_20261023;
|
||||
SET @finance_fee_hash_width_ddl = NULL;
|
||||
|
||||
-- 执行后两列长度均应至少为 71;结构已正确时不执行 ALTER。
|
||||
SELECT TABLE_SCHEMA, TABLE_NAME, COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE,
|
||||
CHARACTER_MAXIMUM_LENGTH >= 71 AS width_ok
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = 'baiyue_finance'
|
||||
AND ((TABLE_NAME = 'fin_inbox_event' AND COLUMN_NAME = 'payload_hash')
|
||||
OR (TABLE_NAME = 'fin_fee_source' AND COLUMN_NAME = 'source_hash'));
|
||||
@@ -1,7 +0,0 @@
|
||||
-- 排车新编号所需独立结构;仅新建序列表,不改写已有排车业务号或业务数据。
|
||||
USE baiyue_lastleg;
|
||||
CREATE TABLE IF NOT EXISTS ll_dispatch_daily_sequence (
|
||||
business_date DATE NOT NULL COMMENT '排车编号业务日期(Asia/Shanghai)',
|
||||
last_sequence INT NOT NULL COMMENT '当日已分配排车流水号,范围0001至9999',
|
||||
PRIMARY KEY(business_date)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车单号每日独立原子流水';
|
||||
@@ -1,108 +0,0 @@
|
||||
-- MySQL 8 可重复执行增量:仓储转运到仓取货与本土卡车接入现有排车。
|
||||
-- 目标数据库固定为 baiyue_lastleg;仅新增运输类型、原单关联及请求账本,不回填或删除业务数据。
|
||||
-- 前置:已完成 Lastleg 现有整车排车结构升级;如部署为缺少每日流水表的旧库,也创建正式排车流水表。
|
||||
USE baiyue_lastleg;
|
||||
|
||||
SET @dispatch_transport_column_ddl = IF(
|
||||
EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE() AND table_name = 'll_dispatch_order' AND column_name = 'task_type'
|
||||
),
|
||||
'SELECT 1',
|
||||
'ALTER TABLE ll_dispatch_order ADD COLUMN task_type VARCHAR(32) NOT NULL DEFAULT ''DELIVERY'' COMMENT ''任务类型:仓库派送、到仓取货、本土卡车'' AFTER dispatch_no'
|
||||
);
|
||||
PREPARE dispatch_transport_column_stmt FROM @dispatch_transport_column_ddl;
|
||||
EXECUTE dispatch_transport_column_stmt;
|
||||
DEALLOCATE PREPARE dispatch_transport_column_stmt;
|
||||
|
||||
-- 旧库兼容:整车主单跨客户,原来单客户排车头字段不再要求填写。
|
||||
-- 如目标库已删除旧字段则跳过;已有客户值保留,不置空、不修改。
|
||||
SET @dispatch_transport_legacy_ddl = IF(
|
||||
EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_dispatch_order' AND column_name = 'customer_id' AND is_nullable = 'NO'),
|
||||
'ALTER TABLE ll_dispatch_order MODIFY COLUMN customer_id VARCHAR(128) NULL COMMENT ''旧客户排车归属,整车主单不使用''',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE dispatch_transport_legacy_stmt FROM @dispatch_transport_legacy_ddl;
|
||||
EXECUTE dispatch_transport_legacy_stmt;
|
||||
DEALLOCATE PREPARE dispatch_transport_legacy_stmt;
|
||||
|
||||
-- 旧本土卡车结构兼容:新单沿用已有的起终点联系人及pickup_at/delivery_at字段。
|
||||
-- 旧单字段保留原值,仅放宽空值约束,避免新单被已弃用字段阻断。
|
||||
SET @dispatch_transport_legacy_ddl = IF(
|
||||
EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_us_truck_order' AND column_name = 'contact_name' AND is_nullable = 'NO'),
|
||||
'ALTER TABLE ll_us_truck_order MODIFY COLUMN contact_name VARCHAR(128) NULL COMMENT ''旧本土卡车联系人,新单使用起终点联系人''',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE dispatch_transport_legacy_stmt FROM @dispatch_transport_legacy_ddl;
|
||||
EXECUTE dispatch_transport_legacy_stmt;
|
||||
DEALLOCATE PREPARE dispatch_transport_legacy_stmt;
|
||||
|
||||
SET @dispatch_transport_legacy_ddl = IF(
|
||||
EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_us_truck_order' AND column_name = 'contact_info' AND is_nullable = 'NO'),
|
||||
'ALTER TABLE ll_us_truck_order MODIFY COLUMN contact_info VARCHAR(128) NULL COMMENT ''旧本土卡车联系方式,新单使用起终点联系方式''',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE dispatch_transport_legacy_stmt FROM @dispatch_transport_legacy_ddl;
|
||||
EXECUTE dispatch_transport_legacy_stmt;
|
||||
DEALLOCATE PREPARE dispatch_transport_legacy_stmt;
|
||||
|
||||
SET @dispatch_transport_legacy_ddl = IF(
|
||||
EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_us_truck_order' AND column_name = 'requested_date' AND is_nullable = 'NO'),
|
||||
'ALTER TABLE ll_us_truck_order MODIFY COLUMN requested_date DATE NULL COMMENT ''旧本土卡车请求日期,新单使用取货和送货时间''',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE dispatch_transport_legacy_stmt FROM @dispatch_transport_legacy_ddl;
|
||||
EXECUTE dispatch_transport_legacy_stmt;
|
||||
DEALLOCATE PREPARE dispatch_transport_legacy_stmt;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_dispatch_daily_sequence (
|
||||
business_date DATE NOT NULL COMMENT '排车编号业务日期(Asia/Shanghai)',
|
||||
last_sequence INT NOT NULL COMMENT '当日已分配排车流水号,范围0001至9999',
|
||||
PRIMARY KEY (business_date)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车单号每日独立原子流水';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_dispatch_truck_task (
|
||||
dispatch_no VARCHAR(64) NOT NULL COMMENT '排车业务号',
|
||||
trip_no VARCHAR(64) NOT NULL COMMENT '本次运输资源记录号',
|
||||
customer_id VARCHAR(128) NOT NULL COMMENT '本土卡车原单客户标识',
|
||||
order_no VARCHAR(64) NOT NULL COMMENT '本土卡车原单业务号',
|
||||
active_order VARCHAR(193) NULL COMMENT '有效原单唯一键;取消置空,完成保留',
|
||||
PRIMARY KEY (dispatch_no),
|
||||
UNIQUE KEY uk_transport_trip (trip_no),
|
||||
UNIQUE KEY uk_transport_source (active_order),
|
||||
KEY idx_transport_order (customer_id, order_no)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车与本土卡车原单关联';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_dispatch_transport_create (
|
||||
trip_no VARCHAR(64) NOT NULL COMMENT '本次运输资源记录号',
|
||||
request_hash CHAR(64) NOT NULL COMMENT '首次安排请求摘要',
|
||||
PRIMARY KEY (trip_no)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='本土卡车排车创建请求账本';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_dispatch_transport_command (
|
||||
dispatch_no VARCHAR(64) NOT NULL COMMENT '排车业务号',
|
||||
request_key VARCHAR(128) NOT NULL COMMENT '客户端幂等请求键',
|
||||
request_hash CHAR(64) NOT NULL COMMENT '执行命令请求摘要',
|
||||
evidence_json JSON NULL COMMENT '已确认运输完成凭证标识列表',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '业务命令提交时间',
|
||||
PRIMARY KEY (dispatch_no, request_key)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车运输执行命令与凭证账本';
|
||||
|
||||
-- 当前承运占用查询还依赖散板来源标识(与V33一致);仅补结构,不创建散板业务记录。
|
||||
SET @dispatch_transport_pool_ddl = IF(
|
||||
EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_fleet_external_booking' AND column_name = 'source_looseboard_order_id'),
|
||||
'SELECT 1',
|
||||
'ALTER TABLE ll_fleet_external_booking ADD COLUMN source_looseboard_order_id BIGINT NULL COMMENT ''客户原始散板商业单ID,取消后保留来源'''
|
||||
);
|
||||
PREPARE dispatch_transport_pool_stmt FROM @dispatch_transport_pool_ddl;
|
||||
EXECUTE dispatch_transport_pool_stmt;
|
||||
DEALLOCATE PREPARE dispatch_transport_pool_stmt;
|
||||
|
||||
SET @dispatch_transport_pool_ddl = IF(
|
||||
EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = 'll_fleet_external_booking' AND index_name = 'idx_booking_looseboard_source'),
|
||||
'SELECT 1',
|
||||
'ALTER TABLE ll_fleet_external_booking ADD KEY idx_booking_looseboard_source (customer_id, source_looseboard_order_id, status)'
|
||||
);
|
||||
PREPARE dispatch_transport_pool_stmt FROM @dispatch_transport_pool_ddl;
|
||||
EXECUTE dispatch_transport_pool_stmt;
|
||||
DEALLOCATE PREPARE dispatch_transport_pool_stmt;
|
||||
@@ -1,16 +0,0 @@
|
||||
-- 仅新增车辆及司机档案照片版本表,可重复执行,不回填业务数据。
|
||||
USE baiyue_lastleg;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_fleet_resource_photo (
|
||||
resource_kind VARCHAR(16) NOT NULL COMMENT '档案类型:车辆或司机',
|
||||
resource_id BIGINT NOT NULL COMMENT '车辆或司机档案标识',
|
||||
record_version BIGINT NOT NULL COMMENT '照片版本,从一递增',
|
||||
file_id BIGINT NOT NULL COMMENT '文件服务照片标识',
|
||||
file_name VARCHAR(512) NOT NULL COMMENT '照片原始文件名',
|
||||
content_type VARCHAR(64) NOT NULL COMMENT '照片媒体类型',
|
||||
size_bytes BIGINT NOT NULL COMMENT '照片字节数',
|
||||
created_by VARCHAR(64) NOT NULL COMMENT '上传操作人标识',
|
||||
created_at DATETIME(3) NOT NULL COMMENT '上传时间(UTC)',
|
||||
PRIMARY KEY (resource_kind, resource_id, record_version),
|
||||
UNIQUE KEY uk_fleet_resource_photo_file (resource_kind, resource_id, file_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车辆及司机档案照片版本记录';
|
||||
@@ -1,4 +0,0 @@
|
||||
-- 客户“确认计费重”统一确认 fl_inbound_fee 的当前推送费用版本。
|
||||
-- 先发布已移除旧重量确认接口及查询的 Firstleg 版本,再执行本脚本。
|
||||
-- 仅删除旧独立重量确认记录,不修改费用快照、订单或测量数据。
|
||||
DROP TABLE IF EXISTS baiyue_firstleg.fl_chargeable_weight_confirmation;
|
||||
+3
-1
@@ -2,7 +2,7 @@
|
||||
-- 目标:lastleg-service / baiyue_lastleg;前置:已有业务表及此前迁移。
|
||||
-- 只调整结构,不回填、删除或重算业务数据;执行失败应停止,修复后可重跑。
|
||||
SET NAMES utf8mb4;
|
||||
-- 由单 schema 执行器选择 baiyue_lastleg,不在迁移内部切库。
|
||||
USE baiyue_lastleg;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_compact_number_clock (
|
||||
number_key VARCHAR(16) NOT NULL COMMENT '业务前缀和客户名称缩写',
|
||||
@@ -11,6 +11,7 @@ CREATE TABLE IF NOT EXISTS ll_compact_number_clock (
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外业务短单号分配时钟';
|
||||
|
||||
-- 仓储转运逐箱测量与清点;仅增加字段,不修改业务数据。
|
||||
USE baiyue_lastleg;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='length_cm'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN length_cm DECIMAL(12,2) NULL COMMENT ''箱子实测长度厘米'''));
|
||||
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='width_cm'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN width_cm DECIMAL(12,2) NULL COMMENT ''箱子实测宽度厘米'''));
|
||||
@@ -41,6 +42,7 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab
|
||||
PREPARE transfer_quantity_stmt FROM @ddl; EXECUTE transfer_quantity_stmt; DEALLOCATE PREPARE transfer_quantity_stmt;
|
||||
|
||||
-- 仓储转运一箱多SKU清点;仅增加结构,不回填历史业务数据。
|
||||
USE baiyue_lastleg;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='sku_items_json'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN sku_items_json JSON NULL COMMENT ''实收箱内SKU及逐项件数明细'''));
|
||||
PREPARE transfer_sku_stmt FROM @ddl; EXECUTE transfer_sku_stmt; DEALLOCATE PREPARE transfer_sku_stmt;
|
||||
|
||||
|
||||
-17
@@ -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;
|
||||
@@ -1,49 +0,0 @@
|
||||
-- Region: OVERSEAS
|
||||
-- Schema: baiyue_lastleg
|
||||
-- Purpose: 排车物理托盘串行点、物理占用及逻辑占用关联结构补齐。
|
||||
-- Preconditions: MySQL 8;已选中目标schema;ll_dispatch_pallet_lock已有业务基线存在。
|
||||
-- Postconditions: 两张实托表和physical_lock_id列/idx_dispatch_logical_physical索引存在。
|
||||
-- 只增加缺失结构,不删除/回填/重算历史数据。重复执行已存在对象时跳过。
|
||||
-- 与当前lastleg.sql最终定义一致;已有异构对象须人工核对,不能以跳过证明一致。
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_physical_pallet_guard (
|
||||
warehouse_pallet_id BIGINT NOT NULL COMMENT '真实全局物理托盘ID',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '首次建立串行点时间',
|
||||
PRIMARY KEY (warehouse_pallet_id)
|
||||
) ENGINE=InnoDB COMMENT='排车冻结与仓内实托组成修改的共同串行点';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_dispatch_physical_pallet_lock (
|
||||
id BIGINT NOT NULL COMMENT '物理实托占用主键',
|
||||
warehouse_id VARCHAR(128) NOT NULL COMMENT '真实起运仓ID',
|
||||
warehouse_pallet_id BIGINT NOT NULL COMMENT '全局真实物理托盘ID',
|
||||
active_warehouse_pallet_id BIGINT NULL COMMENT '活动物理排他键,释放后为空',
|
||||
dispatch_order_id BIGINT NULL COMMENT '正式排车主单ID,与草稿归属互斥',
|
||||
draft_token VARCHAR(64) NULL COMMENT '草稿拥有者令牌,与正式排车归属互斥',
|
||||
draft_container_id BIGINT NULL COMMENT '草稿内唯一占用该实托的容器ID',
|
||||
operator_id VARCHAR(128) NULL COMMENT '占用操作人ID',
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE' COMMENT '物理占用状态',
|
||||
expires_at DATETIME(3) NULL COMMENT '草稿失效时间,正式占用为空',
|
||||
locked_at DATETIME(3) NOT NULL COMMENT '锁定时间',
|
||||
released_at DATETIME(3) NULL COMMENT '释放时间',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_dispatch_physical_active_pallet (active_warehouse_pallet_id),
|
||||
KEY idx_dispatch_physical_order (dispatch_order_id,status),
|
||||
KEY idx_dispatch_physical_draft (draft_token,draft_container_id,status),
|
||||
CONSTRAINT chk_dispatch_physical_active_key CHECK((status='ACTIVE' AND active_warehouse_pallet_id IS NOT NULL AND active_warehouse_pallet_id=warehouse_pallet_id) OR (status<>'ACTIVE' AND active_warehouse_pallet_id IS NULL)),
|
||||
CONSTRAINT chk_dispatch_physical_owner CHECK((dispatch_order_id IS NOT NULL AND draft_token IS NULL AND draft_container_id IS NULL) OR (dispatch_order_id IS NULL AND draft_token IS NOT NULL AND draft_container_id IS NOT NULL))
|
||||
) ENGINE=InnoDB COMMENT='同车共享且跨车排他的物理实托占用';
|
||||
|
||||
-- 已只读确认本地缺失:客户逻辑锁到物理锁的可空关联列及查询索引。
|
||||
-- 仅补结构,不根据历史单据推测或回填关联。
|
||||
SET @overseas_dimension_ddl = IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pallet_lock' AND column_name='physical_lock_id'), 'SELECT 1', 'ALTER TABLE ll_dispatch_pallet_lock ADD COLUMN physical_lock_id BIGINT NULL COMMENT ''实际物理实托占用ID,非物理来源为空''');
|
||||
PREPARE overseas_dimension_stmt FROM @overseas_dimension_ddl;
|
||||
EXECUTE overseas_dimension_stmt;
|
||||
DEALLOCATE PREPARE overseas_dimension_stmt;
|
||||
SET @overseas_dimension_ddl = IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pallet_lock' AND index_name='idx_dispatch_logical_physical'), 'SELECT 1', 'ALTER TABLE ll_dispatch_pallet_lock ADD KEY idx_dispatch_logical_physical (physical_lock_id,status)');
|
||||
PREPARE overseas_dimension_stmt FROM @overseas_dimension_ddl;
|
||||
EXECUTE overseas_dimension_stmt;
|
||||
DEALLOCATE PREPARE overseas_dimension_stmt;
|
||||
SET @overseas_dimension_ddl = NULL;
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
-- 仓储取货与本土卡车接入现有排车;保留旧排车用途默认值。
|
||||
ALTER TABLE ll_dispatch_order ADD COLUMN task_type VARCHAR(32) NOT NULL DEFAULT 'DELIVERY' COMMENT '任务类型:仓库派送、到仓取货、本土卡车' AFTER dispatch_no;
|
||||
CREATE TABLE ll_dispatch_truck_task (
|
||||
dispatch_no VARCHAR(64) NOT NULL COMMENT '排车业务号',
|
||||
trip_no VARCHAR(64) NOT NULL COMMENT '本次运输资源记录号',
|
||||
customer_id VARCHAR(128) NOT NULL COMMENT '本土卡车原单客户标识',
|
||||
order_no VARCHAR(64) NOT NULL COMMENT '本土卡车原单业务号',
|
||||
active_order VARCHAR(193) NULL COMMENT '有效原单唯一键;取消置空,完成保留',
|
||||
PRIMARY KEY(dispatch_no), UNIQUE KEY uk_transport_trip(trip_no), UNIQUE KEY uk_transport_source(active_order),
|
||||
KEY idx_transport_order(customer_id,order_no)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车与本土卡车原单关联';
|
||||
CREATE TABLE ll_dispatch_transport_create (
|
||||
trip_no VARCHAR(64) NOT NULL PRIMARY KEY COMMENT '本次运输资源记录号',
|
||||
request_hash CHAR(64) NOT NULL COMMENT '首次安排请求摘要'
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='本土卡车排车创建请求账本';
|
||||
CREATE TABLE ll_dispatch_transport_command (
|
||||
dispatch_no VARCHAR(64) NOT NULL COMMENT '排车业务号',
|
||||
request_key VARCHAR(128) NOT NULL COMMENT '客户端幂等请求键',
|
||||
request_hash CHAR(64) NOT NULL COMMENT '执行命令请求摘要',
|
||||
evidence_json JSON NULL COMMENT '已确认运输完成凭证标识列表',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '业务命令提交时间',
|
||||
PRIMARY KEY(dispatch_no,request_key)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车运输执行命令与凭证账本';
|
||||
@@ -1,13 +0,0 @@
|
||||
-- Region: OVERSEAS
|
||||
-- Schema: baiyue_lastleg
|
||||
-- Purpose: 客户散板原单接入拼车需求池,未确认登记不占位。
|
||||
-- Preconditions: MySQL 8;已选择 baiyue_lastleg;预订表已存在。
|
||||
-- Postconditions: 预订原单来源列及客户来源索引存在,不回填历史来源。
|
||||
SET @looseboard_demand_ddl = IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_fleet_external_booking' AND column_name='source_looseboard_order_id'), 'SELECT 1', 'ALTER TABLE ll_fleet_external_booking ADD COLUMN source_looseboard_order_id BIGINT NULL COMMENT ''客户原始散板商业单ID,取消后保留来源''');
|
||||
PREPARE looseboard_demand_stmt FROM @looseboard_demand_ddl;
|
||||
EXECUTE looseboard_demand_stmt;
|
||||
DEALLOCATE PREPARE looseboard_demand_stmt;
|
||||
SET @looseboard_demand_ddl = IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_fleet_external_booking' AND index_name='idx_booking_looseboard_source'), 'SELECT 1', 'ALTER TABLE ll_fleet_external_booking ADD KEY idx_booking_looseboard_source (customer_id,source_looseboard_order_id,status)');
|
||||
PREPARE looseboard_demand_stmt FROM @looseboard_demand_ddl;
|
||||
EXECUTE looseboard_demand_stmt;
|
||||
DEALLOCATE PREPARE looseboard_demand_stmt;
|
||||
@@ -1,14 +0,0 @@
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_fleet_resource_photo (
|
||||
resource_kind VARCHAR(16) NOT NULL COMMENT '档案类型:车辆或司机',
|
||||
resource_id BIGINT NOT NULL COMMENT '车辆或司机档案标识',
|
||||
record_version BIGINT NOT NULL COMMENT '照片版本,从一递增',
|
||||
file_id BIGINT NOT NULL COMMENT '文件服务照片标识',
|
||||
file_name VARCHAR(512) NOT NULL COMMENT '照片原始文件名',
|
||||
content_type VARCHAR(64) NOT NULL COMMENT '照片媒体类型',
|
||||
size_bytes BIGINT NOT NULL COMMENT '照片字节数',
|
||||
created_by VARCHAR(64) NOT NULL COMMENT '上传操作人标识',
|
||||
created_at DATETIME(3) NOT NULL COMMENT '上传时间(UTC)',
|
||||
PRIMARY KEY (resource_kind, resource_id, record_version),
|
||||
UNIQUE KEY uk_fleet_resource_photo_file (resource_kind, resource_id, file_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车辆及司机档案照片版本记录';
|
||||
@@ -1,9 +0,0 @@
|
||||
-- 先于新版本 Lastleg 上线执行。历史订单保持海外采购回国内,不改数量、资金或交寄事实。
|
||||
SET @procurement_direction_ddl = IF(
|
||||
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'll_overseas_procurement_order' AND COLUMN_NAME = 'procurement_direction') = 0,
|
||||
'ALTER TABLE ll_overseas_procurement_order ADD COLUMN procurement_direction VARCHAR(32) NOT NULL DEFAULT ''OVERSEAS_TO_DOMESTIC'' COMMENT ''采购方向:海外采购回国内/国内采购发海外''',
|
||||
'SELECT 1');
|
||||
PREPARE procurement_direction_stmt FROM @procurement_direction_ddl;
|
||||
EXECUTE procurement_direction_stmt;
|
||||
DEALLOCATE PREPARE procurement_direction_stmt;
|
||||
@@ -1,121 +0,0 @@
|
||||
-- 海外费用既有写按钮最小修复;MySQL 8,目标只能是 baiyue_iam。
|
||||
-- 仅恢复基线 5105024/5105025 及既有 4000001 PLATFORM_ADMIN 缺失授权。
|
||||
-- 不创建角色/成员,不授予其他角色,不新增 overseas-fee:edit/review 别名。
|
||||
-- 在支持 DELIMITER、遇错停止的客户端同连接整份执行;执行期间暂停相关角色权限维护。
|
||||
-- 不在其他未提交事务中调用。所有业务DML及subject版本变更同事务,任何异常回滚。
|
||||
USE baiyue_iam;
|
||||
SET NAMES utf8mb4;
|
||||
SELECT DATABASE() AS target_database;
|
||||
|
||||
DROP PROCEDURE IF EXISTS repair_overseas_fee_canonical_write;
|
||||
DELIMITER $$
|
||||
CREATE PROCEDURE repair_overseas_fee_canonical_write()
|
||||
BEGIN
|
||||
DECLARE locked_role BIGINT;
|
||||
DECLARE changed_rows BIGINT DEFAULT 0;
|
||||
DECLARE EXIT HANDLER FOR SQLEXCEPTION
|
||||
BEGIN
|
||||
ROLLBACK;
|
||||
RESIGNAL;
|
||||
END;
|
||||
|
||||
START TRANSACTION;
|
||||
IF DATABASE()<>'baiyue_iam' THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Wrong schema: expected baiyue_iam';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM iam_role WHERE id=4000001 AND tenant_id=0 AND app_code='PLATFORM'
|
||||
AND role_code='PLATFORM_ADMIN' AND role_type='SYSTEM' AND status='ACTIVE' AND deleted=0)<>1
|
||||
OR EXISTS(SELECT 1 FROM iam_role WHERE role_code='PLATFORM_ADMIN' AND id<>4000001) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Existing PLATFORM_ADMIN identity/status mismatch; no role will be created';
|
||||
END IF;
|
||||
SELECT id INTO locked_role FROM iam_role WHERE id=4000001 FOR UPDATE;
|
||||
|
||||
-- 新建缺失按钮使用当前客户应收父菜单;只接受已核对的既有财务菜单,不修复其他菜单。
|
||||
IF (SELECT COUNT(*) FROM iam_permission WHERE id=5105041 AND tenant_id=0 AND app_code='PLATFORM'
|
||||
AND permission_type='MENU' AND permission_code='platform:finance:customer-arap:list'
|
||||
AND status='ACTIVE' AND deleted=0)<>1
|
||||
OR EXISTS(SELECT 1 FROM iam_permission WHERE permission_code='platform:finance:customer-arap:list' AND id<>5105041) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Existing customer AR/AP parent menu is missing or mismatched';
|
||||
END IF;
|
||||
IF EXISTS(SELECT 1 FROM iam_permission WHERE
|
||||
(id=5105024 AND NOT(permission_code<=>'platform:finance:fee-source:review'))
|
||||
OR (id=5105025 AND NOT(permission_code<=>'platform:finance:fee-source:edit'))
|
||||
OR (permission_code='platform:finance:fee-source:review' AND id<>5105024)
|
||||
OR (permission_code='platform:finance:fee-source:edit' AND id<>5105025)
|
||||
OR (id IN(5105024,5105025) AND (tenant_id<>0 OR app_code<>'PLATFORM'
|
||||
OR permission_type<>'BUTTON' OR built_in<>1 OR parent_id NOT IN(5105000,5105041)
|
||||
OR route_path IS NOT NULL
|
||||
OR NOT(http_method<=>IF(id=5105024,'POST','PUT'))))) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Canonical fee button identity/type/metadata conflict';
|
||||
END IF;
|
||||
IF EXISTS(SELECT 1 FROM iam_permission child LEFT JOIN iam_permission parent ON parent.id=child.parent_id
|
||||
WHERE child.id IN(5105024,5105025) AND (parent.id IS NULL OR parent.tenant_id<>0
|
||||
OR parent.app_code<>'PLATFORM' OR parent.permission_type<>'MENU' OR parent.status<>'ACTIVE' OR parent.deleted<>0
|
||||
OR NOT(parent.permission_code<=>IF(child.parent_id=5105000,'platform:finance','platform:finance:customer-arap:list')))) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Existing fee button parent is invalid';
|
||||
END IF;
|
||||
IF EXISTS(SELECT 1 FROM iam_role_permission WHERE permission_id IN(5105024,5105025) AND tenant_id<>0)
|
||||
OR EXISTS(SELECT 1 FROM iam_membership_role mr LEFT JOIN iam_membership m ON m.id=mr.membership_id
|
||||
WHERE mr.role_id=4000001 AND (mr.tenant_id<>0 OR m.id IS NULL OR m.tenant_id<>0)) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Existing canonical permission or administrator membership scope mismatch';
|
||||
END IF;
|
||||
-- 恢复停用/缺失资源也会恢复其他角色的旧授权;这种情况必须单独核对,不能随本补丁扩权。
|
||||
IF EXISTS(SELECT 1 FROM iam_role_permission rp LEFT JOIN iam_permission p ON p.id=rp.permission_id
|
||||
WHERE rp.permission_id IN(5105024,5105025) AND rp.role_id<>4000001
|
||||
AND (p.id IS NULL OR p.status<>'ACTIVE' OR p.deleted<>0)) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Restoring fee button would affect another role; review separately';
|
||||
END IF;
|
||||
|
||||
INSERT INTO iam_permission
|
||||
(id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,deleted)
|
||||
SELECT 5105024,0,'PLATFORM','BUTTON',5105041,'platform:finance:fee-source:review','审核财务费用来源',NULL,'POST',150,'ACTIVE',1,0
|
||||
WHERE NOT EXISTS(SELECT 1 FROM iam_permission WHERE id=5105024);
|
||||
SET changed_rows=changed_rows+ROW_COUNT();
|
||||
INSERT INTO iam_permission
|
||||
(id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,deleted)
|
||||
SELECT 5105025,0,'PLATFORM','BUTTON',5105041,'platform:finance:fee-source:edit','编辑财务费用来源',NULL,'PUT',160,'ACTIVE',1,0
|
||||
WHERE NOT EXISTS(SELECT 1 FROM iam_permission WHERE id=5105025);
|
||||
SET changed_rows=changed_rows+ROW_COUNT();
|
||||
UPDATE iam_permission SET status='ACTIVE',deleted=0
|
||||
WHERE id IN(5105024,5105025) AND (status<>'ACTIVE' OR deleted<>0);
|
||||
SET changed_rows=changed_rows+ROW_COUNT();
|
||||
INSERT INTO iam_role_permission(tenant_id,role_id,permission_id,created_at)
|
||||
SELECT 0,4000001,p.id,CURRENT_TIMESTAMP(3) FROM iam_permission p WHERE p.id IN(5105024,5105025)
|
||||
AND NOT EXISTS(SELECT 1 FROM iam_role_permission rp WHERE rp.role_id=4000001 AND rp.permission_id=p.id);
|
||||
SET changed_rows=changed_rows+ROW_COUNT();
|
||||
|
||||
-- 只有真实变更才推进版本;枚举所有既有管理员成员,不依赖当前登录用户,也不遗漏停用成员。
|
||||
IF changed_rows>0 THEN
|
||||
INSERT INTO iam_security_subject_version(auth_user_id,permission_version)
|
||||
SELECT DISTINCT u.auth_user_id,1 FROM iam_user u
|
||||
JOIN iam_membership m ON m.user_id=u.id AND m.tenant_id=0
|
||||
JOIN iam_membership_role mr ON mr.membership_id=m.id AND mr.tenant_id=m.tenant_id AND mr.role_id=4000001
|
||||
WHERE u.auth_user_id IS NOT NULL
|
||||
ON DUPLICATE KEY UPDATE permission_version=permission_version+1;
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM iam_role_permission rp JOIN iam_permission p ON p.id=rp.permission_id
|
||||
WHERE rp.role_id=4000001 AND rp.tenant_id=0 AND p.id IN(5105024,5105025)
|
||||
AND p.tenant_id=0 AND p.app_code='PLATFORM' AND p.permission_type='BUTTON'
|
||||
AND p.status='ACTIVE' AND p.deleted=0)<>2 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Canonical fee write grants incomplete; repair rolled back';
|
||||
END IF;
|
||||
COMMIT;
|
||||
SELECT changed_rows AS repaired_catalog_or_grant_rows;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
CALL repair_overseas_fee_canonical_write();
|
||||
DROP PROCEDURE repair_overseas_fee_canonical_write;
|
||||
|
||||
-- 核验1:必须恰好两行,角色4000001、tenant=0、BUTTON、ACTIVE、deleted=0。
|
||||
SELECT r.id AS role_id,r.role_code,rp.tenant_id,p.id AS permission_id,p.permission_code,
|
||||
p.permission_type,p.status,p.deleted
|
||||
FROM iam_role r JOIN iam_role_permission rp ON rp.role_id=r.id
|
||||
JOIN iam_permission p ON p.id=rp.permission_id
|
||||
WHERE r.id=4000001 AND p.id IN(5105024,5105025) ORDER BY p.id;
|
||||
-- 核验2:仅查看既有管理员主体与当前版本,不创建或切换任何成员角色。
|
||||
SELECT DISTINCT u.auth_user_id,m.id AS membership_id,u.status AS user_status,m.status AS membership_status,
|
||||
COALESCE(sv.permission_version,0) AS permission_version
|
||||
FROM iam_user u JOIN iam_membership m ON m.user_id=u.id AND m.tenant_id=0
|
||||
JOIN iam_membership_role mr ON mr.membership_id=m.id AND mr.tenant_id=m.tenant_id AND mr.role_id=4000001
|
||||
LEFT JOIN iam_security_subject_version sv ON sv.auth_user_id=u.auth_user_id
|
||||
ORDER BY u.auth_user_id,m.id;
|
||||
@@ -1,19 +0,0 @@
|
||||
-- 海外人工费用说明字段。仅修改 baiyue_lastleg.ll_finance_fee_source 的结构。
|
||||
-- 执行需单独授权;使用独立连接、遇错停止。先执行本脚本,再加载配套 Lastleg 代码。
|
||||
-- 无业务数据回填、费用重建、资金操作或菜单变更。已有字段时跳过新增。
|
||||
USE baiyue_lastleg;
|
||||
|
||||
SET @overseas_fee_remark_ddl = (
|
||||
SELECT IF(EXISTS(
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE() AND table_name = 'll_finance_fee_source' AND column_name = 'fee_remark'
|
||||
), 'SELECT ''fee_remark already exists'' AS result',
|
||||
'ALTER TABLE ll_finance_fee_source ADD COLUMN fee_remark VARCHAR(500) NULL COMMENT ''费用说明,补收或重复作业原因'' AFTER fee_item_name')
|
||||
);
|
||||
PREPARE overseas_fee_remark_stmt FROM @overseas_fee_remark_ddl;
|
||||
EXECUTE overseas_fee_remark_stmt;
|
||||
DEALLOCATE PREPARE overseas_fee_remark_stmt;
|
||||
|
||||
SELECT table_schema, table_name, column_name, column_type, is_nullable, column_comment
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'baiyue_lastleg' AND table_name = 'll_finance_fee_source' AND column_name = 'fee_remark';
|
||||
@@ -1,73 +0,0 @@
|
||||
-- 固定目标本机 baiyue_lastleg,只修复自动免费仓租结构,不更新业务数据。
|
||||
-- 使用独立连接、遇错停止;先核对既有表结构,不能重放空库初始化。
|
||||
USE baiyue_lastleg;
|
||||
SELECT COLUMN_NAME,COLUMN_TYPE,IS_NULLABLE FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ll_finance_fee_source'
|
||||
AND COLUMN_NAME IN ('amount','original_amount','exchange_rate','created_by','fee_item_code','direction');
|
||||
|
||||
-- 上次 CALL 前置检查失败可能遗留本专用过程,仅清理本脚本自己的过程后重建。
|
||||
DROP PROCEDURE IF EXISTS storage_rent_zero_fee_repair_20261023;
|
||||
DELIMITER $$
|
||||
CREATE PROCEDURE storage_rent_zero_fee_repair_20261023()
|
||||
BEGIN
|
||||
DECLARE checked_columns INT DEFAULT 0;
|
||||
DECLARE checked_constraints INT DEFAULT 0;
|
||||
DECLARE rate_type VARCHAR(64);
|
||||
DECLARE rate_comment TEXT;
|
||||
DECLARE rate_default TEXT;
|
||||
DECLARE rate_default_sql TEXT;
|
||||
IF DATABASE()<>'baiyue_lastleg' THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='目标必须为 baiyue_lastleg';
|
||||
END IF;
|
||||
SELECT COUNT(*) INTO checked_columns FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ll_finance_fee_source'
|
||||
AND ((COLUMN_NAME IN ('amount','original_amount') AND DATA_TYPE='decimal' AND IS_NULLABLE='NO')
|
||||
OR (COLUMN_NAME='exchange_rate' AND DATA_TYPE='decimal' AND EXTRA='')
|
||||
OR (COLUMN_NAME IN ('created_by','fee_item_code','direction') AND DATA_TYPE='varchar' AND IS_NULLABLE='NO'));
|
||||
SELECT COUNT(*) INTO checked_constraints FROM information_schema.TABLE_CONSTRAINTS
|
||||
WHERE CONSTRAINT_SCHEMA=DATABASE() AND TABLE_NAME='ll_finance_fee_source'
|
||||
AND CONSTRAINT_NAME='chk_ll_finance_fee_amount' AND CONSTRAINT_TYPE='CHECK';
|
||||
IF checked_columns<>6 OR checked_constraints<>1 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='费用源表字段或金额约束异常,停止修复';
|
||||
END IF;
|
||||
-- 增加新约束前只读检查;人工费用仍须正额且有效汇率,防止意外放宽。
|
||||
IF EXISTS (SELECT 1 FROM ll_finance_fee_source
|
||||
WHERE NOT ((amount>0 OR (amount=0 AND original_amount=0 AND created_by='STORAGE_RENT' AND fee_item_code='FEE_TYPE_077' AND direction='RECEIVABLE'))
|
||||
AND ((exchange_rate IS NOT NULL AND exchange_rate>0) OR (exchange_rate IS NULL AND amount=0 AND original_amount=0 AND created_by='STORAGE_RENT' AND fee_item_code='FEE_TYPE_077' AND direction='RECEIVABLE')))) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='存在不满足新约束的费用,停止且不改数据';
|
||||
END IF;
|
||||
SELECT COUNT(*) INTO checked_constraints FROM information_schema.TABLE_CONSTRAINTS
|
||||
WHERE CONSTRAINT_SCHEMA=DATABASE() AND TABLE_NAME='ll_finance_fee_source'
|
||||
AND CONSTRAINT_NAME='chk_ll_finance_fee_exchange_rate' AND CONSTRAINT_TYPE='CHECK';
|
||||
IF checked_constraints=0 THEN
|
||||
-- 保留真实已有列的精度和备注,不把存量 DECIMAL(18,8) 收窄到基线精度。
|
||||
SELECT COLUMN_TYPE,COLUMN_COMMENT,COLUMN_DEFAULT INTO rate_type,rate_comment,rate_default
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ll_finance_fee_source' AND COLUMN_NAME='exchange_rate';
|
||||
IF rate_type NOT REGEXP '^decimal[(][0-9]+,[0-9]+[)]$' THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='汇率列不是预期定点数类型,停止修复';
|
||||
END IF;
|
||||
IF LOCATE('自动零元仓租可空',rate_comment)=0 THEN
|
||||
SET rate_comment=CONCAT(rate_comment,',自动零元仓租可空');
|
||||
END IF;
|
||||
-- 保留既有数值默认值(例如1.00000000);拒绝表达式或非数值默认值。
|
||||
IF rate_default IS NOT NULL AND rate_default NOT REGEXP '^-?[0-9]+([.][0-9]+)?$' THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='汇率默认值不是预期数值,停止修复';
|
||||
END IF;
|
||||
SET rate_default_sql=IF(rate_default IS NULL,' DEFAULT NULL',CONCAT(' DEFAULT ',rate_default));
|
||||
-- 单次原子 ALTER 同步调整可空性与两项约束,原币及结算列完全不改。
|
||||
SET @storage_rent_zero_fee_ddl=CONCAT(
|
||||
'ALTER TABLE baiyue_lastleg.ll_finance_fee_source DROP CHECK chk_ll_finance_fee_amount, ',
|
||||
'MODIFY COLUMN exchange_rate ',rate_type,' NULL',rate_default_sql,' COMMENT ',QUOTE(rate_comment),', ',
|
||||
'ADD CONSTRAINT chk_ll_finance_fee_amount CHECK(amount>0 OR (amount=0 AND original_amount=0 AND created_by=''STORAGE_RENT'' AND fee_item_code=''FEE_TYPE_077'' AND direction=''RECEIVABLE'')), ',
|
||||
'ADD CONSTRAINT chk_ll_finance_fee_exchange_rate CHECK((exchange_rate IS NOT NULL AND exchange_rate>0) OR (exchange_rate IS NULL AND amount=0 AND original_amount=0 AND created_by=''STORAGE_RENT'' AND fee_item_code=''FEE_TYPE_077'' AND direction=''RECEIVABLE''))');
|
||||
PREPARE storage_rent_zero_fee_stmt FROM @storage_rent_zero_fee_ddl;
|
||||
EXECUTE storage_rent_zero_fee_stmt;
|
||||
DEALLOCATE PREPARE storage_rent_zero_fee_stmt;
|
||||
SET @storage_rent_zero_fee_ddl=NULL;
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
CALL storage_rent_zero_fee_repair_20261023();
|
||||
DROP PROCEDURE storage_rent_zero_fee_repair_20261023;
|
||||
SHOW CREATE TABLE ll_finance_fee_source;
|
||||
@@ -1,40 +0,0 @@
|
||||
-- 供应商查询缺失结构修复。MySQL 8;仅用于本次明确授权的本机开发库。
|
||||
-- 固定目标 baiyue_warehouse;不修改供应商、费用、余额或授信数据。
|
||||
-- 已有结构安全跳过;不推断历史拖车供应商的国内/海外类别。
|
||||
-- DDL 会隐式提交,请在独立连接以遇错停止模式整份执行。
|
||||
USE baiyue_warehouse;
|
||||
SET NAMES utf8mb4;
|
||||
SELECT DATABASE() AS target_database;
|
||||
|
||||
-- 先确认正式供应商主表存在;不存在时 ALTER 会报错并停止,不创建第二份主档。
|
||||
SET @supplier_query_column_ddl = IF(
|
||||
EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'baiyue_warehouse'
|
||||
AND table_name = 'wh_supplier' AND column_name = 'trucking_scope'),
|
||||
'SELECT ''trucking_scope 已存在,跳过'' AS structure_result',
|
||||
'ALTER TABLE baiyue_warehouse.wh_supplier ADD COLUMN trucking_scope VARCHAR(16) NULL COMMENT ''拖车类型:DOMESTIC国内拖车、OVERSEAS海外车队;非拖车为空'' AFTER category'
|
||||
);
|
||||
PREPARE supplier_query_statement FROM @supplier_query_column_ddl;
|
||||
EXECUTE supplier_query_statement;
|
||||
DEALLOCATE PREPARE supplier_query_statement;
|
||||
|
||||
-- 正式列表还会读取船司关联;若未初始化,仅按当前模块基线建立空表。
|
||||
CREATE TABLE IF NOT EXISTS wh_supplier_carrier (
|
||||
supplier_id BIGINT UNSIGNED NOT NULL COMMENT 'BOOKING供应商ID',
|
||||
tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '平台公共作用域,固定为0',
|
||||
carrier_code VARCHAR(16) NOT NULL COMMENT '船司标准编码',
|
||||
created_by BIGINT UNSIGNED NULL COMMENT '创建人ID',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
PRIMARY KEY (supplier_id,carrier_code),
|
||||
KEY idx_wh_supplier_carrier_code (tenant_id,carrier_code,supplier_id),
|
||||
CONSTRAINT chk_wh_supplier_carrier_tenant CHECK (tenant_id = 0),
|
||||
CONSTRAINT chk_wh_supplier_carrier_code CHECK (carrier_code IN ('MATSON','SML','ZIM','COSCO','EMC','HEDE','OTHER'))
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='BOOKING供应商与标准船司编码多对多关联';
|
||||
|
||||
-- 执行后只核对结构,不读取供应商联系信息。
|
||||
SELECT table_name, column_name, column_type, is_nullable
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'baiyue_warehouse'
|
||||
AND ((table_name = 'wh_supplier' AND column_name = 'trucking_scope')
|
||||
OR table_name = 'wh_supplier_carrier')
|
||||
ORDER BY table_name, ordinal_position;
|
||||
+11
-104
@@ -1,5 +1,4 @@
|
||||
-- CN 当前纯空库初始化入口(文件名沿用原入口,不提供增量升级语义)。
|
||||
-- 海外费用菜单及平台管理员授权统一在本文件 IAM 种子中维护,不再单列本次版本或专项菜单脚本。
|
||||
-- MySQL 8.0+;只允许目标测试环境的服务 schema 为空,已有表立即报错;禁止 --force/遇错继续。
|
||||
-- 字段、索引、约束及注释直接定义在 CREATE TABLE 内;不回填、不清理、不改造存量。
|
||||
-- 国内 IAM 管理两区域权限;海外不访问国内库。执行方法及系统种子清单见同目录 README.md。
|
||||
@@ -1485,6 +1484,7 @@ CREATE TABLE fl_container_booking (
|
||||
KEY idx_container_booking_voyage (voyage_no,updated_at),
|
||||
KEY idx_container_booking_transit_summary (actual_sailing_at,actual_warehouse_arrival_at,ocean_booking_id,container_type),
|
||||
CONSTRAINT chk_container_booking_capacity CHECK (((capacity_cbm is null) or (capacity_cbm > 0))),
|
||||
CONSTRAINT chk_container_booking_iso6346 CHECK (((container_no is null) or regexp_like(container_no,_utf8mb4'^[A-Z]{3}[UJZ][0-9]{7}$'))),
|
||||
CONSTRAINT chk_container_booking_pickup_code CHECK (((pickup_code is null) or regexp_like(pickup_code,_utf8mb4'^[0-9]{1,64}$'))),
|
||||
CONSTRAINT chk_container_booking_record_status CHECK ((record_status in (_utf8mb4'ACTIVE',_utf8mb4'MERGED'))),
|
||||
CONSTRAINT chk_container_booking_source CHECK ((source_type in (_utf8mb4'SIMULATION',_utf8mb4'OCEAN_BOOKING'))),
|
||||
@@ -1981,11 +1981,11 @@ CREATE TABLE fl_customs_clearance_result (
|
||||
) ENGINE=InnoDB COMMENT='正式申报后的海关结果不可变事实';
|
||||
|
||||
CREATE TABLE fl_document_sequence (
|
||||
sequence_key varchar(32) NOT NULL COMMENT '单据序列键;日序列如LP:20260818,柜次长期序列固定为CABINET',
|
||||
sequence_key varchar(32) NOT NULL COMMENT '单据序列键;日序列如CB:20260818,柜次长期序列固定为CABINET',
|
||||
current_value int unsigned NOT NULL COMMENT '该类型当日已分配的最大序号',
|
||||
PRIMARY KEY (sequence_key),
|
||||
CONSTRAINT chk_fl_document_sequence_value CHECK ((current_value > 0))
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='头程业务编号序列;普通单据按日,柜次全局连续,实物标签独立保留';
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='头程履约业务单号日序列;生成YYMMDD加四位当日序号';
|
||||
|
||||
CREATE TABLE fl_fee_item (
|
||||
id bigint unsigned NOT NULL COMMENT '费用项目ID',
|
||||
@@ -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 '仓储应付是否参与',
|
||||
@@ -3442,7 +3441,7 @@ CREATE TABLE fl_mojia_measurement_receipt (
|
||||
CREATE TABLE fl_ocean_booking (
|
||||
id bigint unsigned NOT NULL COMMENT '海运订舱ID',
|
||||
booking_no varchar(40) NOT NULL COMMENT '平台海运订舱编号',
|
||||
status varchar(24) NOT NULL DEFAULT 'DRAFT' COMMENT '订舱状态:DRAFT草稿、RELEASED已放舱,运输快照冻结且实货量受控补录',
|
||||
status varchar(24) NOT NULL DEFAULT 'DRAFT' COMMENT '订舱状态:DRAFT草稿、RELEASED已发布且永久只读',
|
||||
customer_id bigint unsigned DEFAULT NULL COMMENT '客户ID和数据隔离键',
|
||||
customer_name varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '客户名称快照',
|
||||
channel_code varchar(64) DEFAULT NULL COMMENT '订舱计划物流渠道组编码快照',
|
||||
@@ -3465,17 +3464,6 @@ CREATE TABLE fl_ocean_booking (
|
||||
estimated_sailing_at datetime(3) DEFAULT NULL COMMENT '当前确认预计开船时间',
|
||||
estimated_arrival_at datetime(3) DEFAULT NULL COMMENT '当前确认预计到港时间',
|
||||
remark varchar(1000) DEFAULT NULL COMMENT '平台内部备注',
|
||||
freight_term VARCHAR(64) NULL COMMENT '运费条款',
|
||||
service_term VARCHAR(64) NULL COMMENT '运输条款',
|
||||
package_unit VARCHAR(16) NULL COMMENT '包装单位:CARTONS、PCS或PALLETS',
|
||||
cargo_description VARCHAR(1000) NULL COMMENT '货物品名及托运要求',
|
||||
marks_and_nos VARCHAR(1000) NULL COMMENT '唛头和号码',
|
||||
declared_package_count INT UNSIGNED NULL COMMENT '订舱预报件数(委)',
|
||||
declared_gross_weight_kg DECIMAL(15,3) NULL COMMENT '订舱预报毛重kg(委)',
|
||||
declared_volume_cbm DECIMAL(15,3) NULL COMMENT '订舱预报体积cbm(委)',
|
||||
actual_package_count INT UNSIGNED NULL COMMENT '客服手工实货件数',
|
||||
actual_gross_weight_kg DECIMAL(15,3) NULL COMMENT '客服手工实货毛重kg',
|
||||
actual_volume_cbm DECIMAL(15,3) NULL COMMENT '客服手工实货体积cbm',
|
||||
created_by bigint unsigned NOT NULL COMMENT '创建操作人ID',
|
||||
created_by_name varchar(64) DEFAULT NULL COMMENT '创建操作人姓名快照',
|
||||
created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '订舱记录创建时间',
|
||||
@@ -3490,14 +3478,6 @@ CREATE TABLE fl_ocean_booking (
|
||||
KEY idx_ocean_booking_voyage (vessel_name,voyage_no,updated_at),
|
||||
CONSTRAINT chk_ocean_booking_carrier_code CHECK (((carrier_code is null) or (carrier_code in (_utf8mb4'MATSON',_utf8mb4'SML',_utf8mb4'ZIM',_utf8mb4'COSCO',_utf8mb4'EMC',_utf8mb4'HEDE',_utf8mb4'OTHER')))),
|
||||
CONSTRAINT chk_ocean_booking_customer CHECK ((customer_id > 0)),
|
||||
CONSTRAINT chk_ocean_booking_cargo CHECK (
|
||||
(package_unit IS NULL OR package_unit IN ('CARTONS','PCS','PALLETS'))
|
||||
AND (declared_package_count IS NULL OR declared_package_count BETWEEN 1 AND 999999999)
|
||||
AND (actual_package_count IS NULL OR actual_package_count BETWEEN 1 AND 999999999)
|
||||
AND (declared_gross_weight_kg IS NULL OR declared_gross_weight_kg > 0)
|
||||
AND (actual_gross_weight_kg IS NULL OR actual_gross_weight_kg > 0)
|
||||
AND (declared_volume_cbm IS NULL OR declared_volume_cbm > 0)
|
||||
AND (actual_volume_cbm IS NULL OR actual_volume_cbm > 0)),
|
||||
CONSTRAINT chk_ocean_booking_schedule CHECK (((estimated_sailing_at is null) or (estimated_arrival_at is null) or (estimated_arrival_at >= estimated_sailing_at))),
|
||||
CONSTRAINT chk_ocean_booking_status CHECK ((status in (_utf8mb4'DRAFT',_utf8mb4'RELEASED')))
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='独立海运订舱聚合;柜次仍由fl_container_booking承载';
|
||||
@@ -3584,8 +3564,6 @@ CREATE TABLE fl_ocean_booking_party (
|
||||
address varchar(1000) DEFAULT NULL COMMENT '关系方地址快照',
|
||||
contact_name varchar(128) DEFAULT NULL COMMENT '关系方联系人快照',
|
||||
contact_phone varchar(64) DEFAULT NULL COMMENT '关系方联系电话快照',
|
||||
contact_email VARCHAR(255) NULL COMMENT '关系方联系邮箱',
|
||||
tax_no VARCHAR(128) NULL COMMENT '关系方税号',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_ocean_booking_party (ocean_booking_id,party_type),
|
||||
CONSTRAINT fk_ocean_booking_party FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking (id) ON DELETE CASCADE,
|
||||
@@ -4877,7 +4855,7 @@ CREATE TABLE fin_document_sequence (
|
||||
current_value int unsigned NOT NULL COMMENT '该类型当日已分配的最大序号',
|
||||
PRIMARY KEY (sequence_key),
|
||||
CONSTRAINT chk_fin_document_sequence_value CHECK ((current_value > 0))
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='财务业务单号日序列;生成前缀加yyyyMMdd加四位当日序号';
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='Finance业务单号日序列;生成YYMMDD加四位当日序号';
|
||||
|
||||
CREATE TABLE fin_fee_source (
|
||||
id bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||
@@ -5004,7 +4982,7 @@ CREATE TABLE fin_receipt_bill (
|
||||
CREATE TABLE fin_recharge_request (
|
||||
id bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||
customer_id bigint unsigned NOT NULL COMMENT '客户ID和隔离键',
|
||||
request_no varchar(32) NOT NULL COMMENT '充值申请业务编号RCyyyyMMdd####',
|
||||
request_no varchar(32) NOT NULL COMMENT '充值申请业务编号RC-yyMMdd-####',
|
||||
amount decimal(20,2) NOT NULL COMMENT '申请充值金额',
|
||||
voucher_file_id bigint unsigned NOT NULL COMMENT '付款截图File ID',
|
||||
voucher_file_name varchar(255) NOT NULL COMMENT '付款截图文件名快照',
|
||||
@@ -5255,7 +5233,7 @@ CREATE TABLE fin_wallet_hold (
|
||||
CREATE TABLE fin_wallet_ledger (
|
||||
id bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||
customer_id bigint unsigned NOT NULL COMMENT '客户ID和隔离键',
|
||||
business_no varchar(32) NOT NULL COMMENT '账户变动业务编号ACyyyyMMdd####',
|
||||
business_no varchar(32) NOT NULL COMMENT '账户变动业务编号AC-yyMMdd-####',
|
||||
change_type varchar(16) NOT NULL COMMENT '变动类型:充值、扣款、冻结、解冻、消费或授信调整',
|
||||
amount decimal(20,2) NOT NULL COMMENT '带方向变动金额,充值为正、扣款为负',
|
||||
actual_balance_after decimal(20,2) NOT NULL COMMENT '变动后实际余额',
|
||||
@@ -6047,48 +6025,9 @@ CREATE TRIGGER trg_ocean_allocation_before_insert BEFORE INSERT ON fl_ocean_book
|
||||
END IF;
|
||||
END$$
|
||||
|
||||
CREATE TRIGGER trg_ocean_booking_before_update BEFORE UPDATE ON fl_ocean_booking FOR EACH ROW
|
||||
BEGIN
|
||||
-- 已放舱只允许预计到港、实货量及审计元数据变化,其余运输和预报快照保持冻结。
|
||||
IF OLD.status = 'RELEASED' AND NOT (
|
||||
(NEW.id <=> OLD.id)
|
||||
AND (NEW.booking_no <=> OLD.booking_no)
|
||||
AND (NEW.status <=> OLD.status)
|
||||
AND (NEW.customer_id <=> OLD.customer_id)
|
||||
AND (NEW.customer_name <=> OLD.customer_name)
|
||||
AND (NEW.channel_code <=> OLD.channel_code)
|
||||
AND (NEW.channel_name <=> OLD.channel_name)
|
||||
AND (NEW.warehouse_id <=> OLD.warehouse_id)
|
||||
AND (NEW.warehouse_name <=> OLD.warehouse_name)
|
||||
AND (NEW.bill_of_lading_no <=> OLD.bill_of_lading_no)
|
||||
AND (NEW.shipping_company_id <=> OLD.shipping_company_id)
|
||||
AND (NEW.shipping_company_name <=> OLD.shipping_company_name)
|
||||
AND (NEW.carrier_code <=> OLD.carrier_code)
|
||||
AND (NEW.booking_agent_id <=> OLD.booking_agent_id)
|
||||
AND (NEW.booking_agent_name <=> OLD.booking_agent_name)
|
||||
AND (NEW.vessel_name <=> OLD.vessel_name)
|
||||
AND (NEW.voyage_no <=> OLD.voyage_no)
|
||||
AND (NEW.departure_port_code <=> OLD.departure_port_code)
|
||||
AND (NEW.departure_port_name <=> OLD.departure_port_name)
|
||||
AND (NEW.destination_port_code <=> OLD.destination_port_code)
|
||||
AND (NEW.destination_port_name <=> OLD.destination_port_name)
|
||||
AND (NEW.place_of_delivery <=> OLD.place_of_delivery)
|
||||
AND (NEW.estimated_sailing_at <=> OLD.estimated_sailing_at)
|
||||
AND (NEW.remark <=> OLD.remark)
|
||||
AND (NEW.freight_term <=> OLD.freight_term)
|
||||
AND (NEW.service_term <=> OLD.service_term)
|
||||
AND (NEW.package_unit <=> OLD.package_unit)
|
||||
AND (NEW.cargo_description <=> OLD.cargo_description)
|
||||
AND (NEW.marks_and_nos <=> OLD.marks_and_nos)
|
||||
AND (NEW.declared_package_count <=> OLD.declared_package_count)
|
||||
AND (NEW.declared_gross_weight_kg <=> OLD.declared_gross_weight_kg)
|
||||
AND (NEW.declared_volume_cbm <=> OLD.declared_volume_cbm)
|
||||
AND (NEW.created_by <=> OLD.created_by)
|
||||
AND (NEW.created_by_name <=> OLD.created_by_name)
|
||||
AND (NEW.created_at <=> OLD.created_at)
|
||||
AND NEW.version = OLD.version + 1
|
||||
) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='已放舱仅允许维护预计到港及补录实货量';
|
||||
CREATE TRIGGER trg_ocean_booking_before_update BEFORE UPDATE ON fl_ocean_booking FOR EACH ROW BEGIN
|
||||
IF OLD.status = 'RELEASED' THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='已放舱订舱不可修改';
|
||||
END IF;
|
||||
IF OLD.status <> NEW.status AND NOT (
|
||||
(OLD.status='DRAFT' AND NEW.status='RELEASED')) THEN
|
||||
@@ -6902,18 +6841,6 @@ INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id,
|
||||
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107361,0,CURRENT_TIMESTAMP(3));
|
||||
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107362,0,'PLATFORM','BUTTON',5107360,'platform:fleet:operations:edit','更正出车实测记录',NULL,'PUT',992,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
|
||||
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107362,0,CURRENT_TIMESTAMP(3));
|
||||
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107390,0,'PLATFORM','MENU',0,'platform:fleet:owned-business:list','自有业务','/overseas-master-data/owned-business','GET',993,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
|
||||
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107390,0,CURRENT_TIMESTAMP(3));
|
||||
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107391,0,'PLATFORM','BUTTON',5107390,'platform:fleet:owned-business:edit','登记自有业务运营信息',NULL,'PUT',994,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
|
||||
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107391,0,CURRENT_TIMESTAMP(3));
|
||||
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107392,0,'PLATFORM','BUTTON',5107390,'platform:fleet:owned-business:upload','上传自有业务照片',NULL,'POST',995,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
|
||||
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107392,0,CURRENT_TIMESTAMP(3));
|
||||
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107400,0,'PLATFORM','MENU',0,'platform:fleet:outsourced-business:list','外配业务','/overseas-master-data/outsourced-business','GET',996,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
|
||||
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107400,0,CURRENT_TIMESTAMP(3));
|
||||
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107401,0,'PLATFORM','BUTTON',5107400,'platform:fleet:outsourced-business:edit','登记外配业务承运信息',NULL,'PUT',997,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
|
||||
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107401,0,CURRENT_TIMESTAMP(3));
|
||||
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107402,0,'PLATFORM','BUTTON',5107400,'platform:fleet:outsourced-business:upload','上传外配业务照片',NULL,'POST',998,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
|
||||
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107402,0,CURRENT_TIMESTAMP(3));
|
||||
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107370,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:handover','在途车辆司机交接',NULL,'POST',1000,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
|
||||
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107370,0,CURRENT_TIMESTAMP(3));
|
||||
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107380,0,'PLATFORM','API',5105000,'platform:finance:vehicle-cost:list','查看公司车辆成本','/api/platform/finance/v1/vehicle-costs','GET',1010,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
|
||||
@@ -8443,24 +8370,4 @@ INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at)
|
||||
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102213,0,'PLATFORM','BUTTON',5102200,'platform:firstleg:terminal-drayage:direct-unload:confirm','登记直送目的仓实际卸货',NULL,'POST',60,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
|
||||
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102213,0,CURRENT_TIMESTAMP(3));
|
||||
|
||||
USE baiyue_order;
|
||||
CREATE TABLE ord_document_sequence (
|
||||
sequence_key VARCHAR(32) NOT NULL COMMENT '业务前缀与北京时间日期组成的全局序列键',
|
||||
current_value BIGINT NOT NULL COMMENT '该前缀当日已分配流水,已分配号不回收',
|
||||
PRIMARY KEY (sequence_key),
|
||||
CONSTRAINT chk_ord_document_sequence_value CHECK (current_value > 0)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='业务单据每日独立原子流水';
|
||||
|
||||
USE baiyue_warehouse;
|
||||
CREATE TABLE wh_document_sequence (
|
||||
sequence_key VARCHAR(32) NOT NULL COMMENT '业务前缀与北京时间日期组成的全局序列键',
|
||||
current_value BIGINT NOT NULL COMMENT '该前缀当日已分配流水,已分配号不回收',
|
||||
PRIMARY KEY (sequence_key),
|
||||
CONSTRAINT chk_wh_document_sequence_value CHECK (current_value > 0)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='业务单据每日独立原子流水';
|
||||
|
||||
-- 海外费用查询菜单;编辑、审核继续使用独立财务按钮授权。
|
||||
USE baiyue_iam;
|
||||
INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,created_at,updated_at,deleted) VALUES (5117200,0,'PLATFORM','MENU',0,'platform:overseas-fee:list','海外费用管理','/overseas-business/fees','GET',890,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
|
||||
INSERT INTO iam_role_permission (role_id,permission_id,tenant_id,created_at) VALUES (4000001,5117200,0,CURRENT_TIMESTAMP(3));
|
||||
SELECT 'PASS' AS initialization_status, 'CN' AS region, 229 AS initialized_tables;
|
||||
SELECT 'PASS' AS initialization_status, 'CN' AS region, 227 AS initialized_tables;
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
# 排车运输接入数据库变更汇总
|
||||
|
||||
执行文件:[lastleg-dispatch-transport-tasks.sql](lastleg-dispatch-transport-tasks.sql)。目标为 **MySQL 8 / baiyue_lastleg**,文件内已有 `USE baiyue_lastleg`。可整份重复执行;按 information_schema 判断字段及索引,按 IF NOT EXISTS 创建表。已在本机既有业务数据环境连续执行两次通过;未在远端环境执行。
|
||||
|
||||
| 对象 | 新增或调整 | 用途 |
|
||||
|---|---|---|
|
||||
| ll_dispatch_order.task_type | 新增 VARCHAR(32) NOT NULL DEFAULT DELIVERY | 区分仓库派送、到仓取货、本土卡车;旧单保留派送类型 |
|
||||
| ll_dispatch_truck_task | 新表;dispatch_no、trip_no、customer_id、order_no、active_order | 真实排车关联原单;运输记录唯一、有效原单唯一;取消释放有效键,完成保留 |
|
||||
| ll_dispatch_transport_create | 新表;trip_no、request_hash | 原安排请求摘要,避免重复建单及同键更换原单 |
|
||||
| ll_dispatch_transport_command | 新表;dispatch_no、request_key、request_hash、evidence_json、created_at | 执行命令幂等、已确认完成凭证持久化 |
|
||||
| ll_dispatch_daily_sequence | 既有正式日流水表,缺失时补建 | 统一 PC 日期与四位流水;不修改历史单号 |
|
||||
| ll_dispatch_order.customer_id | 仅旧库存在且为 NOT NULL 时改为允许 NULL | 兼容整车跨客户主单;保留原值,不回填或置空 |
|
||||
| ll_us_truck_order.contact_name / contact_info / requested_date | 仅旧库仍为 NOT NULL 时改为允许 NULL | 兼容现有原单起终点联系人及取送时间模型,保留历史字段和数据 |
|
||||
| ll_fleet_external_booking.source_looseboard_order_id | 缺失时补 BIGINT NULL | 当前承运占用查询依赖,等同 V33;不创建散板业务 |
|
||||
| ll_fleet_external_booking.idx_booking_looseboard_source | 缺失时补客户、原散板ID、状态联合索引 | 原单承运占用查询 |
|
||||
|
||||
所有新增表、字段均有中文注释。SQL不创建账号、不改角色权限、不删除表或记录、不改库存及历史单号,也不回填旧任务。
|
||||
|
||||
前置:目标库已具备当前整车排车、FleetTrip、仓储转运取货及本土卡车业务结构;本文件是本次运输接入增量,不替代这些模块的完整升级。执行完成后配套发布 Lastleg 和 Admin。迁移系统记录为 V32,承运占用依赖结构为 V33;直接交付执行时使用本份汇总 SQL 即可,不再重复手动执行两个版本文件。
|
||||
|
||||
本机验收中发现的旧字段约束与缺少日流水表已一并纳入。业务数据由正式 Gateway HTTP 创建,结构验证未写业务SQL。
|
||||
+23
-261
@@ -1,5 +1,4 @@
|
||||
-- OVERSEAS 当前纯空库初始化入口(文件名沿用原入口,不提供增量升级语义)。
|
||||
-- 海外费用资金、仓租四表、审核人名称快照、采购退款请求幂等字段及唯一索引均在本文件最终建表定义中维护。
|
||||
-- MySQL 8.0+;只允许目标测试环境的服务 schema 为空,已有表立即报错;禁止 --force/遇错继续。
|
||||
-- 字段、索引、约束及注释直接定义在 CREATE TABLE 内;不回填、不清理、不改造存量。
|
||||
-- 国内 IAM 管理两区域权限;海外不访问国内库。执行方法及系统种子清单见同目录 README.md。
|
||||
@@ -315,7 +314,7 @@ CREATE TABLE ll_destuff_pallet_cost_allocation (
|
||||
customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID',
|
||||
actual_volume_cbm decimal(20,6) NOT NULL COMMENT '客户实收体积',
|
||||
allocated_amount decimal(20,2) NOT NULL COMMENT '客户分摊金额',
|
||||
fee_source_id bigint DEFAULT NULL COMMENT '人工审核后关联的海外费用来源ID',
|
||||
fee_source_id bigint NOT NULL COMMENT '海外费用来源ID',
|
||||
created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_ll_destuff_customer (cost_id,customer_id),
|
||||
@@ -417,8 +416,6 @@ CREATE TABLE ll_dispatch_item (
|
||||
CREATE TABLE ll_dispatch_order (
|
||||
id BIGINT NOT NULL COMMENT '整车排车主单内部主键',
|
||||
dispatch_no VARCHAR(64) NOT NULL COMMENT '真实排车业务号',
|
||||
task_type VARCHAR(32) NOT NULL DEFAULT 'DELIVERY' COMMENT '任务类型:仓库派送、到仓取货、本土卡车',
|
||||
publish_looseboard_demand TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否向内部散板需求池发布余位',
|
||||
trip_id BIGINT NOT NULL COMMENT '本趟唯一车辆资源记录ID',
|
||||
overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '真实起运海外仓ID',
|
||||
overseas_warehouse_code VARCHAR(128) NULL COMMENT '起运海外仓编码快照',
|
||||
@@ -431,7 +428,6 @@ CREATE TABLE ll_dispatch_order (
|
||||
review_state VARCHAR(32) NOT NULL DEFAULT 'DRAFT' COMMENT '审核子流程当前状态',
|
||||
review_round BIGINT NOT NULL DEFAULT 0 COMMENT '当前审核轮次',
|
||||
review_basis_hash CHAR(64) NULL COMMENT '本审核轮次核对的货物和资源依据摘要',
|
||||
exceptions_json JSON NULL COMMENT '派送异常登记记录及图片引用,不改变派送及库存状态',
|
||||
dispatch_review_json JSON NULL COMMENT '当前调度审核事实快照',
|
||||
loading_review_json JSON NULL COMMENT '当前理货审核事实快照',
|
||||
resources_json JSON NULL COMMENT '本趟主档或临时车辆司机的真实资源快照',
|
||||
@@ -948,7 +944,6 @@ CREATE TABLE ll_finance_fee_source (
|
||||
warehouse_id bigint DEFAULT NULL COMMENT '关联海外仓ID',
|
||||
fee_item_code varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '费用项目编码快照',
|
||||
fee_item_name varchar(200) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '费用项目名称快照',
|
||||
fee_remark varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '费用说明,补收或重复作业原因',
|
||||
direction varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'RECEIVABLE应收或PAYABLE应付',
|
||||
supplier_id bigint DEFAULT NULL COMMENT '应付供应商ID',
|
||||
supplier_name varchar(200) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '应付供应商名称快照',
|
||||
@@ -964,7 +959,7 @@ CREATE TABLE ll_finance_fee_source (
|
||||
amount decimal(20,6) NOT NULL COMMENT '人民币结算金额',
|
||||
original_currency char(3) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '业务原币种,支持CNY或USD',
|
||||
original_amount decimal(20,6) NOT NULL COMMENT '业务原币金额',
|
||||
exchange_rate decimal(20,6) DEFAULT NULL COMMENT '原币折算人民币汇率,人民币固定为1,自动零元仓租可空',
|
||||
exchange_rate decimal(20,6) NOT NULL COMMENT '原币折算人民币汇率,人民币固定为1',
|
||||
exchange_rate_month char(7) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '汇率月份yyyy-MM',
|
||||
chargeable_quantity decimal(20,6) DEFAULT NULL COMMENT '计费数量,如箱数、托数、体积或天数',
|
||||
quantity_unit varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '计费数量单位',
|
||||
@@ -976,7 +971,6 @@ CREATE TABLE ll_finance_fee_source (
|
||||
source_status varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'ACTIVE、SUPERSEDED或VOIDED',
|
||||
review_reason varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核或作废原因',
|
||||
reviewer_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核人ID',
|
||||
reviewer_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核人名称快照',
|
||||
reviewed_at datetime(3) DEFAULT NULL COMMENT '审核时间',
|
||||
created_by varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '创建人ID',
|
||||
updated_by varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '最后更新人ID',
|
||||
@@ -987,8 +981,7 @@ CREATE TABLE ll_finance_fee_source (
|
||||
KEY idx_ll_finance_fee_customer (customer_id,source_status,created_at),
|
||||
KEY idx_ll_finance_fee_business (business_type,business_no,created_at),
|
||||
KEY idx_ll_finance_fee_container_customer (container_booking_id,customer_id,source_status,created_at),
|
||||
CONSTRAINT chk_ll_finance_fee_exchange_rate CHECK((exchange_rate IS NOT NULL AND exchange_rate>0) OR (exchange_rate IS NULL AND amount=0 AND original_amount=0 AND created_by='STORAGE_RENT' AND fee_item_code='FEE_TYPE_077' AND direction='RECEIVABLE')),
|
||||
CONSTRAINT chk_ll_finance_fee_amount CHECK(amount>0 OR (amount=0 AND original_amount=0 AND created_by='STORAGE_RENT' AND fee_item_code='FEE_TYPE_077' AND direction='RECEIVABLE')),
|
||||
CONSTRAINT chk_ll_finance_fee_amount CHECK ((amount > 0)),
|
||||
CONSTRAINT chk_ll_finance_fee_currency CHECK ((currency = _utf8mb4'CNY')),
|
||||
CONSTRAINT chk_ll_finance_fee_direction CHECK ((direction in (_utf8mb4'RECEIVABLE',_utf8mb4'PAYABLE'))),
|
||||
CONSTRAINT chk_ll_finance_fee_original_currency CHECK ((original_currency in (_utf8mb4'CNY',_utf8mb4'USD'))),
|
||||
@@ -1866,11 +1859,11 @@ CREATE TABLE ll_order_manual_node_history (
|
||||
KEY idx_ll_order_manual_node_history (customer_id,order_id,created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='尾程订单人工节点覆盖不可变历史';
|
||||
|
||||
CREATE TABLE ll_dispatch_daily_sequence (
|
||||
business_date DATE NOT NULL COMMENT '排车编号业务日期(Asia/Shanghai)',
|
||||
last_sequence INT NOT NULL COMMENT '当日已分配排车流水号,范围0001至9999',
|
||||
PRIMARY KEY(business_date)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车单号每日独立原子流水';
|
||||
CREATE TABLE ll_order_no_daily_sequence (
|
||||
business_date date NOT NULL COMMENT '业务日期(Asia/Shanghai)',
|
||||
last_sequence int NOT NULL COMMENT '当日已分配尾程单流水号',
|
||||
PRIMARY KEY (business_date)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='尾程单号按日原子流水';
|
||||
|
||||
CREATE TABLE ll_order_operation_log (
|
||||
id bigint NOT NULL COMMENT '操作记录主键',
|
||||
@@ -2078,7 +2071,6 @@ CREATE TABLE ll_overseas_procurement_fee (
|
||||
settled_command_event_id VARCHAR(64) NULL COMMENT '成功实扣或退款的资金命令事件',
|
||||
source_fee_id BIGINT NULL COMMENT '已实扣费用来源ID',
|
||||
parent_fee_id BIGINT NULL COMMENT '原费用或预估引用',
|
||||
idempotency_key VARCHAR(128) NULL COMMENT '退款请求幂等键,独立于业务单号',
|
||||
request_hash CHAR(64) NULL COMMENT '确认请求摘要',
|
||||
id bigint NOT NULL COMMENT '主键ID',
|
||||
order_id bigint NOT NULL COMMENT '采购订单ID',
|
||||
@@ -2100,7 +2092,6 @@ CREATE TABLE ll_overseas_procurement_fee (
|
||||
updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
||||
CONSTRAINT chk_procurement_confirmation_origin CHECK(confirmation_origin IN('BUSINESS','FINANCIAL')),
|
||||
UNIQUE KEY uk_ll_procurement_fee_version(order_id,purpose,fee_item_code,fee_version),
|
||||
UNIQUE KEY uk_ll_procurement_refund_request(order_id,purpose,idempotency_key),
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_ll_procurement_fee_no (fee_no),
|
||||
KEY idx_ll_procurement_fee_order (customer_id,order_id,created_at),
|
||||
@@ -2126,7 +2117,6 @@ CREATE TABLE ll_overseas_procurement_file (
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外采购签收文件及费用凭证';
|
||||
|
||||
CREATE TABLE ll_overseas_procurement_order (
|
||||
procurement_direction VARCHAR(32) NOT NULL DEFAULT 'OVERSEAS_TO_DOMESTIC' COMMENT '采购方向:海外采购回国内/国内采购发海外',
|
||||
id bigint NOT NULL COMMENT '主键ID',
|
||||
customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID',
|
||||
customer_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '客户名称快照',
|
||||
@@ -2372,6 +2362,14 @@ CREATE TABLE ll_pda_stocktake_task (
|
||||
KEY idx_ll_pda_stocktake_list (overseas_warehouse_id,status,created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外PDA库存盘点任务';
|
||||
|
||||
CREATE TABLE ll_procurement_daily_sequence (
|
||||
customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID',
|
||||
business_date date NOT NULL COMMENT '北京时间业务日期',
|
||||
sequence_value int NOT NULL COMMENT '客户当日已分配最大序号',
|
||||
PRIMARY KEY (customer_id,business_date),
|
||||
CONSTRAINT chk_ll_procurement_sequence CHECK ((sequence_value between 1 and 9999))
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外采购客户日序列';
|
||||
|
||||
CREATE TABLE ll_receiving_container_operation (
|
||||
id bigint NOT NULL COMMENT '柜号收货操作ID',
|
||||
session_id bigint NOT NULL COMMENT '柜号签收会话ID',
|
||||
@@ -3218,7 +3216,6 @@ CREATE TABLE ll_fleet_external_booking (
|
||||
binding_key VARCHAR(128) NULL COMMENT '关联发运请求键',
|
||||
binding_hash CHAR(64) NULL COMMENT '关联发运摘要',
|
||||
offer_id BIGINT NULL COMMENT '来源运力发布,普通车次预订为空',
|
||||
source_looseboard_order_id BIGINT NULL COMMENT '客户原始散板商业单ID,取消后保留来源',
|
||||
sales_owner_user_id VARCHAR(64) NULL COMMENT '登录身份冻结的销售负责人',
|
||||
business_type VARCHAR(32) NULL COMMENT '本土商业类型LOCAL_LOOSEBOARD或US_TRUCK',
|
||||
delivery_mode VARCHAR(32) NULL COMMENT '客户送仓或人工安排提货回仓',
|
||||
@@ -3233,7 +3230,6 @@ CREATE TABLE ll_fleet_external_booking (
|
||||
KEY idx_fleet_booking_sales(sales_owner_user_id,offer_id,id),
|
||||
CONSTRAINT fk_fleet_booking_offer FOREIGN KEY(offer_id) REFERENCES ll_fleet_capacity_offer(id),
|
||||
CONSTRAINT chk_fleet_booking_expected_pallet CHECK(expected_pallet_count IS NULL OR expected_pallet_count > 0),
|
||||
KEY idx_booking_looseboard_source (customer_id,source_looseboard_order_id,status),
|
||||
PRIMARY KEY(id),
|
||||
UNIQUE KEY uk_fleet_booking_no(booking_no),
|
||||
UNIQUE KEY uk_fleet_booking_create(trip_id,created_by,idempotency_key),
|
||||
@@ -4598,7 +4594,6 @@ CREATE TABLE ll_return_sku_conversion (
|
||||
quantity BIGINT NOT NULL COMMENT '实际完成转换件数',
|
||||
status VARCHAR(32) NOT NULL COMMENT 'APPLIED待客户确认/CONFIRMED已确认/REVERSED退回冲回',
|
||||
ownership_json LONGTEXT NOT NULL COMMENT '本次转换件数的来源退货单快照,供精确冲回',
|
||||
storage_rent_shares_json LONGTEXT NULL COMMENT '原仓租批次迁移快照',
|
||||
completed_at DATETIME(3) NOT NULL COMMENT '实物SKU转换时间',
|
||||
confirmed_at DATETIME(3) NULL COMMENT '客户确认时间',
|
||||
reversed_at DATETIME(3) NULL COMMENT '退回冲回时间',
|
||||
@@ -4827,6 +4822,12 @@ CREATE TABLE ll_appointment_work_history (
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='预约工作事项历史与幂等命令';
|
||||
|
||||
-- 海外业务短单号分配时钟。
|
||||
CREATE TABLE ll_compact_number_clock (
|
||||
number_key VARCHAR(16) NOT NULL COMMENT '业务前缀和客户名称缩写',
|
||||
last_second BIGINT NOT NULL COMMENT '最后分配的Unix逻辑秒',
|
||||
PRIMARY KEY (number_key)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外业务短单号分配时钟';
|
||||
|
||||
-- 仓储转运上门取货表归尾程服务,仅用于空库初始化。
|
||||
USE baiyue_lastleg;
|
||||
CREATE TABLE ll_fleet_pickup_plan (
|
||||
@@ -4881,243 +4882,4 @@ CREATE TABLE ll_fleet_pickup_history (
|
||||
KEY idx_pickup_history_trip(trip_no,id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='取货不可变操作历史';
|
||||
|
||||
-- 自有业务仅保存原排车的归属证据和附加运营信息,不复制履约主单。
|
||||
CREATE TABLE ll_fleet_owned_business_ownership (
|
||||
dispatch_id BIGINT NOT NULL COMMENT '原排车内部主键',
|
||||
resource_mode VARCHAR(16) NOT NULL COMMENT '原排车资源模式MASTER或EXTERNAL',
|
||||
vehicle_id BIGINT NULL COMMENT '真实车辆档案主键,临时外配为空',
|
||||
vehicle_ownership VARCHAR(16) NOT NULL COMMENT '装车完成时车辆归属OWNED或DISPATCHED',
|
||||
vehicle_record_version BIGINT NULL COMMENT '捕获归属时真实车辆档案版本',
|
||||
ownership_captured_at DATETIME(3) NOT NULL COMMENT '归属捕获UTC时间',
|
||||
loading_completion_hash VARCHAR(128) NOT NULL COMMENT '原排车装车确认事实摘要',
|
||||
loading_completed_at DATETIME(3) NOT NULL COMMENT '原排车实际装车完成UTC时间',
|
||||
PRIMARY KEY (dispatch_id),
|
||||
KEY idx_owned_business_vehicle (vehicle_ownership,vehicle_id),
|
||||
CONSTRAINT fk_owned_business_ownership_dispatch FOREIGN KEY (dispatch_id) REFERENCES ll_dispatch_order(id),
|
||||
CONSTRAINT ck_owned_business_ownership CHECK (vehicle_ownership IN ('OWNED','DISPATCHED')),
|
||||
CONSTRAINT ck_owned_business_resource_mode CHECK (
|
||||
(resource_mode='MASTER' AND vehicle_id IS NOT NULL AND vehicle_record_version IS NOT NULL AND vehicle_record_version>=0)
|
||||
OR (resource_mode='EXTERNAL' AND vehicle_id IS NULL AND vehicle_record_version IS NULL AND vehicle_ownership='DISPATCHED'))
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='原排车装车完成时的车辆归属证据';
|
||||
|
||||
CREATE TABLE ll_fleet_owned_business_record (
|
||||
id BIGINT NOT NULL COMMENT '附加运营记录内部主键',
|
||||
dispatch_id BIGINT NOT NULL COMMENT '所属原排车内部主键',
|
||||
record_version BIGINT NOT NULL COMMENT '附加运营记录并发版本,独立于原排车版本',
|
||||
currency VARCHAR(3) NULL COMMENT '整车运营金额币种CNY或USD,未登记为空',
|
||||
total_amount DECIMAL(18,2) NULL COMMENT '人工登记整车总金额,不自动形成账务费用',
|
||||
actual_distance_miles DECIMAL(12,3) NULL COMMENT '实际行驶英里数,未登记为空',
|
||||
fuel_consumption_litres_per_100km DECIMAL(12,3) NULL COMMENT '人工登记油耗率,单位升每百公里',
|
||||
arrivals_json JSON NOT NULL COMMENT '按原目的地键记录的实际到达UTC时间',
|
||||
photos_json JSON NOT NULL COMMENT '七个照片框当前已绑定文件的分类快照',
|
||||
created_by VARCHAR(64) NOT NULL COMMENT '首次登记操作人标识',
|
||||
updated_by VARCHAR(64) NOT NULL COMMENT '最近登记操作人标识',
|
||||
created_at DATETIME(3) NOT NULL COMMENT '首次登记UTC时间',
|
||||
updated_at DATETIME(3) NOT NULL COMMENT '最近登记UTC时间',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_owned_business_record_dispatch (dispatch_id),
|
||||
CONSTRAINT fk_owned_business_record_dispatch FOREIGN KEY (dispatch_id) REFERENCES ll_dispatch_order(id),
|
||||
CONSTRAINT ck_owned_business_record_version CHECK (record_version>=0),
|
||||
CONSTRAINT ck_owned_business_money CHECK (
|
||||
(currency IS NULL AND total_amount IS NULL)
|
||||
OR (currency IS NOT NULL AND currency IN ('CNY','USD') AND total_amount IS NOT NULL AND total_amount>=0)),
|
||||
CONSTRAINT ck_owned_business_distance CHECK (actual_distance_miles IS NULL OR actual_distance_miles>=0),
|
||||
CONSTRAINT ck_owned_business_fuel CHECK (fuel_consumption_litres_per_100km IS NULL OR fuel_consumption_litres_per_100km>=0)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='原排车自有业务附加运营信息';
|
||||
|
||||
CREATE TABLE ll_fleet_owned_business_command (
|
||||
id BIGINT NOT NULL COMMENT '附加运营命令内部主键',
|
||||
dispatch_id BIGINT NOT NULL COMMENT '所属原排车内部主键',
|
||||
operator_id VARCHAR(64) NOT NULL COMMENT '原命令操作人标识',
|
||||
idempotency_key VARCHAR(128) COLLATE utf8mb4_bin NOT NULL COMMENT '同一原排车内唯一请求键,不按操作者分组',
|
||||
action VARCHAR(32) NOT NULL COMMENT '运营登记或照片上传绑定动作',
|
||||
expected_version BIGINT NULL COMMENT '原请求附加版本,纯文件上传可为空',
|
||||
request_hash CHAR(64) NOT NULL COMMENT '原操作者动作版本与完整请求摘要',
|
||||
request_json JSON NOT NULL COMMENT '不含文件内容或凭据的完整原请求',
|
||||
status VARCHAR(24) NOT NULL COMMENT 'PENDING或SUCCEEDED或REJECTED或UNKNOWN_RESULT',
|
||||
result_json JSON NULL COMMENT '原命令已知成功结果或明确拒绝事实',
|
||||
error_code VARCHAR(64) NULL COMMENT '明确拒绝或未知结果诊断码',
|
||||
created_at DATETIME(3) NOT NULL COMMENT '命令意图创建UTC时间',
|
||||
updated_at DATETIME(3) NOT NULL COMMENT '命令结果更新UTC时间',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_owned_business_command_key (dispatch_id,idempotency_key),
|
||||
CONSTRAINT fk_owned_business_command_dispatch FOREIGN KEY (dispatch_id) REFERENCES ll_dispatch_order(id),
|
||||
CONSTRAINT ck_owned_business_command_version CHECK (expected_version IS NULL OR expected_version>=0),
|
||||
CONSTRAINT ck_owned_business_command_status CHECK (status IN ('PENDING','SUCCEEDED','REJECTED','UNKNOWN_RESULT'))
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='原排车附加运营写命令及可恢复结果';
|
||||
|
||||
CREATE TABLE ll_fleet_owned_business_history (
|
||||
id BIGINT NOT NULL COMMENT '附加运营历史内部主键',
|
||||
dispatch_id BIGINT NOT NULL COMMENT '所属原排车内部主键',
|
||||
record_version BIGINT NOT NULL COMMENT '成功写入后的附加记录版本',
|
||||
action VARCHAR(32) NOT NULL COMMENT '运营登记或照片绑定动作',
|
||||
operator_id VARCHAR(64) NOT NULL COMMENT '真实操作人标识',
|
||||
reason VARCHAR(1000) NULL COMMENT '人工填写的登记或更正说明',
|
||||
snapshot_json JSON NOT NULL COMMENT '操作后附加运营信息与分类照片不可变快照',
|
||||
occurred_at DATETIME(3) NOT NULL COMMENT '操作UTC时间',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_owned_business_history_version (dispatch_id,record_version),
|
||||
CONSTRAINT fk_owned_business_history_dispatch FOREIGN KEY (dispatch_id) REFERENCES ll_dispatch_order(id),
|
||||
CONSTRAINT ck_owned_business_history_version CHECK (record_version>0)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='原排车自有业务附加运营不可变历史';
|
||||
|
||||
-- 运输原单接入现有排车,不产生仓内库存事实。
|
||||
USE baiyue_lastleg;
|
||||
CREATE TABLE ll_dispatch_truck_task (
|
||||
dispatch_no VARCHAR(64) NOT NULL COMMENT '排车业务号',
|
||||
trip_no VARCHAR(64) NOT NULL COMMENT '本次运输资源记录号',
|
||||
customer_id VARCHAR(128) NOT NULL COMMENT '本土卡车原单客户标识',
|
||||
order_no VARCHAR(64) NOT NULL COMMENT '本土卡车原单业务号',
|
||||
active_order VARCHAR(193) NULL COMMENT '有效原单唯一键;取消置空,完成保留',
|
||||
PRIMARY KEY(dispatch_no), UNIQUE KEY uk_transport_trip(trip_no), UNIQUE KEY uk_transport_source(active_order),
|
||||
KEY idx_transport_order(customer_id,order_no)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车与本土卡车原单关联';
|
||||
CREATE TABLE ll_dispatch_transport_create (
|
||||
trip_no VARCHAR(64) NOT NULL PRIMARY KEY COMMENT '本次运输资源记录号',
|
||||
request_hash CHAR(64) NOT NULL COMMENT '首次安排请求摘要'
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='本土卡车排车创建请求账本';
|
||||
CREATE TABLE ll_dispatch_transport_command (
|
||||
dispatch_no VARCHAR(64) NOT NULL COMMENT '排车业务号',
|
||||
request_key VARCHAR(128) NOT NULL COMMENT '客户端幂等请求键',
|
||||
request_hash CHAR(64) NOT NULL COMMENT '执行命令请求摘要',
|
||||
evidence_json JSON NULL COMMENT '已确认运输完成凭证标识列表',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '业务命令提交时间',
|
||||
PRIMARY KEY(dispatch_no,request_key)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车运输执行命令与凭证账本';
|
||||
|
||||
USE baiyue_lastleg;
|
||||
CREATE TABLE ll_document_sequence (
|
||||
sequence_key VARCHAR(32) NOT NULL COMMENT '业务前缀与北京时间日期组成的全局序列键',
|
||||
current_value BIGINT NOT NULL COMMENT '该前缀当日已分配流水,已分配号不回收',
|
||||
PRIMARY KEY (sequence_key),
|
||||
CONSTRAINT chk_ll_document_sequence_value CHECK (current_value > 0)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='业务单据每日独立原子流水';
|
||||
|
||||
-- 海外费用资金及实收仓租。
|
||||
-- 海外人工费用资金编排。金额与余额以 Finance 钱包为唯一事实,本表仅记录指令及回执状态。
|
||||
CREATE TABLE ll_finance_fee_funding (
|
||||
source_fee_id BIGINT NOT NULL COMMENT '关联海外费用稳定来源ID',
|
||||
customer_id VARCHAR(128) NOT NULL COMMENT '费用所属客户隔离标识',
|
||||
collection_mode VARCHAR(16) NOT NULL COMMENT '客户收费方式:BILL账单结算,WALLET余额及授信',
|
||||
wallet_status VARCHAR(32) NOT NULL COMMENT '资金进度:待冻结、已冻结、待扣款、已扣款、待释放及失败状态',
|
||||
wallet_business_no VARCHAR(128) NULL COMMENT '本次冻结使用的独立业务编号,重提使用新编号',
|
||||
command_event_id VARCHAR(64) NULL COMMENT '当前待处理资金指令事件ID',
|
||||
wallet_hold_id VARCHAR(64) NULL COMMENT 'Finance确认的资金冻结记录ID',
|
||||
wallet_ledger_id VARCHAR(64) NULL COMMENT 'Finance确认的已扣资金流水ID',
|
||||
command_version BIGINT NOT NULL DEFAULT 0 COMMENT '资金指令单调递增版本,防止重试重复入账',
|
||||
error_message VARCHAR(500) NULL COMMENT '最新资金失败原因,成功后清空',
|
||||
command_history JSON NOT NULL COMMENT '已发资金指令及核验结果哈希,识别重复和错序回执',
|
||||
created_at DATETIME(3) NOT NULL COMMENT '资金编排创建UTC时间',
|
||||
updated_at DATETIME(3) NOT NULL COMMENT '资金编排最近变更UTC时间',
|
||||
PRIMARY KEY (source_fee_id),
|
||||
KEY idx_ll_fee_funding_customer_state (customer_id, wallet_status),
|
||||
KEY idx_ll_fee_funding_command (command_event_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='海外人工费用资金冻结、审核扣款及释放编排';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_storage_rent_inbound (
|
||||
receipt_id bigint NOT NULL COMMENT '实际入库凭证主键,防止重复累计计租数量',
|
||||
lot_id bigint NOT NULL COMMENT '头程同单同仓计租批次主键',
|
||||
quantity decimal(24,12) NOT NULL COMMENT '本次实收追加计租箱数',
|
||||
created_at datetime(3) NOT NULL COMMENT '计租登记时间,采用协调世界时',
|
||||
PRIMARY KEY (receipt_id),
|
||||
KEY idx_storage_rent_inbound_lot (lot_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='头程实收计租幂等登记';
|
||||
|
||||
CREATE TABLE ll_storage_rent_lot (
|
||||
id bigint NOT NULL COMMENT '原批次主键',
|
||||
source_key varchar(80) NOT NULL COMMENT '实收来源幂等键',
|
||||
customer_id varchar(32) NOT NULL COMMENT '客户标识',
|
||||
customer_name varchar(255) DEFAULT NULL COMMENT '客户名称',
|
||||
warehouse_id varchar(32) NOT NULL COMMENT '海外仓标识',
|
||||
warehouse_name varchar(255) DEFAULT NULL COMMENT '海外仓名称',
|
||||
business_type varchar(32) NOT NULL COMMENT '原入库业务类型',
|
||||
business_id bigint NOT NULL COMMENT '原入库业务主键',
|
||||
business_no varchar(128) NOT NULL COMMENT '原入库业务单号',
|
||||
order_id bigint DEFAULT NULL COMMENT '尾程订单主键',
|
||||
inbound_code varchar(128) DEFAULT NULL COMMENT '入仓码',
|
||||
balance_id bigint DEFAULT NULL COMMENT '普通库存余额主键',
|
||||
batch_no varchar(128) DEFAULT NULL COMMENT '原实收批次',
|
||||
sku_code varchar(128) DEFAULT NULL COMMENT '商品编码',
|
||||
rule_code varchar(32) NOT NULL COMMENT '仓租规则',
|
||||
original_quantity decimal(24,12) NOT NULL COMMENT '实收原始数量',
|
||||
remaining_quantity decimal(24,12) NOT NULL COMMENT '未出库原始数量',
|
||||
original_units decimal(24,12) DEFAULT NULL COMMENT '原批次计费体积或托数',
|
||||
inbound_at datetime(3) NOT NULL COMMENT '实际入库时间',
|
||||
created_at datetime(3) NOT NULL COMMENT '创建时间',
|
||||
updated_at datetime(3) NOT NULL COMMENT '更新时间',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_storage_rent_lot_source (source_key),
|
||||
KEY idx_storage_rent_stock (balance_id,batch_no),
|
||||
KEY idx_storage_rent_customer (customer_id,warehouse_id,inbound_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='实收计租原批次';
|
||||
|
||||
CREATE TABLE ll_storage_rent_share (
|
||||
balance_id bigint DEFAULT NULL COMMENT '普通库存当前余额,退货箱份额为空',
|
||||
batch_no varchar(128) DEFAULT NULL COMMENT '普通库存当前批次,换标搬移保持原计租批次引用',
|
||||
id bigint NOT NULL COMMENT '份额主键',
|
||||
lot_id bigint NOT NULL COMMENT '原计租批次主键',
|
||||
customer_id varchar(32) NOT NULL COMMENT '客户标识',
|
||||
warehouse_id varchar(32) NOT NULL COMMENT '海外仓标识',
|
||||
carton_mark varchar(255) DEFAULT NULL COMMENT '当前箱唛',
|
||||
sku_code varchar(128) DEFAULT NULL COMMENT '商品编码',
|
||||
quantity decimal(24,12) NOT NULL COMMENT '剩余商品数量',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_storage_rent_return_share (lot_id,carton_mark,sku_code),
|
||||
UNIQUE KEY uk_storage_rent_stock_share (lot_id,balance_id,batch_no),
|
||||
KEY idx_storage_rent_carton (customer_id,warehouse_id,carton_mark),
|
||||
KEY idx_storage_rent_share_stock (customer_id,warehouse_id,balance_id,batch_no)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='当前库存与原计租批次份额';
|
||||
|
||||
CREATE TABLE ll_storage_rent_preparation (
|
||||
id bigint NOT NULL COMMENT '预校验主键',
|
||||
warehouse_id varchar(32) NOT NULL COMMENT '作业海外仓',
|
||||
customer_id varchar(32) NOT NULL COMMENT '费用客户',
|
||||
mode varchar(32) NOT NULL COMMENT '扫码类型',
|
||||
execution_no varchar(128) NOT NULL COMMENT '实际作业单号',
|
||||
request_key varchar(255) NOT NULL COMMENT '原扫码幂等键',
|
||||
request_hash varchar(64) NOT NULL COMMENT '原扫码内容哈希',
|
||||
attempt int NOT NULL COMMENT '冻结尝试次数',
|
||||
business_type varchar(32) NOT NULL COMMENT '本次应收业务类型',
|
||||
business_id bigint NOT NULL COMMENT '本次应收业务主键',
|
||||
business_no varchar(128) NOT NULL COMMENT '本次应收业务单号',
|
||||
status varchar(32) NOT NULL COMMENT '资金与扫码状态',
|
||||
plan_json json NOT NULL COMMENT '原批次计费不可变快照',
|
||||
original_amount decimal(24,12) NOT NULL COMMENT '原币仓租金额,币种见计费快照',
|
||||
amount decimal(24,12) NOT NULL COMMENT '人民币结算金额',
|
||||
exchange_rate decimal(24,12) NULL COMMENT '冻结确认汇率,免费出库为空',
|
||||
exchange_rate_month varchar(7) NOT NULL COMMENT '汇率月份',
|
||||
freeze_event_id varchar(128) DEFAULT NULL COMMENT '冻结指令事件',
|
||||
settle_event_id varchar(128) DEFAULT NULL COMMENT '扣款指令事件',
|
||||
release_event_id varchar(128) DEFAULT NULL COMMENT '解冻指令事件',
|
||||
release_status varchar(32) DEFAULT NULL COMMENT '解冻回执状态,失败后由恢复任务重发',
|
||||
wallet_version bigint NOT NULL COMMENT '同一预占资金命令版本,重试使用新版本保留审计',
|
||||
hold_id varchar(128) DEFAULT NULL COMMENT '财务冻结主键',
|
||||
ledger_id varchar(128) DEFAULT NULL COMMENT '财务扣款流水',
|
||||
error_message varchar(1000) DEFAULT NULL COMMENT '资金处理结果',
|
||||
source_fee_id bigint DEFAULT NULL COMMENT '财务费用来源主键',
|
||||
created_at datetime(3) NOT NULL COMMENT '预校验时间',
|
||||
consumed_at datetime(3) DEFAULT NULL COMMENT '实际扫描出库时间',
|
||||
updated_at datetime(3) NOT NULL COMMENT '更新时间',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_storage_rent_request (warehouse_id,mode,request_key,attempt),
|
||||
KEY idx_storage_rent_execution (mode,execution_no,status),
|
||||
KEY idx_storage_rent_business (customer_id,business_no),
|
||||
KEY idx_storage_rent_recovery (status,updated_at),
|
||||
KEY idx_storage_rent_release (release_status,updated_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='仓租扫码资金预占与结算快照';
|
||||
|
||||
CREATE TABLE ll_fleet_resource_photo (
|
||||
resource_kind VARCHAR(16) NOT NULL COMMENT '档案类型:车辆或司机',
|
||||
resource_id BIGINT NOT NULL COMMENT '车辆或司机档案标识',
|
||||
record_version BIGINT NOT NULL COMMENT '照片版本,从一递增',
|
||||
file_id BIGINT NOT NULL COMMENT '文件服务照片标识',
|
||||
file_name VARCHAR(512) NOT NULL COMMENT '照片原始文件名',
|
||||
content_type VARCHAR(64) NOT NULL COMMENT '照片媒体类型',
|
||||
size_bytes BIGINT NOT NULL COMMENT '照片字节数',
|
||||
created_by VARCHAR(64) NOT NULL COMMENT '上传操作人标识',
|
||||
created_at DATETIME(3) NOT NULL COMMENT '上传时间(UTC)',
|
||||
PRIMARY KEY (resource_kind, resource_id, record_version),
|
||||
UNIQUE KEY uk_fleet_resource_photo_file (resource_kind, resource_id, file_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车辆及司机档案照片版本记录';
|
||||
|
||||
SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 240 AS initialized_tables;
|
||||
SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 228 AS initialized_tables;
|
||||
|
||||
+2
-1
@@ -2,7 +2,8 @@
|
||||
|
||||
## 数据库访问与中文注释约束
|
||||
|
||||
- 强制继承 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则;该节是分层、SQL 执行和方法/核心逻辑中文注释的唯一维护处,本模块不另设例外。
|
||||
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
|
||||
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
|
||||
|
||||
## 职责与边界
|
||||
|
||||
|
||||
@@ -7,16 +7,10 @@ import java.util.Arrays;
|
||||
|
||||
/** File-owned storage policy. Business visibility and lifecycle remain in the calling service. */
|
||||
enum BusinessFileCategory {
|
||||
FLEET_VEHICLE_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||
FLEET_DRIVER_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||
FLEET_DRIVER_LICENSE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||
FLEET_TRIP_OPERATION_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||
OVERSEAS_DISPATCH_VEHICLE_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||
OVERSEAS_DISPATCH_DRIVER_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||
OVERSEAS_DISPATCH_EXCEPTION_EVIDENCE(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||
OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||
OVERSEAS_DISPATCH_LOADING_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||
OVERSEAS_DISPATCH_FUEL_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||
FLEET_VEHICLE_RECORD_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||
DELIVERY_LOADING_PHOTO(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG),
|
||||
DELIVERY_BOL(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG),
|
||||
@@ -77,14 +71,9 @@ enum BusinessFileCategory {
|
||||
|
||||
/** 校验文件归属;跨客户排车证据固定为平台所有,不随货物客户变化。 */
|
||||
void validateOwner(long tenantId) {
|
||||
// 整车运营照片归原排车单,不随同车客户变化,也不能串用装车和油耗类别。
|
||||
if ((this == OVERSEAS_DISPATCH_LOADING_PHOTO || this == OVERSEAS_DISPATCH_FUEL_PHOTO)
|
||||
if ((this == OVERSEAS_DISPATCH_VEHICLE_EVIDENCE || this == OVERSEAS_DISPATCH_DRIVER_EVIDENCE)
|
||||
&& tenantId != 0) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "排车运营照片必须归属平台");
|
||||
}
|
||||
if ((this == OVERSEAS_DISPATCH_VEHICLE_EVIDENCE || this == OVERSEAS_DISPATCH_DRIVER_EVIDENCE || this == OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE || this == OVERSEAS_DISPATCH_EXCEPTION_EVIDENCE)
|
||||
&& tenantId != 0) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "排车证据必须归属平台");
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "排车车辆及司机证据必须归属平台");
|
||||
}
|
||||
if (this == FLEET_TRIP_OPERATION_EVIDENCE && tenantId != 0) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "出车实测凭证必须归属平台");
|
||||
@@ -92,7 +81,7 @@ enum BusinessFileCategory {
|
||||
if (this == FINANCE_VEHICLE_VOUCHER && tenantId != 0) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "公司车辆付款凭证必须归属平台");
|
||||
}
|
||||
if ((this == FLEET_VEHICLE_RECORD_PHOTO || this == FLEET_VEHICLE_PHOTO || this == FLEET_DRIVER_PHOTO) && tenantId != 0) {
|
||||
if (this == FLEET_VEHICLE_RECORD_PHOTO && tenantId != 0) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "车辆运维照片必须归属平台");
|
||||
}
|
||||
if (this == FLEET_DRIVER_LICENSE && tenantId != 0) {
|
||||
|
||||
@@ -4,9 +4,9 @@ info:
|
||||
version: 1.0.0
|
||||
description: >-
|
||||
复用 InternalBusinessFileController 的现有通用内部入口,不新增公开文件路由。
|
||||
三类证据均固定平台 tenantId=0,businessId 为排车内部正数 Long ID 的十进制表示,
|
||||
两类证据均固定平台 tenantId=0,businessId 为排车内部正数 Long ID 的十进制表示,
|
||||
不使用车辆 ID、司机 ID、车牌或客户 ID。车辆与司机证据分别使用
|
||||
OVERSEAS_DISPATCH_VEHICLE_EVIDENCE、OVERSEAS_DISPATCH_DRIVER_EVIDENCE、OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE,不可互换。司机本人照片DRIVER_PHOTO和排车异常图片EXCEPTION仅支持JPG/PNG;其他两类沿PDF/JPG/PNG。
|
||||
OVERSEAS_DISPATCH_VEHICLE_EVIDENCE、OVERSEAS_DISPATCH_DRIVER_EVIDENCE,不可互换。
|
||||
File 校验内部服务令牌、平台归属、正数业务 ID、文件非空、最大 10MiB(10485760 字节,包含边界)、
|
||||
PDF/JPG/PNG MIME 及对应内容签名。元数据和下载按 tenantId、businessType、businessId、fileId
|
||||
联合查询 ACTIVE 且未删除文件,错车或错类别均视为归属不匹配,下载前完成归属校验。
|
||||
@@ -126,7 +126,7 @@ components:
|
||||
description: 正数Long的十进制字符串,值不得超过9223372036854775807
|
||||
DispatchEvidenceType:
|
||||
type: string
|
||||
enum: [OVERSEAS_DISPATCH_VEHICLE_EVIDENCE, OVERSEAS_DISPATCH_DRIVER_EVIDENCE, OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE, OVERSEAS_DISPATCH_EXCEPTION_EVIDENCE]
|
||||
enum: [OVERSEAS_DISPATCH_VEHICLE_EVIDENCE, OVERSEAS_DISPATCH_DRIVER_EVIDENCE]
|
||||
MetadataEnvelope:
|
||||
type: object
|
||||
required: [code, data]
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: 自有业务排车运营照片归属契约
|
||||
version: 1.0.0
|
||||
description: >-
|
||||
自有业务消费原排车事实,仅补充运营照片,不创建另一套排车或装车状态。
|
||||
复用InternalBusinessFileController,不增加公网File路径。
|
||||
businessType分为OVERSEAS_DISPATCH_LOADING_PHOTO和OVERSEAS_DISPATCH_FUEL_PHOTO,
|
||||
tenantId固定0,businessId固定原Lastleg排车内部主键,不接受车次主键、车牌或客户ID替代。
|
||||
仅非空JPG/PNG,最大10MiB,同时校验MIME与真实内容签名;两类照片不能交叉引用。
|
||||
Lastleg按公开dispatchNo解析原排车,负责车辆归属、可见阶段、读写权限、附加记录版本、
|
||||
分类附件引用和历史。文件上传不代表装车确认、签收、实到或运营信息保存。
|
||||
File不读取Lastleg表,只按客户范围、业务类别、业务ID和文件ID核对文件复合归属。
|
||||
paths: {}
|
||||
@@ -1,88 +0,0 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: 车辆与司机档案照片内部文件契约
|
||||
version: 1.0.0
|
||||
description: >-
|
||||
复用InternalBusinessFileController,固定平台tenantId=0、businessType=FLEET_VEHICLE_PHOTO或FLEET_DRIVER_PHOTO、
|
||||
businessId=对应车辆或司机内部ID。Lastleg负责档案存在性、列表/维护权限、版本关联和历史可见性;
|
||||
File校验内部服务令牌、平台复合归属、真实非空文件、MIME与签名。最大10MiB,仅JPG/PNG。
|
||||
上传只生成不可变对象,不改变驾驶证审核结论,不生成公开URL,排车保存时复制为本趟证据,后续档案替换不改历史排车。
|
||||
security:
|
||||
- InternalServiceToken: []
|
||||
paths:
|
||||
/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}:
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/TenantId'}
|
||||
- {$ref: '#/components/parameters/BusinessType'}
|
||||
- {$ref: '#/components/parameters/BusinessId'}
|
||||
post:
|
||||
summary: 上传车辆或司机档案照片
|
||||
parameters:
|
||||
- {name: X-Operator-Id, in: header, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
multipart/form-data:
|
||||
schema:
|
||||
type: object
|
||||
required: [file]
|
||||
properties:
|
||||
file: {type: string, format: binary}
|
||||
responses:
|
||||
'200': {$ref: '#/components/responses/Metadata'}
|
||||
'403': {description: 内部服务令牌无效}
|
||||
/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}/{fileId}:
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/TenantId'}
|
||||
- {$ref: '#/components/parameters/BusinessType'}
|
||||
- {$ref: '#/components/parameters/BusinessId'}
|
||||
- {$ref: '#/components/parameters/FileId'}
|
||||
get:
|
||||
summary: 校验归属并读取元数据
|
||||
responses:
|
||||
'200': {$ref: '#/components/responses/Metadata'}
|
||||
'403': {description: 内部服务令牌无效}
|
||||
/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}/{fileId}/content:
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/TenantId'}
|
||||
- {$ref: '#/components/parameters/BusinessType'}
|
||||
- {$ref: '#/components/parameters/BusinessId'}
|
||||
- {$ref: '#/components/parameters/FileId'}
|
||||
get:
|
||||
summary: 校验归属并下载真实文件
|
||||
responses:
|
||||
'200':
|
||||
description: 文件内容,业务错误按JSON code识别
|
||||
content:
|
||||
image/jpeg: {schema: {type: string, format: binary}}
|
||||
image/png: {schema: {type: string, format: binary}}
|
||||
'403': {description: 内部服务令牌无效}
|
||||
components:
|
||||
securitySchemes:
|
||||
InternalServiceToken: {type: apiKey, in: header, name: X-Internal-Service-Token}
|
||||
parameters:
|
||||
TenantId: {name: tenantId, in: path, required: true, schema: {type: string, enum: ['0']}}
|
||||
BusinessType: {name: businessType, in: path, required: true, schema: {type: string, enum: [FLEET_VEHICLE_PHOTO, FLEET_DRIVER_PHOTO]}}
|
||||
BusinessId: {name: businessId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
FileId: {name: fileId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
responses:
|
||||
Metadata:
|
||||
description: 统一响应;业务成功code为200,复合归属不匹配按业务错误返回
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
code: {type: string}
|
||||
message: {type: string, nullable: true}
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
fileId: {type: string}
|
||||
tenantId: {type: string, enum: ['0']}
|
||||
businessType: {type: string, enum: [FLEET_VEHICLE_PHOTO, FLEET_DRIVER_PHOTO]}
|
||||
businessId: {type: string}
|
||||
fileName: {type: string}
|
||||
sha256: {type: string, pattern: '^[0-9a-f]{64}$'}
|
||||
contentType: {type: string, enum: [image/jpeg, image/png]}
|
||||
sizeBytes: {type: integer, format: int64, minimum: 1, maximum: 10485760}
|
||||
@@ -30,8 +30,6 @@ import static org.mockito.Mockito.*;
|
||||
|
||||
class DispatchEvidenceFileTest {
|
||||
private static final String VEHICLE = "OVERSEAS_DISPATCH_VEHICLE_EVIDENCE";
|
||||
private static final String EXCEPTION = "OVERSEAS_DISPATCH_EXCEPTION_EVIDENCE";
|
||||
private static final String PHOTO = "OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE";
|
||||
private static final String DRIVER = "OVERSEAS_DISPATCH_DRIVER_EVIDENCE";
|
||||
private static final int MAX_BYTES = 10 * 1024 * 1024;
|
||||
@TempDir Path root;
|
||||
@@ -42,9 +40,9 @@ class DispatchEvidenceFileTest {
|
||||
FileObjectEntity.class);
|
||||
}
|
||||
|
||||
/** 三类证据只接受平台与有效排车内部 ID,非法输入不得访问数据库或磁盘。 */
|
||||
/** 两类证据只接受平台与有效排车内部 ID,非法输入不得访问数据库或磁盘。 */
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {VEHICLE, DRIVER, PHOTO, EXCEPTION})
|
||||
@ValueSource(strings = {VEHICLE, DRIVER})
|
||||
void requiresPlatformAndPositiveDispatchIdBeforeAccess(String type) {
|
||||
var files = mock(FileObjectMapper.class);
|
||||
var storage = mock(BusinessFileStorage.class);
|
||||
@@ -66,19 +64,6 @@ class DispatchEvidenceFileTest {
|
||||
verifyNoInteractions(files, storage);
|
||||
}
|
||||
|
||||
/** 司机照片与异常图片使用独立平台类别,拒绝PDF和伪造图片。 */
|
||||
@ParameterizedTest @ValueSource(strings = {PHOTO, EXCEPTION})
|
||||
void photoAcceptsOnlyRealImages(String type) throws Exception {
|
||||
var storage=storage();var category=BusinessFileCategory.require(type);
|
||||
for(var format:new String[]{"jpeg","png"}) {
|
||||
var upload=new MockMultipartFile("file","driver."+format,"image/"+format,image(format));
|
||||
var saved=storage.store(0,"71",upload,category);
|
||||
assertThat(storage.read(saved.objectKey())).isEqualTo(upload.getBytes());
|
||||
}
|
||||
assertThatThrownBy(()->storage.store(0,"71",pdf(),category)).isInstanceOf(BizException.class);
|
||||
assertThatThrownBy(()->storage.store(0,"71",new MockMultipartFile("file","driver.png","image/png",pdf().getBytes()),category)).isInstanceOf(BizException.class);
|
||||
}
|
||||
|
||||
/** 真实 PDF、JPG、PNG 均可写入并保持内容;上传不以扩展名代替 MIME 和签名。 */
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {VEHICLE, DRIVER})
|
||||
@@ -164,7 +149,7 @@ class DispatchEvidenceFileTest {
|
||||
|
||||
/** 元数据和下载均逐列带全复合键;错车、错类别、错文件不得读取存储内容。 */
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {VEHICLE, DRIVER, PHOTO, EXCEPTION})
|
||||
@ValueSource(strings = {VEHICLE, DRIVER})
|
||||
void metadataAndDownloadEnforceEveryScopeKeyBeforeReading(String type) throws Exception {
|
||||
var files = mock(FileObjectMapper.class);
|
||||
var storage = mock(BusinessFileStorage.class);
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
package com.baiyue.file.service;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
class FleetResourcePhotoCategoryTest {
|
||||
@Test void profilePhotosOnlyAllowPlatformImagesAndPositiveResourceIdentities() {
|
||||
for(var category : new BusinessFileCategory[]{BusinessFileCategory.FLEET_VEHICLE_PHOTO,BusinessFileCategory.FLEET_DRIVER_PHOTO}) {
|
||||
category.validateOwner(0);
|
||||
assertThatThrownBy(() -> category.validateOwner(1)).hasMessageContaining("平台");
|
||||
assertThat(category.formatPolicy()).isEqualTo(BusinessFileCategory.FormatPolicy.IMAGE);
|
||||
assertThat(category.maxSizeBytes()).isEqualTo(10L*1024*1024);
|
||||
assertThatThrownBy(() -> category.storageBusinessId("not-a-resource")).isInstanceOf(RuntimeException.class);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
package com.baiyue.file.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.file.entity.FileObjectEntity;
|
||||
import com.baiyue.file.mapper.FileObjectMapper;
|
||||
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.nio.file.Path;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class OwnedBusinessPhotoFileTest {
|
||||
private static final String LOADING = "OVERSEAS_DISPATCH_LOADING_PHOTO";
|
||||
private static final String FUEL = "OVERSEAS_DISPATCH_FUEL_PHOTO";
|
||||
@TempDir Path root;
|
||||
|
||||
@BeforeAll static void columns() {
|
||||
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), "owned-business-photo"), FileObjectEntity.class);
|
||||
}
|
||||
|
||||
@ParameterizedTest @ValueSource(strings = {LOADING, FUEL})
|
||||
void rejectsNonPlatformOrInvalidDispatchIdentityBeforeAnyStorage(String type) {
|
||||
var files = mock(FileObjectMapper.class);
|
||||
var storage = mock(BusinessFileStorage.class);
|
||||
var service = service(files, storage);
|
||||
var file = new MockMultipartFile("file", "photo.png", "image/png", new byte[]{1});
|
||||
for (long owner : new long[]{-1, 1, 900})
|
||||
assertThatThrownBy(() -> service.upload(owner, type, "71", 7, file)).isInstanceOf(BizException.class);
|
||||
for (String businessId : new String[]{"0", "-1", "DS-71", "../71", ""})
|
||||
assertThatThrownBy(() -> service.upload(0, type, businessId, 7, file)).isInstanceOf(BizException.class);
|
||||
verifyNoInteractions(files, storage);
|
||||
}
|
||||
|
||||
@ParameterizedTest @ValueSource(strings = {LOADING, FUEL})
|
||||
void storesRealImagesButRejectsPdfSpoofedEmptyAndOversizedFiles(String type) throws Exception {
|
||||
var storage = new BusinessFileStorage();
|
||||
ReflectionTestUtils.setField(storage, "configuredRoot", root);
|
||||
storage.initializeRoot();
|
||||
var category = BusinessFileCategory.require(type);
|
||||
for (String format : new String[]{"jpeg", "png"}) {
|
||||
byte[] bytes = image(format);
|
||||
var upload = new MockMultipartFile("file", "photo." + format, "image/" + format, bytes);
|
||||
var saved = storage.store(0, "71", upload, category);
|
||||
assertThat(storage.read(saved.objectKey())).isEqualTo(bytes);
|
||||
}
|
||||
for (var upload : new MockMultipartFile[]{
|
||||
new MockMultipartFile("file", "empty.png", "image/png", new byte[0]),
|
||||
new MockMultipartFile("file", "spoof.png", "image/png", "not-image".getBytes()),
|
||||
new MockMultipartFile("file", "receipt.pdf", "application/pdf", "%PDF-1.7\nreceipt".getBytes())})
|
||||
assertThatThrownBy(() -> storage.store(0, "71", upload, category)).isInstanceOf(BizException.class);
|
||||
MultipartFile oversized = mock(MultipartFile.class);
|
||||
when(oversized.isEmpty()).thenReturn(false);
|
||||
when(oversized.getSize()).thenReturn(10L * 1024 * 1024 + 1);
|
||||
assertThatThrownBy(() -> storage.store(0, "71", oversized, category)).isInstanceOf(BizException.class);
|
||||
verify(oversized, never()).getInputStream();
|
||||
}
|
||||
|
||||
/** 用实际Mapper查询条件核对四元组,错车、错类别和错文件必须先拒绝再读内容。 */
|
||||
@ParameterizedTest @ValueSource(strings = {LOADING, FUEL})
|
||||
void metadataAndDownloadRequireExactDispatchCategoryAndFile(String type) throws Exception {
|
||||
var files = mock(FileObjectMapper.class);
|
||||
var storage = mock(BusinessFileStorage.class);
|
||||
var service = service(files, storage);
|
||||
byte[] bytes = image("png");
|
||||
var row = new FileObjectEntity();
|
||||
row.setId(501L); row.setTenantId(0L); row.setBusinessType(type); row.setBusinessId("71");
|
||||
row.setOriginalName("photo.png"); row.setContentType("image/png"); row.setSizeBytes((long) bytes.length);
|
||||
row.setObjectKey("owned-photo"); row.setSha256("a".repeat(64)); row.setCreatedAt(java.time.LocalDateTime.now());
|
||||
when(files.selectOne(any())).thenAnswer(call -> {
|
||||
LambdaQueryWrapper<FileObjectEntity> query = call.getArgument(0);
|
||||
assertThat(bound(query, "status")).isEqualTo("ACTIVE");
|
||||
assertThat(bound(query, "deleted")).isEqualTo(false);
|
||||
return Long.valueOf(0).equals(bound(query, "tenant_id")) && type.equals(bound(query, "business_type"))
|
||||
&& "71".equals(bound(query, "business_id")) && Long.valueOf(501).equals(bound(query, "id")) ? row : null;
|
||||
});
|
||||
when(storage.read("owned-photo")).thenReturn(bytes);
|
||||
assertThat(service.metadata(0, type, "71", 501).businessType()).isEqualTo(type);
|
||||
assertThat(service.download(0, type, "71", 501).content()).isEqualTo(bytes);
|
||||
clearInvocations(storage);
|
||||
for (String other : new String[]{LOADING.equals(type) ? FUEL : LOADING,
|
||||
"FLEET_VEHICLE_RECORD_PHOTO", "OVERSEAS_DISPATCH_VEHICLE_EVIDENCE", "DELIVERY_LOADING_PHOTO"}) {
|
||||
assertThatThrownBy(() -> service.metadata(0, other, "71", 501)).isInstanceOf(BizException.class);
|
||||
assertThatThrownBy(() -> service.download(0, other, "71", 501)).isInstanceOf(BizException.class);
|
||||
}
|
||||
assertThatThrownBy(() -> service.download(0, type, "72", 501)).isInstanceOf(BizException.class);
|
||||
assertThatThrownBy(() -> service.download(0, type, "71", 502)).isInstanceOf(BizException.class);
|
||||
assertThatThrownBy(() -> service.download(1, type, "71", 501)).isInstanceOf(BizException.class);
|
||||
verifyNoInteractions(storage);
|
||||
}
|
||||
|
||||
private static Object bound(LambdaQueryWrapper<FileObjectEntity> query, String column) {
|
||||
var matcher = Pattern.compile("(?<![A-Za-z0-9_])" + column + " = #\\{ew\\.paramNameValuePairs\\.([^}]+)}")
|
||||
.matcher(query.getSqlSegment());
|
||||
assertThat(matcher.find()).as("scope column %s", column).isTrue();
|
||||
return query.getParamNameValuePairs().get(matcher.group(1));
|
||||
}
|
||||
|
||||
private static InternalBusinessFileService service(FileObjectMapper files, BusinessFileStorage storage) {
|
||||
var service = new InternalBusinessFileService();
|
||||
ReflectionTestUtils.setField(service, "files", files);
|
||||
ReflectionTestUtils.setField(service, "storage", storage);
|
||||
return service;
|
||||
}
|
||||
|
||||
private static byte[] image(String format) throws Exception {
|
||||
var bytes = new ByteArrayOutputStream();
|
||||
assertThat(javax.imageio.ImageIO.write(new java.awt.image.BufferedImage(2, 2,
|
||||
java.awt.image.BufferedImage.TYPE_INT_RGB), format, bytes)).isTrue();
|
||||
return bytes.toByteArray();
|
||||
}
|
||||
}
|
||||
+4
-3
@@ -2,7 +2,8 @@
|
||||
|
||||
## 数据库访问与中文注释约束
|
||||
|
||||
- 强制继承 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则;该节是分层、SQL 执行和方法/核心逻辑中文注释的唯一维护处,本模块不另设例外。
|
||||
- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。
|
||||
- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。
|
||||
|
||||
## 模块定位
|
||||
|
||||
@@ -23,7 +24,7 @@
|
||||
| 客户只读账单、收款与应收 API | `controller/ClientFinanceController.java`,统一前缀 `/api/client/finance/v1` |
|
||||
| 订单与客户账户、费用来源审核与结算纳入 | `controller/FinancePhase1OperationController.java`、`service/FinancePhase1OperationService.java` |
|
||||
| 客户财务账户汇总 | `service/FinanceCustomerAccountService.java`、`client/IamCustomerClient.java`;客户主档事实仍归 IAM |
|
||||
| 客户资金账户、授信与充值 | `controller/ClientFinanceWalletController.java`、`controller/PlatformFinanceWalletController.java`、`controller/InternalFinanceWalletController.java`、`service/FinanceWalletService.java`、`dto/FinanceWalletDtos.java`;支持静态收款码、截图申请、平台审核、手工充扣、授信配置及供业务模块使用的冻结/结算/释放;原消费关联退款契约见 `src/main/resources/openapi/finance-wallet-refund-v1.yaml`,第三方支付回调尚未接入 |
|
||||
| 客户资金账户、授信与充值 | `controller/ClientFinanceWalletController.java`、`controller/PlatformFinanceWalletController.java`、`controller/InternalFinanceWalletController.java`、`service/FinanceWalletService.java`、`dto/FinanceWalletDtos.java`;支持静态收款码、截图申请、平台审核、手工充扣、授信配置及供业务模块使用的冻结/结算/释放,第三方支付回调尚未接入 |
|
||||
| 客户对账单 | `controller/ReconciliationStatementController.java`、`service/ReconciliationStatementService.java` |
|
||||
| 应收款月度统计 | `controller/ReceivableStatisticsController.java`、`service/ReceivableStatisticsService.java`、`dto/ReceivableStatisticsDtos.java`;按结算单生成月份和客户汇总 CNY 正式应收结算单 |
|
||||
| 应收/应付结算单 | `controller/FinanceSettlementController.java`、`service/FinanceSettlementService.java` |
|
||||
@@ -50,7 +51,7 @@
|
||||
- 平台写操作必须校验权限、目标客户、幂等键、请求哈希、乐观锁版本和审计;客户接口只能从登录态取得客户范围,不接受客户端指定客户 ID。
|
||||
- 出账后的业务来源和账单不得原地覆盖或物理删除;调整、作废、收款分配和修订必须保留可追溯记录。金额使用定点小数,当前业务币种固定为 CNY,长整型 ID 跨前端按字符串传输。
|
||||
- 对账单出账后将客户确认状态置为 `PENDING_CONFIRMATION`;已出账账单调整后必须清除原确认快照并重新待确认,作废或重开时为 `NOT_REQUIRED`。客户只能确认本企业且状态为 `ISSUED`、`PARTIALLY_PAID` 或 `PAID` 的待确认账单,必须校验登录成员、乐观锁版本和幂等键,并记录确认人姓名快照与确认时间。
|
||||
- 财务单据编号遵循 `../../docs/业务单据编号契约.md`,由本服务 Repository/Mapper 按前缀与北京时间日期原子分配;不得回退为数据库 ID 拼接、随机数或跨时区日期。
|
||||
- 对账单、应收结算单和应付结算单编号统一按北京时间生成 `REC/AR/AP-yyMMdd-####`,各前缀每日独立递增且单日最多 9999;不得回退为数据库 ID 拼接、随机数或跨时区日期。
|
||||
- 客户授信必须带晚于当天的到期日且不限制最远日期;到期日当天仍有效。有效期内消费先用实际资金,不足部分累计为已用授信;次日失效时已用授信自动并入实际余额并允许为负,充值先归还已用授信或负余额。Order 在客户提交新订单时必须拒绝负数实际余额,已存在的资金冻结仍按原业务单结算或释放。
|
||||
- 应收款统计只按正式应收结算单的生成月份汇总 CNY 应收、已收和未收,支持可选客户筛选和分页;导出必须通过 Finance Provider + File `FINANCE_RECEIVABLE_STATISTICS` 异步任务,不在 Controller 或 Admin 直接生成文件。
|
||||
- 费用入池只接受满足上游发布与结算条件的不可变来源版本;来源 ID、版本、客户归属、方向或金额变化时同时核对事件消费、主动快照查询、唯一约束和账单明细消费者。
|
||||
|
||||
-26
@@ -7,16 +7,11 @@ import com.baiyue.finance.dto.FinanceWalletDtos.CreditLimitRequest;
|
||||
import com.baiyue.finance.dto.FinanceWalletDtos.RechargeReviewRequest;
|
||||
import com.baiyue.finance.dto.FinancePhase1Dtos.PageEnvelope;
|
||||
import com.baiyue.finance.dto.FinanceWalletDtos.RechargeRequest;
|
||||
import com.baiyue.finance.dto.FinanceWalletDtos.WalletRefundRequest;
|
||||
import com.baiyue.finance.service.FinanceWalletService;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.UUID;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/platform/finance/v1")
|
||||
@@ -67,25 +62,4 @@ public class PlatformFinanceWalletController {
|
||||
@GetMapping("/recharge-requests/{requestId}/voucher")
|
||||
@PreAuthorize("hasAuthority('platform:finance:customer-account:list')")
|
||||
public ResponseEntity<byte[]> voucher(@PathVariable long requestId) { return service.voucher(requestId); }
|
||||
|
||||
/** 退款必须由平台财务针对原消费发起,复用资金服务的账户锁及累计可退上限。 */
|
||||
@PostMapping("/accounts/{customerId}/ledger/{ledgerId}/refunds")
|
||||
@PreAuthorize("hasAuthority('platform:finance:customer-account:adjust')")
|
||||
public ApiResponse<?> refund(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable long customerId, @PathVariable long ledgerId,
|
||||
@RequestHeader("Idempotency-Key") String idempotencyKey,
|
||||
@RequestBody WalletRefundRequest request) {
|
||||
if (user == null || user.userId() == null || !"PLATFORM".equals(user.appCode()) || !Long.valueOf(0L).equals(user.tenantId())) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "仅平台财务可操作退款");
|
||||
}
|
||||
if (request == null || idempotencyKey == null || idempotencyKey.isBlank()
|
||||
|| idempotencyKey.trim().length() > 128) throw new IllegalArgumentException("退款请求及幂等键必填且幂等键不超过128字符");
|
||||
if (request.reason() == null || request.reason().isBlank() || request.reason().length() > 500)
|
||||
throw new IllegalArgumentException("退款原因必填且不超过500字符");
|
||||
// 技术退款引用由客户和幂等键确定;资金流水号仍由正式 AC 编号生成器分配。
|
||||
String refundNo = "MANUAL_REFUND:" + UUID.nameUUIDFromBytes(
|
||||
(customerId + ":" + idempotencyKey.trim()).getBytes(StandardCharsets.UTF_8));
|
||||
return FinanceResponses.success(service.refundConsumption(customerId, ledgerId, refundNo,
|
||||
idempotencyKey.trim(), request.amount(), user.userId(), user.displayName(), request.reason().trim()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ public final class FinanceSettlementDtos {
|
||||
BigDecimal quantity, BigDecimal unitPrice, BigDecimal amount, String currency,
|
||||
String reviewStatus, String sourceStatus,
|
||||
boolean statementGenerated) {}
|
||||
public record SettlementTransaction(long transactionId, String transactionNo, String transactionType, LocalDate transactionDate,
|
||||
public record SettlementTransaction(long transactionId, String transactionType, LocalDate transactionDate,
|
||||
BigDecimal amount, String method, String referenceNo,
|
||||
List<String> voucherFileIds, long createdBy, String createdByName,
|
||||
LocalDateTime createdAt,
|
||||
|
||||
@@ -54,9 +54,6 @@ public final class FinanceWalletDtos {
|
||||
|
||||
public record WalletHoldActionRequest(String remark) { }
|
||||
|
||||
/** 关联原消费退款,原因必填,原流水保持不可变。 */
|
||||
public record WalletRefundRequest(BigDecimal amount, String reason) { }
|
||||
|
||||
public record WalletHold(long holdId, long customerId, String businessType,
|
||||
String businessNo, BigDecimal amount,
|
||||
BigDecimal actualFrozenAmount, BigDecimal creditFrozenAmount,
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
package com.baiyue.finance.mapper;
|
||||
|
||||
import org.apache.ibatis.annotations.Insert;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Options;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
/** 本服务业务编号序列,更新与读取必须处于同一事务连接。 */
|
||||
@Mapper
|
||||
public interface DocumentSequenceMapper {
|
||||
/** 唯一序列键原子递增,并在连接内保存本次分配值。 */
|
||||
@Insert("INSERT INTO fin_document_sequence(sequence_key,current_value) VALUES(#{key},LAST_INSERT_ID(1)) "
|
||||
+ "ON DUPLICATE KEY UPDATE current_value=LAST_INSERT_ID(current_value+1)")
|
||||
int allocate(@Param("key") String key);
|
||||
|
||||
/** 禁用一级和二级缓存,返回本事务刚刚分配的值。 */
|
||||
@Select("SELECT LAST_INSERT_ID()")
|
||||
@Options(useCache=false, flushCache=Options.FlushCachePolicy.TRUE)
|
||||
Long allocatedValue();
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
package com.baiyue.finance.mapper;
|
||||
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** 费用池按稳定来源读取当前版本,历史快照保留在同一费用的操作记录中。 */
|
||||
@Mapper
|
||||
public interface FinanceFeeSourceReadMapper {
|
||||
String GENERATED = "(EXISTS(SELECT 1 FROM fin_bill_line bl WHERE bl.source_id=f.id AND bl.source_version=f.source_fee_version))";
|
||||
String STATUS = "CASE WHEN f.status='AVAILABLE' AND " + GENERATED + " THEN 'BILLED' ELSE f.status END";
|
||||
String BUSINESS_DATE = "CAST(JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.businessDate')) AS DATE)";
|
||||
String FILTER = " FROM fin_fee_source f WHERE 1=1 " +
|
||||
"<if test=\"status == null or status != 'SUPERSEDED'\">" +
|
||||
" AND f.status<>'SUPERSEDED' AND NOT EXISTS(SELECT 1 FROM fin_fee_source newer" +
|
||||
" WHERE newer.source_system=f.source_system AND newer.source_type=f.source_type" +
|
||||
" AND newer.source_fee_id=f.source_fee_id AND newer.source_fee_version>f.source_fee_version)" +
|
||||
"</if>" +
|
||||
"<if test='customerId != null'> AND f.customer_id=#{customerId}</if>" +
|
||||
"<if test='direction != null'> AND f.direction=#{direction}</if>" +
|
||||
"<if test='reviewStatus != null'> AND f.review_status=#{reviewStatus}</if>" +
|
||||
"<if test='status != null'> AND " + STATUS + "=#{status}</if>" +
|
||||
"<if test='statementGenerated != null'> AND " + GENERATED + "=#{statementGenerated}</if>" +
|
||||
"<if test='dateFrom != null'> AND " + BUSINESS_DATE + ">=#{dateFrom}</if>" +
|
||||
"<if test='dateTo != null'> AND " + BUSINESS_DATE + "<=#{dateTo}</if>" +
|
||||
"<if test='keyword != null'> AND (f.source_no LIKE CONCAT('%',#{keyword},'%')" +
|
||||
" OR f.fee_item_name LIKE CONCAT('%',#{keyword},'%')" +
|
||||
" OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.customerName')) LIKE CONCAT('%',#{keyword},'%')" +
|
||||
" OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.inboundCode')) LIKE CONCAT('%',#{keyword},'%')" +
|
||||
" OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.containerNo')) LIKE CONCAT('%',#{keyword},'%')" +
|
||||
" OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.supplierName')) LIKE CONCAT('%',#{keyword},'%'))</if>";
|
||||
|
||||
/** 筛选和计数共用当前版本条件,不能先分页再去重。 */
|
||||
@Select("<script>SELECT COUNT(*)" + FILTER + "</script>")
|
||||
long count(Map<String, Object> filters);
|
||||
|
||||
/** 应收、应付分别展示当前快照;同名但来源费用号不同的费用不合并。 */
|
||||
@Select("<script>SELECT f.*," + GENERATED + " statement_generated," + STATUS + " effective_status" + FILTER +
|
||||
" ORDER BY " + BUSINESS_DATE + " DESC,f.id DESC LIMIT #{limit} OFFSET #{offset}</script>")
|
||||
List<Map<String, Object>> list(Map<String, Object> filters);
|
||||
|
||||
String LINEAGE = " FROM fin_fee_source anchor JOIN fin_fee_source f ON f.source_system=anchor.source_system" +
|
||||
" AND f.source_type=anchor.source_type AND f.source_fee_id=anchor.source_fee_id" +
|
||||
" AND f.customer_id=anchor.customer_id AND f.direction=anchor.direction" +
|
||||
" AND f.source_fee_version<=anchor.source_fee_version";
|
||||
|
||||
/** 旧版本链接只读取当时及此前快照,不把后续调整倒灌到历史单据。 */
|
||||
@Select("<script>SELECT f.id,f.source_fee_version,f.amount,f.currency_code,f.created_at" + LINEAGE +
|
||||
" WHERE anchor.id=#{id} AND anchor.customer_id=#{customerId}" +
|
||||
" AND f.source_system IN ('FIRSTLEG','LASTLEG') ORDER BY f.source_fee_version,f.id</script>")
|
||||
List<Map<String, Object>> versions(@Param("customerId") long customerId, @Param("id") long id);
|
||||
|
||||
/** 按来源身份串起历次审核和操作,客户与方向边界不能被同名费用跨越。 */
|
||||
@Select("<script>SELECT a.id,a.created_at,a.action,a.operator_name_snapshot,a.reason,a.before_data,a.after_data" + LINEAGE +
|
||||
" JOIN fin_audit_log a ON a.tenant_id=f.customer_id AND a.resource_type='FIN_FEE_SOURCE' AND a.resource_id=f.id" +
|
||||
" WHERE anchor.id=#{id} AND anchor.customer_id=#{customerId} ORDER BY a.created_at DESC,a.id DESC</script>")
|
||||
List<Map<String, Object>> audits(@Param("customerId") long customerId, @Param("id") long id);
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
package com.baiyue.finance.mapper;
|
||||
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** 结算详情只读SQL;子查询均保留所属客户和原结算范围。 */
|
||||
@Mapper
|
||||
public interface FinanceSettlementDetailMapper {
|
||||
String SUMMARY_SELECT =
|
||||
"SELECT s.id,s.tenant_id,s.settlement_no,s.direction,s.customer_name_snapshot,s.supplier_id," +
|
||||
"s.supplier_name_snapshot,s.reconciliation_statement_id,b.bill_no reconciliation_statement_no," +
|
||||
"s.business_period,(SELECT GROUP_CONCAT(DISTINCT fs.fee_item_name ORDER BY fs.fee_item_name SEPARATOR '、') " +
|
||||
"FROM fin_settlement_line sl JOIN fin_fee_source fs ON fs.id=sl.fee_source_id " +
|
||||
"WHERE sl.tenant_id=s.tenant_id AND sl.settlement_id=s.id) fee_item_summary," +
|
||||
"s.currency_code,s.total_amount,s.paid_amount,s.outstanding_amount,s.generated_date,s.due_date," +
|
||||
"s.payment_status,s.status,s.created_by,(SELECT a.operator_name_snapshot FROM fin_audit_log a " +
|
||||
"WHERE a.tenant_id=s.tenant_id AND a.resource_type='FIN_SETTLEMENT' AND a.resource_id=s.id " +
|
||||
"AND a.action='CREATE' ORDER BY a.created_at,a.id LIMIT 1) created_by_name,s.created_at,s.updated_at," +
|
||||
"CASE WHEN s.direction='RECEIVABLE' THEN (SELECT MAX(r.created_at) FROM fin_receipt_bill rb " +
|
||||
"JOIN fin_receipt r ON r.id=rb.receipt_id AND r.tenant_id=rb.tenant_id " +
|
||||
"WHERE rb.tenant_id=s.tenant_id AND rb.bill_id=s.reconciliation_statement_id AND r.status='CONFIRMED') " +
|
||||
"ELSE (SELECT MAX(p.created_at) FROM fin_settlement_payment p WHERE p.tenant_id=s.tenant_id " +
|
||||
"AND p.settlement_id=s.id) END last_payment_at,s.version FROM fin_settlement s " +
|
||||
"LEFT JOIN fin_bill b ON b.tenant_id=s.tenant_id AND b.id=s.reconciliation_statement_id ";
|
||||
/** 读取平台结算主单及来源摘要。 */
|
||||
@Select(SUMMARY_SELECT + "WHERE s.id=#{settlementId}")
|
||||
List<Map<String, Object>> summary(@Param("settlementId") long settlementId);
|
||||
/** 读取该客户该结算的费用明细。 */
|
||||
@Select("SELECT f.id fee_source_id,f.source_type,f.source_fee_id,f.source_fee_version,f.direction,f.customer_id,f.order_id,f.inbound_id,f.container_id,f.fee_item_name,f.amount,f.currency_code,f.review_status,f.status,f.source_snapshot FROM fin_settlement_line sl JOIN fin_fee_source f ON f.id=sl.fee_source_id WHERE sl.tenant_id=#{customerId} AND sl.settlement_id=#{settlementId} ORDER BY sl.id")
|
||||
List<Map<String, Object>> lines(@Param("customerId") long customerId, @Param("settlementId") long settlementId);
|
||||
/** 读取该客户付款流水及落库业务号,不能用ID替代。 */
|
||||
@Select("SELECT id,transaction_no,transaction_type,transaction_date,amount,method,reference_no,voucher_file_ids,created_by,created_by_name,created_at,remark FROM fin_settlement_payment WHERE tenant_id=#{customerId} AND settlement_id=#{settlementId} ORDER BY created_at,id")
|
||||
List<Map<String, Object>> payments(@Param("customerId") long customerId, @Param("settlementId") long settlementId);
|
||||
/** 读取对账单已确认收款;收款凭证号不是PAY业务号。 */
|
||||
@Select("SELECT r.id,'RECEIPT' transaction_type,DATE(r.received_at) transaction_date,rb.applied_amount amount,r.payment_method method,r.voucher_no reference_no,r.voucher_file_ids,r.created_by,r.created_by_name,r.created_at,r.remark FROM fin_receipt_bill rb JOIN fin_receipt r ON r.id=rb.receipt_id AND r.tenant_id=rb.tenant_id WHERE rb.tenant_id=#{customerId} AND rb.bill_id=#{statementId} AND r.status='CONFIRMED' ORDER BY r.created_at,r.id")
|
||||
List<Map<String, Object>> receipts(@Param("customerId") long customerId, @Param("statementId") long statementId);
|
||||
/** 读取该客户结算修订历史。 */
|
||||
@Select("SELECT action,reason,before_data,after_data,operator_id,operator_name_snapshot,created_at FROM fin_audit_log WHERE tenant_id=#{customerId} AND resource_type='FIN_SETTLEMENT' AND resource_id=#{settlementId} ORDER BY created_at,id")
|
||||
List<Map<String, Object>> audits(@Param("customerId") long customerId, @Param("settlementId") long settlementId);
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
package com.baiyue.finance.repository;
|
||||
|
||||
import com.baiyue.finance.mapper.DocumentSequenceMapper;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/** 各业务共用本库序列,独立短事务避免持有编号锁进入业务资源锁。 */
|
||||
@Repository
|
||||
public class DocumentSequenceRepository {
|
||||
private final DocumentSequenceMapper mapper;
|
||||
|
||||
/** 只访问所属服务的编号表。 */
|
||||
public DocumentSequenceRepository(DocumentSequenceMapper mapper) { this.mapper = mapper; }
|
||||
|
||||
/** 已分配号不因外层业务回滚而复用,允许失败请求产生断号。 */
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public long next(String key) {
|
||||
mapper.allocate(key);
|
||||
Long value = mapper.allocatedValue();
|
||||
if (value == null || value < 1) throw new IllegalStateException("业务编号分配失败");
|
||||
return value;
|
||||
}
|
||||
}
|
||||
-145
@@ -1,145 +0,0 @@
|
||||
package com.baiyue.finance.repository;
|
||||
|
||||
import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceSummary;
|
||||
import com.baiyue.finance.dto.FinancePhase1Dtos.OperationLog;
|
||||
import com.baiyue.finance.mapper.FinanceFeeSourceReadMapper;
|
||||
import com.baiyue.finance.service.FinanceEventException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import org.springframework.util.LinkedCaseInsensitiveMap;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.sql.Timestamp;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** 费用当前投影及操作历史;版本记录只读,不覆盖账单引用的不可变快照。 */
|
||||
@Repository
|
||||
public class FinanceFeeSourceReadRepository {
|
||||
private final FinanceFeeSourceReadMapper mapper;
|
||||
private final ObjectMapper json;
|
||||
|
||||
/** 注入正式持久化入口与费用快照解析器。 */
|
||||
public FinanceFeeSourceReadRepository(FinanceFeeSourceReadMapper mapper, ObjectMapper json) {
|
||||
this.mapper = mapper;
|
||||
this.json = json;
|
||||
}
|
||||
|
||||
/** 组装统一查询参数,让计数和分页使用相同来源身份与筛选条件。 */
|
||||
public Map<String, Object> filters(Long customerId, String direction, String status, String reviewStatus,
|
||||
Boolean generated, LocalDate from, LocalDate to, String keyword, int page, int size) {
|
||||
Map<String, Object> values = new HashMap<>();
|
||||
values.put("customerId", customerId); values.put("direction", direction); values.put("status", status);
|
||||
values.put("reviewStatus", reviewStatus); values.put("statementGenerated", generated);
|
||||
values.put("dateFrom", from); values.put("dateTo", to); values.put("keyword", keyword);
|
||||
values.put("limit", size); values.put("offset", (page - 1) * size);
|
||||
return values;
|
||||
}
|
||||
|
||||
/** 计数只包含所筛选的当前费用,历史状态专项查询除外。 */
|
||||
public long count(Map<String, Object> filters) { return mapper.count(filters); }
|
||||
|
||||
/** 当前费用映射沿用原币、数量、客户等正式字段。 */
|
||||
public List<FeeSourceSummary> list(Map<String, Object> filters) {
|
||||
return mapper.list(filters).stream().map(this::summary).toList();
|
||||
}
|
||||
|
||||
/** 由不可变版本追加同步历史,并串联同一费用所有既有审计记录。 */
|
||||
public List<OperationLog> logs(long customerId, long id) {
|
||||
List<OperationLog> result = new ArrayList<>();
|
||||
Map<String, Object> previous = null;
|
||||
for (Map<String, Object> raw : mapper.versions(customerId, id)) {
|
||||
Map<String, Object> row = row(raw);
|
||||
String version = "来源版本 " + row.get("source_fee_version");
|
||||
String amount = amount(row) + " " + row.get("currency_code");
|
||||
// 同步时间与原业务操作者不能混用,原业务审批仍以来源系统记录为准。
|
||||
result.add(new OperationLog(time(row.get("created_at")),
|
||||
previous == null ? "FEE_SOURCE_PUBLISHED" : "FEE_SOURCE_VERSION_ADJUST",
|
||||
"系统同步", "SUCCESS", previous == null ? version + ";金额 " + amount
|
||||
: "来源版本 " + previous.get("source_fee_version") + " → " + row.get("source_fee_version")
|
||||
+ ";金额 " + amount(previous) + " → " + amount));
|
||||
previous = row;
|
||||
}
|
||||
// 金额差分仍按版本升序计算;展示先反转版本,同毫秒时稳定排序保留新版本在前。
|
||||
java.util.Collections.reverse(result);
|
||||
for (Map<String, Object> raw : mapper.audits(customerId, id)) {
|
||||
Map<String, Object> row = row(raw);
|
||||
String remark = text(row.get("reason"));
|
||||
JsonNode before = parse(row.get("before_data"));
|
||||
JsonNode after = parse(row.get("after_data"));
|
||||
if (before.hasNonNull("amount") && after.hasNonNull("amount")
|
||||
&& before.path("amount").decimalValue().compareTo(after.path("amount").decimalValue()) != 0) {
|
||||
remark = (remark == null ? "" : remark + ";") + "金额 " + before.path("amount").asText()
|
||||
+ " → " + after.path("amount").asText() + " " + after.path("currency").asText("CNY");
|
||||
}
|
||||
result.add(new OperationLog(time(row.get("created_at")), text(row.get("action")),
|
||||
text(row.get("operator_name_snapshot")), "SUCCESS", remark));
|
||||
}
|
||||
// 时间排序保持同毫秒版本的新到旧顺序,以及 Mapper 返回的审计 ID 倒序。
|
||||
result.sort(Comparator.comparing(OperationLog::occurredAt, Comparator.nullsLast(Comparator.reverseOrder())));
|
||||
return List.copyOf(result);
|
||||
}
|
||||
|
||||
/** 将持久化快照转换为费用列表 DTO,不从其他客户或来源补齐业务事实。 */
|
||||
private FeeSourceSummary summary(Map<String, Object> raw) {
|
||||
Map<String, Object> row = row(raw);
|
||||
JsonNode snapshot = parse(row.get("source_snapshot"));
|
||||
BigDecimal amount = decimal(row.get("amount"));
|
||||
BigDecimal originalAmount = first(decimal(text(snapshot, "originalAmount")), amount);
|
||||
BigDecimal quantity = decimal(text(snapshot, "chargeableQuantity"));
|
||||
BigDecimal unitPrice = first(decimal(text(snapshot, "unitPrice")), quantity != null && quantity.signum() > 0
|
||||
? originalAmount.divide(quantity, 6, RoundingMode.HALF_UP) : null);
|
||||
return new FeeSourceSummary(number(row.get("id")), text(row.get("source_system")), text(row.get("source_type")),
|
||||
first(text(row.get("source_no")), text(snapshot, "sourceBusinessId")), number(row.get("source_fee_id")),
|
||||
((Number) row.get("source_fee_version")).intValue(), text(row.get("direction")), number(row.get("customer_id")),
|
||||
text(snapshot, "customerName"), positiveLong(text(snapshot, "supplierId")), text(snapshot, "supplierName"),
|
||||
nullableLong(row.get("order_id")), nullableLong(row.get("inbound_id")), text(snapshot, "inboundCode"),
|
||||
nullableLong(row.get("container_id")), text(snapshot, "containerNo"),
|
||||
first(date(text(snapshot, "businessDate")), LocalDate.of(1970, 1, 1)), text(row.get("fee_item_name")),
|
||||
quantity, text(snapshot, "quantityUnit"), unitPrice, date(text(snapshot, "periodStart")), date(text(snapshot, "periodEnd")),
|
||||
amount, text(row.get("currency_code")), first(text(snapshot, "originalCurrency"), text(row.get("currency_code"))),
|
||||
originalAmount, first(decimal(text(snapshot, "exchangeRate")), BigDecimal.ONE), text(snapshot, "exchangeRateMonth"),
|
||||
first(text(row.get("review_status")), "DRAFT"), text(row.get("effective_status")),
|
||||
Boolean.TRUE.equals(row.get("statement_generated")) || row.get("statement_generated") instanceof Number value && value.intValue() != 0);
|
||||
}
|
||||
|
||||
/** 保留不同数据库驱动的列名大小写读取语义。 */
|
||||
private static Map<String, Object> row(Map<String, Object> source) {
|
||||
Map<String, Object> result = new LinkedCaseInsensitiveMap<>(); result.putAll(source); return result;
|
||||
}
|
||||
/** 解析不可变快照,损坏数据失败关闭。 */
|
||||
private JsonNode parse(Object value) {
|
||||
try { return value == null ? json.createObjectNode() : json.readTree(value.toString()); }
|
||||
catch (Exception failure) { throw new FinanceEventException("FIN-DATA-500", "费用来源快照损坏"); }
|
||||
}
|
||||
/** 格式化记录金额,保留定点精度。 */
|
||||
private static String amount(Map<String, Object> row) { return decimal(row.get("amount")).toPlainString(); }
|
||||
/** 读取驱动返回的标准时间。 */
|
||||
private static LocalDateTime time(Object value) { return value instanceof Timestamp time ? time.toLocalDateTime() : (LocalDateTime) value; }
|
||||
/** 读取数字主键,仅用于数据引用。 */
|
||||
private static long number(Object value) { return ((Number) value).longValue(); }
|
||||
/** 保留未关联的可空引用。 */
|
||||
private static Long nullableLong(Object value) { return value == null ? null : number(value); }
|
||||
/** 只接受合法的正整型供应商引用。 */
|
||||
private static Long positiveLong(String value) {
|
||||
try { long parsed = Long.parseLong(value); return parsed > 0 ? parsed : null; }
|
||||
catch (NumberFormatException failure) { return null; }
|
||||
}
|
||||
/** 读取可空金额,不经过浮点转换。 */
|
||||
private static BigDecimal decimal(Object value) { return value == null ? null : new BigDecimal(value.toString()); }
|
||||
/** 读取可空业务日期。 */
|
||||
private static LocalDate date(String value) { return value == null ? null : LocalDate.parse(value); }
|
||||
/** 保持空字段的缺失语义。 */
|
||||
private static String text(Object value) { return value == null || value.toString().isBlank() ? null : value.toString(); }
|
||||
/** 读取快照字段。 */
|
||||
private static String text(JsonNode node, String field) { return node.path(field).isNull() || node.path(field).isMissingNode() ? null : text(node.path(field).asText()); }
|
||||
/** 优先使用来源字段,其次使用已有正式默认值。 */
|
||||
private static <T> T first(T value, T fallback) { return value == null ? fallback : value; }
|
||||
}
|
||||
-29
@@ -1,29 +0,0 @@
|
||||
package com.baiyue.finance.repository;
|
||||
|
||||
import com.baiyue.finance.mapper.FinanceSettlementDetailMapper;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import org.springframework.util.LinkedCaseInsensitiveMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** 结算详情查询封装,列名大小写差异不改变已有字段映射语义。 */
|
||||
@Repository
|
||||
public class FinanceSettlementDetailRepository {
|
||||
private final FinanceSettlementDetailMapper mapper;
|
||||
/** 注入结算详情持久化入口。 */
|
||||
public FinanceSettlementDetailRepository(FinanceSettlementDetailMapper mapper) { this.mapper = mapper; }
|
||||
/** 保留原数据库列名不区分大小写的读取语义。 */
|
||||
private List<Map<String, Object>> rows(List<Map<String, Object>> rows) {
|
||||
return rows.stream().map(row -> { Map<String, Object> copy = new LinkedCaseInsensitiveMap<>(); copy.putAll(row); return copy; }).toList();
|
||||
}
|
||||
/** 读取平台结算主单及来源摘要。 */
|
||||
public List<Map<String, Object>> summary(long settlementId) { return rows(mapper.summary(settlementId)); }
|
||||
/** 读取该客户该结算的费用明细。 */
|
||||
public List<Map<String, Object>> lines(long customerId, long settlementId) { return rows(mapper.lines(customerId, settlementId)); }
|
||||
/** 读取该客户付款流水及落库业务号,不能用ID替代。 */
|
||||
public List<Map<String, Object>> payments(long customerId, long settlementId) { return rows(mapper.payments(customerId, settlementId)); }
|
||||
/** 读取对账单已确认收款;收款凭证号不是PAY业务号。 */
|
||||
public List<Map<String, Object>> receipts(long customerId, long statementId) { return rows(mapper.receipts(customerId, statementId)); }
|
||||
/** 读取该客户结算修订历史。 */
|
||||
public List<Map<String, Object>> audits(long customerId, long settlementId) { return rows(mapper.audits(customerId, settlementId)); }
|
||||
}
|
||||
+4
-25
@@ -135,7 +135,6 @@ public class BusinessFeeSourceEventService {
|
||||
positive(value.walletSettlement().holdId(), "walletSettlement.holdId");
|
||||
positive(value.walletSettlement().ledgerId(), "walletSettlement.ledgerId");
|
||||
if (blank(value.walletSettlement().commandEventId())) throw error("FIN-EVT-012", "缺少原资金命令引用");
|
||||
walletIdentity(value);
|
||||
} else if ("OVERSEAS_PROCUREMENT_FEE".equals(value.sourceType()) && "RECEIVABLE".equals(value.direction())) {
|
||||
throw error("FIN-EVT-012", "采购应收必须引用成功实扣资金,不能进入待收费池");
|
||||
}
|
||||
@@ -155,7 +154,7 @@ public class BusinessFeeSourceEventService {
|
||||
throw error("FIN-EVT-012", "应付来源供应商快照不一致");
|
||||
validateSettlement(value);
|
||||
} else if (!blank(value.supplierId()) || !blank(value.supplierName()) || !blank(value.supplierCategory())
|
||||
|| value.settlementMode() != null && !Set.of("BILL", "WALLET").contains(value.settlementMode()) || value.paidAt() != null
|
||||
|| !blank(value.settlementMode()) || value.paidAt() != null
|
||||
|| !blank(value.paymentMethod()) || !blank(value.paymentReferenceNo())
|
||||
|| (value.paymentVoucherFileIds() != null && !value.paymentVoucherFileIds().isEmpty())) {
|
||||
throw error("FIN-EVT-012", "应收来源不得携带供应商或付款事实");
|
||||
@@ -203,10 +202,9 @@ public class BusinessFeeSourceEventService {
|
||||
long customerId = positive(source.customerId(), "customerId");
|
||||
long holdId = positive(reference.holdId(), "holdId");
|
||||
long ledgerId = positive(reference.ledgerId(), "ledgerId");
|
||||
String[] identity = walletIdentity(source);
|
||||
List<Map<String, Object>> rows = jdbc.queryForList(
|
||||
"SELECT l.amount,c.result_json FROM fin_wallet_hold h JOIN fin_wallet_ledger l ON l.id=h.action_ledger_id AND l.customer_id=h.customer_id JOIN fin_wallet_command_inbox c ON c.event_id=? AND c.status='SUCCEEDED' WHERE h.id=? AND l.id=? AND h.customer_id=? AND h.business_type=? AND h.business_no=? AND h.status='SETTLED' AND l.change_type='CONSUMPTION' FOR UPDATE",
|
||||
reference.commandEventId(), holdId, ledgerId, customerId, identity[0], identity[1]);
|
||||
reference.commandEventId(), holdId, ledgerId, customerId, source.businessType(), source.businessNo());
|
||||
if (rows.size() != 1 || !(rows.get(0).get("amount") instanceof java.math.BigDecimal amount)
|
||||
|| amount.negate().compareTo(source.totalAmount()) != 0) {
|
||||
throw error("FIN-EVT-017", "费用来源与成功扣款的客户、业务或金额不匹配");
|
||||
@@ -220,8 +218,8 @@ public class BusinessFeeSourceEventService {
|
||||
|| !reference.holdId().equals(result.path("walletHoldId").asText())
|
||||
|| !reference.ledgerId().equals(result.path("walletLedgerId").asText())
|
||||
|| result.path("customerId").asLong() != customerId
|
||||
|| !identity[0].equals(result.path("businessType").asText())
|
||||
|| !identity[1].equals(result.path("businessNo").asText())
|
||||
|| !source.businessType().equals(result.path("businessType").asText())
|
||||
|| !source.businessNo().equals(result.path("businessNo").asText())
|
||||
|| result.path("amount").decimalValue().compareTo(source.totalAmount()) != 0) {
|
||||
throw error("FIN-EVT-017", "费用来源引用的资金结果不匹配");
|
||||
}
|
||||
@@ -236,25 +234,6 @@ public class BusinessFeeSourceEventService {
|
||||
feeSourceId);
|
||||
}
|
||||
|
||||
/** 独立锁款仅接收已约定的人工费用或仓租身份,不能借此挂接任意业务扣款。 */
|
||||
private String[] walletIdentity(BusinessFeeSourceChangedV1 source) {
|
||||
var reference = source.walletSettlement();
|
||||
String type = reference.walletBusinessType(), no = reference.walletBusinessNo();
|
||||
if (type == null && no == null) return new String[]{source.businessType(), source.businessNo()};
|
||||
if (blank(type) || blank(no) || !"LASTLEG".equals(source.sourceSystem()))
|
||||
throw error("FIN-EVT-017", "资金业务引用不完整");
|
||||
if ("OVERSEAS_MANUAL_FEE".equals(type)) {
|
||||
if ("FEE_TYPE_077".equals(source.feeItemCode())
|
||||
|| !no.matches(java.util.regex.Pattern.quote(source.sourceFeeId()) + "-[1-9][0-9]*"))
|
||||
throw error("FIN-EVT-017", "人工费用资金引用与来源不匹配");
|
||||
} else if ("STORAGE_RENT".equals(type)) {
|
||||
if (!"FEE_TYPE_077".equals(source.feeItemCode()))
|
||||
throw error("FIN-EVT-017", "仓租资金引用与费用项目不匹配");
|
||||
positive(no, "walletBusinessNo");
|
||||
} else throw error("FIN-EVT-017", "不支持的独立资金业务引用");
|
||||
return new String[]{type, no};
|
||||
}
|
||||
|
||||
private void materializeDirectPaidCost(String system, long feeId, BusinessFeeSourceChangedV1 value) {
|
||||
jdbc.update("INSERT INTO fin_cost(tenant_id,cost_no,cost_type,cost_origin,source_system,source_type,source_fee_id,source_fee_version,source_hash,order_id,inbound_id,container_id,warehouse_id,supplier_id,supplier_name_snapshot,fee_item_code,fee_item_name,currency_code,cost_amount,voucher_file_ids,paid_at,payment_method,payment_reference_no,status,confirmed_at,created_by,created_by_name,updated_by) VALUES(?,?,'SOURCE_DIRECT_PAID','BUSINESS_DIRECT_PAID',?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'CONFIRMED',CURRENT_TIMESTAMP(3),0,'系统',0)",
|
||||
positive(value.customerId(), "customerId"), "DIRECT-" + system + '-' + value.sourceType() + '-' + feeId + "-V" + value.sourceVersion(),
|
||||
|
||||
+35
-14
@@ -1,34 +1,55 @@
|
||||
package com.baiyue.finance.service;
|
||||
|
||||
import com.baiyue.common.core.number.DailyDocumentNumber;
|
||||
import com.baiyue.finance.repository.DocumentSequenceRepository;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
|
||||
/** 按北京时间生成两至四位前缀、八位日期和四位日流水。 */
|
||||
@Component
|
||||
public class FinanceDocumentNumberGenerator {
|
||||
private static final Set<String> PREFIXES = Set.of("REC", "AR", "AP", "RC", "AC", "VC", "VP", "PAY");
|
||||
private final DocumentSequenceRepository sequences;
|
||||
private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
|
||||
private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyMMdd", Locale.ROOT);
|
||||
private static final Set<String> SUPPORTED_PREFIXES = Set.of("REC", "AR", "AP", "RC", "AC", "VC", "VP");
|
||||
private static final long MAX_DAILY_SEQUENCE = 9_999L;
|
||||
|
||||
private final JdbcTemplate jdbc;
|
||||
private final Clock clock;
|
||||
|
||||
/** 正式运行使用统一业务时区。 */
|
||||
@Autowired
|
||||
public FinanceDocumentNumberGenerator(DocumentSequenceRepository sequences) {
|
||||
this(sequences, Clock.system(DailyDocumentNumber.BUSINESS_ZONE));
|
||||
public FinanceDocumentNumberGenerator(JdbcTemplate jdbc) {
|
||||
this(jdbc, Clock.system(BUSINESS_ZONE));
|
||||
}
|
||||
|
||||
/** 固定时钟用于跨日及前缀独立性验证。 */
|
||||
FinanceDocumentNumberGenerator(DocumentSequenceRepository sequences, Clock clock) {
|
||||
this.sequences = sequences;
|
||||
FinanceDocumentNumberGenerator(JdbcTemplate jdbc, Clock clock) {
|
||||
this.jdbc = jdbc;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
/** 只为本服务已登记的业务类型分配号码。 */
|
||||
@Transactional(propagation = Propagation.MANDATORY)
|
||||
public String next(String prefix) {
|
||||
if (!PREFIXES.contains(prefix)) throw new IllegalArgumentException("未登记的业务编号前缀:" + prefix);
|
||||
return DailyDocumentNumber.next(prefix, clock, sequences::next);
|
||||
if (!SUPPORTED_PREFIXES.contains(prefix)) {
|
||||
throw new IllegalArgumentException("unsupported finance document prefix: " + prefix);
|
||||
}
|
||||
LocalDate sequenceDate = LocalDate.now(clock);
|
||||
String sequenceKey = prefix + ":" + DateTimeFormatter.BASIC_ISO_DATE.format(sequenceDate);
|
||||
jdbc.update(
|
||||
"INSERT INTO fin_document_sequence(sequence_key,current_value) " +
|
||||
"VALUES(?,LAST_INSERT_ID(1)) ON DUPLICATE KEY UPDATE " +
|
||||
"current_value=LAST_INSERT_ID(current_value+1)",
|
||||
sequenceKey);
|
||||
Long sequence = jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class);
|
||||
if (sequence == null || sequence <= 0 || sequence > MAX_DAILY_SEQUENCE) {
|
||||
throw new FinanceEventException("FIN_409", "当日" + prefix + "单据编号已用尽");
|
||||
}
|
||||
return prefix + "-" + DATE_FORMAT.format(sequenceDate) + "-" +
|
||||
String.format(Locale.ROOT, "%04d", sequence);
|
||||
}
|
||||
}
|
||||
|
||||
+62
-21
@@ -1,10 +1,10 @@
|
||||
package com.baiyue.finance.service;
|
||||
|
||||
import com.baiyue.finance.repository.FinanceFeeSourceReadRepository;
|
||||
import com.baiyue.finance.dto.FinancePhase1Dtos.AccountSummary;
|
||||
import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceDetail;
|
||||
import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceSummary;
|
||||
import com.baiyue.finance.dto.FinancePhase1Dtos.LedgerEntry;
|
||||
import com.baiyue.finance.dto.FinancePhase1Dtos.OperationLog;
|
||||
import com.baiyue.finance.dto.FinancePhase1Dtos.PageEnvelope;
|
||||
import com.baiyue.finance.dto.FinancePhase1Dtos.ReviewRequest;
|
||||
import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceActionRequest;
|
||||
@@ -12,7 +12,6 @@ import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceEditRequest;
|
||||
import com.baiyue.finance.client.FirstlegFeeSourceClient;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
import feign.FeignException;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
@@ -44,19 +43,19 @@ public class FinancePhase1OperationService {
|
||||
private final ObjectMapper objectMapper;
|
||||
private final FirstlegFeeSourceClient firstleg;
|
||||
private final String internalToken;
|
||||
private final FinanceFeeSourceReadRepository feeSources;
|
||||
|
||||
/** 注入费用来源查询及现有业务依赖。 */
|
||||
@Autowired
|
||||
public FinancePhase1OperationService(JdbcTemplate jdbc, ObjectMapper objectMapper,
|
||||
FirstlegFeeSourceClient firstleg,
|
||||
@Value("${baiyue.security.internal-service-token}") String internalToken,
|
||||
FinanceFeeSourceReadRepository feeSources) {
|
||||
@Value("${baiyue.security.internal-service-token}") String internalToken) {
|
||||
this.jdbc = jdbc;
|
||||
this.objectMapper = objectMapper;
|
||||
this.firstleg = firstleg;
|
||||
this.internalToken = internalToken;
|
||||
this.feeSources = feeSources;
|
||||
}
|
||||
|
||||
public FinancePhase1OperationService(JdbcTemplate jdbc, ObjectMapper objectMapper) {
|
||||
this(jdbc, objectMapper, null, "");
|
||||
}
|
||||
|
||||
public PageEnvelope<AccountSummary> listDerivedCustomerAccounts(int page, int pageSize, String customerName, String status) {
|
||||
@@ -163,7 +162,6 @@ public class FinancePhase1OperationService {
|
||||
return PageEnvelope.success(List.copyOf(projected.subList(from, to)), page, pageSize, projected.size());
|
||||
}
|
||||
|
||||
/** 按同一来源费用的当前版本分页,调整历史不重复计入客户应收应付。 */
|
||||
public PageEnvelope<FeeSourceSummary> listFinanceFeeSources(int page, int pageSize, Long customerId,
|
||||
String direction, String sourceStatus,
|
||||
String reviewStatus,
|
||||
@@ -179,9 +177,34 @@ public class FinancePhase1OperationService {
|
||||
List.of("DRAFT", "PENDING", "APPROVED", "REJECTED"), "审核状态无效");
|
||||
String normalizedSourceStatus = normalized(sourceStatus,
|
||||
List.of("AVAILABLE", "LOCKED", "BILLED", "SETTLED", "VOIDED", "SUPERSEDED"), "费用来源状态无效");
|
||||
Map<String, Object> filters = feeSources.filters(customerId, normalizedDirection, normalizedSourceStatus,
|
||||
normalizedReview, statementGenerated, businessDateFrom, businessDateTo, blankToNull(keyword), page, pageSize);
|
||||
return PageEnvelope.success(feeSources.list(filters), page, pageSize, feeSources.count(filters));
|
||||
String generatedSql = "EXISTS(SELECT 1 FROM fin_bill_line bl WHERE bl.source_id=f.id AND bl.source_version=f.source_fee_version)";
|
||||
String effectiveStatusSql = "CASE WHEN f.status='AVAILABLE' AND " + generatedSql + " THEN 'BILLED' ELSE f.status END";
|
||||
String businessDateSql = "STR_TO_DATE(JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.businessDate')),'%Y-%m-%d')";
|
||||
StringBuilder where = new StringBuilder(" FROM fin_fee_source f WHERE 1=1");
|
||||
List<Object> args = new ArrayList<>();
|
||||
if (customerId != null) { where.append(" AND f.customer_id=?"); args.add(customerId); }
|
||||
if (normalizedDirection != null) { where.append(" AND f.direction=?"); args.add(normalizedDirection); }
|
||||
if (normalizedReview != null) { where.append(" AND f.review_status=?"); args.add(normalizedReview); }
|
||||
if (normalizedSourceStatus != null) { where.append(" AND ").append(effectiveStatusSql).append("=?"); args.add(normalizedSourceStatus); }
|
||||
if (statementGenerated != null) { where.append(" AND ").append(generatedSql).append("=?"); args.add(statementGenerated); }
|
||||
if (businessDateFrom != null) { where.append(" AND ").append(businessDateSql).append(">=?"); args.add(businessDateFrom); }
|
||||
if (businessDateTo != null) { where.append(" AND ").append(businessDateSql).append("<=?"); args.add(businessDateTo); }
|
||||
String normalizedKeyword = blankToNull(keyword);
|
||||
if (normalizedKeyword != null) {
|
||||
where.append(" AND (f.source_no LIKE CONCAT('%',?,'%') OR f.fee_item_name LIKE CONCAT('%',?,'%')")
|
||||
.append(" OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.customerName')) LIKE CONCAT('%',?,'%')")
|
||||
.append(" OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.inboundCode')) LIKE CONCAT('%',?,'%')")
|
||||
.append(" OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.containerNo')) LIKE CONCAT('%',?,'%')")
|
||||
.append(" OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.supplierName')) LIKE CONCAT('%',?,'%'))");
|
||||
for (int i = 0; i < 6; i++) args.add(normalizedKeyword);
|
||||
}
|
||||
Long total = jdbc.queryForObject("SELECT COUNT(*)" + where, Long.class, args.toArray());
|
||||
List<Object> queryArgs = new ArrayList<>(args);
|
||||
queryArgs.add(pageSize); queryArgs.add((page - 1) * pageSize);
|
||||
String select = "SELECT f.*," + generatedSql + " statement_generated," + effectiveStatusSql + " effective_status" +
|
||||
where + " ORDER BY " + businessDateSql + " DESC,f.id DESC LIMIT ? OFFSET ?";
|
||||
List<FeeSourceSummary> rows = jdbc.query(select, (rs, n) -> summary(rs), queryArgs.toArray());
|
||||
return PageEnvelope.success(rows, page, pageSize, total == null ? 0 : total);
|
||||
}
|
||||
|
||||
public FeeSourceDetail getFinanceFeeSource(long feeSourceId) {
|
||||
@@ -315,7 +338,6 @@ public class FinancePhase1OperationService {
|
||||
return after;
|
||||
}
|
||||
|
||||
/** 读取指定不可变版本,并附带同客户同方向的费用调整历史。 */
|
||||
private FeeSourceDetail detail(ResultSet rs) throws SQLException {
|
||||
JsonNode snapshot = parse(rs.getString("source_snapshot"));
|
||||
long id = rs.getLong("id");
|
||||
@@ -333,7 +355,33 @@ public class FinancePhase1OperationService {
|
||||
amount, rs.getString("currency_code"), firstText(text(snapshot, "originalCurrency"), rs.getString("currency_code")), originalAmount,
|
||||
firstDecimal(decimal(snapshot, "exchangeRate"), BigDecimal.ONE), text(snapshot, "exchangeRateMonth"),
|
||||
defaultString(rs.getString("review_status"), "DRAFT"), status, generated,
|
||||
rs.getString("review_reason"), rs.getString("source_hash"), rs.getInt("operation_version"), feeSources.logs(rs.getLong("customer_id"), id));
|
||||
rs.getString("review_reason"), rs.getString("source_hash"), rs.getInt("operation_version"), logs(rs.getLong("customer_id"), id));
|
||||
}
|
||||
|
||||
private FeeSourceSummary summary(ResultSet rs) throws SQLException {
|
||||
JsonNode snapshot = parse(rs.getString("source_snapshot"));
|
||||
boolean generated = rs.getBoolean("statement_generated");
|
||||
BigDecimal quantity = decimal(snapshot, "chargeableQuantity");
|
||||
BigDecimal amount = money(rs, "amount");
|
||||
BigDecimal originalAmount = firstDecimal(decimal(snapshot, "originalAmount"), amount);
|
||||
BigDecimal unitPrice = firstDecimal(decimal(snapshot, "unitPrice"), quantity != null && quantity.signum() > 0
|
||||
? originalAmount.divide(quantity, 6, java.math.RoundingMode.HALF_UP) : null);
|
||||
return new FeeSourceSummary(rs.getLong("id"), rs.getString("source_system"), rs.getString("source_type"),
|
||||
firstText(rs.getString("source_no"), text(snapshot, "sourceBusinessId")), rs.getLong("source_fee_id"), rs.getInt("source_fee_version"),
|
||||
rs.getString("direction"), rs.getLong("customer_id"), text(snapshot, "customerName"),
|
||||
jsonLong(snapshot, "supplierId"), text(snapshot, "supplierName"), nullableLong(rs, "order_id"),
|
||||
nullableLong(rs, "inbound_id"), text(snapshot, "inboundCode"), nullableLong(rs, "container_id"),
|
||||
text(snapshot, "containerNo"), date(snapshot, "businessDate"), rs.getString("fee_item_name"),
|
||||
quantity, text(snapshot, "quantityUnit"), unitPrice, nullableDate(snapshot, "periodStart"), nullableDate(snapshot, "periodEnd"),
|
||||
amount, rs.getString("currency_code"), firstText(text(snapshot, "originalCurrency"), rs.getString("currency_code")), originalAmount,
|
||||
firstDecimal(decimal(snapshot, "exchangeRate"), BigDecimal.ONE), text(snapshot, "exchangeRateMonth"),
|
||||
defaultString(rs.getString("review_status"), "DRAFT"),
|
||||
rs.getString("effective_status"), generated);
|
||||
}
|
||||
|
||||
private List<OperationLog> logs(long customerId, long id) {
|
||||
return jdbc.query("SELECT created_at,action,operator_name_snapshot,reason FROM fin_audit_log WHERE tenant_id=? AND resource_type='FIN_FEE_SOURCE' AND resource_id=? ORDER BY created_at DESC,id DESC", (rs, n) ->
|
||||
new OperationLog(time(rs, "created_at"), rs.getString("action"), rs.getString("operator_name_snapshot"), "SUCCESS", rs.getString("reason")), customerId, id);
|
||||
}
|
||||
|
||||
private Long reserve(long customerId, long operatorId, String operation, String key, String method, String path, Object body) {
|
||||
@@ -389,14 +437,7 @@ public class FinancePhase1OperationService {
|
||||
private static Long nullableLong(ResultSet rs, String column) throws SQLException { long value = rs.getLong(column); return rs.wasNull() ? null : value; }
|
||||
private JsonNode parse(String json) { try { return objectMapper.readTree(json); } catch (Exception e) { throw error("FIN-DATA-500", "费用来源快照损坏"); } }
|
||||
private String json(Object value) { try { return objectMapper.writeValueAsString(value); } catch (Exception e) { throw error("FIN-DATA-500", "财务数据序列化失败"); } }
|
||||
/** 固定映射键序,使同一请求在服务重启前后使用相同摘要。 */
|
||||
private String hash(Object value) {
|
||||
try {
|
||||
byte[] canonical = objectMapper.writer().with(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS)
|
||||
.writeValueAsBytes(value);
|
||||
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(canonical));
|
||||
} catch (Exception failure) { throw error("FIN-DATA-500", "幂等请求哈希失败"); }
|
||||
}
|
||||
private String hash(Object value) { try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json(value).getBytes(StandardCharsets.UTF_8))); } catch (Exception e) { throw error("FIN-DATA-500", "幂等请求哈希失败"); } }
|
||||
private static String text(JsonNode node, String field) { JsonNode value = node.path(field); return value.isMissingNode() || value.isNull() ? null : value.asText(); }
|
||||
private static Long jsonLong(JsonNode node, String field) {
|
||||
JsonNode value = node.path(field);
|
||||
|
||||
@@ -4,7 +4,6 @@ import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.support.GeneratedKeyHolder;
|
||||
import org.springframework.jdbc.support.KeyHolder;
|
||||
@@ -307,14 +306,7 @@ public class FinanceService {
|
||||
default -> throw new IllegalArgumentException("未知财务幂等操作: " + operation);
|
||||
};
|
||||
}
|
||||
/** 固定请求映射键序,避免服务重启后拒绝同一财务命令。 */
|
||||
private String hash(Object request) {
|
||||
try {
|
||||
byte[] canonical = objectMapper.writer().with(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS)
|
||||
.writeValueAsBytes(request == null ? List.of() : request);
|
||||
return java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(canonical));
|
||||
} catch (Exception ex) { throw new IllegalStateException("请求哈希失败", ex); }
|
||||
}
|
||||
private String hash(Object request) { try { byte[] bytes = java.security.MessageDigest.getInstance("SHA-256").digest(json(request).getBytes(java.nio.charset.StandardCharsets.UTF_8)); return java.util.HexFormat.of().formatHex(bytes); } catch (Exception ex) { throw new IllegalStateException("请求哈希失败", ex); } }
|
||||
|
||||
private static void requireCustomer(long tenantId) { if (tenantId <= 0) throw bad("客户范围无效"); }
|
||||
private static BizException bad(String message) { return new BizException(ResultCode.BAD_REQUEST, message); }
|
||||
|
||||
@@ -12,7 +12,6 @@ import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementActionRequest;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
import org.slf4j.MDC;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.support.GeneratedKeyHolder;
|
||||
@@ -45,29 +44,44 @@ import java.util.UUID;
|
||||
public class FinanceSettlementService {
|
||||
private static final String CNY = "CNY";
|
||||
private static final Set<String> PAYMENT_METHODS = Set.of("BANK_TRANSFER", "CASH", "OTHER");
|
||||
private static final String SETTLEMENT_SUMMARY_SELECT = com.baiyue.finance.mapper.FinanceSettlementDetailMapper.SUMMARY_SELECT;
|
||||
private static final String SETTLEMENT_SUMMARY_SELECT =
|
||||
"SELECT s.id,s.tenant_id,s.settlement_no,s.direction,s.customer_name_snapshot,s.supplier_id," +
|
||||
"s.supplier_name_snapshot,s.reconciliation_statement_id,b.bill_no reconciliation_statement_no," +
|
||||
"s.business_period,(SELECT GROUP_CONCAT(DISTINCT fs.fee_item_name ORDER BY fs.fee_item_name SEPARATOR '、') " +
|
||||
"FROM fin_settlement_line sl JOIN fin_fee_source fs ON fs.id=sl.fee_source_id " +
|
||||
"WHERE sl.tenant_id=s.tenant_id AND sl.settlement_id=s.id) fee_item_summary," +
|
||||
"s.currency_code,s.total_amount,s.paid_amount,s.outstanding_amount,s.generated_date,s.due_date," +
|
||||
"s.payment_status,s.status,s.created_by,(SELECT a.operator_name_snapshot FROM fin_audit_log a " +
|
||||
"WHERE a.tenant_id=s.tenant_id AND a.resource_type='FIN_SETTLEMENT' AND a.resource_id=s.id " +
|
||||
"AND a.action='CREATE' ORDER BY a.created_at,a.id LIMIT 1) created_by_name,s.created_at,s.updated_at," +
|
||||
"CASE WHEN s.direction='RECEIVABLE' THEN (SELECT MAX(r.created_at) FROM fin_receipt_bill rb " +
|
||||
"JOIN fin_receipt r ON r.id=rb.receipt_id AND r.tenant_id=rb.tenant_id " +
|
||||
"WHERE rb.tenant_id=s.tenant_id AND rb.bill_id=s.reconciliation_statement_id AND r.status='CONFIRMED') " +
|
||||
"ELSE (SELECT MAX(p.created_at) FROM fin_settlement_payment p WHERE p.tenant_id=s.tenant_id " +
|
||||
"AND p.settlement_id=s.id) END last_payment_at,s.version FROM fin_settlement s " +
|
||||
"LEFT JOIN fin_bill b ON b.tenant_id=s.tenant_id AND b.id=s.reconciliation_statement_id ";
|
||||
private final JdbcTemplate jdbc;
|
||||
private final com.baiyue.finance.repository.FinanceSettlementDetailRepository details;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final FinanceVoucherValidator voucherValidator;
|
||||
private final FinanceSupplierValidator supplierValidator;
|
||||
private final FinanceDocumentNumberGenerator documentNumbers;
|
||||
|
||||
/** 注入统一编号组件,确保所有业务入口共用受事务保护的日流水。 */
|
||||
public FinanceSettlementService(JdbcTemplate jdbc, ObjectMapper objectMapper, FinanceVoucherValidator voucherValidator,
|
||||
FinanceSupplierValidator supplierValidator) {
|
||||
this(jdbc, objectMapper, voucherValidator, supplierValidator, new FinanceDocumentNumberGenerator(jdbc));
|
||||
}
|
||||
|
||||
@Autowired
|
||||
public FinanceSettlementService(JdbcTemplate jdbc, ObjectMapper objectMapper, FinanceVoucherValidator voucherValidator,
|
||||
FinanceSupplierValidator supplierValidator,
|
||||
FinanceDocumentNumberGenerator documentNumbers,
|
||||
com.baiyue.finance.repository.FinanceSettlementDetailRepository details) {
|
||||
FinanceDocumentNumberGenerator documentNumbers) {
|
||||
this.jdbc = jdbc;
|
||||
this.details = details;
|
||||
this.objectMapper = objectMapper;
|
||||
this.voucherValidator = voucherValidator;
|
||||
this.supplierValidator = supplierValidator;
|
||||
this.documentNumbers = documentNumbers;
|
||||
}
|
||||
|
||||
/** 创建应收结算单,幂等请求恢复原单,首次建单分配 AR 日流水。 */
|
||||
@Transactional
|
||||
public SettlementDetail createReceivableSettlement(long operatorId, String key,
|
||||
CreateReceivableSettlementRequest request) {
|
||||
@@ -171,21 +185,33 @@ public class FinanceSettlementService {
|
||||
return getSettlement(settlementId);
|
||||
}
|
||||
|
||||
/** 读取结算及其业务流水号;全部详情数据访问经Repository和Mapper。 */
|
||||
public SettlementDetail getSettlement(long settlementId) {
|
||||
if (settlementId <= 0) throw error("FIN_404", "结算单不存在");
|
||||
List<Map<String, Object>> records = details.summary(settlementId);
|
||||
if (records.isEmpty()) throw error("FIN_404", "结算单不存在");
|
||||
SettlementSummary summary = summary(records.get(0));
|
||||
List<SettlementLine> lines = details.lines(summary.customerId(), settlementId)
|
||||
Map<String, Object> row = one(
|
||||
SETTLEMENT_SUMMARY_SELECT + "WHERE s.id=?",
|
||||
"结算单不存在", settlementId);
|
||||
SettlementSummary summary = summary(row);
|
||||
List<SettlementLine> lines = jdbc.queryForList(
|
||||
"SELECT f.id fee_source_id,f.source_type,f.source_fee_id,f.source_fee_version,f.direction,f.customer_id," +
|
||||
"f.order_id,f.inbound_id,f.container_id,f.fee_item_name,f.amount,f.currency_code,f.review_status,f.status,f.source_snapshot " +
|
||||
"FROM fin_settlement_line sl JOIN fin_fee_source f ON f.id=sl.fee_source_id " +
|
||||
"WHERE sl.tenant_id=? AND sl.settlement_id=? ORDER BY sl.id", summary.customerId(), settlementId)
|
||||
.stream().map(source -> line(source, summary)).toList();
|
||||
List<SettlementTransaction> transactions = new ArrayList<>(details.payments(summary.customerId(), settlementId)
|
||||
.stream().map(this::transaction).toList());
|
||||
List<SettlementTransaction> transactions = new ArrayList<>(jdbc.queryForList(
|
||||
"SELECT id,transaction_type,transaction_date,amount,method,reference_no,voucher_file_ids,created_by,created_by_name,created_at,remark " +
|
||||
"FROM fin_settlement_payment WHERE tenant_id=? AND settlement_id=? ORDER BY created_at,id",
|
||||
summary.customerId(), settlementId).stream().map(this::transaction).toList());
|
||||
if ("RECEIVABLE".equals(summary.direction()) && summary.reconciliationStatementId() != null) {
|
||||
transactions.addAll(details.receipts(summary.customerId(), summary.reconciliationStatementId())
|
||||
.stream().map(this::transaction).toList());
|
||||
transactions.addAll(jdbc.queryForList(
|
||||
"SELECT r.id,'RECEIPT' transaction_type,DATE(r.received_at) transaction_date,rb.applied_amount amount,r.payment_method method,r.voucher_no reference_no,r.voucher_file_ids,r.created_by,r.created_by_name,r.created_at,r.remark " +
|
||||
"FROM fin_receipt_bill rb JOIN fin_receipt r ON r.id=rb.receipt_id AND r.tenant_id=rb.tenant_id " +
|
||||
"WHERE rb.tenant_id=? AND rb.bill_id=? AND r.status='CONFIRMED' ORDER BY r.created_at,r.id",
|
||||
summary.customerId(), summary.reconciliationStatementId()).stream().map(this::transaction).toList());
|
||||
}
|
||||
List<Map<String, Object>> audits = details.audits(summary.customerId(), settlementId);
|
||||
List<Map<String, Object>> audits = jdbc.queryForList(
|
||||
"SELECT action,reason,before_data,after_data,operator_id,operator_name_snapshot,created_at FROM fin_audit_log " +
|
||||
"WHERE tenant_id=? AND resource_type='FIN_SETTLEMENT' AND resource_id=? ORDER BY created_at,id",
|
||||
summary.customerId(), settlementId);
|
||||
List<SettlementRevision> revisions = new ArrayList<>();
|
||||
for (int index = 0; index < audits.size(); index++) revisions.add(revision(index + 1, audits.get(index)));
|
||||
return new SettlementDetail(summary, lines, transactions, List.copyOf(revisions));
|
||||
@@ -453,9 +479,8 @@ public class FinanceSettlementService {
|
||||
text(row, "status"), true);
|
||||
}
|
||||
|
||||
/** 返回真实付款业务号;未生成PAY号的收款记录保持为空,不回退成内部ID。 */
|
||||
private SettlementTransaction transaction(Map<String, Object> row) {
|
||||
return new SettlementTransaction(number(row, "id").longValue(), text(row, "transaction_no"), text(row, "transaction_type"),
|
||||
return new SettlementTransaction(number(row, "id").longValue(), text(row, "transaction_type"),
|
||||
date(row, "transaction_date"), decimal(row, "amount"), text(row, "method"), text(row, "reference_no"),
|
||||
stringList(text(row, "voucher_file_ids")), number(row, "created_by").longValue(),
|
||||
text(row, "created_by_name"),
|
||||
@@ -496,18 +521,15 @@ public class FinanceSettlementService {
|
||||
"outstandingAmount", decimal(settlement, "outstanding_amount"), "status", status);
|
||||
}
|
||||
|
||||
/** 结算收付款使用独立 PAY 日流水,重试由原业务幂等记录恢复。 */
|
||||
private String transactionNo() {
|
||||
return documentNumbers.next("PAY");
|
||||
return "PAY-" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE) + "-" +
|
||||
UUID.randomUUID().toString().replace("-", "").substring(0, 12).toUpperCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
/** 固定映射键序,避免 JVM 重启改变 Map.of 遍历顺序而误拒绝原请求。 */
|
||||
private String hash(Object value) {
|
||||
try {
|
||||
String canonical = objectMapper.writer()
|
||||
.with(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS).writeValueAsString(value);
|
||||
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
|
||||
.digest(canonical.getBytes(StandardCharsets.UTF_8)));
|
||||
.digest(json(value).getBytes(StandardCharsets.UTF_8)));
|
||||
} catch (Exception failure) {
|
||||
throw error("FIN_001", "幂等请求哈希失败");
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ import com.baiyue.finance.dto.FinanceWalletDtos.WalletHoldActionRequest;
|
||||
import com.baiyue.finance.dto.FinanceWalletDtos.WalletLedgerEntry;
|
||||
import com.baiyue.finance.dto.FinanceWalletDtos.WalletPage;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
@@ -695,9 +694,8 @@ public class FinanceWalletService {
|
||||
timestamp(rs.getTimestamp("reviewed_at")));
|
||||
}
|
||||
|
||||
/** 摘要固定映射键序,保证充值和资金指令跨进程重放稳定。 */
|
||||
private String hash(Object value) {
|
||||
try { return sha256(objectMapper.writer().with(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS).writeValueAsBytes(value)); }
|
||||
try { return sha256(objectMapper.writeValueAsBytes(value)); }
|
||||
catch (Exception exception) { throw error("FIN_500", "请求摘要生成失败"); }
|
||||
}
|
||||
private String fileHash(MultipartFile file) {
|
||||
|
||||
+6
-9
@@ -10,7 +10,6 @@ import com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationLine;
|
||||
import com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationRevision;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
import org.slf4j.MDC;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.support.GeneratedKeyHolder;
|
||||
@@ -47,7 +46,10 @@ public class ReconciliationStatementService {
|
||||
private final ObjectMapper objectMapper;
|
||||
private final FinanceDocumentNumberGenerator documentNumbers;
|
||||
|
||||
/** 注入统一编号组件,确保所有业务入口共用受事务保护的日流水。 */
|
||||
public ReconciliationStatementService(JdbcTemplate jdbc, ObjectMapper objectMapper) {
|
||||
this(jdbc, objectMapper, new FinanceDocumentNumberGenerator(jdbc));
|
||||
}
|
||||
|
||||
@Autowired
|
||||
public ReconciliationStatementService(JdbcTemplate jdbc, ObjectMapper objectMapper,
|
||||
FinanceDocumentNumberGenerator documentNumbers) {
|
||||
@@ -56,7 +58,6 @@ public class ReconciliationStatementService {
|
||||
this.documentNumbers = documentNumbers;
|
||||
}
|
||||
|
||||
/** 创建对账单并分配 REC 日流水,重复请求返回已创建记录。 */
|
||||
@Transactional
|
||||
public ReconciliationDetail create(long operatorId, String key, CreateReconciliationRequest request) {
|
||||
validateCreate(request);
|
||||
@@ -522,13 +523,9 @@ public class ReconciliationStatementService {
|
||||
catch (Exception exception) { throw error("FIN_001", "财务数据序列化失败"); }
|
||||
}
|
||||
|
||||
/** 固定映射键序,使同一请求在服务重启前后使用相同摘要。 */
|
||||
private String hash(Object value) {
|
||||
try {
|
||||
byte[] canonical = objectMapper.writer().with(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS)
|
||||
.writeValueAsBytes(value);
|
||||
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(canonical));
|
||||
} catch (Exception failure) { throw error("FIN_001", "幂等请求哈希失败"); }
|
||||
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json(value).getBytes(StandardCharsets.UTF_8))); }
|
||||
catch (Exception exception) { throw error("FIN_001", "幂等请求哈希失败"); }
|
||||
}
|
||||
|
||||
private static LocalDateTime localTime(Object value) {
|
||||
|
||||
@@ -398,7 +398,7 @@ CREATE TABLE IF NOT EXISTS fin_document_sequence (
|
||||
current_value INT UNSIGNED NOT NULL COMMENT '该类型当日已分配的最大序号',
|
||||
PRIMARY KEY (sequence_key),
|
||||
CONSTRAINT chk_fin_document_sequence_value CHECK (current_value > 0)
|
||||
) ENGINE=InnoDB COMMENT='财务业务单号日序列;生成前缀加yyyyMMdd加四位当日序号';
|
||||
) ENGINE=InnoDB COMMENT='Finance业务单号日序列;生成YYMMDD加四位当日序号';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fin_audit_log (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||
@@ -524,7 +524,7 @@ CREATE TABLE IF NOT EXISTS fin_wallet_account (
|
||||
CREATE TABLE IF NOT EXISTS fin_recharge_request (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||
customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和隔离键',
|
||||
request_no VARCHAR(32) NOT NULL COMMENT '充值申请业务编号RCyyyyMMdd####',
|
||||
request_no VARCHAR(32) NOT NULL COMMENT '充值申请业务编号RC-yyMMdd-####',
|
||||
amount DECIMAL(20,2) NOT NULL COMMENT '申请充值金额',
|
||||
voucher_file_id BIGINT UNSIGNED NOT NULL COMMENT '付款截图File ID',
|
||||
voucher_file_name VARCHAR(255) NOT NULL COMMENT '付款截图文件名快照',
|
||||
@@ -549,7 +549,7 @@ CREATE TABLE IF NOT EXISTS fin_recharge_request (
|
||||
CREATE TABLE IF NOT EXISTS fin_wallet_ledger (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||
customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和隔离键',
|
||||
business_no VARCHAR(32) NOT NULL COMMENT '账户变动业务编号ACyyyyMMdd####',
|
||||
business_no VARCHAR(32) NOT NULL COMMENT '账户变动业务编号AC-yyMMdd-####',
|
||||
change_type VARCHAR(16) NOT NULL COMMENT '变动类型:充值、扣款、冻结、解冻、消费或授信调整',
|
||||
amount DECIMAL(20,2) NOT NULL COMMENT '带方向变动金额,充值为正、扣款为负',
|
||||
actual_balance_after DECIMAL(20,2) NOT NULL COMMENT '变动后实际余额',
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: 平台资金账户关联退款
|
||||
version: 1.0.0
|
||||
description: 原消费保持不可变;退款记录关联原消费流水号,累计退款不得超过原消费金额,包含业务退款事件已退金额。
|
||||
paths:
|
||||
/api/platform/finance/v1/accounts/{customerId}/ledger/{ledgerId}/refunds:
|
||||
post:
|
||||
operationId: refundWalletConsumption
|
||||
summary: 原消费关联退款
|
||||
description: 仅平台 tenantId=0 且具有 platform:finance:customer-account:adjust 权限可操作。账户行锁串行校验累计可退金额,无需前端提交账户版本。优先归还已用授信,再增加实际余额。相同键同请求返回原流水;相同键不同原消费或金额失败。非成功消费、跨客户原流水、超额退款均拒绝。
|
||||
security:
|
||||
- bearerAuth: []
|
||||
parameters:
|
||||
- {name: customerId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
- {name: ledgerId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
- {name: Idempotency-Key, in: header, required: true, schema: {type: string, minLength: 1, maxLength: 128}}
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [amount, reason]
|
||||
properties:
|
||||
amount: {type: number, minimum: 0.01, maximum: 999999999999.99, multipleOf: 0.01}
|
||||
reason: {type: string, minLength: 1, maxLength: 500, description: 必填退款原因,不得仅空白}
|
||||
responses:
|
||||
'200':
|
||||
description: 统一响应 code=200 成功;业务失败使用 FIN-REQ-400、FIN-WALLET-404、FIN-IDEMPOTENCY-CONFLICT 或 FIN-WALLET-REFUND-EXCEEDED 等业务码。
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
code: {type: string}
|
||||
data:
|
||||
$ref: '#/components/schemas/RefundLedgerEntry'
|
||||
'403': {description: 非平台主体或缺少调账权限}
|
||||
components:
|
||||
securitySchemes:
|
||||
bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT}
|
||||
schemas:
|
||||
RefundLedgerEntry:
|
||||
type: object
|
||||
description: 完整返回 FinanceWalletDtos.WalletLedgerEntry,资金余额字段沿用账户流水协议。
|
||||
properties:
|
||||
ledgerId: {type: string}
|
||||
businessNo: {type: string, description: 新退款流水正式 AC 单号}
|
||||
changeType: {type: string, enum: [REFUND]}
|
||||
amount: {type: number, description: 实际退款正金额}
|
||||
sourceType: {type: string, enum: [BUSINESS_REFUND]}
|
||||
sourceNo: {type: string, description: 客户及幂等键派生的稳定技术退款引用}
|
||||
referenceNo: {type: string, description: 原消费流水 businessNo,用于关联及累计可退校验}
|
||||
operatorName: {type: string, nullable: true}
|
||||
remark: {type: string, description: 退款原因}
|
||||
actualBalanceAfter: {type: number}
|
||||
frozenBalanceAfter: {type: number}
|
||||
availableBalanceAfter: {type: number}
|
||||
creditUsedAfter: {type: number}
|
||||
availableCreditAfter: {type: number}
|
||||
@@ -286,82 +286,4 @@ class BusinessFeeSourceEventServiceTest {
|
||||
base.aggregateType(), base.aggregateId(), base.aggregateVersion(), null, base.traceId(), base.correlationId(), null,
|
||||
payload, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void manualConsumptionUsesDedicatedFundingIdentityWhilePreservingServiceOrder() {
|
||||
verifyDedicatedConsumption("OVERSEAS_MANUAL_FEE", "701-2", "FEE_TYPE_071");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rentConsumptionUsesPreparationIdentityWhilePreservingServiceOrder() {
|
||||
verifyDedicatedConsumption("STORAGE_RENT", "445", "FEE_TYPE_077");
|
||||
}
|
||||
|
||||
@Test
|
||||
void dedicatedFundingCannotReferenceAnotherManualFeeOrWrongFeeItem() {
|
||||
assertThatThrownBy(() -> service.consume(dedicatedEvent("OVERSEAS_MANUAL_FEE", "702-1", "FEE_TYPE_071")))
|
||||
.hasMessageContaining("来源不匹配");
|
||||
assertThatThrownBy(() -> service.consume(dedicatedEvent("STORAGE_RENT", "445", "FEE_TYPE_071")))
|
||||
.hasMessageContaining("项目不匹配");
|
||||
noFinancialWrites();
|
||||
}
|
||||
|
||||
@Test
|
||||
void dedicatedConsumptionCannotReuseHoldAlreadyLinkedToAnotherSource() {
|
||||
stubDedicatedConsumption("STORAGE_RENT", "445");
|
||||
when(jdbc.queryForList(startsWith("SELECT id FROM fin_fee_source WHERE wallet_hold_id="),
|
||||
eq(Long.class), eq(9L), eq(901L))).thenReturn(List.of(902L));
|
||||
assertThatThrownBy(() -> service.consume(dedicatedEvent("STORAGE_RENT", "445", "FEE_TYPE_077")))
|
||||
.hasMessageContaining("已关联其他费用");
|
||||
verify(jdbc, never()).update(startsWith("UPDATE fin_fee_source SET wallet_hold_id="), any(Object[].class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void dedicatedConsumptionRejectsResultFromDifferentFundingBusiness() {
|
||||
stubDedicatedConsumption("STORAGE_RENT", "445");
|
||||
when(jdbc.queryForList(startsWith("SELECT l.amount,c.result_json"), eq("command-1"), eq(9L), eq(72L),
|
||||
eq(10001L), eq("STORAGE_RENT"), eq("445"))).thenReturn(List.of(Map.of(
|
||||
"amount", new BigDecimal("-120.00"), "result_json", """
|
||||
{"result":"SUCCEEDED","operation":"SETTLE","commandEventId":"command-1",
|
||||
"walletHoldId":"9","walletLedgerId":"72","customerId":10001,
|
||||
"businessType":"STORAGE_RENT","businessNo":"446","amount":120.00}
|
||||
""")));
|
||||
assertThatThrownBy(() -> service.consume(dedicatedEvent("STORAGE_RENT", "445", "FEE_TYPE_077")))
|
||||
.hasMessageContaining("资金结果不匹配");
|
||||
}
|
||||
|
||||
private void verifyDedicatedConsumption(String type, String no, String item) {
|
||||
stubDedicatedConsumption(type, no);
|
||||
EventEnvelope event = dedicatedEvent(type, no, item);
|
||||
service.consume(event);
|
||||
verify(jdbc).update(startsWith("UPDATE fin_fee_source SET wallet_hold_id="), eq(9L), eq(901L), eq(10001L));
|
||||
assertThat(event.payload().path("businessType").asText()).isEqualTo("WAREHOUSE_SERVICE");
|
||||
assertThat(event.payload().path("businessNo").asText()).isEqualTo("PO-US-001");
|
||||
}
|
||||
|
||||
private void stubDedicatedConsumption(String type, String no) {
|
||||
when(jdbc.queryForObject(startsWith("SELECT id FROM fin_fee_source"), eq(Long.class),
|
||||
eq("LASTLEG"), eq("OVERSEAS_WAREHOUSE_FEE"), eq(701L), eq(1))).thenReturn(901L);
|
||||
ObjectNode result = json.createObjectNode();
|
||||
result.put("result", "SUCCEEDED").put("operation", "SETTLE").put("commandEventId", "command-1")
|
||||
.put("walletHoldId", "9").put("walletLedgerId", "72").put("customerId", 10001)
|
||||
.put("businessType", type).put("businessNo", no).put("amount", new BigDecimal("120.00"));
|
||||
when(jdbc.queryForList(startsWith("SELECT l.amount,c.result_json"), eq("command-1"), eq(9L), eq(72L),
|
||||
eq(10001L), eq(type), eq(no))).thenReturn(List.of(Map.of(
|
||||
"amount", new BigDecimal("-120.00"), "result_json", result.toString())));
|
||||
}
|
||||
|
||||
private EventEnvelope dedicatedEvent(String type, String no, String item) {
|
||||
EventEnvelope base = eventWithoutInbound();
|
||||
ObjectNode payload = base.payload().deepCopy();
|
||||
payload.put("feeItemCode", item);
|
||||
payload.putObject("walletSettlement").put("commandEventId", "command-1").put("holdId", "9")
|
||||
.put("ledgerId", "72").put("walletBusinessType", type).put("walletBusinessNo", no);
|
||||
payload.putNull("action"); payload.putNull("sourceHash");
|
||||
payload.put("sourceHash", EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload)));
|
||||
payload.put("action", "PUBLISHED");
|
||||
return new EventEnvelope(base.eventId(), base.eventType(), base.eventVersion(), base.occurredAt(), base.tenantId(),
|
||||
base.aggregateType(), base.aggregateId(), base.aggregateVersion(), null, base.traceId(), base.correlationId(), null,
|
||||
payload, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ class FinanceContractTest {
|
||||
.getAnnotation(org.springframework.scheduling.annotation.EnableScheduling.class));
|
||||
}
|
||||
|
||||
/** 固定财务访问域与账单关联键,查询迁入 Mapper 后仍核对真实来源记录。 */
|
||||
@Test
|
||||
void controllersUseFrozenFinancePaths() throws Exception {
|
||||
assertEquals("/api/platform/finance/v1", PlatformFinanceController.class.getAnnotation(org.springframework.web.bind.annotation.RequestMapping.class).value()[0]);
|
||||
@@ -41,8 +40,7 @@ class FinanceContractTest {
|
||||
assertTrue(controller.contains("costSnapshotClient.loadingPlan") && controller.contains("createCost(request.customerId(), user.userId(), internal, volumes, key)"));
|
||||
assertTrue(service.contains("validateVoucherFiles(tenantId, request.voucherFileIds())"));
|
||||
String operations = Files.readString(Path.of("src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java"));
|
||||
String feeReads = Files.readString(Path.of("src/main/java/com/baiyue/finance/mapper/FinanceFeeSourceReadMapper.java"));
|
||||
assertTrue(feeReads.contains("bl.source_id=f.id"));
|
||||
assertTrue(operations.contains("bl.source_id=f.id"));
|
||||
assertTrue(operations.contains("Boolean.class, id, rs.getInt(\"source_fee_version\")"));
|
||||
assertFalse(operations.contains("bl.source_id=f.source_fee_id"));
|
||||
String unified = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"));
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
package com.baiyue.finance;
|
||||
|
||||
import com.baiyue.finance.mapper.FinanceFeeSourceReadMapper;
|
||||
import com.baiyue.finance.repository.FinanceFeeSourceReadRepository;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
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.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.datasource.DriverManagerDataSource;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/** 隔离数据库执行正式 Mapper,验证版本归并与历史不会改变金额和客户归属。 */
|
||||
public class FinanceFeeSourceReadMapperTest {
|
||||
/** 应收和应付均以来源身份替换当前展示,同名费用、其他来源类型和系统保持独立。 */
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"RECEIVABLE", "PAYABLE"})
|
||||
void currentProjectionAndHistoryKeepStableFeeIdentity(String direction) {
|
||||
var data = new DriverManagerDataSource("jdbc:h2:mem:fee-current-" + UUID.randomUUID() + ";MODE=MySQL", "sa", "");
|
||||
var jdbc = new JdbcTemplate(data);
|
||||
// 保持一个会话存活;Mapper 使用同一隔离数据库,绝不访问共享测试环境。
|
||||
try (var connection = data.getConnection()) {
|
||||
jdbc.execute("CREATE ALIAS JSON_EXTRACT FOR 'com.baiyue.finance.FinanceFeeSourceReadMapperTest.jsonExtract'");
|
||||
jdbc.execute("CREATE ALIAS JSON_UNQUOTE FOR 'com.baiyue.finance.FinanceFeeSourceReadMapperTest.jsonUnquote'");
|
||||
jdbc.execute("CREATE TABLE fin_fee_source(id BIGINT PRIMARY KEY,source_system VARCHAR(32),source_type VARCHAR(32),source_fee_id BIGINT,source_fee_version INT,customer_id BIGINT,direction VARCHAR(16),source_no VARCHAR(64),fee_item_name VARCHAR(100),amount DECIMAL(20,6),currency_code VARCHAR(3),review_status VARCHAR(16),status VARCHAR(16),source_snapshot VARCHAR(2000),order_id BIGINT,inbound_id BIGINT,container_id BIGINT,created_at TIMESTAMP)");
|
||||
jdbc.execute("CREATE TABLE fin_bill_line(source_id BIGINT,source_version INT)");
|
||||
jdbc.execute("CREATE TABLE fin_audit_log(id BIGINT PRIMARY KEY,tenant_id BIGINT,resource_type VARCHAR(32),resource_id BIGINT,action VARCHAR(64),operator_name_snapshot VARCHAR(64),reason VARCHAR(100),before_data VARCHAR(1000),after_data VARCHAR(1000),created_at TIMESTAMP)");
|
||||
insert(jdbc, 1, "FIRSTLEG", "INBOUND_FEE", 700, 1, 9, direction, "SUPERSEDED", "100.00");
|
||||
insert(jdbc, 2, "FIRSTLEG", "INBOUND_FEE", 700, 2, 9, direction, "AVAILABLE", "120.00");
|
||||
insert(jdbc, 3, "FIRSTLEG", "INBOUND_FEE", 701, 1, 9, direction, "AVAILABLE", "80.00");
|
||||
insert(jdbc, 4, "FIRSTLEG", "INBOUND_FEE", 702, 1, 9, direction, "AVAILABLE", "50.00");
|
||||
insert(jdbc, 5, "FIRSTLEG", "INBOUND_FEE", 702, 2, 9, direction, "VOIDED", "70.00");
|
||||
insert(jdbc, 6, "LASTLEG", "INBOUND_FEE", 700, 1, 9, direction, "AVAILABLE", "33.00");
|
||||
insert(jdbc, 7, "FIRSTLEG", "CONTAINER_FEE", 700, 1, 9, direction, "AVAILABLE", "44.00");
|
||||
insert(jdbc, 8, "FIRSTLEG", "INBOUND_FEE", 703, 1, 10, direction, "AVAILABLE", "55.00");
|
||||
jdbc.update("INSERT INTO fin_audit_log VALUES(1,9,'FIN_FEE_SOURCE',1,'FEE_SOURCE_REVIEW_APPROVE','审核员','原费用审核',NULL,NULL,'2026-10-21 10:01:30')");
|
||||
jdbc.update("INSERT INTO fin_audit_log VALUES(2,9,'FIN_FEE_SOURCE',2,'FEE_SOURCE_ADJUST','财务员','更正费用','{\"amount\":100}','{\"amount\":120,\"currency\":\"CNY\"}','2026-10-21 10:03:00')");
|
||||
jdbc.update("INSERT INTO fin_audit_log VALUES(3,9,'FIN_FEE_SOURCE',3,'FEE_SOURCE_ADJUST','其他费用','不能合并',NULL,NULL,'2026-10-21 10:04:00')");
|
||||
jdbc.update("INSERT INTO fin_audit_log VALUES(4,10,'FIN_FEE_SOURCE',1,'FEE_SOURCE_ADJUST','其他客户','不能越界',NULL,NULL,'2026-10-21 10:04:00')");
|
||||
|
||||
var configuration = new Configuration(new Environment("test", new JdbcTransactionFactory(), data));
|
||||
configuration.addMapper(FinanceFeeSourceReadMapper.class);
|
||||
try (var session = new SqlSessionFactoryBuilder().build(configuration).openSession()) {
|
||||
var repository = new FinanceFeeSourceReadRepository(session.getMapper(FinanceFeeSourceReadMapper.class), new ObjectMapper());
|
||||
var filters = repository.filters(9L, direction, null, "APPROVED", false,
|
||||
LocalDate.of(2026, 10, 1), LocalDate.of(2026, 10, 31), "操作费", 1, 20);
|
||||
assertThat(repository.count(filters)).isEqualTo(5);
|
||||
var rows = repository.list(filters);
|
||||
assertThat(rows).extracting(row -> row.feeSourceId()).containsExactly(7L, 6L, 5L, 3L, 2L);
|
||||
assertThat(rows.stream().map(row -> row.amount()).reduce(BigDecimal.ZERO, BigDecimal::add))
|
||||
.isEqualByComparingTo("347.00");
|
||||
assertThat(rows).allSatisfy(row -> {
|
||||
assertThat(row.direction()).isEqualTo(direction);
|
||||
assertThat(row.customerId()).isEqualTo(9);
|
||||
assertThat(row.supplierId()).isEqualTo(79);
|
||||
assertThat(row.inboundCode()).isEqualTo("IN-9");
|
||||
assertThat(row.businessDate()).isEqualTo(LocalDate.of(2026, 10, 21));
|
||||
assertThat(row.originalAmount()).isEqualByComparingTo("200");
|
||||
assertThat(row.unitPrice()).isEqualByComparingTo("20");
|
||||
});
|
||||
filters.put("limit", 2);
|
||||
filters.put("offset", 2);
|
||||
assertThat(repository.count(filters)).isEqualTo(5);
|
||||
assertThat(repository.list(filters)).extracting(row -> row.feeSourceId()).containsExactly(5L, 3L);
|
||||
filters.put("limit", 20); filters.put("offset", 0); filters.put("status", "AVAILABLE");
|
||||
assertThat(repository.count(filters)).isEqualTo(4);
|
||||
assertThat(repository.list(filters)).extracting(row -> row.feeSourceId()).doesNotContain(4L, 5L);
|
||||
filters.put("status", "SUPERSEDED");
|
||||
assertThat(repository.list(filters)).extracting(row -> row.feeSourceId()).containsExactly(1L);
|
||||
|
||||
var logs = repository.logs(9, 2);
|
||||
assertThat(logs).hasSize(4);
|
||||
assertThat(logs).extracting(log -> log.action()).containsExactly(
|
||||
"FEE_SOURCE_ADJUST", "FEE_SOURCE_VERSION_ADJUST", "FEE_SOURCE_REVIEW_APPROVE", "FEE_SOURCE_PUBLISHED");
|
||||
assertThat(logs.get(0).remark()).isEqualTo("更正费用;金额 100 → 120 CNY");
|
||||
assertThat(logs.get(1).remark()).contains("来源版本 1 → 2", "100.000000 → 120.000000 CNY");
|
||||
assertThat(logs).noneSatisfy(log -> assertThat(log.operatorName()).startsWith("其他"));
|
||||
assertThat(repository.logs(9, 2)).isEqualTo(logs);
|
||||
assertThat(repository.logs(9, 1)).hasSize(2);
|
||||
assertThat(repository.logs(10, 2)).isEmpty();
|
||||
// 同毫秒必须先展示新版本;审计仍按 ID 倒序,不能反转金额差分或突破旧版本锚点。
|
||||
jdbc.update("UPDATE fin_fee_source SET created_at='2026-10-21 10:03:00.123' WHERE id IN (1,2)");
|
||||
jdbc.update("UPDATE fin_audit_log SET created_at='2026-10-21 10:03:00.123' WHERE id IN (1,2)");
|
||||
jdbc.update("INSERT INTO fin_audit_log VALUES(6,9,'FIN_FEE_SOURCE',2,'FEE_SOURCE_REVIEW_APPROVE','审核员','同毫秒后审计',NULL,NULL,'2026-10-21 10:03:00.123')");
|
||||
session.clearCache();
|
||||
var sameTimeLogs = repository.logs(9, 2);
|
||||
assertThat(sameTimeLogs).extracting(log -> log.action()).containsExactly(
|
||||
"FEE_SOURCE_VERSION_ADJUST", "FEE_SOURCE_PUBLISHED", "FEE_SOURCE_REVIEW_APPROVE",
|
||||
"FEE_SOURCE_ADJUST", "FEE_SOURCE_REVIEW_APPROVE");
|
||||
assertThat(sameTimeLogs.get(0).remark()).contains("来源版本 1 → 2", "100.000000 → 120.000000 CNY");
|
||||
assertThat(sameTimeLogs.get(1).remark()).contains("来源版本 1;金额 100.000000 CNY");
|
||||
assertThat(sameTimeLogs.subList(2, 5)).extracting(log -> log.remark()).containsExactly(
|
||||
"同毫秒后审计", "更正费用;金额 100 → 120 CNY", "原费用审核");
|
||||
assertThat(repository.logs(9, 2)).isEqualTo(sameTimeLogs);
|
||||
assertThat(repository.logs(9, 1)).extracting(log -> log.action()).containsExactly(
|
||||
"FEE_SOURCE_PUBLISHED", "FEE_SOURCE_REVIEW_APPROVE");
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fin_fee_source", Long.class)).isEqualTo(8);
|
||||
jdbc.update("INSERT INTO fin_bill_line VALUES(2,2)");
|
||||
filters.put("status", "BILLED"); filters.put("statementGenerated", true);
|
||||
assertThat(repository.count(filters)).isEqualTo(1);
|
||||
assertThat(repository.list(filters)).extracting(row -> row.feeSourceId()).containsExactly(2L);
|
||||
|
||||
// 财务内部费用原地改金额,其历史只取真实审计,不能伪造旧时间的同步金额。
|
||||
insert(jdbc, 9, "FINANCE", "MANUAL", 700, 1, 9, direction, "AVAILABLE", "120.00");
|
||||
jdbc.update("INSERT INTO fin_audit_log VALUES(5,9,'FIN_FEE_SOURCE',9,'FEE_SOURCE_ADJUST','财务员','更正人工费用','{\"amount\":100}','{\"amount\":120,\"currency\":\"CNY\"}','2026-10-21 10:05:00')");
|
||||
assertThat(repository.logs(9, 9)).singleElement().satisfies(log -> {
|
||||
assertThat(log.action()).isEqualTo("FEE_SOURCE_ADJUST");
|
||||
assertThat(log.remark()).isEqualTo("更正人工费用;金额 100 → 120 CNY");
|
||||
});
|
||||
}
|
||||
} catch (java.sql.SQLException failure) { throw new AssertionError(failure); }
|
||||
}
|
||||
|
||||
/** 准备具有相同名称、不同稳定来源的隔离费用快照。 */
|
||||
private static void insert(JdbcTemplate jdbc, long id, String system, String type, long fee, int version,
|
||||
long customer, String direction, String status, String amount) {
|
||||
jdbc.update("INSERT INTO fin_fee_source(id,source_system,source_type,source_fee_id,source_fee_version,customer_id,direction,source_no,fee_item_name,amount,currency_code,review_status,status,source_snapshot,created_at) VALUES(?,?,?,?,?,?,?,'IN-9','操作费',?,'CNY','APPROVED',?,?,?)",
|
||||
id, system, type, fee, version, customer, direction, new BigDecimal(amount), status,
|
||||
"{\"businessDate\":\"2026-10-21\",\"supplierId\":79,\"inboundCode\":\"IN-9\",\"chargeableQuantity\":10,\"originalAmount\":200}",
|
||||
java.sql.Timestamp.valueOf("2026-10-21 10:0" + version + ":00"));
|
||||
}
|
||||
|
||||
/** H2 仅补足 MySQL JSON 读取函数,实际筛选、关联和分页仍执行正式 SQL。 */
|
||||
public static String jsonExtract(String value, String path) throws Exception {
|
||||
var node = new ObjectMapper().readTree(value).path(path.substring(2));
|
||||
return node.isMissingNode() || node.isNull() ? null : node.toString();
|
||||
}
|
||||
|
||||
/** 对测试 JSON 标量解引号,保持 MySQL 空值语义。 */
|
||||
public static String jsonUnquote(String value) throws Exception {
|
||||
return value == null ? null : new ObjectMapper().readTree(value).asText();
|
||||
}
|
||||
}
|
||||
@@ -9,13 +9,10 @@ import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceDetail;
|
||||
import com.baiyue.finance.dto.FinancePhase1Dtos.PageEnvelope;
|
||||
import com.baiyue.finance.service.FinanceEventException;
|
||||
import com.baiyue.finance.service.FinancePhase1OperationService;
|
||||
import com.baiyue.finance.repository.FinanceFeeSourceReadRepository;
|
||||
import com.baiyue.finance.service.FinanceCustomerAccountService;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
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.ValueSource;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
@@ -43,9 +40,6 @@ import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.mockito.Mockito.spy;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.times;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class FinancePhase1OperationBehaviorTest {
|
||||
@@ -87,20 +81,18 @@ class FinancePhase1OperationBehaviorTest {
|
||||
assertThat(response.page().total()).isEqualTo(1);
|
||||
}
|
||||
|
||||
/** 无效客户在进入持久化查询前拒绝。 */
|
||||
@Test
|
||||
void ledgerRejectsInvalidCustomerBeforeAnyQuery() {
|
||||
FinancePhase1OperationService service = new FinancePhase1OperationService(mock(JdbcTemplate.class), objectMapper(), null, "", mock(FinanceFeeSourceReadRepository.class));
|
||||
FinancePhase1OperationService service = new FinancePhase1OperationService(mock(JdbcTemplate.class), objectMapper());
|
||||
assertThatThrownBy(() -> service.listDerivedCustomerAccountLedger(0, 1, 20, null, null, null))
|
||||
.isInstanceOf(FinanceEventException.class)
|
||||
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-REQ-400");
|
||||
}
|
||||
|
||||
/** 保留客户账户汇总的固定分页与金额口径。 */
|
||||
@Test
|
||||
void accountListReturnsFixedPagedDto() {
|
||||
AccountJdbc jdbc = new AccountJdbc();
|
||||
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper(), null, "", mock(FinanceFeeSourceReadRepository.class));
|
||||
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper());
|
||||
|
||||
PageEnvelope<FinancePhase1Dtos.AccountSummary> result =
|
||||
service.listDerivedCustomerAccounts(1, 20, "客户9", "ACTIVE");
|
||||
@@ -120,11 +112,10 @@ class FinancePhase1OperationBehaviorTest {
|
||||
assertThat(jdbc.accountListSql).doesNotContain("CAST(c.customer_id AS CHAR)");
|
||||
}
|
||||
|
||||
/** 所有派生流水查询保留目标客户范围。 */
|
||||
@Test
|
||||
void ledgerQueriesEveryDerivedSourceWithinCustomerScope() {
|
||||
AccountJdbc jdbc = new AccountJdbc();
|
||||
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper(), null, "", mock(FinanceFeeSourceReadRepository.class));
|
||||
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper());
|
||||
|
||||
PageEnvelope<FinancePhase1Dtos.LedgerEntry> result =
|
||||
service.listDerivedCustomerAccountLedger(9L, 1, 20, null, null, null);
|
||||
@@ -137,24 +128,58 @@ class FinancePhase1OperationBehaviorTest {
|
||||
.contains("fin_settlement_payment", "transaction_type='PAYMENT'", "s.direction='PAYABLE'"));
|
||||
}
|
||||
|
||||
/** 所有筛选在服务端进入同一版本投影,不在分页后按费用名称去重。 */
|
||||
@Test
|
||||
void feeSourceListUsesFormalFieldsAndAllActiveFilters() {
|
||||
var reads = mock(FinanceFeeSourceReadRepository.class);
|
||||
var filters = java.util.Map.<String, Object>of("query", "current");
|
||||
when(reads.filters(9L, "RECEIVABLE", "AVAILABLE", "APPROVED", false,
|
||||
LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), "IN-9", 2, 50)).thenReturn(filters);
|
||||
when(reads.list(filters)).thenReturn(List.of());
|
||||
when(reads.count(filters)).thenReturn(3L);
|
||||
var service = new FinancePhase1OperationService(mock(JdbcTemplate.class), objectMapper(), null, "", reads);
|
||||
void feeSourceListUsesFormalFieldsAndAllActiveFilters() throws Exception {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_fee_source"), eq(Long.class), any(Object[].class)))
|
||||
.thenReturn(1L);
|
||||
when(jdbc.query(startsWith("SELECT f.*"), any(RowMapper.class), any(Object[].class))).thenAnswer(invocation -> {
|
||||
@SuppressWarnings("unchecked") RowMapper<Object> mapper = invocation.getArgument(1);
|
||||
ResultSet rs = mock(ResultSet.class);
|
||||
when(rs.getLong("id")).thenReturn(88L);
|
||||
when(rs.getLong("source_fee_id")).thenReturn(9001L);
|
||||
when(rs.getInt("source_fee_version")).thenReturn(3);
|
||||
when(rs.getLong("customer_id")).thenReturn(9L);
|
||||
when(rs.getLong("order_id")).thenReturn(19L);
|
||||
when(rs.getLong("inbound_id")).thenReturn(29L);
|
||||
when(rs.getLong("container_id")).thenReturn(39L);
|
||||
when(rs.getString("source_system")).thenReturn("FIRSTLEG");
|
||||
when(rs.getString("source_type")).thenReturn("INBOUND_FEE");
|
||||
when(rs.getString("source_no")).thenReturn("IN-9");
|
||||
when(rs.getString("direction")).thenReturn("RECEIVABLE");
|
||||
when(rs.getString("fee_item_name")).thenReturn("入库操作费");
|
||||
when(rs.getString("currency_code")).thenReturn("CNY");
|
||||
when(rs.getString("review_status")).thenReturn("APPROVED");
|
||||
when(rs.getString("effective_status")).thenReturn("AVAILABLE");
|
||||
when(rs.getString("source_snapshot")).thenReturn("{\"customerName\":\"客户9\",\"supplierId\":\"79\",\"supplierName\":\"供应商A\",\"inboundCode\":\"IN-9\",\"containerNo\":\"CONT-9\",\"businessDate\":\"2026-08-11\",\"chargeableQuantity\":\"10\"}");
|
||||
when(rs.getBigDecimal("amount")).thenReturn(new BigDecimal("120.00"));
|
||||
when(rs.getBoolean("statement_generated")).thenReturn(false);
|
||||
return List.of(mapper.mapRow(rs, 0));
|
||||
});
|
||||
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper());
|
||||
|
||||
var result = service.listFinanceFeeSources(2, 50, 9L, "receivable", "available", "approved",
|
||||
false, LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), "IN-9");
|
||||
assertThat(result.page().total()).isEqualTo(3);
|
||||
assertThat(result.page().page()).isEqualTo(2);
|
||||
verify(reads).list(filters);
|
||||
|
||||
assertThat(result.page()).satisfies(meta -> {
|
||||
assertThat(meta.page()).isEqualTo(2);
|
||||
assertThat(meta.pageSize()).isEqualTo(50);
|
||||
assertThat(meta.total()).isEqualTo(1);
|
||||
});
|
||||
assertThat(result.data()).singleElement().satisfies(source -> {
|
||||
assertThat(source.sourceSystem()).isEqualTo("FIRSTLEG");
|
||||
assertThat(source.sourceBusinessId()).isEqualTo("IN-9");
|
||||
assertThat(source.inboundCode()).isEqualTo("IN-9");
|
||||
assertThat(source.containerNo()).isEqualTo("CONT-9");
|
||||
assertThat(source.supplierId()).isEqualTo(79L);
|
||||
assertThat(source.supplierName()).isEqualTo("供应商A");
|
||||
assertThat(source.reviewStatus()).isEqualTo("APPROVED");
|
||||
assertThat(source.businessDate()).isEqualTo(LocalDate.of(2026, 8, 11));
|
||||
assertThat(source.statementGenerated()).isFalse();
|
||||
});
|
||||
verify(jdbc).query(startsWith("SELECT f.*"), any(RowMapper.class), any(Object[].class));
|
||||
}
|
||||
|
||||
/** 流水返回业务单号和操作者快照。 */
|
||||
@Test
|
||||
void ledgerReturnsStableBusinessTypeDocumentAndOperatorNameSnapshot() throws Exception {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
@@ -172,7 +197,7 @@ class FinancePhase1OperationBehaviorTest {
|
||||
});
|
||||
when(jdbc.query(startsWith("SELECT id,receipt_no"), any(RowMapper.class), eq(9L))).thenReturn(List.of());
|
||||
when(jdbc.query(startsWith("SELECT id,cost_no"), any(RowMapper.class), eq(9L))).thenReturn(List.of());
|
||||
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper(), null, "", mock(FinanceFeeSourceReadRepository.class));
|
||||
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper());
|
||||
|
||||
PageEnvelope<FinancePhase1Dtos.LedgerEntry> result =
|
||||
service.listDerivedCustomerAccountLedger(9L, 1, 20, null, null, null);
|
||||
@@ -186,20 +211,18 @@ class FinancePhase1OperationBehaviorTest {
|
||||
});
|
||||
}
|
||||
|
||||
/** 保留费用审核状态读取。 */
|
||||
@Test
|
||||
void approvedFeeCannotBeEditedAsDraft() throws Exception {
|
||||
JdbcTemplate jdbc = feeSourceJdbc("REJECTED", false, 2);
|
||||
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper(), null, "", mock(FinanceFeeSourceReadRepository.class));
|
||||
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper());
|
||||
|
||||
assertThat(service.getFinanceFeeSource(88L).reviewStatus()).isEqualTo("REJECTED");
|
||||
}
|
||||
|
||||
/** 费用详情保持原客户与来源版本。 */
|
||||
@Test
|
||||
void feeSourceDetailUsesFixedDtoAndRealCustomerScope() throws Exception {
|
||||
FinancePhase1OperationService service = new FinancePhase1OperationService(
|
||||
feeSourceJdbc("APPROVED", true, 2), objectMapper(), null, "", mock(FinanceFeeSourceReadRepository.class));
|
||||
feeSourceJdbc("APPROVED", true, 2), objectMapper());
|
||||
|
||||
FeeSourceDetail detail = service.getFinanceFeeSource(88L);
|
||||
|
||||
@@ -209,11 +232,10 @@ class FinancePhase1OperationBehaviorTest {
|
||||
assertThat(detail.reviewStatus()).isEqualTo("APPROVED");
|
||||
}
|
||||
|
||||
/** 业务来源费用必须回到所属系统调整。 */
|
||||
@Test
|
||||
void firstlegFeeEditAndAdjustAreBlockedInsteadOfMutatingFinanceCopy() throws Exception {
|
||||
JdbcTemplate jdbc = feeSourceJdbc("REJECTED", false, 1);
|
||||
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper(), null, "", mock(FinanceFeeSourceReadRepository.class));
|
||||
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper());
|
||||
|
||||
var request = new FinancePhase1Dtos.FeeSourceEditRequest(
|
||||
new BigDecimal("120.00"), "更正金额", 1);
|
||||
@@ -225,7 +247,6 @@ class FinancePhase1OperationBehaviorTest {
|
||||
(Object[]) any());
|
||||
}
|
||||
|
||||
/** 头程作废等待权威来源事件,不能直接改财务副本。 */
|
||||
@Test
|
||||
void voidingNyexx2608180003DelegatesToFirstlegAndWaitsForEvent() throws Exception {
|
||||
JdbcTemplate jdbc = feeSourceJdbc("APPROVED", false, 2);
|
||||
@@ -236,7 +257,7 @@ class FinancePhase1OperationBehaviorTest {
|
||||
.thenReturn(ApiResponse.success(new FirstlegFeeSourceClient.CommandResult(
|
||||
"INBOUND_FEE", 700L, 3, "VOIDED", false)));
|
||||
FinancePhase1OperationService service = new FinancePhase1OperationService(
|
||||
jdbc, objectMapper(), firstleg, "internal-token", mock(FinanceFeeSourceReadRepository.class));
|
||||
jdbc, objectMapper(), firstleg, "internal-token");
|
||||
|
||||
FeeSourceDetail result = service.voidFinanceFeeSource(88L, 71L, "finance",
|
||||
"void-key", new FinancePhase1Dtos.FeeSourceActionRequest("测试作废", 2));
|
||||
@@ -257,7 +278,6 @@ class FinancePhase1OperationBehaviorTest {
|
||||
(Object[]) any());
|
||||
}
|
||||
|
||||
/** 审核以当前操作版本更新并追加同一记录的审计。 */
|
||||
@Test
|
||||
void reviewUsesExpectedVersionAndReturnsUpdatedDetail() throws Exception {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
@@ -269,6 +289,7 @@ class FinancePhase1OperationBehaviorTest {
|
||||
return List.of(mapper.mapRow(resultSet(review, false, version), 0));
|
||||
});
|
||||
when(jdbc.queryForObject(startsWith("SELECT COUNT(*)>0"), eq(Boolean.class), eq(88L), eq(3))).thenReturn(false);
|
||||
when(jdbc.query(startsWith("SELECT created_at,action"), any(RowMapper.class), eq(9L), eq(88L))).thenReturn(List.of());
|
||||
when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(9L), eq(71L),
|
||||
eq("FEE_SOURCE_REVIEW"), eq("review-key"))).thenReturn(List.of());
|
||||
when(jdbc.update(startsWith("INSERT INTO fin_idempotency_record"), eq(9L), eq(71L),
|
||||
@@ -280,7 +301,7 @@ class FinancePhase1OperationBehaviorTest {
|
||||
eq(71L), eq("操作人 71"), any(), any())).thenReturn(1);
|
||||
when(jdbc.update(startsWith("UPDATE fin_idempotency_record SET status='SUCCEEDED'"),
|
||||
eq(88L), eq(9L), eq(71L), eq("FEE_SOURCE_REVIEW"), eq("review-key"))).thenReturn(1);
|
||||
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper(), null, "", mock(FinanceFeeSourceReadRepository.class));
|
||||
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper());
|
||||
|
||||
FeeSourceDetail result = service.reviewFinanceFeeSource(88L, 71L, "review-key",
|
||||
new FinancePhase1Dtos.ReviewRequest("APPROVE", "核对无误", 0));
|
||||
@@ -290,51 +311,6 @@ class FinancePhase1OperationBehaviorTest {
|
||||
verify(jdbc).update(startsWith("UPDATE fin_fee_source SET review_status="), eq("APPROVED"), eq("核对无误"), eq(88L), eq(0));
|
||||
}
|
||||
|
||||
/** 财务内部应收应付调整原费用记录,重复提交不能新增费用或重复记审计。 */
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"RECEIVABLE", "PAYABLE"})
|
||||
void manualAdjustmentUpdatesOriginalFeeOnceAndChecksVersionAndFrozenState(String direction) throws Exception {
|
||||
var jdbc = mock(JdbcTemplate.class);
|
||||
var service = spy(new FinancePhase1OperationService(jdbc, objectMapper(), null, "", mock(FinanceFeeSourceReadRepository.class)));
|
||||
var beforeJson = objectMapper().createObjectNode().put("feeSourceId", 88).put("sourceSystem", "FINANCE")
|
||||
.put("sourceType", "MANUAL").put("sourceFeeId", 700).put("sourceVersion", 1).put("customerId", 9)
|
||||
.put("direction", direction).put("amount", 100).put("currency", "CNY").put("operationVersion", 2)
|
||||
.put("reviewStatus", "APPROVED").put("sourceStatus", "AVAILABLE").put("statementGenerated", false);
|
||||
var before = objectMapper().treeToValue(beforeJson, FeeSourceDetail.class);
|
||||
var after = objectMapper().treeToValue(beforeJson.deepCopy().put("amount", 120)
|
||||
.put("operationVersion", 3).put("reviewStatus", "DRAFT"), FeeSourceDetail.class);
|
||||
doReturn(before, after).when(service).getFinanceFeeSource(88L);
|
||||
lenient().when(jdbc.update(startsWith("UPDATE fin_fee_source SET amount="), eq(new BigDecimal("120.00")), eq(88L), eq(2), eq("APPROVED")))
|
||||
.thenReturn(1, 0);
|
||||
var request = new FinancePhase1Dtos.FeeSourceEditRequest(new BigDecimal("120.00"), "调整金额", 2);
|
||||
var result = service.editFinanceFeeSource(88, 71, "adjust-once", request, true);
|
||||
assertThat(result.feeSourceId()).isEqualTo(88);
|
||||
assertThat(result.sourceFeeId()).isEqualTo(700);
|
||||
assertThat(result.direction()).isEqualTo(direction);
|
||||
assertThat(result.amount()).isEqualByComparingTo("120");
|
||||
assertThat(result.operationVersion()).isEqualTo(3);
|
||||
var hash = org.mockito.ArgumentCaptor.forClass(String.class);
|
||||
verify(jdbc).update(startsWith("INSERT INTO fin_idempotency_record"), eq(9L), eq(71L),
|
||||
eq("FEE_SOURCE_ADJUST"), eq("adjust-once"), hash.capture());
|
||||
lenient().when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(9L), eq(71L), eq("FEE_SOURCE_ADJUST"), eq("adjust-once")))
|
||||
.thenReturn(List.of(java.util.Map.of("request_hash", hash.getValue(), "status", "SUCCEEDED", "result_resource_id", 88L)));
|
||||
assertThat(service.editFinanceFeeSource(88, 71, "adjust-once", request, true)).isEqualTo(after);
|
||||
verify(jdbc, times(1)).update(startsWith("UPDATE fin_fee_source SET amount="), eq(new BigDecimal("120.00")), eq(88L), eq(2), eq("APPROVED"));
|
||||
verify(jdbc, times(1)).update(startsWith("INSERT INTO fin_audit_log"), eq(9L), eq("FIN_FEE_SOURCE"), eq(88L),
|
||||
eq("FEE_SOURCE_ADJUST"), eq("调整金额"), anyString(), anyString(), eq(71L), anyString(), any(), any());
|
||||
verify(jdbc, never()).update(startsWith("INSERT INTO fin_fee_source"), any(Object[].class));
|
||||
assertThatThrownBy(() -> service.editFinanceFeeSource(88, 71, "adjust-once",
|
||||
new FinancePhase1Dtos.FeeSourceEditRequest(new BigDecimal("121.00"), "调整金额", 2), true))
|
||||
.isInstanceOf(FinanceEventException.class).hasMessageContaining("Idempotency-Key已用于其他请求");
|
||||
assertThatThrownBy(() -> service.editFinanceFeeSource(88, 71, "stale-version", request, true))
|
||||
.isInstanceOf(FinanceEventException.class).hasMessageContaining("状态或版本");
|
||||
doReturn(objectMapper().treeToValue(beforeJson.deepCopy().put("statementGenerated", true), FeeSourceDetail.class))
|
||||
.when(service).getFinanceFeeSource(88L);
|
||||
assertThatThrownBy(() -> service.editFinanceFeeSource(88, 71, "frozen", request, true))
|
||||
.isInstanceOf(FinanceEventException.class).hasMessageContaining("已生成单据");
|
||||
}
|
||||
|
||||
/** 准备指定来源版本的费用查询,不再模拟已迁入 Mapper 的审计查询。 */
|
||||
private static JdbcTemplate feeSourceJdbc(String reviewStatus, boolean included, int version) throws Exception {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
when(jdbc.query(startsWith("SELECT * FROM fin_fee_source"), any(RowMapper.class), (Object[]) any())).thenAnswer(invocation -> {
|
||||
@@ -342,6 +318,7 @@ class FinancePhase1OperationBehaviorTest {
|
||||
return List.of(mapper.mapRow(resultSet(reviewStatus, included, version), 0));
|
||||
});
|
||||
when(jdbc.queryForObject(startsWith("SELECT COUNT(*)>0"), eq(Boolean.class), eq(88L), eq(3))).thenReturn(false);
|
||||
when(jdbc.query(startsWith("SELECT created_at,action"), any(RowMapper.class), eq(9L), eq(88L))).thenReturn(List.of());
|
||||
lenient().when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(9L), eq(71L),
|
||||
eq("FEE_SOURCE_SETTLEMENT"), eq("include-key"))).thenReturn(List.of());
|
||||
return jdbc;
|
||||
|
||||
@@ -93,7 +93,7 @@ class FinancePlatformListContractTest {
|
||||
assertRecordFields(com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationRevision.class,
|
||||
"operatorId", "operatorName");
|
||||
assertRecordFields(com.baiyue.finance.dto.FinanceSettlementDtos.SettlementTransaction.class,
|
||||
"createdBy", "createdByName", "transactionNo");
|
||||
"createdBy", "createdByName");
|
||||
assertRecordFields(com.baiyue.finance.dto.FinanceSettlementDtos.SettlementRevision.class,
|
||||
"operatorId", "operatorName");
|
||||
assertRecordFields(com.baiyue.finance.dto.FinanceSettlementDtos.SettlementSummary.class,
|
||||
|
||||
@@ -236,8 +236,8 @@ class FinanceSettlementBehaviorTest {
|
||||
eq("SETTLEMENT_PAYMENT_CREATE"), eq("pay-key"))).thenReturn(List.of());
|
||||
when(jdbc.queryForList(startsWith("SELECT s.id,s.tenant_id,s.settlement_no"), eq(88L))).thenReturn(List.of(after));
|
||||
when(jdbc.queryForList(startsWith("SELECT f.id fee_source_id"), eq(10L), eq(88L))).thenReturn(List.of());
|
||||
when(jdbc.queryForList(startsWith("SELECT id,transaction_no,transaction_type"), eq(10L), eq(88L))).thenReturn(List.of(Map.ofEntries(
|
||||
Map.entry("id", 501L), Map.entry("transaction_no", "PAY202610210001"), Map.entry("transaction_type", "PAYMENT"),
|
||||
when(jdbc.queryForList(startsWith("SELECT id,transaction_type"), eq(10L), eq(88L))).thenReturn(List.of(Map.ofEntries(
|
||||
Map.entry("id", 501L), Map.entry("transaction_type", "PAYMENT"),
|
||||
Map.entry("transaction_date", Date.valueOf(LocalDate.of(2026, 8, 11))),
|
||||
Map.entry("amount", new BigDecimal("40.00")), Map.entry("method", "BANK_TRANSFER"),
|
||||
Map.entry("reference_no", "BANK-1"), Map.entry("voucher_file_ids", "[\"91\"]"),
|
||||
@@ -258,7 +258,6 @@ class FinanceSettlementBehaviorTest {
|
||||
assertThat(result.transactions()).singleElement().extracting(transaction -> transaction.transactionType())
|
||||
.isEqualTo("PAYMENT");
|
||||
assertThat(result.transactions().get(0).createdByName()).isEqualTo("platform_admin");
|
||||
assertThat(result.transactions().get(0).transactionNo()).isEqualTo("PAYMENT".equals(result.transactions().get(0).transactionType()) ? "PAY202610210001" : null);
|
||||
verify(jdbc).update(startsWith("UPDATE fin_settlement SET paid_amount"),
|
||||
any(), any(), any(), any(), any(), any(), any(), any());
|
||||
}
|
||||
@@ -523,7 +522,6 @@ class FinanceSettlementBehaviorTest {
|
||||
.isEqualTo("RECEIPT");
|
||||
assertThat(result.transactions().get(0).amount()).isEqualByComparingTo("40.00");
|
||||
assertThat(result.transactions().get(0).createdByName()).isEqualTo("platform_admin");
|
||||
assertThat(result.transactions().get(0).transactionNo()).isEqualTo("PAYMENT".equals(result.transactions().get(0).transactionType()) ? "PAY202610210001" : null);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -622,18 +620,10 @@ class FinanceSettlementBehaviorTest {
|
||||
org.mockito.Mockito.lenient().when(jdbc.queryForList(startsWith("SELECT JSON_UNQUOTE"), anyLong(), anyLong()))
|
||||
.thenReturn(List.of(new java.util.HashMap<>(Map.of("supplier_category", "BOOKING"))));
|
||||
FinanceDocumentNumberGenerator documentNumbers = org.mockito.Mockito.mock(FinanceDocumentNumberGenerator.class);
|
||||
org.mockito.Mockito.lenient().when(documentNumbers.next("AR")).thenReturn("AR202608180001");
|
||||
org.mockito.Mockito.lenient().when(documentNumbers.next("AP")).thenReturn("AP202608180001");
|
||||
org.mockito.Mockito.lenient().when(documentNumbers.next("PAY")).thenReturn("PAY202608180001");
|
||||
// 既有业务测试保留JDBC替身,新详情路径通过真实Repository和Mapper接口适配执行。
|
||||
var mapper = org.mockito.Mockito.mock(com.baiyue.finance.mapper.FinanceSettlementDetailMapper.class, invocation -> {
|
||||
var select = invocation.getMethod().getAnnotation(org.apache.ibatis.annotations.Select.class);
|
||||
if (select == null) return RETURNS_DEFAULTS.answer(invocation);
|
||||
String sql = String.join(" ", select.value()).replaceAll("#\\{[a-zA-Z]+\\}", "?");
|
||||
return jdbc.queryForList(sql, invocation.getArguments());
|
||||
});
|
||||
org.mockito.Mockito.lenient().when(documentNumbers.next("AR")).thenReturn("AR-260818-0001");
|
||||
org.mockito.Mockito.lenient().when(documentNumbers.next("AP")).thenReturn("AP-260818-0001");
|
||||
return new FinanceSettlementService(jdbc, new ObjectMapper().findAndRegisterModules(),
|
||||
new FinanceVoucherValidator(fileClient, "internal-token"), supplierValidator, documentNumbers, new com.baiyue.finance.repository.FinanceSettlementDetailRepository(mapper));
|
||||
new FinanceVoucherValidator(fileClient, "internal-token"), supplierValidator, documentNumbers);
|
||||
}
|
||||
|
||||
private static FinanceSupplierValidator supplierValidator() {
|
||||
@@ -666,7 +656,7 @@ class FinanceSettlementBehaviorTest {
|
||||
when(jdbc.queryForList(startsWith("SELECT s.id,s.tenant_id,s.settlement_no"), eq(88L))).thenReturn(List.of(settlement));
|
||||
long customerId = ((Number) settlement.get("tenant_id")).longValue();
|
||||
when(jdbc.queryForList(startsWith("SELECT f.id fee_source_id"), eq(customerId), eq(88L))).thenReturn(List.of());
|
||||
when(jdbc.queryForList(startsWith("SELECT id,transaction_no,transaction_type"), eq(customerId), eq(88L))).thenReturn(List.of());
|
||||
when(jdbc.queryForList(startsWith("SELECT id,transaction_type"), eq(customerId), eq(88L))).thenReturn(List.of());
|
||||
when(jdbc.queryForList(startsWith("SELECT action,reason"), eq(customerId), eq(88L))).thenReturn(List.of());
|
||||
}
|
||||
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
package com.baiyue.finance;
|
||||
|
||||
import com.baiyue.finance.mapper.FinanceSettlementDetailMapper;
|
||||
import com.baiyue.finance.repository.FinanceSettlementDetailRepository;
|
||||
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 static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class FinanceSettlementDetailMapperTest {
|
||||
/** 真实执行付款查询,业务号必须来自本客户本结算的流水,空号不以其他字段替代。 */
|
||||
@Test
|
||||
void paymentNumbersRemainScopedToCustomerAndSettlement() {
|
||||
var source = new DriverManagerDataSource("jdbc:h2:mem:settlement-number;MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
|
||||
var jdbc = new JdbcTemplate(source);
|
||||
jdbc.execute("CREATE TABLE fin_settlement_payment (id BIGINT PRIMARY KEY,tenant_id BIGINT,settlement_id BIGINT,transaction_no VARCHAR(32),transaction_type VARCHAR(32),transaction_date DATE,amount DECIMAL(18,2),method VARCHAR(32),reference_no VARCHAR(64),voucher_file_ids VARCHAR(255),created_by BIGINT,created_by_name VARCHAR(64),created_at TIMESTAMP,remark VARCHAR(255))");
|
||||
jdbc.update("INSERT INTO fin_settlement_payment (id,tenant_id,settlement_id,transaction_no,transaction_type,reference_no) VALUES (1,10,20,'PAY202610210001','PAYMENT','BANK-1'),(2,11,20,'PAY202610210002','PAYMENT','BANK-2'),(3,10,21,'PAY202610210003','PAYMENT','BANK-3'),(4,10,22,NULL,'PAYMENT','BANK-4')");
|
||||
var configuration = new Configuration(new Environment("test", new JdbcTransactionFactory(), source));
|
||||
configuration.addMapper(FinanceSettlementDetailMapper.class);
|
||||
try (var session = new SqlSessionFactoryBuilder().build(configuration).openSession()) {
|
||||
var repository = new FinanceSettlementDetailRepository(session.getMapper(FinanceSettlementDetailMapper.class));
|
||||
var rows = repository.payments(10, 20);
|
||||
assertThat(rows).hasSize(1);
|
||||
assertThat(rows.get(0).get("transaction_no")).isEqualTo("PAY202610210001");
|
||||
assertThat(rows.get(0).get("reference_no")).isEqualTo("BANK-1");
|
||||
assertThat(repository.payments(11, 21)).isEmpty();
|
||||
assertThat(repository.payments(10, 22)).hasSize(1);
|
||||
assertThat(repository.payments(10, 22).get(0).get("transaction_no")).isNull();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
package com.baiyue.finance;
|
||||
|
||||
import com.baiyue.finance.service.FinanceSettlementService;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import com.baiyue.finance.service.FinanceWalletService;
|
||||
import com.baiyue.finance.service.FinancePhase1OperationService;
|
||||
import com.baiyue.finance.service.FinanceService;
|
||||
import com.baiyue.finance.service.ReconciliationStatementService;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class FinanceSettlementRequestHashTest {
|
||||
/** 模拟不同 JVM 的 Map 遍历顺序;同一付款请求必须得到相同摘要。 */
|
||||
@ParameterizedTest
|
||||
@ValueSource(classes = {FinanceSettlementService.class, FinanceWalletService.class,
|
||||
FinancePhase1OperationService.class, FinanceService.class, ReconciliationStatementService.class})
|
||||
void equivalentRequestsIgnoreMapIterationOrderButDetectChangedPayment(Class<?> serviceType) {
|
||||
Object service = org.mockito.Mockito.mock(serviceType, org.mockito.Mockito.CALLS_REAL_METHODS);
|
||||
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper());
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("amount", 190); body.put("expectedVersion", 0);
|
||||
Map<String, Object> first = new LinkedHashMap<>();
|
||||
first.put("path", "/settlements/202/transactions"); first.put("method", "POST");
|
||||
first.put("customerId", 10L); first.put("body", body);
|
||||
Map<String, Object> reversedBody = new LinkedHashMap<>();
|
||||
reversedBody.put("expectedVersion", 0); reversedBody.put("amount", 190);
|
||||
Map<String, Object> second = new LinkedHashMap<>();
|
||||
second.put("body", reversedBody); second.put("customerId", 10L);
|
||||
second.put("method", "POST"); second.put("path", "/settlements/202/transactions");
|
||||
|
||||
String original = ReflectionTestUtils.invokeMethod(service, "hash", first);
|
||||
String reordered = ReflectionTestUtils.invokeMethod(service, "hash", second);
|
||||
assertThat(reordered).isEqualTo(original);
|
||||
reversedBody.put("amount", 191);
|
||||
String changed = ReflectionTestUtils.invokeMethod(service, "hash", second);
|
||||
assertThat(changed).isNotEqualTo(original);
|
||||
second.put("customerId", 11L); reversedBody.put("amount", 190);
|
||||
String otherCustomer = ReflectionTestUtils.invokeMethod(service, "hash", second);
|
||||
assertThat(otherCustomer).isNotEqualTo(original);
|
||||
}
|
||||
}
|
||||
-54
@@ -1,54 +0,0 @@
|
||||
package com.baiyue.finance;
|
||||
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.finance.controller.PlatformFinanceWalletController;
|
||||
import com.baiyue.finance.dto.FinanceWalletDtos.WalletRefundRequest;
|
||||
import com.baiyue.finance.service.FinanceWalletService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Set;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/** 平台退款入口鉴权、必填原因和稳定幂等引用的回归。 */
|
||||
class PlatformFinanceWalletRefundControllerTest {
|
||||
private final FinanceWalletService service = mock(FinanceWalletService.class);
|
||||
private final PlatformFinanceWalletController controller = new PlatformFinanceWalletController(service);
|
||||
private final WalletRefundRequest request = new WalletRefundRequest(new BigDecimal("12.00"), "退回多收费用");
|
||||
|
||||
/** 客户即使携带同名权限也不能借平台入口操作他人资金。 */
|
||||
@Test void nonPlatformPrincipalsAreDeniedBeforeWalletAccess() {
|
||||
for (var user : new AuthenticatedUser[]{null, user("CLIENT", 9L), user("PLATFORM", 9L)})
|
||||
assertThatThrownBy(() -> controller.refund(user, 9, 1, "key", request))
|
||||
.isInstanceOf(org.springframework.web.server.ResponseStatusException.class);
|
||||
verifyNoInteractions(service);
|
||||
}
|
||||
|
||||
/** 同一客户请求键生成同一退款引用,实际防重与金额上限仍由资金事务执行。 */
|
||||
@Test void authorizedCallsUseStableReferenceAndExistingAdjustPermission() throws Exception {
|
||||
controller.refund(user("PLATFORM", 0L), 9, 1, " key ", request);
|
||||
controller.refund(user("PLATFORM", 0L), 9, 1, "key", request);
|
||||
var reference = org.mockito.ArgumentCaptor.forClass(String.class);
|
||||
verify(service, times(2)).refundConsumption(eq(9L), eq(1L), reference.capture(), eq("key"),
|
||||
eq(request.amount()), eq(7L), isNull(), eq(request.reason()));
|
||||
assertThat(reference.getAllValues()).allSatisfy(value -> assertThat(value).startsWith("MANUAL_REFUND:"));
|
||||
assertThat(reference.getAllValues().get(0)).isEqualTo(reference.getAllValues().get(1));
|
||||
assertThat(PlatformFinanceWalletController.class.getMethod("refund", AuthenticatedUser.class,
|
||||
long.class, long.class, String.class, WalletRefundRequest.class).getAnnotation(PreAuthorize.class).value())
|
||||
.isEqualTo("hasAuthority('platform:finance:customer-account:adjust')");
|
||||
}
|
||||
|
||||
/** 空说明或非法幂等键在触达资金前失败。 */
|
||||
@Test void missingReasonAndIdempotencyAreRejected() {
|
||||
assertThatThrownBy(() -> controller.refund(user("PLATFORM", 0L), 9, 1, " ", request)).isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> controller.refund(user("PLATFORM", 0L), 9, 1, "key",
|
||||
new WalletRefundRequest(BigDecimal.ONE, " "))).isInstanceOf(IllegalArgumentException.class);
|
||||
verifyNoInteractions(service);
|
||||
}
|
||||
|
||||
private AuthenticatedUser user(String app, long tenant) {
|
||||
return new AuthenticatedUser(7L, tenant, 0L, null, "finance", app, "WEB",
|
||||
Set.of("platform:finance:customer-account:adjust"));
|
||||
}
|
||||
}
|
||||
@@ -70,7 +70,7 @@ class ReconciliationStatementBehaviorTest {
|
||||
@Test
|
||||
void selectedModeRejectsDuplicateFeeSourcesBeforeWriting() {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
ReconciliationStatementService target = serviceWithNumber(jdbc);
|
||||
ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules());
|
||||
CreateReconciliationRequest request = new CreateReconciliationRequest(9L, "CNY", "SELECTED", List.of(88L, 88L),
|
||||
LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), null);
|
||||
|
||||
@@ -105,7 +105,7 @@ class ReconciliationStatementBehaviorTest {
|
||||
var result = target.create(71L, "create-key", request);
|
||||
|
||||
assertThat(result.statementId()).isEqualTo(501L);
|
||||
assertThat(result.statementNo()).isEqualTo("REC202608180001");
|
||||
assertThat(result.statementNo()).isEqualTo("REC-260818-0001");
|
||||
assertThat(result.currency()).isEqualTo("CNY");
|
||||
assertThat(result.createdByName()).isEqualTo("platform_admin");
|
||||
assertThat(result.updatedByName()).isEqualTo("platform_admin");
|
||||
@@ -132,7 +132,7 @@ class ReconciliationStatementBehaviorTest {
|
||||
});
|
||||
when(jdbc.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT id,customer_id"), eq(88L)))
|
||||
.thenReturn(List.of(Map.of("id", 88L, "customer_id", 10L)));
|
||||
ReconciliationStatementService target = serviceWithNumber(jdbc);
|
||||
ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules());
|
||||
|
||||
assertThatThrownBy(() -> target.create(71L, "create-key", new CreateReconciliationRequest(9L, "CNY",
|
||||
"SELECTED", List.of(88L), LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), null)))
|
||||
@@ -144,7 +144,7 @@ class ReconciliationStatementBehaviorTest {
|
||||
@Test
|
||||
void submitUsesExpectedVersionAndPersistsRevisionBeforeStateChange() throws Exception {
|
||||
JdbcTemplate jdbc = actionJdbc("DRAFT", "PENDING_REVIEW", "submit-key");
|
||||
ReconciliationStatementService target = serviceWithNumber(jdbc);
|
||||
ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules());
|
||||
|
||||
var result = target.action(71L, 501L, "submit-key",
|
||||
new ReconciliationActionRequest("SUBMIT", "提交审核", 0, List.of()));
|
||||
@@ -164,7 +164,7 @@ class ReconciliationStatementBehaviorTest {
|
||||
@Test
|
||||
void approveFreezesInternalBillAndReturnsContractStatus() throws Exception {
|
||||
JdbcTemplate jdbc = actionJdbc("PENDING_REVIEW", "APPROVED", "approve-key");
|
||||
ReconciliationStatementService target = serviceWithNumber(jdbc);
|
||||
ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules());
|
||||
|
||||
var result = target.action(71L, 501L, "approve-key",
|
||||
new ReconciliationActionRequest("APPROVE", "审核通过", 1, List.of()));
|
||||
@@ -177,7 +177,7 @@ class ReconciliationStatementBehaviorTest {
|
||||
@Test
|
||||
void issueFreezesSourcesOnlyAfterApproval() throws Exception {
|
||||
JdbcTemplate jdbc = actionJdbc("APPROVED", "ISSUED", "issue-key");
|
||||
ReconciliationStatementService target = serviceWithNumber(jdbc);
|
||||
ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules());
|
||||
|
||||
var result = target.action(71L, 501L, "issue-key",
|
||||
new ReconciliationActionRequest("ISSUE", null, 2, List.of()));
|
||||
@@ -202,7 +202,7 @@ class ReconciliationStatementBehaviorTest {
|
||||
});
|
||||
when(jdbc.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"),
|
||||
eq(9L), eq(71L), eq("RECONCILIATION_CUSTOMER_CONFIRM"), eq("confirm-key"))).thenReturn(List.of());
|
||||
ReconciliationStatementService target = serviceWithNumber(jdbc);
|
||||
ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules());
|
||||
|
||||
var result = target.confirmForCustomer(9L, 71L, 501L, "confirm-key", new CustomerConfirmationRequest(3));
|
||||
|
||||
@@ -236,7 +236,7 @@ class ReconciliationStatementBehaviorTest {
|
||||
JdbcTemplate crossCustomer = mock(JdbcTemplate.class);
|
||||
when(crossCustomer.query(org.mockito.ArgumentMatchers.startsWith("SELECT b.*"), any(RowMapper.class), eq(501L), eq(9L)))
|
||||
.thenReturn(List.of());
|
||||
assertThatThrownBy(() -> serviceWithNumber(crossCustomer).confirmForCustomer(
|
||||
assertThatThrownBy(() -> new ReconciliationStatementService(crossCustomer, new ObjectMapper()).confirmForCustomer(
|
||||
9L, 71L, 501L, "confirm-key", new CustomerConfirmationRequest(3)))
|
||||
.isInstanceOf(FinanceEventException.class)
|
||||
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_404");
|
||||
@@ -251,7 +251,7 @@ class ReconciliationStatementBehaviorTest {
|
||||
});
|
||||
when(draft.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"),
|
||||
eq(9L), eq(71L), eq("RECONCILIATION_CUSTOMER_CONFIRM"), eq("draft-key"))).thenReturn(List.of());
|
||||
assertThatThrownBy(() -> serviceWithNumber(draft).confirmForCustomer(
|
||||
assertThatThrownBy(() -> new ReconciliationStatementService(draft, new ObjectMapper()).confirmForCustomer(
|
||||
9L, 71L, 501L, "draft-key", new CustomerConfirmationRequest(0)))
|
||||
.isInstanceOf(FinanceEventException.class)
|
||||
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_409");
|
||||
@@ -271,7 +271,7 @@ class ReconciliationStatementBehaviorTest {
|
||||
});
|
||||
when(stale.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"),
|
||||
eq(9L), eq(71L), eq("RECONCILIATION_CUSTOMER_CONFIRM"), eq("stale-key"))).thenReturn(List.of());
|
||||
assertThatThrownBy(() -> serviceWithNumber(stale).confirmForCustomer(
|
||||
assertThatThrownBy(() -> new ReconciliationStatementService(stale, new ObjectMapper()).confirmForCustomer(
|
||||
9L, 71L, 501L, "stale-key", new CustomerConfirmationRequest(2)))
|
||||
.isInstanceOf(FinanceEventException.class)
|
||||
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_409");
|
||||
@@ -288,7 +288,7 @@ class ReconciliationStatementBehaviorTest {
|
||||
eq(9L), eq(71L), eq("RECONCILIATION_ACTION"), eq("used-key"))).thenReturn(List.of(Map.of(
|
||||
"request_hash", "different-request", "status", "SUCCEEDED", "result_resource_id", 501L,
|
||||
"updated_at", LocalDateTime.now())));
|
||||
ReconciliationStatementService target = serviceWithNumber(jdbc);
|
||||
ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules());
|
||||
|
||||
assertThatThrownBy(() -> target.action(71L, 501L, "used-key",
|
||||
new ReconciliationActionRequest("SUBMIT", "提交审核", 0, List.of())))
|
||||
@@ -300,7 +300,7 @@ class ReconciliationStatementBehaviorTest {
|
||||
@Test
|
||||
void invalidActionAndVersionConflictAreRejectedBeforeMutation() throws Exception {
|
||||
JdbcTemplate jdbc = actionJdbc("DRAFT", "DRAFT", "invalid-key");
|
||||
ReconciliationStatementService target = serviceWithNumber(jdbc);
|
||||
ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules());
|
||||
|
||||
assertThatThrownBy(() -> target.action(71L, 501L, "invalid-key",
|
||||
new ReconciliationActionRequest("NOT_AN_ACTION", null, 0, List.of())))
|
||||
@@ -318,7 +318,7 @@ class ReconciliationStatementBehaviorTest {
|
||||
JdbcTemplate jdbc = actionJdbc("ISSUED", "ISSUED", "adjust-key");
|
||||
when(jdbc.queryForObject(org.mockito.ArgumentMatchers.startsWith("SELECT COALESCE(SUM(amount)"), eq(BigDecimal.class), eq(9L), eq(501L)))
|
||||
.thenReturn(new BigDecimal("280.00"));
|
||||
ReconciliationStatementService target = serviceWithNumber(jdbc);
|
||||
ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules());
|
||||
|
||||
target.action(71L, 501L, "adjust-key", new ReconciliationActionRequest("ADJUST", "运费更正", 3,
|
||||
List.of(new StatementAdjustmentLineRequest(601L, new BigDecimal("280.00"), "运费更正"))));
|
||||
@@ -342,7 +342,7 @@ class ReconciliationStatementBehaviorTest {
|
||||
return List.of(mapper.mapRow(rs, 0));
|
||||
});
|
||||
|
||||
assertThatThrownBy(() -> serviceWithNumber(jdbc).action(71L, 501L,
|
||||
assertThatThrownBy(() -> new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules()).action(71L, 501L,
|
||||
"low-adjust-key", new ReconciliationActionRequest("ADJUST", "金额错误", 3,
|
||||
List.of(new StatementAdjustmentLineRequest(601L, new BigDecimal("80.00"), "金额错误")))))
|
||||
.isInstanceOf(FinanceEventException.class)
|
||||
@@ -355,7 +355,7 @@ class ReconciliationStatementBehaviorTest {
|
||||
JdbcTemplate jdbc = actionJdbc("ISSUED", "VOIDED", "void-key");
|
||||
when(jdbc.queryForObject(org.mockito.ArgumentMatchers.startsWith("SELECT COUNT(*) FROM fin_receipt_bill"), eq(Long.class), eq(9L), eq(501L))).thenReturn(0L);
|
||||
when(jdbc.queryForObject(org.mockito.ArgumentMatchers.startsWith("SELECT COUNT(*) FROM fin_settlement"), eq(Long.class), eq(9L), eq(501L))).thenReturn(0L);
|
||||
ReconciliationStatementService target = serviceWithNumber(jdbc);
|
||||
ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules());
|
||||
|
||||
var result = target.action(71L, 501L, "void-key",
|
||||
new ReconciliationActionRequest("VOID", "客户重开账期", 3, List.of()));
|
||||
@@ -372,7 +372,7 @@ class ReconciliationStatementBehaviorTest {
|
||||
.thenReturn("receipt".equals(query) ? 1L : 0L);
|
||||
when(jdbc.queryForObject(org.mockito.ArgumentMatchers.startsWith("SELECT COUNT(*) FROM fin_settlement"), eq(Long.class), eq(9L), eq(501L)))
|
||||
.thenReturn("settlement".equals(query) ? 1L : 0L);
|
||||
assertThatThrownBy(() -> serviceWithNumber(jdbc).action(71L, 501L,
|
||||
assertThatThrownBy(() -> new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules()).action(71L, 501L,
|
||||
"void-" + query, new ReconciliationActionRequest("VOID", "作废原因", 3, List.of())))
|
||||
.isInstanceOf(FinanceEventException.class)
|
||||
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_409");
|
||||
@@ -384,7 +384,7 @@ class ReconciliationStatementBehaviorTest {
|
||||
void reopenVoidedStatementReacquiresEveryReleasedSource() throws Exception {
|
||||
JdbcTemplate jdbc = actionJdbc("VOIDED", "DRAFT", "reopen-key");
|
||||
when(jdbc.queryForObject(org.mockito.ArgumentMatchers.startsWith("SELECT COUNT(*) FROM fin_bill_line"), eq(Integer.class), eq(9L), eq(501L))).thenReturn(1);
|
||||
ReconciliationStatementService target = serviceWithNumber(jdbc);
|
||||
ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules());
|
||||
|
||||
var result = target.action(71L, 501L, "reopen-key",
|
||||
new ReconciliationActionRequest("REOPEN", "重新核对", 4, List.of()));
|
||||
@@ -399,7 +399,7 @@ class ReconciliationStatementBehaviorTest {
|
||||
JdbcTemplate firstJdbc = mock(JdbcTemplate.class, invocation ->
|
||||
"update".equals(invocation.getMethod().getName()) ? 1 : RETURNS_DEFAULTS.answer(invocation));
|
||||
when(firstJdbc.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"), eq(9L), eq(71L), eq("RECONCILIATION_ACTION"), eq("first-key"))).thenReturn(List.of());
|
||||
ReconciliationStatementService firstService = serviceWithNumber(firstJdbc);
|
||||
ReconciliationStatementService firstService = new ReconciliationStatementService(firstJdbc, new ObjectMapper().findAndRegisterModules());
|
||||
|
||||
Long first = ReflectionTestUtils.invokeMethod(firstService, "reserve", 9L, 71L, "RECONCILIATION_ACTION", "first-key", "POST", "/statements/501/actions", body);
|
||||
ArgumentCaptor<String> hash = ArgumentCaptor.forClass(String.class);
|
||||
@@ -412,7 +412,7 @@ class ReconciliationStatementBehaviorTest {
|
||||
.thenReturn(List.of(Map.of("request_hash", hash.getValue(), "status", "SUCCEEDED",
|
||||
"result_resource_id", 501L, "updated_at", LocalDateTime.now())));
|
||||
assertThat((Long) ReflectionTestUtils.invokeMethod(
|
||||
serviceWithNumber(replayJdbc),
|
||||
new ReconciliationStatementService(replayJdbc, new ObjectMapper().findAndRegisterModules()),
|
||||
"reserve", 9L, 71L, "RECONCILIATION_ACTION", "replay-key", "POST", "/statements/501/actions", body))
|
||||
.isEqualTo(501L);
|
||||
|
||||
@@ -420,7 +420,7 @@ class ReconciliationStatementBehaviorTest {
|
||||
when(processingJdbc.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"), eq(9L), eq(71L), eq("RECONCILIATION_ACTION"), eq("processing-key")))
|
||||
.thenReturn(List.of(Map.of("request_hash", hash.getValue(), "status", "PROCESSING", "updated_at", LocalDateTime.now())));
|
||||
assertThatThrownBy(() -> ReflectionTestUtils.invokeMethod(
|
||||
serviceWithNumber(processingJdbc),
|
||||
new ReconciliationStatementService(processingJdbc, new ObjectMapper().findAndRegisterModules()),
|
||||
"reserve", 9L, 71L, "RECONCILIATION_ACTION", "processing-key", "POST", "/statements/501/actions", body))
|
||||
.isInstanceOf(FinanceEventException.class)
|
||||
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_409");
|
||||
@@ -431,7 +431,7 @@ class ReconciliationStatementBehaviorTest {
|
||||
.thenReturn(List.of(Map.of("request_hash", hash.getValue(), "status", "FAILED", "updated_at", LocalDateTime.now())));
|
||||
when(recoveryJdbc.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"), eq(9L), eq(71L), eq("RECONCILIATION_ACTION"), eq("stale-key")))
|
||||
.thenReturn(List.of(Map.of("request_hash", hash.getValue(), "status", "PROCESSING", "updated_at", LocalDateTime.now().minusMinutes(10))));
|
||||
ReconciliationStatementService recoveryService = serviceWithNumber(recoveryJdbc);
|
||||
ReconciliationStatementService recoveryService = new ReconciliationStatementService(recoveryJdbc, new ObjectMapper().findAndRegisterModules());
|
||||
|
||||
assertThat((Long) ReflectionTestUtils.invokeMethod(recoveryService, "reserve", 9L, 71L, "RECONCILIATION_ACTION", "failed-key", "POST", "/statements/501/actions", body)).isNull();
|
||||
assertThat((Long) ReflectionTestUtils.invokeMethod(recoveryService, "reserve", 9L, 71L, "RECONCILIATION_ACTION", "stale-key", "POST", "/statements/501/actions", body)).isNull();
|
||||
@@ -556,7 +556,7 @@ class ReconciliationStatementBehaviorTest {
|
||||
ResultSet rs = mock(ResultSet.class);
|
||||
when(rs.getLong("id")).thenReturn(501L);
|
||||
when(rs.getLong("tenant_id")).thenReturn(9L);
|
||||
when(rs.getString("bill_no")).thenReturn("REC202608180001");
|
||||
when(rs.getString("bill_no")).thenReturn("REC-260818-0001");
|
||||
when(rs.getString("customer_name_snapshot")).thenReturn("客户9");
|
||||
when(rs.getString("status")).thenReturn(status);
|
||||
when(rs.getBigDecimal("total_amount")).thenReturn(new BigDecimal("300.00"));
|
||||
@@ -591,7 +591,7 @@ class ReconciliationStatementBehaviorTest {
|
||||
|
||||
private static ReconciliationStatementService serviceWithNumber(JdbcTemplate jdbc) {
|
||||
FinanceDocumentNumberGenerator documentNumbers = mock(FinanceDocumentNumberGenerator.class);
|
||||
org.mockito.Mockito.lenient().when(documentNumbers.next("REC")).thenReturn("REC202608180001");
|
||||
org.mockito.Mockito.lenient().when(documentNumbers.next("REC")).thenReturn("REC-260818-0001");
|
||||
return new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules(), documentNumbers);
|
||||
}
|
||||
}
|
||||
|
||||
-76
@@ -1,76 +0,0 @@
|
||||
package com.baiyue.finance.repository;
|
||||
|
||||
import com.baiyue.finance.mapper.DocumentSequenceMapper;
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mybatis.spring.SqlSessionTemplate;
|
||||
import org.springframework.aop.framework.ProxyFactory;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.datasource.*;
|
||||
import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource;
|
||||
import org.springframework.transaction.interceptor.TransactionInterceptor;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
/** 实际 Mapper 和隔离 H2 MySQL 模式验证原子序列,不访问部署环境。 */
|
||||
class DocumentSequenceRepositoryTest {
|
||||
private static class Fixture {
|
||||
final JdbcTemplate jdbc;
|
||||
final DataSourceTransactionManager transactions;
|
||||
final DocumentSequenceMapper mapper;
|
||||
final DocumentSequenceRepository repository;
|
||||
/** 仅测试造表允许 JDBC;被测路径全部使用 Mapper。 */
|
||||
Fixture() throws Exception {
|
||||
var data=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=10000","sa","");
|
||||
jdbc=new JdbcTemplate(data);transactions=new DataSourceTransactionManager(data);
|
||||
jdbc.execute("CREATE TABLE fin_document_sequence(sequence_key VARCHAR(32) PRIMARY KEY,current_value BIGINT NOT NULL)");
|
||||
var configuration=new MybatisConfiguration();configuration.addMapper(DocumentSequenceMapper.class);
|
||||
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(data);factory.setConfiguration(configuration);factory.afterPropertiesSet();
|
||||
mapper=new SqlSessionTemplate(factory.getObject()).getMapper(DocumentSequenceMapper.class);
|
||||
var proxy=new ProxyFactory(new DocumentSequenceRepository(mapper));proxy.setProxyTargetClass(true);
|
||||
proxy.addAdvice(new TransactionInterceptor(transactions,new AnnotationTransactionAttributeSource()));
|
||||
repository=(DocumentSequenceRepository)proxy.getProxy();
|
||||
}
|
||||
}
|
||||
/** 日期和前缀各自独立,重复分配持续递增。 */
|
||||
@Test void dateAndPrefixHaveIndependentSequences() throws Exception {
|
||||
var f=new Fixture();
|
||||
assertThat(f.repository.next("AA:20261020")).isEqualTo(1);
|
||||
assertThat(f.repository.next("AA:20261020")).isEqualTo(2);
|
||||
assertThat(f.repository.next("BB:20261020")).isEqualTo(1);
|
||||
assertThat(f.repository.next("AA:20261021")).isEqualTo(1);
|
||||
}
|
||||
/** 两个线程使用真实事务连接并发争用,不能产生重号。 */
|
||||
@Test void concurrentConnectionsAllocateUniqueNumbers() throws Exception {
|
||||
var f=new Fixture();var pool=Executors.newFixedThreadPool(2);
|
||||
try {
|
||||
var work=new ArrayList<Callable<Long>>();
|
||||
for(int i=0;i<64;i++)work.add(()->f.repository.next("AA:20261020"));
|
||||
var values=new TreeSet<Long>();
|
||||
for(var future:pool.invokeAll(work))values.add(future.get());
|
||||
assertThat(values).hasSize(64);assertThat(values.first()).isEqualTo(1L);assertThat(values.last()).isEqualTo(64L);
|
||||
} finally {pool.shutdownNow();}
|
||||
}
|
||||
/** 外层建单失败后不能把已经分配的单号再交给其他单据。 */
|
||||
@Test void businessRollbackDoesNotReuseAllocatedNumber() throws Exception {
|
||||
var f=new Fixture();
|
||||
assertThatThrownBy(()->new TransactionTemplate(f.transactions).execute(status->{
|
||||
assertThat(f.repository.next("AA:20261020")).isEqualTo(1);
|
||||
throw new IllegalStateException("业务回滚");
|
||||
})).isInstanceOf(IllegalStateException.class);
|
||||
assertThat(f.repository.next("AA:20261020")).isEqualTo(2);
|
||||
}
|
||||
/** 同一会话中连接值变化后,读取必须绕过 MyBatis 缓存。 */
|
||||
@Test void connectionResultIsNeverCached() throws Exception {
|
||||
var f=new Fixture();
|
||||
new TransactionTemplate(f.transactions).executeWithoutResult(status->{
|
||||
f.jdbc.queryForObject("SELECT LAST_INSERT_ID(7)",Long.class);
|
||||
assertThat(f.mapper.allocatedValue()).isEqualTo(7L);
|
||||
f.jdbc.queryForObject("SELECT LAST_INSERT_ID(37)",Long.class);
|
||||
assertThat(f.mapper.allocatedValue()).isEqualTo(37L);
|
||||
});
|
||||
}
|
||||
}
|
||||
+57
-28
@@ -1,41 +1,70 @@
|
||||
package com.baiyue.finance.service;
|
||||
|
||||
import com.baiyue.finance.repository.DocumentSequenceRepository;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
|
||||
import java.time.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class FinanceDocumentNumberGeneratorTest {
|
||||
private static final Clock CLOCK=Clock.fixed(Instant.parse("2026-10-19T16:00:00Z"),ZoneOffset.UTC);
|
||||
private static final Clock CLOCK = Clock.fixed(
|
||||
Instant.parse("2026-08-18T02:00:00Z"), ZoneId.of("Asia/Shanghai"));
|
||||
|
||||
@Test
|
||||
void startsAsASpringBeanWhenTheTestClockConstructorAlsoExists() {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) {
|
||||
context.registerBean(JdbcTemplate.class, () -> jdbc);
|
||||
context.register(FinanceDocumentNumberGenerator.class);
|
||||
context.refresh();
|
||||
|
||||
/** 多构造器情况下正式依赖可以被 Spring 正确选择。 */
|
||||
@Test void springCreatesGeneratorWithRepository() {
|
||||
try(var context=new AnnotationConfigApplicationContext()) {
|
||||
context.registerBean(DocumentSequenceRepository.class,()->mock(DocumentSequenceRepository.class));
|
||||
context.register(FinanceDocumentNumberGenerator.class);context.refresh();
|
||||
assertThat(context.getBean(FinanceDocumentNumberGenerator.class)).isNotNull();
|
||||
}
|
||||
}
|
||||
/** 主机使用 UTC 时仍按北京时间跨午夜,且四位流水不截断。 */
|
||||
@Test void usesShanghaiDateAndIndependentSequenceKey() {
|
||||
var sequences=mock(DocumentSequenceRepository.class);
|
||||
when(sequences.next("REC:20261020")).thenReturn(1L,2L,9999L);
|
||||
var numbers=new FinanceDocumentNumberGenerator(sequences,CLOCK);
|
||||
assertThat(numbers.next("REC")).isEqualTo("REC202610200001");
|
||||
assertThat(numbers.next("REC")).isEqualTo("REC202610200002");
|
||||
assertThat(numbers.next("REC")).isEqualTo("REC202610209999");
|
||||
verify(sequences,times(3)).next("REC:20261020");
|
||||
|
||||
@Test
|
||||
void formatsShortDailySequenceForAllThreeDocuments() {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
when(jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class)).thenReturn(1L, 2L, 38L);
|
||||
FinanceDocumentNumberGenerator generator = new FinanceDocumentNumberGenerator(jdbc, CLOCK);
|
||||
|
||||
assertThat(generator.next("REC")).isEqualTo("REC-260818-0001");
|
||||
assertThat(generator.next("AR")).isEqualTo("AR-260818-0002");
|
||||
assertThat(generator.next("AP")).isEqualTo("AP-260818-0038");
|
||||
|
||||
verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("INSERT INTO fin_document_sequence"),
|
||||
eq("REC:20260818"));
|
||||
}
|
||||
/** 未登记类型不能消耗流水,分配异常或溢出时拒绝出号。 */
|
||||
@Test void rejectsUnknownTypeAndOverflow() {
|
||||
var sequences=mock(DocumentSequenceRepository.class);
|
||||
var numbers=new FinanceDocumentNumberGenerator(sequences,CLOCK);
|
||||
assertThatThrownBy(()->numbers.next("ZZZZ")).isInstanceOf(IllegalArgumentException.class);
|
||||
verifyNoInteractions(sequences);
|
||||
when(sequences.next("REC:20261020")).thenReturn(10000L);
|
||||
assertThatThrownBy(()->numbers.next("REC")).isInstanceOf(BizException.class);
|
||||
|
||||
@Test
|
||||
void rejectsUnsupportedTypeAndOverflow() {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
FinanceDocumentNumberGenerator generator = new FinanceDocumentNumberGenerator(jdbc, CLOCK);
|
||||
assertThatThrownBy(() -> generator.next("PAY")).isInstanceOf(IllegalArgumentException.class);
|
||||
|
||||
when(jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class)).thenReturn(10_000L);
|
||||
assertThatThrownBy(() -> generator.next("REC"))
|
||||
.isInstanceOf(FinanceEventException.class)
|
||||
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_409");
|
||||
}
|
||||
|
||||
@Test
|
||||
void vehicleCostsAndPaymentsUseIndependentSequenceKeys() {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
when(jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class)).thenReturn(1L);
|
||||
FinanceDocumentNumberGenerator generator = new FinanceDocumentNumberGenerator(jdbc, CLOCK);
|
||||
assertThat(generator.next("VC")).isEqualTo("VC-260818-0001");
|
||||
assertThat(generator.next("VP")).isEqualTo("VP-260818-0001");
|
||||
verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("INSERT INTO fin_document_sequence"), eq("VC:20260818"));
|
||||
verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("INSERT INTO fin_document_sequence"), eq("VP:20260818"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
package com.baiyue.finance.service;
|
||||
|
||||
import com.baiyue.finance.client.FinanceFileClient;
|
||||
import com.baiyue.finance.dto.FinanceWalletDtos.*;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.h2.jdbcx.JdbcDataSource;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/** 真实资金服务、隔离数据库及事务验证海外费用释放、实扣与重复操作。 */
|
||||
class OverseasFeeWalletClosureTest {
|
||||
private JdbcTemplate jdbc;
|
||||
private FinanceWalletService wallet;
|
||||
private TransactionTemplate transaction;
|
||||
|
||||
/** 复用正式资金表定义,所有造数只进入本测试独立内存库。 */
|
||||
@BeforeEach void setup() throws Exception {
|
||||
var dataSource = new JdbcDataSource();
|
||||
dataSource.setURL("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1");
|
||||
jdbc = new JdbcTemplate(dataSource);
|
||||
transaction = new TransactionTemplate(new DataSourceTransactionManager(dataSource));
|
||||
var sql = Files.readString(Path.of("src/main/resources/db/finance.sql"));
|
||||
for (var table : List.of("fin_wallet_account", "fin_wallet_hold", "fin_wallet_ledger", "fin_idempotency_record", "fin_audit_log")) {
|
||||
var matcher = Pattern.compile("(?s)CREATE TABLE IF NOT EXISTS " + table + " \\(.*?\\) ENGINE=[^;]+;").matcher(sql);
|
||||
assertThat(matcher.find()).as(table).isTrue();
|
||||
// H2 使用同一列及约束,但去除 MySQL 引擎与更新时间自动生成,避免多列生成键差异。
|
||||
var ddl = matcher.group().replaceAll("(?s) ENGINE=.*", "")
|
||||
.replaceAll("(?i) UNSIGNED", "")
|
||||
.replaceAll("(?i) ON UPDATE CURRENT_TIMESTAMP\\(3\\)", "")
|
||||
.replaceAll("(?i) DEFAULT CURRENT_TIMESTAMP\\(3\\)", "")
|
||||
.replaceAll("(?i)((?:created_at|updated_at) DATETIME\\(3\\)) NOT NULL", "$1 NULL");
|
||||
jdbc.execute(ddl);
|
||||
}
|
||||
var numbers = mock(FinanceDocumentNumberGenerator.class);
|
||||
var sequence = new AtomicInteger();
|
||||
when(numbers.next(anyString())).thenAnswer(call -> "AC-TEST-" + sequence.incrementAndGet());
|
||||
wallet = new FinanceWalletService(jdbc, new ObjectMapper().findAndRegisterModules(),
|
||||
mock(FinanceFileClient.class), numbers, "test", "", "test", "test");
|
||||
jdbc.update("INSERT INTO fin_wallet_account(customer_id,currency_code,actual_balance,frozen_balance,available_balance,credit_limit,credit_used,credit_frozen,credit_expires_on,version,created_by,updated_by) VALUES(9,'CNY',60,0,160,100,0,0,?,0,0,0)", LocalDate.now().plusDays(7));
|
||||
}
|
||||
|
||||
/** 驳回后释放原冻结,实际余额和授信均恢复可用且不发生消费。 */
|
||||
@Test void rejectionReleasesCashAndCreditExactlyOnce() {
|
||||
freeze();
|
||||
assertThat(wallet.account(9).frozenBalance()).isEqualByComparingTo("80.00");
|
||||
assertThat(wallet.account(9).creditFrozen()).isEqualByComparingTo("20.00");
|
||||
var request = new WalletHoldActionRequest("金额录错,驳回费用");
|
||||
transaction.execute(status -> wallet.release(9, "OVERSEAS_MANUAL_FEE", "701-1", "release", request));
|
||||
transaction.execute(status -> wallet.release(9, "OVERSEAS_MANUAL_FEE", "701-1", "release", request));
|
||||
var account = wallet.account(9);
|
||||
assertThat(account.actualBalance()).isEqualByComparingTo("60.00");
|
||||
assertThat(account.frozenBalance()).isZero(); assertThat(account.creditFrozen()).isZero();
|
||||
assertThat(account.availableBalance()).isEqualByComparingTo("160.00");
|
||||
assertThat(count("CONSUMPTION")).isZero(); assertThat(count("UNFREEZE")).isEqualTo(1);
|
||||
}
|
||||
|
||||
/** 扣款事务失败后可重试原请求,多次点击或不同请求键均不重复消费。 */
|
||||
@Test void failedSettlementRollsBackAndRetryConsumesOnlyOnce() {
|
||||
freeze();
|
||||
var request = new WalletHoldActionRequest("审核通过扣款");
|
||||
assertThatThrownBy(() -> transaction.execute(status -> {
|
||||
wallet.settle(9, "OVERSEAS_MANUAL_FEE", "701-1", "settle", request);
|
||||
throw new IllegalStateException("模拟扣款事务提交前异常");
|
||||
})).hasMessageContaining("模拟扣款");
|
||||
assertThat(wallet.account(9).actualBalance()).isEqualByComparingTo("60.00");
|
||||
assertThat(count("CONSUMPTION")).isZero();
|
||||
for (int repeat = 0; repeat < 3; repeat++)
|
||||
transaction.execute(status -> wallet.settle(9, "OVERSEAS_MANUAL_FEE", "701-1", "settle", request));
|
||||
assertThatThrownBy(() -> transaction.execute(status -> wallet.settle(9, "OVERSEAS_MANUAL_FEE", "701-1", "another-key", request)))
|
||||
.hasMessageContaining("已处理");
|
||||
assertThat(wallet.account(9).actualBalance()).isZero();
|
||||
assertThat(wallet.account(9).creditUsed()).isEqualByComparingTo("20.00");
|
||||
assertThat(wallet.account(9).frozenBalance()).isZero(); assertThat(count("CONSUMPTION")).isEqualTo(1);
|
||||
}
|
||||
|
||||
/** 第一版错扣退回复用手工充值;原因、关联单号和操作人进入资金流水。 */
|
||||
@Test void manualCreditRetainsCorrectionReasonAndReplaysWithoutDoubleCredit() {
|
||||
var request = new AccountAdjustmentRequest("RECHARGE", new BigDecimal("12.00"), LocalDate.now(),
|
||||
"OTHER", "TR-001 / 费用701", "退回多收操作费", 0);
|
||||
var first = transaction.execute(status -> wallet.adjust(9, 7, "财务员", "correction-credit", request));
|
||||
var second = transaction.execute(status -> wallet.adjust(9, 7, "财务员", "correction-credit", request));
|
||||
assertThat(second.ledgerId()).isEqualTo(first.ledgerId());
|
||||
assertThat(first.remark()).isEqualTo("退回多收操作费");
|
||||
assertThat(first.referenceNo()).isEqualTo("TR-001 / 费用701");
|
||||
assertThat(first.operatorName()).isEqualTo("财务员");
|
||||
assertThat(wallet.account(9).actualBalance()).isEqualByComparingTo("72.00");
|
||||
assertThat(count("RECHARGE")).isEqualTo(1);
|
||||
}
|
||||
|
||||
/** 部分退款优先还授信,累计退款不能超过原消费,原消费事实保持不变。 */
|
||||
@Test void linkedRefundRestoresCreditThenCashAndCannotExceedOriginalConsumption() {
|
||||
freeze();
|
||||
var hold = transaction.execute(status -> wallet.settle(9, "OVERSEAS_MANUAL_FEE", "701-1", "settle", new WalletHoldActionRequest("审核扣款")));
|
||||
long original = hold.actionLedgerId();
|
||||
var first = transaction.execute(status -> wallet.refundConsumption(9, original, "refund-1", "refund-key-1", new BigDecimal("25"), 7, "财务", "多收退款"));
|
||||
var replay = transaction.execute(status -> wallet.refundConsumption(9, original, "refund-1", "refund-key-1", new BigDecimal("25"), 7, "财务", "多收退款"));
|
||||
assertThat(replay.ledgerId()).isEqualTo(first.ledgerId());
|
||||
assertThat(first.changeType()).isEqualTo("REFUND");
|
||||
assertThat(first.sourceType()).isEqualTo("BUSINESS_REFUND");
|
||||
assertThat(first.referenceNo()).isEqualTo(jdbc.queryForObject("SELECT business_no FROM fin_wallet_ledger WHERE id=?", String.class, original));
|
||||
assertThat(wallet.account(9).creditUsed()).isZero();
|
||||
assertThat(wallet.account(9).actualBalance()).isEqualByComparingTo("5.00");
|
||||
assertThatThrownBy(() -> transaction.execute(status -> wallet.refundConsumption(9, original, "refund-1", "refund-key-1", BigDecimal.ONE, 7, "财务", "变更金额"))).hasMessageContaining("幂等");
|
||||
assertThatThrownBy(() -> transaction.execute(status -> wallet.refundConsumption(9, original, "refund-too-much", "refund-too-much", new BigDecimal("56"), 7, "财务", "退款"))).hasMessageContaining("超过");
|
||||
transaction.execute(status -> wallet.refundConsumption(9, original, "refund-2", "refund-key-2", new BigDecimal("55"), 7, "财务", "余款退款"));
|
||||
assertThat(wallet.account(9).actualBalance()).isEqualByComparingTo("60.00");
|
||||
assertThat(count("REFUND")).isEqualTo(2);
|
||||
assertThat(count("CONSUMPTION")).isEqualTo(1);
|
||||
assertThat(jdbc.queryForObject("SELECT amount FROM fin_wallet_ledger WHERE id=?", BigDecimal.class, original)).isEqualByComparingTo("-80.00");
|
||||
}
|
||||
|
||||
/** 非消费流水、跨客户原流水及非正金额均不能改变余额。 */
|
||||
@Test void linkedRefundRejectsWrongCustomerNonConsumptionAndInvalidAmount() {
|
||||
freeze();
|
||||
long frozenLedger = jdbc.queryForObject("SELECT id FROM fin_wallet_ledger WHERE change_type='FREEZE'", Long.class);
|
||||
assertThatThrownBy(() -> transaction.execute(status -> wallet.refundConsumption(9, frozenLedger, "bad", "bad", BigDecimal.ONE, 7, "财务", "退款"))).hasMessageContaining("成功业务扣款");
|
||||
var hold = transaction.execute(status -> wallet.settle(9, "OVERSEAS_MANUAL_FEE", "701-1", "settle", new WalletHoldActionRequest("扣款")));
|
||||
jdbc.update("INSERT INTO fin_wallet_account(customer_id,currency_code,actual_balance,frozen_balance,available_balance,credit_limit,credit_used,credit_frozen,version,created_by,updated_by) VALUES(10,'CNY',0,0,0,0,0,0,0,0,0)");
|
||||
assertThatThrownBy(() -> transaction.execute(status -> wallet.refundConsumption(10, hold.actionLedgerId(), "wrong-customer", "wrong-customer", BigDecimal.ONE, 7, "财务", "退款"))).hasMessageContaining("不存在");
|
||||
assertThatThrownBy(() -> transaction.execute(status -> wallet.refundConsumption(9, hold.actionLedgerId(), "zero", "zero", BigDecimal.ZERO, 7, "财务", "退款"))).isInstanceOf(RuntimeException.class);
|
||||
assertThatThrownBy(() -> transaction.execute(status -> wallet.refundConsumption(9, hold.actionLedgerId(), "blank", "blank", BigDecimal.ONE, 7, "财务", " "))).hasMessageContaining("原因");
|
||||
assertThat(count("REFUND")).isZero();
|
||||
assertThat(wallet.account(9).creditUsed()).isEqualByComparingTo("20.00");
|
||||
assertThat(wallet.account(10).actualBalance()).isZero();
|
||||
}
|
||||
|
||||
/** 不同请求键的并发退款同样受原消费可退余额约束。 */
|
||||
@Test void concurrentRefundsSerializeOnAccountAndNeverOverRefund() throws Exception {
|
||||
freeze();
|
||||
var hold = transaction.execute(status -> wallet.settle(9, "OVERSEAS_MANUAL_FEE", "701-1", "settle", new WalletHoldActionRequest("扣款")));
|
||||
var start = new java.util.concurrent.CountDownLatch(1);
|
||||
var executor = java.util.concurrent.Executors.newFixedThreadPool(2);
|
||||
try {
|
||||
var results = new java.util.ArrayList<java.util.concurrent.Future<Boolean>>();
|
||||
for (int i = 0; i < 2; i++) {
|
||||
final String reference = "concurrent-" + i;
|
||||
results.add(executor.submit(() -> {
|
||||
start.await();
|
||||
try {
|
||||
transaction.execute(status -> wallet.refundConsumption(9, hold.actionLedgerId(), reference, reference, new BigDecimal("50"), 7, "财务", "并发退款"));
|
||||
return true;
|
||||
} catch (FinanceEventException ex) {
|
||||
assertThat(ex.code()).isEqualTo("FIN-WALLET-REFUND-EXCEEDED");
|
||||
return false;
|
||||
}
|
||||
}));
|
||||
}
|
||||
start.countDown();
|
||||
int succeeded = 0;
|
||||
for (var result : results) if (result.get(10, java.util.concurrent.TimeUnit.SECONDS)) succeeded++;
|
||||
assertThat(succeeded).isEqualTo(1);
|
||||
assertThat(count("REFUND")).isEqualTo(1);
|
||||
assertThat(wallet.account(9).actualBalance()).isEqualByComparingTo("30.00");
|
||||
} finally { executor.shutdownNow(); }
|
||||
}
|
||||
|
||||
/** 冻结始终沿用同一海外费用业务身份。 */
|
||||
private void freeze() {
|
||||
transaction.execute(status -> wallet.freeze(9, "freeze", new WalletFreezeRequest(
|
||||
new BigDecimal("80.00"), "OVERSEAS_MANUAL_FEE", "701-1", "海外费用")));
|
||||
}
|
||||
|
||||
/** 按真实流水类型统计资金影响,避免只检查服务返回状态。 */
|
||||
private int count(String type) {
|
||||
return jdbc.queryForObject("SELECT COUNT(*) FROM fin_wallet_ledger WHERE customer_id=9 AND change_type=?", Integer.class, type);
|
||||
}
|
||||
}
|
||||
+96
-45
@@ -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 在线更新安排开发与验收,不另设设备型号门禁。
|
||||
|
||||
@@ -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 前必须补齐非空柜号,不按 ISO 格式阻断;可在 `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`;前缀与生成规则见 `../../../docs/业务单据编号契约.md`,柜次及实物标签规则保持不变 |
|
||||
| 订舱级拖车与整柜出仓调度 | `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`:草稿允许资料不完整并可替换或删除,放舱时校验完整持久化聚合但不要求订舱附件,放舱后除预计到港外的运输、预报货量、三方和柜型需求只读;预计到港允许通过 `/estimated-arrival` 按编辑权限、版本与幂等键单独维护,保留前后审计快照且不得清空或早于预计开船,不改实际到港或计划到仓;三项实际货量允许通过 `OceanBookingService.updateActualCargo` 按编辑权限、版本、幂等键和原因补录并保存前后审计快照。创建草稿和放舱都只维护订舱聚合、三类参与方及柜型需求,不创建真实 `containerNo`、货柜行或分配关系;真实货柜只在后续提柜关联时占用已放舱柜型需求。
|
||||
- 订舱柜型需求至少一项、最多 20 项,数量必须为正整数,容量和限重必须匹配平台柜型规格。起运仓必须是启用的国内中转仓;物流渠道必须来自该仓当前 ACTIVE 价格版本,服务端保存渠道名称快照,渠道失效时失败关闭。预计到港草稿可留空,放舱前必填且不得早于预计开船时间;关联柜次时带入柜级到港来源,供日历和默认到仓日期解析使用。
|
||||
- 订舱号遵循 `../../../docs/业务单据编号契约.md`;不得以实体 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` 后取最新已审核测量箱数;只读补充失败时保守回退当前已分配数,调整写入仍重新查询并校验权威数量。同柜不可拆组只有全部成员命中状态集合时才返回,且必须成员完整、满箱并作为单个分页与分配单元,不能跨页或拆分。一次性移除未拣明细时,普通未拣项直接移除;同柜组仅全组未拣时整组移除,任一成员已拣则整组保留,单事务只递增一次计划版本。推荐顺序按入库时间先进先出。真实集装箱号允许在关联已放舱订舱时暂缺;关联、运输修改和提柜补录统一只规范化空白和大小写,不以 ISO 长度、结构、设备类别或校验位阻断,数据库不设置 ISO 格式 CHECK,仍遵守同订舱唯一及已有柜号一致性。确认预装、创建 PDA 任务和 PDA 完成只要求真实柜号非空,不再校验 ISO 格式;跨服务装柜、船节点和卸柜事件统一接受非 ISO 柜号,冻结事实仍要求柜号已规范化且与来源柜一致。运输纠错继续遵守原因、版本及 PDA 领取和物理作业冻结限制。确认预装及创建 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` 只作为当前结构来源,不为历史开发数据再新增兼容迁移。
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user