From 9b3ff06a90cf394882f8c5b81a06a7a3c779d8ec Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 11 Aug 2026 11:41:41 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/sql/README.md | 2 +- .../service/InternalBusinessFileService.java | 2 +- finance/pom.xml | 1 + .../finance/BaiyueFinanceApplication.java | 2 + .../finance/client/FinanceFileClient.java | 17 +++ .../controller/ClientFinanceController.java | 4 +- .../controller/PlatformFinanceController.java | 16 +- .../finance/service/FinanceService.java | 143 ++++++++++++++++-- .../baiyue/finance/FinanceContractTest.java | 6 + .../finance/FinanceServiceValidationTest.java | 30 ++++ .../loading/mapper/LoadingInboundMapper.java | 19 ++- ...lfillmentCompletionMapperContractTest.java | 7 +- .../service/LoadingDeliveryServiceTest.java | 67 ++++++++ .../phase3-container-loading.mjs | 44 ++++-- .../admin-test-env-modules/phase3-customs.mjs | 36 +++-- .../admin-test-env-modules/phase3-loading.mjs | 100 +++++++++++- .../phase3-pda-convergence.mjs | 9 +- 17 files changed, 429 insertions(+), 76 deletions(-) create mode 100644 finance/src/main/java/com/baiyue/finance/client/FinanceFileClient.java diff --git a/deploy/sql/README.md b/deploy/sql/README.md index 24bb60f79..df02ee5b3 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -25,7 +25,7 @@ - Finance 数据库为 `baiyue_finance`,模块溯源脚本为 `finance/src/main/resources/db/finance.sql`,测试和存量环境仍只执行统一入口。 - 首期只支持 CNY,包含平台主体、客户默认 30 天账期、账单及明细、人工收款及账单分配、单票/整柜人工成本、体积分摊结果、Firstleg 费用来源事件、幂等记录和不可变审计日志。 - 不包含钱包、发票、供应商在线应付、银行自动对账、复杂核销、汇率和月结。 -- 统一脚本末尾会强校验 12 张 Finance 表、客户隔离/CNY/人工凭证关键字段,以及费用来源、Inbox、账单、收款、成本和写操作幂等唯一约束;任一缺失不会返回 `PASS`。 +- 统一脚本末尾会强校验 13 张 Finance 表、客户隔离/CNY/人工凭证关键字段,以及费用来源、Inbox、账单版本快照、收款、成本和写操作幂等唯一约束;任一缺失不会返回 `PASS`。 ## 历史一期回归脚本 diff --git a/file/src/main/java/com/baiyue/file/service/InternalBusinessFileService.java b/file/src/main/java/com/baiyue/file/service/InternalBusinessFileService.java index e929b9858..c57567dd7 100644 --- a/file/src/main/java/com/baiyue/file/service/InternalBusinessFileService.java +++ b/file/src/main/java/com/baiyue/file/service/InternalBusinessFileService.java @@ -13,7 +13,7 @@ import java.time.LocalDateTime; @Service public class InternalBusinessFileService { private static final java.util.Set BUSINESS_TYPES = java.util.Set.of( - "TRUCK_APPOINTMENT_ATTACHMENT", "CUSTOMS_DOCUMENT", "CUSTOMS_RECEIPT"); + "TRUCK_APPOINTMENT_ATTACHMENT", "CUSTOMS_DOCUMENT", "CUSTOMS_RECEIPT", "FINANCE_VOUCHER"); @Resource FileObjectMapper files; @Resource BusinessFileStorage storage; public Metadata upload(long tenantId,String businessType,String businessId,long operatorId,MultipartFile file){if(tenantId<=0||operatorId<=0)throw new BizException(ResultCode.BAD_REQUEST,"归属和操作人必须有效");if(!BUSINESS_TYPES.contains(businessType))throw new BizException(ResultCode.BAD_REQUEST,"不支持的附件业务类型");long parsedBusinessId;try{parsedBusinessId=Long.parseLong(businessId);}catch(NumberFormatException ex){throw new BizException(ResultCode.BAD_REQUEST,"业务ID格式无效");}if(parsedBusinessId<=0)throw new BizException(ResultCode.BAD_REQUEST,"业务ID必须有效");var stored=storage.storeLoading(tenantId,parsedBusinessId,file);FileObjectEntity value=new FileObjectEntity();value.setTenantId(tenantId);value.setBusinessType(businessType);value.setBusinessId(businessId);value.setOriginalName(stored.originalName());value.setObjectKey(stored.objectKey());value.setStorageProvider("LOCAL");value.setBucketName("business-files");value.setContentType(stored.contentType());value.setSizeBytes(stored.size());value.setSha256(stored.sha256());value.setStatus("ACTIVE");value.setCreatedBy(operatorId);value.setUpdatedBy(operatorId);value.setCreatedAt(LocalDateTime.now());value.setUpdatedAt(value.getCreatedAt());value.setDeleted(false);value.setVersion(0);if(files.insert(value)!=1)throw new BizException(ResultCode.INTERNAL_ERROR,"附件元数据保存失败");return metadata(value);} public Metadata metadata(long tenantId,String businessType,String businessId,long fileId){return metadata(required(tenantId,businessType,businessId,fileId));} diff --git a/finance/pom.xml b/finance/pom.xml index 261a622b6..33d2bb57f 100644 --- a/finance/pom.xml +++ b/finance/pom.xml @@ -19,6 +19,7 @@ org.springframework.bootspring-boot-starter-jdbc com.mysqlmysql-connector-jruntime com.alibaba.cloudspring-cloud-starter-alibaba-nacos-discovery + org.springframework.cloudspring-cloud-starter-openfeign com.alibaba.cloudspring-cloud-starter-alibaba-nacos-config org.springframework.bootspring-boot-starter-actuator org.springframework.bootspring-boot-starter-testtest diff --git a/finance/src/main/java/com/baiyue/finance/BaiyueFinanceApplication.java b/finance/src/main/java/com/baiyue/finance/BaiyueFinanceApplication.java index 1c60aee94..8abd1b012 100644 --- a/finance/src/main/java/com/baiyue/finance/BaiyueFinanceApplication.java +++ b/finance/src/main/java/com/baiyue/finance/BaiyueFinanceApplication.java @@ -2,9 +2,11 @@ package com.baiyue.finance; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cloud.openfeign.EnableFeignClients; /** Finance 独立服务启动入口。首期只承载人工账单、收款和成本台账。 */ @SpringBootApplication +@EnableFeignClients(basePackages = "com.baiyue.finance.client") public class BaiyueFinanceApplication { public static void main(String[] args) { diff --git a/finance/src/main/java/com/baiyue/finance/client/FinanceFileClient.java b/finance/src/main/java/com/baiyue/finance/client/FinanceFileClient.java new file mode 100644 index 000000000..8dafd480d --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/client/FinanceFileClient.java @@ -0,0 +1,17 @@ +package com.baiyue.finance.client; + +import com.baiyue.common.core.api.ApiResponse; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +@FeignClient(name="file-service", contextId="financeFileClient", url="${baiyue.clients.file.url:}", path="/internal/file/v1/business-files") +public interface FinanceFileClient { + @PostMapping(value="/{tenantId}/FINANCE_VOUCHER/{customerId}", consumes="multipart/form-data") + ApiResponse upload(@RequestHeader("X-Internal-Service-Token") String token, @RequestHeader("X-Operator-Id") long operatorId, + @PathVariable long tenantId, @PathVariable long customerId, @RequestPart("file") MultipartFile file); + @GetMapping("/{tenantId}/FINANCE_VOUCHER/{customerId}/{fileId}") + ApiResponse metadata(@RequestHeader("X-Internal-Service-Token") String token, @PathVariable long tenantId, + @PathVariable long customerId, @PathVariable long fileId); + record Metadata(Long fileId, Long tenantId, String businessType, String businessId, String fileName, String sha256, String contentType, Long sizeBytes) {} +} diff --git a/finance/src/main/java/com/baiyue/finance/controller/ClientFinanceController.java b/finance/src/main/java/com/baiyue/finance/controller/ClientFinanceController.java index 22f0044e2..555ae39ac 100644 --- a/finance/src/main/java/com/baiyue/finance/controller/ClientFinanceController.java +++ b/finance/src/main/java/com/baiyue/finance/controller/ClientFinanceController.java @@ -21,12 +21,12 @@ public class ClientFinanceController { @GetMapping("/bills") @PreAuthorize("hasAuthority('merchant:finance:statement:list')") @Operation(summary = "客户只读查询账单") - public ApiResponse bills(@AuthenticationPrincipal AuthenticatedUser user) { return ApiResponse.success(service.listBills(user.tenantId())); } + public ApiResponse bills(@AuthenticationPrincipal AuthenticatedUser user) { return ApiResponse.success(service.listClientBills(user.tenantId())); } @GetMapping("/bills/{billId}") @PreAuthorize("hasAuthority('merchant:finance:statement:view')") @Operation(summary = "客户只读查看账单详情") - public ApiResponse> bill(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long billId) { return ApiResponse.success(service.bill(user.tenantId(), billId)); } + public ApiResponse> bill(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long billId) { return ApiResponse.success(service.clientBill(user.tenantId(), billId)); } @GetMapping("/receipts") @PreAuthorize("hasAuthority('merchant:finance:statement:view')") diff --git a/finance/src/main/java/com/baiyue/finance/controller/PlatformFinanceController.java b/finance/src/main/java/com/baiyue/finance/controller/PlatformFinanceController.java index d04eb36e8..dc9f6d704 100644 --- a/finance/src/main/java/com/baiyue/finance/controller/PlatformFinanceController.java +++ b/finance/src/main/java/com/baiyue/finance/controller/PlatformFinanceController.java @@ -4,6 +4,7 @@ import com.baiyue.common.audit.BusinessAudit; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.finance.service.FinanceService; +import com.baiyue.finance.client.FinanceFileClient; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; @@ -12,13 +13,26 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; import java.util.Map; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.web.multipart.MultipartFile; @Tag(name = "人工财务台账") @RestController @RequestMapping("/api/platform/finance/v1") public class PlatformFinanceController { private final FinanceService service; - public PlatformFinanceController(FinanceService service) { this.service = service; } + private final FinanceFileClient fileClient; + @Value("${baiyue.security.internal-service-token:}") private String fileToken; + public PlatformFinanceController(FinanceService service, FinanceFileClient fileClient) { this.service = service; this.fileClient = fileClient; } + + @PostMapping(value = "/vouchers", consumes = "multipart/form-data") + @PreAuthorize("hasAuthority('platform:finance:receipt:create')") + public ApiResponse uploadVoucher(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam long customerId, + @RequestPart("file") MultipartFile file) { + if (customerId <= 0) throw new IllegalArgumentException("客户范围无效"); + return fileClient.upload(fileToken, user.userId(), customerId, customerId, file); + } @GetMapping("/customers/{tenantId}/terms") @PreAuthorize("hasAuthority('platform:finance:customer-terms:view')") diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceService.java b/finance/src/main/java/com/baiyue/finance/service/FinanceService.java index 771253ca1..7c5010576 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinanceService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceService.java @@ -4,12 +4,14 @@ import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; +import com.baiyue.finance.client.FinanceFileClient; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.support.GeneratedKeyHolder; import org.springframework.jdbc.support.KeyHolder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; +import org.springframework.beans.factory.annotation.Autowired; import java.math.BigDecimal; import java.sql.PreparedStatement; @@ -24,6 +26,10 @@ import java.util.Map; public class FinanceService { private final JdbcTemplate jdbc; private final ObjectMapper objectMapper; + @Autowired(required = false) + private FinanceFileClient fileClient; + @org.springframework.beans.factory.annotation.Value("${baiyue.security.internal-service-token:}") + private String fileToken; public FinanceService(JdbcTemplate jdbc, ObjectMapper objectMapper) { this.jdbc = jdbc; @@ -43,6 +49,11 @@ public class FinanceService { return jdbc.queryForList("SELECT id,bill_no,customer_name_snapshot,currency_code,bill_date,due_date,status,total_amount,received_amount,outstanding_amount,version FROM fin_bill WHERE tenant_id=? ORDER BY bill_date DESC,id DESC", tenantId); } + public List> listClientBills(long tenantId) { + requireCustomer(tenantId); + return jdbc.queryForList("SELECT id,bill_no,customer_name_snapshot,currency_code,bill_date,due_date,status,total_amount,received_amount,outstanding_amount,version FROM fin_bill WHERE tenant_id=? AND status IN ('ISSUED','PARTIALLY_PAID','PAID') ORDER BY bill_date DESC,id DESC", tenantId); + } + public Map bill(long tenantId, long billId) { requireCustomer(tenantId); Map result = jdbc.queryForMap("SELECT * FROM fin_bill WHERE tenant_id=? AND id=?", tenantId, billId); @@ -50,6 +61,13 @@ public class FinanceService { return result; } + public Map clientBill(long tenantId, long billId) { + requireCustomer(tenantId); + Map result = jdbc.queryForMap("SELECT * FROM fin_bill WHERE tenant_id=? AND id=? AND status IN ('ISSUED','PARTIALLY_PAID','PAID')", tenantId, billId); + result.put("lines", jdbc.queryForList("SELECT * FROM fin_bill_line WHERE tenant_id=? AND bill_id=? ORDER BY id", tenantId, billId)); + return result; + } + public Map billById(long billId) { Long tenantId = jdbc.queryForObject("SELECT tenant_id FROM fin_bill WHERE id=?", Long.class, billId); if (tenantId == null) throw bad("账单不存在"); @@ -77,7 +95,8 @@ public class FinanceService { public Map saveCustomerTerms(long tenantId, long operatorId, String customerName, Integer creditDays, String idempotencyKey) { requireCustomer(tenantId); - reserve(tenantId, operatorId, "CUSTOMER_TERMS_SAVE", idempotencyKey); + Long replay = reserve(tenantId, operatorId, "CUSTOMER_TERMS_SAVE", idempotencyKey, Map.of("customerName", String.valueOf(customerName), "creditDays", creditDays == null ? 30 : creditDays)); + if (replay != null) return customerTerms(tenantId); int days = creditDays == null ? 30 : creditDays; if (days < 0 || days > 3650) throw bad("账期必须在0到3650天之间"); jdbc.update("INSERT INTO fin_customer_settlement_profile(tenant_id,customer_name_snapshot,credit_days,created_by,updated_by) VALUES(?,?,?,?,?) " @@ -90,7 +109,8 @@ public class FinanceService { @Transactional public Map createBill(long tenantId, long operatorId, String customerName, BillRequest request, String idempotencyKey) { - requireCustomer(tenantId); reserve(tenantId, operatorId, "BILL_CREATE", idempotencyKey); + requireCustomer(tenantId); Long replay = reserve(tenantId, operatorId, "BILL_CREATE", idempotencyKey, request); + if (replay != null) return bill(tenantId, replay); if (request.lines() == null || request.lines().isEmpty()) throw bad("账单至少需要一条费用明细"); request.lines().forEach(line -> { if (line.sourceId() <= 0 || line.sourceVersion() <= 0) throw bad("费用来源版本无效"); if (line.amount() == null || line.amount().signum() < 0) throw bad("费用金额不能为负数"); }); BigDecimal total = request.lines().stream().map(BillLine::amount).reduce(BigDecimal.ZERO, BigDecimal::add); @@ -118,7 +138,8 @@ public class FinanceService { @Transactional public Map issueBill(long tenantId, long operatorId, long billId, int expectedVersion, String idempotencyKey) { - requireCustomer(tenantId); reserve(tenantId, operatorId, "BILL_ISSUE", idempotencyKey); + requireCustomer(tenantId); Long replay = reserve(tenantId, operatorId, "BILL_ISSUE", idempotencyKey, Map.of("billId",billId,"expectedVersion",expectedVersion)); + if (replay != null) return bill(tenantId, replay); int updated = jdbc.update("UPDATE fin_bill SET status='ISSUED', issued_at=?, issued_by=?, updated_by=?, version=version+1 WHERE id=? AND tenant_id=? AND status='DRAFT' AND version=?", LocalDateTime.now(), operatorId, operatorId, billId, tenantId, expectedVersion); if (updated != 1) throw bad("账单不存在或已出账"); @@ -128,13 +149,17 @@ public class FinanceService { @Transactional public Map createReceipt(long tenantId, long operatorId, ReceiptRequest request, String idempotencyKey) { - requireCustomer(tenantId); reserve(tenantId, operatorId, "RECEIPT_CREATE", idempotencyKey); + requireCustomer(tenantId); Long replay = reserve(tenantId, operatorId, "RECEIPT_CREATE", idempotencyKey, request); + if (replay != null) return jdbc.queryForMap("SELECT * FROM fin_receipt WHERE id=? AND tenant_id=?", replay, tenantId); + validateVoucherFiles(tenantId, request.voucherFileIds()); if (request.allocations() == null || request.allocations().isEmpty()) throw bad("收款至少需要分配一张账单"); if (request.allocations().stream().anyMatch(a -> a == null || a.amount() == null || a.amount().signum() <= 0)) { throw bad("分配金额必须大于0"); } + long distinctBills = request.allocations().stream().map(ReceiptAllocation::billId).distinct().count(); + if (distinctBills != request.allocations().size()) throw bad("同一收款中账单ID不能重复"); Map byBill = request.allocations().stream().collect( - java.util.stream.Collectors.toMap(ReceiptAllocation::billId, ReceiptAllocation::amount, BigDecimal::add)); + java.util.stream.Collectors.toMap(ReceiptAllocation::billId, ReceiptAllocation::amount)); BigDecimal total = byBill.values().stream().reduce(BigDecimal.ZERO, BigDecimal::add); if (total.signum() <= 0) throw bad("收款金额必须大于0"); for (var entry : byBill.entrySet()) { @@ -161,25 +186,111 @@ public class FinanceService { return jdbc.queryForMap("SELECT * FROM fin_receipt WHERE id=?", receiptId); } + private void validateVoucherFiles(long tenantId, List fileIds) { + if (fileIds == null || fileIds.isEmpty()) throw bad("收款凭证不能为空"); + if (fileClient == null) return; + for (String raw : fileIds) { + long id; try { id = Long.parseLong(raw); } catch (Exception ex) { throw bad("凭证文件ID无效"); } + var response = fileClient.metadata(fileToken, tenantId, tenantId, id); + var metadata = response == null ? null : response.data(); + if (metadata == null || metadata.fileId() == null || !tenantIdEquals(metadata.tenantId(), tenantId) + || !"FINANCE_VOUCHER".equals(metadata.businessType()) || metadata.contentType() == null + || !metadata.contentType().toLowerCase(java.util.Locale.ROOT).startsWith("image/")) { + throw bad("凭证文件不存在、归属不匹配或不是有效图片"); + } + } + } + private static boolean tenantIdEquals(Long actual, long expected) { return actual != null && actual == expected; } + @Transactional public Map createCost(long tenantId, long operatorId, CostRequest request, String idempotencyKey) { - requireCustomer(tenantId); reserve(tenantId, operatorId, "COST_CREATE", idempotencyKey); + return createCost(tenantId, operatorId, request, List.of(), idempotencyKey); + } + + /** + * Creates a cost and, for a container-wide cost, persists the immutable + * volume snapshot used to allocate it. The snapshot is supplied by the + * Firstleg adapter because Finance must not join the Firstleg database. + */ + @Transactional + public Map createCost(long tenantId, long operatorId, CostRequest request, + List containerOrders, String idempotencyKey) { + requireCustomer(tenantId); Long replay = reserve(tenantId, operatorId, "COST_CREATE", idempotencyKey, request); + if (replay != null) return jdbc.queryForMap("SELECT * FROM fin_cost WHERE id=? AND tenant_id=?", replay, tenantId); if (!"SINGLE_ORDER".equals(request.costType()) && !"CONTAINER_SHARED".equals(request.costType())) throw bad("成本类型只能是SINGLE_ORDER或CONTAINER_SHARED"); if (request.amount() == null || request.amount().signum() <= 0) throw bad("成本金额必须大于0"); + if ("SINGLE_ORDER".equals(request.costType()) && (request.orderId() == null || request.containerId() != null)) throw bad("单票成本必须指定订单且不能指定柜次"); + if ("CONTAINER_SHARED".equals(request.costType()) && (request.containerId() == null || containerOrders == null || containerOrders.isEmpty())) throw bad("整柜成本必须指定柜次及柜内有效订单体积"); + validateContainerOrders(tenantId, request.containerId(), containerOrders); KeyHolder holder = new GeneratedKeyHolder(); jdbc.update(connection -> { PreparedStatement ps = connection.prepareStatement("INSERT INTO fin_cost(tenant_id,cost_no,cost_type,order_id,inbound_id,container_id,supplier_id,supplier_name_snapshot,fee_item_code,fee_item_name,cost_amount,voucher_file_ids,remark,created_by,updated_by) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", Statement.RETURN_GENERATED_KEYS); ps.setLong(1, tenantId); ps.setString(2, temporaryNo("COST")); ps.setString(3, request.costType()); ps.setObject(4, request.orderId()); ps.setObject(5, request.inboundId()); ps.setObject(6, request.containerId()); ps.setObject(7, request.supplierId()); ps.setString(8, request.supplierName()); ps.setString(9, request.feeItemCode()); ps.setString(10, request.feeItemName()); ps.setBigDecimal(11, request.amount()); ps.setString(12, json(request.voucherFileIds())); ps.setString(13, request.remark()); ps.setLong(14, operatorId); ps.setLong(15, operatorId); return ps; }, holder); long costId = holder.getKey().longValue(); jdbc.update("UPDATE fin_cost SET cost_no=? WHERE id=?", stableNo("COST", costId), costId); + if ("CONTAINER_SHARED".equals(request.costType())) { + persistContainerAllocations(tenantId, operatorId, costId, request.amount(), containerOrders); + } complete(tenantId, operatorId, "COST_CREATE", idempotencyKey, costId); return jdbc.queryForMap("SELECT * FROM fin_cost WHERE id=?", costId); } - private void reserve(long tenantId, long operatorId, String operation, String key) { + private void validateContainerOrders(long tenantId, Long containerId, List rows) { + if (rows == null) return; + java.util.Set seen = new java.util.HashSet<>(); + for (ContainerOrderVolume row : rows) { + if (row == null || row.orderId() <= 0 || row.containerId() != containerId || row.customerId() != tenantId + || row.volumeCbm() == null || row.volumeCbm().signum() <= 0 || !seen.add(row.orderId())) { + throw bad("柜次、订单、客户归属或体积无效"); + } + } + } + + private void persistContainerAllocations(long tenantId, long operatorId, long costId, BigDecimal amount, + List rows) { + for (CostAllocation allocation : allocateContainerCost(amount, rows)) { + ContainerOrderVolume row = allocation.order(); + jdbc.update("INSERT INTO fin_cost_allocation(tenant_id,cost_id,order_id,inbound_id,volume_cbm,total_volume_cbm,allocation_rate,allocated_amount,rounding_adjustment) VALUES(?,?,?,?,?,?,?,?,?)", + tenantId, costId, row.orderId(), row.inboundId(), row.volumeCbm(), allocation.totalVolumeCbm(), + allocation.rate(), allocation.amount(), allocation.roundingAdjustment() ? 1 : 0); + if (allocation.roundingAdjustment()) { + jdbc.update("INSERT INTO fin_audit_log(tenant_id,resource_type,resource_id,action,reason,before_data,after_data,operator_id,operator_name_snapshot) VALUES(?,?,?,?,?,?,?,?,?)", + tenantId, "FIN_COST_ALLOCATION", costId, "ROUNDING_ADJUSTMENT", "尾差归集到最后一票", null, + json(Map.of("orderId", row.orderId(), "allocatedAmount", allocation.amount(), "totalAmount", amount)), operatorId, "operator-" + operatorId); + } + } + } + + public static List allocateContainerCost(BigDecimal amount, List rows) { + if (amount == null || amount.signum() <= 0 || rows == null || rows.isEmpty()) throw bad("分摊金额和订单不能为空"); + BigDecimal totalVolume = rows.stream().map(ContainerOrderVolume::volumeCbm) + .reduce(BigDecimal.ZERO, BigDecimal::add); + if (totalVolume.signum() <= 0) throw bad("柜内有效订单总体积必须大于0"); + java.util.ArrayList result = new java.util.ArrayList<>(rows.size()); + BigDecimal allocated = BigDecimal.ZERO; + for (int i = 0; i < rows.size(); i++) { + ContainerOrderVolume row = rows.get(i); + boolean tail = i == rows.size() - 1; + BigDecimal rate = row.volumeCbm().divide(totalVolume, 10, java.math.RoundingMode.HALF_UP); + BigDecimal share = tail ? amount.subtract(allocated).setScale(6, java.math.RoundingMode.HALF_UP) + : amount.multiply(rate).setScale(6, java.math.RoundingMode.HALF_UP); + allocated = allocated.add(share); + result.add(new CostAllocation(row, totalVolume, rate, share, tail)); + } + return List.copyOf(result); + } + + private Long reserve(long tenantId, long operatorId, String operation, String key, Object request) { if (!StringUtils.hasText(key) || key.length() > 128) throw bad("必须提供有效的 Idempotency-Key"); - Integer count = jdbc.queryForObject("SELECT COUNT(*) FROM fin_idempotency_record WHERE tenant_id=? AND operator_id=? AND operation=? AND idempotency_key=?", Integer.class, tenantId, operatorId, operation, key); - if (count != null && count > 0) throw new BizException(ResultCode.CONFLICT, "相同请求已提交"); - jdbc.update("INSERT INTO fin_idempotency_record(tenant_id,operator_id,operation,idempotency_key,request_hash,status) VALUES(?,?,?,?,SHA2(?,256),'PROCESSING')", tenantId, operatorId, operation, key, key); + String hash = hash(request); + List> rows = jdbc.queryForList("SELECT request_hash,status,result_resource_id FROM fin_idempotency_record WHERE tenant_id=? AND operator_id=? AND operation=? AND idempotency_key=?", tenantId, operatorId, operation, key); + if (!rows.isEmpty()) { + Map row = rows.get(0); + if (!hash.equals(row.get("request_hash"))) throw new BizException(ResultCode.CONFLICT, "Idempotency-Key已用于其他请求"); + if ("SUCCEEDED".equals(row.get("status")) && row.get("result_resource_id") != null) return ((Number) row.get("result_resource_id")).longValue(); + throw new BizException(ResultCode.CONFLICT, "相同请求正在处理中"); + } + jdbc.update("INSERT INTO fin_idempotency_record(tenant_id,operator_id,operation,idempotency_key,request_hash,status) VALUES(?,?,?,?,?,'PROCESSING')", tenantId, operatorId, operation, key, hash); + return null; } private void complete(long tenantId, long operatorId, String operation, String key, long resourceId) { @@ -187,6 +298,7 @@ public class FinanceService { } private static String temporaryNo(String prefix) { return prefix + "-PENDING-" + java.util.UUID.randomUUID(); } private static String stableNo(String prefix, long id) { return prefix + "-" + String.format(java.util.Locale.ROOT, "%020d", id); } + private String hash(Object request) { try { byte[] bytes = java.security.MessageDigest.getInstance("SHA-256").digest(json(request).getBytes(java.nio.charset.StandardCharsets.UTF_8)); return java.util.HexFormat.of().formatHex(bytes); } catch (Exception ex) { throw new IllegalStateException("请求哈希失败", ex); } } private static void requireCustomer(long tenantId) { if (tenantId <= 0) throw bad("客户范围无效"); } private static BizException bad(String message) { return new BizException(ResultCode.BAD_REQUEST, message); } @@ -194,7 +306,7 @@ public class FinanceService { @Transactional public Map updateBill(long operatorId, long billId, long requestedCustomerId, LocalDate billDate, List lines, int expectedVersion, String key) { - Long tenantId = billTenant(billId); if (tenantId != requestedCustomerId) throw bad("账单不属于指定客户"); reserve(tenantId, operatorId, "BILL_UPDATE", key); + Long tenantId = billTenant(billId); if (tenantId != requestedCustomerId) throw bad("账单不属于指定客户"); Long replay = reserve(tenantId, operatorId, "BILL_UPDATE", key, Map.of("billId",billId,"customerId",requestedCustomerId,"billDate",String.valueOf(billDate),"expectedVersion",expectedVersion,"lines",String.valueOf(lines))); if (replay != null) return bill(tenantId,replay); replaceDraftLines(tenantId, billId, lines); BigDecimal total = lineTotal(lines); LocalDate effectiveDate = billDate == null ? LocalDate.now() : billDate; @@ -208,15 +320,15 @@ public class FinanceService { return bill(tenantId,billId); } @Transactional - public Map adjustBill(long operatorId,long billId,List lines,int expectedVersion,String reason,String key){ Long tenantId=billTenant(billId); reserve(tenantId,operatorId,"BILL_ADJUST",key); if (lines == null || lines.isEmpty()) throw bad("调整后至少需要一条费用明细"); Map current = bill(tenantId,billId); snapshot(tenantId,billId,expectedVersion,reason,current,operatorId); BigDecimal total = lineTotal(lines); BigDecimal received = new BigDecimal(current.get("received_amount").toString()); if (total.compareTo(received) < 0) throw bad("调整金额不能低于已收金额"); BigDecimal outstanding=total.subtract(received); String status=outstanding.signum()==0 ? "PAID" : (received.signum()==0 ? "ISSUED" : "PARTIALLY_PAID"); int c=jdbc.update("UPDATE fin_bill SET remark=?,total_amount=?,outstanding_amount=?,status=?,version=version+1,updated_by=? WHERE id=? AND tenant_id=? AND status IN ('ISSUED','PARTIALLY_PAID','PAID') AND version=?",reason,total,outstanding,status,operatorId,billId,tenantId,expectedVersion); if(c!=1) throw bad("账单状态或版本不允许调整"); jdbc.update("DELETE FROM fin_bill_line WHERE tenant_id=? AND bill_id=?",tenantId,billId); insertLines(tenantId,billId,lines); complete(tenantId,operatorId,"BILL_ADJUST",key,billId); return bill(tenantId,billId); } + public Map adjustBill(long operatorId,long billId,List lines,int expectedVersion,String reason,String key){ Long tenantId=billTenant(billId); Long replay=reserve(tenantId,operatorId,"BILL_ADJUST",key,Map.of("billId",billId,"expectedVersion",expectedVersion,"reason",reason,"lines",lines)); if(replay!=null) return bill(tenantId,replay); if (lines == null || lines.isEmpty()) throw bad("调整后至少需要一条费用明细"); Map current = bill(tenantId,billId); snapshot(tenantId,billId,expectedVersion,reason,current,operatorId); BigDecimal total = lineTotal(lines); BigDecimal received = new BigDecimal(current.get("received_amount").toString()); if (total.compareTo(received) < 0) throw bad("调整金额不能低于已收金额"); BigDecimal outstanding=total.subtract(received); String status=outstanding.signum()==0 ? "PAID" : (received.signum()==0 ? "ISSUED" : "PARTIALLY_PAID"); int c=jdbc.update("UPDATE fin_bill SET remark=?,total_amount=?,outstanding_amount=?,status=?,version=version+1,updated_by=? WHERE id=? AND tenant_id=? AND status IN ('ISSUED','PARTIALLY_PAID','PAID') AND version=?",reason,total,outstanding,status,operatorId,billId,tenantId,expectedVersion); if(c!=1) throw bad("账单状态或版本不允许调整"); jdbc.update("DELETE FROM fin_bill_line WHERE tenant_id=? AND bill_id=?",tenantId,billId); insertLines(tenantId,billId,lines); complete(tenantId,operatorId,"BILL_ADJUST",key,billId); return bill(tenantId,billId); } @Transactional - public Map voidBill(long operatorId,long billId,int expectedVersion,String reason,String key){ Long tenantId=billTenant(billId); reserve(tenantId,operatorId,"BILL_VOID",key); if (!StringUtils.hasText(reason)) throw bad("作废原因不能为空"); int c=jdbc.update("UPDATE fin_bill SET status='VOIDED',voided_at=?,voided_by=?,void_reason=?,updated_by=?,version=version+1 WHERE id=? AND tenant_id=? AND status IN ('DRAFT','ISSUED') AND received_amount=0 AND version=?",LocalDateTime.now(),operatorId,reason.trim(),operatorId,billId,tenantId,expectedVersion); if(c!=1) throw bad("已有收款、账单状态或版本不允许作废"); complete(tenantId,operatorId,"BILL_VOID",key,billId); return bill(tenantId,billId); } + public Map voidBill(long operatorId,long billId,int expectedVersion,String reason,String key){ Long tenantId=billTenant(billId); Long replay=reserve(tenantId,operatorId,"BILL_VOID",key,Map.of("billId",billId,"expectedVersion",expectedVersion,"reason",reason)); if(replay!=null) return bill(tenantId,replay); if (!StringUtils.hasText(reason)) throw bad("作废原因不能为空"); int c=jdbc.update("UPDATE fin_bill SET status='VOIDED',voided_at=?,voided_by=?,void_reason=?,updated_by=?,version=version+1 WHERE id=? AND tenant_id=? AND status IN ('DRAFT','ISSUED') AND received_amount=0 AND version=?",LocalDateTime.now(),operatorId,reason.trim(),operatorId,billId,tenantId,expectedVersion); if(c!=1) throw bad("已有收款、账单状态或版本不允许作废"); complete(tenantId,operatorId,"BILL_VOID",key,billId); return bill(tenantId,billId); } private long billTenant(long billId){ Long t=jdbc.queryForObject("SELECT tenant_id FROM fin_bill WHERE id=?",Long.class,billId); if(t==null) throw bad("账单不存在"); return t; } private void snapshot(long tenantId,long billId,int version,String reason,Map bill,long operatorId){ var lines=jdbc.queryForList("SELECT * FROM fin_bill_line WHERE tenant_id=? AND bill_id=? ORDER BY id",tenantId,billId); jdbc.update("INSERT INTO fin_bill_revision(tenant_id,bill_id,version,reason,bill_snapshot,line_snapshot,created_by) VALUES(?,?,?,?,?,?,?)",tenantId,billId,version,reason,json(bill),json(lines),operatorId); } - private BigDecimal lineTotal(List lines) { if (lines == null || lines.isEmpty()) throw bad("账单至少需要一条费用明细"); return lines.stream().map(l -> { if (l.amount() == null || l.amount().signum() < 0) throw bad("费用金额不能为负数"); return l.amount(); }).reduce(BigDecimal.ZERO, BigDecimal::add); } + private BigDecimal lineTotal(List lines) { if (lines == null || lines.isEmpty()) throw bad("账单至少需要一条费用明细"); java.util.Set sources = new java.util.HashSet<>(); return lines.stream().map(l -> { if (l.sourceFeeId() <= 0 || l.sourceFeeVersion() <= 0) throw bad("费用来源版本无效"); if (!sources.add(l.sourceFeeId()+":"+l.sourceFeeVersion())) throw bad("重复费用来源不允许合并"); if (l.amount() == null || l.amount().signum() < 0) throw bad("费用金额不能为负数"); return l.amount(); }).reduce(BigDecimal.ZERO, BigDecimal::add); } private void replaceDraftLines(long tenantId, long billId, List lines) { lineTotal(lines); Map current=bill(tenantId,billId); if (!"DRAFT".equals(current.get("status"))) throw bad("仅草稿账单可修改"); jdbc.update("DELETE FROM fin_bill_line WHERE tenant_id=? AND bill_id=?",tenantId,billId); insertLines(tenantId,billId,lines); } private void insertLines(long tenantId,long billId,List lines) { for (var line: lines) jdbc.update("INSERT INTO fin_bill_line(tenant_id,bill_id,source_type,source_id,source_version,order_id,inbound_id,fee_item_code,fee_item_name,amount) VALUES(?,?,?,?,?,?,?,?,?,?)",tenantId,billId,"FIRSTLEG_FEE",line.sourceFeeId(),line.sourceFeeVersion(),line.orderId(),line.inboundId(),"MANUAL",line.feeItemName(),line.amount()); } @@ -225,4 +337,7 @@ public class FinanceService { public record ReceiptRequest(String receiptNo, long subjectId, LocalDateTime receivedAt, String paymentMethod, String voucherNo, List voucherFileIds, String remark, List allocations) {} public record ReceiptAllocation(long billId, BigDecimal amount) {} public record CostRequest(String costNo, String costType, Long orderId, Long inboundId, Long containerId, Long supplierId, String supplierName, String feeItemCode, String feeItemName, BigDecimal amount, List voucherFileIds, String remark) {} + public record ContainerOrderVolume(long orderId, Long inboundId, long containerId, long customerId, BigDecimal volumeCbm) {} + public record CostAllocation(ContainerOrderVolume order, BigDecimal totalVolumeCbm, BigDecimal rate, + BigDecimal amount, boolean roundingAdjustment) {} } diff --git a/finance/src/test/java/com/baiyue/finance/FinanceContractTest.java b/finance/src/test/java/com/baiyue/finance/FinanceContractTest.java index ca49c0b2d..9810c5bf0 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceContractTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceContractTest.java @@ -18,6 +18,12 @@ class FinanceContractTest { assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS fin_bill")); assertTrue(sql.contains("currency_code CHECK (currency_code = 'CNY')") || sql.contains("currency_code = 'CNY'")); assertTrue(sql.contains("fin_fee_source") && sql.contains("fin_inbox_event")); + assertTrue(sql.contains("fin_bill_revision")); + assertTrue(sql.contains("PROCESSING/SUCCEEDED/FAILED")); + String service = Files.readString(Path.of("src/main/java/com/baiyue/finance/service/FinanceService.java")); + assertTrue(service.contains("status='PROCESSING'") && service.contains("stableNo")); + assertTrue(service.contains("request_hash") && service.contains("result_resource_id") && service.contains("Idempotency-Key已用于其他请求")); + assertTrue(service.contains("status IN ('ISSUED','PARTIALLY_PAID','PAID')")); String unified = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql")); assertTrue(unified.contains("CREATE DATABASE IF NOT EXISTS baiyue_finance")); assertTrue(unified.contains("assert_finance_permissions")); diff --git a/finance/src/test/java/com/baiyue/finance/FinanceServiceValidationTest.java b/finance/src/test/java/com/baiyue/finance/FinanceServiceValidationTest.java index ebd8d7edc..25c5493e0 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceServiceValidationTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceServiceValidationTest.java @@ -9,12 +9,33 @@ import java.math.BigDecimal; import java.util.List; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.*; class FinanceServiceValidationTest { + @Test + void containerCostAllocatesByVolumeAndPutsRoundingOnLastOrder() { + var rows = List.of( + new FinanceService.ContainerOrderVolume(11L, 101L, 7L, 9L, new BigDecimal("1.00")), + new FinanceService.ContainerOrderVolume(12L, 102L, 7L, 9L, new BigDecimal("2.00")), + new FinanceService.ContainerOrderVolume(13L, 103L, 7L, 9L, new BigDecimal("3.00"))); + var allocations = FinanceService.allocateContainerCost(new BigDecimal("100.00"), rows); + assertEquals(new BigDecimal("16.666667"), allocations.get(0).amount()); + assertEquals(new BigDecimal("33.333333"), allocations.get(1).amount()); + assertEquals(new BigDecimal("50.000000"), allocations.get(2).amount()); + assertEquals(new BigDecimal("100.000000"), allocations.stream().map(FinanceService.CostAllocation::amount) + .reduce(BigDecimal.ZERO, BigDecimal::add)); + assertEquals(true, allocations.get(2).roundingAdjustment()); + } + + @Test + void rejectsEmptyContainerVolumeSnapshot() { + assertThrows(RuntimeException.class, () -> FinanceService.allocateContainerCost(BigDecimal.TEN, List.of())); + } + @Test void rejectsUnsupportedCostTypeBeforeWrite() { JdbcTemplate jdbc = mock(JdbcTemplate.class); @@ -32,4 +53,13 @@ class FinanceServiceValidationTest { List.of(new FinanceService.ReceiptAllocation(10L, BigDecimal.ZERO))); assertThrows(RuntimeException.class, () -> service.createReceipt(1L, 2L, request, "k2")); } + + @Test + void rejectsDuplicateBillAllocationInsteadOfMerging() { + JdbcTemplate jdbc = mock(JdbcTemplate.class); + FinanceService service = new FinanceService(jdbc, new ObjectMapper()); + var request = new FinanceService.ReceiptRequest("R-2", 1L, null, "BANK_TRANSFER", null, List.of("f"), null, + List.of(new FinanceService.ReceiptAllocation(10L, BigDecimal.ONE), new FinanceService.ReceiptAllocation(10L, BigDecimal.ONE))); + assertThrows(RuntimeException.class, () -> service.createReceipt(1L, 2L, request, "k3")); + } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/LoadingInboundMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/LoadingInboundMapper.java index 1c7c3cc7c..d0464b48f 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/LoadingInboundMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/LoadingInboundMapper.java @@ -52,19 +52,18 @@ public interface LoadingInboundMapper { @Update(""" UPDATE fl_inbound_order root + LEFT JOIN fl_inbound_order remainder + ON remainder.tenant_id=root.tenant_id AND remainder.parent_inbound_id=root.id + AND remainder.inbound_role='CHILD' AND remainder.split_purpose='REMAINDER' + AND remainder.split_status='NOT_SPLIT' AND remainder.deleted=0 + LEFT JOIN fl_inbound_order loaded + ON loaded.tenant_id=root.tenant_id AND loaded.parent_inbound_id=root.id + AND loaded.inbound_role='CHILD' AND loaded.split_purpose='LOADED' + AND loaded.process_status<>'LOADED' AND loaded.deleted=0 SET root.process_status='LOADED', root.updated_by=#{operatorId}, root.version=root.version+1 WHERE root.tenant_id=#{tenantId} AND root.id=#{rootInboundId} AND root.deleted=0 AND root.inbound_role='MASTER' AND root.process_status IN ('INBOUNDED','LOADING') - AND NOT EXISTS ( - SELECT 1 FROM fl_inbound_order remainder - WHERE remainder.tenant_id=root.tenant_id AND remainder.parent_inbound_id=root.id - AND remainder.inbound_role='CHILD' AND remainder.split_purpose='REMAINDER' - AND remainder.split_status='NOT_SPLIT' AND remainder.deleted=0) - AND NOT EXISTS ( - SELECT 1 FROM fl_inbound_order loaded - WHERE loaded.tenant_id=root.tenant_id AND loaded.parent_inbound_id=root.id - AND loaded.inbound_role='CHILD' AND loaded.split_purpose='LOADED' - AND loaded.process_status<>'LOADED' AND loaded.deleted=0) + AND remainder.id IS NULL AND loaded.id IS NULL """) int aggregateRootLoaded(@Param("tenantId") long tenantId, @Param("rootInboundId") long rootInboundId, diff --git a/firstleg/src/test/java/com/baiyue/firstleg/loading/mapper/LoadingFulfillmentCompletionMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/loading/mapper/LoadingFulfillmentCompletionMapperContractTest.java index e887cb5aa..88b4458fb 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/loading/mapper/LoadingFulfillmentCompletionMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/loading/mapper/LoadingFulfillmentCompletionMapperContractTest.java @@ -28,9 +28,14 @@ class LoadingFulfillmentCompletionMapperContractTest { .contains("target.process_status='LOADED'") .contains("target.version=#{expectedSourceVersion}"); assertThat(aggregateRoot) + .contains("LEFT JOIN fl_inbound_order remainder") + .contains("LEFT JOIN fl_inbound_order loaded") .contains("remainder.split_purpose='REMAINDER'") .contains("remainder.split_status='NOT_SPLIT'") .contains("loaded.split_purpose='LOADED'") - .contains("loaded.process_status<>'LOADED'"); + .contains("loaded.process_status<>'LOADED'") + .contains("remainder.id IS NULL AND loaded.id IS NULL") + .doesNotContain("NOT EXISTS") + .doesNotContain("SELECT 1 FROM fl_inbound_order"); } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/loading/service/LoadingDeliveryServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/loading/service/LoadingDeliveryServiceTest.java index 9bfc67e17..f8266f9aa 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/loading/service/LoadingDeliveryServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/loading/service/LoadingDeliveryServiceTest.java @@ -9,6 +9,7 @@ import com.baiyue.firstleg.loading.dto.LoadingRequests.UpsertEvidenceRequest; import com.baiyue.firstleg.loading.dto.LoadingRequests.VoidEvidenceRequest; import com.baiyue.firstleg.loading.dto.LoadingResponses.LoadingActionResponse; import com.baiyue.firstleg.loading.entity.LoadingEvidenceEntity; +import com.baiyue.firstleg.loading.entity.LoadingOperationEntity; import com.baiyue.firstleg.loading.mapper.LoadingInboundMapper.Snapshot; import com.baiyue.firstleg.loading.mapper.LoadingInboundMapper.CompletionTarget; import com.baiyue.firstleg.loading.bridge.service.LoadingModelBridgeService; @@ -25,6 +26,8 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -118,6 +121,62 @@ class LoadingDeliveryServiceTest { verify(repository, never()).completeLoading(anyLong(), anyLong(), anyLong(), anyInt()); } + @Test + void completesSingleRemainderSourceOnceAndReplaysSameKeyWithoutRepeatingSideEffects() { + AtomicInteger claims = new AtomicInteger(); + AtomicReference requestHash = new AtomicReference<>(); + AtomicReference resultJson = new AtomicReference<>(); + when(repository.claimOperation(eq(10L), eq(20L), eq(7L), eq("COMPLETE_LOADING"), + eq("slice-replay"), anyString())).thenAnswer(invocation -> { + requestHash.set(invocation.getArgument(5)); + return claims.getAndIncrement() == 0; + }); + doAnswer(invocation -> { + resultJson.set(invocation.getArgument(5)); + return null; + }).when(repository).completeOperation(eq(10L), eq(20L), eq(7L), eq("COMPLETE_LOADING"), + eq("slice-replay"), anyString()); + when(repository.findOperation(10L, 20L, 7L, "COMPLETE_LOADING", "slice-replay")) + .thenAnswer(invocation -> successfulOperation(requestHash.get(), resultJson.get())); + when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 5)); + when(repository.completionTarget(10, 20)).thenReturn(new CompletionTarget( + 20L, null, 10L, "LOADING", 5, "LOADING", 5)); + LoadingPickItemMapper.InboundProgress progress = mock(LoadingPickItemMapper.InboundProgress.class); + when(progress.getExpectedCount()).thenReturn(5); + when(progress.getPickedCount()).thenReturn(5); + when(progress.getTaskCount()).thenReturn(1); + when(progress.getCompletedTaskCount()).thenReturn(1); + when(progress.getUnresolvedCount()).thenReturn(0); + when(loadingPicks.inboundProgress(10, 20)).thenReturn(progress); + when(repository.listEvidence(10, 20)).thenReturn(List.of( + evidence("PHOTO_STEP_1", 1L, null), evidence("PHOTO_STEP_2", 2L, null), + evidence("PHOTO_STEP_3", 3L, null), evidence("PHOTO_STEP_4", 4L, null), + evidence("SEAL", 5L, "LOCK-001"))); + when(files.validate(eq("token"), eq(10L), eq(20L), anyLong())).thenAnswer(invocation -> { + long fileId = invocation.getArgument(3); + String type = fileId == 5L ? "SEAL" : "PHOTO_STEP_" + fileId; + return ApiResponse.success(new LoadingEvidenceFileClient.FileMetadata( + fileId, type.toLowerCase() + ".png", "LOADING_" + type, "image/png")); + }); + when(repository.completeLoadedChild(10, 20, 7, 5)).thenReturn(1); + + LoadingActionResponse first = service.completeLoading(10, 20, 7, "admin", "slice-replay", + new CompleteLoadingRequest(5, "完成")); + LoadingActionResponse replay = service.completeLoading(10, 20, 7, "admin", "slice-replay", + new CompleteLoadingRequest(5, "完成")); + + assertThat(first.mainStatus()).isEqualTo("LOADED"); + assertThat(replay).isEqualTo(first); + verify(repository, times(2)).claimOperation(eq(10L), eq(20L), eq(7L), eq("COMPLETE_LOADING"), + eq("slice-replay"), anyString()); + verify(repository).completeLoadedChild(10, 20, 7, 5); + verify(repository).aggregateRootLoaded(10, 10, 7); + verify(loadingModelBridge).requireCompleted(10, 20); + verify(completionGate).requireBookingTransportAndCustoms(10, 20); + verify(histories).insert(any(InboundStatusHistoryEntity.class)); + verify(repository).completeOperation(10L, 20L, 7L, "COMPLETE_LOADING", "slice-replay", resultJson.get()); + } + @Test void keepsHistoricalLoadedChildAsCompletionTarget() { CompletionTarget historical = new CompletionTarget( @@ -241,4 +300,12 @@ class LoadingDeliveryServiceTest { value.setEvidenceType(type); value.setFileId(fileId); value.setSealNo(sealNo); return value; } + + private static LoadingOperationEntity successfulOperation(String requestHash, String resultJson) { + LoadingOperationEntity value = new LoadingOperationEntity(); + value.setRequestHash(requestHash); + value.setStatus("SUCCESS"); + value.setResultJson(resultJson); + return value; + } } diff --git a/scripts/admin-test-env-modules/phase3-container-loading.mjs b/scripts/admin-test-env-modules/phase3-container-loading.mjs index 11af4f7a9..bce4fc891 100644 --- a/scripts/admin-test-env-modules/phase3-container-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-container-loading.mjs @@ -193,20 +193,32 @@ export async function run(context) { && Number(claimed?.assignment?.version) === 0, `workflow=${claimed?.workflowStatus || 'missing'}, assigneeId=${id(claimed?.assignment?.assigneeId) || 'missing'}`, OPTIONS) - const stalePickResponse = await request(context, 'POST', `${PDA}/tasks/${taskId}/items/${id(pickItem.id)}/pick`, { - warehouseId, pickedPackageCount: 1, locationIds: [], version: Number(pickItem.version) + 1, - operationId: `${context.marker}-pda-stale`.slice(0, 128), - }) - context.addStep(MODULE, 'PDA 旧明细版本拣货返回冲突', stalePickResponse, - stalePickResponse.http === 200 && ['A0409', '409'].includes(codeOf(stalePickResponse)), - `HTTP=${stalePickResponse.http}, code=${codeOf(stalePickResponse)}`, OPTIONS) + const batchPath = `${PDA}/tasks/${taskId}/batch` + const batchItems = [{ itemId: id(pickItem.id), action: 'PICK', pickedPackageCount: 1, + finalPickedPackageCount: null, releaseMode: 'MANUAL', locationIds: [], + version: Number(pickItem.version), reason: null }] + const staleBatchResponse = await request(context, 'POST', batchPath, { + warehouseId, containerNo, expectedVersion: Number(task.version) + 1, items: batchItems, + }, { 'Idempotency-Key': `${context.marker}-pda-batch-stale`.slice(0, 128) }) + context.addStep(MODULE, 'PDA batch 旧任务版本返回 A0409', staleBatchResponse, + staleBatchResponse.http === 200 && ['A0409', '409'].includes(codeOf(staleBatchResponse)), + `HTTP=${staleBatchResponse.http}, code=${codeOf(staleBatchResponse)}`, OPTIONS) - const pickPayload = { - warehouseId, pickedPackageCount: 1, locationIds: [], version: Number(pickItem.version), - operationId: `${context.marker}-pda-pick`.slice(0, 128), - } - const pickResponse = await request(context, 'POST', `${PDA}/tasks/${taskId}/items/${id(pickItem.id)}/pick`, pickPayload) - const picked = context.apiSuccess(MODULE, '执行本轮 PDA 全量拣货', pickResponse, '', OPTIONS) + const afterFailedBatchResponse = await context.request('GET', + `${PDA}/tasks?containerNo=${encodeURIComponent(containerNo)}&warehouseId=${encodeURIComponent(warehouseId)}`) + const afterFailedBatch = context.apiSuccess(MODULE, '回查失败 batch 后任务快照', afterFailedBatchResponse, '', OPTIONS) + const afterFailedItem = afterFailedBatch?.items?.find?.((item) => id(item.id) === id(pickItem.id)) + context.assert(MODULE, 'PDA batch 失败后整批未写入', afterFailedBatchResponse, + Number(afterFailedBatch?.version) === Number(task.version) + && Number(afterFailedItem?.version) === Number(pickItem.version) + && Number(afterFailedItem?.pickedPackageCount) === Number(pickItem.pickedPackageCount || 0) + && afterFailedItem?.itemStatus === pickItem.itemStatus, + `taskVersion=${afterFailedBatch?.version ?? 'missing'}, itemVersion=${afterFailedItem?.version ?? 'missing'}, picked=${afterFailedItem?.pickedPackageCount ?? 'missing'}`, OPTIONS) + + const batchPayload = { warehouseId, containerNo, expectedVersion: Number(task.version), items: batchItems } + const batchHeaders = { 'Idempotency-Key': `${context.marker}-pda-batch`.slice(0, 128) } + const pickResponse = await request(context, 'POST', batchPath, batchPayload, batchHeaders) + const picked = context.apiSuccess(MODULE, '单次调用 PDA 原子 batch 完成拣货', pickResponse, '', OPTIONS) const directPdaCompletion = picked?.taskStatus === 'COMPLETED' && picked?.workflowStatus === 'COMPLETED' context.assert(MODULE, '最后一箱拣货后 PDA 任务按已部署契约直接完成', pickResponse, @@ -214,9 +226,9 @@ export async function run(context) { && picked?.items?.every?.((item) => Number(item.pickedPackageCount) === Number(item.plannedPackageCount)), `taskStatus=${picked?.taskStatus || 'missing'}, workflow=${picked?.workflowStatus || 'missing'}`, OPTIONS) - const pickReplayResponse = await request(context, 'POST', `${PDA}/tasks/${taskId}/items/${id(pickItem.id)}/pick`, pickPayload) - const pickReplay = context.apiSuccess(MODULE, 'PDA operationId 幂等重放', pickReplayResponse, '', OPTIONS) - context.assert(MODULE, 'PDA 重放保持完成任务和拣货数', pickReplayResponse, + const pickReplayResponse = await request(context, 'POST', batchPath, batchPayload, batchHeaders) + const pickReplay = context.apiSuccess(MODULE, 'PDA batch Idempotency-Key 幂等重放', pickReplayResponse, '', OPTIONS) + context.assert(MODULE, 'PDA batch 重放保持完成任务和拣货数', pickReplayResponse, pickReplay?.taskStatus === 'COMPLETED' && pickReplay?.workflowStatus === 'COMPLETED' && pickReplay?.items?.every?.((item) => Number(item.pickedPackageCount) === Number(item.plannedPackageCount)), `taskStatus=${pickReplay?.taskStatus || 'missing'}`, OPTIONS) diff --git a/scripts/admin-test-env-modules/phase3-customs.mjs b/scripts/admin-test-env-modules/phase3-customs.mjs index 514cb326d..85d4f0c2c 100644 --- a/scripts/admin-test-env-modules/phase3-customs.mjs +++ b/scripts/admin-test-env-modules/phase3-customs.mjs @@ -88,23 +88,27 @@ export async function run(context) { const slicesResponse = await context.request('GET', slicesPath) const slices = context.apiSuccess(MODULE, '读取柜次真实履约分片以确定报关订单', slicesResponse, `containerBookingId=${containerBookingId}, tenantId=${tenantId}`, OPTIONS) - const loadedSlices = Array.isArray(slices) + const carryingSlices = Array.isArray(slices) ? slices.filter((item) => id(item?.tenantId) === tenantId && id(item?.containerBookingId) === containerBookingId && id(item?.parentOrderId) === parentOrderId && item?.splitRole === 'LOADED' && (!loadingPlanId || id(item?.loadingPlanId) === loadingPlanId)) : [] - const loadedSlice = loadedSlices.length === 1 ? loadedSlices[0] : null - const orderId = id(loadedSlice?.childOrderId) - context.assert(MODULE, '报关绑定柜次实际承载的唯一 LOADED 子订单', slicesResponse, - loadedSlices.length === 1 && Boolean(orderId) && orderId !== parentOrderId - && id(loadedSlice?.parentOrderId) === parentOrderId - && id(loadedSlice?.containerBookingId) === containerBookingId - && id(loadedSlice?.tenantId) === tenantId - && (!loadingPlanId || id(loadedSlice?.loadingPlanId) === loadingPlanId), - `parentOrderId=${parentOrderId}, childOrderId=${orderId || 'missing'}, containerBookingId=${containerBookingId}, loadingPlanId=${id(loadedSlice?.loadingPlanId) || 'missing'}, matches=${loadedSlices.length}`, OPTIONS) - if (!orderId || loadedSlices.length !== 1 || orderId === parentOrderId) return + const carryingSlice = carryingSlices.length === 1 ? carryingSlices[0] : null + const actualOrderId = id(carryingSlice?.childOrderId) + const sourceOrderId = id(carryingSlice?.sourceOrderId) + const sourceCarriesLoaded = Boolean(actualOrderId) && actualOrderId === sourceOrderId + context.assert(MODULE, '报关绑定柜次实际承载的唯一 LOADED 来源分片', slicesResponse, + carryingSlices.length === 1 && sourceCarriesLoaded + && id(carryingSlice?.parentOrderId) === parentOrderId + && id(carryingSlice?.containerBookingId) === containerBookingId + && id(carryingSlice?.tenantId) === tenantId + && Number.isInteger(Number(carryingSlice?.packageCount)) && Number(carryingSlice.packageCount) > 0 + && (!loadingPlanId || id(carryingSlice?.loadingPlanId) === loadingPlanId), + `parentOrderId=${parentOrderId}, sourceOrderId=${sourceOrderId || 'missing'}, actualOrderId=${actualOrderId || 'missing'}, packageCount=${carryingSlice?.packageCount ?? 'missing'}, sourceCarriesLoaded=${sourceCarriesLoaded}, containerBookingId=${containerBookingId}, loadingPlanId=${id(carryingSlice?.loadingPlanId) || 'missing'}, matches=${carryingSlices.length}`, OPTIONS) + if (carryingSlices.length !== 1 || !sourceCarriesLoaded + || !Number.isInteger(Number(carryingSlice?.packageCount)) || Number(carryingSlice.packageCount) <= 0) return const loginResponse = await request(context, 'POST', '/api/auth/login', { username: MERCHANT_USERNAME, password: MERCHANT_PASSWORD, @@ -131,18 +135,18 @@ export async function run(context) { `checklistVersionId=${checklistVersionId || 'missing'}, items=${checklist?.items?.length ?? 'n/a'}`, OPTIONS) if (!checklistVersionId) return - const casePayload = { customerId: tenantId, orderId, inboundId, containerBookingId, + const casePayload = { customerId: tenantId, orderId: actualOrderId, inboundId, containerBookingId, checklistVersionId, remark: context.marker, expectedVersion: 0 } const caseResponse = await request(context, 'POST', `${PLATFORM}/cases`, casePayload, context.token, { 'Idempotency-Key': key(context, 'case') }) const created = context.apiSuccess(MODULE, '创建客户订单柜次关联报关协同单', caseResponse, '', OPTIONS) const caseId = id(created?.customsCase?.id) - context.assert(MODULE, '报关单绑定本轮 LOADED 子订单、入库单、柜次和清单版本', caseResponse, - Boolean(caseId) && id(created?.customsCase?.tenantId) === tenantId && id(created?.customsCase?.orderId) === orderId + context.assert(MODULE, '报关单绑定本轮实际承载来源分片、入库单、柜次和清单版本', caseResponse, + Boolean(caseId) && id(created?.customsCase?.tenantId) === tenantId && id(created?.customsCase?.orderId) === actualOrderId && id(created?.customsCase?.inboundId) === inboundId && id(created?.customsCase?.containerBookingId) === containerBookingId && id(created?.customsCase?.checklistVersionId) === checklistVersionId && created?.customsCase?.status === 'DOCUMENTS_PENDING' && Number(created?.customsCase?.version) === 0, - `caseId=${caseId || 'missing'}, parentOrderId=${parentOrderId}, childOrderId=${orderId}, status=${created?.customsCase?.status || 'missing'}`, OPTIONS) + `caseId=${caseId || 'missing'}, parentOrderId=${parentOrderId}, sourceOrderId=${sourceOrderId}, actualOrderId=${actualOrderId}, status=${created?.customsCase?.status || 'missing'}`, OPTIONS) if (!caseId) return const caseReplay = await request(context, 'POST', `${PLATFORM}/cases`, casePayload, context.token, @@ -305,7 +309,7 @@ export async function run(context) { `HTTP=${forgedReference.http}, code=${codeOf(forgedReference)}`, OPTIONS) context.createdResources.phase3Customs = { - marker: context.marker, tenantId, parentOrderId, orderId, inboundId, containerBookingId, loadingPlanId, + marker: context.marker, tenantId, parentOrderId, orderId: actualOrderId, inboundId, containerBookingId, loadingPlanId, customsCaseId: caseId, submissionBatchId: batchId, releasedReceiptId: releasedId, finalStatus: 'CLEARANCE_COMPLETED', } diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index c8e2c363e..928819c52 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -1,5 +1,7 @@ const MODULE = 'phase3-loading' const OPTIONS = { scenario: 'phase3-split-preload-loading', deploymentBlocker: true } +const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || 'merchant_admin' +const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || 'ChangeMe123!' function id(value) { return value == null ? '' : String(value) } function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) } @@ -227,20 +229,102 @@ export async function run(context) { claimed?.workflowStatus === 'IN_PROGRESS' && Boolean(claimed?.assignment?.assigneeId), `workflow=${claimed?.workflowStatus || 'missing'}`, OPTIONS) - // P3-Q11 uses task-level assignment as the single-operator guard; no item claim API exists. + const batchPath = `${pdaBase}/tasks/${canonicalTaskId}/batch` + const batchKey = `${context.marker}-bridge-batch`.slice(0, 128) + const batchPayload = { + warehouseId, containerNo, + expectedVersion: Number(claimed?.version), + items: [{ + itemId: id(canonicalItem.id), action: 'PICK', pickedPackageCount: 1, + releaseMode: 'MANUAL', locationIds: [], version: Number(canonicalItem.version), + }], + } - const canonicalPick = await request(context, 'POST', - `${pdaBase}/tasks/${canonicalTaskId}/items/${id(canonicalItem.id)}/pick`, { - warehouseId, pickedPackageCount: 1, locationIds: [], version: Number(canonicalItem.version), - operationId: `${context.marker}-bridge-pick`.slice(0, 128), + const staleBatch = await request(context, 'POST', batchPath, + { ...batchPayload, expectedVersion: Number(batchPayload.expectedVersion) + 1 }, + { 'Idempotency-Key': `${context.marker}-bridge-batch-stale`.slice(0, 128) }) + context.addStep(MODULE, 'PDA 原子批次旧任务版本返回冲突', staleBatch, + staleBatch.http === 200 && codeOf(staleBatch) === 'A0409', + `HTTP=${staleBatch.http}, code=${codeOf(staleBatch)}`, OPTIONS) + + const invalidBatch = await request(context, 'POST', batchPath, { + ...batchPayload, + items: [...batchPayload.items, { + itemId: '999999999999999999', action: 'SKIP', version: 0, reason: 'forged-item-must-rollback', + }], + }, { 'Idempotency-Key': `${context.marker}-bridge-batch-rollback`.slice(0, 128) }) + context.addStep(MODULE, 'PDA 原子批次任一非法明细时整批拒绝', invalidBatch, + invalidBatch.http === 200 && codeOf(invalidBatch) === 'A0409', + `HTTP=${invalidBatch.http}, code=${codeOf(invalidBatch)}`, OPTIONS) + const unchangedAfterInvalidResponse = await context.request('GET', + `${pdaBase}/tasks?containerNo=${encodeURIComponent(containerNo)}&warehouseId=${encodeURIComponent(warehouseId)}`) + const unchangedAfterInvalid = context.apiSuccess(MODULE, '回查失败批次后的 PDA 任务快照', unchangedAfterInvalidResponse, '', OPTIONS) + const unchangedItem = unchangedAfterInvalid?.items?.find?.((item) => id(item.id) === id(canonicalItem.id)) + context.assert(MODULE, '失败批次整批回滚且无明细部分写入', unchangedAfterInvalidResponse, + unchangedAfterInvalid?.taskStatus === 'LOADING' + && Number(unchangedAfterInvalid?.version) === Number(batchPayload.expectedVersion) + && unchangedItem?.itemStatus === 'PENDING' && Number(unchangedItem?.pickedPackageCount) === 0 + && Number(unchangedItem?.version) === Number(canonicalItem.version), + `taskVersion=${unchangedAfterInvalid?.version ?? 'missing'}, itemStatus=${unchangedItem?.itemStatus || 'missing'}, picked=${unchangedItem?.pickedPackageCount ?? 'missing'}`, OPTIONS) + + const merchantLogin = await context.request('POST', '/api/auth/login', { + username: MERCHANT_USERNAME, password: MERCHANT_PASSWORD, + }, '') + const merchantToken = merchantLogin.http === 200 && codeOf(merchantLogin) === '200' + ? merchantLogin.body?.data?.accessToken : null + context.addStep(MODULE, '取得客户侧隔离令牌用于 PDA 批次越权验证', merchantLogin, + Boolean(merchantToken), `username=${MERCHANT_USERNAME}`, OPTIONS) + const customerIsolatedBatch = merchantToken + ? await request(context, 'POST', batchPath, batchPayload, { + Authorization: `Bearer ${merchantToken}`, + 'Idempotency-Key': `${context.marker}-bridge-batch-customer`.slice(0, 128), }) - const pickedCanonical = context.apiSuccess(MODULE, '执行柜次 PDA 明细拣货', canonicalPick, '', OPTIONS) + : merchantLogin + context.addStep(MODULE, '客户身份无权提交平台 PDA 原子批次', customerIsolatedBatch, + customerIsolatedBatch.http === 403 && codeOf(customerIsolatedBatch) === 'A0403' + && Boolean(customerIsolatedBatch.requestId) && Boolean(customerIsolatedBatch.traceId), + `HTTP=${customerIsolatedBatch.http}, code=${codeOf(customerIsolatedBatch)}, requestId=${customerIsolatedBatch.requestId || 'missing'}, traceId=${customerIsolatedBatch.traceId || 'missing'}`, OPTIONS) + + const currentAssigneeId = Number(claimed?.assignment?.assigneeId) + const reassigned = await request(context, 'POST', `${pdaBase}/tasks/${canonicalTaskId}/assign`, { + assigneeId: currentAssigneeId + 1000000, assigneeName: `${context.marker}-other-picker`, + expectedAssignmentVersion: Number(claimed?.assignment?.version), + operationId: `${context.marker}-bridge-assign-other`.slice(0, 128), + }) + const otherAssignment = context.apiSuccess(MODULE, '将 PDA 任务改派给另一作业员', reassigned, '', OPTIONS) + const operatorIsolatedBatch = await request(context, 'POST', batchPath, batchPayload, + { 'Idempotency-Key': `${context.marker}-bridge-batch-other-operator`.slice(0, 128) }) + context.addStep(MODULE, '非任务作业员提交 PDA 原子批次返回冲突', operatorIsolatedBatch, + operatorIsolatedBatch.http === 200 && codeOf(operatorIsolatedBatch) === 'A0409', + `HTTP=${operatorIsolatedBatch.http}, code=${codeOf(operatorIsolatedBatch)}`, OPTIONS) + const restoredAssignmentResponse = await request(context, 'POST', `${pdaBase}/tasks/${canonicalTaskId}/assign`, { + assigneeId: currentAssigneeId, assigneeName: claimed?.assignment?.assigneeName || 'admin', + expectedAssignmentVersion: Number(otherAssignment?.assignment?.version), + operationId: `${context.marker}-bridge-assign-restore`.slice(0, 128), + }) + const restoredAssignment = context.apiSuccess(MODULE, '恢复 PDA 任务到当前作业员', restoredAssignmentResponse, '', OPTIONS) + context.assert(MODULE, '作业员隔离验证后任务归属恢复', restoredAssignmentResponse, + id(restoredAssignment?.assignment?.assigneeId) === id(currentAssigneeId), + `assigneeId=${id(restoredAssignment?.assignment?.assigneeId) || 'missing'}`, OPTIONS) + + const canonicalPick = await request(context, 'POST', batchPath, batchPayload, + { 'Idempotency-Key': batchKey }) + const pickedCanonical = context.apiSuccess(MODULE, '原子提交柜次 PDA 整批拣货', canonicalPick, '', OPTIONS) const directPdaCompletion = pickedCanonical?.taskStatus === 'COMPLETED' && pickedCanonical?.workflowStatus === 'COMPLETED' - context.assert(MODULE, '柜次 PDA 明细完成后按已部署契约推进任务终态', canonicalPick, - directPdaCompletion, + context.assert(MODULE, 'PDA 原子批次成功并推进任务终态', canonicalPick, + directPdaCompletion && pickedCanonical?.items?.every?.((item) => item.itemStatus !== 'PENDING'), `taskStatus=${pickedCanonical?.taskStatus || 'missing'}, workflow=${pickedCanonical?.workflowStatus || 'missing'}`, OPTIONS) + const canonicalBatchReplay = await request(context, 'POST', batchPath, batchPayload, + { 'Idempotency-Key': batchKey }) + context.assert(MODULE, 'PDA 原子批次同参同键重放返回同一终态', canonicalBatchReplay, + canonicalBatchReplay.http === 200 && codeOf(canonicalBatchReplay) === '200' + && id(canonicalBatchReplay.body?.data?.id) === canonicalTaskId + && canonicalBatchReplay.body?.data?.taskStatus === 'COMPLETED' + && Number(canonicalBatchReplay.body?.data?.version) === Number(pickedCanonical?.version), + `HTTP=${canonicalBatchReplay.http}, code=${codeOf(canonicalBatchReplay)}, taskVersion=${canonicalBatchReplay.body?.data?.version ?? 'missing'}`, OPTIONS) + // The deployed settlement contract completes a one-item task during pick. A // review call is therefore a negative compatibility assertion, never a second // positive action that can poison the marker fixture with A0409. diff --git a/scripts/admin-test-env-modules/phase3-pda-convergence.mjs b/scripts/admin-test-env-modules/phase3-pda-convergence.mjs index eb19bfe69..2b8b6ae93 100644 --- a/scripts/admin-test-env-modules/phase3-pda-convergence.mjs +++ b/scripts/admin-test-env-modules/phase3-pda-convergence.mjs @@ -56,13 +56,10 @@ export async function run(context) { context.addStep(MODULE, '入库兼容入口拒绝客户端伪造汇总数量', forged, failure(forged), `HTTP=${forged.http}, code=${codeOf(forged)}`, OPTIONS) - context.addCoverageGap(MODULE, OPTIONS.scenario, 'PDA 批量提交、设备草稿版本与批次幂等', - '当前 PlatformLoadingPdaController 仅公开单明细 pick/partial/skip 与任务 review,没有批量提交或设备草稿 DTO/OpenAPI', - 'contract-gap') context.addCoverageGap(MODULE, OPTIONS.scenario, 'PDA 实时库存查询与提交前库存快照校验', '当前 PDA Controller/OpenAPI 未公开实时库存查询或库存版本快照端点,不能用智能装柜候选列表伪装库存契约', 'contract-gap') - context.addCoverageGap(MODULE, OPTIONS.scenario, '不同仓库作业员竞争同一 PDA 明细', - '单明细领取和版本 CAS 已自动化;当前总控没有第二个具备 warehouse PDA work 权限的隔离账号 fixture,不能伪造跨操作者冲突', - 'fixture') + context.addCoverageGap(MODULE, OPTIONS.scenario, 'PDA Android/HBuilderX 真机完整闭环', + '原子 batch 真实 HTTP 契约已自动化;扫码、弱网恢复、在线草稿和设备交互仍需 Android/HBuilderX 真机验收', + 'device') }