diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index db18e9fdd..1ac21825d 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -6862,6 +6862,12 @@ 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,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); diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index a82791968..e63767a39 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -4968,4 +4968,4 @@ CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_history ( CONSTRAINT ck_owned_business_history_version CHECK (record_version>0) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='原排车自有业务附加运营不可变历史'; -SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 228 AS initialized_tables; +SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 233 AS initialized_tables; diff --git a/iam/src/main/resources/db/lastleg-permissions.sql b/iam/src/main/resources/db/lastleg-permissions.sql index 994f145a4..c65f73eb3 100644 --- a/iam/src/main/resources/db/lastleg-permissions.sql +++ b/iam/src/main/resources/db/lastleg-permissions.sql @@ -101,6 +101,9 @@ VALUES (5107390,0,'PLATFORM','MENU',0,'platform:fleet:owned-business:list','自有业务','/overseas-master-data/owned-business','GET',993,'ACTIVE',1,0), (5107391,0,'PLATFORM','BUTTON',5107390,'platform:fleet:owned-business:edit','登记自有业务运营信息',NULL,'PUT',994,'ACTIVE',1,0), (5107392,0,'PLATFORM','BUTTON',5107390,'platform:fleet:owned-business:upload','上传自有业务照片',NULL,'POST',995,'ACTIVE',1,0), + (5107400,0,'PLATFORM','MENU',0,'platform:fleet:outsourced-business:list','外配业务','/overseas-master-data/outsourced-business','GET',996,'ACTIVE',1,0), + (5107401,0,'PLATFORM','BUTTON',5107400,'platform:fleet:outsourced-business:edit','登记外配业务承运信息',NULL,'PUT',997,'ACTIVE',1,0), + (5107402,0,'PLATFORM','BUTTON',5107400,'platform:fleet:outsourced-business:upload','上传外配业务照片',NULL,'POST',998,'ACTIVE',1,0), (5107370,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:handover','在途车辆司机交接',NULL,'POST',1000,'ACTIVE',1,0), (5107385,0,'PLATFORM','BUTTON',5107220,'platform:overseas-master-data:vehicle:record-finance-submit','提交维修救援公司成本',NULL,'POST',1015,'ACTIVE',1,0), (5107340,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:cost-create','登记车次直接成本',NULL,'POST',970,'ACTIVE',1,0), @@ -164,7 +167,7 @@ INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, creat SELECT 0, role.id, permission.id, NOW(3) FROM iam_role role JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM' - AND permission.permission_code IN ('platform:fleet:trip:cost-create','platform:fleet:trip:cost-edit','platform:fleet:quote:list','platform:fleet:quote:create','platform:fleet:quote:edit','platform:fleet:quote:status','platform:fleet:quote:delete','platform:fleet:quote:import','platform:fleet:quote:export','platform:fleet:operations:list','platform:fleet:operations:export','platform:fleet:operations:edit','platform:fleet:owned-business:list','platform:fleet:owned-business:edit','platform:fleet:owned-business:upload','platform:fleet:trip:handover','platform:overseas-master-data:vehicle:record-finance-submit') + AND permission.permission_code IN ('platform:fleet:trip:cost-create','platform:fleet:trip:cost-edit','platform:fleet:quote:list','platform:fleet:quote:create','platform:fleet:quote:edit','platform:fleet:quote:status','platform:fleet:quote:delete','platform:fleet:quote:import','platform:fleet:quote:export','platform:fleet:operations:list','platform:fleet:operations:export','platform:fleet:operations:edit','platform:fleet:owned-business:list','platform:fleet:owned-business:edit','platform:fleet:owned-business:upload','platform:fleet:outsourced-business:list','platform:fleet:outsourced-business:edit','platform:fleet:outsourced-business:upload','platform:fleet:trip:handover','platform:overseas-master-data:vehicle:record-finance-submit') WHERE role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='PLATFORM_ADMIN' AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0; diff --git a/iam/src/test/java/com/baiyue/iam/repository/FleetBusinessPermissionSeedSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/FleetBusinessPermissionSeedSqlContractTest.java new file mode 100644 index 000000000..7abc0c123 --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/repository/FleetBusinessPermissionSeedSqlContractTest.java @@ -0,0 +1,45 @@ +package com.baiyue.iam.repository; + +import org.junit.jupiter.api.Test; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.regex.Pattern; +import static org.assertj.core.api.Assertions.assertThat; + +/** 两类装车后运营入口的读写权限独立,初始化不向客户/PDA扩权。 */ +class FleetBusinessPermissionSeedSqlContractTest { + /** 两份权威种子保留页面与两个按钮的固定层级,不能拿写权限代替查看。 */ + @Test void seedsSeparatePlatformReadAndWriteResourcesInBothEntries() throws Exception { + String local=Files.readString(Path.of("src/main/resources/db/lastleg-permissions.sql")); + String unified=Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql")); + for(String sql:List.of(local,unified)) { + for(var scope:List.of("owned-business","outsourced-business")) { + long base=scope.equals("owned-business")?5107390L:5107400L; + assertThat(sql).contains("("+base+",0,'PLATFORM','MENU',0,'platform:fleet:"+scope+":list'"); + assertThat(sql).contains("'/overseas-master-data/"+scope+"','GET'"); + assertThat(sql).contains("("+(base+1)+",0,'PLATFORM','BUTTON',"+base+",'platform:fleet:"+scope+":edit'"); + assertThat(sql).contains("("+(base+2)+",0,'PLATFORM','BUTTON',"+base+",'platform:fleet:"+scope+":upload'"); + } + } + } + /** 模块授权语句必须同时限制有效平台管理员和有效平台权限。 */ + @Test void moduleGrantsOnlyToActivePlatformAdministrators() throws Exception { + String sql=Files.readString(Path.of("src/main/resources/db/lastleg-permissions.sql")); + var grants=Pattern.compile("INSERT IGNORE INTO iam_role_permission[^;]+;",Pattern.DOTALL).matcher(sql).results() + .map(result->result.group()).filter(statement->statement.contains("platform:fleet:outsourced-business:")).toList(); + assertThat(grants).hasSize(1); + assertThat(grants.get(0)).contains("role.tenant_id=0", "role.app_code='PLATFORM'", "role.role_code='PLATFORM_ADMIN'", + "role.status='ACTIVE'", "role.deleted=0", "permission.status='ACTIVE'", "permission.deleted=0"); + } + /** 空库基线三项显式授权只能落到平台管理员种子,不授予客户模板或PDA。 */ + @Test void unifiedBaselineDoesNotGrantExternalOperationsToOtherRoleSeeds() throws Exception { + String sql=Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql")); + for(long id:List.of(5107400L,5107401L,5107402L)) { + var grants=Pattern.compile("INSERT INTO iam_role_permission[^;]+;",Pattern.DOTALL).matcher(sql).results() + .map(result->result.group()).filter(statement->statement.contains(","+id+",")).toList(); + assertThat(grants).hasSize(1); + assertThat(grants.get(0)).contains("VALUES (4000001,"+id+",0,CURRENT_TIMESTAMP(3))"); + } + } +} diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index b74e1142f..9a045e112 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -17,6 +17,8 @@ 拼车余位发布查 `fleet/trip/FleetCapacityOfferService.java`;整车建单与准备查 `dispatch/controller/DispatchAggregateController.java` 和 `dispatch/service/DispatchAggregateService.java`,本土业务接线查 `fleet/business/`;既有外接登记衔接合同为 `fleet-preparation-v1.yaml`,排车流程按 `../../docs/海外仓业务/排车列表统一作业改造契约.md`。 +原排车装车后运营视图分别查 `fleet/ownedbusiness/`、`fleet/outsourcedbusiness/` 与各自 `fleet-*-business-v1.yaml`;自有/外配按同事务捕获的车辆归属,不按货源区分。两入口可信作用域及权限独立,外配仅整车承运金额、逐目的仓到达和五装车照片;复用四张附加表,不修改原履约/库存/财务状态。共同业务规则见 `../../docs/海外仓业务/自有业务首版开发契约.md`、`../../docs/海外仓业务/外配业务首版开发契约.md`,实际发布及HTTP只查部署主契约。 + 事件重试:`controller/EventOperationsController.java`,内部 `/internal/lastleg/event-operations/{entryId}` 查询、`/retry` 写入;仅卸柜、派送、暂扣、资金及费用事件,以 `EVENT_OPERATIONS_TOKEN` 保护。原事件不可改,DEAD→PENDING 与 `event_delivery_retry_audit` 同事务;脚本 `scripts/event-outbox-operations.mjs`。 沿代码追踪;状态、权限、资源或接口边界不明时,定向检索 `docs/business-index.md`;局部任务不扩读无关约束。 diff --git a/lastleg-service/docs/business-index.md b/lastleg-service/docs/business-index.md index d1c7c2c63..e7a751f4b 100644 --- a/lastleg-service/docs/business-index.md +++ b/lastleg-service/docs/business-index.md @@ -86,6 +86,7 @@ - 车次成本:`fleet/trip/FleetTripCostService.java`、`fleet-trip-costs-v1.yaml`;按DEPART实测及外接实收分摊,不产生客户账单/付款。 - 报价/统计:`fleet/quote/`、`fleet/reporting/`,合同`fleet-quotes-v1.yaml`、`fleet-operations-v1.yaml`;实测凭证按车次归属,仅当前/历史版本引用可下载。 - 自有业务运营:`fleet/ownedbusiness/`、`fleet-owned-business-v1.yaml`,共同规则见`../../../docs/海外仓业务/自有业务首版开发契约.md`;原排车装车后三阶段按装车时自有车辆归属展示,仅补金额、逐目的地到达、里程、油耗与照片。原详情只经`dispatch.port.DispatchDetailReader`读取,原装车事务经`OwnedBusinessOwnershipCapture`捕获归属,不另建履约流程。 +- 外配业务运营:`fleet/outsourcedbusiness/`、`fleet-outsourced-business-v1.yaml`,共同规则见`../../../docs/海外仓业务/外配业务首版开发契约.md`;原排车装车后三阶段按冻结DISPATCHED归属展示,包括正式MASTER/EXTERNAL两种资源模式。只登记整车承运金额、逐目的仓到达和五装车照片,无里程/油耗或成本拆分。内部可信作用域复用四表,入口权限与自有严格分开,不引入第二履约模型。 - 外接选价合同:`fleet-quote-candidates-v1.yaml`、`fleet-trips-v1.yaml`;来源快照与成交价分开,确认承运复验。 - 在途交接:`fleet/exception/`、`fleet-handovers-v1.yaml`;后台确认交接,旧资源分别结束任务,DEPART不重写。 diff --git a/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs b/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs index aa50f7c47..fa1c086b4 100644 --- a/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs +++ b/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs @@ -16,6 +16,7 @@ const ownedBusinessReadEdges = [ ['service.OwnedBusinessQueryService', 'DispatchTypes'], ['service.OwnedBusinessQueryService', 'DispatchDetailReader'], ].map(([source, target]) => `com.baiyue.lastleg.fleet.ownedbusiness.${source}->com.baiyue.lastleg.dispatch.port.${target}`).sort(); +const outsourcedBusinessReadEdges = ['com.baiyue.lastleg.fleet.outsourcedbusiness.model.OutsourcedBusinessModels->com.baiyue.lastleg.dispatch.port.DispatchViews']; test('selfoperated analytics is independent of customer outbound and inventory slices', () => { const report = analyze(); @@ -55,7 +56,7 @@ test('fleet stays a business slice and its policies have no infrastructure depen } assert.equal(report.packageSccs.some(component => component.some(name => name.startsWith('com.baiyue.lastleg.fleet.'))), false); assert.deepEqual(report.classDependencies.filter(edge => edge.source.startsWith('com.baiyue.lastleg.fleet.') - && /com\.baiyue\.lastleg\.(dispatch|delivery)\./.test(edge.target)).map(edge => `${edge.source}->${edge.target}`).sort(), ownedBusinessReadEdges); + && /com\.baiyue\.lastleg\.(dispatch|delivery)\./.test(edge.target)).map(edge => `${edge.source}->${edge.target}`).sort(), [...ownedBusinessReadEdges, ...outsourcedBusinessReadEdges].sort()); const dispatchEdges = report.classDependencies.filter(edge => edge.source.startsWith('com.baiyue.lastleg.dispatch.') && edge.target.startsWith('com.baiyue.lastleg.fleet.')); assert.ok(dispatchEdges.length > 0); @@ -92,6 +93,21 @@ test('owned business reads only reviewed dispatch ports and cannot inherit persi assert.ok(contractViolations(report, baseline).some(message => message.includes(`${unreviewed.source}->${unreviewed.target}`))); }); +test('outsourced business reads only its reviewed original dispatch view and cannot reach persistence', () => { + const report = analyze(); + const baseline = JSON.parse(readFileSync(contract, 'utf8')); + const prefix = 'com.baiyue.lastleg.fleet.outsourcedbusiness.'; + const reviewed = baseline.reviewedClassDependencies.filter(edge => edge.source.startsWith(prefix) + && edge.target.startsWith('com.baiyue.lastleg.dispatch.')); + assert.deepEqual(reviewed.map(edge => `${edge.source}->${edge.target}`).sort(), outsourcedBusinessReadEdges); + assert.equal(baseline.allowedSliceEdges.includes('fleet->dispatch-delivery'), false); + for (const target of ['entity.DispatchAggregate', 'mapper.DispatchAggregateMapper', 'service.DispatchAggregateService']) { + const edge = { source: prefix + 'service.OutsourcedBusinessService', target: 'com.baiyue.lastleg.dispatch.' + target }; + report.classDependencies.push(edge); + assert.ok(contractViolations(report, baseline).some(message => message.includes(`${edge.source}->${edge.target}`))); + } +}); + function analyze(sourceSet = 'main') { return JSON.parse(execFileSync(process.execPath, [analyzer, '--source-set', sourceSet, '--format', 'json'], { cwd: moduleRoot, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java index 434f24fc9..299ef2d34 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java @@ -9,6 +9,21 @@ import java.util.List; @Mapper public interface DispatchCandidateMapper { + /** 自动预留托盘不是实收;尚未开始收货时仍以计划来源预排,不生成库存或实托装载事实。 */ + String PLAN_ONLY = "(p.warehouse_pallet_id IS NULL OR (COALESCE(p.assignment_status,'')='RESERVED' AND COALESCE(p.actual_package_count,0)=0))"; + /** 在途计划的全部真实来源必须已离港;人工节点覆盖后不再借用原柜节点。 */ + String DEPARTED_PLAN = + "EXISTS (SELECT 1 FROM ll_inbound_plan_pallet_allocation da JOIN ll_inbound_plan_line dl " + + "ON dl.id=da.line_id WHERE da.pallet_id=p.id AND dl.customer_id=ip.customer_id) AND NOT EXISTS (" + + "SELECT 1 FROM ll_inbound_plan_pallet_allocation da JOIN ll_inbound_plan_line dl " + + "ON dl.id=da.line_id LEFT JOIN ll_order departure_order " + + "ON departure_order.id=dl.order_id AND departure_order.customer_id=dl.customer_id WHERE da.pallet_id=p.id AND dl.customer_id=ip.customer_id AND " + + "(departure_order.id IS NULL OR COALESCE(departure_order.order_type,'')!='FIRST_LEG' OR NOT (" + + "CASE WHEN COALESCE(departure_order.manual_node_override,FALSE)=TRUE THEN EXISTS (" + + "SELECT 1 FROM ll_order_manual_node dn WHERE dn.order_id=departure_order.id AND dn.customer_id=departure_order.customer_id " + + "AND dn.node_code='ACTUAL_DEPARTURE' AND dn.node_at IS NOT NULL) ELSE EXISTS (" + + "SELECT 1 FROM ll_container_shipping_gate dg WHERE dg.container_booking_id=departure_order.source_container_booking_id " + + "AND dg.node_sequence>=40 AND dg.actual_at IS NOT NULL) END)))"; // Only the receiving party's confirmed appointment is a transport reference; it is not a loading plan. String CONFIRMED_APPOINTMENT_AT = "(SELECT CASE WHEN COUNT(DISTINCT fa.id)=1 THEN MAX(fa.appointment_at) END FROM ll_inbound_plan_pallet_allocation pa " + @@ -41,7 +56,7 @@ public interface DispatchCandidateMapper { List findCustomerScopesByPlanPalletIds(@Param("planPalletIds") List planPalletIds); String AVAILABLE_PLAN_BODY = - "SELECT p.plan_id AS planId,p.id AS planPalletId,p.warehouse_pallet_id AS warehousePalletId," + + "SELECT p.plan_id AS planId,p.id AS planPalletId,CASE WHEN " + PLAN_ONLY + " THEN NULL ELSE p.warehouse_pallet_id END AS warehousePalletId," + "MIN(l.order_id) AS orderId," + "MIN(ip.customer_id) AS customerId,MIN(o.customer_name) AS customerName," + "MIN(ip.plan_no) AS planNo,MIN(o.order_type) AS orderType,MIN(o.volume_weight_kg) AS volumeWeightKg," + @@ -53,16 +68,16 @@ public interface DispatchCandidateMapper { "MIN(ip.container_no) AS containerNo," + CONFIRMED_APPOINTMENT_AT + " AS appointmentAt,NULL AS expectedLoadingAt," + // 仓ID文本统一保留128字符,避免混合CASE的NONE排序规则及无长度CHAR截断;来源优先级与身份等值不变。 - "CAST(CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(pos.overseas_warehouse_id) " + + "CAST(CASE WHEN NOT " + PLAN_ONLY + " THEN (SELECT MIN(pos.overseas_warehouse_id) " + "FROM ll_inventory_position pos WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id " + "AND pos.available_packages > 0) ELSE COALESCE(NULLIF(CAST(ip.overseas_warehouse_id AS CHAR(128)),'')," + "(SELECT NULLIF(TRIM(source_order.overseas_warehouse_id),'') FROM ll_order source_order " + "WHERE source_order.id=ip.source_order_id AND source_order.customer_id=ip.customer_id LIMIT 1)) END AS CHAR(128)) AS overseasWarehouseId," + - "CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(b.overseas_warehouse_code) " + + "CASE WHEN NOT " + PLAN_ONLY + " THEN (SELECT MIN(b.overseas_warehouse_code) " + "FROM ll_inventory_position pos JOIN ll_inventory_balance b ON b.id=pos.balance_id AND b.customer_id=pos.customer_id " + "WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0) " + "ELSE MIN(o.overseas_warehouse_code) END AS overseasWarehouseCode," + - "CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(b.overseas_warehouse_name) " + + "CASE WHEN NOT " + PLAN_ONLY + " THEN (SELECT MIN(b.overseas_warehouse_name) " + "FROM ll_inventory_position pos JOIN ll_inventory_balance b ON b.id=pos.balance_id AND b.customer_id=pos.customer_id " + "WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0) " + "ELSE MIN(o.overseas_warehouse_name) END AS overseasWarehouseName,MIN(o.order_no) AS orderNo," + @@ -70,11 +85,11 @@ public interface DispatchCandidateMapper { "p.pallet_no AS palletNo,p.destination_warehouse_id AS destinationWarehouseId," + "p.destination_warehouse_code AS destinationWarehouseCode,p.destination_warehouse_name AS destinationWarehouseName," + "COALESCE(NULLIF(MIN(l.fba_warehouse_code),''),NULLIF(p.destination_warehouse_code,'')) AS fbaWarehouseCode," + - "CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN COALESCE((SELECT SUM(pos.available_packages) " + + "CASE WHEN NOT " + PLAN_ONLY + " THEN COALESCE((SELECT SUM(pos.available_packages) " + "FROM ll_inventory_position pos WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id),0) " + "ELSE COALESCE(SUM(CASE WHEN a.picked_package_count > 0 THEN a.picked_package_count " + "ELSE a.planned_package_count END),0) END AS packageCount,p.allocated_volume_cbm AS volumeCbm," + - "CASE WHEN p.warehouse_pallet_id IS NULL THEN 'PLAN' ELSE 'RECEIVED' END AS sourceStage," + + "CASE WHEN " + PLAN_ONLY + " THEN 'PLAN' ELSE 'RECEIVED' END AS sourceStage," + "(SELECT GROUP_CONCAT(DISTINCT c.carton_mark ORDER BY c.carton_mark SEPARATOR ',') " + "FROM ll_inbound_plan_carton c WHERE c.plan_pallet_id=p.id AND NOT EXISTS (" + "SELECT 1 FROM ll_relabel_item ri JOIN ll_inventory_position rp ON rp.id=ri.source_inventory_position_id " + @@ -108,12 +123,13 @@ public interface DispatchCandidateMapper { " AND (o.order_no LIKE CONCAT('%',#{orderNo},'%') OR l.inbound_code LIKE CONCAT('%',#{orderNo},'%')) " + " AND l.fba_warehouse_code=#{fbaWarehouseCode} " + " AND " + - "CASE WHEN p.warehouse_pallet_id IS NULL THEN 'PLAN' ELSE 'RECEIVED' END=#{sourceStage} " + + "CASE WHEN " + PLAN_ONLY + " THEN 'PLAN' ELSE 'RECEIVED' END=#{sourceStage} " + "AND NOT EXISTS (SELECT 1 FROM ll_inbound_plan_pallet_allocation ha " + "JOIN ll_inbound_plan_line hl ON hl.id=ha.line_id " + "JOIN ll_order ho ON ho.id=hl.order_id AND ho.customer_id=ip.customer_id " + "WHERE ha.pallet_id=p.id AND ho.detained=1) " + - "AND (p.warehouse_pallet_id IS NULL OR EXISTS (SELECT 1 FROM ll_inventory_position pos " + + "AND (NOT " + PLAN_ONLY + " OR (" + DEPARTED_PLAN + ")) " + + "AND (" + PLAN_ONLY + " OR EXISTS (SELECT 1 FROM ll_inventory_position pos " + "WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0)) " + "AND NOT EXISTS (SELECT 1 FROM ll_dispatch_pallet_lock d " + "WHERE d.customer_id=ip.customer_id AND d.status='ACTIVE' " + @@ -150,7 +166,7 @@ public interface DispatchCandidateMapper { @Param("fbaWarehouseCode") String fbaWarehouseCode,@Param("sourceStage") String sourceStage,@Param("draftToken") String draftToken); String SELECTED_PLAN_SQL = "") + @Options(useCache=false,flushCache=Options.FlushCachePolicy.TRUE) + List stocks(@Param("customer")String customer,@Param("pallets")List pallets); + + /** 批读保留托盘身份和原实收顺序,拆分后与单托版本依据完全相同。 */ + @Select("") + List palletReceipts(@Param("customer")String customer,@Param("pallets")List pallets); + + /** 退回实收按客户与当前实托位置批读,不混入另一客户的同托记录。 */ + @Select("") + List palletReturnedReceipts(@Param("customer")String customer,@Param("pallets")List pallets); + /** 只使本客户本完整来源键的到期草稿成员失效,物理键由独立物理层管理。 */ @Update("UPDATE ll_dispatch_pallet_lock SET status='EXPIRED',active_key=NULL,updated_at=#{at} " + "WHERE status='ACTIVE' AND expires_at IS NOT NULL AND expires_at<=#{at} AND customer_id=#{customer} AND source_pallet_key=#{key}") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchNumberSequence.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchNumberSequence.java index b24f3ba13..b6cb699ef 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchNumberSequence.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchNumberSequence.java @@ -2,7 +2,7 @@ package com.baiyue.lastleg.dispatch.repository; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; -import org.springframework.jdbc.core.JdbcTemplate; +import com.baiyue.lastleg.dispatch.mapper.DispatchNumberSequenceMapper; import org.springframework.stereotype.Repository; import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; @@ -11,17 +11,17 @@ import java.time.LocalDate; /** 整车单号独立日流水;独立短事务归还序列锁后才进入车辆、司机及货物锁。 */ @Repository public class DispatchNumberSequence { - private final JdbcTemplate jdbc; + private final DispatchNumberSequenceMapper mapper; /** 流水与排车主单使用相同服务数据库。 */ - public DispatchNumberSequence(JdbcTemplate jdbc) { this.jdbc = jdbc; } + public DispatchNumberSequence(DispatchNumberSequenceMapper mapper) { this.mapper = mapper; } /** 唯一日期行原子递增,连接内取回本次值;失败不复用已分配的业务号。 */ @Transactional(propagation = Propagation.REQUIRES_NEW) public long next(LocalDate date) { - jdbc.update("INSERT INTO ll_dispatch_daily_sequence(business_date,last_sequence) VALUES(?,LAST_INSERT_ID(1)) " - + "ON DUPLICATE KEY UPDATE last_sequence=LAST_INSERT_ID(last_sequence+1)", date); - Long value = jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class); + // 两个实际 Mapper 操作加入本次 REQUIRES_NEW,连接级结果不跨连接也不读缓存。 + mapper.allocate(date); + Long value = mapper.allocatedValue(); if (value == null || value < 1) throw new IllegalStateException("排车日流水分配失败"); if (value > 9999) throw new BizException(ResultCode.CONFLICT, "当日排车单四位流水号已用满"); return value; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchSourceRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchSourceRepository.java index 08d3261f6..0e90dcd0b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchSourceRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchSourceRepository.java @@ -75,6 +75,24 @@ public class DispatchSourceRepository { public List receipts(String customer, Long pallet) { return source.receipts(customer,pallet); } /** 同一实托的退回实收不得从来源证明丢失。 */ public List returnedReceipts(String customer,Long pallet){return source.returnedReceipts(customer,pallet);} + /** 一条来源的库存与实收版本依据;无实托的在途计划三项均为空。 */ + public record SourceFacts(List stock,List receipts, + List returnedReceipts) { } + /** 同客户的候选托盘只用三次查询;拆分顺序与原单托查询一致,不跨请求缓存。 */ + public java.util.Map sourceFacts(String customer,List pallets) { + if(pallets.isEmpty())return java.util.Map.of(); + var stocks=source.stocks(customer,pallets).stream().collect(java.util.stream.Collectors.groupingBy(DispatchSourceMapper.Stock::palletId)); + var receipts=source.palletReceipts(customer,pallets).stream().collect(java.util.stream.Collectors.groupingBy( + DispatchSourceMapper.PalletReceipt::palletId,java.util.stream.Collectors.mapping( + row->new DispatchSourceMapper.Receipt(row.id(),row.orderId(),row.inventoryPositionId(),row.packages(),row.inboundCode()),java.util.stream.Collectors.toList()))); + var returned=source.palletReturnedReceipts(customer,pallets).stream().collect(java.util.stream.Collectors.groupingBy( + DispatchSourceMapper.PalletReturnedReceipt::palletId,java.util.stream.Collectors.mapping( + row->new DispatchSourceMapper.ReturnedReceipt(row.id(),row.orderId(),row.inventoryPositionId(),row.packages()),java.util.stream.Collectors.toList()))); + var result=new java.util.LinkedHashMap(); + for(var pallet:pallets)result.put(pallet,new SourceFacts(List.copyOf(stocks.getOrDefault(pallet,List.of())), + List.copyOf(receipts.getOrDefault(pallet,List.of())),List.copyOf(returned.getOrDefault(pallet,List.of())))); + return java.util.Collections.unmodifiableMap(result); + } /** 释放已到期草稿键。 */ public int expire(String customer, String key, LocalDateTime at) { return source.expire(customer,key,at); } /** 锁定当前活动资源占用。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java index 7c5c760be..556ef8c14 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java @@ -32,6 +32,7 @@ import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*; /** 真整车创建、加货与查询;客户只属于明细,不属于整车头。 */ @Service public class DispatchAggregateService implements DispatchExecutionPort,DispatchAppointmentAmendmentPort { + private static final org.slf4j.Logger LOG=org.slf4j.LoggerFactory.getLogger(DispatchAggregateService.class); @jakarta.annotation.Resource private DispatchNumberGenerator numbers; @jakarta.annotation.Resource private DispatchAppointmentAllocations appointments; @jakarta.annotation.Resource private DispatchPhysicalClaimService physicalClaims; @@ -149,9 +150,13 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA if(query.planIds()!=null && (query.planIds().size()>500 || query.planIds().stream().anyMatch(id->id==null || id<=0))) throw bad("计划筛选不合法"); var normalized=new DispatchPlanningModels.CandidateQuery(query.warehouseId(),query.planIds(),trim(query.orderNo()), trim(query.destinationWarehouseCode()),trim(query.sourceStage()),trim(query.draftToken())); + long started=System.nanoTime(); var rows=sourceRules.wholeCandidates(actor.operatorId(),normalized); + long candidatesRead=System.nanoTime(); var groups=sourceRules.selectionGroups(rows); + long groupsRead=System.nanoTime(); var batchFacts=appointments.candidates(rows); + long batchesRead=System.nanoTime(); var result=new ArrayList(); for(var row:rows) { SourceType type=Set.of("RELABEL","RETURN_RELABEL").contains(Objects.toString(row.sourceStage(),""))?SourceType.RELABEL: @@ -181,6 +186,12 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA row.overseasWarehouseCode(),row.overseasWarehouseName(),row.orderNo(),row.inboundCode(),row.planNo(),row.palletNo(),row.containerNo(), destination,row.packageCount()==null?0:row.packageCount(),row.grossWeightKg(),row.volumeCbm(),"SOURCE_SUGGESTION",List.copyOf(references),List.copyOf(reasons),group)); } + long finished=System.nanoTime(); + // 慢查询只记录阶段耗时和条数,不记录客户、订单、仓库或草稿凭据。 + if(finished-started>=1_000_000_000L)LOG.warn( + "dispatch_candidates_slow count={} candidatesMs={} groupsMs={} appointmentsMs={} assembleMs={} totalMs={}", + rows.size(),(candidatesRead-started)/1_000_000L,(groupsRead-candidatesRead)/1_000_000L, + (batchesRead-groupsRead)/1_000_000L,(finished-batchesRead)/1_000_000L,(finished-started)/1_000_000L); return new DispatchPlanningModels.CandidateList(List.copyOf(result),Instant.now().truncatedTo(ChronoUnit.MILLIS),true); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java index c2b706f2c..ff2f8b705 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java @@ -4,6 +4,7 @@ import com.baiyue.lastleg.dispatch.entity.DispatchItem; import com.baiyue.lastleg.dispatch.mapper.DispatchCandidateMapper; import com.baiyue.lastleg.dispatch.mapper.DispatchDraftMapper; import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate; +import com.baiyue.lastleg.dispatch.repository.DispatchSourceRepository.SourceFacts; import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper; import jakarta.annotation.Resource; import org.springframework.http.HttpStatus; @@ -34,16 +35,64 @@ public class DispatchService { /** 同一完整组只解析一次,隐藏的真实成员也随服务器组返回。 */ public Map selectionGroups(List rows) { + var reads=new CandidateReadScope(rows); var result=new LinkedHashMap(); for(var row:rows) { String key=wholeSourceKey(selection(row,null)); if(result.containsKey(key))continue; - var group=sourceGroup(row,false); + var group=sourceGroup(row,false,reads); for(var source:group.view().sources())result.put(source.sourceKey(),group.view()); } return Collections.unmodifiableMap(result); } + /** 仅当前候选事务内批读普通来源;写入和锁内校验继续逐组读取当前事实。 */ + private final class CandidateReadScope { + private final Map> loaded=new HashMap<>(); + private final Map>> available=new HashMap<>(); + private final Map> sourceFacts=new HashMap<>(); + + /** 同页同仓托盘一次读取,避免每一行再次执行完整计划和退回来源投影。 */ + private CandidateReadScope(List rows) { + var pallets=new TreeMap>(); + for(var row:rows)if(row.warehousePalletId()!=null) + pallets.computeIfAbsent(row.overseasWarehouseId(),key->new TreeSet<>()).add(row.warehousePalletId()); + pallets.forEach((warehouse,ids)->read(warehouse,List.copyOf(ids))); + loadFacts(rows); + } + + /** 换标闭包发现页外实托时补读缺失集合,不拿筛选后的页面充当完整组成。 */ + private List read(String warehouse,List pallets) { + var known=loaded.computeIfAbsent(warehouse,key->new HashSet<>()); + var byPallet=available.computeIfAbsent(warehouse,key->new HashMap<>()); + var missing=pallets.stream().filter(id->!known.contains(id)).distinct().sorted().toList(); + if(!missing.isEmpty()) { + var rows=wholeSources.physicalAvailable(warehouse,missing); + for(var row:rows) + byPallet.computeIfAbsent(row.warehousePalletId(),key->new ArrayList<>()).add(row); + loadFacts(rows); + known.addAll(missing); + } + return pallets.stream().flatMap(id->byPallet.getOrDefault(id,List.of()).stream()).toList(); + } + + /** 只补齐本请求尚未读取的客户实托,隐藏组成员也必须有完整版本依据。 */ + private void loadFacts(Collection rows) { + var missing=new TreeMap>(); + for(var row:rows)if(row.warehousePalletId()!=null + && !sourceFacts.getOrDefault(row.customerId(),Map.of()).containsKey(row.warehousePalletId())) + missing.computeIfAbsent(row.customerId(),key->new TreeSet<>()).add(row.warehousePalletId()); + missing.forEach((customer,pallets)->sourceFacts.computeIfAbsent(customer,key->new HashMap<>()) + .putAll(wholeSources.sourceFacts(customer,List.copyOf(pallets)))); + } + + /** 在途计划没有库存身份,不向数据库发出三个必为空的实收查询。 */ + private SourceFacts facts(DispatchPalletCandidate row) { + return row.warehousePalletId()==null?new SourceFacts(List.of(),List.of(),List.of()): + Objects.requireNonNull(sourceFacts.getOrDefault(row.customerId(),Map.of()).get(row.warehousePalletId()),"实托版本依据缺失"); + } + } + /** 草稿单次操作先从实际入口解析完整组,不让前端循环单来源凑整托。 */ public SelectionGroup selectionGroup(String warehouse,DispatchCommands.CargoSelection requested) { var row=readSelection(warehouse,requested);return sourceGroup(row,false).view(); @@ -78,6 +127,11 @@ public class DispatchService { /** 实托→申请→整申请实托求固定点;未证实来源只阻断,不补造第一客户或伪业务号。 */ private SourceGroup sourceGroup(DispatchPalletCandidate seed,boolean locked) { + return sourceGroup(seed,locked,null); + } + + /** 只读候选复用同事务批读,单组和写入路径不复用候选读取作用域。 */ + private SourceGroup sourceGroup(DispatchPalletCandidate seed,boolean locked,CandidateReadScope reads) { var rows=new TreeMap();rows.put(wholeSourceKey(selection(seed,null)),seed); var pallets=new TreeSet();if(seed.warehousePalletId()!=null)pallets.add(seed.warehousePalletId()); var applications=new TreeMap();var loaded=new HashSet();var reasons=new LinkedHashSet(); @@ -90,7 +144,9 @@ public class DispatchService { if(facts==null)throw new ResponseStatusException(HttpStatus.CONFLICT,"实托完整来源快照缺失"); reasons.addAll(facts.blockReasons()); if(!facts.complete() && facts.blockReasons().isEmpty())reasons.add("实托实际绑定组成未核齐"); - facts.positions().stream().map(RelabelDispatchSourcePort.Position::warehousePalletId).filter(Objects::nonNull).forEach(pallets::add); + // 缺失的实托身份保留在事实与阻断原因中,不能拿零值扩组或传给严格的实托查询入口。 + facts.positions().stream().map(RelabelDispatchSourcePort.Position::warehousePalletId) + .filter(id->id!=null && id>0).forEach(pallets::add); for(var app:facts.applications())applications.put(app.relabelNo(),app.customerId()); var codes=rows.values().stream().map(DispatchPalletCandidate::palletNo).filter(Objects::nonNull).distinct().toList(); for(var app:wholeSources.nativeApplications(seed.overseasWarehouseId(),codes))applications.put(app.relabelNo(),app.customerId()); @@ -109,7 +165,8 @@ public class DispatchService { // 原生退货的资格由其真实箱生产者负责;普通位置提供者明确提示走原生分支。 reasons.removeIf(reason->nativeNos.stream().anyMatch(no->reason.startsWith(no+":"))); if(!pallets.isEmpty()) { - var available=wholeSources.physicalAvailable(seed.overseasWarehouseId(),List.copyOf(pallets)); + var available=reads==null?wholeSources.physicalAvailable(seed.overseasWarehouseId(),List.copyOf(pallets)): + reads.read(seed.overseasWarehouseId(),List.copyOf(pallets)); if(available.size()>1000)reasons.add("实托可用来源超过完整核验上限"); var buckets=available.stream().filter(r->r.packageCount()!=null && r.packageCount()>0).collect(Collectors.groupingBy(r->r.customerId()+":"+r.warehousePalletId(),TreeMap::new,Collectors.toList())); for(var entry:buckets.entrySet()) { @@ -130,7 +187,8 @@ public class DispatchService { reasons.add("实托还有未完整选择或重复计入的真实可用库存来源"); } } - var bases=new TreeMap();for(var entry:rows.entrySet())bases.put(entry.getKey(),baseSourceVersion(entry.getValue(),entry.getKey(),false)); + if(reads!=null)reads.loadFacts(rows.values()); + var bases=new TreeMap();for(var entry:rows.entrySet())bases.put(entry.getKey(),baseSourceVersion(entry.getValue(),entry.getKey(),reads)); String version=wholeHashes.source(Arrays.asList(bases,facts==null?null:facts.compositionVersion(),pallets)); var versions=new TreeMap();bases.forEach((key,basis)->versions.put(key,wholeHashes.source(List.of(basis,version)))); var sources=new ArrayList(); @@ -397,7 +455,7 @@ public class DispatchService { } /** 单来源基础摘要不递归包含组版本,组版本再绑定全部成员基础摘要。 */ - private String baseSourceVersion(DispatchPalletCandidate row,String key,boolean lockStock) { + private String baseSourceVersion(DispatchPalletCandidate row,String key,CandidateReadScope reads) { var facts = new java.util.TreeMap(); facts.put("sourceKey",key); facts.put("customerId",row.customerId()); facts.put("warehouseId",row.overseasWarehouseId()); facts.put("planPalletId",row.planPalletId()); facts.put("returnReceiptId",row.returnReceiptId()); @@ -409,12 +467,15 @@ public class DispatchService { facts.put("grossWeightKg",row.grossWeightKg()); facts.put("volumeCbm",row.volumeCbm()); facts.put("cartonMarks",row.relabelCartonMarks()); if(key.startsWith("RELABEL:")) facts.put("relabelBasis",wholeSources.relabelBasis(key.substring(8,key.lastIndexOf(':')))); - var stock=wholeSources.stock(row.customerId(),row.warehousePalletId(),lockStock); + var basis=row.warehousePalletId()==null?new SourceFacts(List.of(),List.of(),List.of()): + reads!=null?reads.facts(row):new SourceFacts(wholeSources.stock(row.customerId(),row.warehousePalletId(),false), + wholeSources.receipts(row.customerId(),row.warehousePalletId()),wholeSources.returnedReceipts(row.customerId(),row.warehousePalletId())); + var stock=basis.stock(); if(stock.stream().anyMatch(position->!Objects.equals(row.overseasWarehouseId(),position.warehouseId()))) throw new ResponseStatusException(HttpStatus.CONFLICT,"实托库存起运仓不一致,请核对来源"); facts.put("stock",stock); - facts.put("receipts",wholeSources.receipts(row.customerId(),row.warehousePalletId())); - facts.put("returnedReceipts",wholeSources.returnedReceipts(row.customerId(),row.warehousePalletId())); + facts.put("receipts",basis.receipts()); + facts.put("returnedReceipts",basis.returnedReceipts()); return wholeHashes.source(facts); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/outsourcedbusiness/controller/OutsourcedBusinessController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/outsourcedbusiness/controller/OutsourcedBusinessController.java new file mode 100644 index 000000000..7b89a95e2 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/outsourcedbusiness/controller/OutsourcedBusinessController.java @@ -0,0 +1,80 @@ +package com.baiyue.lastleg.fleet.outsourcedbusiness.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.fleet.outsourcedbusiness.model.OutsourcedBusinessModels.*; +import com.baiyue.lastleg.fleet.outsourcedbusiness.service.OutsourcedBusinessService; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.Query; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.Actor; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessScope; +import com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport; +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import org.springframework.http.*; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +/** 外配八类路径固定独立权限,不接收浏览器业务作用域。 */ +@RestController +@Validated +@RequestMapping("/api/platform/v1/last-leg/fleet/outsourced-business") +public class OutsourcedBusinessController { + private final OutsourcedBusinessService service; + private final OwnedBusinessSupport access; + /** 会话只转真实平台操作者及其原权限集合。 */ + public OutsourcedBusinessController(OutsourcedBusinessService service,OwnedBusinessSupport access){this.service=service;this.access=access;} + /** 同筛选分页和完整阶段统计一次返回。 */ + @GetMapping + @PreAuthorize("hasAuthority('platform:fleet:outsourced-business:list')") + public ApiResponse list(@AuthenticationPrincipal AuthenticatedUser user,@Valid Query query){return ApiResponse.success(service.list(actor(user),query));} + /** 列表权限包含详情,不借原排车或自有写权限。 */ + @GetMapping("/{dispatchNo}") + @PreAuthorize("hasAuthority('platform:fleet:outsourced-business:list')") + public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo){return ApiResponse.success(service.detail(actor(user),dispatchNo));} + /** 原主单和本页附加历史保持各自版本字段。 */ + @GetMapping("/{dispatchNo}/history") + @PreAuthorize("hasAuthority('platform:fleet:outsourced-business:list')") + public ApiResponse history(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo, + @RequestParam(defaultValue="1") @Min(1) Integer page,@RequestParam(defaultValue="20") @Min(1) @Max(200) Integer size) { + return ApiResponse.success(service.history(actor(user),dispatchNo,page,size)); + } + /** 只登记整车金额和逐目的仓到达,不分摊或触发财务。 */ + @PutMapping("/{dispatchNo}/operations") + @PreAuthorize("hasAuthority('platform:fleet:outsourced-business:edit')") + public ApiResponse save(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@Valid @RequestBody SaveOperations request) { + return ApiResponse.success(service.save(actor(user),dispatchNo,request)); + } + /** 上传仅准备照片文件,幂等键必须在multipart内。 */ + @PostMapping(value="/{dispatchNo}/photos/{slot}/uploads",consumes=MediaType.MULTIPART_FORM_DATA_VALUE) + @PreAuthorize("hasAuthority('platform:fleet:outsourced-business:upload')") + public ApiResponse upload(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable PhotoSlot slot, + @RequestParam @Size(min=8,max=128) String idempotencyKey,@RequestPart("file") MultipartFile file) { + return ApiResponse.success(service.upload(actor(user),dispatchNo,slot,idempotencyKey,file)); + } + /** 绑定使用独立原键和运营附加版本,失败保留fileId。 */ + @PutMapping("/{dispatchNo}/photos/{slot}") + @PreAuthorize("hasAuthority('platform:fleet:outsourced-business:upload')") + public ApiResponse bind(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable PhotoSlot slot,@Valid @RequestBody BindPhoto request) { + return ApiResponse.success(service.bind(actor(user),dispatchNo,slot,request)); + } + /** 当前或指定历史引用经过外配归属与权限校验后代理读取。 */ + @GetMapping("/{dispatchNo}/photos/{slot}/files/{fileId}/content") + @PreAuthorize("hasAuthority('platform:fleet:outsourced-business:list')") + public ResponseEntity content(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable PhotoSlot slot, + @PathVariable @Positive long fileId,@RequestParam(required=false) @Min(1) Long revisionVersion) { + var result=service.content(actor(user),dispatchNo,slot,fileId,revisionVersion); + return ResponseEntity.ok().cacheControl(CacheControl.noStore()).contentType(MediaType.parseMediaType(result.photo().contentType())) + .header("X-Content-Type-Options","nosniff").body(result.content()); + } + /** 原键仅返回原操作者的结果,不自动补写或换键重试。 */ + @GetMapping("/{dispatchNo}/commands/{idempotencyKey}") + @PreAuthorize("hasAuthority('platform:fleet:outsourced-business:list')") + public ApiResponse command(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable String idempotencyKey) { + return ApiResponse.success(service.command(actor(user),dispatchNo,idempotencyKey)); + } + /** 固定外配入口保留会话真实权限,不接受任何浏览器作用域。 */ + private Actor actor(AuthenticatedUser user){return access.actor(user,OwnedBusinessScope.OUTSOURCED);} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/outsourcedbusiness/model/OutsourcedBusinessModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/outsourcedbusiness/model/OutsourcedBusinessModels.java new file mode 100644 index 000000000..eadd9cb31 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/outsourcedbusiness/model/OutsourcedBusinessModels.java @@ -0,0 +1,48 @@ +package com.baiyue.lastleg.fleet.outsourcedbusiness.model; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.lastleg.dispatch.port.DispatchViews; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.Arrival; +import com.fasterxml.jackson.annotation.JsonAnySetter; +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import java.util.List; + +/** 外配公开合同单独表达五框、承运金额和到达,不公开里程油耗字段。 */ +public final class OutsourcedBusinessModels { + /** DTO容器不允许实例化。 */ + private OutsourcedBusinessModels() {} + public static final String LIST="platform:fleet:outsourced-business:list"; + public static final String EDIT="platform:fleet:outsourced-business:edit"; + public static final String UPLOAD="platform:fleet:outsourced-business:upload"; + public enum PhotoSlot { LOADING_SCENE,LOADING_LIST,PRE_DEPARTURE_CHECK,DRIVER_LICENSE,RECEIPT } + public record Operations(@Valid OwnedBusinessModels.Money money,@NotNull List<@NotNull @Valid Arrival> arrivals) { + /** 不依赖全局Jackson未知字段开关,明确拒绝油耗、里程及浏览器归属。 */ + @JsonAnySetter public void unsupported(String field,Object value){throw unsupportedField(field);} + } + public record SaveOperations(@NotBlank String recordVersion,@NotBlank @Size(min=8,max=128) String idempotencyKey, + @NotNull @Valid Operations operations,@Size(max=1000) String reason) { + /** 固定外配入口不接收浏览器作用域或其他协议字段。 */ + @JsonAnySetter public void unsupported(String field,Object value){throw unsupportedField(field);} + } + public record BindPhoto(@NotBlank String recordVersion,@NotBlank @Size(min=8,max=128) String idempotencyKey,@NotBlank String fileId) { + /** 绑定只接受原版本、原键和File编号,不能提交车辆归属。 */ + @JsonAnySetter public void unsupported(String field,Object value){throw unsupportedField(field);} + } + public record Vehicle(String vehicleId,String plate,String ownership,String carrierName) {} + public record PhotoSummary(int loadingCount) {} + public record Photo(PhotoSlot slot,String category,String fileId,String fileName,String contentType,String sizeBytes,String uploadedAt,String savedAt) {} + public record Row(String dispatchNo,OwnedBusinessModels.Stage stage,String plannedDepartureAt,String loadingCompletedAt,String departedAt,String deliveryCompletedAt, + OwnedBusinessModels.Origin origin,Vehicle vehicle,OwnedBusinessModels.Driver driver,List destinations, + Integer actualPalletCount,Operations operations,String unitPrice,String recordVersion,String dispatchRecordVersion, + PhotoSummary photoSummary,OwnedBusinessModels.Actions actions,List sourceIssues) {} + public record ListResult(List records,int page,int size,int total,OwnedBusinessModels.Summary summary) {} + public record Detail(Row row,DispatchViews.Detail dispatch,List cargo,List photos) {} + public record History(String eventSource,String recordVersion,String dispatchRecordVersion,String action,String operatorName,String occurredAt,String reason,Operations operations,List photos) {} + public record HistoryPage(List records,int page,int size,int total) {} + public record Command(String dispatchNo,String action,String idempotencyKey,String state,String recordVersion,boolean replayed,Object result,String errorCode,boolean retryAllowed) {} + /** 不支持字段作为明确业务输入错误,不能静默隐藏或降级解析。 */ + private static BizException unsupportedField(String field){return new BizException(ResultCode.BAD_REQUEST,"外配业务不支持字段:"+field);} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/outsourcedbusiness/service/OutsourcedBusinessService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/outsourcedbusiness/service/OutsourcedBusinessService.java new file mode 100644 index 000000000..7052b263e --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/outsourcedbusiness/service/OutsourcedBusinessService.java @@ -0,0 +1,94 @@ +package com.baiyue.lastleg.fleet.outsourcedbusiness.service; + +import com.baiyue.lastleg.fleet.outsourcedbusiness.model.OutsourcedBusinessModels.*; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessScope; +import com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessQueryService; +import com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessRecordService; +import com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessPhotoService; +import com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessProjection; +import com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; +import java.util.List; +import static com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport.bad; +import static com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport.conflict; + +/** 固定外配入口复用四表及原键机制,不伪造权限、不复制业务写实现。 */ +@Service +public class OutsourcedBusinessService { + private static final OwnedBusinessScope SCOPE=OwnedBusinessScope.OUTSOURCED; + private final OwnedBusinessQueryService queries; + private final OwnedBusinessRecordService records; + private final OwnedBusinessPhotoService photos; + private final OwnedBusinessProjection projection; + private final OwnedBusinessSupport codec; + /** 只通过内部服务复用事务与存储,Controller不接触Repository。 */ + public OutsourcedBusinessService(OwnedBusinessQueryService queries,OwnedBusinessRecordService records,OwnedBusinessPhotoService photos, + OwnedBusinessProjection projection,OwnedBusinessSupport codec){this.queries=queries;this.records=records;this.photos=photos;this.projection=projection;this.codec=codec;} + /** 列表承运商随批量来源读取,四项统计保持同筛选完整集合。 */ + public ListResult list(OwnedBusinessModels.Actor actor,OwnedBusinessModels.Query query) { + var result=queries.listScoped(actor,query,SCOPE);var page=result.result(); + return new ListResult(page.records().stream().map(r->row(r,result.carrierNames().get(r.dispatchNo()))).toList(),page.page(),page.size(),page.total(),page.summary()); + } + /** 三个页签读取同一原排车详情和外配附加记录。 */ + public Detail detail(OwnedBusinessModels.Actor actor,String number){return detail(queries.detail(actor,number,SCOPE));} + /** 原主单与运营历史共用分页,不在外配DTO输出油耗字段。 */ + public HistoryPage history(OwnedBusinessModels.Actor actor,String number,Integer page,Integer size) { + var result=queries.history(actor,number,page,size,SCOPE); + return new HistoryPage(result.records().stream().map(h->new History(h.eventSource(),h.recordVersion(),h.dispatchRecordVersion(),h.action(),h.operatorName(), + h.occurredAt(),h.reason(),h.operations()==null?null:operations(h.operations()),h.photos().stream().map(this::photo).toList())).toList(),result.page(),result.size(),result.total()); + } + /** 外配只生成金额和到达的规范内部请求,不接收归属或隐藏油耗写入。 */ + public Detail save(OwnedBusinessModels.Actor actor,String number,SaveOperations request) { + if(request==null || request.operations()==null)throw bad("运营登记内容缺失"); + var ops=request.operations(); + return detail(records.save(actor,number,new OwnedBusinessModels.SaveOperations(request.recordVersion(),request.idempotencyKey(), + new OwnedBusinessModels.Operations(ops.money(),null,null,ops.arrivals()),request.reason()),SCOPE)); + } + /** 五框使用已发布装车File分类,上传与绑定分别保留稳定键。 */ + public Photo upload(OwnedBusinessModels.Actor actor,String number,PhotoSlot slot,String key,MultipartFile file) { + return photo(photos.upload(actor,number,slot(slot),key,file,SCOPE)); + } + /** 原File编号由共享绑定事务校验,不创建第二份附件或写原主单。 */ + public Detail bind(OwnedBusinessModels.Actor actor,String number,PhotoSlot slot,BindPhoto request) { + if(request==null)throw bad("照片绑定请求缺失"); + return detail(photos.bind(actor,number,slot(slot),new OwnedBusinessModels.BindPhoto(request.recordVersion(),request.idempotencyKey(),request.fileId()),SCOPE)); + } + /** 当前或历史引用读取始终限定原车、照片框和外配权限。 */ + public OwnedBusinessPhotoService.Download content(OwnedBusinessModels.Actor actor,String number,PhotoSlot slot,long fileId,Long revision) { + return photos.content(actor,number,slot(slot),fileId,revision,SCOPE); + } + /** 原键结果转换成外配原始快照,不重算金额或使用现在的车辆档案。 */ + public Command command(OwnedBusinessModels.Actor actor,String number,String key) { + var original=photos.command(actor,number,key,SCOPE);Object result=null; + if(original.result()!=null)result=switch(original.action()) { + case "SAVE_OPERATIONS","BIND_PHOTO"->detail(codec.read(codec.write(original.result()),OwnedBusinessModels.Detail.class)); + case "UPLOAD_PHOTO"->photo(codec.read(codec.write(original.result()),OwnedBusinessModels.Photo.class)); + default->throw conflict("原命令类型不属于外配业务"); + }; + return new Command(original.dispatchNo(),original.action(),original.idempotencyKey(),original.state(),original.recordVersion(),original.replayed(),result,original.errorCode(),original.retryAllowed()); + } + /** 回执公开形状不带内部里程、油耗或油耗照片摘要。 */ + private Detail detail(OwnedBusinessModels.Detail detail) { + return new Detail(row(detail.row(),projection.carrierName(detail)),detail.dispatch(),detail.cargo(),detail.photos().stream().map(this::photo).toList()); + } + /** 临时车辆合法保留空vehicleId;承运商缺失保留null供页面显示破折号。 */ + private Row row(OwnedBusinessModels.Row r,String carrierName) { + if(!"DISPATCHED".equals(r.vehicle().ownership()) || r.photoSummary().fuelCount()!=0)throw conflict("外配运营快照与冻结归属不一致"); + return new Row(r.dispatchNo(),r.stage(),r.plannedDepartureAt(),r.loadingCompletedAt(),r.departedAt(),r.deliveryCompletedAt(),r.origin(), + new Vehicle(r.vehicle().vehicleId(),r.vehicle().plate(),r.vehicle().ownership(),carrierName==null || carrierName.isBlank()?null:carrierName),r.driver(),r.destinations(), + r.actualPalletCount(),operations(r.operations()),r.unitPrice(),r.recordVersion(),r.dispatchRecordVersion(),new PhotoSummary(r.photoSummary().loadingCount()),r.actions(),r.sourceIssues()); + } + /** 拒绝异常持久化油耗,不用DTO隐藏不受支持的数据。 */ + private Operations operations(OwnedBusinessModels.Operations ops) { + if(ops.actualDistanceMiles()!=null || ops.fuelConsumptionLitresPer100Km()!=null)throw conflict("外配业务不能包含里程或油耗登记"); + return new Operations(ops.money(),ops.arrivals()); + } + /** 外配照片枚举只有五框,异常油耗历史不能借复用向外输出。 */ + private Photo photo(OwnedBusinessModels.Photo p) { + SCOPE.requireSlot(p.slot());return new Photo(PhotoSlot.valueOf(p.slot().name()),p.category(),p.fileId(),p.fileName(),p.contentType(),p.sizeBytes(),p.uploadedAt(),p.savedAt()); + } + /** 显式枚举转换不接受别名、兼容值或浏览器分类。 */ + private OwnedBusinessModels.PhotoSlot slot(PhotoSlot slot){if(slot==null)throw bad("请选择装车照片框");return OwnedBusinessModels.PhotoSlot.valueOf(slot.name());} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/mapper/OwnedBusinessMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/mapper/OwnedBusinessMapper.java index 4047fc84e..f3831c4a1 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/mapper/OwnedBusinessMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/mapper/OwnedBusinessMapper.java @@ -68,14 +68,20 @@ public interface OwnedBusinessMapper { #{reason},#{snapshotJson},#{occurredAt}) """) int insertHistory(History row); - /** 历史按本页版本顺序分页。 */ + /** 两表文本转连接字符规则后合并,长度取原值以保留中文及真实尾空格;不改原历史或JSON。 */ @Select(""" SELECT * FROM ( SELECT id,dispatch_id,record_version,NULL AS dispatch_record_version,'OPERATIONS' AS event_source, - action,operator_id,reason,snapshot_json,occurred_at FROM ll_fleet_owned_business_history WHERE dispatch_id=#{id} + RPAD(CAST(action AS CHAR(40)),CHAR_LENGTH(action),' ') AS action, + RPAD(CAST(operator_id AS CHAR(128)),CHAR_LENGTH(operator_id),' ') AS operator_id, + RPAD(CAST(reason AS CHAR(2000)),CHAR_LENGTH(reason),' ') AS reason, + snapshot_json,occurred_at FROM ll_fleet_owned_business_history WHERE dispatch_id=#{id} UNION ALL SELECT id,dispatch_id,NULL AS record_version,record_version AS dispatch_record_version,'DISPATCH' AS event_source, - action,operator_id,reason,snapshot_json,occurred_at FROM ll_dispatch_history WHERE dispatch_id=#{id} + RPAD(CAST(action AS CHAR(40)),CHAR_LENGTH(action),' ') AS action, + RPAD(CAST(operator_id AS CHAR(128)),CHAR_LENGTH(operator_id),' ') AS operator_id, + RPAD(CAST(reason AS CHAR(2000)),CHAR_LENGTH(reason),' ') AS reason, + snapshot_json,occurred_at FROM ll_dispatch_history WHERE dispatch_id=#{id} ) events ORDER BY occurred_at DESC,event_source,id DESC LIMIT #{limit} OFFSET #{offset} """) List history(@Param("id") long id,@Param("offset") int offset,@Param("limit") int limit); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/mapper/OwnedBusinessQueryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/mapper/OwnedBusinessQueryMapper.java index 45588ded0..a3582c459 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/mapper/OwnedBusinessQueryMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/mapper/OwnedBusinessQueryMapper.java @@ -16,10 +16,14 @@ public interface OwnedBusinessQueryMapper { o.loading_completion_hash AS owned_loading_completion_hash,o.loading_completed_at AS owned_loading_completed_at FROM ll_dispatch_order d LEFT JOIN ll_fleet_owned_business_ownership o ON o.dispatch_id=d.id """; + /** 摘要按列上限128字节完整比较,不受新旧表默认排序规则影响,不能省略二进制宽度。 */ String FILTER=""" d.status='ACTIVE' AND d.stage IN ('AFTER_LOADING','DELIVERING','DELIVERED') AND d.loading_completed_at IS NOT NULL AND d.loading_confirmation_json IS NOT NULL - AND o.vehicle_ownership='OWNED' AND o.loading_completion_hash=d.loading_completion_hash + AND o.vehicle_ownership=#{q.scope.ownership} + AND CAST(o.loading_completion_hash AS BINARY(128))=CAST(d.loading_completion_hash AS BINARY(128)) + AND o.loading_completed_at=d.loading_completed_at + AND (o.resource_mode='MASTER' OR (#{q.scope.ownership}='DISPATCHED' AND o.resource_mode='EXTERNAL')) AND d.planned_departure_at >= #{q.from} AND d.planned_departure_at < #{q.to} AND d.stage=#{q.stage} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/model/OwnedBusinessRows.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/model/OwnedBusinessRows.java index 20be59f32..38e66f20d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/model/OwnedBusinessRows.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/model/OwnedBusinessRows.java @@ -52,6 +52,6 @@ public final class OwnedBusinessRows { private Long allocatedPackages; } public record Search(int offset,int limit,String keyword,String warehouseId,String stage, - LocalDateTime from,LocalDateTime to) {} + LocalDateTime from,LocalDateTime to,OwnedBusinessScope scope) {} @Data public static class StageCount { private String stage;private Long quantity; } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/model/OwnedBusinessScope.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/model/OwnedBusinessScope.java new file mode 100644 index 000000000..fcefe58c5 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/model/OwnedBusinessScope.java @@ -0,0 +1,26 @@ +package com.baiyue.lastleg.fleet.ownedbusiness.model; + +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.PhotoSlot; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.core.api.ResultCode; + +/** 仅服务端固定入口选择的作用域;不得成为浏览器请求字段或权限别名。 */ +public enum OwnedBusinessScope { + OWNED("OWNED","platform:fleet:owned-business:"), + OUTSOURCED("DISPATCHED","platform:fleet:outsourced-business:"); + private final String ownership,prefix; + /** 车辆归属与入口权限一一固定,不能由请求指定。 */ + OwnedBusinessScope(String ownership,String prefix){this.ownership=ownership;this.prefix=prefix;} + /** SQL仅绑定此可信枚举的归属值,不拼接客户端片段。 */ + public String getOwnership(){return ownership;} + /** 查看权限包含本作用域详情、历史和照片读取。 */ + public String listPermission(){return prefix+"list";} + /** 运营写权限独立,外配权限不映射成自有权限。 */ + public String editPermission(){return prefix+"edit";} + /** 照片写权限独立,不扩大到原排车。 */ + public String uploadPermission(){return prefix+"upload";} + /** 外配只支持装车五框,服务调用同样不能绕过。 */ + public void requireSlot(PhotoSlot slot){if(slot==null || this==OUTSOURCED && !"LOADING".equals(slot.category()))throw new BizException(ResultCode.BAD_REQUEST,"当前业务不支持该照片框");} + /** 不存在只显示当前业务归属,不泄露内部身份。 */ + public BizException missing(){return new BizException(ResultCode.NOT_FOUND,"排车单不存在或不属于当前"+(this==OWNED?"自有":"外配")+"业务");} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessPhotoService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessPhotoService.java index 12cec0302..0a6074143 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessPhotoService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessPhotoService.java @@ -3,6 +3,7 @@ package com.baiyue.lastleg.fleet.ownedbusiness.service; import com.baiyue.lastleg.fleet.ownedbusiness.gateway.OwnedBusinessFileGateway; import com.baiyue.lastleg.fleet.ownedbusiness.gateway.HttpOwnedBusinessFileGateway; import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessScope; import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.*; @@ -32,7 +33,12 @@ public class OwnedBusinessPhotoService { public record Download(Photo photo,byte[] content) {} /** 写前落意图,绑定失败须保留原fileId,不能再上传。 */ public Photo upload(Actor actor,String number,PhotoSlot slot,String key,MultipartFile multipart) { - codec.require(actor,UPLOAD);codec.key(key);var source=queries.requireOwned(number); + return upload(actor,number,slot,key,multipart,OwnedBusinessScope.OWNED); + } + /** 归属与实际上传权限在写File意图前核验,外配拒绝两个油耗框。 */ + public Photo upload(Actor actor,String number,PhotoSlot slot,String key,MultipartFile multipart,OwnedBusinessScope scope) { + codec.require(actor,scope.uploadPermission());scope.requireSlot(slot);codec.key(key); + var source=scope==OwnedBusinessScope.OWNED?queries.requireOwned(number):queries.requireScoped(number,scope); if(slot==null || multipart==null)throw bad("请选择照片及分类"); if(multipart.isEmpty() || multipart.getSize()>HttpOwnedBusinessFileGateway.MAX_SIZE)throw bad("照片须为非空JPG/PNG,最大10MiB"); byte[] bytes;try{bytes=multipart.getBytes();}catch(IOException error){throw bad("无法读取待上传照片");} @@ -49,41 +55,56 @@ public class OwnedBusinessPhotoService { commands.finishUpload(reservation.command(),"SUCCEEDED",result,null);return result; } catch(RuntimeException failure) { // 连接异常不能证明File没有写入;持久保留未知结果,禁止自动补造。 - try{commands.finishUpload(reservation.command(),"UNKNOWN_RESULT",null,"OWNED_UPLOAD_UNKNOWN_RESULT");} + try{commands.finishUpload(reservation.command(),"UNKNOWN_RESULT",null,scope==OwnedBusinessScope.OWNED?"OWNED_UPLOAD_UNKNOWN_RESULT":"OUTSOURCED_UPLOAD_UNKNOWN_RESULT");} catch(RuntimeException recordingFailure){failure.addSuppressed(recordingFailure);} throw conflict("照片上传结果未知,请保留原请求键查询,勿重新上传"); } } /** 重放先于File复核,保证原成功结果可以精确恢复。 */ public Detail bind(Actor actor,String number,PhotoSlot slot,BindPhoto request) { - codec.require(actor,UPLOAD);if(request==null || slot==null)throw bad("照片绑定请求缺失"); + return bind(actor,number,slot,request,OwnedBusinessScope.OWNED); + } + /** 原绑定键恢复仍先检查固定业务域和当前会话,不重新上传。 */ + public Detail bind(Actor actor,String number,PhotoSlot slot,BindPhoto request,OwnedBusinessScope scope) { + codec.require(actor,scope.uploadPermission());if(request==null || slot==null)throw bad("照片绑定请求缺失");scope.requireSlot(slot); codec.key(request.idempotencyKey());long version=codec.version(request.recordVersion());long fileId=codec.version(request.fileId()); if(fileId<=0)throw bad("文件编号无效"); - var source=queries.requireOwned(number);var previous=commands.existing(source.getId(),request.idempotencyKey(),actor); + var source=scope==OwnedBusinessScope.OWNED?queries.requireOwned(number):queries.requireScoped(number,scope);var previous=commands.existing(source.getId(),request.idempotencyKey(),actor); if(previous!=null) { commands.same(previous,"BIND_PHOTO",version,new OwnedBusinessRecordService.PhotoBinding(slot,request)); if(!"SUCCEEDED".equals(previous.getStatus()))throw conflict("绑定结果尚未明确,请查询原命令"); return codec.read(previous.getResultJson(),Detail.class); } - return records.bind(actor,number,slot,request,photo(slot,files.metadata(source.getId(),slot,fileId))); + var verified=photo(slot,files.metadata(source.getId(),slot,fileId)); + return scope==OwnedBusinessScope.OWNED?records.bind(actor,number,slot,request,verified):records.bind(actor,number,slot,request,verified,scope); } /** 只有当前或指定真实历史版本中的引用允许代理读取。 */ @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) public Download content(Actor actor,String number,PhotoSlot slot,long fileId,Long revision) { - codec.require(actor,LIST);var source=queries.requireOwned(number);List bound; + return content(actor,number,slot,fileId,revision,OwnedBusinessScope.OWNED); + } + /** 五框历史授权仍由同车同版本的真实引用限定。 */ + @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) + public Download content(Actor actor,String number,PhotoSlot slot,long fileId,Long revision,OwnedBusinessScope scope) { + codec.require(actor,scope.listPermission());scope.requireSlot(slot);var source=scope==OwnedBusinessScope.OWNED?queries.requireOwned(number):queries.requireScoped(number,scope);List bound; if(revision==null)bound=projection.photos(repository.record(source.getId())); else { if(revision<1)throw bad("历史版本无效");var row=repository.revision(source.getId(),revision); - if(row==null)throw missing();bound=codec.read(row.getSnapshotJson(),Snapshot.class).photos(); + if(row==null)throw scope.missing();bound=codec.read(row.getSnapshotJson(),Snapshot.class).photos(); } - var photo=bound.stream().filter(p->p.slot()==slot && String.valueOf(fileId).equals(p.fileId())).findFirst().orElseThrow(OwnedBusinessSupport::missing); + var photo=bound.stream().filter(p->p.slot()==slot && String.valueOf(fileId).equals(p.fileId())).findFirst().orElseThrow(scope::missing); return new Download(photo,files.content(source.getId(),slot,fileId)); } /** 恢复先校验原操作者,不披露他人载荷。 */ @Transactional(readOnly=true) public Command command(Actor actor,String number,String key) { - codec.require(actor,LIST);codec.key(key);var source=queries.requireOwned(number);var row=commands.existing(source.getId(),key,actor); - if(row==null)throw missing();Object result=row.getResultJson()==null?null:codec.read(row.getResultJson(),Object.class); + return command(actor,number,key,OwnedBusinessScope.OWNED); + } + /** 原键查询不能越过归属隔离,外配权限不授权自有账本。 */ + @Transactional(readOnly=true) + public Command command(Actor actor,String number,String key,OwnedBusinessScope scope) { + codec.require(actor,scope.listPermission());codec.key(key);var source=scope==OwnedBusinessScope.OWNED?queries.requireOwned(number):queries.requireScoped(number,scope);var row=commands.existing(source.getId(),key,actor); + if(row==null)throw scope.missing();Object result=row.getResultJson()==null?null:codec.read(row.getResultJson(),Object.class); String version=row.getExpectedVersion()==null?null:String.valueOf(Math.addExact(row.getExpectedVersion(),1)); return new Command(number,row.getAction(),key,row.getStatus(),version,true,result,row.getErrorCode(),false); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessProjection.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessProjection.java index 35223717d..c3d96d31a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessProjection.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessProjection.java @@ -4,6 +4,7 @@ import com.baiyue.lastleg.dispatch.port.DispatchContracts.ConfirmedLoad; import com.baiyue.lastleg.dispatch.port.DispatchContracts.Resources; import com.baiyue.lastleg.fleet.trip.FleetDispatchGateway; import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessScope; import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.*; import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Record; import org.springframework.stereotype.Component; @@ -45,9 +46,15 @@ public class OwnedBusinessProjection { } /** 一车一行;目的地与预约只行内展开,不重复整车金额或托数。 */ public Row row(Source source,Record record,List cargo,List batches,Actor actor) { + return row(source,record,cargo,batches,actor,OwnedBusinessScope.OWNED); + } + /** 两类固定入口复用原事实装配,公开DTO由各域独立转换。 */ + public Row row(Source source,Record record,List cargo,List batches,Actor actor,OwnedBusinessScope scope) { var resources=codec.read(source.getResourcesJson(),Resources.class); if(resources.vehicle()==null)throw OwnedBusinessSupport.conflict("原车辆快照缺失"); var ops=operations(record);var photos=photos(record);int pallets=palletCount(source); + if(scope==OwnedBusinessScope.OUTSOURCED && (ops.actualDistanceMiles()!=null || ops.fuelConsumptionLitresPer100Km()!=null + || photos.stream().anyMatch(p->!"LOADING".equals(p.category()))))throw OwnedBusinessSupport.conflict("外配运营记录含不支持的里程、油耗或照片"); var arrivals=new HashMap();for(var a:ops.arrivals())arrivals.put(a.destinationKey(),a.actualArrivedAt()); var destinations=new LinkedHashMap(); for(var item:cargo) { @@ -61,11 +68,18 @@ public class OwnedBusinessProjection { return new Row(source.getDispatchNo(),Stage.valueOf(source.getStage()),codec.iso(source.getPlannedDepartureAt()), codec.iso(source.getLoadingCompletedAt()),codec.iso(source.getDepartedAt()),codec.iso(source.getDeliveryCompletedAt()), new Origin(source.getOverseasWarehouseId(),source.getOverseasWarehouseCode(),source.getOverseasWarehouseName()), - new Vehicle(String.valueOf(resources.vehicle().vehicleId()),resources.vehicle().plate(),"OWNED"), + new Vehicle(resources.vehicle().vehicleId()==null?null:String.valueOf(resources.vehicle().vehicleId()),resources.vehicle().plate(),scope.getOwnership()), new Driver(driver==null?null:driver.name(),driver==null?null:driver.phone()),List.copyOf(destinations.values()),pallets,ops,price, record==null?"0":String.valueOf(record.getRecordVersion()),String.valueOf(source.getRecordVersion()), new PhotoSummary((int)photos.stream().filter(p->"LOADING".equals(p.category())).count(),(int)photos.stream().filter(p->"FUEL".equals(p.category())).count()), - new Actions(gate(actor,EDIT),gate(actor,UPLOAD)),pallets==0?List.of("实际装车清单无实托,单价未计算"):List.of()); + new Actions(gate(actor,scope.editPermission()),gate(actor,scope.uploadPermission())),pallets==0?List.of("实际装车清单无实托,单价未计算"):List.of()); + } + /** 承运商仅取原资源快照,列表不逐车查询当前档案。 */ + public String carrierName(Source source){return codec.read(source.getResourcesJson(),Resources.class).vehicle().carrierName();} + /** 原键回执的承运商沿原详情快照,不重新读取当前资源。 */ + public String carrierName(Detail detail) { + return detail.dispatch()==null || detail.dispatch().vehicleInfo()==null || detail.dispatch().vehicleInfo().vehicle()==null + ?null:detail.dispatch().vehicleInfo().vehicle().carrierName(); } /** 附加权限独立于原排车写动作。 */ private Gate gate(Actor actor,String permission) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessQueryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessQueryService.java index 66a102f83..d9fb9d19e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessQueryService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessQueryService.java @@ -1,6 +1,7 @@ package com.baiyue.lastleg.fleet.ownedbusiness.service; import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessScope; import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Source; import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Search; import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Record; @@ -29,12 +30,19 @@ public class OwnedBusinessQueryService { /** 四项统计与分页使用同一过滤和快照,GET不写库。 */ @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) public ListResult list(Actor actor,Query query) { - codec.require(actor,LIST);if(query==null)throw bad("查询条件缺失"); + return listScoped(actor,query,OwnedBusinessScope.OWNED).result(); + } + /** 内部批量结果额外带原承运商;不扩展自有公开Row,不逐车补查。 */ + public record ScopedListResult(ListResult result,Map carrierNames) {} + /** 可信作用域只由服务调用方指定,权限与SQL同时限定同一域。 */ + @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) + public ScopedListResult listScoped(Actor actor,Query query,OwnedBusinessScope scope) { + codec.require(actor,scope.listPermission());if(query==null)throw bad("查询条件缺失"); int page=page(query.page()),size=size(query.size());String keyword=optional(query.keyword(),128),warehouse=optional(query.warehouseId(),64); Instant from=codec.instant(query.plannedDepartureFrom()),to=codec.instant(query.plannedDepartureTo()); if(from!=null && to!=null && !from.isBefore(to))throw bad("计划发车时间范围无效"); int offset;try{offset=Math.multiplyExact(page-1,size);}catch(ArithmeticException error){throw bad("页码过大");} - var search=new Search(offset,size,keyword,warehouse,query.stage()==null?null:query.stage().name(),codec.utc(from),codec.utc(to)); + var search=new Search(offset,size,keyword,warehouse,query.stage()==null?null:query.stage().name(),codec.utc(from),codec.utc(to),scope); var counts=repository.summary(search);int after=0,delivering=0,delivered=0; for(var c:counts) { int n=Math.toIntExact(c.getQuantity()); @@ -44,18 +52,26 @@ public class OwnedBusinessQueryService { var sources=repository.page(search);var ids=sources.stream().map(Source::getId).toList(); var records=repository.records(ids).stream().collect(Collectors.toMap(Record::getDispatchId,r->r)); var cargo=repository.cargo(ids);var batches=repository.batches(ids); - var rows=sources.stream().map(s->{eligible(s);return projection.row(s,records.get(s.getId()), + var rows=sources.stream().map(s->{eligible(s,scope);return projection.row(s,records.get(s.getId()), cargo.stream().filter(c->Objects.equals(c.getDispatchOrderId(),s.getId())).toList(), - batches.stream().filter(b->Objects.equals(b.getDispatchId(),s.getId())).toList(),actor);}).toList(); - return new ListResult(rows,page,size,total,new Summary(total,after,delivering,delivered)); + batches.stream().filter(b->Objects.equals(b.getDispatchId(),s.getId())).toList(),actor,scope);}).toList(); + var carriers=new LinkedHashMap();for(var source:sources)carriers.put(source.getDispatchNo(),projection.carrierName(source)); + return new ScopedListResult(new ListResult(rows,page,size,total,new Summary(total,after,delivering,delivered)),Collections.unmodifiableMap(carriers)); } /** 对原排车详情做组合,不创建第二份主单或履约状态。 */ @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) public Detail detail(Actor actor,String number){codec.require(actor,LIST);return detailForWrite(actor,requireOwned(number));} + /** 外配入口只能读取其冻结归属,不能借自有权限取得原详情。 */ + @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) + public Detail detail(Actor actor,String number,OwnedBusinessScope scope){codec.require(actor,scope.listPermission());return detailForWrite(actor,requireScoped(number,scope),scope);} /** 写入已经有独立权限;回执仍由相同真实来源组成。 */ public Detail detailForWrite(Actor actor,Source source) { - eligible(source);var ids=List.of(source.getId());var cargo=repository.cargo(ids); - var row=projection.row(source,repository.record(source.getId()),cargo,repository.batches(ids),actor); + return detailForWrite(actor,source,OwnedBusinessScope.OWNED); + } + /** 写回执也执行固定归属校验,调用者已核对本域独立写权限。 */ + public Detail detailForWrite(Actor actor,Source source,OwnedBusinessScope scope) { + eligible(source,scope);var ids=List.of(source.getId());var cargo=repository.cargo(ids); + var row=projection.row(source,repository.record(source.getId()),cargo,repository.batches(ids),actor,scope); var original=dispatch.detail(new DispatchContracts.Actor(actor.operatorId(),ActorMode.PLATFORM,null),source.getDispatchNo()); return new Detail(row,original,cargo.stream().map(c->new Cargo(c.getSourceKey(),c.getCustomerId(),c.getCustomerName(), c.getOrderNo(),c.getInboundCode(),c.getPalletNo(),projection.destinationKey(c),String.valueOf(c.getPackageCount()))).toList(), @@ -64,23 +80,37 @@ public class OwnedBusinessQueryService { /** 缺少历史归属必须明确拒绝,不能按当前车辆主档降级猜测。 */ public Source requireOwned(String number) { if(number==null || number.isBlank() || number.length()>128)throw bad("请指定排车单号"); - var source=repository.source(number);if(source==null)throw missing();eligible(source);return source; + var source=repository.source(number);if(source==null)throw missing();eligible(source,OwnedBusinessScope.OWNED);return source; + } + /** 共享附加表仍按原装车归属隔离,不接受浏览器业务域参数。 */ + public Source requireScoped(String number,OwnedBusinessScope scope) { + if(scope==OwnedBusinessScope.OWNED)return requireOwned(number); + if(number==null || number.isBlank() || number.length()>128)throw bad("请指定排车单号"); + var source=repository.source(number);if(source==null)throw scope.missing();eligible(source,scope);return source; } /** 阶段、真实装车hash及车辆快照须与捕获证据一致。 */ - private void eligible(Source source) { - if(source.getOwnedVehicleOwnership()==null)throw conflict("本单缺少装车时车辆归属证据,不能归入自有业务"); - if(!"OWNED".equals(source.getOwnedVehicleOwnership()))throw missing(); + private void eligible(Source source,OwnedBusinessScope scope) { + if(source.getOwnedVehicleOwnership()==null)throw conflict("本单缺少装车时车辆归属证据,不能归入"+(scope==OwnedBusinessScope.OWNED?"自有":"外配")+"业务"); + if(!scope.getOwnership().equals(source.getOwnedVehicleOwnership()))throw scope.missing(); if(!"ACTIVE".equals(source.getStatus()) || source.getStage()==null || !Set.of("AFTER_LOADING","DELIVERING","DELIVERED").contains(source.getStage()) || source.getLoadingCompletedAt()==null || source.getLoadingConfirmationJson()==null)throw conflict("本单尚无合法装车完成事实"); var resource=codec.read(source.getResourcesJson(),DispatchContracts.Resources.class).vehicle(); - if(resource==null || resource.vehicleId()==null || !Objects.equals(source.getOwnedVehicleId(),resource.vehicleId()) - || !"MASTER".equals(source.getOwnedResourceMode()) || !Objects.equals(source.getOwnedLoadingCompletionHash(),source.getLoadingCompletionHash()) + boolean master=resource!=null && resource.mode()!=null && "MASTER".equals(resource.mode().name()) && "MASTER".equals(source.getOwnedResourceMode()) + && resource.vehicleId()!=null && Objects.equals(source.getOwnedVehicleId(),resource.vehicleId()); + boolean external=scope==OwnedBusinessScope.OUTSOURCED && resource!=null && resource.mode()!=null && "EXTERNAL".equals(resource.mode().name()) + && "EXTERNAL".equals(source.getOwnedResourceMode()) && resource.vehicleId()==null && source.getOwnedVehicleId()==null; + if((!master && !external) || !Objects.equals(source.getOwnedLoadingCompletionHash(),source.getLoadingCompletionHash()) || !Objects.equals(source.getOwnedLoadingCompletedAt(),source.getLoadingCompletedAt()))throw conflict("车辆归属与原装车事实不一致"); } /** 操作记录只返回真实附加版本,不制造初始示例事件。 */ @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) public HistoryPage history(Actor actor,String number,Integer requestedPage,Integer requestedSize) { - codec.require(actor,LIST);var source=requireOwned(number);int page=page(requestedPage),size=size(requestedSize); + return history(actor,number,requestedPage,requestedSize,OwnedBusinessScope.OWNED); + } + /** 两类业务复用历史分页,但每次读取先核对权限和冻结归属。 */ + @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) + public HistoryPage history(Actor actor,String number,Integer requestedPage,Integer requestedSize,OwnedBusinessScope scope) { + codec.require(actor,scope.listPermission());var source=requireScoped(number,scope);int page=page(requestedPage),size=size(requestedSize); int offset;try{offset=Math.multiplyExact(page-1,size);}catch(ArithmeticException error){throw bad("页码过大");} return new HistoryPage(repository.history(source.getId(),offset,size).stream().map(h->{ var snapshot="OPERATIONS".equals(h.getEventSource())?codec.read(h.getSnapshotJson(),Snapshot.class):null; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessRecordService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessRecordService.java index 581205798..1ec0b5a54 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessRecordService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessRecordService.java @@ -2,6 +2,7 @@ package com.baiyue.lastleg.fleet.ownedbusiness.service; import com.baomidou.mybatisplus.core.toolkit.IdWorker; import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessScope; import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows; import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Record; import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Source; @@ -30,9 +31,16 @@ public class OwnedBusinessRecordService { /** 整车只保留一份可更正/清空的money,实际油耗为手工L/100km。 */ @Transactional(isolation=Isolation.READ_COMMITTED) public Detail save(Actor actor,String number,SaveOperations request) { - codec.require(actor,EDIT);if(request==null || request.operations()==null)throw bad("运营登记内容缺失"); + return save(actor,number,request,OwnedBusinessScope.OWNED); + } + /** 共享CAS与命令账本;固定外配域不允许保存里程或油耗。 */ + @Transactional(isolation=Isolation.READ_COMMITTED) + public Detail save(Actor actor,String number,SaveOperations request,OwnedBusinessScope scope) { + codec.require(actor,scope.editPermission());if(request==null || request.operations()==null)throw bad("运营登记内容缺失"); + if(scope==OwnedBusinessScope.OUTSOURCED && (request.operations().actualDistanceMiles()!=null || request.operations().fuelConsumptionLitresPer100Km()!=null)) + throw bad("外配业务不支持里程或油耗登记"); codec.key(request.idempotencyKey());long version=codec.version(request.recordVersion()); - var source=queries.requireOwned(number);var previous=commands.existing(source.getId(),request.idempotencyKey(),actor); + var source=scope==OwnedBusinessScope.OWNED?queries.requireOwned(number):queries.requireScoped(number,scope);var previous=commands.existing(source.getId(),request.idempotencyKey(),actor); if(previous!=null){commands.same(previous,"SAVE_OPERATIONS",version,request);return result(previous);} if(request.reason()!=null && request.reason().length()>1000)throw bad("操作说明过长"); var ops=normalize(source,request.operations());var row=lock(source,actor); @@ -42,13 +50,18 @@ public class OwnedBusinessRecordService { row.setCurrency(ops.money()==null?null:ops.money().currency());row.setTotalAmount(ops.money()==null?null:new BigDecimal(ops.money().amount())); row.setActualDistanceMiles(decimal(ops.actualDistanceMiles()));row.setFuelConsumptionLitresPer100Km(decimal(ops.fuelConsumptionLitresPer100Km())); row.setArrivalsJson(codec.write(ops.arrivals())); - return commit(source,row,actor,version,"SAVE_OPERATIONS",request.idempotencyKey(),request,request.reason()); + return commit(source,row,actor,version,"SAVE_OPERATIONS",request.idempotencyKey(),request,request.reason(),scope); } /** File元数据已经在HTTP外层校验,绑定事务只处理本页行锁。 */ @Transactional(isolation=Isolation.READ_COMMITTED) public Detail bind(Actor actor,String number,PhotoSlot slot,BindPhoto request,Photo verified) { - codec.require(actor,UPLOAD);if(request==null || slot==null || verified==null)throw bad("照片绑定内容缺失"); - codec.key(request.idempotencyKey());long version=codec.version(request.recordVersion());var source=queries.requireOwned(number); + return bind(actor,number,slot,request,verified,OwnedBusinessScope.OWNED); + } + /** 外配五框在事务内再次校验,不能靠调用共享写服务绕过。 */ + @Transactional(isolation=Isolation.READ_COMMITTED) + public Detail bind(Actor actor,String number,PhotoSlot slot,BindPhoto request,Photo verified,OwnedBusinessScope scope) { + codec.require(actor,scope.uploadPermission());if(request==null || slot==null || verified==null)throw bad("照片绑定内容缺失");scope.requireSlot(slot); + codec.key(request.idempotencyKey());long version=codec.version(request.recordVersion());var source=scope==OwnedBusinessScope.OWNED?queries.requireOwned(number):queries.requireScoped(number,scope); Object identity=new PhotoBinding(slot,request); var old=commands.existing(source.getId(),request.idempotencyKey(),actor); if(old!=null){commands.same(old,"BIND_PHOTO",version,identity);return result(old);} @@ -60,7 +73,7 @@ public class OwnedBusinessRecordService { photos.put(slot,new Photo(slot,slot.category(),verified.fileId(),verified.fileName(),verified.contentType(), verified.sizeBytes(),verified.uploadedAt(),Instant.now().toString())); row.setPhotosJson(codec.write(List.copyOf(photos.values()))); - return commit(source,row,actor,version,"BIND_PHOTO",request.idempotencyKey(),identity,photoReason(slot)); + return commit(source,row,actor,version,"BIND_PHOTO",request.idempotencyKey(),identity,photoReason(slot),scope); } /** 操作记录使用准确中文槽位,不向用户展示内部枚举。 */ private String photoReason(PhotoSlot slot) { @@ -104,13 +117,14 @@ public class OwnedBusinessRecordService { /** 版本变化必须由浏览器刷新,不能覆盖其他操作者的登记。 */ private void requireVersion(Record row,long expected){if(row.getRecordVersion()==null || row.getRecordVersion()!=expected)throw conflict("运营记录版本已变化,请刷新后重试");} /** 只保存附加版本和不可变历史,真实主单读取失败也必须回滚。 */ - private Detail commit(Source source,Record row,Actor actor,long expected,String action,String key,Object request,String reason) { + private Detail commit(Source source,Record row,Actor actor,long expected,String action,String key,Object request,String reason,OwnedBusinessScope scope) { row.setRecordVersion(Math.addExact(expected,1));row.setUpdatedBy(actor.operatorId());row.setUpdatedAt(codec.utc(Instant.now())); if(repository.update(row,expected)!=1)throw conflict("运营记录版本竞争失败"); var history=new OwnedBusinessRows.History();history.setId(IdWorker.getId());history.setDispatchId(source.getId());history.setRecordVersion(row.getRecordVersion()); history.setAction(action);history.setOperatorId(actor.operatorId());history.setReason(reason);history.setOccurredAt(row.getUpdatedAt()); history.setSnapshotJson(codec.write(new Snapshot(projection.operations(row),projection.photos(row),actor.operatorName())));repository.history(history); - var result=queries.detailForWrite(actor,source);commands.success(source.getId(),actor,key,action,expected,request,result);return result; + var result=scope==OwnedBusinessScope.OWNED?queries.detailForWrite(actor,source):queries.detailForWrite(actor,source,scope); + commands.success(source.getId(),actor,key,action,expected,request,result);return result; } /** 重放返回原成功结果,不能从现在的主单/附加版本重算。 */ private Detail result(OwnedBusinessRows.Command command) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessSupport.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessSupport.java index 048c651df..59c4f2cef 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessSupport.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessSupport.java @@ -4,6 +4,7 @@ import com.baiyue.common.core.exception.BizException; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessScope; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.JavaType; @@ -24,9 +25,13 @@ public class OwnedBusinessSupport { public OwnedBusinessSupport(ObjectMapper json){this.json=json;} /** 平台身份和操作者均取可信会话,不接收浏览器归属参数。 */ public Actor actor(AuthenticatedUser user) { + return actor(user,OwnedBusinessScope.OWNED); + } + /** 仅错误文案按固定入口区分,权限仍原样取会话,绝不补造角色。 */ + public Actor actor(AuthenticatedUser user,OwnedBusinessScope scope) { if(user==null)throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); if(!"PLATFORM".equals(user.appCode()) || !Long.valueOf(0).equals(user.tenantId())) - throw new ResponseStatusException(HttpStatus.FORBIDDEN,"仅平台可管理自有业务"); + throw new ResponseStatusException(HttpStatus.FORBIDDEN,"仅平台可管理"+(scope==OwnedBusinessScope.OWNED?"自有":"外配")+"业务"); String operator=user.userId()==null?user.username():user.userId().toString(); if(operator==null || operator.isBlank())throw new ResponseStatusException(HttpStatus.FORBIDDEN,"缺少有效操作账号"); String name=user.displayName()==null || user.displayName().isBlank()?user.username():user.displayName(); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchMapper.java index 3a43629fe..1dd048ce0 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchMapper.java @@ -18,10 +18,10 @@ public interface AppointmentWorkDispatchMapper { record ReferenceRow(long dispatchId,String batchKey,String batchNo,Long recordVersion,String customerId,String sourceKey,String destinationKey,int packages) { } record CandidateSource(long planPalletId,String customerId,String sourceStage,boolean locked,Long orderId,String destinationKey,Integer plannedPackages,Integer countedPackages) { } record CandidateBatch(String batchKey,String batchNo,Long recordVersion,String customerId,Long orderId,String sourceKey,String destinationKey,Integer packages,long allocated) { } - /** 一次读取整页真实客户/托盘的逐票实物及全来源有效占用。 */ + /** 一次读取整页逐票来源及占用;仅预留但未收货的托盘仍按计划箱数预排。 */ @Select(""" """) @@ -98,7 +98,7 @@ public interface AppointmentWorkDispatchMapper { List transportRows(@Param("batches") List batches,@Param("trips") List trips); @Select("SELECT i.id item_id,i.plan_pallet_id,i.customer_id,i.source_stage,i.package_count packages FROM ll_dispatch_item i JOIN ll_dispatch_order d ON d.id=i.dispatch_order_id WHERE d.dispatch_no=#{number}") List dispatchPhysical(String number); @Select("SELECT id item_id,plan_pallet_id,customer_id,source_stage,package_count packages FROM ll_dispatch_item WHERE dispatch_order_id=#{id} ORDER BY id") List dispatchPhysicalById(long id); - @Select("SELECT DISTINCT p.id plan_pallet_id,p.pallet_no,p.plan_id,n.plan_no,p.customer_id,CASE WHEN p.warehouse_pallet_id IS NULL THEN 'PLAN' ELSE 'RECEIVED' END source_stage,EXISTS(SELECT 1 FROM ll_dispatch_pallet_lock k WHERE (k.plan_pallet_id=p.id OR (p.warehouse_pallet_id IS NOT NULL AND k.warehouse_pallet_id=p.warehouse_pallet_id)) AND k.status='ACTIVE' AND (k.dispatch_order_id IS NOT NULL OR k.expires_at>CURRENT_TIMESTAMP)) locked FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan n ON n.id=p.plan_id JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id JOIN ll_inbound_plan_line l ON l.id=a.line_id JOIN ll_inbound_fba_appointment_item i ON i.order_id=l.order_id AND i.customer_id=l.customer_id AND i.active=1 WHERE i.appointment_id=#{id} ORDER BY p.id LIMIT 501") + @Select("SELECT DISTINCT p.id plan_pallet_id,p.pallet_no,p.plan_id,n.plan_no,p.customer_id,CASE WHEN p.warehouse_pallet_id IS NULL OR (COALESCE(p.assignment_status,'')='RESERVED' AND COALESCE(p.actual_package_count,0)=0) THEN 'PLAN' ELSE 'RECEIVED' END source_stage,EXISTS(SELECT 1 FROM ll_dispatch_pallet_lock k WHERE (k.plan_pallet_id=p.id OR (p.warehouse_pallet_id IS NOT NULL AND k.warehouse_pallet_id=p.warehouse_pallet_id)) AND k.status='ACTIVE' AND (k.dispatch_order_id IS NOT NULL OR k.expires_at>CURRENT_TIMESTAMP)) locked FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan n ON n.id=p.plan_id JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id JOIN ll_inbound_plan_line l ON l.id=a.line_id JOIN ll_inbound_fba_appointment_item i ON i.order_id=l.order_id AND i.customer_id=l.customer_id AND i.active=1 WHERE i.appointment_id=#{id} ORDER BY p.id LIMIT 501") List candidates(long id); @Select("SELECT l.order_id,l.customer_id,CASE WHEN l.destination_type='PRIVATE' THEN CONCAT('CUSTOMER:',l.order_id) ELSE CONCAT('FBA:',l.fba_warehouse_code) END destination_key,CASE WHEN COUNT(a.planned_package_count)=COUNT(a.line_id) THEN SUM(a.planned_package_count) END planned_packages,CASE WHEN COUNT(a.counted_package_count)=COUNT(a.line_id) THEN SUM(a.counted_package_count) END counted_packages FROM ll_inbound_plan_pallet_allocation a JOIN ll_inbound_plan_line l ON l.id=a.line_id AND l.customer_id=a.customer_id WHERE a.pallet_id=#{pallet} AND a.customer_id=#{customer} GROUP BY l.order_id,l.customer_id,l.destination_type,l.fba_warehouse_code") List physical(@Param("pallet") long pallet,@Param("customer") String customer); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/repository/RelabelDispatchSourceRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/repository/RelabelDispatchSourceRepository.java index 2adcf5885..579500b79 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/repository/RelabelDispatchSourceRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/repository/RelabelDispatchSourceRepository.java @@ -68,7 +68,9 @@ public class RelabelDispatchSourceRepository { if (position == null) position = mapper.position(item.getSourceInventoryPositionId()); if (position == null) continue; positions.put(position.getId(), position); + // 无效身份只作为本申请缺失组成保留,不能把其他未绑定位置误连为同一实托。 if (warehouseId.equals(position.getOverseasWarehouseId()) && position.getPalletId() != null + && position.getPalletId() > 0 && !visitedPallets.contains(position.getPalletId())) pending.add(position.getPalletId()); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelDispatchSourceService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelDispatchSourceService.java index 28a643181..5052440a0 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelDispatchSourceService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelDispatchSourceService.java @@ -149,8 +149,9 @@ public class RelabelDispatchSourceService implements RelabelDispatchSourcePort { long notified = number(item.getNotifiedPackageCount()); if (!Objects.equals(item.getCustomerId(), app.getCustomerId()) || position == null || !Objects.equals(position.getCustomerId(), app.getCustomerId()) - || !Objects.equals(warehouseId, position.getOverseasWarehouseId()) || position.getPalletId() == null) { - reasons.add(app.getRelabelNo() + ":明细当前位置缺失或客户、仓库归属不一致"); + || !Objects.equals(warehouseId, position.getOverseasWarehouseId()) || position.getPalletId() == null + || position.getPalletId() <= 0) { + reasons.add(app.getRelabelNo() + ":明细当前位置缺失、实托身份无效或客户、仓库归属不一致"); bound = false; } if (item.getReturnCartonId() != null || "RETURN_BULK_SKU_LABEL".equals(item.getOperationType())) { diff --git a/lastleg-service/src/main/resources/openapi/fleet-outsourced-business-v1.yaml b/lastleg-service/src/main/resources/openapi/fleet-outsourced-business-v1.yaml new file mode 100644 index 000000000..b1067f20e --- /dev/null +++ b/lastleg-service/src/main/resources/openapi/fleet-outsourced-business-v1.yaml @@ -0,0 +1,304 @@ +openapi: "3.0.3" +info: + title: "原排车外配车辆运营视图" + version: "1.0.0" + description: "仅ACTIVE且装车后/派送中/已完成。按原装车归属DISPATCHED及MASTER/EXTERNAL资源快照读取;无快照不回填。复用四张附加表,不推进排车、不触发库存/财务。承运金额CNY/USD显式登记;单价除以实际物理托数并HALF_UP两位。无里程、油耗及油耗照片。响应遵循NON_NULL:可空属性可省略,省略与null均表示空值,0仍是明确登记值;请求必填项仍以required及DTO校验为准。" +security: + - bearerAuth: [] +paths: + /api/platform/v1/last-leg/fleet/outsourced-business: + get: + operationId: listOutsourcedBusiness + summary: "外配列表及同筛选四项完整统计" + x-permission-code: "platform:fleet:outsourced-business:list" + parameters: + - { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } } + - { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 20 } } + - { name: keyword, in: query, schema: { type: string, maxLength: 128 }, description: "原排车号、车牌或真实目的仓/FBA编码" } + - { name: warehouseId, in: query, schema: { type: string, maxLength: 64 } } + - { name: plannedDepartureFrom, in: query, schema: { type: string, format: date-time }, description: "原计划发车时间下界(含)" } + - { name: plannedDepartureTo, in: query, schema: { type: string, format: date-time }, description: "原计划发车时间上界(不含)" } + - { name: stage, in: query, schema: { "$ref": "fleet-owned-business-v1.yaml#/components/schemas/Stage" } } + responses: + "200": + description: "完整条件统计不带分页,不逐行GET;响应为统一ApiResponse。" + content: + application/json: + schema: { "$ref": "#/components/schemas/ListResponse" } + /api/platform/v1/last-leg/fleet/outsourced-business/{dispatchNo}: + parameters: [ { "$ref": "#/components/parameters/DispatchNo" } ] + get: + operationId: getOutsourcedBusiness + summary: "外配原排车组合详情" + x-permission-code: "platform:fleet:outsourced-business:list" + responses: + "200": { "$ref": "#/components/responses/DetailResponse" } + /api/platform/v1/last-leg/fleet/outsourced-business/{dispatchNo}/history: + parameters: [ { "$ref": "#/components/parameters/DispatchNo" } ] + get: + operationId: getOutsourcedBusinessHistory + summary: "原排车与运营附加历史分页" + x-permission-code: "platform:fleet:outsourced-business:list" + parameters: + - { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } } + - { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 20 } } + responses: + "200": + description: "DISPATCH/OPERATIONS保留独立版本,原历史无虚构运营字段。" + content: + application/json: + schema: { "$ref": "#/components/schemas/HistoryResponse" } + /api/platform/v1/last-leg/fleet/outsourced-business/{dispatchNo}/operations: + parameters: [ { "$ref": "#/components/parameters/DispatchNo" } ] + put: + operationId: saveOutsourcedBusinessOperations + summary: "登记或清空整车承运金额和逐目的仓实际到达" + x-permission-code: "platform:fleet:outsourced-business:edit" + requestBody: + required: true + content: + application/json: + schema: { "$ref": "#/components/schemas/SaveOperations" } + responses: + "200": { "$ref": "#/components/responses/DetailResponse" } + /api/platform/v1/last-leg/fleet/outsourced-business/{dispatchNo}/photos/{slot}/uploads: + parameters: [ { "$ref": "#/components/parameters/DispatchNo" }, { "$ref": "#/components/parameters/Slot" } ] + post: + operationId: uploadOutsourcedBusinessPhoto + summary: "准备五框照片文件,尚未绑定" + x-permission-code: "platform:fleet:outsourced-business:upload" + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [file, idempotencyKey] + properties: + file: { type: string, format: binary, description: "非空JPG/PNG,签名校验,最大10MiB;固定装车File分类" } + idempotencyKey: { type: string, minLength: 8, maxLength: 128 } + responses: + "200": + description: "savedAt=null;未知结果保留原键查询,不重传。" + content: + application/json: + schema: { "$ref": "#/components/schemas/PhotoResponse" } + /api/platform/v1/last-leg/fleet/outsourced-business/{dispatchNo}/photos/{slot}: + parameters: [ { "$ref": "#/components/parameters/DispatchNo" }, { "$ref": "#/components/parameters/Slot" } ] + put: + operationId: bindOutsourcedBusinessPhoto + summary: "以独立稳定键绑定原File编号" + x-permission-code: "platform:fleet:outsourced-business:upload" + requestBody: + required: true + content: + application/json: + schema: { "$ref": "#/components/schemas/BindPhoto" } + responses: + "200": { "$ref": "#/components/responses/DetailResponse" } + /api/platform/v1/last-leg/fleet/outsourced-business/{dispatchNo}/photos/{slot}/files/{fileId}/content: + parameters: [ { "$ref": "#/components/parameters/DispatchNo" }, { "$ref": "#/components/parameters/Slot" } ] + get: + operationId: readOutsourcedBusinessPhotoContent + summary: "读取当前或精确历史版本的同车照片引用" + x-permission-code: "platform:fleet:outsourced-business:list" + parameters: + - { name: fileId, in: path, required: true, schema: { type: integer, format: int64, minimum: 1 } } + - { name: revisionVersion, in: query, schema: { type: integer, format: int64, minimum: 1 } } + responses: + "200": + description: "原始照片字节,Cache-Control=no-store,X-Content-Type-Options=nosniff;无引用不允许探测。" + content: + image/png: { schema: { type: string, format: binary } } + image/jpeg: { schema: { type: string, format: binary } } + /api/platform/v1/last-leg/fleet/outsourced-business/{dispatchNo}/commands/{idempotencyKey}: + parameters: [ { "$ref": "#/components/parameters/DispatchNo" } ] + get: + operationId: getOutsourcedBusinessCommand + summary: "仅原操作账号查询原命令结果" + x-permission-code: "platform:fleet:outsourced-business:list" + parameters: + - { name: idempotencyKey, in: path, required: true, schema: { type: string, minLength: 8, maxLength: 128 } } + responses: + "200": + description: "同dispatch唯一账本;相同键异内容/版本冲突,异账号拒绝,未知不自动重试。" + content: + application/json: + schema: { "$ref": "#/components/schemas/CommandResponse" } +components: + securitySchemes: + bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT } + parameters: + DispatchNo: { name: dispatchNo, in: path, required: true, schema: { type: string, minLength: 1, maxLength: 128 } } + Slot: { name: slot, in: path, required: true, schema: { "$ref": "#/components/schemas/PhotoSlot" } } + responses: + DetailResponse: + description: "统一ApiResponse;成功code=200,业务拒绝A0400/A0403/A0404/A0409,认证/网关可真实401/403/503;保留requestId/traceId。" + content: + application/json: + schema: + allOf: + - { "$ref": "#/components/schemas/Envelope" } + - { type: object, properties: { data: { allOf: [ { "$ref": "#/components/schemas/Detail" } ], nullable: true } } } + schemas: + Envelope: + type: object + properties: + code: { type: string } + message: { type: string } + requestId: { type: string, nullable: true } + traceId: { type: string, nullable: true } + Operations: + type: object + additionalProperties: false + x-java-record: OutsourcedBusinessModels.Operations + required: [arrivals] + properties: + money: { allOf: [ { "$ref": "fleet-owned-business-v1.yaml#/components/schemas/Money" } ], nullable: true } + arrivals: { type: array, items: { "$ref": "fleet-owned-business-v1.yaml#/components/schemas/Arrival" }, description: "只接受原实际destinationKey,禁止重复或其他车辆目的地;省略目的地或null均不推定到达" } + SaveOperations: + type: object + additionalProperties: false + x-java-record: OutsourcedBusinessModels.SaveOperations + required: [recordVersion, idempotencyKey, operations] + properties: + recordVersion: { type: string, pattern: "^(0|[1-9][0-9]*)$" } + idempotencyKey: { type: string, minLength: 8, maxLength: 128 } + operations: { "$ref": "#/components/schemas/Operations" } + reason: { type: string, nullable: true, maxLength: 1000 } + BindPhoto: + type: object + additionalProperties: false + x-java-record: OutsourcedBusinessModels.BindPhoto + required: [recordVersion, idempotencyKey, fileId] + properties: + recordVersion: { type: string, pattern: "^(0|[1-9][0-9]*)$" } + idempotencyKey: { type: string, minLength: 8, maxLength: 128 } + fileId: { type: string, pattern: "^[1-9][0-9]*$" } + Vehicle: + type: object + x-java-record: OutsourcedBusinessModels.Vehicle + required: [ownership] + properties: + vehicleId: { type: string, nullable: true, description: "原临时EXTERNAL车辆合法为空,档案MASTER核对原捕获ID" } + plate: { type: string, nullable: true } + ownership: { type: string, enum: [DISPATCHED] } + carrierName: { type: string, nullable: true, description: "只取原车辆快照,缺失显示—;不查当前档案补值" } + PhotoSlot: + type: string + enum: [LOADING_SCENE, LOADING_LIST, PRE_DEPARTURE_CHECK, DRIVER_LICENSE, RECEIPT] + Photo: + type: object + x-java-record: OutsourcedBusinessModels.Photo + required: [slot, category, fileId, fileName, contentType, sizeBytes, uploadedAt] + properties: + slot: { "$ref": "#/components/schemas/PhotoSlot" } + category: { type: string, enum: [LOADING] } + fileId: { type: string } + fileName: { type: string } + contentType: { type: string, enum: [image/png, image/jpeg] } + sizeBytes: { type: string } + uploadedAt: { type: string, format: date-time } + savedAt: { type: string, format: date-time, nullable: true } + PhotoSummary: + type: object + x-java-record: OutsourcedBusinessModels.PhotoSummary + required: [loadingCount] + properties: + loadingCount: { type: integer, minimum: 0, maximum: 5 } + Row: + type: object + x-java-record: OutsourcedBusinessModels.Row + required: [dispatchNo, stage, plannedDepartureAt, loadingCompletedAt, origin, vehicle, driver, destinations, actualPalletCount, operations, recordVersion, dispatchRecordVersion, photoSummary, actions, sourceIssues] + properties: + dispatchNo: { type: string } + stage: { "$ref": "fleet-owned-business-v1.yaml#/components/schemas/Stage" } + plannedDepartureAt: { type: string, format: date-time } + loadingCompletedAt: { type: string, format: date-time } + departedAt: { type: string, format: date-time, nullable: true } + deliveryCompletedAt: { type: string, format: date-time, nullable: true } + origin: { "$ref": "fleet-owned-business-v1.yaml#/components/schemas/Origin" } + vehicle: { "$ref": "#/components/schemas/Vehicle" } + driver: { "$ref": "fleet-owned-business-v1.yaml#/components/schemas/Driver" } + destinations: { type: array, items: { "$ref": "fleet-owned-business-v1.yaml#/components/schemas/Destination" } } + actualPalletCount: { type: integer, minimum: 0, description: "原实载warehousePalletId去重;同客户/跨客户共托只计一次,不用标准托位作除数" } + operations: { "$ref": "#/components/schemas/Operations" } + unitPrice: { type: string, nullable: true, description: "整车金额除实际物理托数,HALF_UP两位;金额null或托数0时null" } + recordVersion: { type: string, description: "本页附加版本,初始只读虚拟0;原主单版本独立" } + dispatchRecordVersion: { type: string } + photoSummary: { "$ref": "#/components/schemas/PhotoSummary" } + actions: { "$ref": "fleet-owned-business-v1.yaml#/components/schemas/Actions" } + sourceIssues: { type: array, items: { type: string } } + ListResult: + type: object + x-java-record: OutsourcedBusinessModels.ListResult + required: [records, page, size, total, summary] + properties: + records: { type: array, items: { "$ref": "#/components/schemas/Row" } } + page: { type: integer } + size: { type: integer } + total: { type: integer } + summary: { "$ref": "fleet-owned-business-v1.yaml#/components/schemas/Summary" } + Detail: + type: object + x-java-record: OutsourcedBusinessModels.Detail + required: [row, dispatch, cargo, photos] + properties: + row: { "$ref": "#/components/schemas/Row" } + dispatch: { "$ref": "overseas-business-v1.yaml#/components/schemas/DispatchDetail" } + cargo: { type: array, items: { "$ref": "fleet-owned-business-v1.yaml#/components/schemas/Cargo" } } + photos: { type: array, items: { "$ref": "#/components/schemas/Photo" } } + History: + type: object + x-java-record: OutsourcedBusinessModels.History + required: [eventSource, action, occurredAt, photos] + properties: + eventSource: { type: string, enum: [DISPATCH, OPERATIONS] } + recordVersion: { type: string, nullable: true } + dispatchRecordVersion: { type: string, nullable: true } + action: { type: string } + operatorName: { type: string, nullable: true } + occurredAt: { type: string, format: date-time } + reason: { type: string, nullable: true } + operations: { allOf: [ { "$ref": "#/components/schemas/Operations" } ], nullable: true } + photos: { type: array, items: { "$ref": "#/components/schemas/Photo" } } + HistoryPage: + type: object + x-java-record: OutsourcedBusinessModels.HistoryPage + required: [records, page, size, total] + properties: + records: { type: array, items: { "$ref": "#/components/schemas/History" } } + page: { type: integer } + size: { type: integer } + total: { type: integer } + Command: + type: object + x-java-record: OutsourcedBusinessModels.Command + description: "SAVE_OPERATIONS/BIND_PHOTO成功时recordVersion和result必须有值;UPLOAD_PHOTO成功时result必须含fileId,recordVersion可空。仅可空的未确定或拒绝结果字段允许省略,不放宽成功回执的关联要求。" + required: [dispatchNo, action, idempotencyKey, state, replayed, retryAllowed] + properties: + dispatchNo: { type: string } + action: { type: string, enum: [SAVE_OPERATIONS, UPLOAD_PHOTO, BIND_PHOTO] } + idempotencyKey: { type: string } + state: { type: string, enum: [SUCCEEDED, PENDING, UNKNOWN_RESULT, REJECTED] } + recordVersion: { type: string, nullable: true } + replayed: { type: boolean } + result: { nullable: true, oneOf: [ { "$ref": "#/components/schemas/Detail" }, { "$ref": "#/components/schemas/Photo" } ] } + errorCode: { type: string, nullable: true } + retryAllowed: { type: boolean, enum: [false] } + ListResponse: + allOf: + - { "$ref": "#/components/schemas/Envelope" } + - { type: object, properties: { data: { allOf: [ { "$ref": "#/components/schemas/ListResult" } ], nullable: true } } } + HistoryResponse: + allOf: + - { "$ref": "#/components/schemas/Envelope" } + - { type: object, properties: { data: { allOf: [ { "$ref": "#/components/schemas/HistoryPage" } ], nullable: true } } } + PhotoResponse: + allOf: + - { "$ref": "#/components/schemas/Envelope" } + - { type: object, properties: { data: { allOf: [ { "$ref": "#/components/schemas/Photo" } ], nullable: true } } } + CommandResponse: + allOf: + - { "$ref": "#/components/schemas/Envelope" } + - { type: object, properties: { data: { allOf: [ { "$ref": "#/components/schemas/Command" } ], nullable: true } } } diff --git a/lastleg-service/src/main/resources/openapi/fleet-owned-business-v1.yaml b/lastleg-service/src/main/resources/openapi/fleet-owned-business-v1.yaml index 2b2b9372d..a65002c10 100644 --- a/lastleg-service/src/main/resources/openapi/fleet-owned-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/fleet-owned-business-v1.yaml @@ -2,7 +2,7 @@ openapi: "3.0.3" info: title: "原排车自有车辆运营视图" version: "1.0.0" - description: "装车完成时冻结车辆OWNED/DISPATCHED;所有主单事实来自原dispatch。新增数据仅附加运营记录、命令、历史及归属证据。油耗L/100km手工登记,金额CNY/USD不默认、不换汇;零与未登记不同。" + description: "装车完成时冻结车辆OWNED/DISPATCHED;所有主单事实来自原dispatch。新增数据仅附加运营记录、命令、历史及归属证据。油耗L/100km手工登记,金额CNY/USD不默认、不换汇;零与未登记不同。响应遵循NON_NULL:可空属性可省略,省略与null均表示空值,0仍是明确登记值;请求必填项仍以required及DTO校验为准。" security: - bearerAuth: [] @@ -237,15 +237,6 @@ paths: - "$ref": "#/components/schemas/Photo" nullable: true - parameters: - - - name: "idempotencyKey" - in: "query" - required: true - schema: - type: "string" - minLength: 8 - maxLength: 128 requestBody: required: true content: @@ -254,7 +245,13 @@ paths: type: "object" required: - "file" + - "idempotencyKey" properties: + idempotencyKey: + type: "string" + minLength: 8 + maxLength: 128 + description: "原上传请求键,以multipart表单字段提交,绑定使用独立请求键。" file: type: "string" format: "binary" @@ -432,7 +429,6 @@ components: x-java-record: "OwnedBusinessModels.Arrival" required: - "destinationKey" - - "actualArrivedAt" properties: destinationKey: type: "string" @@ -446,9 +442,6 @@ components: type: "object" x-java-record: "OwnedBusinessModels.Operations" required: - - "money" - - "actualDistanceMiles" - - "fuelConsumptionLitresPer100Km" - "arrivals" properties: money: @@ -512,7 +505,6 @@ components: x-java-record: "OwnedBusinessModels.Gate" required: - "allowed" - - "blockReason" properties: allowed: type: "boolean" @@ -563,9 +555,6 @@ components: Driver: type: "object" x-java-record: "OwnedBusinessModels.Driver" - required: - - "name" - - "phone" properties: name: type: "string" @@ -578,8 +567,6 @@ components: x-java-record: "OwnedBusinessModels.Appointment" required: - "appointmentNo" - - "confirmationNo" - - "scheduledAt" - "allocatedPackages" properties: appointmentNo: @@ -599,11 +586,7 @@ components: x-java-record: "OwnedBusinessModels.Destination" required: - "destinationKey" - - "warehouseCode" - - "warehouseName" - - "address" - "appointments" - - "actualArrivedAt" properties: destinationKey: type: "string" @@ -635,7 +618,6 @@ components: - "contentType" - "sizeBytes" - "uploadedAt" - - "savedAt" properties: slot: "$ref": "#/components/schemas/PhotoSlot" @@ -684,15 +666,11 @@ components: - "stage" - "plannedDepartureAt" - "loadingCompletedAt" - - "departedAt" - - "deliveryCompletedAt" - "origin" - "vehicle" - "driver" - "destinations" - - "actualPalletCount" - "operations" - - "unitPrice" - "recordVersion" - "dispatchRecordVersion" - "photoSummary" @@ -804,9 +782,6 @@ components: required: - "sourceKey" - "customerId" - - "customerName" - - "orderNo" - - "inboundCode" - "palletNo" - "destinationKey" - "packages" @@ -856,13 +831,8 @@ components: x-java-record: "OwnedBusinessModels.History" required: - "eventSource" - - "recordVersion" - - "dispatchRecordVersion" - "action" - - "operatorName" - "occurredAt" - - "reason" - - "operations" - "photos" properties: eventSource: @@ -925,15 +895,13 @@ components: Command: type: "object" x-java-record: "OwnedBusinessModels.Command" + description: "SAVE_OPERATIONS/BIND_PHOTO成功时recordVersion和result必须有值;UPLOAD_PHOTO成功时result必须含fileId,recordVersion可空。仅可空的未确定或拒绝结果字段允许省略,不放宽成功回执的关联要求。" required: - "dispatchNo" - "action" - "idempotencyKey" - "state" - - "recordVersion" - "replayed" - - "result" - - "errorCode" - "retryAllowed" properties: dispatchNo: diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchCandidateMapperContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchCandidateMapperContractTest.java index daf2c0e44..5b0cdbba1 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchCandidateMapperContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchCandidateMapperContractTest.java @@ -16,12 +16,62 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertEquals; class DispatchCandidateMapperContractTest { + /** 执行正式计划/实收判定与离港门禁,覆盖自动预留、人工覆盖、混合来源及库存耗尽。 */ + @Test + void reservedPalletsCanBePlannedAfterDepartureWithoutInventingReceivedStock() throws Exception { + try (var connection = DriverManager.getConnection("jdbc:h2:mem:dispatch-prearrival;MODE=MySQL")) { + var db = connection.createStatement(); + db.execute("CREATE TABLE ll_inbound_plan(id BIGINT,customer_id VARCHAR)"); + db.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT,plan_id BIGINT,warehouse_pallet_id BIGINT,assignment_status VARCHAR,actual_package_count INT)"); + db.execute("CREATE TABLE ll_inbound_plan_line(id BIGINT,customer_id VARCHAR,order_id BIGINT)"); + db.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(pallet_id BIGINT,line_id BIGINT)"); + db.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR,order_type VARCHAR,manual_node_override BOOLEAN,source_container_booking_id BIGINT)"); + db.execute("CREATE TABLE ll_order_manual_node(order_id BIGINT,customer_id VARCHAR,node_code VARCHAR,node_at TIMESTAMP)"); + db.execute("CREATE TABLE ll_container_shipping_gate(container_booking_id BIGINT,node_sequence INT,actual_at TIMESTAMP)"); + db.execute("CREATE TABLE ll_inventory_position(customer_id VARCHAR,pallet_id BIGINT,available_packages INT)"); + for (int id = 1; id <= 9; id++) { + db.execute("INSERT INTO ll_inbound_plan VALUES (" + id + ",'A')"); + db.execute("INSERT INTO ll_inbound_plan_pallet VALUES (" + id + "," + id + "," + (100 + id) + ",'RESERVED',0)"); + db.execute("INSERT INTO ll_inbound_plan_line VALUES (" + id + ",'A'," + id + ")"); + db.execute("INSERT INTO ll_inbound_plan_pallet_allocation VALUES (" + id + "," + id + ")"); + db.execute("INSERT INTO ll_order VALUES (" + id + ",'A','FIRST_LEG',FALSE," + id + ")"); + if (id != 2) db.execute("INSERT INTO ll_container_shipping_gate VALUES (" + id + ",40,CURRENT_TIMESTAMP)"); + } + db.execute("UPDATE ll_inbound_plan_pallet SET assignment_status='ASSIGNED',actual_package_count=5 WHERE id IN (3,4)"); + db.execute("INSERT INTO ll_inventory_position VALUES ('A',103,5),('A',104,0),('B',104,10)"); + db.execute("UPDATE ll_inbound_plan_pallet SET actual_package_count=1 WHERE id=5"); + db.execute("UPDATE ll_order SET manual_node_override=TRUE WHERE id IN (6,7,9)"); + db.execute("INSERT INTO ll_order_manual_node VALUES (7,'A','ACTUAL_DEPARTURE',CURRENT_TIMESTAMP),(9,'B','ACTUAL_DEPARTURE',CURRENT_TIMESTAMP)"); + db.execute("INSERT INTO ll_inbound_plan_line VALUES (20,'A',2)"); + db.execute("INSERT INTO ll_inbound_plan_pallet_allocation VALUES (8,20)"); + String plan = DispatchCandidateMapper.PLAN_ONLY; + String eligible = "AND (NOT " + plan + " OR (" + DispatchCandidateMapper.DEPARTED_PLAN + ")) " + + "AND (" + plan + " OR EXISTS (SELECT 1 FROM ll_inventory_position pos " + + "WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0))"; + var ids = new ArrayList(); + try (var rows = db.executeQuery("SELECT p.id,CASE WHEN " + plan + " THEN NULL ELSE p.warehouse_pallet_id END AS physical," + + "CASE WHEN " + plan + " THEN 'PLAN' ELSE 'RECEIVED' END AS stage " + + "FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id WHERE 1=1 " + eligible + " ORDER BY p.id")) { + while (rows.next()) { + long id = rows.getLong("id"); ids.add(id); + assertEquals(id == 3 ? "RECEIVED" : "PLAN", rows.getString("stage")); + assertEquals(id == 3 ? 103L : null, rows.getObject("physical", Long.class)); + } + } + assertEquals(List.of(1L, 3L, 7L), ids); + // 查询和写入选中来源共用同一判定;不允许仅在列表展示在途但提交时又要求实收。 + for (String sql : List.of(DispatchCandidateMapper.AVAILABLE_PLAN_BODY, DispatchCandidateMapper.SELECTED_PLAN_SQL)) { + assertTrue(sql.contains(eligible.replace("> 0", "> 0").replace("pos.customer_id=ip.customer_id", sql.contains("WHERE pos.customer_id=#{customerId}") ? "pos.customer_id=#{customerId}" : "pos.customer_id=ip.customer_id"))); + assertTrue(sql.contains("CASE WHEN " + plan + " THEN NULL ELSE p.warehouse_pallet_id END AS warehousePalletId")); + } + } + } /** 执行正式仓ID的完整CASE及外层过滤,保留实库存优先、客户来源和长ID文本身份。 */ @Test void wholeCandidateWarehouseProjectionExecutesAsTextWithoutChangingSourceOwnership() throws Exception { String sql = String.join(" ", DispatchCandidateMapper.class.getMethod("wholeAvailable", String.class, String.class, List.class, String.class, String.class, String.class, String.class).getAnnotation(Select.class).value()); - int start = sql.indexOf("CAST(CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(pos.overseas_warehouse_id)"); + int start = sql.indexOf("CAST(CASE WHEN NOT " + DispatchCandidateMapper.PLAN_ONLY + " THEN (SELECT MIN(pos.overseas_warehouse_id)"); int end = sql.indexOf(" AS overseasWarehouseId", start); assertTrue(start >= 0 && end > start, "正式whole候选必须在整个仓CASE外统一为文本投影"); String expression = sql.substring(start, end).replace(">", ">").replace("<", "<"); @@ -35,15 +85,16 @@ class DispatchCandidateMapperContractTest { try (var connection = DriverManager.getConnection("jdbc:h2:mem:dispatch-whole-warehouse-text;MODE=MySQL")) { var statement = connection.createStatement(); statement.execute("CREATE TABLE ll_inbound_plan(id BIGINT,customer_id VARCHAR(128),overseas_warehouse_id BIGINT,source_order_id BIGINT)"); - statement.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT,plan_id BIGINT,warehouse_pallet_id BIGINT)"); + statement.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT,plan_id BIGINT,warehouse_pallet_id BIGINT,assignment_status VARCHAR DEFAULT 'ASSIGNED',actual_package_count INT DEFAULT 1)"); statement.execute("CREATE TABLE ll_inventory_position(customer_id VARCHAR(128),pallet_id BIGINT,available_packages BIGINT,overseas_warehouse_id VARCHAR(128))"); statement.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR(128),overseas_warehouse_id VARCHAR(128))"); statement.execute("INSERT INTO ll_inbound_plan VALUES (1,'A',193000000000200002,NULL)," + "(2,'A',193000000000100001,NULL),(3,'B',NULL,20),(4,'A',193000000000100001,NULL)," + "(5,'B',193000000000200002,NULL),(6,'A',NULL,NULL),(7,'A',193000000000100001,21)," + "(8,'A',193000000000100001,NULL),(9,'A',NULL,21)"); - statement.execute("INSERT INTO ll_inbound_plan_pallet VALUES (1,1,10),(2,2,NULL),(3,3,NULL)," + statement.execute("INSERT INTO ll_inbound_plan_pallet(id,plan_id,warehouse_pallet_id) VALUES (1,1,10),(2,2,NULL),(3,3,NULL)," + "(4,4,40),(5,5,50),(6,6,NULL),(7,7,NULL),(8,8,80),(9,9,NULL)"); + statement.execute("INSERT INTO ll_inbound_plan_pallet VALUES (10,1,1000,'RESERVED',0)"); statement.execute("INSERT INTO ll_inventory_position VALUES ('A',10,1,'193000000000100001')," + "('B',40,1,'193000000000100001'),('A',40,0,'193000000000100001')," + "('B',50,1,'193000000000100001'),('A',80,1,'193000000000200002')"); @@ -51,7 +102,7 @@ class DispatchCandidateMapperContractTest { + "(20,'B','193000000000100001'),(21,'B','193000000000100001')"); try (var prepared = connection.prepareStatement(query)) { assertWarehouseRows(prepared, "193000000000100001", List.of(1L, 2L, 3L, 5L, 7L)); - assertWarehouseRows(prepared, "193000000000200002", List.of(8L)); + assertWarehouseRows(prepared, "193000000000200002", List.of(8L, 10L)); for (String invalid : List.of("19300000000010000", "193000000000100001x", "0193000000000100001", "1.93e17", "wrong")) assertWarehouseRows(prepared, invalid, List.of()); } @@ -215,7 +266,7 @@ class DispatchCandidateMapperContractTest { Method method = DispatchCandidateMapper.class.getMethod("lockSelected", String.class, List.class); String sql = String.join(" ", method.getAnnotation(Select.class).value()); - assertTrue(sql.contains("p.warehouse_pallet_id IS NULL OR EXISTS")); + assertTrue(sql.contains("AND (" + DispatchCandidateMapper.PLAN_ONLY + " OR EXISTS")); assertTrue(sql.contains("pos.available_packages > 0")); } @@ -249,7 +300,7 @@ class DispatchCandidateMapperContractTest { String.class, String.class, String.class), DispatchCandidateMapper.class.getMethod("lockSelected", String.class, List.class))) { String sql = String.join(" ", method.getAnnotation(Select.class).value()); - assertTrue(sql.contains("CASE WHEN p.warehouse_pallet_id IS NOT NULL")); + assertTrue(sql.contains("CASE WHEN NOT " + DispatchCandidateMapper.PLAN_ONLY)); assertTrue(sql.contains("pos.overseas_warehouse_id")); assertTrue(sql.contains(method.getName().equals("available") ? "NULLIF(CAST(ip.overseas_warehouse_id AS CHAR(128)),'')" diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/repository/DispatchNumberSequenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/repository/DispatchNumberSequenceTest.java index d8758a6d9..5db5ce777 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/repository/DispatchNumberSequenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/repository/DispatchNumberSequenceTest.java @@ -1,6 +1,10 @@ package com.baiyue.lastleg.dispatch.repository; +import com.baiyue.lastleg.dispatch.mapper.DispatchNumberSequenceMapper; +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.*; @@ -18,22 +22,29 @@ class DispatchNumberSequenceTest { final JdbcTemplate jdbc; final DataSourceTransactionManager transactions; final DispatchNumberSequence sequence; - Fixture() { + final DispatchNumberSequenceMapper mapper; + /** 隔离库造表只使用测试 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 ll_dispatch_daily_sequence(business_date DATE PRIMARY KEY,last_sequence BIGINT NOT NULL)"); - var proxy=new ProxyFactory(new DispatchNumberSequence(jdbc));proxy.setProxyTargetClass(true); + var configuration=new MybatisConfiguration();configuration.addMapper(DispatchNumberSequenceMapper.class); + var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(data);factory.setConfiguration(configuration);factory.afterPropertiesSet(); + mapper=new SqlSessionTemplate(factory.getObject()).getMapper(DispatchNumberSequenceMapper.class); + var proxy=new ProxyFactory(new DispatchNumberSequence(mapper));proxy.setProxyTargetClass(true); proxy.addAdvice(new TransactionInterceptor(transactions,new AnnotationTransactionAttributeSource())); sequence=(DispatchNumberSequence)proxy.getProxy(); } } - @Test void startsAtOneEachDayAndContinuesAcrossGeneratorInstances() { + /** 每次业务事务独立取号,同一天连续增长,下一天从一开始。 */ + @Test void startsAtOneEachDayAndContinuesAcrossGeneratorInstances() throws Exception { var f=new Fixture();var today=LocalDate.of(2026,10,20); assertThat(f.sequence.next(today)).isEqualTo(1); assertThat(f.sequence.next(today)).isEqualTo(2); assertThat(f.sequence.next(today.plusDays(1))).isEqualTo(1); assertThat(f.sequence.next(today)).isEqualTo(3); } + /** 两个真实事务连接争用同一天主键,全部已提交号码唯一且连续。 */ @Test void concurrentConnectionsAllocateEveryNumberOnce() throws Exception { var f=new Fixture();var date=LocalDate.of(2026,10,20);var pool=Executors.newFixedThreadPool(2); try { @@ -43,17 +54,30 @@ class DispatchNumberSequenceTest { assertThat(values.last()).isEqualTo(64L); } finally {pool.shutdownNow();} } - @Test void outerBusinessRollbackDoesNotReuseAnAlreadyAllocatedNumber() { + /** 外层建单回滚不能回滚独立取号事务,也不能复用已分配号。 */ + @Test void outerBusinessRollbackDoesNotReuseAnAlreadyAllocatedNumber() throws Exception { var f=new Fixture();var date=LocalDate.of(2026,10,20); assertThatThrownBy(()->new TransactionTemplate(f.transactions).execute(status->{ assertThat(f.sequence.next(date)).isEqualTo(1);throw new IllegalStateException("business rollback"); })).isInstanceOf(IllegalStateException.class); assertThat(f.sequence.next(date)).isEqualTo(2); } - @Test void fourDigitExhaustionDoesNotWrapOrIncrementTheStoredCounter() { + /** 四位上限拒绝分配,并回滚本次递增,保持当日已用尽事实。 */ + @Test void fourDigitExhaustionDoesNotWrapOrIncrementTheStoredCounter() throws Exception { var f=new Fixture();var date=LocalDate.of(2026,10,20); f.jdbc.update("INSERT INTO ll_dispatch_daily_sequence VALUES(?,9999)",date); assertThatThrownBy(()->f.sequence.next(date)).hasMessageContaining("四位流水号已用满"); assertThat(f.jdbc.queryForObject("SELECT last_sequence FROM ll_dispatch_daily_sequence WHERE business_date=?",Long.class,date)).isEqualTo(9999L); } + + /** 直接改变连接级值后再次调用同一 Mapper,必须绕过一级会话缓存返回新值。 */ + @Test void connectionResultBypassesSessionCacheInTheSameTransaction() 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); + }); + } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchMergedRelabelPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchMergedRelabelPersistenceTest.java index d4c32ae67..9a84ac56d 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchMergedRelabelPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchMergedRelabelPersistenceTest.java @@ -148,6 +148,33 @@ class DispatchMergedRelabelPersistenceTest { var created=f.create("actual-plan",group.sources().stream().map(s->s.selection()).toList());assertThat(created.counts().palletCount()).isEqualTo(1);assertThat(created.counts().packageCount()).isEqualTo(4); } + /** 无效实托不成为候选或跨申请扩组锚点;同申请缺失的组成仍明确阻断建单。 */ + @ParameterizedTest @ValueSource(longs={0,-1}) + void invalidPalletIdentityDoesNotCrashOrConnectUnrelatedApplications(Long invalidPallet) throws Exception { + var f=new Merged(false); + for(long id:List.of(102L,103L))f.insert("ll_inventory_position","id",id,"customer_id","101","balance_id",601, + "overseas_warehouse_id","9","pallet_id",invalidPallet,"pallet_no","","batch_no","MISSING-"+id, + "warehouse_location_code","LOC-1","hold_packages",1); + f.insert("ll_relabel_item","id",303,"application_id",201,"customer_id","101","product_name_zh","待核对来源", + "total_package_count",1,"notified_package_count",1,"carton_mark","OLD-MISSING","fba_carton_mark","NEW-MISSING", + "operation_type","CARTON_LABEL","source_inventory_balance_id",601,"source_inventory_position_id",102, + "source_inbound_order_id",7201,"source_inbound_order_no","ORIGIN-MISSING","source_inbound_code","CODE-MISSING", + "destination_fba_warehouse_code","ONT8"); + f.application(203,"RL-UNRELATED","101",9003,304,103,1,"UNRELATED"); + var candidates=f.rules.wholeCandidates("platform",new DispatchPlanningModels.CandidateQuery("9",null,null,null,"RELABEL",null)); + var groups=f.rules.selectionGroups(candidates); + assertThat(candidates).hasSize(2).allSatisfy(row->assertThat(row.warehousePalletId()).isEqualTo(501L)); + var group=groups.get("RELABEL:RL-A:501"); + assertThat(group.complete()).isFalse(); + assertThat(group.blockReasons()).anyMatch(reason->reason.contains("RL-A")&&reason.contains("明细当前位置缺失")); + assertThat(group.sources()).extracting(DispatchPlanningModels.SelectionGroupSource::sourceBusinessNo) + .containsExactlyInAnyOrder("RL-A","RL-B"); + assertThat(group.pallets()).hasSize(1); + assertThatThrownBy(()->f.create("invalid-pallet",group.sources().stream().map(s->s.selection()).toList())) + .isInstanceOf(org.springframework.web.server.ResponseStatusException.class); + assertThat(f.count("ll_dispatch_order")).isZero();assertThat(f.count("ll_dispatch_pallet_lock")).isZero(); + } + /** 只隔离测量和车队外部目录;来源提供者、候选、库存与所有排车Mapper均为实际实现。 */ private static final class Merged { final DispatchAggregatePersistenceTest.Fixture f=new DispatchAggregatePersistenceTest.Fixture(); @@ -163,7 +190,7 @@ class DispatchMergedRelabelPersistenceTest { for(var table:List.of("ll_order","ll_inventory_position","ll_inventory_balance","ll_inventory_ledger","ll_inventory_operation","ll_physical_pallet_guard", "ll_relabel_application","ll_relabel_item","ll_relabel_pda_task","ll_relabel_pda_history","ll_relabel_pda_unit_fact","ll_inbound_plan","ll_inbound_plan_pallet", "ll_inbound_stock_receipt","ll_dispatch_item_source","ll_dispatch_physical_pallet_lock","ll_dispatch_pallet_lock","ll_dispatch_pda_task","ll_dispatch_pda_pick","ll_dispatch_pda_pick_order","ll_warehouse_transfer_inventory_allocation","ll_warehouse_transfer_order","ll_warehouse_transfer_pallet", - "ll_inbound_plan_line","ll_inbound_plan_pallet_allocation","ll_inbound_plan_carton","ll_order_detail","ll_inbound_order","ll_inbound_fba_appointment","ll_inbound_fba_appointment_item")) { + "ll_inbound_plan_line","ll_inbound_plan_pallet_allocation","ll_inbound_plan_carton","ll_order_detail","ll_inbound_order","ll_inbound_fba_appointment","ll_inbound_fba_appointment_item","ll_order_manual_node","ll_container_shipping_gate")) { f.db.execute("DROP TABLE IF EXISTS "+table);int start=schema.indexOf("CREATE TABLE "+table+" (");int end=schema.indexOf(") ENGINE=",start); if(start<0 || end<0)throw new IllegalStateException("正式初始化表缺失:"+table); // H2的JSON、collation及约束命名空间与MySQL不同;保留实际列和等价四来源排他约束。 diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchWholeSourcePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchWholeSourcePersistenceTest.java index 446d3e332..92e6eda23 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchWholeSourcePersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchWholeSourcePersistenceTest.java @@ -13,6 +13,67 @@ import static org.mockito.ArgumentMatchers.*; /** 候选投影在此受控,来源订单/仓/库存版本/实收组成与锁序均经实际MyBatis查询。 */ class DispatchWholeSourcePersistenceTest { + /** 同页普通来源批读不随托盘逐行重复,完整组与版本须和单组权威读取完全一致。 */ + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(booleans={true,false}) + void batchesPageSourceReadsWithoutChangingGroupsOrReusingThemAcrossRequests(boolean sameCustomer) throws Exception { + var fixture=new Sources(sameCustomer);var selections=fixture.selections(); + var first=fixture.rules.selectionGroup("9",selections.get(0)); + var second=fixture.rules.selectionGroup("9",selections.get(1)); + clearInvocations(fixture.sourceMapper,fixture.candidateMapper); + var rows=fixture.f.candidates.entrySet().stream().sorted(Map.Entry.comparingByKey()).map(Map.Entry::getValue).toList(); + var groups=fixture.f.tx.execute(status->fixture.rules.selectionGroups(rows)); + assertThat(groups.get("PLAN:1")).isEqualTo(first); + assertThat(groups.get("PLAN:2")).isEqualTo(second); + verify(fixture.sourceMapper,times(1)).physicalPlans("9",List.of(501L,502L)); + verify(fixture.sourceMapper,times(1)).physicalReturns("9",List.of(501L,502L)); + if(sameCustomer) { + verify(fixture.candidateMapper,times(1)).readSelected("101",List.of(1L,2L)); + verify(fixture.candidateMapper,never()).readSelected("101",List.of(1L)); + } + var customers=sameCustomer?Map.of("101",List.of(501L,502L)):Map.of("101",List.of(501L),"202",List.of(502L)); + customers.forEach((customer,pallets)->{ + verify(fixture.sourceMapper,times(1)).stocks(customer,pallets); + verify(fixture.sourceMapper,times(1)).palletReceipts(customer,pallets); + verify(fixture.sourceMapper,times(1)).palletReturnedReceipts(customer,pallets); + }); + verify(fixture.sourceMapper,never()).stock(anyString(),anyLong(),anyBoolean()); + verify(fixture.sourceMapper,never()).receipts(anyString(),anyLong()); + verify(fixture.sourceMapper,never()).returnedReceipts(anyString(),anyLong()); + verify(fixture.sourceMapper,never()).physicalPlans("9",List.of(501L)); + fixture.f.db.update("UPDATE ll_inbound_stock_receipt SET received_packages=4 WHERE id=1"); + var refreshed=fixture.f.tx.execute(status->fixture.rules.selectionGroups(rows)); + assertThat(refreshed.get("PLAN:1").groupVersion()).isNotEqualTo(first.groupVersion()); + verify(fixture.sourceMapper,times(2)).physicalPlans("9",List.of(501L,502L)); + } + /** 未实收计划没有实托,整页版本生成不能逐条发空库存和实收查询。 */ + @Test void plannedCandidatesNeedNoPhysicalFactQueries() throws Exception { + var fixture=new Sources(true); + var rows=fixture.f.candidates.values().stream().map(row->row.warehousePalletId(null).sourceStage("PLAN")).toList(); + var expected=new HashMap(); + for(var row:rows)expected.put("PLAN:"+row.planPalletId(),fixture.rules.wholeSourceVersion(row,"PLAN:"+row.planPalletId(),false)); + clearInvocations(fixture.sourceMapper,fixture.candidateMapper); + var groups=fixture.f.tx.execute(status->fixture.rules.selectionGroups(rows)); + for(var entry:expected.entrySet())assertThat(groups.get(entry.getKey()).sources().get(0).selection().sourceVersion()).isEqualTo(entry.getValue()); + verifyNoInteractions(fixture.sourceMapper,fixture.candidateMapper); + } + /** 同实托不同客户的收货依据必须隔离,真实退回实收仍参加摘要且保持原顺序。 */ + @Test void batchFactsMatchIndividualQueriesForSharedPalletCustomersAndReturns() throws Exception { + var fixture=new Sources(); + fixture.f.db.update("INSERT INTO ll_inventory_position VALUES(3,'202',3,501,'PALLET-501','9',2,0,0,0,1)"); + fixture.f.db.update("INSERT INTO ll_inbound_stock_receipt VALUES(3,'202','IN-OTHER',9002,3,2,501)"); + fixture.f.db.update("INSERT INTO ll_return_stock_receipt VALUES(5,'101',1,9001,1,2)"); + fixture.f.db.update("INSERT INTO ll_return_stock_receipt VALUES(6,'202',3,9002,3,1)"); + var repository=new DispatchSourceRepository(fixture.candidateMapper,mock(ReturnDispatchCandidateMapper.class),fixture.sourceMapper); + for(var customer:List.of("101","202")) { + var batch=repository.sourceFacts(customer,List.of(501L,502L)); + for(var pallet:List.of(501L,502L)) { + assertThat(batch.get(pallet).stock()).isEqualTo(repository.stock(customer,pallet,false)); + assertThat(batch.get(pallet).receipts()).isEqualTo(repository.receipts(customer,pallet)); + assertThat(batch.get(pallet).returnedReceipts()).isEqualTo(repository.returnedReceipts(customer,pallet)); + } + } + } /** 同仓多客户先锁齐来源订单,随后稳定锁计划和库存,不以MIN展示订单替代组成。 */ @Test void resolvesMultipleCustomersAndLocksAllSourceOrdersBeforeInventory() throws Exception { var fixture=new Sources();var selections=fixture.selections();clearInvocations(fixture.sourceMapper); @@ -43,10 +104,12 @@ class DispatchWholeSourcePersistenceTest { /** 实际来源表保持独立客户;只有复杂候选投影查询在本用例隔离。 */ private static final class Sources { final DispatchAggregatePersistenceTest.Fixture f=new DispatchAggregatePersistenceTest.Fixture(); - final DispatchSourceMapper sourceMapper;final DispatchService rules=new DispatchService(); + final DispatchSourceMapper sourceMapper;final DispatchCandidateMapper candidateMapper;final DispatchService rules=new DispatchService(); /** 构造真实订单组成和库存版本,不能由候选响应替代这些数据库事实。 */ - Sources() throws Exception { - f.source(1,"101",501,"ONT8",3);f.source(2,"202",502,"LAX9",5); + Sources() throws Exception { this(false); } + /** 同客户多托用于核对候选批读次数,跨客户夹具继续覆盖原隔离与锁序。 */ + Sources(boolean sameCustomer) throws Exception { + f.source(1,"101",501,"ONT8",3);f.source(2,sameCustomer?"101":"202",502,"LAX9",5); f.db.execute("ALTER TABLE ll_order ADD detained INT DEFAULT 0"); f.db.execute("ALTER TABLE ll_order ADD overseas_warehouse_id VARCHAR(64)"); f.db.execute("CREATE TABLE ll_inbound_plan(id BIGINT PRIMARY KEY,customer_id VARCHAR(64),overseas_warehouse_id VARCHAR(64),source_order_id BIGINT)"); @@ -60,9 +123,9 @@ class DispatchWholeSourcePersistenceTest { f.db.update("INSERT INTO ll_inventory_position VALUES(?,?,?,?,?,?,?,0,0,0,1)",id,row.customerId(),id,row.warehousePalletId(),row.palletNo(),"9",row.packageCount()); f.db.update("INSERT INTO ll_inbound_stock_receipt VALUES(?,?,?,?,?,?,?)",id,row.customerId(),row.inboundCode(),row.orderId(),id,row.packageCount(),row.warehousePalletId()); } - var plans=mock(DispatchCandidateMapper.class); + var plans=mock(DispatchCandidateMapper.class);candidateMapper=plans; when(plans.findCustomerScopesByPlanPalletIds(anyList())).thenAnswer(call->{List ids=call.getArgument(0);return List.of(f.candidates.get(ids.get(0)).customerId());}); - when(plans.readSelected(anyString(),anyList())).thenAnswer(call->{List ids=call.getArgument(1);return List.of(f.candidates.get(ids.get(0)));}); + when(plans.readSelected(anyString(),anyList())).thenAnswer(call->{List ids=call.getArgument(1);return ids.stream().map(f.candidates::get).toList();}); sourceMapper=spy(f.session.getMapper(DispatchSourceMapper.class)); var sources=new DispatchSourceRepository(plans,mock(ReturnDispatchCandidateMapper.class),sourceMapper); ReflectionTestUtils.setField(rules,"wholeSources",sources);ReflectionTestUtils.setField(rules,"wholeHashes",new DispatchCommandHasher(f.json)); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/outsourcedbusiness/OutsourcedBusinessContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/outsourcedbusiness/OutsourcedBusinessContractTest.java new file mode 100644 index 000000000..ddc0d4ca8 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/outsourcedbusiness/OutsourcedBusinessContractTest.java @@ -0,0 +1,59 @@ +package com.baiyue.lastleg.fleet.outsourcedbusiness; + +import com.baiyue.lastleg.fleet.outsourcedbusiness.model.OutsourcedBusinessModels; +import com.baiyue.lastleg.fleet.outsourcedbusiness.controller.OutsourcedBusinessController; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.core.JsonProcessingException; +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; + +/** HTTP公开JSON精简及拒绝行为,防止仅页面隐藏不支持字段。 */ +class OutsourcedBusinessContractTest { + /** 即使全局允许未知字段,本域仍明确拒绝油耗、里程及客户端归属。 */ + @Test void unsupportedOperationsAreRejectedEvenWithLenientGlobalJackson() throws Exception { + var json=new ObjectMapper().findAndRegisterModules().disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); + String body="{\"recordVersion\":\"0\",\"idempotencyKey\":\"test-original-key\",\"operations\":{\"money\":null,\"arrivals\":[]}}"; + assertNotNull(json.readValue(body,OutsourcedBusinessModels.SaveOperations.class)); + for(String field:List.of("actualDistanceMiles","fuelConsumptionLitresPer100Km","actualFuelLitres","ownership","scope")) { + String extra=body.replace("\"arrivals\":[]","\"arrivals\":[],\""+field+"\":null"); + assertThrows(JsonProcessingException.class,()->json.readValue(extra,OutsourcedBusinessModels.SaveOperations.class),field); + } + assertThrows(JsonProcessingException.class,()->json.readValue(body.replace("\"recordVersion\":\"0\"","\"recordVersion\":\"0\",\"scope\":\"OWNED\""),OutsourcedBusinessModels.SaveOperations.class)); + assertThrows(JsonProcessingException.class,()->json.readValue("{\"recordVersion\":\"0\",\"idempotencyKey\":\"bind-original\",\"fileId\":\"9\",\"ownership\":\"OWNED\"}",OutsourcedBusinessModels.BindPhoto.class)); + } + /** 外配输出没有油耗字段和摘要,自有原JSON字段继续存在。 */ + @Test void outsourcedSerializationIsLeanAndOwnedPublicShapeRemains() throws Exception { + var json=new ObjectMapper().findAndRegisterModules(); + var output=json.valueToTree(new OutsourcedBusinessModels.Operations(new OwnedBusinessModels.Money("USD","0"),List.of())); + assertEquals(2,output.size());assertTrue(output.has("money"));assertTrue(output.has("arrivals")); + assertFalse(output.has("actualDistanceMiles"));assertFalse(output.has("fuelConsumptionLitresPer100Km")); + assertEquals(1,json.valueToTree(new OutsourcedBusinessModels.PhotoSummary(5)).size()); + var external=json.valueToTree(new OutsourcedBusinessModels.Vehicle(null,"TEST-EXT","DISPATCHED",null)); + assertTrue(external.get("vehicleId").isNull());assertTrue(external.get("carrierName").isNull());assertEquals(4,external.size()); + var owned=json.valueToTree(new OwnedBusinessModels.Operations(null,null,null,List.of())); + assertTrue(owned.has("actualDistanceMiles"));assertTrue(owned.has("fuelConsumptionLitresPer100Km")); + assertEquals(3,json.valueToTree(new OwnedBusinessModels.Vehicle("7","TEST-OWN","OWNED")).size()); + } + /** 油耗框不是外配合法URL枚举,不能先上传再在页面隐藏。 */ + @Test void onlyFiveLoadingPhotoSlotsCanDeserialize() throws Exception { + var json=new ObjectMapper();assertEquals(5,OutsourcedBusinessModels.PhotoSlot.values().length); + for(var slot:OutsourcedBusinessModels.PhotoSlot.values())assertEquals(slot,json.readValue("\""+slot.name()+"\"",OutsourcedBusinessModels.PhotoSlot.class)); + assertThrows(JsonProcessingException.class,()->json.readValue("\"FUEL_START\"",OutsourcedBusinessModels.PhotoSlot.class)); + assertThrows(JsonProcessingException.class,()->json.readValue("\"FUEL_END\"",OutsourcedBusinessModels.PhotoSlot.class)); + } + /** 八类HTTP操作各用外配真实权限,不将外配权限伪装为自有。 */ + @Test void controllerAlwaysRequiresTheActualOutsourcedPermission() { + int count=0; + for(var method:OutsourcedBusinessController.class.getDeclaredMethods()) { + var authorization=method.getAnnotation(PreAuthorize.class);if(authorization==null)continue;count++; + String action=switch(method.getName()){case "save"->"edit";case "upload","bind"->"upload";default->"list";}; + assertEquals("hasAuthority('platform:fleet:outsourced-business:"+action+"')",authorization.value()); + assertTrue(Arrays.stream(method.getParameterTypes()).noneMatch(p->p.getSimpleName().equals("OwnedBusinessScope"))); + } + assertEquals(8,count); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessScopePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessScopePersistenceTest.java new file mode 100644 index 000000000..b2e75e9ec --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessScopePersistenceTest.java @@ -0,0 +1,265 @@ +package com.baiyue.lastleg.fleet.ownedbusiness; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.lastleg.dispatch.port.DispatchDetailReader; +import com.baiyue.lastleg.dispatch.port.DispatchContracts; +import com.baiyue.lastleg.dispatch.port.DispatchViews; +import com.baiyue.lastleg.dispatch.port.DispatchTypes; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessScope; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows; +import com.baiyue.lastleg.fleet.ownedbusiness.gateway.OwnedBusinessFileGateway; +import com.baiyue.lastleg.fleet.ownedbusiness.service.*; +import com.baiyue.lastleg.fleet.outsourcedbusiness.model.OutsourcedBusinessModels; +import com.baiyue.lastleg.fleet.outsourcedbusiness.service.OutsourcedBusinessService; +import org.junit.jupiter.api.*; +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource; +import org.springframework.transaction.interceptor.TransactionInterceptor; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.server.ResponseStatusException; +import java.util.*; +import java.util.concurrent.atomic.AtomicLong; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** 复用既有四表测试装配,实际Mapper、事务、权限与原键一并验收。 */ +class OwnedBusinessScopePersistenceTest { + final OwnedBusinessPersistenceTest baseline=new OwnedBusinessPersistenceTest(); + OwnedBusinessQueryService queries; + OwnedBusinessRecordService records; + OwnedBusinessPhotoService photos; + OwnedBusinessFileGateway files; + OutsourcedBusinessService outsourced; + final Map uploaded=new HashMap<>(); + final byte[] png={(byte)137,80,78,71,13,10,26,10,0}; + /** 只调用原测试的建库装配,不继承或重复运行其@Test。 */ + @BeforeEach void setup() throws Exception { + baseline.setup();var reader=mock(DispatchDetailReader.class); + when(reader.detail(any(),anyString())).thenAnswer(call->{ + var s=baseline.repository.source(call.getArgument(1));var r=baseline.codec.read(s.getResourcesJson(),DispatchContracts.Resources.class); + return new DispatchViews.Detail(s.getDispatchNo(),DispatchTypes.Status.ACTIVE,DispatchTypes.Stage.valueOf(s.getStage()),s.getRecordVersion(), + DispatchTypes.ReviewState.APPROVED,null,null,true,Map.of(),new DispatchViews.VehicleInfo(r.vehicle(),r.driver()),null,null,List.of()); + }); + queries=tx(new OwnedBusinessQueryService(baseline.repository,baseline.projection,baseline.codec,reader)); + var commands=tx(new OwnedBusinessCommandService(baseline.repository,baseline.codec)); + records=tx(new OwnedBusinessRecordService(baseline.repository,queries,baseline.projection,commands,baseline.codec)); + files=mock(OwnedBusinessFileGateway.class);var sequence=new AtomicLong(900); + when(files.upload(anyLong(),any(),anyString(),any())).thenAnswer(call->{ + long id=sequence.incrementAndGet();var evidence=new OwnedBusinessFileGateway.Evidence(id,"TEST-"+id+".png","image/png",png.length,"2026-10-20T08:00:00Z");uploaded.put(id,evidence);return evidence; + }); + when(files.metadata(anyLong(),any(),anyLong())).thenAnswer(call->uploaded.get(call.getArgument(2))); + when(files.content(anyLong(),any(),anyLong())).thenReturn(png); + photos=tx(new OwnedBusinessPhotoService(queries,records,baseline.repository,commands,baseline.projection,baseline.codec,files)); + outsourced=new OutsourcedBusinessService(queries,records,photos,baseline.projection,baseline.codec); + insert(2,"MASTER","AFTER_LOADING","TEST原承运商");insert(3,"EXTERNAL","DELIVERING",null);insert(4,"MASTER","DELIVERED","TEST另一承运商"); + } + /** 数据库仅在本机隔离H2,用后关闭。 */ + @AfterEach void close(){baseline.closeDatabase();} + /** 所有被测写路径都走真实注解事务,不以内存集合替代Mapper。 */ + @SuppressWarnings("unchecked") private T tx(T target) { + var proxy=new ProxyFactory(target);proxy.setProxyTargetClass(true); + proxy.addAdvice(new TransactionInterceptor(baseline.manager,new AnnotationTransactionAttributeSource()));return (T)proxy.getProxy(); + } + /** 两类外配身份固定原快照,实际托盘号和两个客户仍逐项保留。 */ + private void insert(long id,String mode,String stage,String carrier) { + var original=OwnedBusinessFixtures.source(); + var resources=baseline.codec.read(original.getResourcesJson(),DispatchContracts.Resources.class); + var vehicle=new DispatchContracts.VehicleSnapshot(DispatchTypes.ResourceMode.valueOf(mode),"MASTER".equals(mode)?7L:null,"TEST-OUT-"+id,carrier,"IN_SERVICE",resources.vehicle().capacity(),List.of()); + String snapshot=baseline.codec.write(new DispatchContracts.Resources(vehicle,resources.driver())); + String load=original.getLoadingConfirmationJson().replace("\"warehousePalletId\":31","\"warehousePalletId\":"+(31+id)); + baseline.jdbc.update(""" + INSERT INTO ll_dispatch_order SELECT ?,?,trip_id,status,?,record_version,planned_departure_at,loading_completed_at,departed_at,delivery_completed_at, + loading_completion_hash,?,?,?,overseas_warehouse_id,overseas_warehouse_code,overseas_warehouse_name FROM ll_dispatch_order WHERE id=1 + """,id,"DS-OUT-"+id,stage,load,snapshot,"TEST-OUT-"+id); + baseline.jdbc.update(""" + INSERT INTO ll_dispatch_item SELECT id+?, ?,customer_id,customer_name,source_key,order_no,inbound_code,?, ?,destination_type, + destination_warehouse_code,destination_warehouse_name,fba_warehouse_code,delivery_address,postal_code,package_count FROM ll_dispatch_item WHERE dispatch_order_id=1 + """,id*100,id,"PAL-"+(31+id),31+id); + var ownership=new OwnedBusinessRows.Ownership();ownership.setDispatchId(id);ownership.setVehicleId(vehicle.vehicleId()); + ownership.setResourceMode(mode);ownership.setVehicleOwnership("DISPATCHED");ownership.setVehicleRecordVersion(vehicle.vehicleId()==null?null:3L); + ownership.setLoadingCompletionHash(original.getLoadingCompletionHash());ownership.setLoadingCompletedAt(original.getLoadingCompletedAt());ownership.setOwnershipCapturedAt(original.getLoadingCompletedAt());baseline.repository.capture(ownership); + baseline.jdbc.update("INSERT INTO ll_dispatch_history VALUES(?,?,8,'LOADING_COMPLETE','TEST-picker',NULL,'{}',?)",100+id,id,original.getLoadingCompletedAt()); + if(!"AFTER_LOADING".equals(stage))baseline.jdbc.update("UPDATE ll_dispatch_order SET departed_at=? WHERE id=?",original.getLoadingCompletedAt().plusHours(1),id); + if("DELIVERED".equals(stage))baseline.jdbc.update("UPDATE ll_dispatch_order SET delivery_completed_at=? WHERE id=?",original.getLoadingCompletedAt().plusHours(2),id); + } + /** 外配真实三权限,与自有集合完全不相交。 */ + private OwnedBusinessModels.Actor actor(){return new OwnedBusinessModels.Actor("outsourced-operator","TEST外配操作员",Set.of(OutsourcedBusinessModels.LIST,OutsourcedBusinessModels.EDIT,OutsourcedBusinessModels.UPLOAD));} + /** 请求只包含金额和本车目的地到达,版本与原键逐次显式提供。 */ + private OutsourcedBusinessModels.SaveOperations request(String version,String key,OwnedBusinessModels.Money money,List arrivals) { + return new OutsourcedBusinessModels.SaveOperations(version,key,new OutsourcedBusinessModels.Operations(money,arrivals),"本机TEST承运登记"); + } + /** 真实SQL统计跨页且按归属、阶段、日期、车牌和目的仓共同过滤。 */ + @Test void fullSummaryAndFilterUseOneFrozenScope() { + var query=new OwnedBusinessModels.Query(1,1,null,null,null,null,"9");var result=outsourced.list(actor(),query); + assertEquals(1,result.records().size());assertEquals(3,result.total());assertEquals(new OwnedBusinessModels.Summary(3,1,1,1),result.summary()); + assertEquals(1,queries.list(OwnedBusinessFixtures.actor(),query).total()); + assertEquals(1,outsourced.list(actor(),new OwnedBusinessModels.Query(1,20,null,null,"TEST-OUT-3",null,"9")).total()); + assertEquals(3,outsourced.list(actor(),new OwnedBusinessModels.Query(1,20,null,null,"ONT8",null,"9")).total()); + assertEquals(0,outsourced.list(actor(),new OwnedBusinessModels.Query(1,20,null,null,null,null,"other")).total()); + assertEquals(0,outsourced.list(actor(),new OwnedBusinessModels.Query(1,20,null,"2026-10-20T08:00:00Z",null,null,"9")).total()); + for(var stage:OwnedBusinessModels.Stage.values())assertEquals(1,outsourced.list(actor(),new OwnedBusinessModels.Query(1,20,"2026-10-20T08:00:00Z","2026-10-21T08:00:00Z",null,stage,"9")).total()); + } + /** 外配档案和临时车辆分别保持真实ID/空ID及原承运商,GET不建记录。 */ + @Test void masterAndExternalReadOriginalSnapshotWithoutBackfill() { + var master=outsourced.detail(actor(),"DS-OUT-2");var external=outsourced.detail(actor(),"DS-OUT-3"); + assertEquals("7",master.row().vehicle().vehicleId());assertEquals("TEST原承运商",master.row().vehicle().carrierName()); + assertNull(external.row().vehicle().vehicleId());assertNull(external.row().vehicle().carrierName()); + assertEquals("DISPATCHED",external.row().vehicle().ownership());assertEquals("0",master.row().recordVersion()); + assertEquals(2,master.cargo().size());assertEquals(1,master.row().actualPalletCount());assertTrue(master.row().actions().edit().allowed()); + assertEquals(0,baseline.jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_owned_business_record",Integer.class)); + baseline.jdbc.update("DELETE FROM ll_fleet_owned_business_ownership WHERE dispatch_id=2"); + assertThrows(BizException.class,()->outsourced.detail(actor(),"DS-OUT-2"));assertEquals(2,outsourced.list(actor(),new OwnedBusinessModels.Query(1,20,null,null,null,null,null)).total());assertNull(baseline.repository.ownership(2)); + } + /** 实际服务权限与原归属双向隔离,不允许靠共享四表越权。 */ + @Test void bothPermissionAndDispatchScopeAreRequiredOnReadWriteAndCommand() { + assertThrows(ResponseStatusException.class,()->outsourced.detail(OwnedBusinessFixtures.actor(),"DS-OUT-2")); + assertThrows(ResponseStatusException.class,()->queries.detail(actor(),"DS-OWN-ONE")); + assertThrows(BizException.class,()->outsourced.detail(actor(),"DS-OWN-ONE")); + assertThrows(BizException.class,()->queries.detail(OwnedBusinessFixtures.actor(),"DS-OUT-2")); + assertThrows(BizException.class,()->outsourced.save(actor(),"DS-OWN-ONE",request("0","foreign-scope-save",null,List.of()))); + assertThrows(BizException.class,()->outsourced.command(actor(),"DS-OWN-ONE","foreign-scope-key")); + assertThrows(BizException.class,()->photos.command(OwnedBusinessFixtures.actor(),"DS-OUT-2","foreign-scope-key")); + assertThrows(ResponseStatusException.class,()->outsourced.save(OwnedBusinessFixtures.actor(),"DS-OUT-2",request("0","wrong-permission-write",null,List.of()))); + var all=new HashSet<>(actor().permissions());all.addAll(OwnedBusinessFixtures.actor().permissions()); + var both=new OwnedBusinessModels.Actor("both-scopes","TEST双权限操作员",all); + assertThrows(BizException.class,()->outsourced.detail(both,"DS-OWN-ONE"));assertThrows(BizException.class,()->queries.detail(both,"DS-OUT-2")); + assertThrows(BizException.class,()->records.save(both,"DS-OUT-2",new OwnedBusinessModels.SaveOperations("0","wrong-owned-write",new OwnedBusinessModels.Operations(null,null,null,List.of()),null))); + assertEquals(0,baseline.jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_owned_business_command",Integer.class)); + assertEquals(0,baseline.jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_owned_business_record",Integer.class)); + } + /** 多客户同一物理托盘只除一次,逐目的地登记、零值与NULL清空都走真实CAS。 */ + @Test void samePhysicalPalletMultipleDestinationsAndMoneyClearUseRealCas() { + baseline.jdbc.update("UPDATE ll_dispatch_item SET destination_warehouse_code='LAX9' WHERE id=212"); + var arrivals=List.of(new OwnedBusinessModels.Arrival("FBA:ONT8","2026-10-20T08:10:00Z"),new OwnedBusinessModels.Arrival("FBA:LAX9","2026-10-20T08:20:00Z")); + var body=request("0","carrier-amount-original",new OwnedBusinessModels.Money("CNY","60"),arrivals); + var first=outsourced.save(actor(),"DS-OUT-2",body); + assertEquals("60.00",first.row().unitPrice());assertEquals(2,first.row().destinations().size());assertTrue(first.row().destinations().stream().allMatch(d->d.actualArrivedAt()!=null)); + assertEquals(first,outsourced.save(actor(),"DS-OUT-2",body)); + assertEquals(first,outsourced.command(actor(),"DS-OUT-2",body.idempotencyKey()).result()); + var stale=assertThrows(BizException.class,()->outsourced.save(actor(),"DS-OUT-2",request("0","carrier-amount-stale",null,List.of())));assertEquals(ResultCode.CONFLICT,stale.getResultCode()); + assertThrows(BizException.class,()->outsourced.save(actor(),"DS-OUT-2",request("0",body.idempotencyKey(),new OwnedBusinessModels.Money("USD","0"),arrivals))); + var zero=outsourced.save(actor(),"DS-OUT-2",request("1","carrier-amount-zero",new OwnedBusinessModels.Money("USD","0"),arrivals)); + assertEquals("0.00",zero.row().unitPrice());assertEquals("USD",zero.row().operations().money().currency()); + var cleared=outsourced.save(actor(),"DS-OUT-2",request("2","carrier-amount-clear",null,List.of())); + assertNull(cleared.row().operations().money());assertNull(cleared.row().unitPrice());assertTrue(cleared.row().destinations().stream().allMatch(d->d.actualArrivedAt()==null)); + assertNull(baseline.repository.record(2).getCurrency());assertNull(baseline.repository.record(2).getActualDistanceMiles());assertNull(baseline.repository.record(2).getFuelConsumptionLitresPer100Km()); + assertEquals(8L,baseline.repository.source("DS-OUT-2").getRecordVersion()); + } + /** 油耗及里程在共享服务再次拒绝,不能绕过外配DTO直接调用内部入口。 */ + @Test void unsupportedFuelAndForeignDestinationAreRejectedBeforeAnyWrite() { + var body=new OwnedBusinessModels.SaveOperations("0","illegal-fuel-write",new OwnedBusinessModels.Operations(null,"1",null,List.of()),null); + assertThrows(BizException.class,()->records.save(actor(),"DS-OUT-2",body,OwnedBusinessScope.OUTSOURCED)); + var fuel=new OwnedBusinessModels.SaveOperations("0","illegal-fuel-rate",new OwnedBusinessModels.Operations(null,null,"1",List.of()),null); + assertThrows(BizException.class,()->records.save(actor(),"DS-OUT-2",fuel,OwnedBusinessScope.OUTSOURCED)); + assertThrows(BizException.class,()->photos.upload(actor(),"DS-OUT-2",OwnedBusinessModels.PhotoSlot.FUEL_START,"illegal-fuel-photo",new MockMultipartFile("file",png),OwnedBusinessScope.OUTSOURCED)); + assertThrows(BizException.class,()->outsourced.save(actor(),"DS-OUT-2",request("0","illegal-destination",null,List.of(new OwnedBusinessModels.Arrival("FBA:OTHER",null))))); + assertThrows(BizException.class,()->outsourced.save(actor(),"DS-OUT-2",request("0","duplicate-destination",null,List.of(new OwnedBusinessModels.Arrival("FBA:ONT8",null),new OwnedBusinessModels.Arrival("FBA:ONT8",null))))); + assertEquals(0,baseline.jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_owned_business_record",Integer.class));verifyNoInteractions(files); + } + /** 五框上传/绑定/原键恢复及历史读取保持同车原引用,异账号不能复用。 */ + @Test void fivePhotosReplayHistoryAndActorIsolationDoNotChangeDispatch() { + String version="0",firstFile=null;int index=0; + for(var slot:OutsourcedBusinessModels.PhotoSlot.values()) { + var file=new MockMultipartFile("file","TEST.png","image/png",png);String uploadKey="out-upload-"+slot; + var photo=outsourced.upload(actor(),"DS-OUT-2",slot,uploadKey,file);assertNull(photo.savedAt()); + assertEquals(photo,outsourced.upload(actor(),"DS-OUT-2",slot,uploadKey,file)); + assertEquals(version,outsourced.detail(actor(),"DS-OUT-2").row().recordVersion()); + var binding=new OutsourcedBusinessModels.BindPhoto(version,"out-bind-"+slot,photo.fileId()); + var bound=outsourced.bind(actor(),"DS-OUT-2",slot,binding);assertEquals(bound,outsourced.bind(actor(),"DS-OUT-2",slot,binding));version=bound.row().recordVersion(); + if(index++==0)firstFile=photo.fileId();assertArrayEquals(png,outsourced.content(actor(),"DS-OUT-2",slot,Long.parseLong(photo.fileId()),null).content()); + } + assertEquals(5,outsourced.detail(actor(),"DS-OUT-2").row().photoSummary().loadingCount()); + var replacement=outsourced.upload(actor(),"DS-OUT-2",OutsourcedBusinessModels.PhotoSlot.LOADING_SCENE,"out-replacement-upload",new MockMultipartFile("file","TEST.png","image/png",png)); + outsourced.bind(actor(),"DS-OUT-2",OutsourcedBusinessModels.PhotoSlot.LOADING_SCENE,new OutsourcedBusinessModels.BindPhoto(version,"out-replacement-bind",replacement.fileId())); + final long oldId=Long.parseLong(firstFile); + assertThrows(BizException.class,()->outsourced.content(actor(),"DS-OUT-2",OutsourcedBusinessModels.PhotoSlot.LOADING_SCENE,oldId,null)); + assertArrayEquals(png,outsourced.content(actor(),"DS-OUT-2",OutsourcedBusinessModels.PhotoSlot.LOADING_SCENE,oldId,1L).content()); + var history=outsourced.history(actor(),"DS-OUT-2",1,200);assertEquals(7,history.total()); + assertTrue(history.records().stream().anyMatch(h->h.eventSource().equals("DISPATCH") && h.recordVersion()==null && h.dispatchRecordVersion().equals("8") && h.operations()==null)); + assertTrue(history.records().stream().filter(h->h.eventSource().equals("OPERATIONS")).allMatch(h->h.dispatchRecordVersion()==null && h.reason().startsWith("保存"))); + var other=new OwnedBusinessModels.Actor("other","TEST另一外配账号",actor().permissions()); + assertThrows(ResponseStatusException.class,()->outsourced.command(other,"DS-OUT-2","out-upload-LOADING_SCENE")); + assertThrows(ResponseStatusException.class,()->outsourced.upload(other,"DS-OUT-2",OutsourcedBusinessModels.PhotoSlot.LOADING_SCENE,"out-upload-LOADING_SCENE",new MockMultipartFile("file","TEST.png","image/png",png))); + verify(files,times(6)).upload(anyLong(),any(),anyString(),any());assertEquals(8L,baseline.repository.source("DS-OUT-2").getRecordVersion()); + } + /** File结果未知只保存原意图,任何同键重放不得再次写File。 */ + @Test void unknownUploadIsRecoverableButNeverAutomaticallyRetried() { + when(files.upload(anyLong(),any(),anyString(),any())).thenThrow(new IllegalStateException("本机模拟连接中断")); + var file=new MockMultipartFile("file","TEST.png","image/png",png); + assertThrows(BizException.class,()->outsourced.upload(actor(),"DS-OUT-2",OutsourcedBusinessModels.PhotoSlot.LOADING_LIST,"out-upload-unknown",file)); + assertThrows(BizException.class,()->outsourced.upload(actor(),"DS-OUT-2",OutsourcedBusinessModels.PhotoSlot.LOADING_LIST,"out-upload-unknown",file)); + var command=outsourced.command(actor(),"DS-OUT-2","out-upload-unknown");assertEquals("UNKNOWN_RESULT",command.state());assertFalse(command.retryAllowed());assertNull(command.result()); + assertEquals("OUTSOURCED_UPLOAD_UNKNOWN_RESULT",command.errorCode());verify(files,times(1)).upload(anyLong(),any(),anyString(),any()); + } + /** 原装车ID、模式、hash、时间任何一项不一致都不能进入外配详情。 */ + @Test void frozenIdentityHashAndTimeCannotBeSubstituted() { + baseline.jdbc.update("UPDATE ll_fleet_owned_business_ownership SET vehicle_id=999 WHERE dispatch_id=2"); + assertThrows(BizException.class,()->outsourced.detail(actor(),"DS-OUT-2")); + baseline.jdbc.update("UPDATE ll_fleet_owned_business_ownership SET vehicle_id=7,loading_completion_hash='different' WHERE dispatch_id=2"); + assertThrows(BizException.class,()->outsourced.detail(actor(),"DS-OUT-2")); + baseline.jdbc.update("UPDATE ll_fleet_owned_business_ownership SET loading_completion_hash='actual-load',loading_completed_at=? WHERE dispatch_id=2",OwnedBusinessFixtures.source().getLoadingCompletedAt().plusSeconds(1)); + assertThrows(BizException.class,()->outsourced.detail(actor(),"DS-OUT-2")); + String external=baseline.repository.source("DS-OUT-3").getResourcesJson().replace("\"vehicleId\":null","\"vehicleId\":99"); + baseline.jdbc.update("UPDATE ll_dispatch_order SET resources_json=? WHERE id=3",external); + assertThrows(BizException.class,()->outsourced.detail(actor(),"DS-OUT-3")); + assertEquals(0,baseline.jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_owned_business_record",Integer.class)); + } + /** 承运金额按两实托HALF_UP两位,零实托不能伪造单价或使用来源条数替代。 */ + @Test void roundingUsesActualDistinctPalletsAndZeroDenominatorStaysNull() { + String load=baseline.repository.source("DS-OUT-2").getLoadingConfirmationJson().replace("\"pallets\":[", "\"pallets\":[{\"warehousePalletId\":99,\"palletSpaces\":5,\"weightKg\":10,\"volumeCbm\":1,\"deliveryIndex\":1},"); + baseline.jdbc.update("UPDATE ll_dispatch_order SET loading_confirmation_json=? WHERE id=2",load); + baseline.jdbc.update("INSERT INTO ll_dispatch_item SELECT id+1000,dispatch_order_id,customer_id,customer_name,CONCAT(source_key,'-2'),order_no,inbound_code,'PAL-99',99,destination_type,destination_warehouse_code,destination_warehouse_name,fba_warehouse_code,delivery_address,postal_code,package_count FROM ll_dispatch_item WHERE id=211"); + var saved=outsourced.save(actor(),"DS-OUT-2",request("0","out-half-up-rounding",new OwnedBusinessModels.Money("USD","10.01"),List.of())); + assertEquals(2,saved.row().actualPalletCount());assertEquals(3,saved.cargo().size());assertEquals("5.01",saved.row().unitPrice()); + var confirmation=baseline.codec.read(load,DispatchContracts.ConfirmedLoad.class); + baseline.jdbc.update("UPDATE ll_dispatch_order SET loading_confirmation_json=? WHERE id=2",baseline.codec.write(new DispatchContracts.ConfirmedLoad(confirmation.manifestHash(),List.of(),confirmation.operatorId(),confirmation.confirmedAt()))); + var empty=outsourced.detail(actor(),"DS-OUT-2");assertEquals(0,empty.row().actualPalletCount());assertNull(empty.row().unitPrice());assertFalse(empty.row().sourceIssues().isEmpty()); + } + /** 元数据暂不可读时原绑定体和fileId不丢失,恢复后不重复上传。 */ + @Test void failedBindRetainsOriginalFileAndKeyForExplicitRecovery() { + var slot=OutsourcedBusinessModels.PhotoSlot.LOADING_LIST; + var uploadedPhoto=outsourced.upload(actor(),"DS-OUT-2",slot,"out-bind-failure-upload",new MockMultipartFile("file","TEST.png","image/png",png)); + long fileId=Long.parseLong(uploadedPhoto.fileId()); + when(files.metadata(2,OwnedBusinessModels.PhotoSlot.LOADING_LIST,fileId)).thenThrow(new BizException(ResultCode.CONFLICT,"本机模拟元数据暂不可读")).thenReturn(uploaded.get(fileId)); + var body=new OutsourcedBusinessModels.BindPhoto("0","out-bind-failure-key",uploadedPhoto.fileId()); + assertThrows(BizException.class,()->outsourced.bind(actor(),"DS-OUT-2",slot,body)); + assertEquals("0",outsourced.detail(actor(),"DS-OUT-2").row().recordVersion()); + var bound=outsourced.bind(actor(),"DS-OUT-2",slot,body);assertEquals(uploadedPhoto.fileId(),bound.photos().get(0).fileId()); + verify(files,times(1)).upload(anyLong(),any(),anyString(),any()); + } + /** 完整摘要严格匹配,同前缀末位、大小写和尾空格均不能误命中。 */ + @Test void queryHashComparesCompleteValueRatherThanDefaultBinaryWidth() { + String prefix="a".repeat(63),exact=prefix+"1"; + baseline.jdbc.update("UPDATE ll_dispatch_order SET loading_completion_hash=? WHERE id=1",exact); + baseline.jdbc.update("UPDATE ll_fleet_owned_business_ownership SET loading_completion_hash=? WHERE dispatch_id=1",exact); + var query=new OwnedBusinessModels.Query(1,20,null,null,null,null,null); + assertEquals(1,queries.list(OwnedBusinessFixtures.actor(),query).total()); + for(String different:List.of(prefix+"2","A"+exact.substring(1),prefix+" ")) { + baseline.jdbc.update("UPDATE ll_fleet_owned_business_ownership SET loading_completion_hash=? WHERE dispatch_id=1",different); + var result=queries.list(OwnedBusinessFixtures.actor(),query);assertEquals(0,result.total());assertTrue(result.records().isEmpty()); + } + baseline.jdbc.update("UPDATE ll_fleet_owned_business_ownership SET loading_completion_hash=? WHERE dispatch_id=1",exact); + assertEquals(1,queries.list(OwnedBusinessFixtures.actor(),query).total()); + } + /** 历史投影统一字符比较规则,完整保留中文、原尾空格、动作和原操作账号。 */ + @Test void historyCharacterProjectionPreservesOriginalTextAndSnapshot() { + // 原测试装配的原生历史采用较短替身列,这里只在隔离H2对齐实际原表上限。 + baseline.jdbc.execute("ALTER TABLE ll_dispatch_history ALTER COLUMN operator_id VARCHAR(128)"); + baseline.jdbc.execute("ALTER TABLE ll_dispatch_history ALTER COLUMN reason VARCHAR(2000)"); + baseline.jdbc.execute("ALTER TABLE ll_dispatch_history ALTER COLUMN action VARCHAR(40)"); + String nativeReason="原排车完整中文历史🌏"+"中".repeat(1200)+" ",operationReason="整车承运登记完整中文说明🌏 ",operator="o".repeat(127)+"X",nativeAction="A".repeat(39)+"X"; + baseline.jdbc.update("UPDATE ll_dispatch_history SET reason=?,operator_id=?,action=? WHERE dispatch_id=2",nativeReason,operator,nativeAction); + var saved=outsourced.save(actor(),"DS-OUT-2",new OutsourcedBusinessModels.SaveOperations("0","history-character-original", + new OutsourcedBusinessModels.Operations(new OwnedBusinessModels.Money("CNY","30"),List.of()),operationReason)); + var history=outsourced.history(actor(),"DS-OUT-2",1,20);assertEquals(2,history.total()); + var original=history.records().stream().filter(h->h.eventSource().equals("DISPATCH")).findFirst().orElseThrow(); + var operations=history.records().stream().filter(h->h.eventSource().equals("OPERATIONS")).findFirst().orElseThrow(); + assertEquals(nativeReason,original.reason());assertEquals(nativeAction,original.action()); + assertEquals(operationReason,operations.reason());assertEquals("SAVE_OPERATIONS",operations.action()); + assertEquals(saved.row().operations(),operations.operations());assertEquals("TEST外配操作员",operations.operatorName()); + var raw=baseline.repository.history(2,0,20).stream().filter(h->h.getEventSource().equals("DISPATCH")).findFirst().orElseThrow(); + assertEquals(operator,raw.getOperatorId()); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripAuthorizationTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripAuthorizationTest.java index 910d83b73..e43e900e1 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripAuthorizationTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripAuthorizationTest.java @@ -12,20 +12,23 @@ import static org.assertj.core.api.Assertions.*; import static org.mockito.Mockito.*; class FleetTripAuthorizationTest { - @Test void calendarRequiresTripReadPermissionThroughMethodSecurity() { + /** 日历按现行排车只读权限放行,预约和旧车次权限不能替代。 */ + @Test void calendarRequiresDispatchReadPermissionThroughMethodSecurity() { try (var context = new AnnotationConfigApplicationContext(Config.class)) { var controller = context.getBean(PlatformFleetTripController.class); var calendar = context.getBean(FleetTripCalendarService.class); var from = java.time.Instant.parse("2026-10-01T00:00:00Z"); var to = from.plusSeconds(86400); var user = new com.baiyue.common.security.AuthenticatedUser(1L, 0L, 0L, 1L, - "platform", "PLATFORM", "WEB", java.util.Set.of("platform:fleet:trip:list")); - SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken( - user, "unused", List.of(new SimpleGrantedAuthority("platform:appointment:list")))); - assertThatThrownBy(() -> controller.calendar(user, from, to)).isInstanceOf(AccessDeniedException.class); + "platform", "PLATFORM", "WEB", java.util.Set.of("platform:overseas-warehouse:dispatch:list")); + for (var permission : List.of("platform:appointment:list", "platform:fleet:trip:list")) { + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken( + user, "unused", List.of(new SimpleGrantedAuthority(permission)))); + assertThatThrownBy(() -> controller.calendar(user, from, to)).isInstanceOf(AccessDeniedException.class); + } verifyNoInteractions(calendar); SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken( - user, "unused", List.of(new SimpleGrantedAuthority("platform:fleet:trip:list")))); + user, "unused", List.of(new SimpleGrantedAuthority("platform:overseas-warehouse:dispatch:list")))); controller.calendar(user, from, to); verify(calendar).calendar(from, to); } finally { SecurityContextHolder.clearContext(); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchPersistenceTest.java index d22b4b544..0d500f4a7 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchPersistenceTest.java @@ -26,6 +26,23 @@ import static org.mockito.Mockito.*; /** 真实MyBatis、批次行锁、逐客户分配与回滚;原批次和来源读取边界由当前DB事实供给。 */ class AppointmentWorkDispatchPersistenceTest { + /** 自动预留托盘与已实收托盘分开取数,防止有预约的在途货物被零实收数量挡住。 */ + @Test void reservedPalletKeepsPlannedQuantityInBatchReferencesUntilReceivingStarts() { + jdbc.update("UPDATE ll_inbound_plan_pallet SET assignment_status='RESERVED',actual_package_count=0 WHERE id=111"); + jdbc.update("UPDATE ll_inbound_plan_pallet_allocation SET counted_package_count=0 WHERE pallet_id=111"); + var physical = List.of(new AppointmentWorkDispatchPort.CandidatePhysical("101",111L)); + assertEquals("PLAN", mapper.candidateSources(physical).get(0).sourceStage()); + assertEquals("PLAN", mapper.candidates(1L).stream().filter(row -> row.planPalletId()==111).findFirst().orElseThrow().sourceStage()); + var planned = candidates.batchReferences(physical).get(physical.get(0)); + assertTrue(planned.blockReasons().isEmpty()); + assertEquals(80, planned.appointmentBatches().get(0).sources().get(0).packageCount()); + jdbc.update("UPDATE ll_inbound_plan_pallet SET actual_package_count=1 WHERE id=111"); + assertEquals("RECEIVED", mapper.candidateSources(physical).get(0).sourceStage()); + assertFalse(candidates.batchReferences(physical).get(physical.get(0)).blockReasons().isEmpty()); + jdbc.update("UPDATE ll_inbound_plan_pallet SET assignment_status='ASSIGNED',actual_package_count=80 WHERE id=111"); + jdbc.update("UPDATE ll_inbound_plan_pallet_allocation SET counted_package_count=80 WHERE pallet_id=111"); + assertTrue(candidates.batchReferences(physical).get(physical.get(0)).blockReasons().isEmpty()); + } private JdbcTemplate jdbc; private TransactionTemplate tx; private AppointmentWorkDispatchPort binding; @@ -46,7 +63,7 @@ class AppointmentWorkDispatchPersistenceTest { jdbc.execute("CREATE TABLE ll_inbound_fba_appointment(id BIGINT PRIMARY KEY,appointment_no VARCHAR(40),version INT,replacement_keys_json VARCHAR(200))"); jdbc.execute("CREATE TABLE ll_inbound_fba_appointment_item(appointment_id BIGINT,order_id BIGINT,customer_id VARCHAR(40),source_key VARCHAR(60),fba_warehouse_code VARCHAR(40),package_count INT,active INT)"); jdbc.execute("CREATE TABLE ll_inbound_plan(id BIGINT PRIMARY KEY,plan_no VARCHAR(40))"); - jdbc.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT PRIMARY KEY,pallet_no VARCHAR(40),plan_id BIGINT,customer_id VARCHAR(40),warehouse_pallet_id BIGINT)"); + jdbc.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT PRIMARY KEY,pallet_no VARCHAR(40),plan_id BIGINT,customer_id VARCHAR(40),warehouse_pallet_id BIGINT,assignment_status VARCHAR DEFAULT 'ASSIGNED',actual_package_count INT DEFAULT 1)"); jdbc.execute("CREATE TABLE ll_inbound_plan_line(id BIGINT PRIMARY KEY,order_id BIGINT,customer_id VARCHAR(40),destination_type VARCHAR(30),fba_warehouse_code VARCHAR(40))"); jdbc.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(pallet_id BIGINT,line_id BIGINT,customer_id VARCHAR(40),planned_package_count INT,counted_package_count INT)"); jdbc.execute("CREATE TABLE ll_dispatch_pallet_lock(id BIGINT PRIMARY KEY,plan_pallet_id BIGINT,warehouse_pallet_id BIGINT,status VARCHAR(30),dispatch_order_id BIGINT,expires_at TIMESTAMP)"); @@ -67,7 +84,7 @@ class AppointmentWorkDispatchPersistenceTest { /** 插入真实客户、托盘和逐票数量,不从请求猜测。 */ private void seed(long item,String customer,long order,int count,String destination) { jdbc.update("INSERT INTO ll_dispatch_item VALUES(?,1,?,?,'RECEIVED',?)",item,customer,item+100,count); - jdbc.update("INSERT INTO ll_inbound_plan_pallet VALUES(?,?,1,?,?)",item+100,"P-"+item,customer,item+1000); + jdbc.update("INSERT INTO ll_inbound_plan_pallet(id,pallet_no,plan_id,customer_id,warehouse_pallet_id) VALUES(?,?,1,?,?)",item+100,"P-"+item,customer,item+1000); jdbc.update("INSERT INTO ll_inbound_plan_line VALUES(?,?,?,?, 'ONT8')",item,order,customer,destination); jdbc.update("INSERT INTO ll_inbound_plan_pallet_allocation VALUES(?,?,?,?,?)",item+100,item,customer,count,count); } diff --git a/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json b/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json index 578a127a9..7dc76b0b9 100644 --- a/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json +++ b/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json @@ -56,6 +56,11 @@ "com.baiyue.lastleg.security.service.SecurityProjectionMonotonicity" ], "reviewedClassDependencies": [ + { + "source": "com.baiyue.lastleg.fleet.outsourcedbusiness.model.OutsourcedBusinessModels", + "target": "com.baiyue.lastleg.dispatch.port.DispatchViews", + "reason": "外配装车后视图组合原排车公开只读详情,复用同一履约事实,不读取实体或写主单。" + }, { "source": "com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels", "target": "com.baiyue.lastleg.dispatch.port.DispatchViews", diff --git a/scripts/synchronous-dependency-baseline.mjs b/scripts/synchronous-dependency-baseline.mjs index 940e5d7c9..6afa6837f 100644 --- a/scripts/synchronous-dependency-baseline.mjs +++ b/scripts/synchronous-dependency-baseline.mjs @@ -15,6 +15,24 @@ const http = (path, source, target, contexts, access, idempotency, note) => ({ }) export const SYNCHRONOUS_CLIENTS = Object.freeze([ + feign('firstleg/src/main/java/com/baiyue/firstleg/client/AppointmentWorkRouteClient.java', + 'firstleg-service', 'lastleg-service', ['REQUEST'], 'READ', 'SAFE_READ', + '直送路线守卫经内部令牌读取原预约路线事实,GET无远端业务写入,不接管预约创建'), + feign('firstleg/src/main/java/com/baiyue/firstleg/workflow/LastlegWorkflowClient.java', + 'firstleg-service', 'lastleg-service', ['REQUEST'], 'READ', 'SAFE_READ', + '工作流页按精确来源批量只读尾程事实,POST仅查询,不跨库或写尾程状态'), + feign('lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFirstlegClient.java', + 'lastleg-service', 'firstleg-service', ['REQUEST', 'BACKGROUND'], 'READ', 'SAFE_READ', + '请求及周期同步读取原头程容器和来源事实,GET/批量POST均为远端只读,本地同步写入不等于写Firstleg'), + feign('lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkOwnerClient.java', + 'lastleg-service', 'iam-service', ['REQUEST'], 'READ', 'SAFE_READ', + '预约负责人校验只读IAM有效平台成员目录,内部令牌保护,不修改账号角色或预约'), + http('integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingQuantityHttpSink.java', + 'integration-service', 'lastleg-service', ['BACKGROUND'], 'READ_WRITE', 'MIXED', + '有界诊断观察worker读取状态并记录观测/停止状态,不接触供应商;请求和响应有界、拒绝重定向,不自动重试或假定写入可免费重放'), + http('lastleg-service/src/main/java/com/baiyue/lastleg/discard/gateway/HttpCargoDiscardPhotoFileGateway.java', + 'lastleg-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED', + '废弃照片按客户、业务类别和原任务核对File归属,上传无远端幂等键且不自动重传,元数据及内容只读'), feign('lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentForecastClient.java', 'lastleg-service', 'firstleg-service', ['REQUEST'], 'READ', 'SAFE_READ', '预约影响刷新按柜次批量读取头程不可变船期事实;POST仅查询,刷新写入仅在Lastleg本域,不写Firstleg'), diff --git a/scripts/synchronous-dependency-contract.test.mjs b/scripts/synchronous-dependency-contract.test.mjs index 968657de1..d063e1224 100644 --- a/scripts/synchronous-dependency-contract.test.mjs +++ b/scripts/synchronous-dependency-contract.test.mjs @@ -26,6 +26,7 @@ const expectedOnlineEdges = [ 'finance-service->warehouse-service', 'firstleg-service->file-service', 'firstleg-service->iam-service', + 'firstleg-service->lastleg-service', 'firstleg-service->order-service', 'firstleg-service->print-service', 'firstleg-service->warehouse-service', @@ -107,7 +108,7 @@ test('all Feign clients have reviewed context, access and idempotency metadata', const reviewed = SYNCHRONOUS_CLIENTS.filter(client => client.transport === 'FEIGN') .map(({ path, source, target }) => ({ path, source, target })) .sort((left, right) => left.path.localeCompare(right.path)) - assert.equal(reviewed.length, 77) + assert.equal(reviewed.length, 81) assert.deepEqual(await discoverFeignEdges(), reviewed) }) @@ -123,7 +124,7 @@ test('all raw HTTP transports have reviewed context, access and idempotency meta } const reviewed = SYNCHRONOUS_CLIENTS.filter(client => client.transport === 'HTTP') .map(client => client.path).sort() - assert.equal(reviewed.length, 30) + assert.equal(reviewed.length, 32) assert.deepEqual(discovered.sort(), reviewed) }) @@ -278,6 +279,10 @@ test('known user entries expose every longest simple REQUEST path over the two-h 'iam-service->warehouse-service' ] }, 'order-service': { hops: 6, paths: [ + 'order-service->finance-service->firstleg-service->lastleg-service->file-service->iam-service->auth-service', + 'order-service->finance-service->firstleg-service->lastleg-service->file-service->iam-service->warehouse-service', + 'order-service->firstleg-service->lastleg-service->finance-service->file-service->iam-service->auth-service', + 'order-service->firstleg-service->lastleg-service->finance-service->file-service->iam-service->warehouse-service', 'order-service->lastleg-service->finance-service->firstleg-service->file-service->iam-service->auth-service', 'order-service->lastleg-service->finance-service->firstleg-service->file-service->iam-service->warehouse-service' ] },