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");
if (blank(value.walletSettlement().commandEventId())) throw error("FIN-EVT-012", "缺少原资金命令引用");
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", "采购应收必须引用成功实扣资金,不能进入待收费池");
}
if ("OVERSEAS_CONTAINER_FEE".equals(value.sourceType())) {
@@ -328,6 +328,53 @@ class BusinessFeeSourceEventServiceTest {
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
void settledProcurementUsesVerifiedActualConsumptionAndNeverOriginalEstimate() {
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.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 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,
@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);
@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);
@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);
@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);
@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);
@Update("""
@@ -28,6 +28,14 @@ class ContainerCustomsMapperContractTest {
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 {
String value=sql("completedPlanForOrder",long.class,long.class);
assertThat(value)
+1 -1
View File
@@ -14,7 +14,7 @@
拒绝、取消等异常结果独立展示,不增加正常流程节点。采购方向只影响起运仓候选与方向展示,不再增加国内、海外两套作业步骤。
收货批次保存实际数量和绑定本采购单的附件。采购完成后冻结收货结果,客户确认前不得出库。客户可确认或填写1~1000字原因驳回;驳回回到收货中,平台展示原因并逐批重新上传照片。驳回前所有已上传文件均不可复用;仅替换照片,不重复累加数量。全部旧照片替换后方可再次提交确认。平台不得代客户调用确认接口;客户身份从统一认证上下文取得,不接受请求指定其他客户。
收货批次保存实际数量和绑定本采购单的附件。采购完成后冻结收货结果,客户确认前不得出库。客户可确认或填写1~1000字原因驳回;驳回回到收货中,平台展示原因并允许按需重新上传照片,也可保留原照片直接再次提交采购完成,不要求逐批重新上传。选择替换照片时不能把旧附件当作本次新上传结果;仅替换照片,不重复累加数量。再次提交采购完成成功后清除上一轮驳回原因。平台不得代客户调用确认接口;客户身份从统一认证上下文取得,不接受请求指定其他客户。
出库按批次保存数量、物流跟踪号、派送和签收资料。创建批次时预留可用数量,避免多个批次超出采购库存;签收完成时才扣减该批采购库存。签收请求原键重放不得再次扣减。存在剩余库存时仍可继续创建批次,不能因前一批签收而提前结束整单。采购库存属于采购台账,不写普通尾程 WMS 库存。
@@ -12,7 +12,8 @@ public final class OverseasProcurementResponses {
String purchaseCategory, String productName, String productSpecification,
long purchaseQuantity, BigDecimal budgetUnitPrice, String currency,
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) {}
@@ -38,6 +38,7 @@ import java.util.HexFormat;
import java.util.Objects;
import java.util.Set;
import org.springframework.web.multipart.MultipartFile;
import com.baiyue.lastleg.wallet.budget.OrderBudgetService;
@Service
public class OverseasProcurementService {
@@ -57,6 +58,7 @@ public class OverseasProcurementService {
@Resource private ObjectMapper objectMapper;
@Resource private OverseasProcurementFileMapper procurementFiles;
@Resource private ProcurementFileGateway fileGateway;
@Resource private OrderBudgetService orderBudget;
/** 创建客户采购订单,首次请求分配 CG 日流水。 */
@Transactional
@@ -247,6 +249,10 @@ public class OverseasProcurementService {
orders.updateById(order);
history(order, "REVIEW", PENDING_REVIEW, next, operatorId,
key, changeDetail(before, reviewSnapshot(order)), request.remark(), now);
if ("RECEIVING".equals(next) && orderBudget != null) {
// 预估冻结只保护下单审核前的资金,审核通过后实际采购费用独立按余额/授信扣除。
orderBudget.releaseAfterReview(customerId, "procurement", orderNo);
}
return detail(customerId, orderNo);
}
@@ -286,7 +292,7 @@ public class OverseasProcurementService {
order.getCustomerName(), order.getPurchaseCategory(), order.getProductName(),
order.getProductSpecification(), value(order.getPurchaseQuantity()), order.getBudgetUnitPrice(),
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,
@@ -28,6 +28,7 @@ public class ProcurementQueryService {
String productSpecification,String productUrl,long purchaseQuantity,BigDecimal budgetUnitPrice,String currency,
String receivingAddress,String contactName,String contactPhone,LocalDateTime estimatedArrivalAt,
String plannedWarehouseName,String actualWarehouseCode,String actualWarehouseName,String status,long version,
String remark,
LocalDateTime createdAt,LocalDateTime updatedAt,String procurementDirection) { }
public record Budget(BigDecimal totalAmount,String currency,LocalDateTime authorizedAt) { }
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(),
order.getProductSpecification(),order.getProductUrl(),order.getPurchaseQuantity(),order.getBudgetUnitPrice(),order.getCurrency(),
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 fees=manualFees.procurementFees(order.getCustomerId(),order.getId()).stream()
.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);
version(order,request.version());state(order,"RECEIVING");
if(snapshot(order).receivedQuantity()<=0) throw conflict("请先登记收货照片和数量");
if(snapshot(order).photosNeedUpdate()) throw conflict("客户已驳回,请重新上传各收货批次照片后再提交确认");
// 重新提交采购完成后,上一轮客户驳回原因已完成整改,不再继续展示旧提示。
order.setCustomerRejectionReason(null);
order.setRejectedPhotoIds(null);
order.setStatus("WAIT_CUSTOMER_CONFIRM");
finish(order,operator,request.idempotencyKey(),"REQUEST_CONFIRMATION",request);
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();
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();
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();
}
/** 审核通过后立即释放采购预估冻结,后续实际费用按正常余额/授信扣除。 */
@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); }
/** 相关指令ID、客户、操作、金额均匹配后才接受最终回执。 */
@@ -4595,10 +4595,10 @@ CREATE TABLE IF NOT EXISTS ll_procurement_workflow_command (
CREATE TABLE IF NOT EXISTS ll_order_budget_reservation (
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 '客户下单业务类型',
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 '客户预估原币金额',
currency VARCHAR(3) 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),
KEY idx_order_budget_release(status,release_requested,created_at),
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"));
assertThat(platform.customerView().budget().totalAmount()).isEqualByComparingTo("150");
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.actions()).isEmpty();assertThat(platform.operations()).isEmpty();
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("洛杉矶仓");
}
@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("当前客户");
verifyNoInteractions(fees);
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")));
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(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");
}
/** 驳回后必须全批换新照片,返工不能重复增加库存。 */
/** 驳回后可选择换图,换图仍校验附件归属且不能重复增加库存。 */
@Test void rejectionRequiresNewPhotosWithoutChangingStock() {
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")));
@@ -59,16 +59,17 @@ class ProcurementWorkflowPersistenceTest {
assertThatThrownBy(()->tx.execute(t->service.reject("10","CG-1","8",new Rejection(2,"other","破损")))).hasMessageContaining("不属于");
var request=new Rejection(2,"reject","照片不清晰");
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);
assertThatThrownBy(()->tx.execute(t->service.requestConfirmation("9","CG-1","7",new Command(3,"early")))).hasMessageContaining("重新上传");
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-upload",receipt,List.of(104L))))).hasMessageContaining("重新上传");
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))));
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");
}
@@ -99,6 +100,42 @@ class ProcurementWorkflowPersistenceTest {
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() {
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")));
@@ -54,6 +54,15 @@ class OrderBudgetServiceTest {
assertThat(service.read("9","procurement","key").usable()).isFalse();
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) {
return new WalletCommandCompletedV1(event,operation,9L,OrderBudgetService.BUSINESS_TYPE,"11",outcome,status,new BigDecimal("800"),null,error,Instant.now(),"hold",null);
}