优化
This commit is contained in:
@@ -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`。
|
||||
|
||||
## 历史一期回归脚本
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import java.time.LocalDateTime;
|
||||
@Service
|
||||
public class InternalBusinessFileService {
|
||||
private static final java.util.Set<String> 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));}
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-jdbc</artifactId></dependency>
|
||||
<dependency><groupId>com.mysql</groupId><artifactId>mysql-connector-j</artifactId><scope>runtime</scope></dependency>
|
||||
<dependency><groupId>com.alibaba.cloud</groupId><artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId></dependency>
|
||||
<dependency><groupId>org.springframework.cloud</groupId><artifactId>spring-cloud-starter-openfeign</artifactId></dependency>
|
||||
<dependency><groupId>com.alibaba.cloud</groupId><artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId></dependency>
|
||||
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-actuator</artifactId></dependency>
|
||||
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<Metadata> 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> 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) {}
|
||||
}
|
||||
@@ -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<Map<String, Object>> bill(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long billId) { return ApiResponse.success(service.bill(user.tenantId(), billId)); }
|
||||
public ApiResponse<Map<String, Object>> bill(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long billId) { return ApiResponse.success(service.clientBill(user.tenantId(), billId)); }
|
||||
|
||||
@GetMapping("/receipts")
|
||||
@PreAuthorize("hasAuthority('merchant:finance:statement:view')")
|
||||
|
||||
@@ -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<FinanceFileClient.Metadata> 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')")
|
||||
|
||||
@@ -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<Map<String, Object>> 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<String, Object> bill(long tenantId, long billId) {
|
||||
requireCustomer(tenantId);
|
||||
Map<String, Object> 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<String, Object> clientBill(long tenantId, long billId) {
|
||||
requireCustomer(tenantId);
|
||||
Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<Long, BigDecimal> 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<String> 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<String, Object> 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<String, Object> createCost(long tenantId, long operatorId, CostRequest request,
|
||||
List<ContainerOrderVolume> 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<ContainerOrderVolume> rows) {
|
||||
if (rows == null) return;
|
||||
java.util.Set<Long> 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<ContainerOrderVolume> 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<CostAllocation> allocateContainerCost(BigDecimal amount, List<ContainerOrderVolume> 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<CostAllocation> 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<Map<String,Object>> 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<String,Object> 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<String,Object> updateBill(long operatorId, long billId, long requestedCustomerId, LocalDate billDate, List<com.baiyue.finance.controller.PlatformFinanceController.BillLineInput> 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<String,Object> adjustBill(long operatorId,long billId,List<com.baiyue.finance.controller.PlatformFinanceController.BillLineInput> 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<String,Object> 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<String,Object> adjustBill(long operatorId,long billId,List<com.baiyue.finance.controller.PlatformFinanceController.BillLineInput> 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<String,Object> 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<String,Object> 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<String,Object> 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<String,Object> 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<com.baiyue.finance.controller.PlatformFinanceController.BillLineInput> 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<com.baiyue.finance.controller.PlatformFinanceController.BillLineInput> lines) { if (lines == null || lines.isEmpty()) throw bad("账单至少需要一条费用明细"); java.util.Set<String> 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<com.baiyue.finance.controller.PlatformFinanceController.BillLineInput> lines) { lineTotal(lines); Map<String,Object> 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<com.baiyue.finance.controller.PlatformFinanceController.BillLineInput> 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<String> voucherFileIds, String remark, List<ReceiptAllocation> 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<String> 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) {}
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
+9
-10
@@ -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,
|
||||
|
||||
+6
-1
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
+67
@@ -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<String> requestHash = new AtomicReference<>();
|
||||
AtomicReference<String> 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user