From e1bfcfc8f529b7b5a135485da3f16acb4130b050 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Fri, 30 Oct 2026 11:54:49 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../V36__align_order_budget_collation.sql | 10 ++++ .../BusinessFeeSourceEventService.java | 5 +- .../BusinessFeeSourceEventServiceTest.java | 47 +++++++++++++++++++ .../mapper/ContainerCustomsMapper.java | 17 +++++-- .../ContainerCustomsMapperContractTest.java | 8 ++++ lastleg-service/docs/procurement-workflow.md | 2 +- .../dto/OverseasProcurementResponses.java | 3 +- .../service/OverseasProcurementService.java | 8 +++- .../service/ProcurementQueryService.java | 3 +- .../service/ProcurementWorkflowService.java | 7 ++- .../wallet/budget/OrderBudgetService.java | 19 ++++++++ .../src/main/resources/db/lastleg.sql | 6 +-- .../service/ProcurementQueryServiceTest.java | 3 ++ .../ProcurementWorkflowPersistenceTest.java | 45 ++++++++++++++++-- .../wallet/budget/OrderBudgetServiceTest.java | 9 ++++ 15 files changed, 173 insertions(+), 19 deletions(-) create mode 100644 deploy/sql/migrations/overseas/baiyue_lastleg/V36__align_order_budget_collation.sql diff --git a/deploy/sql/migrations/overseas/baiyue_lastleg/V36__align_order_budget_collation.sql b/deploy/sql/migrations/overseas/baiyue_lastleg/V36__align_order_budget_collation.sql new file mode 100644 index 000000000..4349db920 --- /dev/null +++ b/deploy/sql/migrations/overseas/baiyue_lastleg/V36__align_order_budget_collation.sql @@ -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 '成功创建的业务单号'; diff --git a/finance/src/main/java/com/baiyue/finance/service/BusinessFeeSourceEventService.java b/finance/src/main/java/com/baiyue/finance/service/BusinessFeeSourceEventService.java index 20c27e713..1568089f9 100644 --- a/finance/src/main/java/com/baiyue/finance/service/BusinessFeeSourceEventService.java +++ b/finance/src/main/java/com/baiyue/finance/service/BusinessFeeSourceEventService.java @@ -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())) { diff --git a/finance/src/test/java/com/baiyue/finance/BusinessFeeSourceEventServiceTest.java b/finance/src/test/java/com/baiyue/finance/BusinessFeeSourceEventServiceTest.java index de880e17a..3fca57ba6 100644 --- a/finance/src/test/java/com/baiyue/finance/BusinessFeeSourceEventServiceTest.java +++ b/finance/src/test/java/com/baiyue/finance/BusinessFeeSourceEventServiceTest.java @@ -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); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/mapper/ContainerCustomsMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/mapper/ContainerCustomsMapper.java index 38353542b..e5d76e11e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/mapper/ContainerCustomsMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/mapper/ContainerCustomsMapper.java @@ -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(""" diff --git a/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsMapperContractTest.java index 101bcff6c..39576e965 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsMapperContractTest.java @@ -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) diff --git a/lastleg-service/docs/procurement-workflow.md b/lastleg-service/docs/procurement-workflow.md index 65d3e7e66..3bdb40856 100644 --- a/lastleg-service/docs/procurement-workflow.md +++ b/lastleg-service/docs/procurement-workflow.md @@ -14,7 +14,7 @@ 拒绝、取消等异常结果独立展示,不增加正常流程节点。采购方向只影响起运仓候选与方向展示,不再增加国内、海外两套作业步骤。 -收货批次保存实际数量和绑定本采购单的附件。采购完成后冻结收货结果,客户确认前不得出库。客户可确认或填写1~1000字原因驳回;驳回回到收货中,平台展示原因并逐批重新上传照片。驳回前所有已上传文件均不可复用;仅替换照片,不重复累加数量。全部旧照片替换后方可再次提交确认。平台不得代客户调用确认接口;客户身份从统一认证上下文取得,不接受请求指定其他客户。 +收货批次保存实际数量和绑定本采购单的附件。采购完成后冻结收货结果,客户确认前不得出库。客户可确认或填写1~1000字原因驳回;驳回回到收货中,平台展示原因并允许按需重新上传照片,也可保留原照片直接再次提交采购完成,不要求逐批重新上传。选择替换照片时不能把旧附件当作本次新上传结果;仅替换照片,不重复累加数量。再次提交采购完成成功后清除上一轮驳回原因。平台不得代客户调用确认接口;客户身份从统一认证上下文取得,不接受请求指定其他客户。 出库按批次保存数量、物流跟踪号、派送和签收资料。创建批次时预留可用数量,避免多个批次超出采购库存;签收完成时才扣减该批采购库存。签收请求原键重放不得再次扣减。存在剩余库存时仍可继续创建批次,不能因前一批签收而提前结束整单。采购库存属于采购台账,不写普通尾程 WMS 库存。 diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementResponses.java index d6ee2c223..888f39a75 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementResponses.java @@ -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) {} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java index 60c9e0c3b..5316d749a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java @@ -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, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementQueryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementQueryService.java index cfe729351..16989a0e4 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementQueryService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementQueryService.java @@ -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()) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementWorkflowService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementWorkflowService.java index f218c62ed..e904076a6 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementWorkflowService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementWorkflowService.java @@ -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 visible=new HashSet<>();receipts.forEach(r->visible.addAll(r.fileIds()));outbounds.forEach(b->visible.addAll(b.fileIds())); var files=visible.isEmpty()?List.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); } /** 文件数量、客户归属、用途和照片类型均由服务端验证。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/wallet/budget/OrderBudgetService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/wallet/budget/OrderBudgetService.java index a17c2bb28..61a927e11 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/wallet/budget/OrderBudgetService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/wallet/budget/OrderBudgetService.java @@ -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、客户、操作、金额均匹配后才接受最终回执。 */ diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 2d06b6622..0c3fc064a 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -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='海外客户下单预估资金冻结投影'; diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementQueryServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementQueryServiceTest.java index 9deb5b7e7..0877ae8f5 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementQueryServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementQueryServiceTest.java @@ -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"); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementWorkflowPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementWorkflowPersistenceTest.java index 7c15a7b2c..2a99a6e0d 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementWorkflowPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementWorkflowPersistenceTest.java @@ -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"))); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/wallet/budget/OrderBudgetServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/wallet/budget/OrderBudgetServiceTest.java index 724d3dbb7..be0f8021a 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/wallet/budget/OrderBudgetServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/wallet/budget/OrderBudgetServiceTest.java @@ -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); }