提交
This commit is contained in:
@@ -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);
|
||||
|
||||
+12
-5
@@ -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("""
|
||||
|
||||
+8
@@ -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)
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
拒绝、取消等异常结果独立展示,不增加正常流程节点。采购方向只影响起运仓候选与方向展示,不再增加国内、海外两套作业步骤。
|
||||
|
||||
收货批次保存实际数量和绑定本采购单的附件。采购完成后冻结收货结果,客户确认前不得出库。客户可确认或填写1~1000字原因驳回;驳回回到收货中,平台展示原因并逐批重新上传照片。驳回前所有已上传文件均不可复用;仅替换照片,不重复累加数量。全部旧照片替换后方可再次提交确认。平台不得代客户调用确认接口;客户身份从统一认证上下文取得,不接受请求指定其他客户。
|
||||
收货批次保存实际数量和绑定本采购单的附件。采购完成后冻结收货结果,客户确认前不得出库。客户可确认或填写1~1000字原因驳回;驳回回到收货中,平台展示原因并允许按需重新上传照片,也可保留原照片直接再次提交采购完成,不要求逐批重新上传。选择替换照片时不能把旧附件当作本次新上传结果;仅替换照片,不重复累加数量。再次提交采购完成成功后清除上一轮驳回原因。平台不得代客户调用确认接口;客户身份从统一认证上下文取得,不接受请求指定其他客户。
|
||||
|
||||
出库按批次保存数量、物流跟踪号、派送和签收资料。创建批次时预留可用数量,避免多个批次超出采购库存;签收完成时才扣减该批采购库存。签收请求原键重放不得再次扣减。存在剩余库存时仍可继续创建批次,不能因前一批签收而提前结束整单。采购库存属于采购台账,不写普通尾程 WMS 库存。
|
||||
|
||||
|
||||
+2
-1
@@ -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) {}
|
||||
|
||||
|
||||
+7
-1
@@ -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,
|
||||
|
||||
+2
-1
@@ -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())
|
||||
|
||||
+5
-2
@@ -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);
|
||||
}
|
||||
|
||||
/** 文件数量、客户归属、用途和照片类型均由服务端验证。 */
|
||||
|
||||
+19
@@ -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='海外客户下单预估资金冻结投影';
|
||||
|
||||
+3
@@ -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");
|
||||
}
|
||||
|
||||
+41
-4
@@ -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")));
|
||||
|
||||
+9
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user