From 2063d29d1645817a1af954c6112f85a3f738b254 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 3 Sep 2026 19:10:18 +0800 Subject: [PATCH] fix: complete development acceptance paths --- deploy/sql/头程数据库统一增量-MySQL8.sql | 21 ++++++ .../BusinessFeeSourceEventService.java | 2 +- .../BusinessFeeSourceEventServiceTest.java | 4 +- .../controller/InventoryQueryController.java | 13 ++-- .../query/service/InventoryQueryService.java | 14 +--- .../InventoryQueryControllerTest.java | 29 ++++++++ ...ce-direct-paid-isolation-contract.test.mjs | 2 + .../finance-direct-paid-isolation.mjs | 74 ++++++++++--------- ...se3-loading-model-bridge-contract.test.mjs | 20 +++++ .../phase3-warehouse-rbac.mjs | 48 +++++++++--- ...canonical-warehouse-rbac-contract.test.mjs | 30 +++++++- ...eas-finance-cross-region-contract.test.mjs | 16 ++-- .../phase4-overseas-finance-cross-region.mjs | 9 ++- 13 files changed, 203 insertions(+), 79 deletions(-) diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 0dd707ffe..f87464880 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -10201,6 +10201,8 @@ SELECT r.id,p.id,0 FROM iam_role r JOIN iam_permission p ON p.tenant_id=0 AND p. AND p.permission_code IN('platform:firstleg:customs:list','platform:firstleg:customs:manage') WHERE r.tenant_id=0 AND r.role_code IN('PLATFORM_ADMIN','ORDER_OPERATOR'); +USE baiyue_firstleg; + /* Phase 3: link inbound preload plans to one or more container loading plans. */ CREATE TABLE IF NOT EXISTS fl_preload_loading_plan_link ( id BIGINT UNSIGNED NOT NULL COMMENT '关联ID', @@ -12586,6 +12588,8 @@ CALL queue_table_comment('baiyue_order', 'ord_order_status_history', '订单状 CALL queue_table_comment('baiyue_order', 'ord_outbox_event', '订单事务Outbox表:与订单业务变更在同一本地事务写入,供后台发布器可靠投递RocketMQ,保证数据库状态与领域事件最终一致;不是普通操作日志'); CALL queue_table_comment('baiyue_order', 'sys_audit_outbox', '可靠审计事件本地发送箱'); CALL queue_table_comment('baiyue_warehouse', 'sys_audit_outbox', '可靠审计事件本地发送箱'); +CALL queue_table_comment('baiyue_firstleg', 'fl_order_declaration_event', 'Order客户申报事件永久幂等账本'); +CALL queue_table_comment('baiyue_firstleg', 'fl_order_declaration_snapshot', 'Order客户申报版本快照;与箱码、实测、托盘、PDA和计费事实隔离'); CALL queue_table_comment('baiyue_warehouse', 'wh_express_company', '平台公共快递公司主数据,固定归属 tenant_id=0'); CALL queue_table_comment('baiyue_warehouse', 'wh_location', '二期库位基础资料,默认一托盘对应一个库位且不作容量校验'); CALL queue_table_comment('baiyue_warehouse', 'wh_location_occupancy', '已确认的库位占用事实记录,托盘字段仅为主数据快照'); @@ -17214,7 +17218,24 @@ CALL queue_column_comment('baiyue_firstleg','fl_loading_cabinet_evidence','updat CALL queue_column_comment('baiyue_firstleg','fl_loading_cabinet_evidence','updated_by_name','最后修改操作人名称快照'); CALL queue_column_comment('baiyue_firstleg','fl_loading_cabinet_evidence','created_at','创建时间'); CALL queue_column_comment('baiyue_firstleg','fl_loading_cabinet_evidence','updated_at','最后更新时间'); +CALL queue_column_comment('baiyue_firstleg','fl_order_declaration_event','tenant_id','客户ID和隔离键'); +CALL queue_column_comment('baiyue_firstleg','fl_order_declaration_event','event_id','永久保留的Order Outbox事件ID'); +CALL queue_column_comment('baiyue_firstleg','fl_order_declaration_event','order_id','Order订单ID'); +CALL queue_column_comment('baiyue_firstleg','fl_order_declaration_event','order_version','事件携带的Order聚合版本'); +CALL queue_column_comment('baiyue_firstleg','fl_order_declaration_event','request_hash','完整事件SHA-256,用于异参重放拒绝'); CALL queue_column_comment('baiyue_firstleg','fl_order_declaration_event','consumed_at','事件消费完成时间'); +CALL queue_column_comment('baiyue_firstleg','fl_order_declaration_snapshot','tenant_id','客户ID和隔离键'); +CALL queue_column_comment('baiyue_firstleg','fl_order_declaration_snapshot','order_id','Order订单ID'); +CALL queue_column_comment('baiyue_firstleg','fl_order_declaration_snapshot','event_id','Order Outbox事件ID'); +CALL queue_column_comment('baiyue_firstleg','fl_order_declaration_snapshot','order_version','Order聚合版本,严格单调消费'); +CALL queue_column_comment('baiyue_firstleg','fl_order_declaration_snapshot','request_hash','完整事件SHA-256'); +CALL queue_column_comment('baiyue_firstleg','fl_order_declaration_snapshot','customer_remark','客户备注快照'); +CALL queue_column_comment('baiyue_firstleg','fl_order_declaration_snapshot','expected_arrival_at','预计到仓时间快照'); +CALL queue_column_comment('baiyue_firstleg','fl_order_declaration_snapshot','channel_code','渠道编码快照'); +CALL queue_column_comment('baiyue_firstleg','fl_order_declaration_snapshot','channel_name','渠道名称快照'); +CALL queue_column_comment('baiyue_firstleg','fl_order_declaration_snapshot','cargoes_json','客户申报货物行完整快照,不覆盖实测事实'); +CALL queue_column_comment('baiyue_firstleg','fl_order_declaration_snapshot','changes_json','本版本申报字段变化摘要'); +CALL queue_column_comment('baiyue_firstleg','fl_order_declaration_snapshot','occurred_at','Order事件发生时间'); CALL queue_column_comment('baiyue_firstleg','fl_order_declaration_snapshot','created_at','快照创建时间'); CALL queue_column_comment('baiyue_firstleg','fl_order_declaration_snapshot','updated_at','快照更新时间'); SET @unified_ddl = IF( diff --git a/finance/src/main/java/com/baiyue/finance/service/BusinessFeeSourceEventService.java b/finance/src/main/java/com/baiyue/finance/service/BusinessFeeSourceEventService.java index 1b38916da..7d65d5d8e 100644 --- a/finance/src/main/java/com/baiyue/finance/service/BusinessFeeSourceEventService.java +++ b/finance/src/main/java/com/baiyue/finance/service/BusinessFeeSourceEventService.java @@ -86,7 +86,7 @@ public class BusinessFeeSourceEventService { system, source.sourceType(), feeId); jdbc.update("UPDATE fin_cost SET status='VOIDED',voided_at=CURRENT_TIMESTAMP(3),void_reason='来源版本已替代',version=version+1,updated_by=0 WHERE cost_origin='BUSINESS_DIRECT_PAID' AND source_system=? AND source_type=? AND source_fee_id=? AND status='CONFIRMED'", system, source.sourceType(), feeId); - jdbc.update("INSERT INTO fin_fee_source(source_system,source_fee_id,source_fee_version,customer_id,source_type,source_no,business_type,business_id,business_no,inbound_code,warehouse_id,direction,order_id,inbound_id,container_id,container_booking_id,container_no,fee_item_code,fee_item_name,amount,currency_code,settlement_mode,paid_at,payment_method,payment_reference_no,payment_voucher_file_ids,source_hash,source_snapshot,status) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?, 'AVAILABLE')", + jdbc.update("INSERT INTO fin_fee_source(source_system,source_fee_id,source_fee_version,customer_id,source_type,source_no,business_type,business_id,business_no,inbound_code,warehouse_id,direction,order_id,inbound_id,container_id,container_booking_id,container_no,fee_item_code,fee_item_name,amount,currency_code,settlement_mode,paid_at,payment_method,payment_reference_no,payment_voucher_file_ids,source_hash,source_snapshot,review_status,status) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?, 'APPROVED','AVAILABLE')", system, feeId, source.sourceVersion(), positive(source.customerId(), "customerId"), source.sourceType(), trim(source.sourceNo()), trim(source.businessType()), number(source.businessId()), trim(source.businessNo()), trim(source.inboundCode()), number(source.warehouseId()), source.direction(), diff --git a/finance/src/test/java/com/baiyue/finance/BusinessFeeSourceEventServiceTest.java b/finance/src/test/java/com/baiyue/finance/BusinessFeeSourceEventServiceTest.java index 817e6b48e..f8d4afaf5 100644 --- a/finance/src/test/java/com/baiyue/finance/BusinessFeeSourceEventServiceTest.java +++ b/finance/src/test/java/com/baiyue/finance/BusinessFeeSourceEventServiceTest.java @@ -23,6 +23,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.startsWith; import static org.mockito.Mockito.mock; @@ -50,7 +51,8 @@ class BusinessFeeSourceEventServiceTest { assertThat(service.consume(event)).isEqualTo(901L); ArgumentCaptor values = ArgumentCaptor.forClass(Object[].class); - verify(jdbc).update(startsWith("INSERT INTO fin_fee_source"), values.capture()); + verify(jdbc).update(argThat(sql -> sql.startsWith("INSERT INTO fin_fee_source") + && sql.contains("review_status,status") && sql.contains("'APPROVED','AVAILABLE'")), values.capture()); assertThat(values.getValue()[3]).isEqualTo(10001L); assertThat(values.getValue()[5]).isEqualTo("PO-US-001"); assertThat(values.getValue()[9]).isNull(); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryController.java index efb7abbbe..5133e9791 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryController.java @@ -24,7 +24,6 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; import org.springframework.web.server.ResponseStatusException; import java.nio.charset.StandardCharsets; @@ -81,30 +80,30 @@ public class InventoryQueryController { @GetMapping(value = BASE_PATH + "/balances/export", produces = "text/csv") @PreAuthorize("hasAuthority('" + OverseasPermissions.INVENTORY_EXPORT + "')") @Operation(summary = "Export filtered inbound-order inventory as UTF-8 CSV", description = "Required authority: " + OverseasPermissions.INVENTORY_EXPORT) - public ResponseEntity exportBalances( + public ResponseEntity exportBalances( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @Valid InventoryQueryRequests.BalanceQuery query) { String scope = platformScope(user, customerId); - return csv(output -> service.writeBalancesCsv(scope, query, output), "inventory-balances.csv"); + return csv(service.exportBalances(scope, query), "inventory-balances.csv"); } @GetMapping(value = BASE_PATH + "/ledger/export", produces = "text/csv") @PreAuthorize("hasAuthority('" + OverseasPermissions.INVENTORY_EXPORT + "')") @Operation(summary = "Export filtered inventory movements as UTF-8 CSV", description = "Required authority: " + OverseasPermissions.INVENTORY_EXPORT) - public ResponseEntity exportLedgers( + public ResponseEntity exportLedgers( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @Valid InventoryQueryRequests.LedgerQuery query) { String scope = platformScope(user, customerId); - return csv(output -> service.writeLedgersCsv(scope, query, output), "inventory-ledger.csv"); + return csv(service.exportLedgers(scope, query), "inventory-ledger.csv"); } - private static ResponseEntity csv(StreamingResponseBody stream, String filename) { + private static ResponseEntity csv(byte[] content, String filename) { return ResponseEntity.ok() .contentType(new MediaType("text", "csv", StandardCharsets.UTF_8)) .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=" + filename) - .body(stream); + .body(content); } private String platformScope(AuthenticatedUser user, String requestedCustomerId) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/service/InventoryQueryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/service/InventoryQueryService.java index e32de7bd4..2f84aed63 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/service/InventoryQueryService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/service/InventoryQueryService.java @@ -81,12 +81,7 @@ public class InventoryQueryService { public byte[] exportBalances(String customerId, InventoryQueryRequests.BalanceQuery query) { try (ByteArrayOutputStream output = new ByteArrayOutputStream()) { - validateStatus(query.inventoryStatus(), BALANCE_STATUSES); - output.write(("\uFEFFcustomerId,customerName,overseasWarehouseCode,overseasWarehouseName,destinationWarehouseCode,destinationWarehouseName,inboundOrderId,inboundOrderNo,inboundCode,skuCode,productNameZh,productNameEn,totalPackages,availablePackages,holdPackages,allocatedPackages,pickedPackages,inTransitPackages,receivingPackages,procurementPackages\r\n").getBytes(StandardCharsets.UTF_8)); - for (InventoryBalance value : mapper.balances(customerId, query, 0, 10_000)) { - InventoryBalanceView v = balanceView(value); - output.write((String.join(",", csv(v.customerId()), csv(v.customerName()), csv(v.overseasWarehouseCode()), csv(v.overseasWarehouseName()), csv(v.destinationWarehouseCode()), csv(v.destinationWarehouseName()), csv(v.inboundOrderId()), csv(v.inboundOrderNo()), csv(v.inboundCode()), csv(v.skuCode()), csv(v.productNameZh()), csv(v.productNameEn()), csv(v.totalPackages()), csv(v.availablePackages()), csv(v.holdPackages()), csv(v.allocatedPackages()), csv(v.pickedPackages()), csv(v.inTransitPackages()), csv(v.receivingPackages()), csv(v.procurementPackages())) + "\r\n").getBytes(StandardCharsets.UTF_8)); - } + writeBalancesCsv(customerId, query, output); return output.toByteArray(); } catch (IOException e) { throw new IllegalStateException("Unable to export inventory balances", e); } } @@ -117,12 +112,7 @@ public class InventoryQueryService { public byte[] exportLedgers(String customerId, InventoryQueryRequests.LedgerQuery query) { try (ByteArrayOutputStream output = new ByteArrayOutputStream()) { - validateDateRange(query); - output.write(("\uFEFFoccurredAt,customerId,overseasWarehouseId,destinationWarehouseCode,inboundOrderId,inboundOrderNo,inboundCode,businessType,businessOrderNo,outboundOrderNo,palletNo,batchNo,statusFrom,statusTo,beforePackages,deltaPackages,afterPackages,operatorId,remark\r\n").getBytes(StandardCharsets.UTF_8)); - for (InventoryLedger value : mapper.ledgers(customerId, query, 0, 10_000)) { - InventoryLedgerView v = ledgerView(value); - output.write((String.join(",", csv(v.occurredAt()), csv(v.customerId()), csv(v.overseasWarehouseId()), csv(v.destinationWarehouseCode()), csv(v.inboundOrderId()), csv(v.inboundOrderNo()), csv(v.inboundCode()), csv(v.businessType()), csv(v.businessOrderNo()), csv(v.outboundOrderNo()), csv(v.palletNo()), csv(v.batchNo()), csv(v.statusFrom()), csv(v.statusTo()), csv(v.beforePackages()), csv(v.deltaPackages()), csv(v.afterPackages()), csv(v.operatorId()), csv(v.remark())) + "\r\n").getBytes(StandardCharsets.UTF_8)); - } + writeLedgersCsv(customerId, query, output); return output.toByteArray(); } catch (IOException e) { throw new IllegalStateException("Unable to export inventory ledger", e); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryControllerTest.java index 7fc387a0f..c2847e1d0 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryControllerTest.java @@ -4,6 +4,7 @@ import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.lastleg.inventory.query.dto.InventoryQueryRequests; import com.baiyue.lastleg.inventory.query.service.InventoryQueryService; import org.junit.jupiter.api.Test; +import org.springframework.http.ResponseEntity; import org.springframework.test.util.ReflectionTestUtils; import org.springframework.web.server.ResponseStatusException; @@ -13,6 +14,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; class InventoryQueryControllerTest { private final InventoryQueryService service = mock(InventoryQueryService.class); @@ -44,6 +46,33 @@ class InventoryQueryControllerTest { verify(service).dashboard(eq(null), eq(query)); } + @Test + void balanceExportIsMaterializedBeforeReturningResponse() { + byte[] csv = "\uFEFFcustomerId\r\n10001\r\n".getBytes(java.nio.charset.StandardCharsets.UTF_8); + when(service.exportBalances("10001", query)).thenReturn(csv); + + ResponseEntity response = controller.exportBalances(user(0L, "PLATFORM"), "10001", query); + + org.junit.jupiter.api.Assertions.assertArrayEquals(csv, response.getBody()); + org.junit.jupiter.api.Assertions.assertEquals("text/csv;charset=UTF-8", + response.getHeaders().getContentType().toString()); + verify(service).exportBalances("10001", query); + } + + @Test + void ledgerExportIsMaterializedBeforeReturningResponse() { + InventoryQueryRequests.LedgerQuery ledgerQuery = new InventoryQueryRequests.LedgerQuery( + 1L, 20L, null, null, null, null, null, null, null, null, null, null, null, null, null); + byte[] csv = "\uFEFFoccurredAt\r\n".getBytes(java.nio.charset.StandardCharsets.UTF_8); + when(service.exportLedgers("10001", ledgerQuery)).thenReturn(csv); + + ResponseEntity response = controller.exportLedgers( + user(0L, "PLATFORM"), "10001", ledgerQuery); + + org.junit.jupiter.api.Assertions.assertArrayEquals(csv, response.getBody()); + verify(service).exportLedgers("10001", ledgerQuery); + } + @Test void unauthenticatedPositionDetailIsRejected() { InventoryQueryRequests.PositionQuery positionQuery = diff --git a/scripts/admin-test-env-modules/finance-direct-paid-isolation-contract.test.mjs b/scripts/admin-test-env-modules/finance-direct-paid-isolation-contract.test.mjs index 41652b4ba..a800d9a68 100644 --- a/scripts/admin-test-env-modules/finance-direct-paid-isolation-contract.test.mjs +++ b/scripts/admin-test-env-modules/finance-direct-paid-isolation-contract.test.mjs @@ -29,6 +29,7 @@ test('成本与未结清结算单选择严格命中客户金额和可支付状 { id: 2, customerId: '101', amount: 17.31, feeItemName: body.feeItemName }, ], body)?.id, 2) assert.equal(findOpenSettlement([ + { settlementId: 3, outstandingAmount: 0, status: 'ISSUED' }, { settlementId: 1, outstandingAmount: 0, status: 'PAID' }, { settlementId: 2, outstandingAmount: 9, status: 'ISSUED' }, ])?.settlementId, 2) @@ -40,6 +41,7 @@ test('HTTP 模块覆盖直接已付投影、AP 排除、幂等冲突、冻结和 '收款同键同参重放且异参冲突', 'AP 付款同键同参重放且异参冲突', '拒绝编辑、调整和作废且零漂移', '第二客户不能读取或确认他人账单', 'foreign-receipt', 'foreign-payable', 'merchant-qualification-fixture.mjs', + 'multi-bill-receipt', 'ap-partial-payment', 'settlementKey', ]) assert.ok(source.includes(token), token) assert.match(source, /fixture\.marker !== context\.marker/) assert.match(source, /禁止借历史财务单据/) diff --git a/scripts/admin-test-env-modules/finance-direct-paid-isolation.mjs b/scripts/admin-test-env-modules/finance-direct-paid-isolation.mjs index 3545aba6e..e4fa6a647 100644 --- a/scripts/admin-test-env-modules/finance-direct-paid-isolation.mjs +++ b/scripts/admin-test-env-modules/finance-direct-paid-isolation.mjs @@ -22,6 +22,10 @@ function key(context, operation) { return `${context.marker}-finance-direct-${operation}`.replace(/[^A-Za-z0-9_.:-]/g, '-').slice(0, 128) } +function settlementKey(context, operation) { + return `${context.marker}-finance-settlement-${operation}`.replace(/[^A-Za-z0-9_.:-]/g, '-').slice(0, 128) +} + function write(context, method, path, body, operation, token = context.token, overseas = false) { const headers = { 'Idempotency-Key': key(context, operation) } return overseas @@ -75,6 +79,7 @@ export function findDirectPaidCost(items, body) { export function findOpenSettlement(items) { return rows(items).find((item) => settlementId(item) + && outstanding(item) > 0 && !['VOIDED', 'PAID'].includes(id(item?.status)) && !['VOIDED', 'PAID'].includes(id(item?.paymentStatus))) ?? null } @@ -134,8 +139,11 @@ async function verifyDirectPaidProjection(context, fixture, published) { let source = null let costs = [] let summary = null - for (let attempt = 0; attempt < 40; attempt += 1) { - const [sourcesResponse, costsResponse, summaryResponse] = await Promise.all([ + let sourcesResponse = null + let costsResponse = null + let summaryResponse = null + for (let attempt = 0; attempt < 120; attempt += 1) { + ;[sourcesResponse, costsResponse, summaryResponse] = await Promise.all([ context.request('GET', `${FINANCE}/fee-sources?page=1&pageSize=200&customerId=${encodeURIComponent(fixture.customerId)}&direction=PAYABLE`), context.request('GET', `${FINANCE}/cost-entries?customerId=${encodeURIComponent(fixture.customerId)}`), context.request('GET', `${FINANCE}/customer-accounts/${encodeURIComponent(fixture.customerId)}/summary`), @@ -151,7 +159,7 @@ async function verifyDirectPaidProjection(context, fixture, published) { context.assert(MODULE, 'DIRECT_PAID 生成 BUSINESS_DIRECT_PAID 成本并进入客户总成本和已付成本', null, Boolean(source && cost) && Number(summary?.totalCost) >= published.body.amount && Number(summary?.paidCost) >= published.body.amount, - `source=${sourceId(source) || 'missing'}, cost=${costId(cost) || 'missing'}, totalCost=${summary?.totalCost ?? 'missing'}, paidCost=${summary?.paidCost ?? 'missing'}`, OPTIONS) + `source=${sourceId(source) || 'missing'}, cost=${costId(cost) || 'missing'}, totalCost=${summary?.totalCost ?? 'missing'}, paidCost=${summary?.paidCost ?? 'missing'}; sources=${diagnostic(sourcesResponse)}; costs=${diagnostic(costsResponse)}; summary=${diagnostic(summaryResponse)}`, OPTIONS) const apPoolResponse = await context.request('GET', `${FINANCE}/fee-pool?customerId=${encodeURIComponent(fixture.customerId)}&direction=PAYABLE`) const apListResponse = await context.request('GET', @@ -164,45 +172,43 @@ async function verifyDirectPaidProjection(context, fixture, published) { } async function verifyPaymentIdempotency(context, fixture) { - const ar = findOpenSettlement(fixture.receivableSettlements) - const ap = findOpenSettlement(fixture.payableSettlements) - if (!ar || !ap) { - blocker(context, '取得未结清 AR/AP 用于收付款幂等', - `AR=${settlementId(ar) || 'missing'}, AP=${settlementId(ap) || 'missing'};需在 finance-settlement-full 消费前执行本模块`, null, - 'open-finance-settlement-unavailable') + const receivables = rows(fixture.receivableSettlements).filter((item) => settlementId(item) && statementId(item)) + const ap = rows(fixture.payableSettlements).find((item) => settlementId(item)) + const priorPayment = fixture.payments?.partial + const priorPaymentTransaction = rows(priorPayment?.transactions)[0] + if (receivables.length < 3 || !ap || !fixture.receipts?.multi || !priorPayment || !priorPaymentTransaction) { + blocker(context, '取得前序收付款成功结果用于幂等重放', + `AR=${receivables.length}, AP=${settlementId(ap) || 'missing'}, receipt=${id(fixture.receipts?.multi?.id) || 'missing'}, payment=${settlementId(priorPayment) || 'missing'};需在 finance-settlement-full 后执行本模块`, null, + 'completed-finance-payment-fixture-unavailable') return } - const arDetailResponse = await context.request('GET', `${FINANCE}/settlements/${encodeURIComponent(settlementId(ar))}`) - const arDetail = context.apiSuccess(MODULE, '读取未结清 AR 详情用于收款幂等', arDetailResponse, - diagnostic(arDetailResponse), OPTIONS) - const receiptAmount = Math.min(0.01, outstanding(arDetail)) - const receiptBody = { customerId: id(fixture.customerId), receivedDate: today(), amount: receiptAmount, + const allocationAmount = Number(fixture.multiBillAllocationAmount || 1) + const allocations = receivables.slice(0, 3).map((item) => ({ billId: Number(statementId(item)), amount: allocationAmount })) + const receiptBody = { customerId: id(fixture.customerId), receivedDate: today(), amount: allocationAmount * allocations.length, currency: 'CNY', paymentMethod: 'BANK_TRANSFER', voucherFileIds: [id(fixture.voucherFileId)], - remark: context.marker, allocations: [{ billId: statementId(ar), amount: receiptAmount }] } - const receipt = await write(context, 'POST', `${FINANCE}/receipts`, receiptBody, 'receipt-replay') - const receiptReplay = await write(context, 'POST', `${FINANCE}/receipts`, receiptBody, 'receipt-replay') - const receiptConflict = await write(context, 'POST', `${FINANCE}/receipts`, - { ...receiptBody, remark: `${context.marker}-different` }, 'receipt-replay') + remark: context.marker, allocations } + const receiptHeaders = { 'Idempotency-Key': settlementKey(context, 'multi-bill-receipt') } + const receiptReplay = await context.request('POST', `${FINANCE}/receipts`, receiptBody, context.token, receiptHeaders) + const receiptConflict = await context.request('POST', `${FINANCE}/receipts`, + { ...receiptBody, remark: `${context.marker}-different` }, context.token, receiptHeaders) context.assert(MODULE, '收款同键同参重放且异参冲突', receiptReplay, - ok(receipt) && ok(receiptReplay) - && id(data(receipt)?.receiptId ?? data(receipt)?.id) === id(data(receiptReplay)?.receiptId ?? data(receiptReplay)?.id) + ok(receiptReplay) + && id(fixture.receipts.multi?.id) === id(data(receiptReplay)?.receiptId ?? data(receiptReplay)?.id) && rejected(receiptConflict), - `receipt=${id(data(receipt)?.receiptId ?? data(receipt)?.id) || 'missing'}; conflict=${diagnostic(receiptConflict)}`, OPTIONS) + `receipt=${id(data(receiptReplay)?.receiptId ?? data(receiptReplay)?.id) || 'missing'}; conflict=${diagnostic(receiptConflict)}`, OPTIONS) - const apDetailResponse = await context.request('GET', `${FINANCE}/settlements/${encodeURIComponent(settlementId(ap))}`) - const apDetail = data(apDetailResponse) - const paymentAmount = Math.min(0.01, outstanding(apDetail)) - const paymentBody = { transactionDate: today(), amount: paymentAmount, currency: 'CNY', method: 'BANK_TRANSFER', - referenceNo: `${context.marker}-AP-IDEMPOTENT`, voucherFileIds: [id(fixture.voucherFileId)], - expectedVersion: version(apDetail), remark: context.marker } + const paymentBody = { transactionDate: id(priorPaymentTransaction.transactionDate) || today(), + amount: Number(priorPaymentTransaction.amount), currency: 'CNY', method: 'BANK_TRANSFER', + referenceNo: `${context.marker}-AP`, voucherFileIds: [id(fixture.voucherFileId)], + expectedVersion: Math.max(0, version(priorPayment) - 1), remark: context.marker } const path = `${FINANCE}/settlements/${encodeURIComponent(settlementId(ap))}/transactions` - const payment = await write(context, 'POST', path, paymentBody, 'ap-replay') - const paymentReplay = await write(context, 'POST', path, paymentBody, 'ap-replay') - const paymentConflict = await write(context, 'POST', path, - { ...paymentBody, referenceNo: `${paymentBody.referenceNo}-different` }, 'ap-replay') + const paymentHeaders = { 'Idempotency-Key': settlementKey(context, 'ap-partial-payment') } + const paymentReplay = await context.request('POST', path, paymentBody, context.token, paymentHeaders) + const paymentConflict = await context.request('POST', path, + { ...paymentBody, referenceNo: `${paymentBody.referenceNo}-different` }, context.token, paymentHeaders) context.assert(MODULE, 'AP 付款同键同参重放且异参冲突', paymentReplay, - ok(payment) && ok(paymentReplay) && rejected(paymentConflict) - && version(data(paymentReplay)) === version(data(payment)), + ok(paymentReplay) && rejected(paymentConflict) + && version(data(paymentReplay)) >= version(priorPayment), `settlementId=${settlementId(ap)}, version=${version(data(paymentReplay))}; conflict=${diagnostic(paymentConflict)}`, OPTIONS) } diff --git a/scripts/admin-test-env-modules/phase3-loading-model-bridge-contract.test.mjs b/scripts/admin-test-env-modules/phase3-loading-model-bridge-contract.test.mjs index d97113dd8..93fa2850c 100644 --- a/scripts/admin-test-env-modules/phase3-loading-model-bridge-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-loading-model-bridge-contract.test.mjs @@ -5,6 +5,26 @@ import { fileURLToPath } from 'node:url' import { stableLinkSnapshot } from './phase3-loading-model-bridge.mjs' const source = readFileSync(fileURLToPath(new URL('./phase3-loading-model-bridge.mjs', import.meta.url)), 'utf8') +const unifiedSql = readFileSync(fileURLToPath(new URL('../../deploy/sql/头程数据库统一增量-MySQL8.sql', import.meta.url)), 'utf8') + +test('unified migration selects firstleg before creating the loading model bridge', () => { + assert.match( + unifiedSql, + /USE baiyue_firstleg;\s*\/\* Phase 3: link inbound preload plans to one or more container loading plans\. \*\/\s*CREATE TABLE IF NOT EXISTS fl_preload_loading_plan_link/, + ) +}) + +test('unified migration converges declaration ledger table comments before final schema validation', () => { + assert.match(unifiedSql, /queue_table_comment\('baiyue_firstleg', 'fl_order_declaration_event', '[^']*[一-龥][^']*'\)/) + assert.match(unifiedSql, /queue_table_comment\('baiyue_firstleg', 'fl_order_declaration_snapshot', '[^']*[一-龥][^']*'\)/) + for (const column of ['tenant_id', 'event_id', 'order_id', 'order_version', 'request_hash', 'consumed_at']) { + assert.match(unifiedSql, new RegExp(`queue_column_comment\\('baiyue_firstleg','fl_order_declaration_event','${column}','[^']*[一-龥][^']*'\\)`)) + } + for (const column of ['tenant_id', 'order_id', 'event_id', 'order_version', 'request_hash', 'customer_remark', + 'expected_arrival_at', 'channel_code', 'channel_name', 'cargoes_json', 'changes_json', 'occurred_at', 'created_at', 'updated_at']) { + assert.match(unifiedSql, new RegExp(`queue_column_comment\\('baiyue_firstleg','fl_order_declaration_snapshot','${column}','[^']*[一-龥][^']*'\\)`)) + } +}) test('bridge acceptance treats an existing pair as a read-only idempotent replay', () => { assert.match(source, /已由推送建立的同一桥接 pair 重放返回原关联/) diff --git a/scripts/admin-test-env-modules/phase3-warehouse-rbac.mjs b/scripts/admin-test-env-modules/phase3-warehouse-rbac.mjs index b4ed078ef..cba0304b6 100644 --- a/scripts/admin-test-env-modules/phase3-warehouse-rbac.mjs +++ b/scripts/admin-test-env-modules/phase3-warehouse-rbac.mjs @@ -13,6 +13,29 @@ const PHASE4_READ_CODES = [ 'platform:overseas-business:looseboard:list', ] const PHASE4_WRITE_SUFFIXES = [':create', ':edit', ':status', ':export', ':adjust', ':delete', ':review'] +const PHASE5_OPERATOR_CODES = [ + 'platform:overseas-warehouse:dispatch:list', + 'platform:overseas-warehouse:dispatch:edit', + 'platform:overseas-warehouse:dispatch:cancel', + 'platform:overseas-warehouse:dispatch:transfer-delivery', + 'platform:overseas-warehouse:outbound:list', + 'platform:overseas-warehouse:outbound:create', + 'platform:overseas-warehouse:outbound:cancel', + 'platform:overseas-warehouse:delivery:list', + 'platform:overseas-warehouse:exception-order:list', + 'platform:overseas-warehouse:exception-order:create', + 'platform:overseas-warehouse:delivery:return', + 'platform:overseas-warehouse:delivery:status', + 'platform:overseas-warehouse:customer-visit:list', + 'platform:overseas-warehouse:customer-visit:edit', +] +const PHASE5_TALLY_CODES = [ + 'platform:overseas-warehouse:dispatch:list', + 'platform:overseas-warehouse:outbound:list', + 'platform:overseas-warehouse:delivery:list', + 'platform:overseas-warehouse:exception-order:list', + 'platform:overseas-warehouse:customer-visit:list', +] function id(value) { return value == null ? '' : String(value) } @@ -62,38 +85,41 @@ export async function run(context) { `permissions=${[...overseasPdaCodes].sort().join(',')}`, OPTIONS) const operatorCodes = roleCodes('WAREHOUSE_OPERATOR') - context.assert(MODULE, '操作员具备国内入仓权限与四期十项只读权限', rolesResponse, + context.assert(MODULE, '操作员具备国内入仓权限、四期只读权限与五期履约权限', rolesResponse, operatorCodes.has('platform:firstleg:inbound:loading:view') && operatorCodes.has('platform:firstleg:inbound:loading:edit') - && operatorCodes.size === 15 + && operatorCodes.size === 29 && PHASE4_READ_CODES.every((code) => operatorCodes.has(code)) + && PHASE5_OPERATOR_CODES.every((code) => operatorCodes.has(code)) && !operatorCodes.has('platform:firstleg:inbound:loading:complete') && !operatorCodes.has('platform:firstleg:pda:assign') && !operatorCodes.has('platform:firstleg:outbound-dispatch:confirm') && !operatorCodes.has('platform:firstleg:outbound-dispatch:reverse') && [...operatorCodes].filter((code) => code.startsWith('platform:overseas-')) - .every((code) => !PHASE4_WRITE_SUFFIXES.some((suffix) => code.endsWith(suffix))), + .every((code) => PHASE4_READ_CODES.includes(code) || PHASE5_OPERATOR_CODES.includes(code)), `permissionCount=${operatorCodes.size}`, OPTIONS) const tallyCodes = roleCodes('WAREHOUSE_TALLY') - context.assert(MODULE, '理货员可后台维护证据和复核但不能完成装柜', rolesResponse, + context.assert(MODULE, '理货员可维护证据、复核并只读查看海外履约', rolesResponse, tallyCodes.has('platform:firstleg:inbound:loading:view') && tallyCodes.has('platform:firstleg:inbound:loading:edit') && tallyCodes.has('platform:firstleg:pda:review') - && tallyCodes.size === 22 + && tallyCodes.size === 28 && PHASE4_READ_CODES.every((code) => tallyCodes.has(code)) + && PHASE5_TALLY_CODES.every((code) => tallyCodes.has(code)) && !tallyCodes.has('platform:firstleg:inbound:loading:complete') && !tallyCodes.has('platform:firstleg:outbound-dispatch:confirm') && [...tallyCodes].filter((code) => code.startsWith('platform:overseas-')) - .every((code) => !PHASE4_WRITE_SUFFIXES.some((suffix) => code.endsWith(suffix))), + .every((code) => PHASE4_READ_CODES.includes(code) || PHASE5_TALLY_CODES.includes(code)), `permissionCount=${tallyCodes.size}`, OPTIONS) - context.assert(MODULE, '四期list权限同时用于列表与详情且不隐含写权限', rolesResponse, + context.assert(MODULE, '四期只读与五期岗位权限集合严格匹配', rolesResponse, PHASE4_READ_CODES.every((code) => code.endsWith(':list')) - && [...operatorCodes, ...tallyCodes] - .filter((code) => code.startsWith('platform:overseas-')) - .every((code) => PHASE4_READ_CODES.includes(code)), - `phase4ReadCodes=${PHASE4_READ_CODES.length}`, OPTIONS) + && PHASE5_TALLY_CODES.every((code) => code.endsWith(':list')) + && PHASE5_OPERATOR_CODES.some((code) => PHASE4_WRITE_SUFFIXES.some((suffix) => code.endsWith(suffix))) + && PHASE5_OPERATOR_CODES.every((code) => operatorCodes.has(code)) + && PHASE5_TALLY_CODES.every((code) => tallyCodes.has(code)), + `phase4ReadCodes=${PHASE4_READ_CODES.length}, phase5OperatorCodes=${PHASE5_OPERATOR_CODES.length}, phase5TallyCodes=${PHASE5_TALLY_CODES.length}`, OPTIONS) context.assert(MODULE, '仓库系统角色不包含任何客户侧权限', rolesResponse, [...operatorCodes, ...domesticPdaCodes, ...overseasPdaCodes, ...tallyCodes] diff --git a/scripts/admin-test-env-modules/phase4-canonical-warehouse-rbac-contract.test.mjs b/scripts/admin-test-env-modules/phase4-canonical-warehouse-rbac-contract.test.mjs index 14406d161..ab1c0faf1 100644 --- a/scripts/admin-test-env-modules/phase4-canonical-warehouse-rbac-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-canonical-warehouse-rbac-contract.test.mjs @@ -4,6 +4,7 @@ import { readFileSync } from 'node:fs' const acceptance = readFileSync(new URL('./phase3-warehouse-rbac.mjs', import.meta.url), 'utf8') const iamSql = readFileSync(new URL('../../iam/src/main/resources/db/lastleg-permissions.sql', import.meta.url), 'utf8') +const phase5Sql = readFileSync(new URL('../../iam/src/main/resources/db/phase5-delivery-permissions.sql', import.meta.url), 'utf8') const unifiedSql = readFileSync(new URL('../../deploy/sql/头程数据库统一增量-MySQL8.sql', import.meta.url), 'utf8') const listCodes = [ @@ -19,19 +20,42 @@ const listCodes = [ 'platform:overseas-business:looseboard:list', ] -test('warehouse operator and tally use the exact phase-four list/detail permission set', () => { +test('warehouse operator and tally use the exact phase-four and phase-five permission sets', () => { for (const code of listCodes) { assert.match(acceptance, new RegExp(code.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))) assert.match(iamSql, new RegExp(`'WAREHOUSE_OPERATOR','${code}'`)) assert.match(iamSql, new RegExp(`'WAREHOUSE_TALLY','${code}'`)) } - assert.match(acceptance, /operatorCodes\.size === 15/) - assert.match(acceptance, /tallyCodes\.size === 22/) + assert.match(acceptance, /operatorCodes\.size === 29/) + assert.match(acceptance, /tallyCodes\.size === 28/) assert.match(acceptance, /PHASE4_WRITE_SUFFIXES/) assert.match(iamSql, /assert_phase4_warehouse_role_minimum/) assert.match(iamSql, /permission_code REGEXP ':\(create\|edit\|status\|export\|adjust\|delete\|review\)\$'/) }) +test('warehouse roles include the exact phase-five fulfillment responsibilities', () => { + assert.match(acceptance, /PHASE5_OPERATOR_CODES/) + assert.match(acceptance, /PHASE5_TALLY_CODES/) + for (const code of [ + 'platform:overseas-warehouse:dispatch:list', + 'platform:overseas-warehouse:dispatch:edit', + 'platform:overseas-warehouse:dispatch:cancel', + 'platform:overseas-warehouse:dispatch:transfer-delivery', + 'platform:overseas-warehouse:outbound:list', + 'platform:overseas-warehouse:outbound:create', + 'platform:overseas-warehouse:outbound:cancel', + 'platform:overseas-warehouse:delivery:list', + 'platform:overseas-warehouse:exception-order:list', + 'platform:overseas-warehouse:exception-order:create', + 'platform:overseas-warehouse:delivery:return', + 'platform:overseas-warehouse:delivery:status', + 'platform:overseas-warehouse:customer-visit:list', + 'platform:overseas-warehouse:customer-visit:edit' + ]) { + assert.match(phase5Sql, new RegExp(code.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))) + } +}) + test('domestic and overseas PDA operators use isolated authorities', () => { assert.match(acceptance, /domesticPdaCodes\.size === 2/) assert.match(acceptance, /overseasPdaCodes\.size === 1/) diff --git a/scripts/admin-test-env-modules/phase4-overseas-finance-cross-region-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-finance-cross-region-contract.test.mjs index 62f5c25fb..57b7df659 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-finance-cross-region-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-finance-cross-region-contract.test.mjs @@ -24,9 +24,9 @@ function fixture() { const fees = new Map() let sequence = 7000 const businessRows = { - '/api/platform/v1/last-leg/overseas-procurements': { id: '3001', orderNo: 'PO-1', customerId: '1001' }, - '/api/platform/v1/last-leg/us-truck-orders': { id: '3002', orderNo: 'TK-1', customerId: '1001' }, - '/api/platform/v1/last-leg/local-looseboard-orders': { id: '3003', orderNo: 'LB-1', customerId: '1001' }, + '/api/platform/v1/last-leg/overseas-procurements': { id: '3001', orderNo: 'PO-1', customerId: '1101' }, + '/api/platform/v1/last-leg/us-truck-orders': { id: '3002', orderNo: 'TK-1', customerId: '1201' }, + '/api/platform/v1/last-leg/local-looseboard-orders': { id: '3003', orderNo: 'LB-1', customerId: '1301' }, } const context = { marker, @@ -36,9 +36,9 @@ function fixture() { createdResources: { merchantQualificationFixture: { tenantId: '1001' }, phase4OverseasBusiness: { - purchase: { orderNo: 'PO-1', customerId: '1001' }, - truck: { orderNo: 'TK-1', customerId: '1001' }, - looseboard: { orderNo: 'LB-1', customerId: '1001' }, + purchase: { orderNo: 'PO-1', customerId: '1101' }, + truck: { orderNo: 'TK-1', customerId: '1201' }, + looseboard: { orderNo: 'LB-1', customerId: '1301' }, }, phase4OverseasPda: { customerId: '2001', overseasWarehouseId: '9001' }, }, @@ -77,7 +77,9 @@ function fixture() { const existing = commands.get(`CREATE:${key}`) if (existing) return existing.hash === hash ? response(existing.fee) : response(null, 'A0409') const expectedCustomer = body.businessId === '4001' ? '2001' - : ['3001', '3002', '3003'].includes(String(body.businessId)) ? '1001' : body.customerId + : body.businessId === '3001' ? '1101' + : body.businessId === '3002' ? '1201' + : body.businessId === '3003' ? '1301' : body.customerId if (body.businessId && body.customerId !== expectedCustomer) return response(null, 'A0400') const fee = { ...structuredClone(body), sourceFeeId: String(++sequence), sourceVersion: 1, reviewStatus: 'DRAFT', publicationStatus: 'UNPUBLISHED', sourceStatus: 'ACTIVE' } diff --git a/scripts/admin-test-env-modules/phase4-overseas-finance-cross-region.mjs b/scripts/admin-test-env-modules/phase4-overseas-finance-cross-region.mjs index f8ee7e451..96b565e71 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-finance-cross-region.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-finance-cross-region.mjs @@ -61,11 +61,14 @@ async function resolveFixtures(context) { const merchantCustomerId = id(context.createdResources?.merchantQualificationFixture?.tenantId || business?.purchase?.customerId || business?.truck?.customerId || business?.looseboard?.customerId) const procurement = await resolveListedBusiness(context, - '/api/platform/v1/last-leg/overseas-procurements', business?.purchase, merchantCustomerId, '海外采购') + '/api/platform/v1/last-leg/overseas-procurements', business?.purchase, + id(business?.purchase?.customerId), '海外采购') const truck = await resolveListedBusiness(context, - '/api/platform/v1/last-leg/us-truck-orders', business?.truck, merchantCustomerId, '美国卡车') + '/api/platform/v1/last-leg/us-truck-orders', business?.truck, + id(business?.truck?.customerId), '美国卡车') const looseboard = await resolveListedBusiness(context, - '/api/platform/v1/last-leg/local-looseboard-orders', business?.looseboard, merchantCustomerId, '本土散板') + '/api/platform/v1/last-leg/local-looseboard-orders', business?.looseboard, + id(business?.looseboard?.customerId), '本土散板') const pda = context.createdResources?.phase4OverseasPda const inboundCustomerId = id(pda?.customerId)