This commit is contained in:
2026-10-30 11:54:49 +08:00
parent 0bd25de906
commit e1bfcfc8f5
15 changed files with 173 additions and 19 deletions
@@ -0,0 +1,10 @@
-- Region: OVERSEAS
-- Schema: baiyue_lastleg
-- Purpose: 预算释放查询的客户和订单号关联字段与采购单、卡车单排序规则一致
-- Preconditions: ll_order_budget_reservation 已存在;核对列长度、可空性及索引
-- Postconditions: customer_id、order_no 使用 utf8mb4_unicode_ci;不修改任何业务数据
-- DDL 非事务,执行前核对目标库;失败后检查列定义再继续。
ALTER TABLE baiyue_lastleg.ll_order_budget_reservation
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci,
MODIFY COLUMN customer_id VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户隔离标识',
MODIFY COLUMN order_no VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '成功创建的业务单号';
@@ -148,7 +148,10 @@ public class BusinessFeeSourceEventService {
positive(value.walletSettlement().ledgerId(), "walletSettlement.ledgerId"); positive(value.walletSettlement().ledgerId(), "walletSettlement.ledgerId");
if (blank(value.walletSettlement().commandEventId())) throw error("FIN-EVT-012", "缺少原资金命令引用"); if (blank(value.walletSettlement().commandEventId())) throw error("FIN-EVT-012", "缺少原资金命令引用");
walletIdentity(value); walletIdentity(value);
} else if ("OVERSEAS_PROCUREMENT_FEE".equals(value.sourceType()) && "RECEIVABLE".equals(value.direction())) { } else if ("OVERSEAS_PROCUREMENT_FEE".equals(value.sourceType()) && "RECEIVABLE".equals(value.direction())
// 新采购主单复用人工收费,只有显式账单结算可入池;旧采购和余额费用仍必须引用实扣。
&& !("LASTLEG".equals(value.sourceSystem()) && "OVERSEAS_PROCUREMENT".equals(value.businessType())
&& "BILL".equals(value.settlementMode()))) {
throw error("FIN-EVT-012", "采购应收必须引用成功实扣资金,不能进入待收费池"); throw error("FIN-EVT-012", "采购应收必须引用成功实扣资金,不能进入待收费池");
} }
if ("OVERSEAS_CONTAINER_FEE".equals(value.sourceType())) { if ("OVERSEAS_CONTAINER_FEE".equals(value.sourceType())) {
@@ -328,6 +328,53 @@ class BusinessFeeSourceEventServiceTest {
noFinancialWrites(); noFinancialWrites();
} }
/** 新采购主单只有明确选择账单时才可作为未收应收入池。 */
@Test
void manualProcurementExplicitBillCanEnterReceivablePool() {
EventEnvelope event = manualProcurementEvent("BILL");
when(jdbc.queryForObject(startsWith("SELECT id FROM fin_fee_source"), eq(Long.class),
eq("LASTLEG"), eq("OVERSEAS_PROCUREMENT_FEE"), eq(701L), eq(1))).thenReturn(901L);
assertThat(service.consume(event).eventId()).isEqualTo(event.eventId());
verify(jdbc).update(startsWith("INSERT INTO fin_fee_source"), any(Object[].class));
verify(jdbc, never()).update(startsWith("UPDATE fin_fee_source SET wallet_hold_id="), any(Object[].class));
}
/** 采购主单的余额或默认模式不能因兼容账单而绕过实扣校验。 */
@ParameterizedTest
@org.junit.jupiter.params.provider.NullSource
@ValueSource(strings = {"WALLET"})
void manualProcurementWithoutConsumptionCannotEnterPool(String mode) {
assertThatThrownBy(() -> service.consume(manualProcurementEvent(mode))).hasMessageContaining("必须引用成功实扣");
noFinancialWrites();
}
/** 保留旧采购子费用的扣款约束,即使载荷声明账单结算也不能入池。 */
@Test
void legacyProcurementCannotBypassConsumptionWithBillMode() {
EventEnvelope base = procurementEvent(false);
ObjectNode payload = (ObjectNode) base.payload().deepCopy();
payload.put("settlementMode", "BILL");
assertThatThrownBy(() -> service.consume(resignProcurementEvent(base, payload))).hasMessageContaining("必须引用成功实扣");
noFinancialWrites();
}
private EventEnvelope manualProcurementEvent(String mode) {
EventEnvelope base = procurementEvent(false);
ObjectNode payload = (ObjectNode) base.payload().deepCopy();
payload.put("businessType", "OVERSEAS_PROCUREMENT");
if (mode == null) payload.putNull("settlementMode"); else payload.put("settlementMode", mode);
return resignProcurementEvent(base, payload);
}
private EventEnvelope resignProcurementEvent(EventEnvelope base, ObjectNode payload) {
payload.putNull("action"); payload.putNull("sourceHash");
payload.put("sourceHash", EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload)));
payload.put("action", "PUBLISHED");
return new EventEnvelope(base.eventId(), base.eventType(), base.eventVersion(), base.occurredAt(), base.tenantId(),
base.aggregateType(), base.aggregateId(), base.aggregateVersion(), null, base.traceId(), base.correlationId(), null,
payload, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload)));
}
@Test @Test
void settledProcurementUsesVerifiedActualConsumptionAndNeverOriginalEstimate() { void settledProcurementUsesVerifiedActualConsumptionAndNeverOriginalEstimate() {
EventEnvelope event = procurementEvent(true); EventEnvelope event = procurementEvent(true);
@@ -35,6 +35,13 @@ public interface ContainerCustomsMapper {
f.file_hash fileHash,f.content_type contentType,f.size_bytes sizeBytes,f.uploaded_by uploadedBy, f.file_hash fileHash,f.content_type contentType,f.size_bytes sizeBytes,f.uploaded_by uploadedBy,
f.uploaded_by_name uploadedByName,f.uploaded_at uploadedAt,f.version version f.uploaded_by_name uploadedByName,f.uploaded_at uploadedAt,f.version version
"""; """;
String CONTAINER_COLUMNS = """
id id,loading_plan_id loadingPlanId,container_booking_id containerBookingId,status status,
started_by startedBy,started_by_name startedByName,started_at startedAt,
completed_by completedBy,completed_by_name completedByName,completed_at completedAt,
broker_submitted_by brokerSubmittedBy,broker_submitted_by_name brokerSubmittedByName,
broker_submitted_at brokerSubmittedAt,created_at createdAt,updated_at updatedAt,version version
""";
@Select(""" @Select("""
SELECT DISTINCT p.id loadingPlanId,p.plan_no planNo,p.container_booking_id containerBookingId, SELECT DISTINCT p.id loadingPlanId,p.plan_no planNo,p.container_booking_id containerBookingId,
@@ -132,19 +139,19 @@ public interface ContainerCustomsMapper {
int insertContainer(@Param("id") long id,@Param("planId") long planId, int insertContainer(@Param("id") long id,@Param("planId") long planId,
@Param("containerBookingId") long containerBookingId,@Param("status") String status); @Param("containerBookingId") long containerBookingId,@Param("status") String status);
@Select("SELECT * FROM fl_customs_container WHERE loading_plan_id=#{planId}") @Select("SELECT " + CONTAINER_COLUMNS + " FROM fl_customs_container WHERE loading_plan_id=#{planId}")
ContainerRow container(@Param("planId") long planId); ContainerRow container(@Param("planId") long planId);
@Select("SELECT * FROM fl_customs_container WHERE id=#{id}") @Select("SELECT " + CONTAINER_COLUMNS + " FROM fl_customs_container WHERE id=#{id}")
ContainerRow containerById(@Param("id") long id); ContainerRow containerById(@Param("id") long id);
@Select("SELECT * FROM fl_customs_container WHERE container_booking_id=#{containerBookingId}") @Select("SELECT " + CONTAINER_COLUMNS + " FROM fl_customs_container WHERE container_booking_id=#{containerBookingId}")
ContainerRow containerByBooking(@Param("containerBookingId") long containerBookingId); ContainerRow containerByBooking(@Param("containerBookingId") long containerBookingId);
@Select("SELECT * FROM fl_customs_container WHERE loading_plan_id=#{planId} FOR UPDATE") @Select("SELECT " + CONTAINER_COLUMNS + " FROM fl_customs_container WHERE loading_plan_id=#{planId} FOR UPDATE")
ContainerRow lockContainer(@Param("planId") long planId); ContainerRow lockContainer(@Param("planId") long planId);
@Select("SELECT * FROM fl_customs_container WHERE id=#{id} FOR UPDATE") @Select("SELECT " + CONTAINER_COLUMNS + " FROM fl_customs_container WHERE id=#{id} FOR UPDATE")
ContainerRow lockContainerById(@Param("id") long id); ContainerRow lockContainerById(@Param("id") long id);
@Update(""" @Update("""
@@ -28,6 +28,14 @@ class ContainerCustomsMapperContractTest {
assertThat(currentRead.getCache()).isNull(); assertThat(currentRead.getCache()).isNull();
} }
@Test void containerQueriesExplicitlyMapEveryRecordComponentInOrder() throws Exception {
assertRecordProjection("container", ContainerCustomsMapper.ContainerRow.class, long.class);
assertRecordProjection("containerById", ContainerCustomsMapper.ContainerRow.class, long.class);
assertRecordProjection("containerByBooking", ContainerCustomsMapper.ContainerRow.class, long.class);
assertRecordProjection("lockContainer", ContainerCustomsMapper.ContainerRow.class, long.class);
assertRecordProjection("lockContainerById", ContainerCustomsMapper.ContainerRow.class, long.class);
}
@Test void merchantOrderUsesCompletedTaxRefundPlanWithRealCompletionTimestamp() throws Exception { @Test void merchantOrderUsesCompletedTaxRefundPlanWithRealCompletionTimestamp() throws Exception {
String value=sql("completedPlanForOrder",long.class,long.class); String value=sql("completedPlanForOrder",long.class,long.class);
assertThat(value) assertThat(value)
+1 -1
View File
@@ -14,7 +14,7 @@
拒绝、取消等异常结果独立展示,不增加正常流程节点。采购方向只影响起运仓候选与方向展示,不再增加国内、海外两套作业步骤。 拒绝、取消等异常结果独立展示,不增加正常流程节点。采购方向只影响起运仓候选与方向展示,不再增加国内、海外两套作业步骤。
收货批次保存实际数量和绑定本采购单的附件。采购完成后冻结收货结果,客户确认前不得出库。客户可确认或填写1~1000字原因驳回;驳回回到收货中,平台展示原因并逐批重新上传照片。驳回前所有已上传文件均不可复用;仅替换照片,不重复累加数量。全部旧照片替换后方可再次提交确认。平台不得代客户调用确认接口;客户身份从统一认证上下文取得,不接受请求指定其他客户。 收货批次保存实际数量和绑定本采购单的附件。采购完成后冻结收货结果,客户确认前不得出库。客户可确认或填写1~1000字原因驳回;驳回回到收货中,平台展示原因并允许按需重新上传照片,也可保留原照片直接再次提交采购完成,不要求逐批重新上传。选择替换照片时不能把旧附件当作本次新上传结果;仅替换照片,不重复累加数量。再次提交采购完成成功后清除上一轮驳回原因。平台不得代客户调用确认接口;客户身份从统一认证上下文取得,不接受请求指定其他客户。
出库按批次保存数量、物流跟踪号、派送和签收资料。创建批次时预留可用数量,避免多个批次超出采购库存;签收完成时才扣减该批采购库存。签收请求原键重放不得再次扣减。存在剩余库存时仍可继续创建批次,不能因前一批签收而提前结束整单。采购库存属于采购台账,不写普通尾程 WMS 库存。 出库按批次保存数量、物流跟踪号、派送和签收资料。创建批次时预留可用数量,避免多个批次超出采购库存;签收完成时才扣减该批采购库存。签收请求原键重放不得再次扣减。存在剩余库存时仍可继续创建批次,不能因前一批签收而提前结束整单。采购库存属于采购台账,不写普通尾程 WMS 库存。
@@ -12,7 +12,8 @@ public final class OverseasProcurementResponses {
String purchaseCategory, String productName, String productSpecification, String purchaseCategory, String productName, String productSpecification,
long purchaseQuantity, BigDecimal budgetUnitPrice, String currency, long purchaseQuantity, BigDecimal budgetUnitPrice, String currency,
String purchaseWarehouseId, String purchaseWarehouseName, String purchaseWarehouseId, String purchaseWarehouseName,
String status, long version, LocalDateTime createdAt, LocalDateTime updatedAt, String procurementDirection) {} String status, long version, LocalDateTime createdAt, LocalDateTime updatedAt, String procurementDirection,
String reviewRemark) {}
public record CustomerOption(String customerId, String customerName) {} public record CustomerOption(String customerId, String customerName) {}
@@ -38,6 +38,7 @@ import java.util.HexFormat;
import java.util.Objects; import java.util.Objects;
import java.util.Set; import java.util.Set;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import com.baiyue.lastleg.wallet.budget.OrderBudgetService;
@Service @Service
public class OverseasProcurementService { public class OverseasProcurementService {
@@ -57,6 +58,7 @@ public class OverseasProcurementService {
@Resource private ObjectMapper objectMapper; @Resource private ObjectMapper objectMapper;
@Resource private OverseasProcurementFileMapper procurementFiles; @Resource private OverseasProcurementFileMapper procurementFiles;
@Resource private ProcurementFileGateway fileGateway; @Resource private ProcurementFileGateway fileGateway;
@Resource private OrderBudgetService orderBudget;
/** 创建客户采购订单,首次请求分配 CG 日流水。 */ /** 创建客户采购订单,首次请求分配 CG 日流水。 */
@Transactional @Transactional
@@ -247,6 +249,10 @@ public class OverseasProcurementService {
orders.updateById(order); orders.updateById(order);
history(order, "REVIEW", PENDING_REVIEW, next, operatorId, history(order, "REVIEW", PENDING_REVIEW, next, operatorId,
key, changeDetail(before, reviewSnapshot(order)), request.remark(), now); key, changeDetail(before, reviewSnapshot(order)), request.remark(), now);
if ("RECEIVING".equals(next) && orderBudget != null) {
// 预估冻结只保护下单审核前的资金,审核通过后实际采购费用独立按余额/授信扣除。
orderBudget.releaseAfterReview(customerId, "procurement", orderNo);
}
return detail(customerId, orderNo); return detail(customerId, orderNo);
} }
@@ -286,7 +292,7 @@ public class OverseasProcurementService {
order.getCustomerName(), order.getPurchaseCategory(), order.getProductName(), order.getCustomerName(), order.getPurchaseCategory(), order.getProductName(),
order.getProductSpecification(), value(order.getPurchaseQuantity()), order.getBudgetUnitPrice(), order.getProductSpecification(), value(order.getPurchaseQuantity()), order.getBudgetUnitPrice(),
order.getCurrency(), order.getPurchaseWarehouseId(), order.getPurchaseWarehouseName(), order.getCurrency(), order.getPurchaseWarehouseId(), order.getPurchaseWarehouseName(),
displayStatus, value(order.getVersion()), order.getCreatedAt(), order.getUpdatedAt(), ProcurementDirection.normalize(order.getProcurementDirection())); displayStatus, value(order.getVersion()), order.getCreatedAt(), order.getUpdatedAt(), ProcurementDirection.normalize(order.getProcurementDirection()), order.getReviewRemark());
} }
private void history(OverseasProcurementOrder order, String operationType, String from, String to, private void history(OverseasProcurementOrder order, String operationType, String from, String to,
@@ -28,6 +28,7 @@ public class ProcurementQueryService {
String productSpecification,String productUrl,long purchaseQuantity,BigDecimal budgetUnitPrice,String currency, String productSpecification,String productUrl,long purchaseQuantity,BigDecimal budgetUnitPrice,String currency,
String receivingAddress,String contactName,String contactPhone,LocalDateTime estimatedArrivalAt, String receivingAddress,String contactName,String contactPhone,LocalDateTime estimatedArrivalAt,
String plannedWarehouseName,String actualWarehouseCode,String actualWarehouseName,String status,long version, String plannedWarehouseName,String actualWarehouseCode,String actualWarehouseName,String status,long version,
String remark,
LocalDateTime createdAt,LocalDateTime updatedAt,String procurementDirection) { } LocalDateTime createdAt,LocalDateTime updatedAt,String procurementDirection) { }
public record Budget(BigDecimal totalAmount,String currency,LocalDateTime authorizedAt) { } public record Budget(BigDecimal totalAmount,String currency,LocalDateTime authorizedAt) { }
public record CustomerFee(String feeNo,String purpose,String name,Integer version,boolean active,String determination, public record CustomerFee(String feeNo,String purpose,String name,Integer version,boolean active,String determination,
@@ -59,7 +60,7 @@ public class ProcurementQueryService {
var header=new Header(order.getId(),order.getOrderNo(),order.getCustomerId(),order.getCustomerName(),order.getPurchaseCategory(),order.getProductName(), var header=new Header(order.getId(),order.getOrderNo(),order.getCustomerId(),order.getCustomerName(),order.getPurchaseCategory(),order.getProductName(),
order.getProductSpecification(),order.getProductUrl(),order.getPurchaseQuantity(),order.getBudgetUnitPrice(),order.getCurrency(), order.getProductSpecification(),order.getProductUrl(),order.getPurchaseQuantity(),order.getBudgetUnitPrice(),order.getCurrency(),
order.getReceivingAddress(),order.getContactName(),order.getContactPhone(),order.getEstimatedArrivalAt(),order.getPurchaseWarehouseName(), order.getReceivingAddress(),order.getContactName(),order.getContactPhone(),order.getEstimatedArrivalAt(),order.getPurchaseWarehouseName(),
null,order.getPurchaseWarehouseName(),order.getStatus(),order.getVersion(),order.getCreatedAt(),order.getUpdatedAt(),ProcurementDirection.normalize(order.getProcurementDirection())); null,order.getPurchaseWarehouseName(),order.getStatus(),order.getVersion(),order.getRemark(),order.getCreatedAt(),order.getUpdatedAt(),ProcurementDirection.normalize(order.getProcurementDirection()));
var budget=new Budget(order.getBudgetUnitPrice()==null?null:order.getBudgetUnitPrice().multiply(BigDecimal.valueOf(order.getPurchaseQuantity())),order.getCurrency(),null); var budget=new Budget(order.getBudgetUnitPrice()==null?null:order.getBudgetUnitPrice().multiply(BigDecimal.valueOf(order.getPurchaseQuantity())),order.getCurrency(),null);
var fees=manualFees.procurementFees(order.getCustomerId(),order.getId()).stream() var fees=manualFees.procurementFees(order.getCustomerId(),order.getId()).stream()
.filter(f->"RECEIVABLE".equals(f.getDirection()) && "OVERSEAS_PROCUREMENT".equals(f.getBusinessType()) .filter(f->"RECEIVABLE".equals(f.getDirection()) && "OVERSEAS_PROCUREMENT".equals(f.getBusinessType())
@@ -49,7 +49,9 @@ public class ProcurementWorkflowService {
if(replay(order,request.idempotencyKey(),"REQUEST_CONFIRMATION",request)) return snapshot(order); if(replay(order,request.idempotencyKey(),"REQUEST_CONFIRMATION",request)) return snapshot(order);
version(order,request.version());state(order,"RECEIVING"); version(order,request.version());state(order,"RECEIVING");
if(snapshot(order).receivedQuantity()<=0) throw conflict("请先登记收货照片和数量"); if(snapshot(order).receivedQuantity()<=0) throw conflict("请先登记收货照片和数量");
if(snapshot(order).photosNeedUpdate()) throw conflict("客户已驳回,请重新上传各收货批次照片后再提交确认"); // 重新提交采购完成后,上一轮客户驳回原因已完成整改,不再继续展示旧提示。
order.setCustomerRejectionReason(null);
order.setRejectedPhotoIds(null);
order.setStatus("WAIT_CUSTOMER_CONFIRM"); order.setStatus("WAIT_CUSTOMER_CONFIRM");
finish(order,operator,request.idempotencyKey(),"REQUEST_CONFIRMATION",request); finish(order,operator,request.idempotencyKey(),"REQUEST_CONFIRMATION",request);
return snapshot(order); return snapshot(order);
@@ -180,7 +182,8 @@ public class ProcurementWorkflowService {
long reserved=outbounds.stream().filter(b->!Set.of("COMPLETED","CANCELLED").contains(b.status())).mapToLong(OutboundRow::quantity).sum(); long reserved=outbounds.stream().filter(b->!Set.of("COMPLETED","CANCELLED").contains(b.status())).mapToLong(OutboundRow::quantity).sum();
Set<Long> visible=new HashSet<>();receipts.forEach(r->visible.addAll(r.fileIds()));outbounds.forEach(b->visible.addAll(b.fileIds())); Set<Long> visible=new HashSet<>();receipts.forEach(r->visible.addAll(r.fileIds()));outbounds.forEach(b->visible.addAll(b.fileIds()));
var files=visible.isEmpty()?List.<FileInfo>of():repository.files(order).stream().filter(f->visible.contains(f.getFileId())).map(f->new FileInfo(f.getFileId(),f.getFileName(),f.getContentType())).toList(); var files=visible.isEmpty()?List.<FileInfo>of():repository.files(order).stream().filter(f->visible.contains(f.getFileId())).map(f->new FileInfo(f.getFileId(),f.getFileName(),f.getContentType())).toList();
return new Workflow(order.getOrderNo(),order.getVersion(),order.getStatus(),received,received-completed,reserved,received-completed-reserved,receipts,outbounds,files,order.getCustomerRejectionReason(),order.getRejectedPhotoIds()!=null && receipts.stream().flatMap(r->r.fileIds().stream()).anyMatch(ids(order.getRejectedPhotoIds())::contains)); // 驳回后的照片允许按需替换,不作为重新提交采购完成的门禁。
return new Workflow(order.getOrderNo(),order.getVersion(),order.getStatus(),received,received-completed,reserved,received-completed-reserved,receipts,outbounds,files,order.getCustomerRejectionReason(),false);
} }
/** 文件数量、客户归属、用途和照片类型均由服务端验证。 */ /** 文件数量、客户归属、用途和照片类型均由服务端验证。 */
@@ -75,6 +75,25 @@ public class OrderBudgetService implements WalletResultHandler {
return change.get(); return change.get();
} }
/** 审核通过后立即释放采购预估冻结,后续实际费用按正常余额/授信扣除。 */
@Transactional
public void releaseAfterReview(String customer, String kind, String orderNo) {
requireKind(kind);
if (orderNo == null || orderNo.isBlank()) return;
var row = repository.lockOrder(customer, kind, orderNo);
if (row == null || Boolean.TRUE.equals(row.getReleaseRequested())
|| Set.of("RELEASED", "RELEASE_PENDING").contains(row.getStatus())) return;
row.setReleaseRequested(true);
if ("FROZEN".equals(row.getStatus())) {
if (row.getAmountCny().signum() == 0) row.setStatus("RELEASED");
else sendRelease(row);
} else if ("FAILED".equals(row.getStatus())) {
row.setStatus("RELEASED");
}
row.setUpdatedAt(now());
repository.save(row);
}
/** 仅接收本服务独立预算资金身份。 */ /** 仅接收本服务独立预算资金身份。 */
@Override public boolean supports(String type) { return BUSINESS_TYPE.equals(type); } @Override public boolean supports(String type) { return BUSINESS_TYPE.equals(type); }
/** 相关指令ID、客户、操作、金额均匹配后才接受最终回执。 */ /** 相关指令ID、客户、操作、金额均匹配后才接受最终回执。 */
@@ -4595,10 +4595,10 @@ CREATE TABLE IF NOT EXISTS ll_procurement_workflow_command (
CREATE TABLE IF NOT EXISTS ll_order_budget_reservation ( CREATE TABLE IF NOT EXISTS ll_order_budget_reservation (
id BIGINT NOT NULL COMMENT '预算冻结记录主键', id BIGINT NOT NULL COMMENT '预算冻结记录主键',
customer_id VARCHAR(64) NOT NULL COMMENT '客户隔离标识', customer_id VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户隔离标识',
kind VARCHAR(32) NOT NULL COMMENT '客户下单业务类型', kind VARCHAR(32) NOT NULL COMMENT '客户下单业务类型',
request_key VARCHAR(128) NOT NULL COMMENT '下单幂等键', request_key VARCHAR(128) NOT NULL COMMENT '下单幂等键',
order_no VARCHAR(64) NULL COMMENT '成功创建的业务单号', order_no VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '成功创建的业务单号',
original_amount DECIMAL(18,2) NOT NULL COMMENT '客户预估原币金额', original_amount DECIMAL(18,2) NOT NULL COMMENT '客户预估原币金额',
currency VARCHAR(3) NOT NULL COMMENT '预估金额币种', currency VARCHAR(3) NOT NULL COMMENT '预估金额币种',
amount_cny DECIMAL(18,2) NOT NULL COMMENT '按下单月份汇率折算的人民币冻结金额', amount_cny DECIMAL(18,2) NOT NULL COMMENT '按下单月份汇率折算的人民币冻结金额',
@@ -4612,4 +4612,4 @@ CREATE TABLE IF NOT EXISTS ll_order_budget_reservation (
PRIMARY KEY(id), UNIQUE KEY uk_order_budget_request(customer_id,kind,request_key), PRIMARY KEY(id), UNIQUE KEY uk_order_budget_request(customer_id,kind,request_key),
KEY idx_order_budget_release(status,release_requested,created_at), KEY idx_order_budget_release(status,release_requested,created_at),
KEY idx_order_budget_order(customer_id,kind,order_no) KEY idx_order_budget_order(customer_id,kind,order_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='海外客户下单预估资金冻结投影'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外客户下单预估资金冻结投影';
@@ -36,6 +36,7 @@ class ProcurementQueryServiceTest {
var platform=query.platform("9","CG-1",Set.of("platform:overseas-business:purchase:buy")); var platform=query.platform("9","CG-1",Set.of("platform:overseas-business:purchase:buy"));
assertThat(platform.customerView().budget().totalAmount()).isEqualByComparingTo("150"); assertThat(platform.customerView().budget().totalAmount()).isEqualByComparingTo("150");
assertThat(platform.customerView().order().actualWarehouseName()).isEqualTo("洛杉矶仓"); assertThat(platform.customerView().order().actualWarehouseName()).isEqualTo("洛杉矶仓");
assertThat(platform.customerView().order().remark()).isEqualTo("internal note");
assertThat(platform.customerView().boxes()).isEmpty();assertThat(platform.customerView().shipments()).isEmpty(); assertThat(platform.customerView().boxes()).isEmpty();assertThat(platform.customerView().shipments()).isEmpty();
assertThat(platform.actions()).isEmpty();assertThat(platform.operations()).isEmpty(); assertThat(platform.actions()).isEmpty();assertThat(platform.operations()).isEmpty();
assertThat(platform.files()).hasSize(3);assertThat(platform.internalRemark()).isEqualTo("internal note"); assertThat(platform.files()).hasSize(3);assertThat(platform.internalRemark()).isEqualTo("internal note");
@@ -49,11 +50,13 @@ class ProcurementQueryServiceTest {
assertThat(query.customer("9","CG-1").order().actualWarehouseName()).isEqualTo("洛杉矶仓"); assertThat(query.customer("9","CG-1").order().actualWarehouseName()).isEqualTo("洛杉矶仓");
} }
@Test void customerCannotReadAnotherCustomersOrderOrInternalDiagnostics() throws Exception { @Test void customerCannotReadAnotherCustomersOrderOrInternalDiagnostics() throws Exception {
fixture.jdbc.update("UPDATE ll_overseas_procurement_order SET remark='purchase note' WHERE id=11");
assertThatThrownBy(()->query.customer("10","CG-1")).hasMessageContaining("当前客户"); assertThatThrownBy(()->query.customer("10","CG-1")).hasMessageContaining("当前客户");
verifyNoInteractions(fees); verifyNoInteractions(fees);
var fee=fee("9",11L,"RECEIVABLE");fee.setWalletErrorMessage("private error");fee.setReviewReason("private reason"); var fee=fee("9",11L,"RECEIVABLE");fee.setWalletErrorMessage("private error");fee.setReviewReason("private reason");
when(fees.procurementFees("9",11L)).thenReturn(List.of(fee,fee("10",11L,"RECEIVABLE"),fee("9",12L,"RECEIVABLE"),fee("9",11L,"PAYABLE"))); when(fees.procurementFees("9",11L)).thenReturn(List.of(fee,fee("10",11L,"RECEIVABLE"),fee("9",12L,"RECEIVABLE"),fee("9",11L,"PAYABLE")));
var result=query.customer("9","CG-1"); var result=query.customer("9","CG-1");
assertThat(result.order().remark()).isEqualTo("purchase note");
assertThat(result.fees()).singleElement().satisfies(row->{assertThat(row.walletStatus()).isEqualTo("FREEZE_PENDING");assertThat(row.sourceLinked()).isFalse();}); assertThat(result.fees()).singleElement().satisfies(row->{assertThat(row.walletStatus()).isEqualTo("FREEZE_PENDING");assertThat(row.sourceLinked()).isFalse();});
assertThat(new ObjectMapper().findAndRegisterModules().writeValueAsString(result)).doesNotContain("private", "walletErrorMessage", "reviewReason", "internalRemark"); assertThat(new ObjectMapper().findAndRegisterModules().writeValueAsString(result)).doesNotContain("private", "walletErrorMessage", "reviewReason", "internalRemark");
} }
@@ -51,7 +51,7 @@ class ProcurementWorkflowPersistenceTest {
file(101L,11L,"9","image/jpeg");file(102L,11L,"9","application/pdf");file(103L,12L,"10","image/jpeg");file(104L,11L,"9","image/png"); file(101L,11L,"9","image/jpeg");file(102L,11L,"9","application/pdf");file(103L,12L,"10","image/jpeg");file(104L,11L,"9","image/png");
} }
/** 驳回后必须全批换新照片,返工不能重复增加库存。 */ /** 驳回后可选择换图,换图仍校验附件归属且不能重复增加库存。 */
@Test void rejectionRequiresNewPhotosWithoutChangingStock() { @Test void rejectionRequiresNewPhotosWithoutChangingStock() {
tx.execute(t->service.receive("9","CG-1","7",new Receipt(0,"receipt",10,List.of(101L)))); tx.execute(t->service.receive("9","CG-1","7",new Receipt(0,"receipt",10,List.of(101L))));
tx.execute(t->service.requestConfirmation("9","CG-1","7",new Command(1,"ask"))); tx.execute(t->service.requestConfirmation("9","CG-1","7",new Command(1,"ask")));
@@ -59,16 +59,17 @@ class ProcurementWorkflowPersistenceTest {
assertThatThrownBy(()->tx.execute(t->service.reject("10","CG-1","8",new Rejection(2,"other","破损")))).hasMessageContaining("不属于"); assertThatThrownBy(()->tx.execute(t->service.reject("10","CG-1","8",new Rejection(2,"other","破损")))).hasMessageContaining("不属于");
var request=new Rejection(2,"reject","照片不清晰"); var request=new Rejection(2,"reject","照片不清晰");
var rejected=tx.execute(t->service.reject("9","CG-1","8",request)); var rejected=tx.execute(t->service.reject("9","CG-1","8",request));
assertThat(rejected.status()).isEqualTo("RECEIVING");assertThat(rejected.rejectionReason()).isEqualTo("照片不清晰");assertThat(rejected.photosNeedUpdate()).isTrue(); assertThat(rejected.status()).isEqualTo("RECEIVING");assertThat(rejected.rejectionReason()).isEqualTo("照片不清晰");assertThat(rejected.photosNeedUpdate()).isFalse();
assertThat(tx.execute(t->service.reject("9","CG-1","8",request)).version()).isEqualTo(3); assertThat(tx.execute(t->service.reject("9","CG-1","8",request)).version()).isEqualTo(3);
assertThatThrownBy(()->tx.execute(t->service.requestConfirmation("9","CG-1","7",new Command(3,"early")))).hasMessageContaining("重新上传");
long receipt=rejected.receipts().get(0).id(); long receipt=rejected.receipts().get(0).id();
assertThatThrownBy(()->tx.execute(t->service.replacePhotos("9","CG-1","7",new ReceiptPhotos(3,"old",receipt,List.of(101L))))).hasMessageContaining("不能复用"); assertThatThrownBy(()->tx.execute(t->service.replacePhotos("9","CG-1","7",new ReceiptPhotos(3,"old",receipt,List.of(101L))))).hasMessageContaining("不能复用");
assertThatThrownBy(()->tx.execute(t->service.replacePhotos("9","CG-1","7",new ReceiptPhotos(3,"old-upload",receipt,List.of(104L))))).hasMessageContaining("重新上传"); assertThatThrownBy(()->tx.execute(t->service.replacePhotos("9","CG-1","7",new ReceiptPhotos(3,"old-upload",receipt,List.of(104L))))).hasMessageContaining("重新上传");
file(105L,11L,"9","image/png"); file(105L,11L,"9","image/png");
var current=tx.execute(t->service.replacePhotos("9","CG-1","7",new ReceiptPhotos(3,"new",receipt,List.of(105L)))); var current=tx.execute(t->service.replacePhotos("9","CG-1","7",new ReceiptPhotos(3,"new",receipt,List.of(105L))));
assertThat(current.photosNeedUpdate()).isFalse();assertThat(current.stockQuantity()).isEqualTo(10);assertThat(current.receipts()).hasSize(1); assertThat(current.photosNeedUpdate()).isFalse();assertThat(current.stockQuantity()).isEqualTo(10);assertThat(current.receipts()).hasSize(1);
tx.execute(t->service.requestConfirmation("9","CG-1","7",new Command(4,"again"))); var resubmitted=tx.execute(t->service.requestConfirmation("9","CG-1","7",new Command(4,"again")));
assertThat(resubmitted.rejectionReason()).isNull();
assertThat(resubmitted.photosNeedUpdate()).isFalse();
assertThat(tx.execute(t->service.confirm("9","CG-1","8",new Command(5,"confirm"))).status()).isEqualTo("CONFIRMED"); assertThat(tx.execute(t->service.confirm("9","CG-1","8",new Command(5,"confirm"))).status()).isEqualTo("CONFIRMED");
} }
@@ -99,6 +100,42 @@ class ProcurementWorkflowPersistenceTest {
assertThat(current.files()).hasSize(3); assertThat(current.files()).hasSize(3);
} }
/** 多批次驳回后仅整改需要换图的批次,即可再次提交,其他批次保留。 */
@Test void replacingOneRejectedBatchAllowsResubmissionWithoutReplacingEveryBatch() {
tx.execute(s -> service.receive("9", "CG-1", "7", new Receipt(0, "multi-r1", 4, List.of(101L))));
tx.execute(s -> service.receive("9", "CG-1", "7", new Receipt(1, "multi-r2", 6, List.of(104L))));
tx.execute(s -> service.requestConfirmation("9", "CG-1", "7", new Command(2, "multi-ask")));
var rejected = tx.execute(s -> service.reject("9", "CG-1", "8", new Rejection(3, "multi-reject", "两批照片均不清晰")));
assertThat(rejected.photosNeedUpdate()).isFalse();
long first = rejected.receipts().get(0).id();
file(106L, 11L, "9", "image/png");
var firstReplaced = tx.execute(s -> service.replacePhotos("9", "CG-1", "7", new ReceiptPhotos(4, "multi-r1-new", first, List.of(106L))));
assertThat(firstReplaced.photosNeedUpdate()).isFalse();
assertThat(firstReplaced.receipts().get(1).fileIds()).containsExactly(104L);
assertThat(firstReplaced.receivedQuantity()).isEqualTo(10);
var resubmitted = tx.execute(s -> service.requestConfirmation("9", "CG-1", "7", new Command(5, "multi-again")));
assertThat(resubmitted.status()).isEqualTo("WAIT_CUSTOMER_CONFIRM");
assertThat(resubmitted.rejectionReason()).isNull();
assertThat(resubmitted.photosNeedUpdate()).isFalse();
}
/** 多批次均保留原照片也可重新提交,成功后清除上一轮原因,重放不改变库存。 */
@Test void rejectedPurchaseCanResubmitWithoutUploadingPhotos() {
tx.execute(s -> service.receive("9", "CG-1", "7", new Receipt(0, "keep-r1", 4, List.of(101L))));
tx.execute(s -> service.receive("9", "CG-1", "7", new Receipt(1, "keep-r2", 6, List.of(104L))));
tx.execute(s -> service.requestConfirmation("9", "CG-1", "7", new Command(2, "keep-ask")));
tx.execute(s -> service.reject("9", "CG-1", "8", new Rejection(3, "keep-reject", "请核对")));
var command = new Command(4, "keep-again");
var resubmitted = tx.execute(s -> service.requestConfirmation("9", "CG-1", "7", command));
assertThat(resubmitted.status()).isEqualTo("WAIT_CUSTOMER_CONFIRM");
assertThat(resubmitted.rejectionReason()).isNull();
assertThat(resubmitted.photosNeedUpdate()).isFalse();
assertThat(resubmitted.receipts().get(0).fileIds()).containsExactly(101L);
assertThat(resubmitted.receipts().get(1).fileIds()).containsExactly(104L);
assertThat(resubmitted.stockQuantity()).isEqualTo(10);
assertThat(tx.execute(s -> service.requestConfirmation("9", "CG-1", "7", command)).version()).isEqualTo(5);
}
@Test void partialPurchaseCanFinishButRemainingStockRequiresMoreOutbounds() { @Test void partialPurchaseCanFinishButRemainingStockRequiresMoreOutbounds() {
tx.execute(s->service.receive("9","CG-1","7",new Receipt(0,"r",6,List.of(101L)))); tx.execute(s->service.receive("9","CG-1","7",new Receipt(0,"r",6,List.of(101L))));
tx.execute(s->service.requestConfirmation("9","CG-1","7",new Command(1,"ask"))); tx.execute(s->service.requestConfirmation("9","CG-1","7",new Command(1,"ask")));
@@ -54,6 +54,15 @@ class OrderBudgetServiceTest {
assertThat(service.read("9","procurement","key").usable()).isFalse(); assertThat(service.read("9","procurement","key").usable()).isFalse();
assertThat(service.read("9","procurement","key").message()).contains("过期"); assertThat(service.read("9","procurement","key").message()).contains("过期");
} }
@Test void reviewReleaseImmediatelyReleasesFrozenBudget() {
row.setOrderNo("CG-1"); row.setStatus("FROZEN");
when(repository.lockOrder("9","procurement","CG-1")).thenReturn(row);
when(wallet.publish(eq("RELEASE"),eq(9L),eq(OrderBudgetService.BUSINESS_TYPE),eq("11"),eq(new BigDecimal("800")),anyString(),eq(2L))).thenReturn("release");
service.releaseAfterReview("9","procurement","CG-1");
assertThat(row.getReleaseRequested()).isTrue();
assertThat(row.getStatus()).isEqualTo("RELEASE_PENDING");
verify(repository).save(row);
}
private WalletCommandCompletedV1 result(String event,String operation,String outcome,String status,String error) { private WalletCommandCompletedV1 result(String event,String operation,String outcome,String status,String error) {
return new WalletCommandCompletedV1(event,operation,9L,OrderBudgetService.BUSINESS_TYPE,"11",outcome,status,new BigDecimal("800"),null,error,Instant.now(),"hold",null); return new WalletCommandCompletedV1(event,operation,9L,OrderBudgetService.BUSINESS_TYPE,"11",outcome,status,new BigDecimal("800"),null,error,Instant.now(),"hold",null);
} }