From 2ec5414b39892a212cea78163a283a5f23e358ba Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Fri, 9 Oct 2026 11:49:17 +0800 Subject: [PATCH 01/40] =?UTF-8?q?=E8=B4=B4=E6=A0=87=E7=94=B3=E8=AF=B7?= =?UTF-8?q?=EF=BC=8C=E4=BB=93=E5=82=A8=E8=BD=AC=E8=BF=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../customer/FleetCustomerDirectory.java | 7 ++- .../lastleg/mapper/LastlegOrderMapper.java | 12 ++++ .../service/RelabelApplicationService.java | 31 +++++++++- .../MerchantReturnOrderController.java | 5 ++ .../dto/ReturnOrderResponses.java | 6 +- .../service/ReturnReceiptService.java | 53 ++++++++++++++-- .../lastleg/service/LastlegOrderService.java | 13 ++++ .../service/ReturnOrderReferenceResolver.java | 62 +++++++++++++++++++ .../RelabelApplicationServiceTest.java | 24 +++++++ .../MerchantReturnOrderControllerTest.java | 52 ++++++++++++++++ .../service/ReturnReceiptServiceTest.java | 28 +++++++++ .../service/LastlegOrderServiceTest.java | 4 ++ .../ReturnOrderReferenceResolverTest.java | 49 +++++++++++++++ 13 files changed, 335 insertions(+), 11 deletions(-) create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/service/ReturnOrderReferenceResolver.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/controller/MerchantReturnOrderControllerTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/service/ReturnOrderReferenceResolverTest.java diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/customer/FleetCustomerDirectory.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/customer/FleetCustomerDirectory.java index aec4a4f23..34ceb75af 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/customer/FleetCustomerDirectory.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/customer/FleetCustomerDirectory.java @@ -15,9 +15,12 @@ public class FleetCustomerDirectory { this.client = client; this.token = token; } public List options(String keyword) { return load(keyword, null); } - public Customer required(long id) { - var customer = load(null, id).stream().filter(row -> row.id().equals(Long.toString(id))).findFirst() + public Customer find(String id) { + return load(null, Long.parseLong(id)).stream().filter(row -> row.id().equals(id)).findFirst() .orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "请选择有效客户")); + } + public Customer required(long id) { + var customer = find(Long.toString(id)); try { var result = client.eligibility(token, id); if (result == null || !result.successful() || result.data() == null) throw unavailable(); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java index 97a0b043d..e41a99b4f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java @@ -45,6 +45,18 @@ public interface LastlegOrderMapper extends BaseMapper { record OrderWeightRow(Long orderId, BigDecimal grossWeightKg, BigDecimal netWeightKg) { } + record ReturnVolumeRow(Long orderId, BigDecimal volumeCbm) { } + + @Select("") + List findReturnVolumes(@Param("orderIds") List orderIds); + @Select("") @@ -31,7 +33,7 @@ public interface ReturnInventoryCartonMapper extends BaseMapper merchantPage(@Param("customerId") String customerId, @Param("query") com.baiyue.lastleg.returnreceipt.dto.ReturnInventoryRequests.Query query, @Param("offset") long offset); - @Select("") List findAvailable(@Param("customerId") String customerId, @Param("warehouseId") String warehouseId); @@ -44,7 +46,7 @@ public interface ReturnInventoryCartonMapper extends BaseMapper0 AND total_quantity>0 AND relabel_application_id IS NULL") + "WHERE id=#{id} AND status='AVAILABLE' AND count_status='CONFIRMED' AND sku_types>0 AND total_quantity>0 AND relabel_application_id IS NULL") int markRelabelPending(@Param("id") long id, @Param("applicationId") long applicationId, @Param("relabelNo") String relabelNo, @Param("updatedAt") java.time.LocalDateTime updatedAt); @@ -81,12 +83,13 @@ public interface ReturnInventoryCartonMapper extends BaseMapper listReceipt(@Param("receiptId") long receiptId); - @Select("SELECT c.* FROM ll_return_inventory_carton c JOIN ll_return_inventory_carton_item i ON i.carton_id=c.id WHERE c.customer_id=#{customerId} AND c.overseas_warehouse_id=#{warehouseId} AND c.pallet_code=#{palletCode} AND c.status='AVAILABLE' ORDER BY c.id LIMIT 1 FOR UPDATE") + @Select("SELECT c.* FROM ll_return_inventory_carton c JOIN ll_return_inventory_carton_item i ON i.carton_id=c.id WHERE c.customer_id=#{customerId} AND c.overseas_warehouse_id=#{warehouseId} AND c.pallet_code=#{palletCode} AND c.status='AVAILABLE' AND c.count_status='CONFIRMED' ORDER BY c.id LIMIT 1 FOR UPDATE") ReturnInventoryCarton findPalletSku(@Param("customerId") String customerId, @Param("warehouseId") String warehouseId, @Param("palletCode") String palletCode); - @Select("SELECT * FROM ll_return_inventory_carton WHERE overseas_warehouse_id=#{warehouseId} AND pallet_code=#{palletCode} AND status='AVAILABLE' ORDER BY id LIMIT 1") + @Select("SELECT * FROM ll_return_inventory_carton WHERE overseas_warehouse_id=#{warehouseId} AND pallet_code=#{palletCode} AND status='AVAILABLE' AND count_status='CONFIRMED' ORDER BY id LIMIT 1") ReturnInventoryCarton findPalletSkuForCheck(@Param("customerId") String ignoredCustomerId, @Param("warehouseId") String warehouseId, @Param("palletCode") String palletCode); + // Carrier ownership checks must include boxes still waiting for contents counting. @Select("SELECT * FROM ll_return_inventory_carton WHERE overseas_warehouse_id=#{warehouseId} AND pallet_code=#{palletCode} AND status='AVAILABLE' ORDER BY id") List listPalletInventory(@Param("warehouseId") String warehouseId, @Param("palletCode") String palletCode); @@ -103,7 +106,7 @@ public interface ReturnInventoryCartonMapper extends BaseMapperAND c.overseas_warehouse_id=#{warehouseId} " + "AND c.carton_mark LIKE CONCAT('%',#{cartonMark},'%') " + "AND c.source_return_no LIKE CONCAT('%',#{returnNo},'%') " + - "AND c.status=#{status} " + + "AND (CASE WHEN c.count_status='PENDING' AND c.status='AVAILABLE' THEN 'PENDING_COUNT' ELSE c.status END)=#{status} " + "AND EXISTS(SELECT 1 FROM ll_return_inventory_carton_item i WHERE i.carton_id=c.id AND i.sku_code LIKE CONCAT('%',#{skuCode},'%')) ") long platformCount(@Param("customerId") String customerId, @Param("warehouseId") String warehouseId, @Param("cartonMark") String cartonMark, @Param("returnNo") String returnNo, @Param("status") String status, @Param("skuCode") String skuCode); @@ -113,7 +116,7 @@ public interface ReturnInventoryCartonMapper extends BaseMapperAND c.overseas_warehouse_id=#{warehouseId} " + "AND c.carton_mark LIKE CONCAT('%',#{cartonMark},'%') " + "AND c.source_return_no LIKE CONCAT('%',#{returnNo},'%') " + - "AND c.status=#{status} " + + "AND (CASE WHEN c.count_status='PENDING' AND c.status='AVAILABLE' THEN 'PENDING_COUNT' ELSE c.status END)=#{status} " + "AND EXISTS(SELECT 1 FROM ll_return_inventory_carton_item i WHERE i.carton_id=c.id AND i.sku_code LIKE CONCAT('%',#{skuCode},'%')) " + "ORDER BY c.created_at DESC,c.id DESC LIMIT #{offset},#{size}") List platformPage(@Param("customerId") String customerId, @Param("warehouseId") String warehouseId, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryOperationMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryOperationMapper.java index d0a866fcb..54faede60 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryOperationMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryOperationMapper.java @@ -8,6 +8,11 @@ import org.apache.ibatis.annotations.Select; @Mapper public interface ReturnInventoryOperationMapper extends BaseMapper { + @Select("SELECT o.* FROM ll_return_inventory_operation o JOIN ll_return_inventory_carton c ON c.id=o.result_carton_id " + + "WHERE c.source_receipt_id=#{receiptId} AND c.customer_id=#{customerId} AND o.operation_type IN ('COUNT_CONTENTS','SIGN_CONTENTS') " + + "ORDER BY CASE WHEN o.operation_type='COUNT_CONTENTS' THEN 0 ELSE 1 END, o.created_at DESC, o.id DESC LIMIT 1") + ReturnInventoryOperation receiptContents(@Param("customerId") String customerId, @Param("receiptId") long receiptId); + @Select("SELECT * FROM ll_return_inventory_operation WHERE operator_id=#{operatorId} AND idempotency_key=#{key} LIMIT 1") ReturnInventoryOperation findKey(@Param("operatorId") String operatorId, @Param("key") String key); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryQueryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryQueryMapper.java index b5d131221..c665877f5 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryQueryMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryQueryMapper.java @@ -11,7 +11,7 @@ import java.util.List; public interface ReturnInventoryQueryMapper { @Select("") + long countPendingContents(@Param("warehouseId") String warehouseId, @Param("keyword") String keyword); + @Select("") + List pendingContents(@Param("warehouseId") String warehouseId, @Param("keyword") String keyword, + @Param("offset") long offset, @Param("size") long size); @Select("SELECT * FROM ll_return_receipt WHERE operator_id=#{operatorId} AND idempotency_key=#{key} LIMIT 1") ReturnReceipt findKey(@Param("operatorId") String operatorId, @Param("key") String key); @Select("SELECT * FROM ll_return_receipt WHERE operator_id=#{operatorId} AND idempotency_key=#{key} LIMIT 1 FOR UPDATE") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnCartonCountGuard.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnCartonCountGuard.java new file mode 100644 index 000000000..e59e9768e --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnCartonCountGuard.java @@ -0,0 +1,14 @@ +package com.baiyue.lastleg.returnreceipt.service; + +import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +public final class ReturnCartonCountGuard { + private ReturnCartonCountGuard() { } + public static void requireConfirmed(ReturnInventoryCarton carton) { + if (!"CONFIRMED".equals(carton.getCountStatus())) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "退货箱尚未完成箱内清点,请先到 PDA 退货箱内清点作业:" + carton.getCartonMark()); + } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnCartonCountService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnCartonCountService.java new file mode 100644 index 000000000..57879c8ea --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnCartonCountService.java @@ -0,0 +1,172 @@ +package com.baiyue.lastleg.returnreceipt.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity; +import com.baiyue.lastleg.returnreceipt.dto.ReturnCartonCountDtos.*; +import com.baiyue.lastleg.returnreceipt.entity.*; +import com.baiyue.lastleg.returnreceipt.mapper.*; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; +import java.time.LocalDateTime; +import java.util.*; + +/** Box quantities are released together after reconciliation to the immutable pallet receipt totals. */ +@Service +public class ReturnCartonCountService { + @Resource private ReturnReceiptMapper receipts; + @Resource private ReturnReceiptItemMapper receiptItems; + @Resource private ReturnInventoryCartonMapper cartons; + @Resource private ReturnInventoryCartonItemMapper items; + @Resource private ReturnInventoryBalanceMapper balances; + @Resource private ReturnInventoryOperationMapper operations; + @Resource private ReturnCartonCountSessionMapper sessions; + @Resource private ObjectMapper objectMapper; + + @Transactional(readOnly=true) + public PageResult page(PdaWarehouseIdentity identity, Query query) { + String warehouse = Long.toString(identity.overseasWarehouseId()); + long total = receipts.countPendingContents(warehouse, query.keyword()); + List tasks = receipts.pendingContents(warehouse, query.keyword(), (query.page()-1)*query.size(), query.size()).stream() + .map(receipt -> task(receipt, cartons.listReceipt(receipt.getId()))).toList(); + return new PageResult<>(tasks, query.page(), query.size(), total, (total+query.size()-1)/query.size()); + } + + @Transactional(readOnly=true) + public List scanTasks(PdaWarehouseIdentity identity, String code) { + if (code == null || code.isBlank() || code.trim().length() > 128) throw error(HttpStatus.BAD_REQUEST, "请扫描有效的箱号或托盘号"); + return receipts.scanPendingContents(Long.toString(identity.overseasWarehouseId()), code.trim()).stream() + .map(receipt -> task(receipt, cartons.listReceipt(receipt.getId()))).toList(); + } + + @Transactional(readOnly=true) + public Task scan(PdaWarehouseIdentity identity, String mark) { + ReturnInventoryCarton carton = cartons.findMark(mark.trim()); + if (carton == null || !Long.toString(identity.overseasWarehouseId()).equals(carton.getOverseasWarehouseId())) throw error(HttpStatus.NOT_FOUND, "当前仓没有该退货箱"); + if (!"PALLET".equals(carton.getSourceUnitType()) || carton.getSourceReceiptId() == null) throw error(HttpStatus.CONFLICT, "该箱不属于托盘签收清点任务"); + ReturnReceipt receipt = receipts.selectById(carton.getSourceReceiptId()); + if (receipt == null) throw error(HttpStatus.NOT_FOUND, "签收批次不存在"); + return task(receipt, cartons.listReceipt(receipt.getId())); + } + + @Transactional + public Task begin(PdaWarehouseIdentity identity, String receiptNo) { + var session = lockSession(identity); + ReturnReceipt receipt = receipts.lockCountReceipt(receiptNo); + if (receipt == null) throw error(HttpStatus.NOT_FOUND, "清点任务不存在"); + if (session.receiptId() != null && !session.receiptId().equals(receipt.getId())) + throw error(HttpStatus.CONFLICT, "您有未清点完成的任务,请先继续当前清点;一次只能清点一单"); + List boxes = cartons.lockReceipt(receipt.getId()); + requireWarehouse(identity, receipt, boxes); + if (boxes.stream().anyMatch(box -> !"PENDING".equals(box.getCountStatus()) || !"AVAILABLE".equals(box.getStatus()))) + throw error(HttpStatus.CONFLICT, "该任务无需清点或箱子已被其他作业占用,请刷新任务"); + var owner = sessions.findReceipt(receipt.getId()); + if (owner != null && !identity.operatorKey().equals(owner.operatorId())) + throw error(HttpStatus.CONFLICT, "该任务正在由其他人员清点"); + if (session.receiptId() == null && sessions.claim(identity.operatorKey(), Long.toString(identity.overseasWarehouseId()), receipt.getId()) != 1) + throw error(HttpStatus.CONFLICT, "当前清点任务已变化,请重试"); + return task(receipt, boxes); + } + + @Transactional(readOnly=true) + public Task current(PdaWarehouseIdentity identity) { + var session = sessions.findOperator(identity.operatorKey()); + if (session == null || session.receiptId() == null) return null; + if (!Long.toString(identity.overseasWarehouseId()).equals(session.warehouseId())) + throw error(HttpStatus.CONFLICT, "您在其他仓有未完成的清点任务,请先完成原任务"); + ReturnReceipt receipt = receipts.selectById(session.receiptId()); + if (receipt == null) throw error(HttpStatus.CONFLICT, "当前清点任务已异常,请核实原任务"); + var boxes = cartons.listReceipt(receipt.getId()); + requireWarehouse(identity, receipt, boxes); + return task(receipt, boxes); + } + + private ReturnCartonCountSessionMapper.Session lockSession(PdaWarehouseIdentity identity) { + sessions.ensure(identity.operatorKey(), Long.toString(identity.overseasWarehouseId())); + var session = sessions.lockOperator(identity.operatorKey()); + if (session == null) throw error(HttpStatus.CONFLICT, "清点会话未建立,请重试"); + return session; + } + + private void requireWarehouse(PdaWarehouseIdentity identity, ReturnReceipt receipt, List boxes) { + if (boxes.isEmpty() || boxes.stream().anyMatch(box -> !Long.toString(identity.overseasWarehouseId()).equals(box.getOverseasWarehouseId()) || !receipt.getCustomerId().equals(box.getCustomerId()))) + throw error(HttpStatus.NOT_FOUND, "当前仓没有该托盘签收任务"); + } + + @Transactional + public Task confirm(PdaWarehouseIdentity identity, String receiptNo, Confirm request) { + var session = lockSession(identity); + ReturnReceipt receipt = receipts.lockCountReceipt(receiptNo); + if (receipt == null) throw error(HttpStatus.NOT_FOUND, "托盘签收任务不存在"); + List boxes = cartons.lockReceipt(receipt.getId()); + requireWarehouse(identity, receipt, boxes); + String hash = payload(List.of(receiptNo, request.boxes())); + ReturnInventoryOperation replay = operations.lockKey(identity.operatorKey(), request.idempotencyKey()); + if (replay != null) { + if (!"COUNT_CONTENTS".equals(replay.getOperationType()) || !hash.equals(replay.getRequestHash())) throw error(HttpStatus.CONFLICT, "该幂等键已用于不同的清点内容"); + try { return objectMapper.readValue(replay.getResultJson(), Task.class); } + catch (Exception error) { throw new IllegalStateException("清点结果读取失败", error); } + } + if (!Objects.equals(session.receiptId(), receipt.getId()) || !Long.toString(identity.overseasWarehouseId()).equals(session.warehouseId())) + throw error(HttpStatus.CONFLICT, "请先开始本单清点;未完成当前单前不能清点其他单"); + if (boxes.stream().anyMatch(box -> !"PENDING".equals(box.getCountStatus()) || !"AVAILABLE".equals(box.getStatus()) || box.getRelabelApplicationId() != null)) throw error(HttpStatus.CONFLICT, "箱子已清点或被其他作业占用,请刷新任务"); + Map byMark = new LinkedHashMap<>(); boxes.forEach(box -> byMark.put(box.getCartonMark(), box)); + if (request.boxes().size() != boxes.size()) throw error(HttpStatus.BAD_REQUEST, "必须清点本次托盘签收的全部箱子"); + Map expected = new TreeMap<>(), actual = new TreeMap<>(), oldCounts = new TreeMap<>(), newCounts = new TreeMap<>(); + Map names = new HashMap<>(); + for (ReturnReceiptItem line : receiptItems.listReceipt(receipt.getId())) expected.merge(line.getSkuCode(), line.getReceivedQuantity(), Math::addExact); + for (ReturnInventoryCarton box : boxes) for (ReturnInventoryCartonItem line : items.lockCarton(box.getId())) { + if (line.getPackingReservedQuantity() != null && line.getPackingReservedQuantity() > 0) throw error(HttpStatus.CONFLICT, "来源箱已被合箱计划占用"); + oldCounts.merge(line.getSkuCode(), 1L, Long::sum); names.put(line.getSkuCode(), line.getProductNameZh()); + } + Set marks = new HashSet<>(); + for (CountBox box : request.boxes()) { + if (!byMark.containsKey(box.cartonMark()) || !marks.add(box.cartonMark())) throw error(HttpStatus.BAD_REQUEST, "箱号不属于本次签收或重复填写"); + Set skus = new HashSet<>(); + for (CountItem line : box.items()) { + if (!expected.containsKey(line.skuCode()) || !skus.add(line.skuCode()) || line.quantity() <= 0) throw error(HttpStatus.BAD_REQUEST, "SKU 不属于本次签收、数量无效或同箱重复"); + actual.merge(line.skuCode(), line.quantity(), Math::addExact); newCounts.merge(line.skuCode(), 1L, Long::sum); + } + } + if (!expected.equals(actual)) throw error(HttpStatus.CONFLICT, "各箱 SKU 数量汇总必须与本托签收数量一致;签收=" + expected + ",清点=" + actual); + LocalDateTime now = LocalDateTime.now(); + for (CountBox input : request.boxes()) { + ReturnInventoryCarton box = byMark.get(input.cartonMark()); items.deleteCarton(box.getId()); + for (CountItem line : input.items()) { + ReturnInventoryCartonItem item = new ReturnInventoryCartonItem(); item.setId(IdWorker.getId()); item.setCartonId(box.getId()); + item.setCustomerId(box.getCustomerId()); item.setSkuCode(line.skuCode()); item.setProductNameZh(names.get(line.skuCode())); item.setQuantity(line.quantity()); + item.setPackingReservedQuantity(0L); items.insert(item); + } + box.setSkuTypes(input.items().size()); box.setTotalQuantity(input.items().stream().mapToLong(CountItem::quantity).sum()); + box.setCountStatus("CONFIRMED"); box.setCountedAt(now); box.setCountedBy(identity.operatorKey()); box.setUpdatedAt(now); cartons.updateById(box); + } + for (String sku : expected.keySet()) { + long delta = newCounts.getOrDefault(sku, 0L) - oldCounts.getOrDefault(sku, 0L); + if (delta != 0 && balances.adjustCartonQuantity(receipt.getCustomerId(), boxes.get(0).getOverseasWarehouseId(), sku, delta) != 1) throw error(HttpStatus.CONFLICT, "SKU 箱数余额已变化,请重新核对"); + } + if (sessions.release(identity.operatorKey(), receipt.getId()) != 1) throw error(HttpStatus.CONFLICT, "清点会话已变化,请重新核对"); + Task result = task(receipt, boxes); + ReturnInventoryOperation operation = new ReturnInventoryOperation(); operation.setId(IdWorker.getId()); operation.setOperatorId(identity.operatorKey()); + operation.setIdempotencyKey(request.idempotencyKey()); operation.setRequestHash(hash); operation.setOperationType("COUNT_CONTENTS"); + operation.setResultCartonId(boxes.get(0).getId()); operation.setResultJson(json(result)); operation.setCreatedAt(now); operation.setUpdatedAt(now); operations.insert(operation); + return result; + } + + private Task task(ReturnReceipt receipt, List boxes) { + List expected = receiptItems.listReceipt(receipt.getId()).stream().map(line -> new Sku(line.getSkuCode(), null, line.getReceivedQuantity())).toList(); + List contents = boxes.stream().map(box -> new Box(box.getCartonMark(), items.listCarton(box.getId()).stream() + .map(line -> new Sku(line.getSkuCode(), line.getProductNameZh(), "PENDING".equals(box.getCountStatus()) ? 0 : line.getQuantity())).toList())).toList(); + return new Task(receipt.getReceiptNo(), boxes.isEmpty() ? null : boxes.get(0).getSourceReturnNo(), receipt.getPalletCode(), + boxes.stream().allMatch(box -> "CONFIRMED".equals(box.getCountStatus())) ? "CONFIRMED" : sessions.findReceipt(receipt.getId()) != null ? "COUNTING" : "PENDING", expected, contents); + } + private String json(Object value) { try { return objectMapper.writeValueAsString(value); } catch(Exception error) { throw new IllegalStateException(error); } } + private String payload(Object value) { + try { return HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(json(value).getBytes(java.nio.charset.StandardCharsets.UTF_8))); } + catch(Exception error) { throw new IllegalStateException(error); } + } + private ResponseStatusException error(HttpStatus status, String message) { return new ResponseStatusException(status, message); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryService.java index b5d0c3275..e5841fed6 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryService.java @@ -142,6 +142,7 @@ public class ReturnInventoryService { || !first.getOverseasWarehouseId().equals(carton.getOverseasWarehouseId()) || !AVAILABLE.equals(carton.getStatus()) || !sourceMatches(carton, returnNos)) throw conflict("来源箱不属于所选退货单或已被占用"); + ReturnCartonCountGuard.requireConfirmed(carton); if ("BOX".equals(mode) && "PARCEL".equals(carton.getSourceUnitType())) throw badRequest("包裹散 SKU 不能作为整箱选择"); for (ReturnInventoryCartonItem item : cartonItems.lockCarton(carton.getId())) { long free = Math.max(0, value(item.getQuantity()) - value(item.getPackingReservedQuantity())); @@ -385,6 +386,7 @@ public class ReturnInventoryService { if (carton == null || !customerId.equals(carton.getCustomerId()) || !warehouseId.equals(carton.getOverseasWarehouseId()) || !matchesReturnSource(carton, returnNo) || !"OUTBOUND_PENDING".equals(carton.getStatus())) throw conflict("退货箱状态或客户、仓库、来源不匹配"); + ReturnCartonCountGuard.requireConfirmed(carton); List contents = cartonItems.lockCarton(carton.getId()); long total = 0; var skus = new java.util.HashSet(); @@ -449,6 +451,7 @@ public class ReturnInventoryService { carton.setSourceReturnNo(order.getReturnNo()); carton.setCartonMark(cartonMark); carton.setPalletCode(text(receipt.getPalletCode())); carton.setSourceUnitType(receipt.getUnitType()); + carton.setCountStatus("PALLET".equals(receipt.getUnitType()) ? "PENDING" : "CONFIRMED"); carton.setSourceUnitCode(normalizedUnitCode); // Pallet measurements describe the carrier, never each child carton. if ("BOX".equals(receipt.getUnitType())) { @@ -464,6 +467,15 @@ public class ReturnInventoryService { balances.adjust(IdWorker.getId(), order.getCustomerId(), order.getOverseasWarehouseId(), line.skuCode(), text(line.productNameZh()), line.quantity(), line.quantity(), 1); } + if ("BOX".equals(receipt.getUnitType())) { + var contents = lines.stream().map(line -> new com.baiyue.lastleg.returnreceipt.dto.ReturnCartonCountDtos.Sku(line.skuCode(), line.productNameZh(), line.quantity())).toList(); + var snapshot = new com.baiyue.lastleg.returnreceipt.dto.ReturnCartonCountDtos.Task(receipt.getReceiptNo(), order.getReturnNo(), receipt.getPalletCode(), "CONFIRMED", contents, + List.of(new com.baiyue.lastleg.returnreceipt.dto.ReturnCartonCountDtos.Box(cartonMark, contents))); + ReturnInventoryOperation operation = new ReturnInventoryOperation(); operation.setId(IdWorker.getId()); + operation.setOperatorId(receipt.getOperatorId()); operation.setIdempotencyKey("sign-contents-" + receipt.getId() + "-" + cartonId); + operation.setRequestHash(hash(snapshot)); operation.setOperationType("SIGN_CONTENTS"); operation.setResultCartonId(cartonId); + operation.setResultJson(json(snapshot)); operation.setCreatedAt(now); operation.setUpdatedAt(now); operations.insert(operation); + } return carton; } @@ -512,7 +524,7 @@ public class ReturnInventoryService { List records = total == 0 ? List.of() : cartons.merchantPage(customerId, request, (request.page() - 1) * request.size()).stream() .map(carton -> new ReturnInventoryResponses.CartonSummary(carton.getCartonMark(), carton.getOverseasWarehouseId(), - carton.getSourceBatchNo(), carton.getSourceReturnNo(), carton.getStatus(), + carton.getSourceBatchNo(), carton.getSourceReturnNo(), displayStatus(carton), carton.getSkuTypes() == null ? 0 : carton.getSkuTypes(), value(carton.getTotalQuantity()), carton.getLength(), carton.getWidth(), carton.getHeight(), carton.getWeight(), carton.getCreatedAt())).toList(); return new PageResult<>(records, request.page(), request.size(), total, (total + request.size() - 1) / request.size()); @@ -557,7 +569,7 @@ public class ReturnInventoryService { List records = total == 0 ? List.of() : cartons.platformPage(customerId, warehouseId, cartonMark, returnNo, status, sku, (request.page() - 1) * request.size(), request.size()).stream().map(carton -> new ReturnInventoryResponses.PlatformCarton( carton.getCustomerId(), carton.getOverseasWarehouseId(), carton.getCartonMark(), carton.getSourceReturnNo(), carton.getSourceUnitType(), carton.getSourceBatchNo(), - carton.getStatus(), carton.getSkuTypes() == null ? 0 : carton.getSkuTypes(), value(carton.getTotalQuantity()), carton.getFbaCartonMark(), carton.getDestinationWarehouseCode(), carton.getDestinationWarehouseName(), carton.getRelabelNo(), carton.getRelabelStatus(), carton.getLength(), + displayStatus(carton), carton.getSkuTypes() == null ? 0 : carton.getSkuTypes(), value(carton.getTotalQuantity()), carton.getFbaCartonMark(), carton.getDestinationWarehouseCode(), carton.getDestinationWarehouseName(), carton.getRelabelNo(), carton.getRelabelStatus(), carton.getLength(), carton.getWidth(), carton.getHeight(), carton.getWeight(), carton.getCreatedAt(), carton.getLocationCode(), carton.getPalletCode(), carton.getPutawayAt())).toList(); return new PageResult<>(records, request.page(), request.size(), total, (total + request.size() - 1) / request.size()); } @@ -616,6 +628,21 @@ public class ReturnInventoryService { } /** Print only persisted physical cartons; a parcel's SKU rows are not cartons. */ + @Transactional(readOnly = true) + public List receiptContents(ReturnReceipt receipt) { + if ("PARCEL".equals(receipt.getUnitType())) return List.of(); + var snapshot = operations.receiptContents(receipt.getCustomerId(), receipt.getId()); + if (snapshot != null) { + var task = read(snapshot.getResultJson(), new TypeReference() { }); + if (!receipt.getReceiptNo().equals(task.receiptNo())) throw new IllegalStateException("箱内清点快照与签收单不一致"); + return task.boxes().stream().map(box -> new ReturnOrderResponses.ReceiptCarton(receipt.getReceiptNo(), receipt.getPalletCode(), box.cartonMark(), "CONFIRMED", snapshot.getCreatedAt(), + box.items().stream().map(line -> new ReturnOrderResponses.ReceiptItem(line.skuCode(), line.productNameZh(), line.quantity(), 1)).toList())).toList(); + } + // Never present provisional averages or subsequently changed inventory as receipt facts. + return cartons.listReceipt(receipt.getId()).stream().map(box -> new ReturnOrderResponses.ReceiptCarton(receipt.getReceiptNo(), receipt.getPalletCode(), box.getCartonMark(), + "PENDING".equals(box.getCountStatus()) ? "PENDING" : "UNRECORDED", box.getCountedAt(), List.of())).toList(); + } + @Transactional(readOnly = true) public List receiptLabels(ReturnOrder order, ReturnReceipt receipt, List lines) { @@ -780,6 +807,7 @@ public class ReturnInventoryService { ReturnInventoryCarton carton = cartons.findMark(required(mark, "箱唛不能为空")); if (carton == null || !String.valueOf(identity.overseasWarehouseId()).equals(carton.getOverseasWarehouseId())) throw notFound("当前仓没有该退货箱"); if (!AVAILABLE.equals(carton.getStatus()) || "PARCEL".equals(carton.getSourceUnitType())) throw conflict("该箱不是可用实物箱"); + ReturnCartonCountGuard.requireConfirmed(carton); return cartonDetail(carton); } @@ -801,6 +829,7 @@ public class ReturnInventoryService { if (carton == null || !AVAILABLE.equals(carton.getStatus()) || "PARCEL".equals(carton.getSourceUnitType()) || !String.valueOf(identity.overseasWarehouseId()).equals(carton.getOverseasWarehouseId())) throw conflict("来源箱不存在或不可合并"); if (customer != null && !customer.equals(carton.getCustomerId())) throw conflict("不同客户的箱不能合并"); + ReturnCartonCountGuard.requireConfirmed(carton); customer = carton.getCustomerId(); returnNos.addAll(java.util.Arrays.asList(required(carton.getSourceReturnNo(), "来源退货单缺失").split(","))); Map items = new LinkedHashMap<>(); for (var item : cartonItems.lockCarton(carton.getId())) { @@ -865,6 +894,7 @@ public class ReturnInventoryService { || limits.containsKey(targetMark))) throw conflict("目标箱不存在、被占用、跨客户或与来源箱相同"); if (existingTarget != null && cartonItems.lockCarton(existingTarget.getId()).stream().anyMatch(i -> value(i.getPackingReservedQuantity()) > 0)) throw conflict("目标箱已有合箱预占,不能并入"); + if (existingTarget != null) ReturnCartonCountGuard.requireConfirmed(existingTarget); if (existingTarget != null) for (var item : cartonItems.lockCarton(existingTarget.getId())) { long tracked = packingTrace.ownership(customerId, targetMark, item.getSkuCode()).stream() .mapToLong(com.baiyue.lastleg.returnreceipt.mapper.ReturnPackingTraceMapper.Ownership::quantity).sum(); @@ -894,6 +924,7 @@ public class ReturnInventoryService { && String.valueOf(identity.overseasWarehouseId()).equals(locked.getOverseasWarehouseId()) && sourceMatches(locked, returnNos) && (limits != null || "PARCEL".equals(locked.getSourceUnitType()))) { sources.add(locked); sourceOwners.put(locked.getId(), candidate.getValue()); + ReturnCartonCountGuard.requireConfirmed(locked); } } if (sources.isEmpty()) throw conflict("所选退货单没有可合箱的库存箱"); @@ -1031,7 +1062,7 @@ public class ReturnInventoryService { .map(item -> new ReturnInventoryResponses.CartonItem(item.getSkuCode(), item.getProductNameZh(), item.getQuantity(), Math.max(0, value(item.getQuantity()) - value(item.getPackingReservedQuantity())))).toList(); return new ReturnInventoryResponses.Label(carton.getCartonMark(), carton.getOverseasWarehouseId(), carton.getCustomerId(), - carton.getSourceBatchNo(), carton.getSourceReturnNo(), value(carton.getTotalQuantity()), carton.getLength(), carton.getWidth(), carton.getHeight(), carton.getWeight(), items, LocalDateTime.now(), printableHtml(carton, items)); + carton.getSourceBatchNo(), carton.getSourceReturnNo(), value(carton.getTotalQuantity()), carton.getLength(), carton.getWidth(), carton.getHeight(), carton.getWeight(), items, LocalDateTime.now(), printableHtml(carton)); } private ReturnInventoryResponses.InventoryItem item(ReturnInventoryRow row) { @@ -1045,26 +1076,24 @@ public class ReturnInventoryService { value(balance.getTotalQuantity()), value(balance.getAvailableQuantity()), value(balance.getCartonQuantity()), balance.getUpdatedAt()); } + private static String displayStatus(ReturnInventoryCarton carton) { + return "PENDING".equals(carton.getCountStatus()) && AVAILABLE.equals(carton.getStatus()) ? "PENDING_COUNT" : carton.getStatus(); + } + private ReturnInventoryResponses.CartonDetail cartonDetail(ReturnInventoryCarton carton) { List items = cartonItems.listCarton(carton.getId()).stream() .map(item -> new ReturnInventoryResponses.CartonItem(item.getSkuCode(), item.getProductNameZh(), item.getQuantity(), - "AVAILABLE".equals(carton.getStatus()) + "AVAILABLE".equals(carton.getStatus()) && "CONFIRMED".equals(carton.getCountStatus()) ? Math.max(0, value(item.getQuantity()) - value(item.getPackingReservedQuantity())) : 0)).toList(); return new ReturnInventoryResponses.CartonDetail(carton.getCartonMark(), carton.getCustomerId(), carton.getOverseasWarehouseId(), carton.getSourceBatchNo(), carton.getSourceReturnNo(), - carton.getStatus(), carton.getFbaCartonMark(), carton.getDestinationWarehouseCode(), carton.getDestinationWarehouseName(), carton.getRelabelNo(), carton.getRelabelStatus(), carton.getSkuTypes() == null ? 0 : carton.getSkuTypes(), value(carton.getTotalQuantity()), carton.getLength(), carton.getWidth(), carton.getHeight(), carton.getWeight(), carton.getCreatedAt(), items, carton.getLocationCode(), carton.getPalletCode(), carton.getPutawayAt(), packingTrace.listCartonHistory(carton.getCustomerId(), carton.getCartonMark())); + displayStatus(carton), carton.getFbaCartonMark(), carton.getDestinationWarehouseCode(), carton.getDestinationWarehouseName(), carton.getRelabelNo(), carton.getRelabelStatus(), carton.getSkuTypes() == null ? 0 : carton.getSkuTypes(), value(carton.getTotalQuantity()), carton.getLength(), carton.getWidth(), carton.getHeight(), carton.getWeight(), carton.getCreatedAt(), items, carton.getLocationCode(), carton.getPalletCode(), carton.getPutawayAt(), packingTrace.listCartonHistory(carton.getCustomerId(), carton.getCartonMark())); } - private static String printableHtml(ReturnInventoryCarton carton, List items) { - StringBuilder rows = new StringBuilder(); - for (ReturnInventoryResponses.CartonItem item : items) { - rows.append("
") - .append(escape(item.skuCode())) - .append("
"); - } + private static String printableHtml(ReturnInventoryCarton carton) { return "退货箱唛
" + + "@page{size:100mm 100mm;margin:8mm}*{box-sizing:border-box}body{font-family:Arial,'Microsoft YaHei',sans-serif;color:#111;margin:0}.label{width:84mm;height:84mm;padding:4mm;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;page-break-after:always}.label:last-child{page-break-after:auto}.mark{font:700 14px Arial,sans-serif;line-height:1.25;overflow-wrap:anywhere;margin:0 0 5mm}.barcode{width:100%;max-width:76mm;margin:0}.barcode svg{display:block;width:100%;height:auto}
" + escape(carton.getCartonMark()) + "
" - + ReturnCartonBarcode.svg(carton.getCartonMark()) + "
" + rows + + ReturnCartonBarcode.svg(carton.getCartonMark()) + "
" + "
"; } private static String dimension(BigDecimal value) { return value == null ? "—" : value.stripTrailingZeros().toPlainString(); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptService.java index f32ec0f1d..accdef6c8 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptService.java @@ -124,7 +124,7 @@ public class ReturnReceiptService { private void createTailOrder(ReturnOrder returnOrder, String operatorId, LocalDateTime now) { LastlegOrder tail = new LastlegOrder(); tail.setId(IdWorker.getId()); - tail.setOrderNo(numbers.next("RETURN_RELABEL", returnOrder.getOverseasWarehouseId(), tail.getId())); + tail.setOrderNo(returnOrder.getReturnNo()); tail.setCustomerId(returnOrder.getCustomerId()); tail.setOrderType("RETURN_RELABEL"); tail.setSourceRegion("CUSTOMER"); tail.setSourceInboundId(returnOrder.getReturnNo()); tail.setSourceOrderId(String.valueOf(returnOrder.getId())); @@ -301,7 +301,8 @@ public class ReturnReceiptService { List photos = photoList(order).stream() .map(file -> new ReturnOrderResponses.Photo(file.fileId(), file.fileName(), file.contentType(), file.sizeBytes())).toList(); return new ReturnOrderResponses.Detail(summary(order, relabelSubOrders(List.of(order)).getOrDefault(order.getLastlegOrderId(), List.of())), order.getRemark(), items, receiptViews, palletViews, photos, - inventory.packingHistory(order.getCustomerId(), order.getReturnNo())); + inventory.packingHistory(order.getCustomerId(), order.getReturnNo()), + orderReceipts.stream().flatMap(receipt -> inventory.receiptContents(receipt).stream()).toList()); } private String combineLabels(List labels) { @@ -364,7 +365,7 @@ public class ReturnReceiptService { List result = new ArrayList<>(); for (ReturnReceipt receipt : receipts.listOrder(order.getId())) { for (ReturnInventoryCarton carton : cartons.listReceipt(receipt.getId())) { - if (!ReturnInventoryService.AVAILABLE.equals(carton.getStatus()) || carton.getRelabelApplicationId() != null) continue; + if (!ReturnInventoryService.AVAILABLE.equals(carton.getStatus()) || !"CONFIRMED".equals(carton.getCountStatus()) || carton.getRelabelApplicationId() != null) continue; List items = cartonItems.listCarton(carton.getId()).stream() .map(i -> new ReturnOrderResponses.Item(i.getSkuCode(), i.getProductNameZh(), value(i.getQuantity()), value(i.getQuantity()))).toList(); result.add(new ReturnOrderResponses.RelabelCandidate(carton.getId(), carton.getCartonMark(), carton.getSourceReturnNo(), carton.getStatus(), value(carton.getTotalQuantity()), items, carton.getRelabelStatus())); @@ -457,6 +458,7 @@ public class ReturnReceiptService { if (cartonItems.lockCarton(carton.getId()).stream().anyMatch(line -> value(line.getPackingReservedQuantity()) > 0)) throw conflict("退货 SKU 已被合箱计划锁定,请先完成或取消合箱计划"); long allocated = Math.min(remaining, skuQuantity); + ReturnCartonCountGuard.requireConfirmed(carton); RelabelItem item = new RelabelItem(); item.setId(IdWorker.getId()); item.setApplicationId(appId); item.setCustomerId(customerId); item.setSkuCode(source.getSkuCode()); item.setProductNameZh(text(source.getProductNameZh()) == null ? source.getSkuCode() : text(source.getProductNameZh())); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java index a2e1807ff..fa378ce91 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java @@ -72,6 +72,7 @@ public class LastlegOrderService { "OVERSEAS_CUSTOMS_COMPLETED", "ACTUAL_ARRIVAL_WAITING_UNLOAD", "PICKUP_APPOINTED", "UNLOADED"); @Resource private LastlegOrderMapper orderMapper; + @Resource private com.baiyue.lastleg.returnreceipt.service.ReturnReceiptService returnReceipts; @Resource private LastlegOrderDetailMapper detailMapper; @Resource private LastlegOrderStatusHistoryMapper historyMapper; @Resource private OverseasFeeSourceService feeSources; @@ -364,7 +365,9 @@ public class LastlegOrderService { .map(value -> new LastlegOrderDetailResponse.History(value.getId(), value.getFromStatus(), value.getToStatus(), value.getRemark(), value.getCreatedAt())).toList(), fees, operations.stream() .map(value -> new LastlegOrderDetailResponse.Operation(value.getId(), value.getOperationNode(), - value.getOperatorName(), value.getResult(), value.getCreatedAt())).toList()); + value.getOperatorName(), value.getResult(), value.getCreatedAt())).toList(), + "RETURN_RELABEL".equals(order.getOrderType()) && StringUtils.hasText(order.getSourceOrderNo()) + ? returnReceipts.detail(order.getCustomerId(), order.getSourceOrderNo()) : null); } @Transactional @@ -653,7 +656,7 @@ public class LastlegOrderService { } static String displayOrderNo(LastlegOrder order) { - return "WAREHOUSE_TRANSFER".equals(order.getOrderType()) && StringUtils.hasText(order.getSourceOrderNo()) + return ("WAREHOUSE_TRANSFER".equals(order.getOrderType()) || "RETURN_RELABEL".equals(order.getOrderType())) && StringUtils.hasText(order.getSourceOrderNo()) ? order.getSourceOrderNo() : order.getOrderNo(); } diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 8050d6ba2..78a58d645 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -3160,3 +3160,37 @@ SET @return_outbound_ddl = IF(EXISTS(SELECT 1 FROM information_schema.columns WH PREPARE return_outbound_stmt FROM @return_outbound_ddl; EXECUTE return_outbound_stmt; DEALLOCATE PREPARE return_outbound_stmt; + +-- 退货托盘签收箱内清点;MySQL 8;仅在目标尾程库执行,可重复运行。 +USE baiyue_lastleg; +SET @rc_count_ddl = IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='count_status'),'SELECT 1','ALTER TABLE ll_return_inventory_carton ADD COLUMN count_status VARCHAR(16) NOT NULL DEFAULT ''CONFIRMED'' COMMENT ''箱内清点状态 PENDING待清点 CONFIRMED已确认'''); +PREPARE rc_count_stmt FROM @rc_count_ddl; EXECUTE rc_count_stmt; DEALLOCATE PREPARE rc_count_stmt; +SET @rc_count_ddl = IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='counted_at'),'SELECT 1','ALTER TABLE ll_return_inventory_carton ADD COLUMN counted_at DATETIME(3) NULL COMMENT ''箱内清点确认时间'''); +PREPARE rc_count_stmt FROM @rc_count_ddl; EXECUTE rc_count_stmt; DEALLOCATE PREPARE rc_count_stmt; +SET @rc_count_ddl = IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='counted_by'),'SELECT 1','ALTER TABLE ll_return_inventory_carton ADD COLUMN counted_by VARCHAR(128) NULL COMMENT ''箱内清点确认操作人'''); +PREPARE rc_count_stmt FROM @rc_count_ddl; EXECUTE rc_count_stmt; DEALLOCATE PREPARE rc_count_stmt; +SET @rc_count_ddl = IF(EXISTS(SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND index_name='idx_ll_return_count'),'SELECT 1','CREATE INDEX idx_ll_return_count ON ll_return_inventory_carton(overseas_warehouse_id,count_status,status,source_receipt_id)'); +PREPARE rc_count_stmt FROM @rc_count_ddl; EXECUTE rc_count_stmt; DEALLOCATE PREPARE rc_count_stmt; +-- 历史箱:只迁移整批仍可用、没有换标/合箱占用、未确认清点的托盘签收箱。 +-- 已进入履约或合箱的批次不回退;后续需要逐批人工核实。 +UPDATE ll_return_inventory_carton c +JOIN (SELECT source_receipt_id FROM (SELECT a.source_receipt_id + FROM ll_return_inventory_carton a WHERE a.source_receipt_id IS NOT NULL AND a.source_unit_type='PALLET' + GROUP BY a.source_receipt_id + HAVING SUM(a.status<>'AVAILABLE' OR a.relabel_application_id IS NOT NULL OR a.relabel_no IS NOT NULL OR a.counted_at IS NOT NULL)=0 + AND SUM(EXISTS(SELECT 1 FROM ll_return_inventory_carton_item i WHERE i.carton_id=a.id AND COALESCE(i.packing_reserved_quantity,0)>0))=0 + AND SUM(EXISTS(SELECT 1 FROM ll_return_packing_trace t WHERE t.source_carton_mark=a.carton_mark OR t.target_carton_mark=a.carton_mark))=0 +) pending) p ON p.source_receipt_id=c.source_receipt_id +SET c.count_status='PENDING' WHERE c.source_unit_type='PALLET' AND c.counted_at IS NULL; + +-- 退货箱内清点作业会话:每位操作员只能认领一个批次,每个批次只能被一人认领。 +CREATE TABLE IF NOT EXISTS ll_return_carton_count_session ( + operator_id VARCHAR(128) NOT NULL COMMENT '平台PDA操作员标识', + warehouse_id VARCHAR(128) NOT NULL COMMENT '认领时绑定海外仓标识', + receipt_id BIGINT NULL COMMENT '正在清点的签收批次,完成后释放为NULL', + started_at DATETIME(3) NULL COMMENT '本次开始清点时间', + created_at DATETIME(3) NOT NULL COMMENT '会话创建时间', + updated_at DATETIME(3) NOT NULL COMMENT '会话更新时间', + PRIMARY KEY(operator_id), + UNIQUE KEY uk_ll_return_count_session_receipt(receipt_id) +) ENGINE=InnoDB COMMENT='退货箱内清点单人单任务持久会话'; diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index 99eb066fc..e925b5f76 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -233,13 +233,14 @@ paths: /api/platform/v1/last-leg/orders/{id}: get: summary: 查询尾程订单详情 + x-return-order-number: RETURN_RELABEL 类型的 order.orderNo 与来源退货单 returnNo 相同;创建时使用同一 RT 单号,退货关联快照 lastlegOrderNo 同步保存该号。 description: 权限 platform:overseas-warehouse:last-mile-order:list;返回订单、货物、状态历史、费用明细和操作记录。订单中的 containerNode 来自稳定 sourceContainerBookingId 对应的柜级船节点门禁;grossWeightKg/netWeightKg 由头程冻结货物明细聚合。 security: [{ bearerAuth: [] }] parameters: - { name: id, in: path, required: true, schema: { type: integer, format: int64 } } - { name: customerId, in: query, required: false, schema: { type: string, pattern: '^[1-9][0-9]*$' } } responses: - '200': { description: 统一 ApiResponse 包装的尾程订单详情;order.relabelApplications 返回所有换标子单的独立状态和本次数量 } + '200': { description: 统一 ApiResponse 包装的尾程订单详情;order.relabelApplications 返回所有换标子单的独立状态和本次数量。RETURN_RELABEL 订单另返回 returnReceipt,其结构与退货单详情相同,按订单真实客户与 sourceOrderNo 读取签收汇总和 cartons 箱内签收清点快照;其他类型 returnReceipt 为 null } '404': { description: 尾程订单不存在或不属于查询客户 } /api/platform/v1/last-leg/orders/{id}/relabel-review: put: @@ -1611,7 +1612,7 @@ paths: '200': { description: 客户换标申请分页, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelMerchantPageResponse' } } } } post: summary: 创建客户换标申请 - description: 权限 merchant:overseas-warehouse:relabel:create;申请创建即冻结对应库存。SKU_LABEL 不要求派送方式;CARTON_LABEL/CARTON_AND_SKU 必须指定 TRUCK、PICKUP 或 EXPRESS。 + description: 权限 merchant:overseas-warehouse:relabel:create;申请创建即冻结对应库存。一张申请只能选择一种换标类型,所有 items.operationType 必须一致;不同类型混合提交返回业务参数错误且不冻结库存。SKU_LABEL 不要求派送方式;CARTON_LABEL/CARTON_AND_SKU 必须指定 TRUCK、PICKUP 或 EXPRESS。 security: [{ bearerAuth: [] }] requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelCreate' } } } } responses: @@ -1721,7 +1722,7 @@ paths: /api/merchant/v1/last-leg/return-orders/{returnNo}: get: summary: 客户查询退货订单与签收批次 - description: 权限 merchant:overseas-warehouse:return-order:list;返回各 SKU 的预报和实收数量,以及每次 PDA 签收产生的批次号和可选快递单号。 + description: 权限 merchant:overseas-warehouse:return-order:list;返回各 SKU 的预报和实收数量,以及每次 PDA 签收产生的批次号和可选快递单号。cartons 数组包含 receiptNo、palletCode、cartonMark、countStatus、countedAt、items(skuCode、productNameZh、quantity、boxCount)。CONFIRMED 为签收或清点时不可变箱内快照;PENDING 的 items 为空,不返回预分配平均数;UNRECORDED 表示历史未留存箱内快照,不能以已变化库存冒充签收数量。包裹散件不生成实物箱明细。 security: [{ bearerAuth: [] }] parameters: [{ name: returnNo, in: path, required: true, schema: { type: string } }] responses: { '200': { description: 当前客户退货订单详情 }, '404': { description: 当前客户下退货订单不存在 } } diff --git a/lastleg-service/src/main/resources/openapi/return-carton-counting-v1.yaml b/lastleg-service/src/main/resources/openapi/return-carton-counting-v1.yaml new file mode 100644 index 000000000..effcc5473 --- /dev/null +++ b/lastleg-service/src/main/resources/openapi/return-carton-counting-v1.yaml @@ -0,0 +1,93 @@ +openapi: 3.0.3 +info: + title: 退货托盘箱内清点 + version: 1.0.0 +security: [{ bearerAuth: [] }] +paths: + /api/pda/v1/last-leg/return-inventory/counting/current: + get: + summary: 读取当前登录操作员未完成的清点任务 + description: 作业会话由数据库保存,退出页面或刷新不释放;只返回本人且绑定仓一致的任务。无任务data为null。 + responses: + '200': { description: COUNTING任务或null } + '409': { description: 操作员在其他仓仍有未完成任务或原任务异常 } + /api/pda/v1/last-leg/return-inventory/counting/receipts/{receiptNo}/begin: + post: + summary: 弹窗确认后开始或恢复清点任务 + description: 按操作员会话行锁和签收批次行锁串行认领,数据库保证一人一任务且一任务一人。同一操作员重复认领当前任务自然幂等;本单状态COUNTING,所有箱仍不可换标或出库。开始另一单或认领他人任务409;退出不释放。 + parameters: [{ name: receiptNo, in: path, required: true, schema: { type: string } }] + responses: + '200': { description: 返回COUNTING任务,允许填写真实数量 } + '404': { description: 任务不存在或不属于绑定仓 } + '409': { description: 已认领其他单、被其他人认领、无需清点或被其他作业占用 } + /api/pda/v1/last-leg/return-inventory/counting/scan/{code}: + get: + summary: 统一扫描箱号或托盘号读取待清点批次 + description: 仅精确匹配登录绑定仓的箱号或托盘号,仅返回PENDING且AVAILABLE的托盘签收批次。一个托盘多次签收返回多个任务供选择。无待清点箱返回空数组,PDA显示无需清点;权限、网络或服务错误不得当作无需清点。 + parameters: [{ name: code, in: path, required: true, schema: { type: string, maxLength: 128 } }] + responses: + '200': { description: data为清点任务数组,无待清点箱返回空数组 } + '400': { description: 扫码内容为空或超过128字符 } + '403': { description: PDA角色、权限或仓库绑定缺失 } + /api/pda/v1/last-leg/return-inventory/counting/tasks: + get: + summary: 分页查询绑定海外仓的待清点签收批次 + description: 需要 lastleg:pda:operate 和 ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR。仓库来自登录身份;托盘签收按一次签收批次整体清点,待清点箱内 quantity=0,不展示平均分配为实际数量。 + parameters: + - { name: page, in: query, required: true, schema: { type: integer, minimum: 1 } } + - { name: size, in: query, required: true, schema: { type: integer, minimum: 1, maximum: 50 } } + - { name: keyword, in: query, schema: { type: string, maxLength: 128 }, description: 退货单、托盘或箱号 } + responses: + '200': { description: code=200,data 为 records/page/size/total/pages 分页信封 } + '403': { description: PDA角色、权限或仓库绑定缺失 } + /api/pda/v1/last-leg/return-inventory/counting/cartons/{cartonMark}: + get: + summary: 扫描箱号读取所属托盘签收批次及全部箱子 + parameters: [{ name: cartonMark, in: path, required: true, schema: { type: string } }] + responses: + '200': { description: data含receiptNo/returnNo/palletCode/status/expectedItems/boxes,各箱含cartonMark/items } + '404': { description: 箱号不存在或不属于绑定仓,不泄露跨仓数据 } + '409': { description: 非托盘签收实物箱 } + /api/pda/v1/last-leg/return-inventory/counting/receipts/{receiptNo}: + post: + summary: 提交整个托盘签收批次的真实箱内清点 + description: 仅本人已认领的COUNTING任务允许提交。所有实物箱各一次,同箱SKU不得重复且数量为正整数;允许不等量和混箱。各箱SKU总量必须等于签收总量。锁定操作员会话、签收批次和各箱,事务内替换明细、整批放行并释放作业会话,不改变SKU总库存。失败不释放会话。未清点箱不得合箱、换标或创建及执行退货出库。幂等键按操作员隔离,同载荷重放确认快照,不同载荷409。错误保留requestId/traceId。 + parameters: [{ name: receiptNo, in: path, required: true, schema: { type: string } }] + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/Confirm' } + responses: + '200': { description: CONFIRMED清点批次,箱明细为真实数量 } + '400': { description: 箱号或SKU重复、非本批次、数量无效 } + '404': { description: 批次不存在或不属于绑定仓 } + '409': { description: 汇总不符、已清点、已占用或幂等键载荷冲突,事务回滚 } +components: + securitySchemes: + bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT } + schemas: + Confirm: + type: object + required: [idempotencyKey, boxes] + properties: + idempotencyKey: { type: string, minLength: 1, maxLength: 128 } + boxes: + type: array + minItems: 1 + maxItems: 500 + items: + type: object + required: [cartonMark, items] + properties: + cartonMark: { type: string, minLength: 1, maxLength: 128 } + items: + type: array + minItems: 1 + maxItems: 100 + items: + type: object + required: [skuCode, quantity] + properties: + skuCode: { type: string, minLength: 1, maxLength: 128 } + quantity: { type: integer, format: int64, minimum: 1 } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java index fd60babac..8a506392e 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java @@ -169,6 +169,18 @@ class DeliveryTransferServiceTest { org.mockito.Mockito.lenient().when(returnStock.matchesReturnSource(any(), any())).thenCallRealMethod(); } + @Test + void uncountedPalletBoxCannotCreateReturnOutbound() { + ReturnInventoryCarton carton = returnCarton("RC-UNCOUNTED", "PAL-1"); + carton.setCountStatus("PENDING"); + when(returnCartons.lockMark("RC-UNCOUNTED")).thenReturn(carton); + var error = assertThrows(ResponseStatusException.class, () -> service.createReturnOutbound("TENANT-A", "U-1", + new DeliveryRequests.ReturnOutbound("receiver", "WH-1", "RT-1", List.of("RC-UNCOUNTED"), null, "uncounted-1"))); + assertEquals(409, error.getStatusCode().value()); + org.mockito.Mockito.verifyNoInteractions(returnCartonItems, outboundPallets); + verify(outboundOrders, org.mockito.Mockito.never()).insert(any(OutboundOrder.class)); + } + @Test void returnBoxOutboundCanSelectOneCartonFromAPallet() { ReturnInventoryCarton first = returnCarton("RC-1", "PAL-1"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java index be80fd7ca..d51f5d0dd 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java @@ -895,6 +895,25 @@ class RelabelApplicationServiceTest { return app; } + @Test + void uncountedPalletBoxCannotCreateRelabelApplication() { + var cartonMapper = org.mockito.Mockito.mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper.class); + ReflectionTestUtils.setField(service, "returnCartons", cartonMapper); + var carton = new com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton(); + carton.setId(901L); carton.setCustomerId("10001"); carton.setOverseasWarehouseId("WH-1"); + carton.setCartonMark("RETURN-BOX-1"); carton.setStatus("AVAILABLE"); carton.setSourceUnitType("PALLET"); carton.setCountStatus("PENDING"); + when(cartonMapper.lockId(901L)).thenReturn(carton); + var detail = new RelabelRequests.Item(null, null, null, "SKU-A", null, null, null, + "Return goods", 1, 1, "RT-1", null, "NEW-A", null, null, + null, null, null, "RETURN-BOX-1", null, null, "SKU_LABEL", + List.of(new RelabelRequests.SkuItem("SKU-A", "NEW-A", 1, null)), 901L); + var request = new RelabelRequests.Create("RT-1", "WH-1", LocalDateTime.now().plusDays(1), + "Li", "13800000000", null, null, "return-uncounted", List.of(detail), null, false); + var error = assertThrows(ResponseStatusException.class, () -> service.create("10001", "operator-1", request)); + assertTrue(error.getReason().contains("清点")); + verify(applications, never()).insert(any(RelabelApplication.class)); + } + @Test void returnSkuRelabelRejectsQuantityBeyondSelectedSourceBox() { var cartonMapper = org.mockito.Mockito.mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper.class); @@ -934,6 +953,19 @@ class RelabelApplicationServiceTest { "SKU-NEW", "Label on carton", "file-1", "relabel.pdf", "https://files/relabel.pdf"))); } + @Test + void rejectsDifferentOperationTypesInOneApplicationBeforeStockIsHeld() { + var first = carton("A", "RC-A", "FBA-A", "ABE8"); + var second = new RelabelRequests.Item(101L, 202L, 301L, "B", null, null, null, + "商品B", 1, 1, "SHP-2", null, "B-NEW", null, null, + null, null, null, "RC-B", null, null, "SKU_LABEL", List.of()); + var request = new RelabelRequests.Create("CUSTOMER-1", "WH-1", LocalDateTime.now().plusDays(1), + "Li", "13800000000", new BigDecimal("20.00"), null, "mixed-types", List.of(first, second)); + var error = assertThrows(ResponseStatusException.class, () -> service.create("10001", "operator-1", request)); + assertEquals(400, error.getStatusCode().value()); + assertTrue(error.getReason().contains("只能选择一种")); + } + private static RelabelRequests.Item carton(String sku, String cartonMark, String fbaCartonMark, String destinationWarehouseId) { long positionId = "ABE8".equals(destinationWarehouseId) ? 202L : 201L; diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnCartonCountServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnCartonCountServiceTest.java new file mode 100644 index 000000000..721aae72d --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnCartonCountServiceTest.java @@ -0,0 +1,168 @@ +package com.baiyue.lastleg.returnreceipt.service; + +import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity; +import com.baiyue.lastleg.returnreceipt.dto.ReturnCartonCountDtos.*; +import com.baiyue.lastleg.returnreceipt.entity.*; +import com.baiyue.lastleg.returnreceipt.mapper.*; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.server.ResponseStatusException; +import java.util.List; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; + +@ExtendWith(MockitoExtension.class) +class ReturnCartonCountServiceTest { + @Mock ReturnReceiptMapper receipts; + @Mock ReturnReceiptItemMapper receiptItems; + @Mock ReturnInventoryCartonMapper cartons; + @Mock ReturnInventoryCartonItemMapper items; + @Mock ReturnInventoryBalanceMapper balances; + @Mock ReturnInventoryOperationMapper operations; + @Mock ReturnCartonCountSessionMapper sessions; + ReturnCartonCountService service; + PdaWarehouseIdentity identity = new PdaWarehouseIdentity("op", "operator", 123L); + ReturnReceipt receipt; + ReturnInventoryCarton a, b; + @BeforeEach void setup() { + service = new ReturnCartonCountService(); + ReflectionTestUtils.setField(service,"receipts",receipts); + ReflectionTestUtils.setField(service,"receiptItems",receiptItems); + ReflectionTestUtils.setField(service,"cartons",cartons); + ReflectionTestUtils.setField(service,"items",items); + ReflectionTestUtils.setField(service,"balances",balances); + ReflectionTestUtils.setField(service,"operations",operations); + ReflectionTestUtils.setField(service,"sessions",sessions); + lenient().when(sessions.lockOperator("op")).thenReturn(new ReturnCartonCountSessionMapper.Session("op","123",1L,null)); + lenient().when(sessions.lockOperator("other")).thenReturn(new ReturnCartonCountSessionMapper.Session("other","999",null,null)); + lenient().when(sessions.release("op",1L)).thenReturn(1); + ReflectionTestUtils.setField(service,"objectMapper",new ObjectMapper().findAndRegisterModules()); + receipt = new ReturnReceipt(); receipt.setId(1L); receipt.setReceiptNo("RR-1"); receipt.setCustomerId("C1"); receipt.setPalletCode("PLT-1"); + a = box(2L,"RC#2-1"); b = box(3L,"RC#2-2"); + } + private ReturnInventoryCarton box(long id, String mark) { + var box = new ReturnInventoryCarton(); box.setId(id); box.setCartonMark(mark); box.setCustomerId("C1"); box.setOverseasWarehouseId("123"); + box.setCountStatus("PENDING"); box.setStatus("AVAILABLE"); box.setSourceReceiptId(1L); box.setSourceUnitType("PALLET"); return box; + } + private ReturnReceiptItem expected(String sku, long qty) { + var item = new ReturnReceiptItem(); item.setSkuCode(sku); item.setReceivedQuantity(qty); return item; + } + private ReturnInventoryCartonItem provisional(long box, String sku, long qty) { + var item = new ReturnInventoryCartonItem(); item.setCartonId(box); item.setSkuCode(sku); item.setQuantity(qty); item.setPackingReservedQuantity(0L); return item; + } + private void prepare(List expected) { + when(receipts.lockCountReceipt("RR-1")).thenReturn(receipt); + when(cartons.lockReceipt(1L)).thenReturn(List.of(a,b)); + when(receiptItems.listReceipt(1L)).thenReturn(expected); + } + private Confirm unequal(long first, long second) { + return new Confirm("retry", List.of(new CountBox(a.getCartonMark(),List.of(new CountItem("SKU1",first))),new CountBox(b.getCartonMark(),List.of(new CountItem("SKU1",second))))); + } + @Test void unequalBoxesAreConfirmedWithoutChangingSkuUnitBalanceAndRetryIsStable() { + prepare(List.of(expected("SKU1",10))); + when(items.lockCarton(2L)).thenReturn(List.of(provisional(2,"SKU1",5))); + when(items.lockCarton(3L)).thenReturn(List.of(provisional(3,"SKU1",5))); + when(items.listCarton(2L)).thenReturn(List.of(provisional(2,"SKU1",3))); + when(items.listCarton(3L)).thenReturn(List.of(provisional(3,"SKU1",7))); + var result = service.confirm(identity,"RR-1",unequal(3,7)); + assertThat(result.status()).isEqualTo("CONFIRMED"); + assertThat(a.getTotalQuantity()).isEqualTo(3); assertThat(b.getTotalQuantity()).isEqualTo(7); + assertThat(a.getCountedBy()).isEqualTo("op"); assertThat(a.getCountedAt()).isNotNull(); + verify(items).deleteCarton(2L); verify(items).deleteCarton(3L); verify(items,times(2)).insert(any(ReturnInventoryCartonItem.class)); + verifyNoInteractions(balances); + var audit = org.mockito.ArgumentCaptor.forClass(ReturnInventoryOperation.class); verify(operations).insert(audit.capture()); + when(operations.lockKey("op","retry")).thenReturn(audit.getValue()); + assertThat(service.confirm(identity,"RR-1",unequal(3,7))).isEqualTo(result); + assertThatThrownBy(() -> service.confirm(identity,"RR-1",unequal(4,6))).isInstanceOf(ResponseStatusException.class).hasMessageContaining("幂等键"); + verify(items,times(2)).deleteCarton(anyLong()); + } + @Test void mixedBoxesAdjustOnlyContainingBoxCounts() { + prepare(List.of(expected("SKU1",6),expected("SKU2",4))); + when(items.lockCarton(2L)).thenReturn(List.of(provisional(2,"SKU1",6))); + when(items.lockCarton(3L)).thenReturn(List.of(provisional(3,"SKU2",4))); + when(balances.adjustCartonQuantity("C1","123","SKU1",1)).thenReturn(1); + when(balances.adjustCartonQuantity("C1","123","SKU2",1)).thenReturn(1); + var request = new Confirm("mixed",List.of(new CountBox(a.getCartonMark(),List.of(new CountItem("SKU1",2),new CountItem("SKU2",1))),new CountBox(b.getCartonMark(),List.of(new CountItem("SKU1",4),new CountItem("SKU2",3))))); + service.confirm(identity,"RR-1",request); + verify(balances).adjustCartonQuantity("C1","123","SKU1",1); verify(balances).adjustCartonQuantity("C1","123","SKU2",1); + verifyNoMoreInteractions(balances); assertThat(a.getSkuTypes()).isEqualTo(2); + } + @Test void totalsMismatchDoesNotDeleteOrReleaseAnyBox() { + prepare(List.of(expected("SKU1",10))); + assertThatThrownBy(() -> service.confirm(identity,"RR-1",unequal(3,6))).isInstanceOf(ResponseStatusException.class).hasMessageContaining("汇总"); + verify(items,never()).deleteCarton(anyLong()); verify(cartons,never()).updateById(any(ReturnInventoryCarton.class)); verifyNoInteractions(balances); + assertThat(a.getCountStatus()).isEqualTo("PENDING"); + } + @Test void duplicateOrForeignBoxesCannotBeSubmitted() { + prepare(List.of(expected("SKU1",10))); + var request = new Confirm("dup",List.of(new CountBox(a.getCartonMark(),List.of(new CountItem("SKU1",5))),new CountBox(a.getCartonMark(),List.of(new CountItem("SKU1",5))))); + assertThatThrownBy(() -> service.confirm(identity,"RR-1",request)).isInstanceOf(ResponseStatusException.class).hasMessageContaining("重复"); + verify(items,never()).deleteCarton(anyLong()); + } + @Test void warehouseScopeCannotBeSuppliedByAnotherOperator() { + when(receipts.lockCountReceipt("RR-1")).thenReturn(receipt); when(cartons.lockReceipt(1L)).thenReturn(List.of(a,b)); + assertThatThrownBy(() -> service.confirm(new PdaWarehouseIdentity("other","other",999),"RR-1",unequal(3,7))).isInstanceOf(ResponseStatusException.class).hasMessageContaining("当前仓"); + verifyNoInteractions(items,receiptItems,operations); + } + @Test void scanDoesNotExposeProvisionalAverageAsActualCount() { + when(cartons.findMark(a.getCartonMark())).thenReturn(a); when(receipts.selectById(1L)).thenReturn(receipt); + when(cartons.listReceipt(1L)).thenReturn(List.of(a,b)); when(receiptItems.listReceipt(1L)).thenReturn(List.of(expected("SKU1",10))); + when(items.listCarton(2L)).thenReturn(List.of(provisional(2,"SKU1",5))); when(items.listCarton(3L)).thenReturn(List.of(provisional(3,"SKU1",5))); + var task = service.scan(identity,a.getCartonMark()); + assertThat(task.boxes()).allSatisfy(box -> assertThat(box.items()).allSatisfy(item -> assertThat(item.quantity()).isZero())); + assertThatThrownBy(() -> ReturnCartonCountGuard.requireConfirmed(a)).isInstanceOf(ResponseStatusException.class).hasMessageContaining("清点"); + a.setCountStatus("CONFIRMED"); assertThatCode(() -> ReturnCartonCountGuard.requireConfirmed(a)).doesNotThrowAnyException(); + } + @Test void unifiedScanUsesExactCodeAndWarehouseAndReturnsAllPendingPalletBatches() { + var second = new ReturnReceipt(); second.setId(4L); second.setReceiptNo("RR-2"); second.setPalletCode("PLT-1"); + when(receipts.scanPendingContents("123","PLT-1")).thenReturn(List.of(receipt,second)); + when(cartons.listReceipt(1L)).thenReturn(List.of(a,b)); + var tasks = service.scanTasks(identity," PLT-1 "); + assertThat(tasks).extracting(Task::receiptNo).containsExactly("RR-1","RR-2"); + verify(receipts).scanPendingContents("123","PLT-1"); + verifyNoInteractions(operations,balances); + } + @Test void unifiedScanWithNoPendingBoxesReturnsEmptyInsteadOfCompletedContents() { + when(receipts.scanPendingContents("123","RC-DONE")).thenReturn(List.of()); + assertThat(service.scanTasks(identity,"RC-DONE")).isEmpty(); + verifyNoInteractions(cartons,items,receiptItems); + } + @Test void beginClaimsAndRetriesSameTaskWithoutClaimingAgain() { + when(sessions.lockOperator("op")).thenReturn(new ReturnCartonCountSessionMapper.Session("op","123",null,null),new ReturnCartonCountSessionMapper.Session("op","123",1L,null)); + when(receipts.lockCountReceipt("RR-1")).thenReturn(receipt); when(cartons.lockReceipt(1L)).thenReturn(List.of(a,b)); + when(sessions.claim("op","123",1L)).thenReturn(1); + when(sessions.findReceipt(1L)).thenReturn(null,new ReturnCartonCountSessionMapper.Session("op","123",1L,null)); + service.begin(identity,"RR-1"); service.begin(identity,"RR-1"); + verify(sessions,times(1)).claim("op","123",1L); + verify(sessions,never()).release(anyString(),anyLong()); + } + @Test void activeTaskBlocksAnotherTaskEvenAfterLeavingThePage() { + var another = new ReturnReceipt(); another.setId(4L); another.setReceiptNo("RR-2"); + when(receipts.lockCountReceipt("RR-2")).thenReturn(another); + assertThatThrownBy(() -> service.begin(identity,"RR-2")).isInstanceOf(ResponseStatusException.class).hasMessageContaining("一次只能清点一单"); + verify(sessions,never()).claim(anyString(),anyString(),anyLong()); verifyNoInteractions(cartons); + } + @Test void anotherOperatorCannotTakeClaimedTask() { + when(sessions.lockOperator("op")).thenReturn(new ReturnCartonCountSessionMapper.Session("op","123",null,null)); + when(receipts.lockCountReceipt("RR-1")).thenReturn(receipt); when(cartons.lockReceipt(1L)).thenReturn(List.of(a,b)); + when(sessions.findReceipt(1L)).thenReturn(new ReturnCartonCountSessionMapper.Session("someone","123",1L,null)); + assertThatThrownBy(() -> service.begin(identity,"RR-1")).isInstanceOf(ResponseStatusException.class).hasMessageContaining("其他人员"); + verify(sessions,never()).claim(anyString(),anyString(),anyLong()); + } + @Test void currentRestoresClaimedTaskAndDirectConfirmRequiresBegin() { + when(sessions.findOperator("op")).thenReturn(new ReturnCartonCountSessionMapper.Session("op","123",1L,null)); + when(sessions.findReceipt(1L)).thenReturn(new ReturnCartonCountSessionMapper.Session("op","123",1L,null)); + when(receipts.selectById(1L)).thenReturn(receipt); when(cartons.listReceipt(1L)).thenReturn(List.of(a,b)); + assertThat(service.current(identity).status()).isEqualTo("COUNTING"); + when(sessions.lockOperator("op")).thenReturn(new ReturnCartonCountSessionMapper.Session("op","123",null,null)); + when(receipts.lockCountReceipt("RR-1")).thenReturn(receipt); when(cartons.lockReceipt(1L)).thenReturn(List.of(a,b)); + assertThatThrownBy(() -> service.confirm(identity,"RR-1",unequal(3,7))).isInstanceOf(ResponseStatusException.class).hasMessageContaining("先开始"); + verify(items,never()).deleteCarton(anyLong()); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryServiceTest.java index 099a453da..a872ca7bc 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryServiceTest.java @@ -84,6 +84,46 @@ class ReturnInventoryServiceTest { verify(packingPlans).listPlatform(null, "PLAN-1", "C1", "W1", "SKU1", 20L, 20L); } + @Test + void receiptDetailsUseCountSnapshotEvenAfterInventoryChanges() throws Exception { + var receipt = new ReturnReceipt(); receipt.setId(21L); receipt.setCustomerId("C1"); receipt.setReceiptNo("RR-1"); receipt.setUnitType("PALLET"); receipt.setPalletCode("PLT-1"); + var snapshot = new com.baiyue.lastleg.returnreceipt.dto.ReturnCartonCountDtos.Task("RR-1", "RT-1", "PLT-1", "CONFIRMED", List.of(), List.of( + new com.baiyue.lastleg.returnreceipt.dto.ReturnCartonCountDtos.Box("RC#2-1", List.of(new com.baiyue.lastleg.returnreceipt.dto.ReturnCartonCountDtos.Sku("A", "商品A", 3))), + new com.baiyue.lastleg.returnreceipt.dto.ReturnCartonCountDtos.Box("RC#2-2", List.of(new com.baiyue.lastleg.returnreceipt.dto.ReturnCartonCountDtos.Sku("A", "商品A", 7))))); + var operation = new com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryOperation(); + operation.setResultJson(new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules().writeValueAsString(snapshot)); + when(operations.receiptContents("C1", 21L)).thenReturn(operation); + var detail = service.receiptContents(receipt); + assertThat(detail).hasSize(2); + assertThat(detail.get(0).items().get(0).quantity()).isEqualTo(3); + assertThat(detail.get(1).items().get(0).quantity()).isEqualTo(7); + assertThat(detail.get(0).countStatus()).isEqualTo("CONFIRMED"); + verifyNoInteractions(cartons, cartonItems); + } + + @Test + void pendingReceiptDetailsDoNotExposeProvisionalAveragesAndParcelsHaveNoBoxes() { + var receipt = new ReturnReceipt(); receipt.setId(21L); receipt.setCustomerId("C1"); receipt.setReceiptNo("RR-1"); receipt.setUnitType("PALLET"); + var box = new ReturnInventoryCarton(); box.setCartonMark("RC#2-1"); box.setCountStatus("PENDING"); box.setTotalQuantity(5L); + when(cartons.listReceipt(21L)).thenReturn(List.of(box)); + var detail = service.receiptContents(receipt); + assertThat(detail.get(0).countStatus()).isEqualTo("PENDING"); + assertThat(detail.get(0).items()).isEmpty(); + receipt.setUnitType("PARCEL"); + assertThat(service.receiptContents(receipt)).isEmpty(); + verifyNoInteractions(cartonItems); + } + + @Test + void boxSigningStoresActualContentsAsImmutableReceiptSnapshot() { + var receipt = new ReturnReceipt(); receipt.setId(21L); receipt.setReceiptNo("RR-BOX"); receipt.setUnitType("BOX"); receipt.setUnitCode("RC-BOX"); receipt.setOperatorId("operator-1"); + service.createReceiptCarton(completedOrder(), receipt, "RC-BOX", List.of(new ReturnInventoryService.ReceiptLine("A", "商品A", 3), new ReturnInventoryService.ReceiptLine("B", "商品B", 7))); + var capture = ArgumentCaptor.forClass(com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryOperation.class); + verify(operations).insert(capture.capture()); + assertThat(capture.getValue().getOperationType()).isEqualTo("SIGN_CONTENTS"); + assertThat(capture.getValue().getResultJson()).contains("RC-BOX", "RR-BOX", "商品A", "\"quantity\":3", "\"quantity\":7"); + } + @Test void cartonDetailsExposeImmutableMergeHistoryWithinCustomerScope() { ReturnInventoryCarton carton = new ReturnInventoryCarton(); @@ -141,7 +181,8 @@ class ReturnInventoryServiceTest { when(cartons.findCustomerMark(carton.getCustomerId(), carton.getCartonMark())).thenReturn(carton); when(cartonItems.listCarton(31L)).thenReturn(List.of(item(41L, "SKU-A", 2L))); String html = service.label(carton.getCustomerId(), carton.getCartonMark()).printableHtml(); - assertThat(html).contains(" saved = new AtomicReference<>(); when(orders.findKey("customer-1", "create-key")).thenReturn(null); when(numbers.next(eq("RETURN"), eq("10"), anyLong())).thenReturn("RT-1"); - when(numbers.next(eq("RETURN_RELABEL"), eq("10"), anyLong())).thenReturn("RETU-1"); when(orders.insert(any(ReturnOrder.class))).thenAnswer(invocation -> { saved.set(invocation.getArgument(0)); return 1; }); when(orders.findNo("customer-1", "RT-1")).thenAnswer(invocation -> saved.get()); when(orderItems.listOrder(anyLong())).thenReturn(List.of()); @@ -313,7 +312,9 @@ class ReturnReceiptServiceTest { assertEquals("RT-1", tail.getValue().getSourceInboundId()); assertEquals("RETURN_ORDER:RT-1", tail.getValue().getSourceEventId()); assertEquals(saved.get().getRequestHash(), tail.getValue().getSourcePayloadHash()); - assertEquals("RETU-1", result.order().lastlegOrderNo()); + assertEquals("RT-1", tail.getValue().getOrderNo()); + assertEquals("RT-1", result.order().lastlegOrderNo()); + verify(numbers, never()).next(eq("RETURN_RELABEL"), anyString(), anyLong()); } @Test diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java index 02ea84783..7683464b1 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java @@ -75,6 +75,7 @@ class LastlegOrderServiceTest { @Mock WarehouseResourceGateway warehouseResourceGateway; @Mock ContainerShippingGateMapper containerShippingGates; @Mock ReturnOrderReferenceResolver returnOrderReferences; + @Mock com.baiyue.lastleg.returnreceipt.service.ReturnReceiptService returnReceipts; LastlegOrderService service; @Test @@ -87,6 +88,13 @@ class LastlegOrderServiceTest { assertEquals("WT-WH-20260911-020225", LastlegOrderService.displayOrderNo(order)); } + @Test + void returnTailOrderUsesSourceReturnNumber() { + LastlegOrder order = new LastlegOrder(); order.setOrderType("RETURN_RELABEL"); + order.setOrderNo("RETU-OLD"); order.setSourceOrderNo("RT-1"); + assertEquals("RT-1", LastlegOrderService.displayOrderNo(order)); + } + @Test void exposesStableContainerBookingIdInOrderSummary() { LastlegOrder order = new LastlegOrder(); @@ -236,6 +244,18 @@ class LastlegOrderServiceTest { .noneMatch(component -> "eventId".equals(component.getName()))); } + @Test + void returnTailDetailReadsReceiptFactsWithOwnedCustomerAndSourceReturnNumber() { + ReflectionTestUtils.setField(service, "returnReceipts", returnReceipts); + LastlegOrder order = order(11L, "100", "INBOUND_COMPLETED", null); + order.setOrderType("RETURN_RELABEL"); order.setSourceOrderNo("RT-SOURCE"); + when(orders.selectOne(any())).thenReturn(order); + var receiptDetail = new com.baiyue.lastleg.returnreceipt.dto.ReturnOrderResponses.Detail(null, null, List.of(), List.of(), List.of(), List.of()); + when(returnReceipts.detail("100", "RT-SOURCE")).thenReturn(receiptDetail); + assertSame(receiptDetail, service.detail("100", 11L).returnReceipt()); + verify(returnReceipts).detail("100", "RT-SOURCE"); + } + @Test void detailReadsContainerNodeFromTheContainerShippingGate() { LastlegOrder order = order(11L, "100", "PENDING_ASSIGNMENT", null); diff --git a/scripts/README.md b/scripts/README.md index 1ac3ce273..850195c07 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,5 +1,13 @@ # 后端脚本管理与全量 HTTP 验收索引 +`db/local-four-flows-schema-incremental-20261009.sql`:本地退货换标、仓储转运、贴标出库及退货出库结构比对所得的 MySQL 8 定向增量。显式 `USE baiyue_lastleg`,补齐预约结束时间/版本、转运换标来源绑定、分配 SKU 快照及转运收货幂等命令表;字段按存在性判断,表使用 `CREATE TABLE IF NOT EXISTS`,可重复执行,不删除或回填业务数据。使用操作者显式选择的本地数据库连接,无内置凭据;完整执行输出 `LOCAL_FLOW_SCHEMA_READY`。失败保留已完成的 DDL 与原错误,核对后重跑;不自动清理业务记录,不是全库迁移或生产验收入口。 + +`db/return-tail-order-number-incremental-20261009.sql` 将已验证关联的退货尾程单号及退货单尾程号快照统一为退货单号。显式 `USE baiyue_lastleg`,可重复执行,只更新同客户、关联订单 ID 和来源退货号均一致的 `RETURN_RELABEL` 单据;不改变业务 ID、库存或履约状态。执行前核对目标数据库,执行后核验查询应为空,异常关系保留供核实。已同步海外业务统一增量脚本。 + +`db/return-carton-count-session-incremental-20261009.sql` 为本地清点作业认领增量,明确指向 `baiyue_lastleg`,MySQL 8 可重复执行。新增按操作员唯一、签收批次唯一的持久会话表;开始清点后退出页面不释放,只有核平完成事务才释放,不修改库存数量。凭据取本机配置,执行输出作为迁移记录;失败保留错误,修复后重跑,不删除单据或清点会话。同表已同步尾程基线、海外统一增量及完整箱内清点增量脚本。 + +`db/return-carton-counting-incremental-20261009.sql` 为本地退货箱内清点增量,显式指向 `baiyue_lastleg`,适用于已执行退货合箱增量的 MySQL 8。增加清点状态、时间、操作人及索引;将整批仍可用且没有合箱追溯或换标占用的历史托盘签收箱转为待清点,不修改其数量。已履约或曾合箱的历史批次不自动回退,须单独核实。凭据由本机配置提供,不在脚本中保存;SQL 执行输出即为核验记录,可重复运行,已清点批次不会回退,失败保留错误和业务现场,不删除单据。同批结构同步到尾程基线与海外统一增量;服务须加载新代码后才能执行清点接口。 + `db/return-relabel-truck-incremental-20261008.sql` 为本地退货换标排车增量,显式指向 `baiyue_iam` / `baiyue_lastleg`,适用于 MySQL 8。补齐管理员正式司机、车次与审核权限,以及车辆容量和正式车队表;不修改退货库存或业务单据。凭据从本机配置读取,可重复执行,失败保留原错误和业务现场;重新登录后核对权限快照。结构同步到基线及两个统一增量,权限由正式 IAM 权限种子维护。 `db/return-packing-incremental-20261008.sql` 为本地退货合箱增量,明确指向 `baiyue_iam` 和 `baiyue_lastleg`,适用于 MySQL 8,可重复执行。初始化平台管理员的库存管理权限,补齐精确预占、原退货单来源追溯、完成结果幂等快照和箱级库位字段,移除“一签收单只能一箱”的旧唯一索引;不修改退货、库存等业务数据。数据库凭据从本机环境配置读取;执行失败应保留原错误,修复后重跑,不清理验收业务单据。同批结构已同步到基线、海外统一增量与 `db/incremental.sql`。 diff --git a/scripts/db/local-four-flows-schema-incremental-20261009.sql b/scripts/db/local-four-flows-schema-incremental-20261009.sql new file mode 100644 index 000000000..7d510f119 --- /dev/null +++ b/scripts/db/local-four-flows-schema-incremental-20261009.sql @@ -0,0 +1,49 @@ +-- 本地四流程结构补齐;目标 baiyue_lastleg;MySQL 8;不删除或回填业务数据。 +USE `baiyue_lastleg`; +SET NAMES utf8mb4; +DROP PROCEDURE IF EXISTS ensure_local_flow_column; +DELIMITER $$ +CREATE PROCEDURE ensure_local_flow_column(IN t VARCHAR(64), IN n VARCHAR(64), IN d TEXT) +BEGIN + IF NOT EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name=t AND column_name=n) THEN + SET @local_flow_ddl=CONCAT('ALTER TABLE `',t,'` ADD COLUMN `',n,'` ',d); + PREPARE local_flow_stmt FROM @local_flow_ddl; EXECUTE local_flow_stmt; DEALLOCATE PREPARE local_flow_stmt; + END IF; +END$$ +DELIMITER ; +CALL ensure_local_flow_column('ll_inbound_fba_appointment','appointment_end_at','DATETIME(3) NULL COMMENT ''预约结束时间,未明确时为空'''); + +CALL ensure_local_flow_column('ll_inbound_fba_appointment','version','INT NOT NULL DEFAULT 0 COMMENT ''并发版本'''); + +CALL ensure_local_flow_column('ll_warehouse_transfer_relabel_binding','source_allocation_id','BIGINT NULL COMMENT ''已有库存转运的原始来源分配ID'''); + +CALL ensure_local_flow_column('ll_warehouse_transfer_relabel_binding','relabel_item_id','BIGINT NULL COMMENT ''本票关联换标明细ID'''); + +CALL ensure_local_flow_column('ll_warehouse_transfer_inventory_allocation','sku_code','VARCHAR(128) NULL COMMENT ''本次来源分配的实际SKU快照'''); + +CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_receipt_command ( + id BIGINT NOT NULL COMMENT '收货命令主键', + customer_id VARCHAR(128) NOT NULL COMMENT '客户标识', + transfer_order_id BIGINT NOT NULL COMMENT '转运单标识', + source_inventory_position_id BIGINT NOT NULL COMMENT '本次收货来源库存位置', + idempotency_key VARCHAR(255) NOT NULL COMMENT '原始收货幂等键', + request_hash CHAR(64) NOT NULL COMMENT '完整原始收货请求摘要', + request_json JSON NOT NULL COMMENT '完整原始收货请求快照', + target_warehouse_id BIGINT NOT NULL COMMENT '实际目标海外收货仓', + target_pallet_id BIGINT NOT NULL COMMENT '目标实物托盘', + target_location_id BIGINT NOT NULL COMMENT '目标实际库位', + target_hash CHAR(64) NOT NULL COMMENT '共享目标资源绑定摘要', + target_json JSON NOT NULL COMMENT '权威目标仓托盘库位及目的仓快照', + reservation_key VARCHAR(128) NOT NULL COMMENT '同转运同目标托盘共享资源占用键', + resource_lock_id BIGINT NULL COMMENT '已确认有效的海外资源锁标识', + status VARCHAR(32) NOT NULL COMMENT '命令状态PREPARED准备完成RESERVED资源占用COMPLETED收货完成', + created_at DATETIME(3) NOT NULL COMMENT '命令创建时间', + updated_at DATETIME(3) NOT NULL COMMENT '命令最后更新时间', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_wh_receipt_cmd_source (transfer_order_id, source_inventory_position_id), + UNIQUE KEY uk_ll_wh_receipt_cmd_key (customer_id, idempotency_key), + KEY idx_ll_wh_receipt_cmd_target (customer_id, transfer_order_id, target_pallet_id), + CONSTRAINT ck_ll_wh_receipt_cmd_status CHECK (status IN ('PREPARED','RESERVED','COMPLETED')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='转运逐来源收货原请求与共享目标资源占用命令'; +DROP PROCEDURE IF EXISTS ensure_local_flow_column; +SELECT 'LOCAL_FLOW_SCHEMA_READY' AS migration_status, DATABASE() AS target_database; diff --git a/scripts/db/return-carton-count-session-incremental-20261009.sql b/scripts/db/return-carton-count-session-incremental-20261009.sql new file mode 100644 index 000000000..34459e15f --- /dev/null +++ b/scripts/db/return-carton-count-session-incremental-20261009.sql @@ -0,0 +1,13 @@ +-- MySQL 8,本地尾程库,可重复执行;不修改现有清点数量或自动释放未完成任务。 +USE baiyue_lastleg; +-- 退货箱内清点作业会话:每位操作员只能认领一个批次,每个批次只能被一人认领。 +CREATE TABLE IF NOT EXISTS ll_return_carton_count_session ( + operator_id VARCHAR(128) NOT NULL COMMENT '平台PDA操作员标识', + warehouse_id VARCHAR(128) NOT NULL COMMENT '认领时绑定海外仓标识', + receipt_id BIGINT NULL COMMENT '正在清点的签收批次,完成后释放为NULL', + started_at DATETIME(3) NULL COMMENT '本次开始清点时间', + created_at DATETIME(3) NOT NULL COMMENT '会话创建时间', + updated_at DATETIME(3) NOT NULL COMMENT '会话更新时间', + PRIMARY KEY(operator_id), + UNIQUE KEY uk_ll_return_count_session_receipt(receipt_id) +) ENGINE=InnoDB COMMENT='退货箱内清点单人单任务持久会话'; diff --git a/scripts/db/return-carton-counting-incremental-20261009.sql b/scripts/db/return-carton-counting-incremental-20261009.sql new file mode 100644 index 000000000..ab29ddfb1 --- /dev/null +++ b/scripts/db/return-carton-counting-incremental-20261009.sql @@ -0,0 +1,33 @@ +-- 退货托盘签收箱内清点;MySQL 8;仅在目标尾程库执行,可重复运行。 +USE baiyue_lastleg; +SET @rc_count_ddl = IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='count_status'),'SELECT 1','ALTER TABLE ll_return_inventory_carton ADD COLUMN count_status VARCHAR(16) NOT NULL DEFAULT ''CONFIRMED'' COMMENT ''箱内清点状态 PENDING待清点 CONFIRMED已确认'''); +PREPARE rc_count_stmt FROM @rc_count_ddl; EXECUTE rc_count_stmt; DEALLOCATE PREPARE rc_count_stmt; +SET @rc_count_ddl = IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='counted_at'),'SELECT 1','ALTER TABLE ll_return_inventory_carton ADD COLUMN counted_at DATETIME(3) NULL COMMENT ''箱内清点确认时间'''); +PREPARE rc_count_stmt FROM @rc_count_ddl; EXECUTE rc_count_stmt; DEALLOCATE PREPARE rc_count_stmt; +SET @rc_count_ddl = IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='counted_by'),'SELECT 1','ALTER TABLE ll_return_inventory_carton ADD COLUMN counted_by VARCHAR(128) NULL COMMENT ''箱内清点确认操作人'''); +PREPARE rc_count_stmt FROM @rc_count_ddl; EXECUTE rc_count_stmt; DEALLOCATE PREPARE rc_count_stmt; +SET @rc_count_ddl = IF(EXISTS(SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND index_name='idx_ll_return_count'),'SELECT 1','CREATE INDEX idx_ll_return_count ON ll_return_inventory_carton(overseas_warehouse_id,count_status,status,source_receipt_id)'); +PREPARE rc_count_stmt FROM @rc_count_ddl; EXECUTE rc_count_stmt; DEALLOCATE PREPARE rc_count_stmt; +-- 历史箱:只迁移整批仍可用、没有换标/合箱占用、未确认清点的托盘签收箱。 +-- 已进入履约或合箱的批次不回退;后续需要逐批人工核实。 +UPDATE ll_return_inventory_carton c +JOIN (SELECT source_receipt_id FROM (SELECT a.source_receipt_id + FROM ll_return_inventory_carton a WHERE a.source_receipt_id IS NOT NULL AND a.source_unit_type='PALLET' + GROUP BY a.source_receipt_id + HAVING SUM(a.status<>'AVAILABLE' OR a.relabel_application_id IS NOT NULL OR a.relabel_no IS NOT NULL OR a.counted_at IS NOT NULL)=0 + AND SUM(EXISTS(SELECT 1 FROM ll_return_inventory_carton_item i WHERE i.carton_id=a.id AND COALESCE(i.packing_reserved_quantity,0)>0))=0 + AND SUM(EXISTS(SELECT 1 FROM ll_return_packing_trace t WHERE t.source_carton_mark=a.carton_mark OR t.target_carton_mark=a.carton_mark))=0 +) pending) p ON p.source_receipt_id=c.source_receipt_id +SET c.count_status='PENDING' WHERE c.source_unit_type='PALLET' AND c.counted_at IS NULL; + +-- 退货箱内清点作业会话:每位操作员只能认领一个批次,每个批次只能被一人认领。 +CREATE TABLE IF NOT EXISTS ll_return_carton_count_session ( + operator_id VARCHAR(128) NOT NULL COMMENT '平台PDA操作员标识', + warehouse_id VARCHAR(128) NOT NULL COMMENT '认领时绑定海外仓标识', + receipt_id BIGINT NULL COMMENT '正在清点的签收批次,完成后释放为NULL', + started_at DATETIME(3) NULL COMMENT '本次开始清点时间', + created_at DATETIME(3) NOT NULL COMMENT '会话创建时间', + updated_at DATETIME(3) NOT NULL COMMENT '会话更新时间', + PRIMARY KEY(operator_id), + UNIQUE KEY uk_ll_return_count_session_receipt(receipt_id) +) ENGINE=InnoDB COMMENT='退货箱内清点单人单任务持久会话'; diff --git a/scripts/db/return-tail-order-number-incremental-20261009.sql b/scripts/db/return-tail-order-number-incremental-20261009.sql new file mode 100644 index 000000000..de72843da --- /dev/null +++ b/scripts/db/return-tail-order-number-incremental-20261009.sql @@ -0,0 +1,13 @@ +-- 退货业务统一单号;只修改已验证关联的单号快照,保留订单ID、库存、清点、换标及履约状态。 +USE baiyue_lastleg; +UPDATE ll_order o +JOIN ll_return_order r ON r.lastleg_order_id=o.id AND r.customer_id=o.customer_id +SET o.order_no=r.return_no, r.lastleg_order_no=r.return_no +WHERE o.order_type='RETURN_RELABEL' AND o.source_order_no=r.return_no + AND (NOT (o.order_no <=> r.return_no) OR NOT (r.lastleg_order_no <=> r.return_no)); + +-- 应返回零行;非零表示来源关系异常,须核实,脚本不猜测或改绑。 +SELECT r.return_no, o.order_no, r.lastleg_order_no +FROM ll_return_order r JOIN ll_order o ON o.id=r.lastleg_order_id AND o.customer_id=r.customer_id +WHERE o.order_type='RETURN_RELABEL' + AND (NOT (o.order_no <=> r.return_no) OR NOT (r.lastleg_order_no <=> r.return_no)); From 1e15a2838ad891b4c455f5b16cece6b95dbec578 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Fri, 9 Oct 2026 15:25:00 +0800 Subject: [PATCH 04/40] =?UTF-8?q?=E8=B4=B4=E6=A0=87=E7=94=B3=E8=AF=B7?= =?UTF-8?q?=EF=BC=8C=E4=BB=93=E5=82=A8=E8=BD=AC=E8=BF=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/RelabelApplicationService.java | 1 + .../dto/WarehouseTransferResponses.java | 2 +- .../RelabelApplicationServiceTest.java | 32 +++++++++++++++++++ .../service/WarehouseTransferServiceTest.java | 4 +-- 4 files changed, 36 insertions(+), 3 deletions(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java index db4fd408e..3be6ada40 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java @@ -645,6 +645,7 @@ public class RelabelApplicationService implements WalletResultHandler { } else if ("COMPLETED".equals(next)) { boolean preDispatched = "PRE_DISPATCH_CREATED".equals(application.getFulfillmentStatus()); application.setFulfillmentStatus("OUTBOUNDED"); + applications.updateById(application); if (!StringUtils.hasText(application.getOutboundOrderNo()) && !preDispatched) { releaseInventory(customerId, operatorId, application); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferResponses.java index 205113cce..7a3ece053 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferResponses.java @@ -35,7 +35,7 @@ public final class WarehouseTransferResponses { String lastlegOrderNo, String operatingWarehouseId, String fbaWarehouseCode, String destinationType, String destinationWarehouseCode, String destinationWarehouseName, int packageCount, String deliveryMethod, boolean needRelabel, String relabelNo, - String relabelApplicationStatus, String relabelFulfillmentStatus, + String relabelStatus, String relabelFulfillmentStatus, String status, LocalDateTime estimatedArrivalAt, LocalDateTime createdAt) {} public record Detail(Long id, String transferNo, Long inboundOrderId, String inboundCode, Long lastlegOrderId, String lastlegOrderNo, String orderType, String orderStatus, diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java index d51f5d0dd..f985d5af3 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java @@ -569,6 +569,38 @@ class RelabelApplicationServiceTest { assertEquals("COMPLETED", order.getRelabelStatus()); } + @Test + void zeroFeeCompletionPersistsFulfillmentForBothOutboundRoutes() { + for (String route : List.of("PRE_DISPATCH_CREATED", "OUTBOUND_CREATED")) { + RelabelApplication app = application(11L, "FBR-ZERO", "10001", "key", "hash", RelabelApplicationService.READY_FOR_OUTBOUND); + app.setLastlegOrderId(21L); + app.setWalletStatus("NOT_REQUIRED"); + app.setChargeAmount(BigDecimal.ZERO); + app.setFulfillmentStatus(route); + if ("OUTBOUND_CREATED".equals(route)) app.setOutboundOrderNo("OUT-ZERO"); + LastlegOrder order = order(21L, "10001", "LMO-ZERO", "PROCESSING"); + order.setOrderType("RETURN_RELABEL"); + order.setRelabelOrderNo(app.getRelabelNo()); + when(applications.lockNo("10001", app.getRelabelNo())).thenReturn(app); + when(orders.selectById(21L)).thenReturn(order); + stubDetail(app); + List persistedFulfillment = new java.util.ArrayList<>(); + org.mockito.Mockito.doAnswer(invocation -> { + RelabelApplication saved = invocation.getArgument(0); + persistedFulfillment.add(saved.getFulfillmentStatus()); + return 1; + }).when(applications).updateById(any(RelabelApplication.class)); + + service.changeStatus("10001", app.getRelabelNo(), "operator-2", + new RelabelRequests.ChangeStatus("COMPLETED", "finished")); + + assertEquals("OUTBOUNDED", persistedFulfillment.get(persistedFulfillment.size() - 1), route); + } + verify(feeSources, never()).publishApproved(any(), any(), any()); + verify(walletCommands, never()).publish(any(), anyLong(), any(), any(), any(), any(), anyLong()); + verify(inventoryCommands, never()).release(any(), any(), any()); + } + @Test void completingEarlierReturnRelabelDoesNotOverwriteLaterApplicationStatus() { RelabelApplication application = application(11L, "FBR-FIRST", "10001", "key-1", "hash", RelabelApplicationService.READY_FOR_OUTBOUND); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java index b0e97d320..ef75e2f19 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java @@ -136,7 +136,7 @@ class WarehouseTransferServiceTest { 1, 20, null, null, null, null, null, null, null, null)); assertEquals("RL-20260923-000001", result.records().get(0).relabelNo()); - assertEquals("COMPLETED", result.records().get(0).relabelApplicationStatus()); + assertEquals("COMPLETED", result.records().get(0).relabelStatus()); assertEquals("READY_FOR_DISPATCH", result.records().get(0).relabelFulfillmentStatus()); } @@ -152,7 +152,7 @@ class WarehouseTransferServiceTest { 1, 20, null, null, null, null, null, null, null, null)); assertEquals(1, result.records().size()); - assertNull(result.records().get(0).relabelApplicationStatus()); + assertNull(result.records().get(0).relabelStatus()); assertNull(result.records().get(0).relabelFulfillmentStatus()); verify(relabelApplications, never()).selectList(any()); } From bac7e0b214d069f5347e53c46b4c6b2f6ead71dd Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Fri, 9 Oct 2026 15:53:30 +0800 Subject: [PATCH 05/40] =?UTF-8?q?=E8=B4=B4=E6=A0=87=E7=94=B3=E8=AF=B7?= =?UTF-8?q?=EF=BC=8C=E4=BB=93=E5=82=A8=E8=BD=AC=E8=BF=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../phase4-overseas-return-followups.mjs | 6 +++--- .../phase4-overseas-return-receipt-contract.test.mjs | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/scripts/admin-test-env-modules/phase4-overseas-return-followups.mjs b/scripts/admin-test-env-modules/phase4-overseas-return-followups.mjs index da71efdfc..cca62cca9 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-return-followups.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-return-followups.mjs @@ -293,7 +293,7 @@ export async function verifyReturnOutbound(context, merchantToken, pdaToken, { const before = await snapshot() const carton = before.carton require('出库箱历史明细归属及SKU数量有效', carton?.cartonMark === cartonMark && carton.customerId === customerId - && carton.overseasWarehouseId === warehouseId && carton.sourceReturnNo === returnNo && carton.status === 'AVAILABLE' + && carton.overseasWarehouseId === warehouseId && carton.sourceReturnNo === returnNo && carton.status === 'OUTBOUND_PENDING' && carton.items?.length > 0 && new Set(carton.items.map(row => row.skuCode)).size === carton.items.length && Number(carton.skuTypes) === carton.items.length && carton.items.every(row => nonBlank(row.skuCode) && Number.isSafeInteger(Number(row.quantity)) && Number(row.quantity) > 0) @@ -315,7 +315,7 @@ export async function verifyReturnOutbound(context, merchantToken, pdaToken, { const quantities = new Map(carton.items.map(row => [row.skuCode, Number(row.quantity)])) require('待出库SKU库存唯一且足够', new Set(before.balances.map(row => row.skuCode)).size === before.balances.length && [...quantities].every(([sku, quantity]) => before.balances.some(row => row.skuCode === sku - && Number(row.totalQuantity) >= quantity && Number(row.availableQuantity) >= quantity && Number(row.cartonQuantity) >= 1))) + && Number(row.totalQuantity) >= quantity && Number(row.cartonQuantity) >= 1))) const pickBody = { outboundPalletItemId: item.outboundPalletItemId, inventoryPositionId: null, packages: 1, cartonMarks: [position.cartonMark], idempotencyKey: key(context, `${action}-PICK`) } await command(pdaToken, 'POST', 'PDA扫描真实退货箱', `${path}/pick`, pickBody) @@ -323,7 +323,7 @@ export async function verifyReturnOutbound(context, merchantToken, pdaToken, { const expectedBalances = before.balances.map(row => { const quantity = quantities.get(row.skuCode) return quantity === undefined ? row : { ...row, totalQuantity: Number(row.totalQuantity) - quantity, - availableQuantity: Number(row.availableQuantity) - quantity, cartonQuantity: Number(row.cartonQuantity) - 1 } + cartonQuantity: Number(row.cartonQuantity) - 1 } }) const withoutTime = rows => rows.map(({ updatedAt, ...row }) => row) require('逐SKU扣减实际件数与一个可用箱且其他SKU不变', facts(withoutTime(picked.balances)) === facts(withoutTime(expectedBalances)), diff --git a/scripts/admin-test-env-modules/phase4-overseas-return-receipt-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-return-receipt-contract.test.mjs index b1216edc3..f1bd550b5 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-return-receipt-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-return-receipt-contract.test.mjs @@ -37,7 +37,7 @@ test('relabelled outbound refuses another relabel application before creating an function outboundHarness(fault = '', quantity = 5, multiSku = false, wire = true) { const calls = [] - let picked = false, completed = false, duplicate = false, drift = false + let picked = false, completed = false, duplicate = false, drift = false, reserved = true const context = { marker: 'fixture', token: 'admin', context: { merchantQualificationFixture: { tenantId: '123' } }, createdResources: { phase4OverseasReturnReceipt: {} }, addStep() {}, assert(_m, name, _r, passed) { assert.ok(passed, name) }, @@ -56,14 +56,14 @@ function outboundHarness(fault = '', quantity = 5, multiSku = false, wire = true } else if (fault === 'replay deducts') duplicate = true data = { status: 'IN_PROGRESS' } } else if (path.endsWith('/complete')) { completed = true; data = { status: 'COMPLETED' } } - else if (path.endsWith('/candidates')) data = [{ returnNo: 'RT-1', cartonMark: 'BOX' }] - else if (path.endsWith('/outbound-orders/return')) data = { taskNo: 'TASK', outboundNo: 'OUT' } + else if (path.endsWith('/candidates')) { reserved = false; data = [{ returnNo: 'RT-1', cartonMark: 'BOX' }] } + else if (path.endsWith('/outbound-orders/return')) { reserved = true; data = { taskNo: 'TASK', outboundNo: 'OUT' } } else if (path.endsWith('/return-inventory')) { assert.equal(url.searchParams.get('customerId'), '123'); assert.equal(url.searchParams.get('overseasWarehouseId'), '42') let deducted = picked ? (fault === 'package count deduction' ? 1 : quantity) : 0 if (duplicate || (completed && fault === 'complete deducts')) deducted += quantity const stock = { customerId: fault === 'wrong scope' ? 'other' : '123', overseasWarehouseId: '42', skuCode: 'SKU', - totalQuantity: quantity - deducted, availableQuantity: quantity - deducted, + totalQuantity: quantity - deducted, availableQuantity: reserved ? 0 : quantity - deducted, cartonQuantity: picked && fault !== 'carton not deducted' ? 0 : 1, updatedAt: picked ? 'after' : 'before' } const unrelated = { ...stock, skuCode: 'OTHER', totalQuantity: drift ? 8 : 9, availableQuantity: 9, cartonQuantity: 1 } data = { total: fault === 'truncated page' ? 3 : 2, records: url.searchParams.get('page') === '1' ? [stock] @@ -77,7 +77,7 @@ function outboundHarness(fault = '', quantity = 5, multiSku = false, wire = true data = { records: rows.reverse(), total: rows.length } } else if (path.includes('/return-inventory/cartons/')) data = { cartonMark: 'BOX', customerId: '123', overseasWarehouseId: '42', sourceReturnNo: 'RT-1', relabelNo: 'RL-1', relabelStatus: 'COMPLETED', - status: picked ? 'OUTBOUNDED' : 'AVAILABLE', skuTypes: 1, totalQuantity: quantity, + status: picked ? 'OUTBOUNDED' : reserved ? 'OUTBOUND_PENDING' : 'AVAILABLE', skuTypes: 1, totalQuantity: quantity, receivedAt: picked && fault === 'history time drift' ? '2026-10-02T17:54:38.305' : '2026-10-02T17:52:56.794', items: [{ skuCode: 'SKU', quantity: picked && fault === 'history erased' ? 0 : quantity }] } else if (path.endsWith('/outbound-orders/OUT')) data = { outbound: { outboundNo: 'OUT', outboundType: 'RETURN_OUTBOUND', status: completed ? 'OUTBOUNDED' : 'WAIT_PICK' }, pallets: [{ id: '9', cartonMarks: 'BOX', pickedPackageCount: picked ? 1 : 0 }] } @@ -89,7 +89,7 @@ function outboundHarness(fault = '', quantity = 5, multiSku = false, wire = true if (multiSku && path.endsWith('/return-inventory')) { data.total += 1 if (url.searchParams.get('page') === '2') data.records.push({ customerId: '123', overseasWarehouseId: '42', - skuCode: 'SKU-B', totalQuantity: picked ? 0 : 2, availableQuantity: picked ? 0 : 2, cartonQuantity: picked ? 0 : 1 }) + skuCode: 'SKU-B', totalQuantity: picked ? 0 : 2, availableQuantity: reserved ? 0 : picked ? 0 : 2, cartonQuantity: picked ? 0 : 1 }) } if (multiSku && path.includes('/return-inventory/cartons/')) { data.skuTypes = 2; data.totalQuantity += 2; data.items.push({ skuCode: 'SKU-B', quantity: 2 }) From 3b5454fb9944bdae421d1d364c891d487bb20ed5 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 9 Oct 2026 16:12:02 +0800 Subject: [PATCH 06/40] feat(lastleg): query commercial sources independently of fulfillment types --- .../lastleg/dto/LastlegOrderRequests.java | 5 +- .../lastleg/dto/LastlegOrderSummary.java | 5 +- .../lastleg/export/LastlegExportColumns.java | 2 +- .../lastleg/mapper/LastlegOrderMapper.java | 19 +++++ .../lastleg/service/LastlegOrderService.java | 26 ++++++- .../resources/openapi/lastleg-exports-v1.yaml | 2 +- .../openapi/overseas-business-v1.yaml | 10 ++- .../LastlegOrderControllerTest.java | 4 +- .../MerchantLastlegOrderControllerTest.java | 2 +- .../LastlegExportProviderServiceTest.java | 13 ++++ .../LastlegOrderBusinessSourceTest.java | 75 +++++++++++++++++++ .../service/LastlegOrderSailingStageTest.java | 2 +- .../service/LastlegOrderServiceTest.java | 10 +-- 13 files changed, 156 insertions(+), 19 deletions(-) create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderBusinessSourceTest.java diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderRequests.java index 9be19ed6f..e1a1b37a2 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderRequests.java @@ -37,7 +37,10 @@ public final class LastlegOrderRequests { Boolean detained, LocalDateTime createdFrom, LocalDateTime createdTo, - SailingStage sailingStage + SailingStage sailingStage, + @Size(max = 64) String businessType, + @Size(max = 128) String businessOrderNo, + @Size(max = 128) String externalBookingNo ) { public Query { if (page == null || page == 0) page = 1L; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java index 5d7ef8417..d640aad0b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java @@ -74,6 +74,9 @@ public record LastlegOrderSummary( LocalDateTime createdAt, LocalDateTime updatedAt, @Schema(description = "同一主单关联的全部换标子单及各自状态,按创建时间排序") - List relabelApplications + List relabelApplications, + BusinessSource businessSource ) { + @Schema(description = "从正式单据关联读取的商业来源;与履约类型 orderType 独立") + public record BusinessSource(String businessType, String label, String businessOrderNo, String externalBookingNo) { } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/export/LastlegExportColumns.java b/lastleg-service/src/main/java/com/baiyue/lastleg/export/LastlegExportColumns.java index 22ae6a31f..a70411217 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/export/LastlegExportColumns.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/export/LastlegExportColumns.java @@ -10,7 +10,7 @@ import java.util.Arrays; final class LastlegExportColumns { private LastlegExportColumns() { } private static final Map DEFINITIONS = Map.ofEntries( - Map.entry("LAST_MILE_ORDER_LIST", "orderNo:尾程单号,orderTypeName:订单类型,inboundCode:入仓码,sourceOrderNo:头程单号,customerName:货主,overseasWarehouseName:海外仓,transferWarehouseName:转仓,transferWarehouseCode:转仓编码,fbaWarehouseCode:FBA仓库,containerNo:集装箱号,containerNode:货柜节点,abnormal:是否异常,detained:是否暂扣,palletCount:托数:INTEGER,packageCount:客户预报箱数:INTEGER,receivedPackageCount:实收箱数:INTEGER,packageDifference:差异箱数:INTEGER,volumeCbm:体积(m³):DECIMAL,productQuantity:数量:INTEGER,volumeWeightKg:体积重量:DECIMAL,estimatedArrivalAt:预计到港时间:DATETIME,actualArrivalAt:实际到港时间:DATETIME,estimatedWarehouseAt:预计到仓:DATETIME,inboundOrderNo:入库单号,relabelOrderNo:换标单号,relabelStatus:换标状态,palletMergeFlag:合托标记,currentStatus:主流程状态,subOrderStatus:子单状态,privateRemark:备注(仅自己),customerRemark:备注(客户),warehouseRemark:备注(仓库)"), + Map.entry("LAST_MILE_ORDER_LIST", "orderNo:尾程单号,orderTypeName:履约类型,businessSource.label:业务来源,businessSource.businessOrderNo:关联商业单号,businessSource.externalBookingNo:外接预订号,inboundCode:入仓码,sourceOrderNo:来源订单号,customerName:货主,overseasWarehouseName:海外仓,transferWarehouseName:转仓,transferWarehouseCode:转仓编码,fbaWarehouseCode:FBA仓库,containerNo:集装箱号,containerNode:货柜节点,abnormal:是否异常,detained:是否暂扣,palletCount:托数:INTEGER,packageCount:客户预报箱数:INTEGER,receivedPackageCount:实收箱数:INTEGER,packageDifference:差异箱数:INTEGER,volumeCbm:体积(m³):DECIMAL,productQuantity:数量:INTEGER,volumeWeightKg:体积重量:DECIMAL,estimatedArrivalAt:预计到港时间:DATETIME,actualArrivalAt:实际到港时间:DATETIME,estimatedWarehouseAt:预计到仓:DATETIME,inboundOrderNo:入库单号,relabelOrderNo:换标单号,relabelStatus:换标状态,palletMergeFlag:合托标记,currentStatus:主流程状态,subOrderStatus:子单状态,privateRemark:备注(仅自己),customerRemark:备注(客户),warehouseRemark:备注(仓库)"), Map.entry("OVERSEAS_INVENTORY_BALANCE", "customerName:客户,overseasWarehouseCode:海外仓编码,overseasWarehouseName:海外仓,destinationWarehouseCode:目的仓编码,destinationWarehouseName:目的仓,inboundOrderNo:入库单号,inboundCode:入仓码,skuCode:SKU,productNameZh:中文品名,productNameEn:英文品名,totalPackages:总箱数:INTEGER,availablePackages:可用箱数:INTEGER,holdPackages:暂扣箱数:INTEGER,allocatedPackages:已分配箱数:INTEGER,pickedPackages:已拣箱数:INTEGER,inTransitPackages:在途箱数:INTEGER,receivingPackages:收货中箱数:INTEGER,procurementPackages:采购箱数:INTEGER"), Map.entry("OVERSEAS_INVENTORY_LEDGER", "occurredAt:发生时间:DATETIME,customerName:客户,destinationWarehouseCode:目的仓编码,inboundOrderNo:入库单号,inboundCode:入仓码,businessType:业务类型,businessOrderNo:业务单号,outboundOrderNo:出库单号,palletNo:托盘号,batchNo:批次号,statusFrom:原状态,statusTo:新状态,beforePackages:变动前箱数:INTEGER,deltaPackages:变动箱数:INTEGER,afterPackages:变动后箱数:INTEGER,remark:备注"), Map.entry("OVERSEAS_QUOTE_LIST", "name:报价名称,warehouseCode:合作仓编码,warehouseName:合作仓名称,providerName:报价方,currency:币种,effectiveStart:生效开始:DATE,effectiveEnd:生效结束:DATE,versionCode:版本号,status:状态,updatedAt:更新时间:DATETIME"), diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java index e41a99b4f..eb19f1692 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java @@ -16,6 +16,25 @@ import java.math.BigDecimal; @Mapper public interface LastlegOrderMapper extends BaseMapper { + // Correlate both order identity and customer; never infer a business source from remarks. + String POOL_SOURCE_WHERE = " FROM ll_fleet_pool_order_link l WHERE l.lastleg_order_id=ll_order.id " + + "AND l.customer_id=ll_order.customer_id AND ll_order.order_type='WAREHOUSE_TRANSFER'"; + String BUSINESS_TYPE_SQL = "COALESCE((SELECT l.business_type" + POOL_SOURCE_WHERE + "),ll_order.order_type)"; + String BUSINESS_ORDER_NO_SQL = "COALESCE((SELECT l.business_order_no" + POOL_SOURCE_WHERE + + "),CASE WHEN ll_order.order_type='LOCAL_LOOSEBOARD' THEN ll_order.source_order_no END)"; + String EXTERNAL_BOOKING_NO_SQL = "(SELECT b.booking_no FROM ll_fleet_external_booking b " + + "WHERE b.customer_id=ll_order.customer_id AND b.incoming_no=ll_order.source_order_no " + + "AND ll_order.order_type='WAREHOUSE_TRANSFER')"; + + @Select("") + List findBusinessSources(@Param("orderIds") List orderIds); + + record BusinessSourceRow(Long orderId, String businessType, String businessOrderNo, + String externalBookingNo, boolean poolSource) { } + // Manual node snapshots replace container nodes permanently for the order. String SAILING_STAGE_FILTER_SQL = "(ll_order.order_type='FIRST_LEG' AND " + "(CASE WHEN COALESCE(ll_order.manual_node_override,FALSE)=TRUE THEN " + diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java index a2e1807ff..973f30864 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java @@ -100,11 +100,12 @@ public class LastlegOrderService { Map dispatchStatuses = dispatchStatuses(page.getRecords()); Map weights = orderWeights(page.getRecords()); Map> relabels = relabelSubOrders(page.getRecords()); + Map businessSources = businessSources(page.getRecords()); return new PageResult<>(page.getRecords().stream().map(order -> summary(order, mergeRelations.get(order.getId()), StringUtils.hasText(order.getSourceContainerBookingId()) ? containerNodes.get(order.getSourceContainerBookingId()) : null, dispatchStatuses.getOrDefault(order.getId(), "UNSCHEDULED"), weights.get(order.getId()), - relabels.getOrDefault(order.getId(), List.of()))).toList(), + relabels.getOrDefault(order.getId(), List.of()), businessSources.get(order.getId()))).toList(), page.getCurrent(), page.getSize(), page.getTotal(), page.getPages()); } @@ -360,7 +361,8 @@ public class LastlegOrderService { .sorted(java.util.Comparator.comparingInt(value -> MANUAL_NODE_CODES.indexOf(value.nodeCode()))).toList(); return new LastlegOrderDetailResponse(summary(order, palletMergeRelations(List.of(order)).get(order.getId()), containerNode(order), dispatchStatuses(List.of(order)).getOrDefault(order.getId(), "UNSCHEDULED"), - orderWeights(List.of(order)).get(order.getId()), relabelSubOrders(List.of(order)).getOrDefault(order.getId(), List.of())), manualNodes, cargo, history.stream() + orderWeights(List.of(order)).get(order.getId()), relabelSubOrders(List.of(order)).getOrDefault(order.getId(), List.of()), + businessSources(List.of(order)).get(order.getId())), manualNodes, cargo, history.stream() .map(value -> new LastlegOrderDetailResponse.History(value.getId(), value.getFromStatus(), value.getToStatus(), value.getRemark(), value.getCreatedAt())).toList(), fees, operations.stream() .map(value -> new LastlegOrderDetailResponse.Operation(value.getId(), value.getOperationNode(), @@ -549,6 +551,9 @@ public class LastlegOrderService { .or().apply("EXISTS (SELECT 1 FROM ll_relabel_application r WHERE r.lastleg_order_id=ll_order.id " + "AND r.customer_id=ll_order.customer_id AND r.relabel_no LIKE CONCAT('%',{0},'%'))", relabelNo)) .eq(StringUtils.hasText(query.orderType()), LastlegOrder::getOrderType, query.orderType()) + .apply(StringUtils.hasText(query.businessType()), LastlegOrderMapper.BUSINESS_TYPE_SQL + "={0}", query.businessType()) + .apply(StringUtils.hasText(query.businessOrderNo()), LastlegOrderMapper.BUSINESS_ORDER_NO_SQL + " LIKE CONCAT('%',{0},'%')", query.businessOrderNo()) + .apply(StringUtils.hasText(query.externalBookingNo()), LastlegOrderMapper.EXTERNAL_BOOKING_NO_SQL + " LIKE CONCAT('%',{0},'%')", query.externalBookingNo()) .apply(query.sailingStage() != null, LastlegOrderMapper.SAILING_STAGE_FILTER_SQL, query.sailingStage() == LastlegOrderRequests.SailingStage.AFTER_DEPARTURE ? 1 : 0) .like(StringUtils.hasText(query.inboundCode()), LastlegOrder::getInboundCode, query.inboundCode()) @@ -623,10 +628,23 @@ public class LastlegOrderService { return rows == null ? Map.of() : rows.stream().collect(Collectors.groupingBy(com.baiyue.lastleg.dto.RelabelSubOrder::lastlegOrderId)); } + private Map businessSources(List orders) { + if (orders.isEmpty()) return Map.of(); + return orderMapper.findBusinessSources(orders.stream().map(LastlegOrder::getId).toList()).stream() + .collect(Collectors.toMap(LastlegOrderMapper.BusinessSourceRow::orderId, row -> { + String label = "US_TRUCK".equals(row.businessType()) ? "本土卡车" : LastlegOrderType.labelOf(row.businessType()); + if (row.poolSource()) label += " · 拼车需求池"; + else if ("LOCAL_LOOSEBOARD".equals(row.businessType())) label += " · 独立下单"; + else if (StringUtils.hasText(row.externalBookingNo())) label += " · 外接来货"; + return new LastlegOrderSummary.BusinessSource(row.businessType(), label, row.businessOrderNo(), row.externalBookingNo()); + })); + } + private LastlegOrderSummary summary(LastlegOrder o, List relations, String containerNode, String dispatchStatus, LastlegOrderMapper.OrderWeightRow weight, - List relabelApplications) { + List relabelApplications, + LastlegOrderSummary.BusinessSource businessSource) { List related = relations == null ? List.of() : relations; return new LastlegOrderSummary(o.getId(), displayOrderNo(o), o.getOrderType(), LastlegOrderType.labelOf(o.getOrderType()), o.getDestinationType(), o.getSourceInboundId(), @@ -644,7 +662,7 @@ public class LastlegOrderService { o.getPalletMergeFlag(), related.stream().map(LastlegOrderMapper.PalletMergeRelationRow::mergeGroupNo).distinct().findFirst().orElse(null), related.stream().map(LastlegOrderMapper.PalletMergeRelationRow::relatedInboundNo).filter(Objects::nonNull).distinct().toList(), o.getCurrentStatus(), o.getSubOrderStatus(), o.getPrivateRemark(), - o.getCustomerRemark(), o.getWarehouseRemark(), o.getCreatedAt(), o.getUpdatedAt(), relabelApplications); + o.getCustomerRemark(), o.getWarehouseRemark(), o.getCreatedAt(), o.getUpdatedAt(), relabelApplications, businessSource); } private static Integer packageDifference(LastlegOrder order) { diff --git a/lastleg-service/src/main/resources/openapi/lastleg-exports-v1.yaml b/lastleg-service/src/main/resources/openapi/lastleg-exports-v1.yaml index dba8fc6c2..47152332a 100644 --- a/lastleg-service/src/main/resources/openapi/lastleg-exports-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/lastleg-exports-v1.yaml @@ -6,7 +6,7 @@ info: Admin 导出统一通过 POST /api/platform/v1/files/export-tasks 创建 XLSX 任务。 File 按导出类型校验原业务 export 权限,通过内部令牌分批读取 Lastleg;Provider 不经公网 Gateway。 parameters 与对应业务列表筛选一致,不包含 page/size;进销存支持 customerName。 - 尾程订单支持 relabelNo 和 sailingStage,库存余额按来源入库单展开,不导出内部数据库 ID。 + 尾程订单支持 relabelNo、sailingStage、businessType、businessOrderNo、externalBookingNo;导出履约类型、业务来源、关联商业单号和外接预订号,与列表同一投影。库存余额按来源入库单展开;所有导出均不包含内部数据库 ID。 每批最多 200 行,总行数上限 200000;车队报价及出车记录保留业务查询的 5000 条限制。 snapshotToken 绑定任务、申请人、执行版本和筛选条件,不表示数据库历史快照。 TRIP_OPERATIONS_LIST 的 costVisible 仅由 File 根据申请人的 platform:fleet:trip:list 权限写入,不能信任客户端值。 diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index 99eb066fc..4d3dbde60 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -211,9 +211,12 @@ paths: /api/platform/v1/last-leg/orders: get: summary: 分页查询尾程订单 - description: 权限 platform:overseas-warehouse:last-mile-order:list;平台管理员省略 customerId 时查询全部客户。containerNode 来自稳定 sourceContainerBookingId 对应的柜级船节点门禁;grossWeightKg/netWeightKg 由头程冻结货物明细聚合。 + description: 权限 platform:overseas-warehouse:last-mile-order:list;平台管理员省略 customerId 时查询全部客户。containerNode 来自稳定 sourceContainerBookingId 对应的柜级船节点门禁;grossWeightKg/netWeightKg 由头程冻结货物明细聚合。businessSource 返回 businessType、label、businessOrderNo、externalBookingNo;商业类型优先取同客户同尾程单的池来源桥,独立散板取自身来源单号,普通转运不推断为散板。orderType 保留履约类型;无关联单号为空。 security: [{ bearerAuth: [] }] parameters: + - { name: businessType, in: query, description: 商业类型;本土散板同时覆盖独立和池来源,仓储转运排除已关联散板或卡车商业单, schema: { type: string, maxLength: 64 } } + - { name: businessOrderNo, in: query, description: 正式关联商业单号包含匹配,不解析备注, schema: { type: string, maxLength: 128 } } + - { name: externalBookingNo, in: query, description: 正式外接预订号包含匹配,不解析备注, schema: { type: string, maxLength: 128 } } - { name: sailingStage, in: query, description: '可选开船阶段。仅匹配头程订单类型 FIRST_LEG;未传不限制类型。按实际柜级节点序号>=40判断开船后;人工节点覆盖后仅按该票 ACTUAL_DEPARTURE 非空判断,不回退柜级。无实际开船事实归开船前。', schema: { type: string, enum: [BEFORE_DEPARTURE, AFTER_DEPARTURE] } } - { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } } - { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 20 } } @@ -233,7 +236,7 @@ paths: /api/platform/v1/last-leg/orders/{id}: get: summary: 查询尾程订单详情 - description: 权限 platform:overseas-warehouse:last-mile-order:list;返回订单、货物、状态历史、费用明细和操作记录。订单中的 containerNode 来自稳定 sourceContainerBookingId 对应的柜级船节点门禁;grossWeightKg/netWeightKg 由头程冻结货物明细聚合。 + description: 权限 platform:overseas-warehouse:last-mile-order:list;返回订单、货物、状态历史、费用明细和操作记录。订单中的 containerNode 来自稳定 sourceContainerBookingId 对应的柜级船节点门禁;grossWeightKg/netWeightKg 由头程冻结货物明细聚合。businessSource 返回 businessType、label、businessOrderNo、externalBookingNo;商业类型优先取同客户同尾程单的池来源桥,独立散板取自身来源单号,普通转运不推断为散板。orderType 保留履约类型;无关联单号为空。 security: [{ bearerAuth: [] }] parameters: - { name: id, in: path, required: true, schema: { type: integer, format: int64 } } @@ -1168,6 +1171,9 @@ paths: description: 权限 merchant:overseas-warehouse:last-mile-order:list;列表权限包含详情只读,客户范围只取认证 Token。 security: [{ bearerAuth: [] }] parameters: + - { name: businessType, in: query, description: 商业类型;本土散板同时覆盖独立和池来源,仓储转运排除已关联散板或卡车商业单, schema: { type: string, maxLength: 64 } } + - { name: businessOrderNo, in: query, description: 正式关联商业单号包含匹配,不解析备注, schema: { type: string, maxLength: 128 } } + - { name: externalBookingNo, in: query, description: 正式外接预订号包含匹配,不解析备注, schema: { type: string, maxLength: 128 } } - { name: sailingStage, in: query, description: '可选开船阶段。仅匹配头程订单类型 FIRST_LEG;未传不限制类型。按实际柜级节点序号>=40判断开船后;人工节点覆盖后仅按该票 ACTUAL_DEPARTURE 非空判断,不回退柜级。无实际开船事实归开船前。', schema: { type: string, enum: [BEFORE_DEPARTURE, AFTER_DEPARTURE] } } responses: { '200': { description: 当前客户海外尾程订单分页 }, '403': { description: 缺少海外尾程订单查看权限 } } /api/merchant/v1/last-leg/orders/{id}: diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/controller/LastlegOrderControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/controller/LastlegOrderControllerTest.java index b05e0e1b0..cfe7a9ddf 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/controller/LastlegOrderControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/controller/LastlegOrderControllerTest.java @@ -38,11 +38,11 @@ class LastlegOrderControllerTest { private final LastlegOrderCommandService commandService = mock(LastlegOrderCommandService.class); private final LastlegOrderController controller = controller(); private final LastlegOrderRequests.Query query = new LastlegOrderRequests.Query( - 1L, 20L, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null); + 1L, 20L, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null); @Test void orderQueryHasOneResolvableConstructorForRequestParameterBinding() { - assertEquals(17, BeanUtils.getResolvableConstructor(LastlegOrderRequests.Query.class).getParameterCount()); + assertEquals(20, BeanUtils.getResolvableConstructor(LastlegOrderRequests.Query.class).getParameterCount()); } @Test diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/controller/MerchantLastlegOrderControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/controller/MerchantLastlegOrderControllerTest.java index d04fa2097..3f01b95ba 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/controller/MerchantLastlegOrderControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/controller/MerchantLastlegOrderControllerTest.java @@ -18,7 +18,7 @@ class MerchantLastlegOrderControllerTest { private final LastlegOrderService service = mock(LastlegOrderService.class); private final MerchantLastlegOrderController controller = controller(); private final LastlegOrderRequests.Query query = new LastlegOrderRequests.Query( - 1L, 20L, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null); + 1L, 20L, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null); @Test void merchantListUsesOnlyAuthenticatedTenant() { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/export/LastlegExportProviderServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/export/LastlegExportProviderServiceTest.java index 726ee8912..2ab08ed38 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/export/LastlegExportProviderServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/export/LastlegExportProviderServiceTest.java @@ -75,6 +75,19 @@ class LastlegExportProviderServiceTest { assertTrue(result.columns().stream().noneMatch(c -> c.key().equals("id") || c.key().equals("customerId"))); } + @Test void exportsBusinessReferencesUsingTheSameFiltersAndProjectionAsTheList() { + var order = new ObjectMapper().findAndRegisterModules().convertValue(Map.of( + "id", "1", "orderNo", "WT-1", "orderTypeName", "仓储转运", "businessSource", Map.of( + "businessType", "LOCAL_LOOSEBOARD", "label", "本土散板 · 拼车需求池", "businessOrderNo", "LB-1", "externalBookingNo", "FB-1")), com.baiyue.lastleg.dto.LastlegOrderSummary.class); + when(orders.page(isNull(), any(), isNull())).thenReturn(new PageResult<>(List.of(order),1,1,1,1)); + var result = service.batch("LAST_MILE_ORDER_LIST",request(Map.of("businessType","LOCAL_LOOSEBOARD","businessOrderNo","LB-1","externalBookingNo","FB-1"),null,null)); + verify(orders).page(isNull(),argThat(q -> "LOCAL_LOOSEBOARD".equals(q.businessType()) && "LB-1".equals(q.businessOrderNo()) && "FB-1".equals(q.externalBookingNo())),isNull()); + assertEquals("仓储转运",result.rows().get(0).get("orderTypeName")); + assertEquals("本土散板 · 拼车需求池",result.rows().get(0).get("businessSource.label")); + assertEquals("LB-1",result.rows().get(0).get("businessSource.businessOrderNo")); + assertEquals("FB-1",result.rows().get(0).get("businessSource.externalBookingNo")); + } + @Test void keepsCustomerScopeAndLedgerCustomerFilter() { when(inventory.ledgers(eq("200"), any())).thenReturn(new PageResult<>(List.of(),1,1,0,0)); service.batch("OVERSEAS_INVENTORY_LEDGER", request(Map.of("customerId","200","customerName","客户"),null,null)); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderBusinessSourceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderBusinessSourceTest.java new file mode 100644 index 000000000..cd21e2847 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderBusinessSourceTest.java @@ -0,0 +1,75 @@ +package com.baiyue.lastleg.service; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.baiyue.lastleg.dto.LastlegOrderRequests; +import com.baiyue.lastleg.dto.LastlegOrderSummary; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.apache.ibatis.datasource.unpooled.UnpooledDataSource; +import org.apache.ibatis.mapping.Environment; +import org.apache.ibatis.session.SqlSessionFactoryBuilder; +import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; +import java.util.*; +import java.util.regex.Pattern; +import static org.junit.jupiter.api.Assertions.*; + +class LastlegOrderBusinessSourceTest { + @Test void projectsAndFiltersFormalSourcesWithinTheOrderCustomer() throws Exception { + var config = new MybatisConfiguration(); + config.setEnvironment(new Environment("test", new JdbcTransactionFactory(), + new UnpooledDataSource("org.h2.Driver", "jdbc:h2:mem:business-source;MODE=MySQL", "sa", ""))); + config.addMapper(LastlegOrderMapper.class); + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(config, "source-test"), LastlegOrder.class); + try (var session = new SqlSessionFactoryBuilder().build(config).openSession()) { + var db = session.getConnection(); var sql = db.createStatement(); + sql.execute("CREATE TABLE ll_order (id BIGINT, customer_id VARCHAR, order_type VARCHAR, source_order_no VARCHAR)"); + sql.execute("CREATE TABLE ll_fleet_pool_order_link (lastleg_order_id BIGINT, customer_id VARCHAR, business_type VARCHAR, business_order_no VARCHAR)"); + sql.execute("CREATE TABLE ll_fleet_external_booking (customer_id VARCHAR, incoming_no VARCHAR, booking_no VARCHAR)"); + sql.execute("INSERT INTO ll_order VALUES (1,'A','LOCAL_LOOSEBOARD','LB-DIRECT'),(2,'A','WAREHOUSE_TRANSFER','WT-POOL')," + + "(3,'A','WAREHOUSE_TRANSFER','WT-TRUCK'),(4,'A','WAREHOUSE_TRANSFER','WT-NORMAL')," + + "(5,'B','WAREHOUSE_TRANSFER','WT-POOL'),(6,'A','WAREHOUSE_TRANSFER','WT-EXTERNAL')"); + sql.execute("INSERT INTO ll_fleet_pool_order_link VALUES (2,'A','LOCAL_LOOSEBOARD','LB-POOL')," + + "(3,'A','US_TRUCK','TRUCK-POOL'),(5,'B','LOCAL_LOOSEBOARD','LB-OTHER')," + + "(4,'B','LOCAL_LOOSEBOARD','LB-WRONG-CUSTOMER')"); + sql.execute("INSERT INTO ll_fleet_external_booking VALUES ('A','WT-POOL','FB-POOL'),('A','WT-TRUCK','FB-TRUCK')," + + "('B','WT-POOL','FB-OTHER'),('A','WT-EXTERNAL','FB-EXTERNAL')"); + var mapper = session.getMapper(LastlegOrderMapper.class); + var sources = mapper.findBusinessSources(List.of(1L,2L,3L,4L,5L,6L)); + assertEquals(6, sources.size()); + var service = new LastlegOrderService(); ReflectionTestUtils.setField(service,"orderMapper",mapper); + List orders = sources.stream().map(row -> { var o = new LastlegOrder(); o.setId(row.orderId()); return o; }).toList(); + Map values = ReflectionTestUtils.invokeMethod(service,"businessSources",orders); + assertEquals(new LastlegOrderSummary.BusinessSource("LOCAL_LOOSEBOARD","本土散板 · 独立下单","LB-DIRECT",null),values.get(1L)); + assertEquals(new LastlegOrderSummary.BusinessSource("LOCAL_LOOSEBOARD","本土散板 · 拼车需求池","LB-POOL","FB-POOL"),values.get(2L)); + assertEquals(new LastlegOrderSummary.BusinessSource("US_TRUCK","本土卡车 · 拼车需求池","TRUCK-POOL","FB-TRUCK"),values.get(3L)); + assertEquals(new LastlegOrderSummary.BusinessSource("WAREHOUSE_TRANSFER","仓储转运",null,null),values.get(4L)); + assertEquals("FB-OTHER",values.get(5L).externalBookingNo()); + assertEquals(new LastlegOrderSummary.BusinessSource("WAREHOUSE_TRANSFER","仓储转运 · 外接来货",null,"FB-EXTERNAL"),values.get(6L)); + assertEquals(List.of(1L,2L),find(db,"A","LOCAL_LOOSEBOARD",null,null)); + assertEquals(List.of(1L,2L,5L),find(db,null,"LOCAL_LOOSEBOARD",null,null)); + assertEquals(List.of(4L,6L),find(db,"A","WAREHOUSE_TRANSFER",null,null)); + assertEquals(List.of(3L),find(db,"A","US_TRUCK",null,null)); + assertEquals(List.of(2L),find(db,"A",null,"LB-POOL","FB-POOL")); + assertEquals(List.of(),find(db,"A",null,"LB-OTHER",null)); + assertEquals(List.of(),find(db,"A",null,null,"FB-OTHER")); + assertEquals(List.of(),find(db,"A",null,"' OR 1=1 --",null)); + } + } + private List find(java.sql.Connection db,String customer,String businessType,String businessOrderNo,String bookingNo) throws Exception { + var query = new LastlegOrderRequests.Query(1L,20L,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,businessType,businessOrderNo,bookingNo); + LambdaQueryWrapper filter = ReflectionTestUtils.invokeMethod(new LastlegOrderService(),"filter",customer,query); + var matcher = Pattern.compile("#\\{ew.paramNameValuePairs.([^}]+)}").matcher(filter.getSqlSegment()); + var parameters = new ArrayList(); var bound = new StringBuilder(); + while(matcher.find()) { parameters.add(filter.getParamNameValuePairs().get(matcher.group(1))); matcher.appendReplacement(bound,"?"); } + matcher.appendTail(bound); + try(var statement = db.prepareStatement("SELECT id FROM ll_order WHERE " + bound + " ORDER BY id")) { + for(int i=0;i(); try(var rows=statement.executeQuery()){while(rows.next())ids.add(rows.getLong(1));} return ids; + } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderSailingStageTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderSailingStageTest.java index d6b55754c..02cd882a6 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderSailingStageTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderSailingStageTest.java @@ -59,7 +59,7 @@ class LastlegOrderSailingStageTest { private List find(java.sql.Connection db, String customerId, SailingStage stage, String status) throws Exception { var query = new LastlegOrderRequests.Query(1L, 20L, null, null, null, null, null, null, - status, null, null, null, null, null, null, null, stage); + status, null, null, null, null, null, null, null, stage, null, null, null); LambdaQueryWrapper filter = ReflectionTestUtils.invokeMethod( new LastlegOrderService(), "filter", customerId, query); String where = filter.getSqlSegment(); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java index 02ea84783..39e6982a9 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java @@ -96,7 +96,7 @@ class LastlegOrderServiceTest { LastlegOrderSummary summary = ReflectionTestUtils.invokeMethod( service, "summary", order, List.of(), null, "UNSCHEDULED", - new LastlegOrderMapper.OrderWeightRow(11L, new java.math.BigDecimal("30"), new java.math.BigDecimal("22")), List.of()); + new LastlegOrderMapper.OrderWeightRow(11L, new java.math.BigDecimal("30"), new java.math.BigDecimal("22")), List.of(), null); assertNotNull(summary); assertEquals("BOOKING-1", summary.sourceContainerBookingId()); @@ -379,7 +379,7 @@ class LastlegOrderServiceTest { @Test void pageUsesPaginationAndPlatformFiltersWithoutCustomerScope() { var query = new LastlegOrderRequests.Query(3L, 25L, "LL-", "FIRST_LEG", "IN-", null, - null, "", "PENDING_ASSIGNMENT", null, "LAX", null, null, null, null, null, null); + null, "", "PENDING_ASSIGNMENT", null, "LAX", null, null, null, null, null, null, null, null, null); var resultPage = new com.baomidou.mybatisplus.extension.plugins.pagination.Page(3, 25); resultPage.setTotal(0); when(orders.selectPage(any(), any())).thenReturn(resultPage); @@ -536,7 +536,7 @@ class LastlegOrderServiceTest { new LastlegOrderMapper.PalletMergeRelationRow(11L, "INBOUND-2", "PMG-1"), new LastlegOrderMapper.PalletMergeRelationRow(11L, "INBOUND-3", "PMG-1"))); var query = new LastlegOrderRequests.Query(1L, 10L, null, null, null, null, - null, null, null, null, null, null, null, null, null, null, null); + null, null, null, null, null, null, null, null, null, null, null, null, null, null); var result = service.page(null, query); @@ -560,7 +560,7 @@ class LastlegOrderServiceTest { resultPage.setTotal(3); when(orders.selectPage(any(), any())).thenReturn(resultPage); var query = new LastlegOrderRequests.Query(1L, 10L, null, null, null, null, - null, null, null, null, null, null, null, null, null, null, null); + null, null, null, null, null, null, null, null, null, null, null, null, null, null); var result = service.page(null, query); @@ -592,7 +592,7 @@ class LastlegOrderServiceTest { when(orders.selectPage(any(), any())).thenReturn(resultPage); var result = service.page(null, new LastlegOrderRequests.Query(1L, 10L, null, null, null, null, - null, null, null, null, input, null, null, null, null, null, null)); + null, null, null, null, input, null, null, null, null, null, null, null, null, null)); ArgumentCaptor> filter = ArgumentCaptor.forClass(com.baomidou.mybatisplus.core.conditions.Wrapper.class); From f6605a1ab06ac8399a8d8cc3214ee663ae8035ab Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 9 Oct 2026 16:20:50 +0800 Subject: [PATCH 07/40] fix(sql): keep return counting in clean initialization definitions --- deploy/sql/海外区域统一增量-MySQL8.sql | 39 ++++---------------------- 1 file changed, 6 insertions(+), 33 deletions(-) diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index d6649d9e3..e08c3b3f5 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -2365,6 +2365,9 @@ CREATE TABLE ll_return_inventory_carton ( relabel_status varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '换标状态', relabel_completed_at datetime(3) DEFAULT NULL COMMENT '换标完成时间', status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'AVAILABLE可用或MERGED已合箱', + count_status VARCHAR(16) NOT NULL DEFAULT 'CONFIRMED' COMMENT '箱内清点状态 PENDING待清点 CONFIRMED已确认', + counted_at DATETIME(3) NULL COMMENT '箱内清点确认时间', + counted_by VARCHAR(128) NULL COMMENT '箱内清点确认操作人', sku_types int NOT NULL COMMENT '箱内SKU种类数', total_quantity bigint NOT NULL COMMENT '箱内总件数', length decimal(12,3) DEFAULT NULL COMMENT '箱长CM', @@ -2375,6 +2378,7 @@ CREATE TABLE ll_return_inventory_carton ( updated_at datetime(3) NOT NULL COMMENT '最后更新时间', merged_at datetime(3) DEFAULT NULL COMMENT '合箱完成时间', PRIMARY KEY (id), + KEY idx_ll_return_count (overseas_warehouse_id,count_status,status,source_receipt_id), UNIQUE KEY uk_ll_return_inventory_carton_mark (carton_mark), KEY idx_ll_return_inventory_carton_customer (customer_id,overseas_warehouse_id,status,created_at), CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status IN('AVAILABLE','PACKING_RESERVED','RELABEL_PENDING','OUTBOUND_PENDING','OUTBOUNDED','DISCARD_PENDING') AND sku_types>0 AND total_quantity>0) OR (status IN('MERGED','DISCARDED') AND sku_types=0 AND total_quantity=0)), @@ -3586,32 +3590,7 @@ CREATE TABLE event_delivery_attempt ( KEY idx_delivery_attempt_outcome (outcome,started_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='本服务业务事件逐次投递审计'; -SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 159 AS initialized_tables; - --- 退货托盘签收箱内清点;MySQL 8;仅在目标尾程库执行,可重复运行。 -USE baiyue_lastleg; -SET @rc_count_ddl = IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='count_status'),'SELECT 1','ALTER TABLE ll_return_inventory_carton ADD COLUMN count_status VARCHAR(16) NOT NULL DEFAULT ''CONFIRMED'' COMMENT ''箱内清点状态 PENDING待清点 CONFIRMED已确认'''); -PREPARE rc_count_stmt FROM @rc_count_ddl; EXECUTE rc_count_stmt; DEALLOCATE PREPARE rc_count_stmt; -SET @rc_count_ddl = IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='counted_at'),'SELECT 1','ALTER TABLE ll_return_inventory_carton ADD COLUMN counted_at DATETIME(3) NULL COMMENT ''箱内清点确认时间'''); -PREPARE rc_count_stmt FROM @rc_count_ddl; EXECUTE rc_count_stmt; DEALLOCATE PREPARE rc_count_stmt; -SET @rc_count_ddl = IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='counted_by'),'SELECT 1','ALTER TABLE ll_return_inventory_carton ADD COLUMN counted_by VARCHAR(128) NULL COMMENT ''箱内清点确认操作人'''); -PREPARE rc_count_stmt FROM @rc_count_ddl; EXECUTE rc_count_stmt; DEALLOCATE PREPARE rc_count_stmt; -SET @rc_count_ddl = IF(EXISTS(SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND index_name='idx_ll_return_count'),'SELECT 1','CREATE INDEX idx_ll_return_count ON ll_return_inventory_carton(overseas_warehouse_id,count_status,status,source_receipt_id)'); -PREPARE rc_count_stmt FROM @rc_count_ddl; EXECUTE rc_count_stmt; DEALLOCATE PREPARE rc_count_stmt; --- 历史箱:只迁移整批仍可用、没有换标/合箱占用、未确认清点的托盘签收箱。 --- 已进入履约或合箱的批次不回退;后续需要逐批人工核实。 -UPDATE ll_return_inventory_carton c -JOIN (SELECT source_receipt_id FROM (SELECT a.source_receipt_id - FROM ll_return_inventory_carton a WHERE a.source_receipt_id IS NOT NULL AND a.source_unit_type='PALLET' - GROUP BY a.source_receipt_id - HAVING SUM(a.status<>'AVAILABLE' OR a.relabel_application_id IS NOT NULL OR a.relabel_no IS NOT NULL OR a.counted_at IS NOT NULL)=0 - AND SUM(EXISTS(SELECT 1 FROM ll_return_inventory_carton_item i WHERE i.carton_id=a.id AND COALESCE(i.packing_reserved_quantity,0)>0))=0 - AND SUM(EXISTS(SELECT 1 FROM ll_return_packing_trace t WHERE t.source_carton_mark=a.carton_mark OR t.target_carton_mark=a.carton_mark))=0 -) pending) p ON p.source_receipt_id=c.source_receipt_id -SET c.count_status='PENDING' WHERE c.source_unit_type='PALLET' AND c.counted_at IS NULL; - --- 退货箱内清点作业会话:每位操作员只能认领一个批次,每个批次只能被一人认领。 -CREATE TABLE IF NOT EXISTS ll_return_carton_count_session ( +CREATE TABLE ll_return_carton_count_session ( operator_id VARCHAR(128) NOT NULL COMMENT '平台PDA操作员标识', warehouse_id VARCHAR(128) NOT NULL COMMENT '认领时绑定海外仓标识', receipt_id BIGINT NULL COMMENT '正在清点的签收批次,完成后释放为NULL', @@ -3622,10 +3601,4 @@ CREATE TABLE IF NOT EXISTS ll_return_carton_count_session ( UNIQUE KEY uk_ll_return_count_session_receipt(receipt_id) ) ENGINE=InnoDB COMMENT='退货箱内清点单人单任务持久会话'; --- 退货尾程与来源退货单统一业务单号,保留ID及库存/履约状态。 -USE baiyue_lastleg; -UPDATE ll_order o -JOIN ll_return_order r ON r.lastleg_order_id=o.id AND r.customer_id=o.customer_id -SET o.order_no=r.return_no, r.lastleg_order_no=r.return_no -WHERE o.order_type='RETURN_RELABEL' AND o.source_order_no=r.return_no - AND (NOT (o.order_no <=> r.return_no) OR NOT (r.lastleg_order_no <=> r.return_no)); +SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 160 AS initialized_tables; From 023b28e31f72569efffc023ab3b3b981e971b14d Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 9 Oct 2026 16:31:19 +0800 Subject: [PATCH 08/40] fix(lastleg): normalize source key comparisons across MySQL collations --- .../baiyue/lastleg/mapper/LastlegOrderMapper.java | 15 +++++++++++++-- .../lastleg/service/LastlegOrderService.java | 4 ++-- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java index eb19f1692..103c4d6c9 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java @@ -17,13 +17,24 @@ import java.math.BigDecimal; @Mapper public interface LastlegOrderMapper extends BaseMapper { // Correlate both order identity and customer; never infer a business source from remarks. + // CHAR(128) uses the connection character set/collation on both sides. These keys + // have at most 128 characters; table defaults differ between the fleet and order schemas. String POOL_SOURCE_WHERE = " FROM ll_fleet_pool_order_link l WHERE l.lastleg_order_id=ll_order.id " + - "AND l.customer_id=ll_order.customer_id AND ll_order.order_type='WAREHOUSE_TRANSFER'"; + "AND CAST(l.customer_id AS CHAR(128))=CAST(ll_order.customer_id AS CHAR(128)) " + + "AND ll_order.order_type='WAREHOUSE_TRANSFER'"; String BUSINESS_TYPE_SQL = "COALESCE((SELECT l.business_type" + POOL_SOURCE_WHERE + "),ll_order.order_type)"; String BUSINESS_ORDER_NO_SQL = "COALESCE((SELECT l.business_order_no" + POOL_SOURCE_WHERE + "),CASE WHEN ll_order.order_type='LOCAL_LOOSEBOARD' THEN ll_order.source_order_no END)"; + // Compare each authoritative column separately: MySQL COALESCE across these tables + // has no single collation and cannot safely be compared with a bound string. + String BUSINESS_TYPE_FILTER_SQL = "(EXISTS(SELECT 1" + POOL_SOURCE_WHERE + " AND l.business_type={0}) OR " + + "(ll_order.order_type={0} AND NOT EXISTS(SELECT 1" + POOL_SOURCE_WHERE + ")))"; + String BUSINESS_ORDER_NO_FILTER_SQL = "(EXISTS(SELECT 1" + POOL_SOURCE_WHERE + + " AND l.business_order_no LIKE CONCAT('%',{0},'%')) OR (ll_order.order_type='LOCAL_LOOSEBOARD' " + + "AND ll_order.source_order_no LIKE CONCAT('%',{0},'%')))"; String EXTERNAL_BOOKING_NO_SQL = "(SELECT b.booking_no FROM ll_fleet_external_booking b " + - "WHERE b.customer_id=ll_order.customer_id AND b.incoming_no=ll_order.source_order_no " + + "WHERE CAST(b.customer_id AS CHAR(128))=CAST(ll_order.customer_id AS CHAR(128)) " + + "AND CAST(b.incoming_no AS CHAR(128))=CAST(ll_order.source_order_no AS CHAR(128)) " + "AND ll_order.order_type='WAREHOUSE_TRANSFER')"; @Select("") + long count(@Param("origins") List origins, @Param("destinations") List destinations, + @Param("warehouseId") String warehouseId, @Param("destination") String destination, @Param("keyword") String keyword); + + @Select("") + List page(@Param("origins") List origins, @Param("destinations") List destinations, + @Param("warehouseId") String warehouseId, @Param("destination") String destination, + @Param("keyword") String keyword, @Param("size") int size, @Param("offset") long offset); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoService.java new file mode 100644 index 000000000..507af0b90 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoService.java @@ -0,0 +1,57 @@ +package com.baiyue.lastleg.appointmentplanning; + +import com.baiyue.common.core.api.PageResult; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; +import java.util.List; +import java.util.Set; + +@Service +public class AppointmentCargoService { + private final AppointmentCargoMapper mapper; + private final AppointmentWarehouseClient warehouses; + private final String token; + + public AppointmentCargoService(AppointmentCargoMapper mapper, AppointmentWarehouseClient warehouses, + @Value("${baiyue.security.internal-service-token:}") String token) { + this.mapper = mapper; this.warehouses = warehouses; this.token = token; + } + + public PageResult page(int page, int size, String warehouseId, String destination, String keyword) { + if (page < 1 || size < 1 || size > 100) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "每页数量必须为1至100"); + if (keyword != null && keyword.length() > 128) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "搜索词过长"); + var scope = scope(); + var origins = scope.origins(); + var destinations = scope.destinations(); + warehouseId = clean(warehouseId); destination = clean(destination); keyword = clean(keyword); + if (warehouseId != null && !origins.contains(warehouseId) || destination != null && !destinations.contains(destination)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "请选择已启用的美国接仓点及FBA目的仓"); + } + if (origins.isEmpty() || destinations.isEmpty()) return new PageResult<>(List.of(), page, size, 0, 0); + long total = mapper.count(origins, destinations, warehouseId, destination, keyword); + var rows = mapper.page(origins, destinations, warehouseId, destination, keyword, size, (long) (page - 1) * size); + return new PageResult<>(rows, page, size, total, (total + size - 1) / size); + } + + private record Scope(List origins, List destinations) { } + private Scope scope() { + final List catalog; + try { + var result = warehouses.catalog(token); + if (result == null || !result.successful() || result.data() == null) throw new IllegalStateException(); + catalog = result.data(); + } catch (RuntimeException error) { + throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "仓库目录暂不可用,无法核实美国货物范围", error); + } + var us = catalog.stream().filter(w -> w != null && w.id() != null && w.id() > 0 && "US".equals(w.countryCode())).toList(); + var origins = us.stream().filter(w -> Set.of("OVERSEAS", "OVERSEAS_PARTNER").contains(w.warehouseType() == null ? "" : w.warehouseType())) + .map(w -> w.id().toString()).distinct().toList(); + var destinations = us.stream().filter(w -> "FBA".equals(w.warehouseType()) && clean(w.warehouseCode()) != null) + .map(AppointmentWarehouseClient.Warehouse::warehouseCode).distinct().toList(); + return new Scope(origins, destinations); + } + + private static String clean(String value) { return value == null || value.isBlank() ? null : value.trim(); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCoverageMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCoverageMapper.java new file mode 100644 index 000000000..beaa43b28 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCoverageMapper.java @@ -0,0 +1,37 @@ +package com.baiyue.lastleg.appointmentplanning; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import java.time.LocalDateTime; +import java.util.List; + +@Mapper +public interface AppointmentCoverageMapper { + record OrderRef(String customerId, Long orderId) { } + record Source(Long orderId, String customerId, String destinationType, String fbaWarehouseCode, + Integer packages, Integer version, LocalDateTime updatedAt, boolean shipmentReferencesMissing) { } + record Fact(Long orderId, String customerId, String appointmentNo, String status, + LocalDateTime appointmentAt, LocalDateTime appointmentEndAt, LocalDateTime updatedAt, + Long lineId, Integer packages, String warehouseCode) { } + + @Select({""}) + List sources(@Param("sources") List sources); + + @Select({""}) + List appointments(@Param("sources") List sources); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCoverageService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCoverageService.java new file mode 100644 index 000000000..9f3feb3e6 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCoverageService.java @@ -0,0 +1,101 @@ +package com.baiyue.lastleg.appointmentplanning; + +import org.springframework.stereotype.Service; +import java.time.LocalDateTime; +import java.util.*; +import java.util.stream.Collectors; + +/** Shared read projection. Callers authorize the customer/source before invoking; never confirms bookings. */ +@Service +public class AppointmentCoverageService { + public record SourceRef(String customerId, String sourceKey, String destinationKey) { } + public record Appointment(String appointmentNo, String confirmationNo, String status, + LocalDateTime confirmedAt, LocalDateTime confirmedEndAt, String timezone) { } + public record Coverage(String customerId, String sourceKey, String destinationKey, String status, + List appointments, List missingFields, + String sourceVersion, boolean changeReviewRequired) { } + private final AppointmentCoverageMapper mapper; + private final AppointmentExternalSourceGateway external; + public AppointmentCoverageService(AppointmentCoverageMapper mapper, AppointmentExternalSourceGateway external) { + this.mapper=mapper; this.external=external; + } + + public List query(List input) { + if (input==null || input.isEmpty()) return List.of(); + if (input.size()>100 || input.stream().anyMatch(s -> s==null || blank(s.customerId()) || blank(s.sourceKey()) || blank(s.destinationKey()))) { + throw new IllegalArgumentException("每次最多核对100个完整的预约来源"); + } + var requested=input.stream().distinct().toList(); + var externalRefs=requested.stream().filter(s -> s.sourceKey().startsWith("BOOKING:")) + .map(s -> new AppointmentExternalSourceGateway.SourceRef(s.customerId(),s.sourceKey().substring(8))).toList(); + var bookings=externalRefs.isEmpty() ? List.of() : external.resolve(externalRefs); + var bookingMap=bookings.stream().collect(Collectors.toMap(s -> key(s.customerId(),"BOOKING:"+s.bookingNo()),s -> s)); + var refs=new LinkedHashMap(); + for (var source:requested) { + if (source.sourceKey().startsWith("ORDER:")) { + try { + long id=Long.parseLong(source.sourceKey().substring(6)); + if (id>0) refs.put(source,new AppointmentCoverageMapper.OrderRef(source.customerId(),id)); + } catch (NumberFormatException ignored) { /* Invalid source is returned explicitly below. */ } + } else if (source.sourceKey().startsWith("BOOKING:")) { + var booking=bookingMap.get(key(source.customerId(),source.sourceKey())); + if (booking!=null && booking.orderId()!=null && Objects.equals(source.destinationKey(),booking.destinationKey())) { + refs.put(source,new AppointmentCoverageMapper.OrderRef(source.customerId(),booking.orderId())); + } + } + } + var unique=refs.values().stream().distinct().toList(); + if (unique.size()!=refs.size()) throw new IllegalArgumentException("同一货物存在重复来源,请保留原外接登记来源后再核对"); + var orders=unique.isEmpty() ? List.of() : mapper.sources(unique); + var orderMap=orders.stream().collect(Collectors.toMap(s -> new AppointmentCoverageMapper.OrderRef(s.customerId(),s.orderId()),s -> s)); + var facts=unique.isEmpty() ? List.of() : mapper.appointments(unique); + var byOrder=facts.stream().collect(Collectors.groupingBy(f -> new AppointmentCoverageMapper.OrderRef(f.customerId(),f.orderId()))); + var output=new ArrayList(); + for (var source:requested) { + var booking=bookingMap.get(key(source.customerId(),source.sourceKey())); + if (booking!=null && !source.destinationKey().equals(booking.destinationKey())) { + output.add(empty(source,"DESTINATION_MISMATCH","DESTINATION",version(booking.recordVersion()),true)); continue; + } + if (booking!=null && !Set.of("CONFIRMED","RECEIVED","BOUND").contains(booking.bookingStatus()==null ? "" : booking.bookingStatus())) { + output.add(empty(source,"SOURCE_NOT_READY","CARRIER_CONFIRMATION",version(booking.recordVersion()),false)); continue; + } + var order=orderMap.get(refs.get(source)); + if (order==null) { + output.add(empty(source,booking==null ? "SOURCE_NOT_FOUND" : "SOURCE_NOT_MATERIALIZED","REAL_CARGO_MATERIALS",booking==null ? null : version(booking.recordVersion()),false)); continue; + } + if (!"FBA".equals(order.destinationType()) || !source.destinationKey().equals("FBA:"+order.fbaWarehouseCode())) { + output.add(empty(source,"DESTINATION_MISMATCH","DESTINATION",version(order.version()),true)); continue; + } + var relevant=byOrder.getOrDefault(refs.get(source),List.of()); + var distinct=new LinkedHashMap(); + var seen=new HashSet(); long confirmed=0, covered=0; boolean invalid=false, changed=false; + for (var fact:relevant) { + if (!Objects.equals(order.fbaWarehouseCode(),fact.warehouseCode()) || fact.packages()==null || fact.packages()<=0 || !seen.add(fact.lineId())) { invalid=true; continue; } + covered+=fact.packages(); + if ("SUCCESS".equals(fact.status()) && fact.appointmentAt()!=null) confirmed+=fact.packages(); + boolean success="SUCCESS".equals(fact.status()); + distinct.putIfAbsent(fact.appointmentNo(),new Appointment(fact.appointmentNo(),success ? fact.appointmentNo() : null,fact.status(),success ? fact.appointmentAt() : null,success ? fact.appointmentEndAt() : null,null)); + changed|=order.updatedAt()==null || fact.updatedAt()==null || order.updatedAt().isAfter(fact.updatedAt()); + } + var missing=new ArrayList(); + String status; + if (invalid || order.packages()==null || order.packages()<=0 || covered>order.packages()) { status="REVIEW_REQUIRED"; missing.add("CARGO_QUANTITY_REVIEW"); } + else if (covered==0) { status="UNASSIGNED"; missing.add("RECEIVING_APPOINTMENT"); } + else if (covered0) missing.add("APPOINTMENT_TIMEZONE_UNVERIFIED"); + if (order.shipmentReferencesMissing()) missing.add("FBA_SHIPMENT_REFERENCES"); + if (changed) missing.add("SOURCE_CHANGED_REVIEW"); + output.add(new Coverage(source.customerId(),source.sourceKey(),source.destinationKey(),status,List.copyOf(distinct.values()),List.copyOf(missing), + (booking==null ? "" : "BOOKING:"+version(booking.recordVersion())+"/")+"ORDER:"+version(order.version()),changed || invalid)); + } + return List.copyOf(output); + } + private static Coverage empty(SourceRef s,String status,String missing,String version,boolean review) { + return new Coverage(s.customerId(),s.sourceKey(),s.destinationKey(),status,List.of(),List.of(missing),version,review); + } + private static String key(String customerId,String sourceKey) { return customerId+"\n"+sourceKey; } + private static String version(Number version) { return version==null ? null : version.toString(); } + private static boolean blank(String value) { return value==null || value.isBlank(); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentExternalSourceGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentExternalSourceGateway.java new file mode 100644 index 000000000..2404ba580 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentExternalSourceGateway.java @@ -0,0 +1,11 @@ +package com.baiyue.lastleg.appointmentplanning; + +import java.util.List; + +/** Implemented by the external-booking owner; planning must not query Fleet's persistence directly. */ +public interface AppointmentExternalSourceGateway { + record SourceRef(String customerId, String bookingNo) { } + record Source(String customerId, String bookingNo, String tripNo, String bookingStatus, + String warehouseId, String destinationKey, Long orderId, Integer packageCount, Long recordVersion) { } + List resolve(List sources); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentFollowup.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentFollowup.java new file mode 100644 index 000000000..44110b2a8 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentFollowup.java @@ -0,0 +1,28 @@ +package com.baiyue.lastleg.appointmentplanning; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Data +@TableName("ll_appointment_followup") +public class AppointmentFollowup { + @TableId(type = IdType.INPUT) private Long id; + private Long sourceOrderId; + private String sourceKey; + private String customerId; + private String stage; + private Integer version; + private String contactedParty; + private String result; + private String nextAction; + private LocalDate followupDate; + private String operatorId; + private String operatorName; + private String commandKey; + private String requestHash; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentFollowupMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentFollowupMapper.java new file mode 100644 index 000000000..bc71648fa --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentFollowupMapper.java @@ -0,0 +1,19 @@ +package com.baiyue.lastleg.appointmentplanning; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import java.util.List; + +@Mapper +public interface AppointmentFollowupMapper extends BaseMapper { + @Select("SELECT * FROM ll_appointment_followup WHERE source_key=#{sourceKey} AND customer_id=#{customerId} AND stage=#{stage} AND command_key=#{key}") + AppointmentFollowup command(@Param("sourceKey") String sourceKey, @Param("customerId") String customerId, @Param("stage") String stage, @Param("key") String key); + @Select("SELECT COALESCE(MAX(version),0) FROM ll_appointment_followup WHERE source_key=#{sourceKey} AND customer_id=#{customerId} AND stage=#{stage}") + int version(@Param("sourceKey") String sourceKey, @Param("customerId") String customerId, @Param("stage") String stage); + @Select("SELECT * FROM ll_appointment_followup WHERE source_key=#{sourceKey} AND customer_id=#{customerId} ORDER BY created_at DESC,id DESC LIMIT #{size} OFFSET #{offset}") + List page(@Param("sourceKey") String sourceKey, @Param("customerId") String customerId, @Param("size") int size, @Param("offset") long offset); + @Select("SELECT COUNT(*) FROM ll_appointment_followup WHERE source_key=#{sourceKey} AND customer_id=#{customerId}") + long count(@Param("sourceKey") String sourceKey, @Param("customerId") String customerId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentFollowupService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentFollowupService.java new file mode 100644 index 000000000..053d082a9 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentFollowupService.java @@ -0,0 +1,83 @@ +package com.baiyue.lastleg.appointmentplanning; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.common.core.api.PageResult; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.validation.constraints.*; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.nio.charset.StandardCharsets; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.time.Instant; +import java.util.Set; + +@Service +public class AppointmentFollowupService { + private final AppointmentSourceService sources; + private final AppointmentFollowupMapper mapper; + private final ObjectMapper json; + public AppointmentFollowupService(AppointmentSourceService sources, AppointmentFollowupMapper mapper, ObjectMapper json) { + this.sources = sources; this.mapper = mapper; this.json = json; + } + public record Save(@NotBlank @Size(max=32) String stage, @Min(0) int expectedVersion, + @NotBlank @Size(max=64) String idempotencyKey, + @Size(max=200) String contactedParty, @NotBlank @Size(max=1000) String result, + @Size(max=500) String nextAction, LocalDate followupDate) { } + public record Record(String stage, int version, String contactedParty, String result, String nextAction, + LocalDate followupDate, String operatorName, Instant createdAt) { } + public record History(PageResult history, java.util.Map versions) { } + private static final Set STAGES = Set.of("DRAYAGE_CONTACT", "TERMINAL_APPOINTMENT", "WAREHOUSE_RECEIVING", "FBA_RECEIVING"); + + public History history(String sourceKey, String customerId, int page, int size) { + if (page<1 || size<1 || size>100) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"分页参数无效"); + long total = mapper.count(sourceKey,customerId); + if (total == 0) sources.resolve(sourceKey,customerId,null); + var records = mapper.page(sourceKey,customerId,size,(long)(page-1)*size).stream().map(AppointmentFollowupService::view).toList(); + var versions = new java.util.LinkedHashMap(); + STAGES.stream().sorted().forEach(stage -> versions.put(stage, mapper.version(sourceKey,customerId,stage))); + return new History(new PageResult<>(records,page,size,total,(total+size-1)/size),versions); + } + + @Transactional(isolation = org.springframework.transaction.annotation.Isolation.READ_COMMITTED) + public Record save(String sourceKey, String customerId, Save request, String operatorId, String operatorName) { + if (!STAGES.contains(request.stage())) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"请选择有效跟进事项"); + if (request.followupDate()!=null && (request.nextAction()==null || request.nextAction().isBlank())) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"设置跟进日期时请填写下一步安排"); + } + final String hash; + try { hash = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest((customerId+"\n"+operatorId+"\n"+json.writeValueAsString(request)).getBytes(StandardCharsets.UTF_8))); } + catch (JsonProcessingException | NoSuchAlgorithmException error) { throw new IllegalStateException("无法保存跟进请求",error); } + var existing = mapper.command(sourceKey,customerId,request.stage(),request.idempotencyKey()); + if (existing!=null) { + if (!hash.equals(existing.getRequestHash())) throw new ResponseStatusException(HttpStatus.CONFLICT,"重复请求内容不一致,请核对跟进记录"); + return view(existing); + } + var source = sources.resolve(sourceKey,customerId,null); + if (!source.canFollowup()) throw new ResponseStatusException(HttpStatus.CONFLICT,"当前来源仅可查看,请先核对承运状态"); + if (mapper.version(sourceKey,customerId,request.stage())!=request.expectedVersion()) throw new ResponseStatusException(HttpStatus.CONFLICT,"跟进记录已更新,请重新读取后提交"); + var value = new AppointmentFollowup(); value.setId(IdWorker.getId()); value.setSourceOrderId(source.orderId()); value.setSourceKey(source.sourceKey()); value.setCustomerId(source.customerId()); + value.setStage(request.stage()); value.setVersion(request.expectedVersion()+1); value.setContactedParty(clean(request.contactedParty())); + value.setResult(request.result().trim()); value.setNextAction(clean(request.nextAction())); value.setFollowupDate(request.followupDate()); + value.setOperatorId(operatorId); value.setOperatorName(clean(operatorName)); value.setCommandKey(request.idempotencyKey()); + value.setRequestHash(hash); value.setCreatedAt(LocalDateTime.now(ZoneOffset.UTC)); + try { mapper.insert(value); } + catch (org.springframework.dao.DuplicateKeyException conflict) { + var raced = mapper.command(sourceKey,customerId,request.stage(),request.idempotencyKey()); + if (raced!=null && hash.equals(raced.getRequestHash())) return view(raced); + throw new ResponseStatusException(HttpStatus.CONFLICT,"跟进记录已更新或请求内容冲突,请重新读取",conflict); + } + return view(value); + } + private static String clean(String value) { return value==null || value.isBlank() ? null : value.trim(); } + private static Record view(AppointmentFollowup row) { + return new Record(row.getStage(),row.getVersion(),row.getContactedParty(),row.getResult(),row.getNextAction(),row.getFollowupDate(),row.getOperatorName(),row.getCreatedAt().toInstant(ZoneOffset.UTC)); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentPlanningController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentPlanningController.java new file mode 100644 index 000000000..4e71ef1e1 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentPlanningController.java @@ -0,0 +1,70 @@ +package com.baiyue.lastleg.appointmentplanning; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.security.AuthenticatedUser; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import jakarta.validation.Valid; +import org.springframework.web.server.ResponseStatusException; + +@RestController +@RequestMapping("/api/platform/v1/last-leg/appointment-planning") +public class AppointmentPlanningController { + private final AppointmentCargoService service; + private final AppointmentFollowupService followups; + private final AppointmentSourceService sources; + private final AppointmentCoverageService coverage; + public AppointmentPlanningController(AppointmentCargoService service, AppointmentFollowupService followups, + AppointmentSourceService sources, AppointmentCoverageService coverage) { + this.service = service; this.followups = followups; this.sources = sources; this.coverage = coverage; + } + public record Resolved(AppointmentSourceService.Source source, AppointmentCoverageService.Coverage coverage) { } + + @GetMapping("/sources/resolve") + @PreAuthorize("hasAuthority('platform:appointment:list') and hasAuthority('platform:overseas-warehouse:last-mile-order:list')") + public ApiResponse resolve(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam String sourceKey, @RequestParam String customerId, @RequestParam(required=false) String destinationKey) { + requirePlatform(user); + var source = sources.resolve(sourceKey,customerId,destinationKey); + var result = coverage.query(java.util.List.of(new AppointmentCoverageService.SourceRef(source.customerId(),source.sourceKey(),source.destinationKey()))); + return ApiResponse.success(new Resolved(source,result.get(0))); + } + + @GetMapping("/cargo") + @PreAuthorize("hasAuthority('platform:appointment:list') and hasAuthority('platform:overseas-warehouse:last-mile-order:list')") + public ApiResponse> cargo(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "20") int size, + @RequestParam(required = false) String warehouseId, @RequestParam(required = false) String destination, + @RequestParam(required = false) String keyword) { + requirePlatform(user); + return ApiResponse.success(service.page(page, size, warehouseId, destination, keyword)); + } + + @GetMapping("/sources/{sourceKey}/follow-ups") + @PreAuthorize("hasAuthority('platform:appointment:list') and hasAuthority('platform:overseas-warehouse:last-mile-order:list')") + public ApiResponse history(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String sourceKey, @RequestParam String customerId, @RequestParam(defaultValue="1") int page, @RequestParam(defaultValue="20") int size) { + requirePlatform(user); return ApiResponse.success(followups.history(sourceKey,customerId,page,size)); + } + + @PostMapping("/sources/{sourceKey}/follow-ups") + @PreAuthorize("hasAuthority('platform:appointment:list') and hasAuthority('platform:overseas-warehouse:last-mile-order:list') and hasAuthority('platform:appointment:followup')") + public ApiResponse followup(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String sourceKey, @RequestParam String customerId, @Valid @RequestBody AppointmentFollowupService.Save request) { + requirePlatform(user); + return ApiResponse.success(followups.save(sourceKey,customerId,request,user.userId().toString(),user.displayName()==null ? user.username() : user.displayName())); + } + private static void requirePlatform(AuthenticatedUser user) { + if (user == null || user.userId()==null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + if (!"PLATFORM".equals(user.appCode()) || !Long.valueOf(0).equals(user.tenantId())) throw new ResponseStatusException(HttpStatus.FORBIDDEN); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentPlanningPolicy.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentPlanningPolicy.java new file mode 100644 index 000000000..009285889 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentPlanningPolicy.java @@ -0,0 +1,83 @@ +package com.baiyue.lastleg.appointmentplanning; + +import java.time.Instant; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; + +/** Stage-specific planning rules. Planning never creates stock, vehicles or carrier confirmations. */ +public final class AppointmentPlanningPolicy { + private AppointmentPlanningPolicy() { } + + public record Cargo(String sourceKey, String customerId, String originWarehouseId, + String destinationKey, String originCountry, String destinationCountry, + Integer packages, Instant readyFrom, Instant readyTo, + boolean eligibleForPlanning, String blockReason) { } + public record Group(String originWarehouseId, String destinationKey, long packages, + int sourceCount, int customerCount, Instant readyFrom, Instant readyTo, + boolean readinessIncomplete) { } + public record Forecast(String sourceKey, String sourceVersion, Instant eta, Instant readyAt) { } + public record Impact(String sourceKey, String reason, Instant previousEta, Instant latestEta, + Instant confirmedAt, boolean reviewRequired) { } + + public static Group group(List cargo) { + if (cargo == null || cargo.isEmpty()) throw new IllegalArgumentException("请选择需要安排的真实货物"); + if (cargo.size() > 500) throw new IllegalArgumentException("每批最多选择500条来源货物"); + Set sources = new HashSet<>(); + Set customers = new HashSet<>(); + String origin = null, destination = null; + long packages = 0; + Instant from = null, to = null; + boolean incomplete = false; + for (Cargo row : cargo) { + if (row == null || blank(row.sourceKey()) || blank(row.customerId())) { + throw new IllegalArgumentException("货物缺少稳定来源或客户归属"); + } + if (!sources.add(row.sourceKey())) throw new IllegalArgumentException("同一来源货物不能重复选择"); + if (!row.eligibleForPlanning()) throw new IllegalArgumentException( + blank(row.blockReason()) ? "所选货物暂不具备筹划资格" : row.blockReason()); + if (!"US".equals(row.originCountry()) || !"US".equals(row.destinationCountry())) { + throw new IllegalArgumentException("请选择已确认美国起运点及目的地的货物"); + } + if (blank(row.originWarehouseId()) || blank(row.destinationKey())) { + throw new IllegalArgumentException("货物缺少实际接仓点或目的地"); + } + if (origin == null) { origin = row.originWarehouseId(); destination = row.destinationKey(); } + if (!origin.equals(row.originWarehouseId()) || !destination.equals(row.destinationKey())) { + throw new IllegalArgumentException("同批货物必须具有相同实际起运仓和目的地"); + } + if (row.packages() == null || row.packages() <= 0) throw new IllegalArgumentException("货物箱数待核实,不能填默认数量"); + packages = Math.addExact(packages, row.packages().longValue()); + customers.add(row.customerId()); + if (row.readyFrom() == null || row.readyTo() == null) incomplete = true; + if (row.readyFrom() != null && row.readyTo() != null && row.readyTo().isBefore(row.readyFrom())) { + throw new IllegalArgumentException("货物预计备妥窗口无效"); + } + if (row.readyFrom() != null && (from == null || row.readyFrom().isAfter(from))) from = row.readyFrom(); + if (row.readyTo() != null && (to == null || row.readyTo().isBefore(to))) to = row.readyTo(); + } + if (from != null && to != null && to.isBefore(from)) throw new IllegalArgumentException("所选货物预计备妥窗口不相容,请分别安排"); + return new Group(origin, destination, packages, sources.size(), customers.size(), from, to, incomplete); + } + + /** A new forecast only requests review; it never changes an externally confirmed appointment. */ + public static Impact compare(Forecast acknowledged, Forecast latest, Instant confirmedAt) { + if (acknowledged == null || latest == null || blank(latest.sourceKey()) + || !Objects.equals(acknowledged.sourceKey(), latest.sourceKey())) { + throw new IllegalArgumentException("只能比较同一真实货源的预测"); + } + boolean changed = !Objects.equals(acknowledged.eta(), latest.eta()) + || !Objects.equals(acknowledged.readyAt(), latest.readyAt()); + String reason = "UNCHANGED"; + if (changed) { + reason = latest.eta() == null ? "FORECAST_MISSING" : "FORECAST_CHANGED"; + if (confirmedAt != null && latest.readyAt() != null && latest.readyAt().isAfter(confirmedAt)) { + reason = "READINESS_AFTER_APPOINTMENT"; + } + } + return new Impact(latest.sourceKey(), reason, acknowledged.eta(), latest.eta(), confirmedAt, changed); + } + + private static boolean blank(String value) { return value == null || value.isBlank(); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentSourceService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentSourceService.java new file mode 100644 index 000000000..168ecc0a4 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentSourceService.java @@ -0,0 +1,86 @@ +package com.baiyue.lastleg.appointmentplanning; + +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; +import java.util.List; +import java.util.Objects; +import java.util.Set; + +@Service +public class AppointmentSourceService { + public record Source(String sourceKey, String customerId, Long orderId, String inboundCode, String bookingNo, + String containerNo, String originWarehouseId, String originWarehouseName, + String destinationKey, String destinationWarehouseCode, Integer packages, + String sourceVersion, boolean canFollowup, List blockReasons) { } + private final LastlegOrderMapper orders; + private final ContainerShippingGateMapper shipping; + private final AppointmentExternalSourceGateway external; + private final AppointmentWarehouseClient warehouses; + private final String token; + public AppointmentSourceService(LastlegOrderMapper orders, ContainerShippingGateMapper shipping, + AppointmentExternalSourceGateway external, AppointmentWarehouseClient warehouses, + @Value("${baiyue.security.internal-service-token:}") String token) { + this.orders=orders; this.shipping=shipping; this.external=external; this.warehouses=warehouses; this.token=token; + } + public Source resolve(String sourceKey,String customerId,String destinationKey) { + if (blank(sourceKey) || sourceKey.length()>160 || blank(customerId) || customerId.length()>128) throw bad("请选择完整的真实货源"); + com.baiyue.lastleg.entity.LastlegOrder order=null; + String bookingNo=null,origin,destination,version; + Integer packages; + boolean followup=true; + List reasons=List.of(); + if (sourceKey.startsWith("ORDER:")) { + long id; + try { id=Long.parseLong(sourceKey.substring(6)); if (id<=0 || !sourceKey.equals("ORDER:"+id)) throw new NumberFormatException(); } + catch (NumberFormatException error) { throw bad("货源标识无效"); } + order=orders.selectById(id); + if (order==null || !customerId.equals(order.getCustomerId())) throw missing(); + if (!"FIRST_LEG".equals(order.getOrderType())) throw bad("请从原外接登记继续跟进,避免重复建立货源"); + var gate=order.getSourceContainerBookingId()==null ? null : shipping.selectById(order.getSourceContainerBookingId()); + if (gate==null || gate.getNodeSequence()==null || gate.getNodeSequence()<40) throw bad("货物尚无已开船或后续实际船节点"); + origin=order.getOverseasWarehouseId(); destination="FBA:"+order.getFbaWarehouseCode(); + if (!"FBA".equals(order.getDestinationType())) throw bad("当前货源不是FBA送仓货物"); + packages=order.getPackageCount(); version=order.getVersion()==null ? null : "ORDER:"+order.getVersion(); + } else if (sourceKey.startsWith("BOOKING:")) { + bookingNo=sourceKey.substring(8); + if (blank(bookingNo)) throw bad("外接登记号不能为空"); + var matches=external.resolve(List.of(new AppointmentExternalSourceGateway.SourceRef(customerId,bookingNo))); + if (matches.size()!=1) throw missing(); + var source=matches.get(0); + if (!Objects.equals(source.customerId(),customerId) || !Objects.equals(source.bookingNo(),bookingNo)) throw missing(); + origin=source.warehouseId(); destination=source.destinationKey(); packages=source.packageCount(); + version=source.recordVersion()==null ? null : "BOOKING:"+source.recordVersion(); + followup=Set.of("CONFIRMED","RECEIVED","BOUND").contains(source.bookingStatus()==null ? "" : source.bookingStatus()); + if (!followup) reasons=List.of("当前登记尚未确认承运或已取消,仅可查看跟进记录"); + if (source.orderId()!=null) { + order=orders.selectById(source.orderId()); + if (order==null || !customerId.equals(order.getCustomerId())) throw missing(); + if (!Objects.equals(order.getOverseasWarehouseId(),origin)) throw bad("来货的接仓点与原登记不一致,请先核对"); + if (!"FBA".equals(order.getDestinationType()) || !Objects.equals(destination,"FBA:"+order.getFbaWarehouseCode())) throw bad("来货目的仓与原登记不一致,请先核对"); + packages=order.getPackageCount(); + } + } else throw bad("不支持的货源类型"); + if (order!=null && Set.of("CANCELLED","COMPLETED").contains(order.getCurrentStatus()==null ? "" : order.getCurrentStatus())) { + followup=false; reasons=List.of("该货物已取消或完成,仅可查看跟进记录"); + } + if (!blank(destinationKey) && !Objects.equals(destination,destinationKey)) throw bad("目的地已变化,请返回原业务重新核对"); + if (destination==null || !destination.startsWith("FBA:")) throw bad("当前仅支持FBA收货跟进来源"); + var catalog=catalog(); final String originId=origin; final String code=destination.substring(4); + var warehouse=catalog.stream().filter(w -> w.id()!=null && w.id().toString().equals(originId) && "US".equals(w.countryCode()) + && Set.of("OVERSEAS","OVERSEAS_PARTNER").contains(w.warehouseType()==null ? "" : w.warehouseType())).findFirst().orElseThrow(() -> bad("接仓点不在启用的美国仓目录中")); + if (catalog.stream().noneMatch(w -> "FBA".equals(w.warehouseType()) && "US".equals(w.countryCode()) && code.equals(w.warehouseCode()))) throw bad("目的仓不在启用的美国FBA目录中"); + return new Source(sourceKey,customerId,order==null ? null : order.getId(),order==null ? null : order.getInboundCode(),bookingNo, + order==null ? null : order.getContainerNo(),origin,warehouse.warehouseName(),destination,code,packages,version,followup,reasons); + } + private List catalog() { + try { var result=warehouses.catalog(token); if (result==null || !result.successful() || result.data()==null) throw new IllegalStateException(); return result.data().stream().filter(Objects::nonNull).toList(); } + catch (RuntimeException error) { throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"仓库目录暂不可用,请稍后重新核对",error); } + } + private static boolean blank(String value) { return value==null || value.isBlank(); } + private static ResponseStatusException bad(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST,message); } + private static ResponseStatusException missing() { return new ResponseStatusException(HttpStatus.NOT_FOUND,"未找到当前客户的货物来源"); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentTimePolicy.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentTimePolicy.java new file mode 100644 index 000000000..b41cfbaa7 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentTimePolicy.java @@ -0,0 +1,42 @@ +package com.baiyue.lastleg.appointmentplanning; + +import java.time.DateTimeException; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.util.List; + +/** Resolves a location's appointment window without using the operator's system time zone. */ +public final class AppointmentTimePolicy { + private AppointmentTimePolicy() { } + + public record Window(Instant start, Instant end, String timezone) { } + + public static Window resolve(LocalDateTime start, LocalDateTime end, String timezone, + ZoneOffset startOffset, ZoneOffset endOffset) { + if (start == null) throw new IllegalArgumentException("请填写预约开始时间"); + if (timezone == null || timezone.isBlank()) throw new IllegalArgumentException("请先补齐预约地点时区"); + final ZoneId zone; + try { zone = ZoneId.of(timezone); } + catch (DateTimeException error) { throw new IllegalArgumentException("预约地点时区无效", error); } + if (zone instanceof ZoneOffset) throw new IllegalArgumentException("预约地点需要地区时区,不能仅填写固定时差"); + Instant first = instant(start, zone, startOffset); + Instant last = end == null ? null : instant(end, zone, endOffset); + if (end == null && endOffset != null) throw new IllegalArgumentException("未填写结束时间,不能指定结束时差"); + if (last != null && !last.isAfter(first)) throw new IllegalArgumentException("预约结束时间必须晚于开始时间"); + return new Window(first, last, zone.getId()); + } + + private static Instant instant(LocalDateTime value, ZoneId zone, ZoneOffset selected) { + List offsets = zone.getRules().getValidOffsets(value); + if (offsets.isEmpty()) throw new IllegalArgumentException("该当地时间因夏令时切换不存在,请重新选择"); + if (selected == null && offsets.size() > 1) { + throw new IllegalArgumentException("该当地时间在夏令时切换期间出现两次,请明确时差"); + } + if (selected != null && !offsets.contains(selected)) { + throw new IllegalArgumentException("所选时差与预约地点及日期不符"); + } + return value.toInstant(selected == null ? offsets.get(0) : selected); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentWarehouseClient.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentWarehouseClient.java new file mode 100644 index 000000000..af5039153 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentWarehouseClient.java @@ -0,0 +1,14 @@ +package com.baiyue.lastleg.appointmentplanning; + +import com.baiyue.common.core.api.ApiResponse; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import java.util.List; + +@FeignClient(name = "warehouse-service", contextId = "appointmentWarehouseClient", url = "${baiyue.clients.domestic-warehouse.url:}") +public interface AppointmentWarehouseClient { + @GetMapping("/internal/warehouses/order-catalog") + ApiResponse> catalog(@RequestHeader("X-Internal-Service-Token") String token); + record Warehouse(Long id, String warehouseCode, String warehouseName, String warehouseType, String countryCode) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/fleet/FleetDispatchPlanningAdapter.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/fleet/FleetDispatchPlanningAdapter.java new file mode 100644 index 000000000..61dcbe5ed --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/fleet/FleetDispatchPlanningAdapter.java @@ -0,0 +1,58 @@ +package com.baiyue.lastleg.dispatch.fleet; + +import com.baiyue.common.core.api.PageResult; +import com.baiyue.lastleg.dispatch.dto.DispatchRequests; +import com.baiyue.lastleg.dispatch.service.DispatchService; +import com.baiyue.lastleg.fleet.trip.FleetBookingIncomingGateway; +import com.baiyue.lastleg.fleet.trip.FleetDispatchGateway; +import com.baiyue.lastleg.fleet.trip.FleetDispatchPlanningGateway; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.http.HttpStatus; +import java.util.*; + +@Component +@Transactional(propagation=Propagation.MANDATORY) +public class FleetDispatchPlanningAdapter implements FleetDispatchPlanningGateway { + private final FleetDispatchPlanningMapper mapper; + private final DispatchService dispatches; + private final FleetDispatchGateway cargo; + public FleetDispatchPlanningAdapter(FleetDispatchPlanningMapper mapper,@Lazy DispatchService dispatches,FleetDispatchGateway cargo) { + this.mapper=mapper;this.dispatches=dispatches;this.cargo=cargo; + } + public PageResult candidates(String tripNo,String warehouseId,String keyword,int page,int size) { + var result=mapper.candidates(new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>(page,size),tripNo,warehouseId,keyword); + return new PageResult<>(result.getRecords(),result.getCurrent(),result.getSize(),result.getTotal(),result.getPages()); + } + public String resolveIncoming(String customerId,FleetBookingIncomingGateway.Evidence receipt,String operator) { + if(receipt==null||receipt.inboundCode()==null||!"READY_FOR_DISPATCH".equals(receipt.status())||receipt.pallets().isEmpty()) + throw conflict("请先完成真实收货和实收核对"); + var existing=mapper.sourceDispatches(customerId,receipt.inboundCode()); + if(existing.size()>1)throw conflict("本次来货已有多张排车安排,请调度核查:"+String.join("、",existing)); + String number; + if(existing.isEmpty()) { + var ids=mapper.receivedPlanPallets(customerId,receipt.inboundCode()); + if(ids.isEmpty())throw conflict("实收来源没有可用于排车的真实托盘"); + number=dispatches.preArrange(customerId,operator,new DispatchRequests.PreArrange(ids,null,null,null,null)).dispatchNo(); + } else number=existing.get(0); + validateIncomingDispatch(number,customerId,receipt); + return number; + } + public void validateIncomingDispatch(String number,String customerId,FleetBookingIncomingGateway.Evidence receipt) { + var snapshot=cargo.lockForPlanning(number); + if(!customerId.equals(snapshot.customerId())||!receipt.warehouseId().equals(snapshot.warehouseId()))throw conflict("排车客户或起运仓与来货不一致"); + var expected=new TreeMap(); + for(var p:receipt.pallets())expected.merge(p.warehousePalletId(),p.packages(),Math::addExact); + var selected=new TreeMap(); + for(var item:cargo.sourceCargo(number)) { + if(!Objects.equals(receipt.inboundCode(),item.inboundCode())||!Objects.equals(receipt.destinationKey(),item.destinationKey())||item.warehousePalletId()==null) + throw conflict("候选排车包含其他来源或目的地,请调度核查 "+number); + selected.merge(item.warehousePalletId(),item.packages(),Math::addExact); + } + if(!expected.equals(selected))throw conflict("候选排车未完整覆盖本票全部实收货物,请调度核查 "+number); + } + private static ResponseStatusException conflict(String message){return new ResponseStatusException(HttpStatus.CONFLICT,message);} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/fleet/FleetDispatchPlanningMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/fleet/FleetDispatchPlanningMapper.java new file mode 100644 index 000000000..60f01d757 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/fleet/FleetDispatchPlanningMapper.java @@ -0,0 +1,30 @@ +package com.baiyue.lastleg.dispatch.fleet; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baiyue.lastleg.fleet.trip.FleetDispatchPlanningGateway; +import org.apache.ibatis.annotations.*; +import java.util.List; + +@Mapper +public interface FleetDispatchPlanningMapper { + @Select({""}) + IPage candidates(IPage page,@Param("tripNo") String tripNo,@Param("warehouseId") String warehouseId,@Param("keyword") String keyword); + + @Select("SELECT DISTINCT d.dispatch_no FROM ll_dispatch_order d JOIN ll_dispatch_item i ON i.dispatch_order_id=d.id AND i.customer_id=d.customer_id " + + "WHERE d.customer_id=#{customerId} AND i.inbound_code=#{inboundCode} AND d.status!='CANCELLED' ORDER BY d.dispatch_no") + List sourceDispatches(@Param("customerId") String customerId,@Param("inboundCode") String inboundCode); + + @Select("SELECT DISTINCT p.id FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id " + + "JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id JOIN ll_inbound_plan_line l ON l.id=a.line_id " + + "WHERE ip.customer_id=#{customerId} AND l.inbound_code=#{inboundCode} AND p.warehouse_pallet_id IS NOT NULL ORDER BY p.id") + List receivedPlanPallets(@Param("customerId") String customerId,@Param("inboundCode") String inboundCode); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetAppointmentSourceAdapter.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetAppointmentSourceAdapter.java new file mode 100644 index 000000000..21ffd2bc5 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetAppointmentSourceAdapter.java @@ -0,0 +1,17 @@ +package com.baiyue.lastleg.fleet.business; + +import com.baiyue.lastleg.appointmentplanning.AppointmentExternalSourceGateway; +import org.springframework.stereotype.Component; +import java.util.List; + +/** Appointment resolves aliases through Fleet's read boundary, never by copying cargo. */ +@Component +public class FleetAppointmentSourceAdapter implements AppointmentExternalSourceGateway { + private final FleetPoolSourceResolver sources; + public FleetAppointmentSourceAdapter(FleetPoolSourceResolver sources){this.sources=sources;} + @Override + public List resolve(List refs) { + return sources.resolve(refs.stream().map(r->new FleetPoolSourceResolver.SourceRef(r.customerId(),r.bookingNo())).toList()) + .stream().map(s->new Source(s.customerId(),s.bookingNo(),s.tripNo(),s.bookingStatus(),s.warehouseId(),s.destinationKey(),s.lastlegOrderId(),s.packageCount(),s.recordVersion())).toList(); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderBridge.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderBridge.java index 7256d4e75..b76c8fed8 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderBridge.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderBridge.java @@ -39,6 +39,13 @@ public class FleetPoolOrderBridge { this.links=links;this.loose=loose;this.looseHistory=looseHistory;this.trucks=trucks; this.truckHistory=truckHistory;this.numbers=numbers;this.json=json;this.deliveryEvidence=deliveryEvidence; } + @Transactional(readOnly=true) + public Map fulfillmentStages(List bookingIds) { + if(bookingIds.isEmpty())return Map.of(); + var result=new HashMap(); + for(var link:links.bookings(bookingIds))result.put(link.getBookingId(),link.getFulfillmentStage()); + return Map.copyOf(result); + } @Transactional(propagation=Propagation.MANDATORY) public BusinessOrderRef confirm(ConfirmedBooking in,String operator) { validate(in); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderMapper.java index e20de43bc..e4519b73c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderMapper.java @@ -7,6 +7,15 @@ import java.util.List; @Mapper public interface FleetPoolOrderMapper extends BaseMapper { + @Select({""}) + List resolveSources(@Param("sources") List sources); @Select("SELECT * FROM ll_fleet_pool_order_link WHERE booking_id=#{id} FOR UPDATE") FleetPoolOrderLink lockBooking(long id); @Select("SELECT * FROM ll_fleet_pool_order_link WHERE booking_no=#{no} FOR UPDATE") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetPoolSourceResolver.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetPoolSourceResolver.java new file mode 100644 index 000000000..cba1be01f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetPoolSourceResolver.java @@ -0,0 +1,37 @@ +package com.baiyue.lastleg.fleet.business; + +import java.util.List; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** Internal read boundary for appointment planning; caller must authorize every customer/source. */ +@Service +public class FleetPoolSourceResolver { + public record SourceRef(String customerId, String bookingNo) { } + public record Source(String customerId, String bookingNo, String tripNo, String bookingStatus, + String warehouseId, String destinationKey, String destinationLabel, String incomingNo, + Long lastlegOrderId, String businessOrderNo, String fulfillmentStage, Long recordVersion, Integer packageCount, Integer expectedPalletCount, java.time.LocalDateTime estimatedArrivalAt) { } + public record SourceRow(String customerId, String bookingNo, String tripNo, String bookingStatus, + String warehouseId, String destinationKey, String destinationLabel, String incomingNo, + Long lastlegOrderId, String businessOrderNo, String fulfillmentStage, Long recordVersion, + String cargoJson, Integer expectedPalletCount, java.time.LocalDateTime estimatedArrivalAt) { } + private final com.fasterxml.jackson.databind.ObjectMapper json; + private final FleetPoolOrderMapper links; + public FleetPoolSourceResolver(FleetPoolOrderMapper links,com.fasterxml.jackson.databind.ObjectMapper json) { this.links = links; this.json=json; } + + @Transactional(readOnly = true) + public List resolve(List sources) { + if (sources == null || sources.isEmpty()) return List.of(); + if (sources.size() > 100 || sources.stream().anyMatch(s -> s == null || s.customerId() == null || s.customerId().isBlank() || s.bookingNo() == null || s.bookingNo().isBlank())) + throw new IllegalArgumentException("来源须包含客户及登记业务号,每次最多100条"); + return links.resolveSources(sources.stream().distinct().toList()).stream().map(row -> { + Integer packages; + try { + var value=json.readTree(row.cargoJson()).get("packageCount"); + if(value==null||value.isNull()) packages=null; + else { if(!value.isIntegralNumber()||!value.canConvertToInt())throw new IllegalStateException("预报箱数格式无效"); packages=value.intValue(); } + } catch(java.io.IOException e){throw new IllegalStateException("货物预报读取失败",e);} + return new Source(row.customerId(),row.bookingNo(),row.tripNo(),row.bookingStatus(),row.warehouseId(),row.destinationKey(),row.destinationLabel(),row.incomingNo(),row.lastlegOrderId(),row.businessOrderNo(),row.fulfillmentStage(),row.recordVersion(),packages,row.expectedPalletCount(),row.estimatedArrivalAt()); + }).toList(); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingIncomingGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingIncomingGateway.java index 35b41658f..daf79585a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingIncomingGateway.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingIncomingGateway.java @@ -11,6 +11,8 @@ public interface FleetBookingIncomingGateway { record Pallet(String warehousePalletId,String palletNo,int packages,BigDecimal weightKg,BigDecimal volumeCbm) { } record Evidence(String incomingNo,String inboundCode,String warehouseId,String destinationKey,String status, String receiptHash,List pallets,int receivedPackages) { } + record Source(String customerId,String incomingNo) { } + java.util.Map statuses(List sources); Evidence create(String operator, Incoming incoming); Evidence inspect(String customerId,String incomingNo,boolean lock); Evidence confirm(String customerId,String incomingNo,String operator,String key); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingService.java index aa1294cb3..01e8144a0 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingService.java @@ -35,12 +35,17 @@ public class FleetBookingService { private final FleetBookingQuoteContext quoteContext; private final FleetQuoteCandidateService quotes; @jakarta.annotation.Resource private FleetPoolOrderBridge poolOrders; + @jakarta.annotation.Resource private FleetDispatchPlanningGateway planning; + @jakarta.annotation.Resource private FleetCapacityOfferCommandMapper commands; public FleetBookingService(FleetTripMapper trips,FleetBookingMapper bookings,FleetBookingReservations reservations, FleetTripService history,FleetTripExecutionService execution,FleetCustomerDirectory customers,ObjectMapper json,FleetBookingIncomingGateway incoming, FleetBookingQuoteContext quoteContext,FleetQuoteCandidateService quotes){ this.trips=trips;this.bookings=bookings;this.reservations=reservations;this.history=history;this.execution=execution;this.customers=customers;this.json=json;this.incoming=incoming; this.quoteContext=quoteContext;this.quotes=quotes; } + public java.util.Map incomingStatuses(java.util.List rows) { + return incoming.statuses(rows.stream().filter(r->r.getIncomingNo()!=null).map(r->new FleetBookingIncomingGateway.Source(r.getCustomerId(),r.getIncomingNo())).distinct().toList()); + } @Transactional(readOnly=true,isolation=org.springframework.transaction.annotation.Isolation.REPEATABLE_READ) public FleetBookingModels.ListResult list(String tripNo){var trip=trip(tripNo,false);return new FleetBookingModels.ListResult(trip.getRecordVersion(),reservations.responses(trip.getId()));} @Transactional(readOnly=true,isolation=org.springframework.transaction.annotation.Isolation.REPEATABLE_READ) @@ -125,9 +130,12 @@ public class FleetBookingService { requireTrip(trip,request.tripRecordVersion());requireVersion(row,request.recordVersion()); if(!"CONFIRMED".equals(row.getStatus())||row.getIncomingNo()!=null)throw conflict("仅已确认且尚未登记来货的预订允许此操作"); customers.required(Long.parseLong(row.getCustomerId())); - var arrival=request.estimatedArrivalAt()==null?trip.getPlannedDepartureAt().toInstant(ZoneOffset.UTC).minusSeconds(7200):request.estimatedArrivalAt(); + var arrival=request.estimatedArrivalAt()!=null?request.estimatedArrivalAt():row.getEstimatedArrivalAt()==null?null:row.getEstimatedArrivalAt().toInstant(ZoneOffset.UTC); + if(arrival==null)throw conflict("请确认预计到仓时间,不自动推算到仓安排"); + if(arrival.isAfter(trip.getPlannedDepartureAt().toInstant(ZoneOffset.UTC)))throw conflict("预计到仓不得晚于计划发车时间"); + var instructions=trim(request.deliveryInstructions())!=null?request.deliveryInstructions():reservations.cargo(row).remark(); var evidence=incoming.create(operator,new FleetBookingIncomingGateway.Incoming(row.getCustomerId(),trip.getWarehouseId().toString(),trip.getWarehouseName(),row.getBookingNo(),reservations.cargo(row), - LocalDateTime.ofInstant(arrival,java.time.ZoneId.systemDefault()),request.deliveryInstructions(),request.privateAddress(),request.postalCode(),request.recipientName(),request.recipientPhone())); + LocalDateTime.ofInstant(arrival,java.time.ZoneId.systemDefault()),instructions,request.privateAddress(),request.postalCode(),request.recipientName(),request.recipientPhone())); requireEvidence(trip,row,evidence); row.setIncomingNo(evidence.incomingNo());row.setIncomingKey(request.idempotencyKey());row.setIncomingHash(hash); save(trip,row,operator,"BOOKING_INCOMING","登记外接来货 "+evidence.incomingNo());return result(trip,row,false); @@ -169,12 +177,48 @@ public class FleetBookingService { if(Objects.equals(row.getBindingKey(),request.idempotencyKey())){if(!hash.equals(row.getBindingHash()))throw conflict("关联发运请求内容不一致");return result(trip,row,true);} requireTrip(trip,request.tripRecordVersion());requireVersion(row,request.recordVersion()); if(!"RECEIVED".equals(row.getStatus()))throw conflict("请先确认外接实收货量和运费"); + if(row.getOfferId()!=null) { + var accepted=reservations.receipt(row); + var evidence=incoming.inspect(row.getCustomerId(),row.getIncomingNo(),true);requireEvidence(trip,row,evidence); + if(accepted==null||!Objects.equals(accepted.source().receiptHash(),evidence.receiptHash()))throw conflict("实收来源已变化,请重新核对"); + planning.validateIncomingDispatch(request.dispatchNo(),row.getCustomerId(),evidence); + } row.setBindingKey(request.idempotencyKey());row.setBindingHash(hash);bookings.updateById(row); var linked=history.bindDispatch(tripNo,operator,new FleetTripModels.BindingCommand(request.tripRecordVersion(),request.dispatchNo(),"关联外接预订 "+bookingNo)); var result=booking(trip,bookingNo); if(!"BOUND".equals(result.getStatus())||!Objects.equals(result.getDispatchNo(),request.dispatchNo().trim()))throw conflict("所选排车未包含此外接来货单的实际货物"); return new FleetBookingModels.Result(linked.trip().recordVersion(),reservations.response(result),false); } + @Transactional + public FleetBookingModels.Result arrangeDispatch(String tripNo,String bookingNo,String operator,FleetBookingModels.Command request) { + var trip=trip(tripNo,true);String hash=hash(request); + var previous=commands.find(trip.getId(),operator,"BOOKING_ARRANGE",bookingNo,request.idempotencyKey()); + if(previous!=null) { + if(!hash.equals(previous.getRequestHash()))throw conflict("排入本车请求内容不一致"); + try {var saved=json.readValue(previous.getResultJson(),FleetBookingModels.Result.class);return new FleetBookingModels.Result(saved.tripRecordVersion(),saved.booking(),true);} + catch(java.io.IOException e){throw new IllegalStateException("原排入结果读取失败",e);} + } + String resourceBlock=execution.currentVehicleBlockReason(trip,true);var row=booking(trip,bookingNo); + requireTrip(trip,request.tripRecordVersion());requireVersion(row,request.recordVersion()); + if(!"RECEIVED".equals(row.getStatus()))throw conflict("请先确认全部实收货量和运费"); + var accepted=reservations.receipt(row); + var evidence=incoming.inspect(row.getCustomerId(),row.getIncomingNo(),true);requireEvidence(trip,row,evidence); + if(accepted==null||!Objects.equals(accepted.source().receiptHash(),evidence.receiptHash()))throw conflict("实收来源已变化,请重新核对"); + if(resourceBlock!=null)throw conflict(resourceBlock); + var plan=execution.capacityPlan(tripNo); + if(!plan.planValid()||plan.blockReason()!=null)throw conflict("本车计划容量待核对,暂不能排入"); + reservations.segments(trip,plan.capacityPlan(),row.getId(),row); + String dispatchNo=planning.resolveIncoming(row.getCustomerId(),evidence,operator); + var linked=history.bindDispatch(tripNo,operator,new FleetTripModels.BindingCommand(trip.getRecordVersion(),dispatchNo,"实收核对后排入本车 "+bookingNo)); + row=booking(trip,bookingNo); + if(!"BOUND".equals(row.getStatus())||!Objects.equals(row.getDispatchNo(),dispatchNo))throw conflict("未完整关联本票实收来源"); + row.setBindingKey(request.idempotencyKey());row.setBindingHash(hash);bookings.updateById(row); + var result=new FleetBookingModels.Result(linked.trip().recordVersion(),reservations.response(row),false); + var command=new FleetCapacityOfferCommand();command.setId(IdWorker.getId());command.setTripId(trip.getId());command.setOperatorId(operator); + command.setAction("BOOKING_ARRANGE");command.setScopeNo(bookingNo);command.setIdempotencyKey(request.idempotencyKey());command.setRequestHash(hash); + command.setResultJson(write(result));command.setCreatedAt(now());commands.insert(command); + return result; + } private static void requireEvidence(FleetTripEntity trip,FleetBookingEntity row,FleetBookingIncomingGateway.Evidence evidence){ if(evidence==null||evidence.incomingNo()==null||!Objects.equals(trip.getWarehouseId().toString(),evidence.warehouseId())||!Objects.equals(row.getDestinationKey(),evidence.destinationKey()))throw conflict("来货单仓库或目的地与外接承运约定不一致"); if(row.getIncomingNo()!=null&&!Objects.equals(row.getIncomingNo(),evidence.incomingNo()))throw conflict("来货单引用不一致"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetCapacityOfferModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetCapacityOfferModels.java index 51d53dd31..b5f54bde1 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetCapacityOfferModels.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetCapacityOfferModels.java @@ -21,7 +21,7 @@ public final class FleetCapacityOfferModels { } public record InquiryQuery(@Min(1) Integer page,@Min(1) @Max(100) Integer size, @Size(max=128) String keyword,@Size(max=64) String offerNo,@Size(max=32) String status, - @Pattern(regexp="MINE|ALL") String scope) { + @Pattern(regexp="MINE|ALL|TODO") String scope) { public InquiryQuery {page=page==null?1:page;size=size==null?20:size;scope=scope==null?"MINE":scope;} } public record Write(@NotBlank @Size(max=64) String tripNo,@NotNull @Min(0) Long tripRecordVersion, @@ -50,12 +50,15 @@ public final class FleetCapacityOfferModels { String contactName,String contactPhone,String remark,List destinations,List segments, boolean capacityKnown,boolean canCreateInquiry,boolean canConfirm,String blockReason,Actions actions) { } public record InquiryActions(boolean canEdit,boolean canConfirm,boolean canCancel,String blockReason) { } + public record Progress(String stage,String label,String reservation,String owner,String primaryAction, + String message,List missingFields) { } public record Inquiry(String bookingNo,String offerNo,String tripNo,String status,long recordVersion, long offerRecordVersion,long tripRecordVersion,String salesOwnerUserId,String customerId,String customerCode, String customerName,String businessType,String deliveryMode,Instant estimatedArrivalAt,Integer expectedPalletCount, FleetBookingModels.Cargo cargo,String destinationLabel,BigDecimal agreedAmount, FleetQuoteCandidateModels.SourceSnapshot quoteSource,String businessOrderNo,String incomingNo,String dispatchNo, - Instant updatedAt,InquiryActions actions) { } + Instant updatedAt,InquiryActions actions,Progress progress,com.baiyue.lastleg.appointmentplanning.AppointmentCoverageService.Coverage appointmentCoverage) { } + public record Fulfillment(FleetBookingModels.Response booking,FleetBookingModels.IncomingPreview preview,Instant plannedDepartureAt) { } public record Detail(Offer offer,PageResult inquiries) { } public record OfferResult(Offer offer,boolean replayed) { } public record InquiryResult(Inquiry inquiry,Offer offer,boolean replayed) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetCapacityOfferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetCapacityOfferService.java index 0d2ac1b1e..becab1ea6 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetCapacityOfferService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetCapacityOfferService.java @@ -37,6 +37,7 @@ public class FleetCapacityOfferService { private final FleetPoolOrderBridge orders; private final ObjectMapper json; private final Clock clock; + @jakarta.annotation.Resource private com.baiyue.lastleg.appointmentplanning.AppointmentCoverageService appointmentCoverage; @Autowired public FleetCapacityOfferService(FleetCapacityOfferMapper offers,FleetCapacityOfferCommandMapper commands, FleetTripMapper trips,FleetBookingMapper bookings,FleetBookingService bookingService,FleetBookingReservations reservations, @@ -67,23 +68,52 @@ public class FleetCapacityOfferService { } @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) public PageResult inquiries(FleetCapacityOfferModels.InquiryQuery query){ - readPermission();if("ALL".equals(query.scope())&&!manage())throw forbidden(); + readPermission();if(!"MINE".equals(query.scope())&&!manage())throw forbidden(); var filter=new LambdaQueryWrapper().isNotNull(FleetBookingEntity::getOfferId); - if(!"ALL".equals(query.scope()))filter.eq(FleetBookingEntity::getSalesOwnerUserId,operator()); + if("MINE".equals(query.scope()))filter.eq(FleetBookingEntity::getSalesOwnerUserId,operator()); if(query.offerNo()!=null){var row=offer(query.offerNo(),false);requireVisible(row);filter.eq(FleetBookingEntity::getOfferId,row.getId());} if(nonblank(query.status()))filter.eq(FleetBookingEntity::getStatus,query.status()); - if(nonblank(query.keyword()))filter.and(q->q.like(FleetBookingEntity::getBookingNo,query.keyword()).or().like(FleetBookingEntity::getCustomerName,query.keyword()).or().like(FleetBookingEntity::getCustomerCode,query.keyword())); + if(nonblank(query.keyword()))filter.and(q->q.like(FleetBookingEntity::getBookingNo,query.keyword()).or().like(FleetBookingEntity::getCustomerName,query.keyword()).or().like(FleetBookingEntity::getCustomerCode,query.keyword()).or().like(FleetBookingEntity::getTripNo,query.keyword())); + boolean todo="TODO".equals(query.scope()); + if(todo)filter.in(FleetBookingEntity::getStatus,"DRAFT","CONFIRMED","RECEIVED"); filter.orderByDesc(FleetBookingEntity::getUpdatedAt,FleetBookingEntity::getId); - var result=bookings.selectPage(new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>(query.page(),query.size()),filter); + var result=bookings.selectPage(new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>(todo?1:query.page(),todo?100:query.size()),filter); + if(todo&&result.getTotal()>100)throw bad("待办实时核对最多100条,请按客户或登记号缩小范围"); + var incomingStatuses=bookingService.incomingStatuses(result.getRecords()); + var coverage=coverageByBooking(result.getRecords()); + var stages=orders.fulfillmentStages(result.getRecords().stream().map(FleetBookingEntity::getId).toList()); var numbers=orders.businessOrderNumbers(result.getRecords().stream().map(FleetBookingEntity::getId).toList()); var projections=new HashMap(); - var values=result.getRecords().stream().map(row->inquiry(row,numbers.get(row.getId()),projections.computeIfAbsent(row.getOfferId(),key->{var offer=offers.selectById(key);return project(offer,trip(offer.getTripNo(),false));}))).toList(); + var values=result.getRecords().stream().map(row->inquiry(row,numbers.get(row.getId()),projections.computeIfAbsent(row.getOfferId(),key->{var offer=offers.selectById(key);return project(offer,trip(offer.getTripNo(),false));}),incomingStatuses.get(new FleetBookingIncomingGateway.Source(row.getCustomerId(),row.getIncomingNo())),stages.get(row.getId()),coverage.get(row.getBookingNo()))).filter(v->!todo||"调度".equals(v.progress().owner())).toList(); + if(todo)return page(values,query.page(),query.size()); return new PageResult<>(values,result.getCurrent(),result.getSize(),result.getTotal(),result.getPages()); } @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) public FleetCapacityOfferModels.Inquiry inquiry(String number){ readPermission();var row=inquiryRow(number);own(row);return inquiry(row,orders.businessOrderNumbers(List.of(row.getId())).get(row.getId())); } + @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) + public FleetCapacityOfferModels.Fulfillment fulfillment(String number) { + readPermission();var row=inquiryRow(number);own(row); + var trip=trip(row.getTripNo(),false); + return new FleetCapacityOfferModels.Fulfillment(reservations.response(row), + bookingService.incoming(row.getTripNo(),number),instant(trip.getPlannedDepartureAt())); + } + @Transactional + public FleetBookingModels.Result arrangeIncoming(String number,FleetBookingModels.StartIncoming request) { + requireManage();var row=inquiryRow(number); + return bookingService.startIncoming(row.getTripNo(),number,operator(),request); + } + @Transactional + public FleetBookingModels.Result acceptReceipt(String number,FleetBookingModels.AcceptReceipt request) { + requireManage();var row=inquiryRow(number); + return bookingService.acceptReceipt(row.getTripNo(),number,operator(),request); + } + @Transactional + public FleetBookingModels.Result arrangeDispatch(String number,FleetBookingModels.Command request) { + requireManage();var row=inquiryRow(number); + return bookingService.arrangeDispatch(row.getTripNo(),number,operator(),request); + } @Transactional(readOnly=true) public List customerOptions(String keyword){ writePermission("inquiry-create"); @@ -165,6 +195,16 @@ public class FleetCapacityOfferService { var result=inquiryResult(row,booking);saveCommand(trip,"INQUIRY_EDIT",number,request.idempotencyKey(),request,result);return result; } @Transactional + public FleetCapacityOfferModels.InquiryResult saveAndConfirm(String number,FleetCapacityOfferModels.InquiryWrite request) { + requireManage();var initial=inquiryRow(number);var trip=trip(initial.getTripNo(),true); + var replay=replay(trip,"SAVE_CONFIRM",number,request.idempotencyKey(),request,FleetCapacityOfferModels.InquiryResult.class); + if(replay!=null)return replayed(replay); + var saved=updateInquiry(number,request); + var result=confirm(number,new FleetCapacityOfferModels.InquiryCommand(saved.offer().tripRecordVersion(),saved.offer().recordVersion(),saved.inquiry().recordVersion(),request.idempotencyKey(),"核对资料并确认承运")); + saveCommand(trip,"SAVE_CONFIRM",number,request.idempotencyKey(),request,result); + return result; + } + @Transactional public FleetCapacityOfferModels.InquiryResult confirm(String number,FleetCapacityOfferModels.InquiryCommand request){ requireManage();var initial=inquiryRow(number);var trip=trip(initial.getTripNo(),true); String vehicleBlock=execution.currentVehicleBlockReason(trip,true);var row=offers.lock(offers.selectById(initial.getOfferId()).getOfferNo());var booking=bookings.lock(trip.getId(),number); @@ -172,7 +212,10 @@ public class FleetCapacityOfferService { versions(trip,row,request.tripRecordVersion(),request.offerRecordVersion());requireInquiryOpen(row,trip); if(vehicleBlock!=null)throw conflict(vehicleBlock); var offer=project(row,trip);if(!offer.canConfirm())throw conflict(offer.blockReason()); - var cargo=reservations.cargo(booking);FleetBookingService.requireCompleteCargo(cargo); + var cargo=reservations.cargo(booking); + var missing=FleetInquiryProgress.missing(booking,cargo,offer); + if(!missing.isEmpty())throw conflict("确认前还需:"+String.join("、",missing)); + FleetBookingService.requireCompleteCargo(cargo); if(!nonblank(cargo.contactName())||!nonblank(cargo.contactPhone())||!nonblank(cargo.cargoName()))throw conflict("成交前请补齐货名和联系信息"); if("US_TRUCK".equals(booking.getBusinessType())&&(booking.getExpectedPalletCount()==null||booking.getExpectedPalletCount()<1))throw conflict("本土卡车成交前请填写预计实托数,不能用托位替代"); if(booking.getEstimatedArrivalAt()==null||booking.getEstimatedArrivalAt().isAfter(row.getCutoffAt()))throw conflict("请填写不晚于交货截止的预计到仓时间"); @@ -197,18 +240,32 @@ public class FleetCapacityOfferService { var result=inquiryResult(row,bookings.lock(trip.getId(),number));saveCommand(trip,"CANCEL",number,request.idempotencyKey(),request,result);return result; } + private Map coverageByBooking(List rows) { + var refs=rows.stream().filter(r->r.getDestinationKey()!=null&&r.getDestinationKey().startsWith("FBA:")) + .map(r->new com.baiyue.lastleg.appointmentplanning.AppointmentCoverageService.SourceRef(r.getCustomerId(),"BOOKING:"+r.getBookingNo(),r.getDestinationKey())).toList(); + var result=new HashMap(); + if(refs.isEmpty())return result; + for(var c:appointmentCoverage.query(refs))result.put(c.sourceKey().substring("BOOKING:".length()),c); + return result; + } private FleetCapacityOfferModels.InquiryResult inquiryResult(FleetCapacityOfferEntity row,FleetBookingEntity booking){ return new FleetCapacityOfferModels.InquiryResult(inquiry(booking,orders.businessOrderNumbers(List.of(booking.getId())).get(booking.getId())),project(row,trip(row.getTripNo(),false)),false); } private FleetCapacityOfferModels.InquiryResult replayed(FleetCapacityOfferModels.InquiryResult value){return new FleetCapacityOfferModels.InquiryResult(value.inquiry(),value.offer(),true);} private FleetCapacityOfferModels.Inquiry inquiry(FleetBookingEntity booking,String businessNo){ - var row=offers.selectById(booking.getOfferId());return inquiry(booking,businessNo,project(row,trip(booking.getTripNo(),false))); + var row=offers.selectById(booking.getOfferId());return inquiry(booking,businessNo,project(row,trip(booking.getTripNo(),false)),bookingService.incomingStatuses(List.of(booking)).get(new FleetBookingIncomingGateway.Source(booking.getCustomerId(),booking.getIncomingNo())),orders.fulfillmentStages(List.of(booking.getId())).get(booking.getId()),coverageByBooking(List.of(booking)).get(booking.getBookingNo())); } - private FleetCapacityOfferModels.Inquiry inquiry(FleetBookingEntity booking,String businessNo,FleetCapacityOfferModels.Offer offer){ + private FleetCapacityOfferModels.Inquiry inquiry(FleetBookingEntity booking,String businessNo,FleetCapacityOfferModels.Offer offer,String incomingStatus,String fulfillmentStage,com.baiyue.lastleg.appointmentplanning.AppointmentCoverageService.Coverage coverage){ boolean owned=manage()||Objects.equals(operator(),booking.getSalesOwnerUserId());boolean draft="DRAFT".equals(booking.getStatus()); boolean canCancel=owned&&(draft||manage()&&"CONFIRMED".equals(booking.getStatus())&&booking.getIncomingNo()==null); + var cargo=reservations.cargo(booking); + var missing=FleetInquiryProgress.missing(booking,cargo,offer); + String block=draft?FleetInquiryProgress.capacityBlock(cargo,offer):null; + boolean canEdit=owned&&draft&&offer.canCreateInquiry()&&(manage()||has(LIST)&&has(LIST.replace(":list",":inquiry-edit"))); + boolean canConfirm=manage()&&draft&&offer.canConfirm()&&missing.isEmpty()&&block==null; return new FleetCapacityOfferModels.Inquiry(booking.getBookingNo(),offer.offerNo(),booking.getTripNo(),booking.getStatus(),booking.getRecordVersion(),offer.recordVersion(),offer.tripRecordVersion(),booking.getSalesOwnerUserId(),booking.getCustomerId(),booking.getCustomerCode(),booking.getCustomerName(),booking.getBusinessType(),booking.getDeliveryMode(),instant(booking.getEstimatedArrivalAt()),booking.getExpectedPalletCount(),reservations.cargo(booking),booking.getDestinationLabel(),booking.getAgreedAmount(),reservations.quoteSource(booking),businessNo,booking.getIncomingNo(),booking.getDispatchNo(),instant(booking.getUpdatedAt()), - new FleetCapacityOfferModels.InquiryActions(owned&&draft&&offer.canCreateInquiry()&&(manage()||has(LIST)&&has(LIST.replace(":list",":inquiry-edit"))),manage()&&draft&&offer.canConfirm(),canCancel&&(manage()||has(LIST)&&has(LIST.replace(":list",":inquiry-cancel"))),offer.blockReason())); + new FleetCapacityOfferModels.InquiryActions(canEdit,canConfirm,canCancel&&(manage()||has(LIST)&&has(LIST.replace(":list",":inquiry-cancel"))),block), + FleetInquiryProgress.project(booking,missing,block,canEdit,canConfirm,incomingStatus,fulfillmentStage),coverage); } private FleetCapacityOfferModels.Offer project(FleetCapacityOfferEntity row,FleetTripEntity trip){ String block=openBlock(row,trip,clock.instant());var planning=execution.capacityPlan(trip.getTripNo()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetDispatchPlanningGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetDispatchPlanningGateway.java new file mode 100644 index 000000000..56a5d1fc3 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetDispatchPlanningGateway.java @@ -0,0 +1,11 @@ +package com.baiyue.lastleg.fleet.trip; + +import com.baiyue.common.core.api.PageResult; + +/** Dispatch owns candidate eligibility and creation; fleet orchestrates the trip transaction. */ +public interface FleetDispatchPlanningGateway { + record Candidate(String dispatchNo,String customerId,String warehouseId,int packageCount,String route) { } + PageResult candidates(String tripNo,String warehouseId,String keyword,int page,int size); + void validateIncomingDispatch(String dispatchNo,String customerId,FleetBookingIncomingGateway.Evidence receipt); + String resolveIncoming(String customerId,FleetBookingIncomingGateway.Evidence receipt,String operator); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetInquiryProgress.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetInquiryProgress.java new file mode 100644 index 000000000..34c7746a3 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetInquiryProgress.java @@ -0,0 +1,72 @@ +package com.baiyue.lastleg.fleet.trip; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; + +/** Derived handling guidance, never a second writable booking state machine. */ +final class FleetInquiryProgress { + private FleetInquiryProgress() { } + + static List missing(FleetBookingEntity booking, FleetBookingModels.Cargo cargo, + FleetCapacityOfferModels.Offer offer) { + var fields = new ArrayList(); + if (cargo.packageCount() == null || cargo.packageCount() < 1) fields.add("预计箱数"); + if (cargo.palletSpaces() == null || cargo.palletSpaces() < 1) fields.add("占用标准托位"); + if (!positive(cargo.weightKg())) fields.add("预计重量"); + if (!positive(cargo.volumeCbm())) fields.add("预计体积"); + if ("US_TRUCK".equals(booking.getBusinessType()) && + (booking.getExpectedPalletCount() == null || booking.getExpectedPalletCount() < 1)) fields.add("预计实托数"); + if (cargo.unitPrice() == null || cargo.unitPrice().signum() < 0) fields.add("约定价格"); + if (!List.of("CNY", "USD").contains(cargo.currency() == null ? "" : cargo.currency())) fields.add("币种(CNY或USD)"); + if (blank(cargo.cargoName())) fields.add("货物名称"); + if (blank(cargo.contactName())) fields.add("联系人"); + if (blank(cargo.contactPhone())) fields.add("联系方式"); + if (booking.getEstimatedArrivalAt() == null) fields.add("预计到仓时间"); + else if (booking.getEstimatedArrivalAt().toInstant(java.time.ZoneOffset.UTC).isAfter(offer.cutoffAt())) fields.add("预计到仓时间(不得晚于最晚送仓)"); + return List.copyOf(fields); + } + + static String capacityBlock(FleetBookingModels.Cargo cargo, FleetCapacityOfferModels.Offer offer) { + if (!offer.canConfirm()) return offer.blockReason(); + var destination = offer.destinations().stream().filter(d -> d.destinationKey().equals(cargo.destinationKey())).findFirst(); + if (destination.isEmpty()) return "卸货站已变化,请调度核对路线"; + var remaining = destination.get().remaining(); + if (remaining == null) return "余位待调度核对"; + if (cargo.palletSpaces() != null && cargo.palletSpaces() > remaining.palletSpaces() + || cargo.weightKg() != null && cargo.weightKg().compareTo(remaining.weightKg()) > 0 + || cargo.volumeCbm() != null && cargo.volumeCbm().compareTo(remaining.volumeCbm()) > 0) + return "本票预报超过所选目的地余位,请调度核对"; + return null; + } + + static FleetCapacityOfferModels.Progress project(FleetBookingEntity booking, List missing, + String block, boolean canEdit, boolean canConfirm, String incomingStatus, String fulfillmentStage) { + if ("IN_TRANSIT".equals(fulfillmentStage)) return progress("IN_TRANSIT", "已发车", "运输中", "派送", "VIEW", "按实际派送进度跟进", List.of()); + if ("COMPLETED".equals(fulfillmentStage)) return progress("COMPLETED", "已完成", "运输已完成", "无待办", "VIEW", "实际货物已全部签收", List.of()); + return switch (booking.getStatus()) { + case "DRAFT" -> { + if (!canEdit && !canConfirm && block != null) + yield progress("BLOCKED", "登记暂不可处理", "尚未保位", "调度", "VIEW", block, missing); + if (!missing.isEmpty()) yield progress("NEEDS_DETAILS", "待补资料", "尚未保位", "销售", canEdit ? "EDIT" : "VIEW", "确认前还需:" + String.join("、", missing), missing); + if (block != null) yield progress("RECHECK", "待调度核对", "尚未保位", "调度", "VIEW", block, missing); + yield progress("AWAITING_CONFIRMATION", "待调度确认", "尚未保位", "调度", canConfirm ? "CONFIRM" : "VIEW", "保存后调度可见,确认承运成功后才保位", missing); + } + case "CONFIRMED" -> booking.getIncomingNo() == null + ? progress("AWAITING_INCOMING", "尚未安排来货", "已保留货位", "调度", "INCOMING", "确定来货安排后再生成来货单", List.of()) + : incomingStatus != null && List.of("WAIT_CUSTOMER_CONFIRMATION","READY_FOR_DISPATCH").contains(incomingStatus) + ? progress("AWAITING_RECEIPT_CHECK", "实收完成待核量", "已保留货位", "调度", "RECEIPT", "核对预报与实收差异、容量及约定运费", List.of()) + : progress("RECEIVING", "已安排来货", "已保留货位", "仓库", "VIEW", incomingStatus == null ? "收货进度暂不可确认,请打开本票进度核对" : "等待仓库实际收货,销售跟进客户送仓", List.of()); + case "RECEIVED" -> progress("READY_TO_BIND", "实收已核对", "已保留货位", "调度", "BIND", "确认排入原车次,仍需后续审核和实际作业", List.of()); + case "BOUND" -> progress("BOUND", "已排入本车", "已转为本车货物", "审核 / 仓库", "VIEW", "排入不等于已装车或发车,请查看关联排车作业", List.of()); + default -> progress("CANCELLED", "已取消", "未保留货位", "无待办", "VIEW", "原登记保留供追溯", List.of()); + }; + } + + private static FleetCapacityOfferModels.Progress progress(String stage, String label, String reservation, + String owner, String action, String message, List missing) { + return new FleetCapacityOfferModels.Progress(stage, label, reservation, owner, action, message, missing); + } + private static boolean blank(String value) { return value == null || value.isBlank(); } + private static boolean positive(BigDecimal value) { return value != null && value.signum() > 0; } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripPreparationService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripPreparationService.java new file mode 100644 index 000000000..7e5848ca2 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripPreparationService.java @@ -0,0 +1,81 @@ +package com.baiyue.lastleg.fleet.trip; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.security.core.context.SecurityContextHolder; +import java.util.*; +import java.time.LocalDateTime; +import java.time.ZoneOffset; + +/** Shared by dispatch and appointment consumers; all business writes commit together. */ +@Service +public class FleetTripPreparationService { + public record Prepare(@Size(max=64) String tripNo,@Min(0) Long recordVersion, + @Valid FleetTripModels.Create plan,@Positive Long vehicleId,@Positive Long driverId, + @NotEmpty @Size(max=100) List<@NotBlank @Size(max=64) String> dispatchNos, + @NotBlank @Size(min=8,max=128) String idempotencyKey) { } + private final FleetTripService service; + private final FleetTripMapper trips; + private final FleetCapacityOfferCommandMapper commands; + private final ObjectMapper json; + public FleetTripPreparationService(FleetTripService service,FleetTripMapper trips,FleetCapacityOfferCommandMapper commands,ObjectMapper json) { + this.service=service;this.trips=trips;this.commands=commands;this.json=json; + } + @Transactional + public FleetTripModels.Detail prepare(String operator,Prepare request) { + boolean create=request.tripNo()==null||request.tripNo().isBlank(); + if(create) { permission("platform:fleet:trip:create");permission("platform:fleet:trip:assign"); } + permission("platform:fleet:trip:edit"); + if(new HashSet<>(request.dispatchNos()).size()!=request.dispatchNos().size())throw conflict("排车单不能重复选择"); + if(create && (request.plan()==null||request.vehicleId()==null||request.driverId()==null))throw conflict("新建车次请填写计划、车辆和司机"); + if(!create && (request.plan()!=null||request.vehicleId()!=null||request.driverId()!=null||request.recordVersion()==null))throw conflict("加入已有车次请提交车次版本,不重复修改资源或计划"); + var initial=create?trips.findCreated(operator,request.idempotencyKey()):trips.findByNo(request.tripNo()); + if(!create&&initial==null)throw conflict("所选车次不存在"); + String payload=write(request); + if(initial!=null) { + var locked=trips.lockByNo(initial.getTripNo()); + var previous=commands.find(locked.getId(),operator,"PREPARE_TRIP",locked.getTripNo(),request.idempotencyKey()); + if(previous!=null) { + if(!Objects.equals(previous.getRequestHash(),hash(payload)))throw conflict("同一安排请求不能更换内容"); + try{return json.readValue(previous.getResultJson(),FleetTripModels.Detail.class);}catch(Exception e){throw new IllegalStateException(e);} + } + if(create)throw conflict("该新建请求已被其他操作使用,请核对原车次"); + } + FleetTripModels.Detail detail; + if(create) { + var p=request.plan(); + detail=service.create(operator,new FleetTripModels.Create(p.warehouseId(),p.plannedDepartureAt(),p.expectedDriverEndAt(),p.stops(),p.remark(),request.idempotencyKey())); + // Concurrent same-key create may have committed while waiting for its unique constraint. + var locked=trips.lockByNo(detail.trip().tripNo()); + var previous=commands.find(locked.getId(),operator,"PREPARE_TRIP",locked.getTripNo(),request.idempotencyKey()); + if(previous!=null) { + if(!Objects.equals(previous.getRequestHash(),hash(payload)))throw conflict("同一安排请求不能更换内容"); + try{return json.readValue(previous.getResultJson(),FleetTripModels.Detail.class);}catch(Exception e){throw new IllegalStateException(e);} + } + detail=service.assign(detail.trip().tripNo(),operator,new FleetTripModels.Assign(detail.trip().recordVersion(),request.vehicleId(),request.driverId(),"安排车次时分配资源")); + } else { + detail=service.detail(request.tripNo()); + if(detail.trip().recordVersion()!=request.recordVersion())throw conflict("车次已变化,请重新核对"); + } + for(String number:request.dispatchNos().stream().sorted().toList()) + detail=service.bindDispatch(detail.trip().tripNo(),operator,new FleetTripModels.BindingCommand(detail.trip().recordVersion(),number,"勾选待运货物安排车次")); + var row=trips.findByNo(detail.trip().tripNo()); + var command=new FleetCapacityOfferCommand();command.setId(IdWorker.getId());command.setTripId(row.getId());command.setOperatorId(operator); + command.setAction("PREPARE_TRIP");command.setScopeNo(row.getTripNo());command.setIdempotencyKey(request.idempotencyKey());command.setRequestHash(hash(payload)); + command.setResultJson(write(detail));command.setCreatedAt(LocalDateTime.now(ZoneOffset.UTC));commands.insert(command); + return detail; + } + private static void permission(String name) { + var auth=SecurityContextHolder.getContext().getAuthentication(); + if(auth==null||auth.getAuthorities().stream().noneMatch(a->name.equals(a.getAuthority())))throw new ResponseStatusException(HttpStatus.FORBIDDEN,"缺少安排车次所需权限"); + } + private String write(Object value){try{return json.writeValueAsString(value);}catch(Exception e){throw new IllegalStateException(e);}} + private static String hash(String value){try{return HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(value.getBytes(java.nio.charset.StandardCharsets.UTF_8)));}catch(Exception e){throw new IllegalStateException(e);}} + private static ResponseStatusException conflict(String message){return new ResponseStatusException(HttpStatus.CONFLICT,message);} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripService.java index d3cb48671..00df73c67 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripService.java @@ -190,6 +190,15 @@ public class FleetTripService { return detail(trip); } + @jakarta.annotation.Resource private FleetDispatchPlanningGateway planning; + @Transactional(readOnly=true,isolation=org.springframework.transaction.annotation.Isolation.REPEATABLE_READ) + public com.baiyue.common.core.api.PageResult dispatchCandidates(String tripNo,String keyword,int page,int size) { + var trip=trips.findByNo(tripNo); + if(trip==null)throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.NOT_FOUND,"车次不存在"); + if(!"PLANNED".equals(trip.getStatus()))throw conflict("仅计划中的车次可以添加待运货物"); + return planning.candidates(tripNo,trip.getWarehouseId().toString(),keyword,page,size); + } + @Transactional public FleetTripModels.Detail bindDispatch(String tripNo, String operator, FleetTripModels.BindingCommand request) { var trip = planned(tripNo, request.recordVersion()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformFleetCapacityOfferController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformFleetCapacityOfferController.java index f7e36a100..d7fbcd061 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformFleetCapacityOfferController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformFleetCapacityOfferController.java @@ -45,4 +45,16 @@ public class PlatformFleetCapacityOfferController { public ApiResponse> customers(@RequestParam(required=false) String keyword){return ApiResponse.success(service.customerOptions(keyword));} @GetMapping("/{offerNo}/quote-candidates") @PreAuthorize("hasAuthority('platform:fleet:quote:list') and hasAnyAuthority('platform:fleet:capacity-offer:list','platform:fleet:capacity-offer:manage')") public ApiResponse> quoteCandidates(@PathVariable String offerNo,@Valid FleetBookingModels.QuoteCandidatesQuery query){return ApiResponse.success(service.quoteCandidates(offerNo,query));} + @GetMapping("/inquiries/{bookingNo}/fulfillment") @PreAuthorize("hasAnyAuthority('platform:fleet:capacity-offer:list','platform:fleet:capacity-offer:manage')") + public ApiResponse fulfillment(@PathVariable String bookingNo){return ApiResponse.success(service.fulfillment(bookingNo));} + @PostMapping("/inquiries/{bookingNo}/incoming") @PreAuthorize("hasAuthority('platform:fleet:capacity-offer:manage') and hasAuthority('platform:fleet:trip:booking-edit')") + public ApiResponse incoming(@PathVariable String bookingNo,@Valid @RequestBody FleetBookingModels.StartIncoming request){return ApiResponse.success(service.arrangeIncoming(bookingNo,request));} + @PostMapping("/inquiries/{bookingNo}/receipt-confirmation") @PreAuthorize("hasAuthority('platform:fleet:capacity-offer:manage') and hasAuthority('platform:fleet:trip:booking-confirm')") + public ApiResponse receipt(@PathVariable String bookingNo,@Valid @RequestBody FleetBookingModels.AcceptReceipt request){return ApiResponse.success(service.acceptReceipt(bookingNo,request));} + @PostMapping("/inquiries/{bookingNo}/arrange-dispatch") @PreAuthorize("hasAuthority('platform:fleet:capacity-offer:manage') and hasAuthority('platform:fleet:trip:booking-edit') and hasAuthority('platform:fleet:trip:edit') and hasAuthority('platform:overseas-warehouse:inbound-order:smart-dispatch')") + public ApiResponse arrange(@PathVariable String bookingNo,@Valid @RequestBody FleetBookingModels.Command request){return ApiResponse.success(service.arrangeDispatch(bookingNo,request));} + + @PostMapping("/inquiries/{bookingNo}/save-and-confirm") @PreAuthorize("hasAuthority('platform:fleet:capacity-offer:manage')") + public ApiResponse saveAndConfirm(@PathVariable String bookingNo,@Valid @RequestBody FleetCapacityOfferModels.InquiryWrite request){return ApiResponse.success(service.saveAndConfirm(bookingNo,request));} + } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformFleetTripController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformFleetTripController.java index 18259b501..e21203694 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformFleetTripController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformFleetTripController.java @@ -17,6 +17,13 @@ import org.springframework.web.server.ResponseStatusException; @Validated @RequestMapping("/api/platform/v1/last-leg/fleet/trips") public class PlatformFleetTripController { + @jakarta.annotation.Resource private FleetTripPreparationService preparation; + @PostMapping("/prepare") + @PreAuthorize("hasAuthority('platform:fleet:trip:edit')") + public ApiResponse prepare(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody FleetTripPreparationService.Prepare request) { + return ApiResponse.success(preparation.prepare(operator(user),request)); + } private final FleetTripService service; private final FleetTripExecutionService execution; private final FleetTripCompletionService completion; @@ -50,6 +57,13 @@ public class PlatformFleetTripController { public ApiResponse cancel(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String tripNo, @Valid @RequestBody FleetTripModels.Cancel request) { return ApiResponse.success(service.cancel(tripNo, operator(user), request)); } + @GetMapping("/{tripNo}/dispatch-candidates") + @PreAuthorize("hasAuthority('platform:fleet:trip:list')") + public ApiResponse> dispatchCandidates(@PathVariable String tripNo, + @RequestParam(required=false) String keyword,@RequestParam(defaultValue="1") @Min(1) int page, + @RequestParam(defaultValue="20") @Min(1) @Max(100) int size) { + return ApiResponse.success(service.dispatchCandidates(tripNo,keyword,page,size)); + } @PostMapping("/{tripNo}/dispatches/bind") @PreAuthorize("hasAuthority('platform:fleet:trip:edit')") public ApiResponse bind(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String tripNo, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/fleet/FleetIncomingAdapter.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/fleet/FleetIncomingAdapter.java index 43b5a96d6..8999ddc0c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/fleet/FleetIncomingAdapter.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/fleet/FleetIncomingAdapter.java @@ -24,6 +24,16 @@ public class FleetIncomingAdapter implements FleetBookingIncomingGateway { private final ObjectMapper json; public FleetIncomingAdapter(WarehouseTransferService service,WarehouseTransferOrderMapper orders,ObjectMapper json){this.service=service;this.orders=orders;this.json=json;} @Override @Transactional(propagation=Propagation.MANDATORY) + public Map statuses(List sources) { + if(sources.isEmpty())return Map.of(); + if(sources.size()>100)throw conflict("每次最多读取100条来货进度"); + var query=new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper(); + query.and(q->{for(var source:sources)q.or(c->c.eq(com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder::getCustomerId,source.customerId()).eq(com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder::getTransferNo,source.incomingNo()));}); + var result=new HashMap(); + for(var row:orders.selectList(query))result.put(new Source(row.getCustomerId(),row.getTransferNo()),row.getStatus()); + return Map.copyOf(result); + } + @Override @Transactional(propagation=Propagation.MANDATORY) public Evidence create(String operator,Incoming input){ boolean fba=input.cargo().destinationKey().startsWith("FBA:"); String code=fba?input.cargo().destinationKey().substring(4):null; diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 78a58d645..855d8538d 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -1624,6 +1624,30 @@ CREATE TABLE IF NOT EXISTS ll_pda_inventory_move ( KEY idx_ll_pda_inventory_move_pallet(overseas_warehouse_id,pallet_id,created_at) ) ENGINE=InnoDB COMMENT='海外PDA同仓整托移库记录'; +CREATE TABLE IF NOT EXISTS ll_appointment_followup ( + id BIGINT NOT NULL COMMENT '预约筹划跟进记录ID', + source_key VARCHAR(160) NOT NULL COMMENT '稳定ORDER或BOOKING来源键,不因实收转换而变化', + customer_id VARCHAR(128) NOT NULL COMMENT '服务端核实的来源客户', + source_order_id BIGINT NULL COMMENT '已形成的真实尾程货源ID,尚未生成时为空', + stage VARCHAR(32) NOT NULL COMMENT '跟进对象:DRAYAGE_CONTACT/TERMINAL_APPOINTMENT/WAREHOUSE_RECEIVING/FBA_RECEIVING', + version INT NOT NULL COMMENT '同一货源及跟进对象的记录版本', + contacted_party VARCHAR(200) NULL COMMENT '本次联系对象,不替代承运主档', + result VARCHAR(1000) NOT NULL COMMENT '本次反馈事实,不自动确认预约或推进实物作业', + next_action VARCHAR(500) NULL COMMENT '下一步安排', + followup_date DATE NULL COMMENT '按业务地点当地日期记录的下次跟进日', + operator_id VARCHAR(128) NOT NULL COMMENT '登录平台操作者ID', + operator_name VARCHAR(200) NULL COMMENT '操作人姓名快照', + command_key VARCHAR(64) NOT NULL COMMENT '持久幂等键', + request_hash CHAR(64) NOT NULL COMMENT '含操作者身份的请求SHA256摘要', + created_at DATETIME(3) NOT NULL COMMENT 'UTC记录时间', + PRIMARY KEY (id), + UNIQUE KEY uk_appointment_followup_version (source_key,stage,version), + UNIQUE KEY uk_appointment_followup_command (source_key,stage,command_key), + KEY idx_appointment_followup_date (followup_date,source_order_id), + CONSTRAINT fk_appointment_followup_order FOREIGN KEY (source_order_id) REFERENCES ll_order(id), + CONSTRAINT chk_appointment_followup_stage CHECK (stage IN ('DRAYAGE_CONTACT','TERMINAL_APPOINTMENT','WAREHOUSE_RECEIVING','FBA_RECEIVING')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='真实在途货源的预约筹划联系记录,追加留痕'; + CREATE TABLE IF NOT EXISTS ll_inbound_fba_appointment ( id BIGINT NOT NULL COMMENT '主键ID', appointment_no VARCHAR(64) NOT NULL COMMENT '预约单号', diff --git a/lastleg-service/src/main/resources/openapi/appointment-planning-v1.yaml b/lastleg-service/src/main/resources/openapi/appointment-planning-v1.yaml new file mode 100644 index 000000000..6a3b3ca95 --- /dev/null +++ b/lastleg-service/src/main/resources/openapi/appointment-planning-v1.yaml @@ -0,0 +1,230 @@ +openapi: 3.0.3 +info: + title: 美国预约筹划货源 + version: 1.0.0 + description: 读取真实已开船头程货物,不要求预先创建入仓计划,不生成库存、车次或对外确认。 +security: + - bearerAuth: [] +paths: + /api/platform/v1/last-leg/appointment-planning/cargo: + get: + operationId: appointmentPlanningCargo + summary: 分页读取美国FBA筹划货源 + description: >- + 仅平台身份;同时要求platform:appointment:list与platform:overseas-warehouse:last-mile-order:list。 + 从公共仓库目录核对美国接仓点与美国FBA目的仓;目录失败返回错误,不冒充空结果。 + 当前读取FIRST_LEG货物与已确认开船或更晚的实际船节点,未建计划可见;已有计划不重复计数。 + 预计时间来自尾程订单保存的来源快照,不代表实时船司ETA;shippingObservedAt仅是实际节点观察时间。 + sourcePalletCount是头程预报托数,不是海外实测托位。其他货源由后续正式合同接入,不能从本接口推断已支持。 + parameters: + - {name: page, in: query, schema: {type: integer, minimum: 1, default: 1}} + - {name: size, in: query, schema: {type: integer, minimum: 1, maximum: 100, default: 20}} + - {name: warehouseId, in: query, schema: {type: string}, description: 美国接仓点主档ID,仅用于关联} + - {name: destination, in: query, schema: {type: string}, description: 美国FBA仓编码} + - {name: keyword, in: query, schema: {type: string, maxLength: 128}, description: 入仓码、柜号或客户名称} + responses: + '200': + description: 业务响应,必须核对code + content: + application/json: + schema: + type: object + properties: + code: {type: string} + message: {type: string} + data: + type: object + properties: + records: {type: array, items: {$ref: '#/components/schemas/Cargo'}} + page: {type: integer} + size: {type: integer} + total: {type: integer} + pages: {type: integer} + '400': {description: 查询参数或美国范围无效} + '403': {description: 无来源读取权限或非平台身份} + '503': {description: 仓库目录暂不可用,不能证明美国货源范围} + /api/platform/v1/last-leg/appointment-planning/sources/resolve: + get: + operationId: resolveAppointmentPlanningSource + summary: 核对深链来源并读取预约覆盖 + description: >- + 仅平台;同时要求platform:appointment:list与platform:overseas-warehouse:last-mile-order:list。 + 依据真实客户、货源与美国仓目录核对来源,URL参数不能授予预约资格。 + ORDER仅接受已开船头程FBA货;BOOKING保留原登记身份,确认承运后尚未生成入仓单也能跟进。 + 此查询不建单、不确认预约。当前覆盖读取已有FBA预约,尚未生成货物时返回SOURCE_NOT_MATERIALIZED。 + parameters: + - {name: sourceKey, in: query, required: true, schema: {type: string, maxLength: 160}, description: 'ORDER:<内部关联ID>或BOOKING:<登记号>,非用户展示编号'} + - {name: customerId, in: query, required: true, schema: {type: string, maxLength: 128}} + - {name: destinationKey, in: query, schema: {type: string}, description: 'FBA:<仓编码>,传入后必须与真实来源一致'} + responses: + '200': + description: code=200时data包含已核实来源和预约覆盖 + content: + application/json: + schema: + type: object + properties: + code: {type: string} + message: {type: string} + data: + type: object + properties: + source: {$ref: '#/components/schemas/PlanningSource'} + coverage: {$ref: '#/components/schemas/Coverage'} + '400': {description: 非美国FBA来源、尚未开船或目的地变化} + '403': {description: 无读取权限或非平台身份} + '404': {description: 客户与来源不匹配或来源不存在} + '503': {description: 仓库目录不可用} + /api/platform/v1/last-leg/appointment-planning/sources/{sourceKey}/follow-ups: + parameters: + - {name: sourceKey, in: path, required: true, schema: {type: string, maxLength: 160}, description: 'ORDER或BOOKING稳定来源键,路径须URL编码'} + - {name: customerId, in: query, required: true, schema: {type: string, maxLength: 128}} + get: + summary: 分页读取分事项跟进历史与最新版本 + description: 仅平台;同时要求platform:appointment:list与platform:overseas-warehouse:last-mile-order:list。已有跟进历史在货物完成后仍可读取。 + parameters: + - {name: page, in: query, schema: {type: integer, minimum: 1, default: 1}} + - {name: size, in: query, schema: {type: integer, minimum: 1, maximum: 100, default: 20}} + responses: + '200': + description: code=200时data含history分页和按stage排列的versions;createdAt为UTC时间点,followupDate为业务地点当地日期。 + content: + application/json: + schema: + type: object + properties: + code: {type: string} + message: {type: string} + data: + type: object + properties: + history: + type: object + properties: + records: {type: array, items: {$ref: '#/components/schemas/FollowupRecord'}} + page: {type: integer} + size: {type: integer} + total: {type: integer} + pages: {type: integer} + versions: {type: object, additionalProperties: {type: integer, minimum: 0}, description: 四个事项各自的最新版本} + '403': {description: 无读取权限或非平台身份} + '404': {description: 无历史且来源不具备美国筹划资格} + post: + summary: 追加人工联系结果与下次跟进 + description: >- + 仅平台;除读取权限外要求platform:appointment:followup。 + 跟进人来自登录主体,不接受客户端操作者ID。核实来源后按stage版本追加,以持久化唯一约束防止并发覆盖;幂等键持久化,同键同操作者同内容返回原记录,异内容拒绝。 + 不要求已创建入仓计划、外部预约号或司机,不更新正式预约、库存和车次。设置followupDate时nextAction必填。 + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [stage, expectedVersion, idempotencyKey, result] + properties: + stage: {type: string, enum: [DRAYAGE_CONTACT, TERMINAL_APPOINTMENT, WAREHOUSE_RECEIVING, FBA_RECEIVING]} + expectedVersion: {type: integer, minimum: 0, description: 初次为0,后续取history.versions中对应事项版本} + idempotencyKey: {type: string, maxLength: 64} + contactedParty: {type: string, maxLength: 200, nullable: true} + result: {type: string, minLength: 1, maxLength: 1000} + nextAction: {type: string, maxLength: 500, nullable: true} + followupDate: {type: string, format: date, nullable: true} + responses: + '200': + description: data为原记录或新增记录 + content: + application/json: + schema: + type: object + properties: + code: {type: string} + message: {type: string} + data: {$ref: '#/components/schemas/FollowupRecord'} + '400': {description: 无效事项或缺少下一步安排} + '403': {description: 无写权限或非平台身份} + '404': {description: 来源不存在或不可筹划} + '409': {description: 版本冲突或同键异内容} +components: + securitySchemes: + bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT} + schemas: + FollowupRecord: + type: object + properties: + stage: {type: string, enum: [DRAYAGE_CONTACT, TERMINAL_APPOINTMENT, WAREHOUSE_RECEIVING, FBA_RECEIVING]} + version: {type: integer, minimum: 1} + contactedParty: {type: string, nullable: true} + result: {type: string} + nextAction: {type: string, nullable: true} + followupDate: {type: string, format: date, nullable: true} + operatorName: {type: string, nullable: true} + createdAt: {type: string, format: date-time, description: UTC时间点} + PlanningSource: + type: object + properties: + sourceKey: {type: string} + customerId: {type: string} + orderId: {type: string, nullable: true} + inboundCode: {type: string, nullable: true} + bookingNo: {type: string, nullable: true} + containerNo: {type: string, nullable: true} + originWarehouseId: {type: string} + originWarehouseName: {type: string} + destinationKey: {type: string} + destinationWarehouseCode: {type: string} + packages: {type: integer, nullable: true, description: 未知数量保持空值,不能替换为0} + sourceVersion: {type: string, nullable: true} + canFollowup: {type: boolean} + blockReasons: {type: array, items: {type: string}} + Coverage: + type: object + properties: + customerId: {type: string} + sourceKey: {type: string} + destinationKey: {type: string} + status: {type: string, enum: [SOURCE_NOT_FOUND, SOURCE_NOT_MATERIALIZED, SOURCE_NOT_READY, DESTINATION_MISMATCH, UNASSIGNED, PARTIAL, PENDING, CONFIRMED, REVIEW_REQUIRED]} + sourceVersion: {type: string, nullable: true} + changeReviewRequired: {type: boolean} + missingFields: {type: array, items: {type: string}, description: 缺项或待核实原因代码,客户端需显示业务文案} + appointments: + type: array + items: + type: object + properties: + appointmentNo: {type: string} + confirmationNo: {type: string, nullable: true} + status: {type: string} + confirmedAt: {type: string, nullable: true} + confirmedEndAt: {type: string, nullable: true} + timezone: {type: string, nullable: true, description: 旧预约未记录时区时为空并返回缺项,不能按浏览器时区推定} + Cargo: + type: object + properties: + sourceKey: {type: string, description: 稳定ORDER来源键,不因生成入仓计划而变化;非展示编号} + orderId: {type: string} + customerId: {type: string} + customerName: {type: string, nullable: true} + inboundCode: {type: string, nullable: true} + containerBookingId: {type: string} + containerNo: {type: string, nullable: true} + originWarehouseId: {type: string} + originWarehouseCode: {type: string} + originWarehouseName: {type: string} + destinationWarehouseCode: {type: string} + packages: {type: integer, nullable: true} + receivedPackages: {type: integer, nullable: true} + shippedPackages: {type: integer, nullable: true} + sourcePalletCount: {type: integer, nullable: true} + volumeCbm: {type: number, nullable: true} + estimatedArrivalAt: {type: string, nullable: true, description: 来源预计到港快照,保留原业务当地时间;不得按浏览器时区转换} + estimatedWarehouseAt: {type: string, nullable: true, description: 已保存预计到仓,不因本查询改变} + orderVersion: {type: integer} + standardNode: {type: string} + shippingVersion: {type: string, nullable: true} + shippingObservedAt: {type: string, nullable: true} + shippingProvider: {type: string, nullable: true} + hasInboundPlan: {type: boolean} + hasActiveAppointment: {type: boolean} + detained: {type: boolean} + mixedDestinations: {type: boolean, description: 存在其他目的地明细时,不能按整票默认为同仓货物} diff --git a/lastleg-service/src/main/resources/openapi/fleet-capacity-offers-v1.yaml b/lastleg-service/src/main/resources/openapi/fleet-capacity-offers-v1.yaml index 96ff377c3..52163e41b 100644 --- a/lastleg-service/src/main/resources/openapi/fleet-capacity-offers-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/fleet-capacity-offers-v1.yaml @@ -376,7 +376,7 @@ paths: /api/platform/v1/last-leg/fleet/capacity-offers/inquiries: get: summary: 分页查询本人或获准的全部意向 - description: 权限:platform:fleet:capacity-offer:list 或 platform:fleet:capacity-offer:manage。ALL只允许manage;销售负责人由登录身份过滤,历史不依赖发布仍OPEN。 + description: 权限:platform:fleet:capacity-offer:list 或 platform:fleet:capacity-offer:manage。ALL和TODO只允许manage;TODO按服务端负责角色筛选调度待办,基础候选超过100条要求按客户或登记号缩小范围,过滤后精确分页,不按当前页伪造总数;销售负责人由登录身份过滤,历史不依赖发布仍OPEN。 responses: '200': description: 统一业务响应;code=200成功,冲突或资格拒绝不得当成功 @@ -440,6 +440,7 @@ paths: enum: - MINE - ALL + - TODO default: MINE /api/platform/v1/last-leg/fleet/capacity-offers/inquiries/{bookingNo}: get: @@ -985,8 +986,41 @@ components: actions: $ref: '#/components/schemas/OfferActions' Inquiry: + description: 登记返回服务端资料缺项与处理进度;canConfirm同时核对本票完整性和目的地三维余位,最终写命令仍复验。 type: object properties: + appointmentCoverage: + type: object + nullable: true + description: FBA来源通过AppointmentCoverageService按客户和BOOKING稳定来源批量读取;无订单保留待形成货件资料,私人目的地为null。不把保位当收货预约,不推算未知时区。 + properties: + status: {type: string} + sourceVersion: {type: string, nullable: true} + changeReviewRequired: {type: boolean} + missingFields: {type: array, items: {type: string}} + appointments: + type: array + items: + type: object + properties: + appointmentNo: {type: string} + confirmationNo: {type: string, nullable: true} + status: {type: string} + confirmedAt: {type: string, nullable: true} + confirmedEndAt: {type: string, nullable: true} + timezone: {type: string, nullable: true} + progress: + type: object + description: 只读派生处理提示,不是可写状态;负责人及动作按资料完整性、承运/来货/实收事实返回;保存不保位。 + required: [stage, label, reservation, owner, primaryAction, message, missingFields] + properties: + stage: {type: string} + label: {type: string} + reservation: {type: string} + owner: {type: string} + primaryAction: {type: string, enum: [VIEW, EDIT, CONFIRM, INCOMING, RECEIPT, BIND]} + message: {type: string} + missingFields: {type: array, items: {type: string}} bookingNo: type: string offerNo: diff --git a/lastleg-service/src/main/resources/openapi/fleet-preparation-v1.yaml b/lastleg-service/src/main/resources/openapi/fleet-preparation-v1.yaml new file mode 100644 index 000000000..8488475c3 --- /dev/null +++ b/lastleg-service/src/main/resources/openapi/fleet-preparation-v1.yaml @@ -0,0 +1,103 @@ +openapi: 3.0.3 +info: + title: 共用车次准备与拼车连续处理 + version: 1.0.0 +security: + - bearerAuth: [] +paths: + /api/platform/v1/last-leg/fleet/trips/prepare: + post: + summary: 原子新建或加入车次并关联所选排车单 + description: '权限:platform:fleet:trip:edit;新建另需platform:fleet:trip:create和platform:fleet:trip:assign。新建、资源分配和全部关联在同一事务,失败回滚;幂等键同载荷重放原结果,异载荷拒绝。同仓、未执行、未绑定其他车次;不确认容量、不审批、不发车。' + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/Prepare'} + responses: + '200': {description: '统一ApiResponse,data为FleetTripModels.Detail;code=200才成功'} + /api/platform/v1/last-leg/fleet/trips/{tripNo}/dispatch-candidates: + parameters: [{$ref: './fleet-trips-v1.yaml#/components/parameters/TripNo'}] + get: + summary: 本仓未关联且未开始执行的排车候选 + description: '权限:platform:fleet:trip:list。数据库筛选后分页,绑定时重新锁定校验。候选不代表预约、装载或审核已完成。' + parameters: + - {name: keyword, in: query, schema: {type: string}} + - {name: page, in: query, schema: {type: integer, minimum: 1, default: 1}} + - {name: size, in: query, schema: {type: integer, minimum: 1, maximum: 100, default: 20}} + responses: + '200': {description: '统一分页data:records含dispatchNo、customerId、warehouseId、packageCount、route及total/page/size/pages;内部身份不作为用户业务编号'} + /api/platform/v1/last-leg/fleet/capacity-offers/inquiries/{bookingNo}/save-and-confirm: + parameters: [{$ref: './fleet-trips-v1.yaml#/components/parameters/BookingNo'}] + post: + summary: 同窗补齐登记资料并原子确认承运 + description: '权限:platform:fleet:capacity-offer:manage。复用InquiryWrite,更新资料、创建唯一商业订单及占位同事务;任何阶段失败全部回滚,不把保存当成交。同键同载荷返回原结果。' + requestBody: + required: true + content: + application/json: + schema: {$ref: './fleet-capacity-offers-v1.yaml#/components/schemas/InquiryWrite'} + responses: + '200': {description: '统一ApiResponse,data为InquiryResult'} + /api/platform/v1/last-leg/fleet/capacity-offers/inquiries/{bookingNo}/fulfillment: + parameters: [{$ref: './fleet-trips-v1.yaml#/components/parameters/BookingNo'}] + get: + summary: 从原登记查看本票履约进度 + description: '权限:platform:fleet:capacity-offer:list或platform:fleet:capacity-offer:manage。销售仅本人;返回本票booking、preview及plannedDepartureAt,不返回整车司机或他人货物。preview含tripRecordVersion、recordVersion、incoming,未安排时incoming为null。' + responses: + '200': {description: '统一ApiResponse,data为Fulfillment'} + /api/platform/v1/last-leg/fleet/capacity-offers/inquiries/{bookingNo}/incoming: + parameters: [{$ref: './fleet-trips-v1.yaml#/components/parameters/BookingNo'}] + post: + summary: 从原登记明确安排来货 + description: '权限:platform:fleet:capacity-offer:manage和platform:fleet:trip:booking-edit。复用版本及幂等门禁,未填时间时沿用登记预计到仓,仍缺失则拒绝,不自动减两小时猜测。承运确认不自动调用此命令。' + requestBody: + required: true + content: + application/json: + schema: {$ref: './fleet-trips-v1.yaml#/components/schemas/BookingIncoming'} + responses: + '200': {$ref: './fleet-trips-v1.yaml#/components/responses/BookingResult'} + /api/platform/v1/last-leg/fleet/capacity-offers/inquiries/{bookingNo}/receipt-confirmation: + parameters: [{$ref: './fleet-trips-v1.yaml#/components/parameters/BookingNo'}] + post: + summary: 原登记核对真实实收与约定运费 + description: '权限:platform:fleet:capacity-offer:manage和platform:fleet:trip:booking-confirm。沿实际receiptHash/托盘和三维容量校验;不以预报覆盖实收。' + requestBody: + required: true + content: + application/json: + schema: {$ref: './fleet-trips-v1.yaml#/components/schemas/BookingAcceptReceipt'} + responses: + '200': {$ref: './fleet-trips-v1.yaml#/components/responses/BookingResult'} + /api/platform/v1/last-leg/fleet/capacity-offers/inquiries/{bookingNo}/arrange-dispatch: + parameters: [{$ref: './fleet-trips-v1.yaml#/components/parameters/BookingNo'}] + post: + summary: 实收后复用或生成排车并原子排入本车 + description: '权限:platform:fleet:capacity-offer:manage、platform:fleet:trip:booking-edit、platform:fleet:trip:edit和platform:overseas-warehouse:inbound-order:smart-dispatch。锁原车次和本票来货,先校验实收快照、车辆、有效容量;来源有多张排车或不完整时拒绝,不另建。无排车时复用真实预排命令;任何校验或绑定失败整体回滚。同键重放不重复建单。成功将预留替换为BOUND,旧计划失效待核对,仍需审核和现场作业。' + requestBody: + required: true + content: + application/json: + schema: {$ref: './fleet-trips-v1.yaml#/components/schemas/BookingCommand'} + responses: + '200': {$ref: './fleet-trips-v1.yaml#/components/responses/BookingResult'} +components: + securitySchemes: + bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT} + schemas: + Prepare: + type: object + required: [dispatchNos, idempotencyKey] + properties: + tripNo: {type: string, nullable: true, maxLength: 64} + recordVersion: {type: integer, format: int64, nullable: true, minimum: 0} + plan: + allOf: + - {$ref: './fleet-trips-v1.yaml#/components/schemas/Create'} + nullable: true + description: 仅新建提交,实际创建幂等键使用外层idempotencyKey。 + vehicleId: {type: string, nullable: true} + driverId: {type: string, nullable: true} + dispatchNos: {type: array, minItems: 1, maxItems: 100, uniqueItems: true, items: {type: string, maxLength: 64}} + idempotencyKey: {type: string, minLength: 8, maxLength: 128} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoMapperTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoMapperTest.java new file mode 100644 index 000000000..7f7f18b11 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoMapperTest.java @@ -0,0 +1,45 @@ +package com.baiyue.lastleg.appointmentplanning; + +import org.apache.ibatis.mapping.Environment; +import org.apache.ibatis.session.Configuration; +import org.apache.ibatis.session.SqlSessionFactoryBuilder; +import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory; +import org.h2.jdbcx.JdbcDataSource; +import org.junit.jupiter.api.Test; +import java.util.List; +import java.util.UUID; +import static org.junit.jupiter.api.Assertions.*; + +class AppointmentCargoMapperTest { + @Test void unplannedSailedCargoIsVisibleAndCreatingPlansDoesNotDuplicateTheSource() throws Exception { + var ds = new JdbcDataSource(); ds.setURL("jdbc:h2:mem:appointment-"+ UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1"); + try (var c=ds.getConnection(); var s=c.createStatement()) { + s.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR,customer_name VARCHAR,inbound_code VARCHAR,source_container_booking_id VARCHAR,container_no VARCHAR,overseas_warehouse_id VARCHAR,overseas_warehouse_code VARCHAR,overseas_warehouse_name VARCHAR,fba_warehouse_code VARCHAR,package_count INT,received_package_count INT,shipped_package_count INT,pallet_count INT,volume_cbm DECIMAL,estimated_arrival_at TIMESTAMP,estimated_warehouse_at TIMESTAMP,version INT,detained BOOLEAN,order_type VARCHAR,destination_type VARCHAR,current_status VARCHAR,created_at TIMESTAMP)"); + s.execute("CREATE TABLE ll_container_shipping_gate(container_booking_id VARCHAR,node_sequence INT,standard_node VARCHAR,aggregate_version BIGINT,observed_at TIMESTAMP,provider_code VARCHAR)"); + s.execute("CREATE TABLE ll_inbound_plan(id BIGINT,source_order_id BIGINT,customer_id VARCHAR)"); + s.execute("CREATE TABLE ll_inbound_fba_appointment_item(order_id BIGINT,customer_id VARCHAR,appointment_id BIGINT)"); + s.execute("CREATE TABLE ll_inbound_fba_appointment(id BIGINT,appointment_status VARCHAR)"); + s.execute("CREATE TABLE ll_order_detail(order_id BIGINT,customer_id VARCHAR,destination_warehouse_code VARCHAR)"); + s.execute("INSERT INTO ll_order(id,customer_id,inbound_code,source_container_booking_id,overseas_warehouse_id,fba_warehouse_code,package_count,order_type,destination_type,current_status,created_at) VALUES(1,'A','IN-A','C1','100','ONT8',10,'FIRST_LEG','FBA','WAIT_INBOUND',CURRENT_TIMESTAMP),(2,'B','IN-B','C2','100','ONT8',8,'FIRST_LEG','FBA','WAIT_INBOUND',CURRENT_TIMESTAMP)"); + s.execute("INSERT INTO ll_container_shipping_gate(container_booking_id,node_sequence) VALUES('C1',40),('C2',30)"); + } + var config=new Configuration(new Environment("test",new JdbcTransactionFactory(),ds)); + config.setMapUnderscoreToCamelCase(true); config.addMapper(AppointmentCargoMapper.class); + try (var session=new SqlSessionFactoryBuilder().build(config).openSession(true)) { + var mapper=session.getMapper(AppointmentCargoMapper.class); + assertEquals(1,mapper.count(List.of("100"),List.of("ONT8"),null,null,null)); + var first=mapper.page(List.of("100"),List.of("ONT8"),null,null,null,20,0); + assertEquals("ORDER:1",first.get(0).getSourceKey()); assertFalse(first.get(0).isHasInboundPlan()); + try (var s=session.getConnection().createStatement()) { + s.execute("INSERT INTO ll_inbound_plan VALUES(11,1,'A'),(12,1,'A')"); + s.execute("INSERT INTO ll_inbound_fba_appointment VALUES(20,'SUCCESS')"); + s.execute("INSERT INTO ll_inbound_fba_appointment_item VALUES(1,'A',20)"); + } + session.clearCache(); + var after=mapper.page(List.of("100"),List.of("ONT8"),null,null,null,20,0); + assertEquals(1,after.size()); assertTrue(after.get(0).isHasInboundPlan()); assertTrue(after.get(0).isHasActiveAppointment()); + assertEquals(10,after.get(0).getPackages()); assertEquals(1,mapper.count(List.of("100"),List.of("ONT8"),null,null,null)); + assertEquals(0,mapper.count(List.of("200"),List.of("ONT8"),null,null,null)); + } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoServiceTest.java new file mode 100644 index 000000000..280e1dc8a --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoServiceTest.java @@ -0,0 +1,44 @@ +package com.baiyue.lastleg.appointmentplanning; + +import com.baiyue.common.core.api.ApiResponse; +import org.junit.jupiter.api.Test; +import org.springframework.web.server.ResponseStatusException; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class AppointmentCargoServiceTest { + @Test void authenticUsDirectoryScopesTheDatabaseQueryAndPreservesServerPagination() { + var mapper = mock(AppointmentCargoMapper.class); + var warehouses = mock(AppointmentWarehouseClient.class); + when(warehouses.catalog("token")).thenReturn(ApiResponse.success(List.of( + new AppointmentWarehouseClient.Warehouse(1L,"US-WH","美国仓","OVERSEAS","US"), + new AppointmentWarehouseClient.Warehouse(2L,"CA-WH","加拿大仓","OVERSEAS","CA"), + new AppointmentWarehouseClient.Warehouse(3L,"ONT8","ONT8","FBA","US"), + new AppointmentWarehouseClient.Warehouse(4L,"YYZ1","YYZ1","FBA","CA")))); + var row = new AppointmentCargo(); row.setOrderId(99L); row.setSourceKey("ORDER:99"); row.setHasInboundPlan(false); + when(mapper.count(List.of("1"),List.of("ONT8"),null,null,"WT")).thenReturn(21L); + when(mapper.page(List.of("1"),List.of("ONT8"),null,null,"WT",20,20)).thenReturn(List.of(row)); + var result = new AppointmentCargoService(mapper,warehouses,"token").page(2,20,null,null," WT "); + assertEquals(21,result.total()); assertEquals(2,result.pages()); assertEquals(1,result.records().size()); + assertFalse(result.records().get(0).isHasInboundPlan()); + verify(warehouses,times(1)).catalog("token"); + } + + @Test void directoryFailureIsNotReportedAsAnEmptyAmericanCargoList() { + var mapper = mock(AppointmentCargoMapper.class); var warehouses = mock(AppointmentWarehouseClient.class); + when(warehouses.catalog("token")).thenThrow(new IllegalStateException("offline")); + var error = assertThrows(ResponseStatusException.class, () -> new AppointmentCargoService(mapper,warehouses,"token").page(1,20,null,null,null)); + assertEquals(503,error.getStatusCode().value()); verifyNoInteractions(mapper); + } + + @Test void invalidPaginationAndNonUsFiltersNeverQueryCargo() { + var mapper = mock(AppointmentCargoMapper.class); var warehouses = mock(AppointmentWarehouseClient.class); + var service = new AppointmentCargoService(mapper,warehouses,"token"); + assertThrows(ResponseStatusException.class, () -> service.page(0,20,null,null,null)); + verifyNoInteractions(warehouses); + when(warehouses.catalog("token")).thenReturn(ApiResponse.success(List.of())); + assertThrows(ResponseStatusException.class, () -> service.page(1,20,"unknown",null,null)); + verifyNoInteractions(mapper); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentCoverageMapperTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentCoverageMapperTest.java new file mode 100644 index 000000000..736bfd28d --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentCoverageMapperTest.java @@ -0,0 +1,41 @@ +package com.baiyue.lastleg.appointmentplanning; + +import org.apache.ibatis.mapping.Environment; +import org.apache.ibatis.session.Configuration; +import org.apache.ibatis.session.SqlSessionFactoryBuilder; +import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory; +import org.h2.jdbcx.JdbcDataSource; +import org.junit.jupiter.api.Test; +import java.util.List; +import java.util.UUID; +import static org.junit.jupiter.api.Assertions.*; + +class AppointmentCoverageMapperTest { + @Test void readsOnlyMatchingCustomerAndPlanLinesAndExcludesCancelledAppointments() throws Exception { + var ds=new JdbcDataSource(); ds.setURL("jdbc:h2:mem:coverage-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1"); + try (var c=ds.getConnection();var s=c.createStatement()) { + s.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR(128),destination_type VARCHAR(32),fba_warehouse_code VARCHAR(128),package_count INT,version INT,updated_at TIMESTAMP)"); + s.execute("CREATE TABLE ll_order_detail(order_id BIGINT,customer_id VARCHAR(128),shipment_no VARCHAR(128),fba_tracking_no VARCHAR(128))"); + s.execute("CREATE TABLE ll_inbound_plan_line(id BIGINT,plan_id BIGINT,order_id BIGINT,customer_id VARCHAR(128),required_package_count INT,fba_warehouse_code VARCHAR(128))"); + s.execute("CREATE TABLE ll_inbound_fba_appointment(id BIGINT,appointment_no VARCHAR(128),appointment_status VARCHAR(32),appointment_at TIMESTAMP,appointment_end_at TIMESTAMP,updated_at TIMESTAMP)"); + s.execute("CREATE TABLE ll_inbound_fba_appointment_item(id BIGINT,appointment_id BIGINT,order_id BIGINT,customer_id VARCHAR(128),line_id BIGINT,plan_id BIGINT)"); + s.execute("INSERT INTO ll_order VALUES(1,'A','FBA','ONT8',20,1,CURRENT_TIMESTAMP),(2,'B','FBA','ONT8',8,1,CURRENT_TIMESTAMP)"); + s.execute("INSERT INTO ll_order_detail VALUES(1,'A','SHIP-A',NULL),(2,'B',NULL,NULL)"); + s.execute("INSERT INTO ll_inbound_plan_line VALUES(10,100,1,'A',20,'ONT8'),(20,200,2,'B',8,'ONT8')"); + s.execute("INSERT INTO ll_inbound_fba_appointment VALUES(1000,'APP-1','SUCCESS',CURRENT_TIMESTAMP,NULL,CURRENT_TIMESTAMP),(2000,'CANCELLED-1','CANCELLED',CURRENT_TIMESTAMP,NULL,CURRENT_TIMESTAMP)"); + s.execute("INSERT INTO ll_inbound_fba_appointment_item VALUES(1,1000,1,'A',10,100),(2,1000,2,'B',20,200),(3,2000,1,'A',10,100),(4,1000,1,'A',20,200)"); + } + var config=new Configuration(new Environment("test",new JdbcTransactionFactory(),ds)); + config.setMapUnderscoreToCamelCase(true); config.addMapper(AppointmentCoverageMapper.class); + try (var session=new SqlSessionFactoryBuilder().build(config).openSession(true)) { + var mapper=session.getMapper(AppointmentCoverageMapper.class); + var refs=List.of(new AppointmentCoverageMapper.OrderRef("A",1L)); + var source=mapper.sources(refs).get(0); assertFalse(source.shipmentReferencesMissing()); assertEquals(20,source.packages()); + var facts=mapper.appointments(refs); assertEquals(1,facts.size()); assertEquals("APP-1",facts.get(0).appointmentNo()); assertEquals(20,facts.get(0).packages()); + assertTrue(mapper.sources(List.of(new AppointmentCoverageMapper.OrderRef("B",1L))).isEmpty()); + assertTrue(mapper.sources(List.of(new AppointmentCoverageMapper.OrderRef("B",2L))).get(0).shipmentReferencesMissing()); + try (var s=session.getConnection().createStatement()) { s.execute("INSERT INTO ll_order_detail VALUES(1,'A',' ',NULL)"); } + session.clearCache(); assertTrue(mapper.sources(refs).get(0).shipmentReferencesMissing()); + } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentCoverageServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentCoverageServiceTest.java new file mode 100644 index 000000000..02b3794b6 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentCoverageServiceTest.java @@ -0,0 +1,65 @@ +package com.baiyue.lastleg.appointmentplanning; + +import org.junit.jupiter.api.Test; +import java.time.LocalDateTime; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class AppointmentCoverageServiceTest { + private static final LocalDateTime BEFORE=LocalDateTime.of(2026,10,9,1,0), AFTER=BEFORE.plusHours(1); + private static AppointmentCoverageMapper.Source order() { + return new AppointmentCoverageMapper.Source(1L,"A","FBA","ONT8",20,2,BEFORE,false); + } + private static AppointmentCoverageMapper.Fact fact(long line,int packages,String status) { + return new AppointmentCoverageMapper.Fact(1L,"A","FBA-CONFIRM-1",status,AFTER.plusDays(1),null,AFTER,line,packages,"ONT8"); + } + @Test void completeQuantitySharesOneAppointmentButDoesNotInventItsTimezone() { + var mapper=mock(AppointmentCoverageMapper.class); var gateway=mock(AppointmentExternalSourceGateway.class); + when(mapper.sources(anyList())).thenReturn(List.of(order())); + when(mapper.appointments(anyList())).thenReturn(List.of(fact(10,10,"SUCCESS"),fact(11,10,"SUCCESS"))); + var result=new AppointmentCoverageService(mapper,gateway).query(List.of(new AppointmentCoverageService.SourceRef("A","ORDER:1","FBA:ONT8"))).get(0); + assertEquals("CONFIRMED",result.status()); assertEquals(1,result.appointments().size()); + assertNull(result.appointments().get(0).timezone()); assertTrue(result.missingFields().contains("APPOINTMENT_TIMEZONE_UNVERIFIED")); + assertFalse(result.changeReviewRequired()); verifyNoInteractions(gateway); + } + @Test void partialPendingAndDuplicateClaimsNeverAppearAsCompleteCoverage() { + var mapper=mock(AppointmentCoverageMapper.class); when(mapper.sources(anyList())).thenReturn(List.of(order())); + var service=new AppointmentCoverageService(mapper,mock(AppointmentExternalSourceGateway.class)); + var refs=List.of(new AppointmentCoverageService.SourceRef("A","ORDER:1","FBA:ONT8")); + when(mapper.appointments(anyList())).thenReturn(List.of(fact(10,10,"SUCCESS"))); + assertEquals("PARTIAL",service.query(refs).get(0).status()); + when(mapper.appointments(anyList())).thenReturn(List.of(fact(10,20,"PENDING"))); + assertEquals("PENDING",service.query(refs).get(0).status()); + when(mapper.appointments(anyList())).thenReturn(List.of(fact(10,10,"SUCCESS"),fact(10,10,"SUCCESS"))); + assertEquals("REVIEW_REQUIRED",service.query(refs).get(0).status()); + } + @Test void externalBookingKeepsItsIdentityBeforeAndAfterRealCargoExists() { + var mapper=mock(AppointmentCoverageMapper.class); var gateway=mock(AppointmentExternalSourceGateway.class); + var refs=List.of(new AppointmentCoverageService.SourceRef("A","BOOKING:B-1","FBA:ONT8")); + var service=new AppointmentCoverageService(mapper,gateway); + when(gateway.resolve(anyList())).thenReturn(List.of(new AppointmentExternalSourceGateway.Source("A","B-1","TR-1","CONFIRMED","100","FBA:ONT8",null,20,1L))); + assertEquals("SOURCE_NOT_MATERIALIZED",service.query(refs).get(0).status()); verifyNoInteractions(mapper); + when(gateway.resolve(anyList())).thenReturn(List.of(new AppointmentExternalSourceGateway.Source("A","B-1","TR-1","BOUND","100","FBA:ONT8",1L,20,2L))); + when(mapper.sources(anyList())).thenReturn(List.of(order())); when(mapper.appointments(anyList())).thenReturn(List.of(fact(10,20,"SUCCESS"))); + var result=service.query(refs).get(0); assertEquals("BOOKING:B-1",result.sourceKey()); assertEquals("CONFIRMED",result.status()); + assertThrows(IllegalArgumentException.class, () -> service.query(List.of(refs.get(0),new AppointmentCoverageService.SourceRef("A","ORDER:1","FBA:ONT8")))); + } + @Test void anotherCustomerAndChangedDestinationCannotBorrowAppointmentCoverage() { + var mapper=mock(AppointmentCoverageMapper.class); var gateway=mock(AppointmentExternalSourceGateway.class); + when(mapper.sources(anyList())).thenReturn(List.of(order())); when(mapper.appointments(anyList())).thenReturn(List.of(fact(10,20,"SUCCESS"))); + var service=new AppointmentCoverageService(mapper,gateway); + assertEquals("SOURCE_NOT_FOUND",service.query(List.of(new AppointmentCoverageService.SourceRef("B","ORDER:1","FBA:ONT8"))).get(0).status()); + assertEquals("DESTINATION_MISMATCH",service.query(List.of(new AppointmentCoverageService.SourceRef("A","ORDER:1","FBA:LAX9"))).get(0).status()); + } + @Test void changedSourcesAndMissingShipmentReferencesAreExplicitWithoutChangingConfirmedTime() { + var mapper=mock(AppointmentCoverageMapper.class); + when(mapper.sources(anyList())).thenReturn(List.of(new AppointmentCoverageMapper.Source(1L,"A","FBA","ONT8",20,3,AFTER.plusHours(1),true))); + when(mapper.appointments(anyList())).thenReturn(List.of(fact(10,20,"SUCCESS"))); + var result=new AppointmentCoverageService(mapper,mock(AppointmentExternalSourceGateway.class)).query(List.of(new AppointmentCoverageService.SourceRef("A","ORDER:1","FBA:ONT8"))).get(0); + assertTrue(result.changeReviewRequired()); assertTrue(result.missingFields().containsAll(List.of("SOURCE_CHANGED_REVIEW","FBA_SHIPMENT_REFERENCES"))); + assertEquals(AFTER.plusDays(1),result.appointments().get(0).confirmedAt()); + verify(mapper,times(1)).sources(anyList()); verify(mapper,times(1)).appointments(anyList()); verifyNoMoreInteractions(mapper); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentFollowupPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentFollowupPersistenceTest.java new file mode 100644 index 000000000..885ccc328 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentFollowupPersistenceTest.java @@ -0,0 +1,45 @@ +package com.baiyue.lastleg.appointmentplanning; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.MybatisSqlSessionFactoryBuilder; +import org.apache.ibatis.mapping.Environment; +import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory; +import org.h2.jdbcx.JdbcDataSource; +import org.junit.jupiter.api.Test; +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.util.UUID; +import static org.junit.jupiter.api.Assertions.*; + +class AppointmentFollowupPersistenceTest { + @Test void baselineAndMapperPersistSeparateStagesAndEnforceCommandAndVersionUniqueness() throws Exception { + String sql; + try (var input=getClass().getResourceAsStream("/db/lastleg.sql")) { + assertNotNull(input); var baseline=new String(input.readAllBytes(),StandardCharsets.UTF_8); + int start=baseline.indexOf("CREATE TABLE IF NOT EXISTS ll_appointment_followup ("); + assertTrue(start>=0); sql=baseline.substring(start,baseline.indexOf(';',start)+1); + } + var ds=new JdbcDataSource(); ds.setURL("jdbc:h2:mem:followup-"+ UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1"); + try (var c=ds.getConnection();var s=c.createStatement()) { + s.execute("CREATE TABLE ll_order(id BIGINT PRIMARY KEY)"); s.execute("INSERT INTO ll_order VALUES(1)"); s.execute(sql); + } + var config=new MybatisConfiguration(); config.setEnvironment(new Environment("test",new JdbcTransactionFactory(),ds)); + config.setMapUnderscoreToCamelCase(true); config.addMapper(AppointmentFollowupMapper.class); + try (var session=new MybatisSqlSessionFactoryBuilder().build(config).openSession(true)) { + var mapper=session.getMapper(AppointmentFollowupMapper.class); + var first=record(11L,"DRAYAGE_CONTACT",1,"key-a"); mapper.insert(first); + mapper.insert(record(12L,"FBA_RECEIVING",1,"key-a")); + assertEquals(1,mapper.version("ORDER:1","A","DRAYAGE_CONTACT")); assertEquals(1,mapper.version("ORDER:1","A","FBA_RECEIVING")); + assertEquals(2,mapper.count("ORDER:1","A")); assertEquals("联系结果",mapper.command("ORDER:1","A","DRAYAGE_CONTACT","key-a").getResult()); + assertEquals(1,mapper.page("ORDER:1","A",1,0).size()); + assertThrows(RuntimeException.class, () -> mapper.insert(record(13L,"DRAYAGE_CONTACT",2,"key-a"))); + assertThrows(RuntimeException.class, () -> mapper.insert(record(14L,"DRAYAGE_CONTACT",1,"key-b"))); + assertEquals(2,mapper.count("ORDER:1","A")); + } + } + private static AppointmentFollowup record(long id,String stage,int version,String key) { + var row=new AppointmentFollowup(); row.setId(id); row.setSourceOrderId(1L); row.setSourceKey("ORDER:1"); row.setCustomerId("A"); row.setStage(stage); row.setVersion(version); + row.setResult("联系结果"); row.setOperatorId("operator"); row.setCommandKey(key); row.setRequestHash("a".repeat(64)); + row.setCreatedAt(LocalDateTime.of(2026,10,9,12,0)); return row; + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentFollowupServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentFollowupServiceTest.java new file mode 100644 index 000000000..dcc6a2825 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentFollowupServiceTest.java @@ -0,0 +1,59 @@ +package com.baiyue.lastleg.appointmentplanning; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.web.server.ResponseStatusException; +import java.time.LocalDate; +import java.util.HashMap; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class AppointmentFollowupServiceTest { + @Test void contactsBeforePlanningAreAppendOnlyAndOldCommandsReplayTheirOriginalResult() { + var cargo=mock(AppointmentSourceService.class); var mapper=mock(AppointmentFollowupMapper.class); + var saved=new HashMap(); var version=new int[]{0}; + when(cargo.resolve("ORDER:1","A",null)).thenReturn(new AppointmentSourceService.Source("ORDER:1","A",1L,"IN-A",null,"C1","100","美国仓","FBA:ONT8","ONT8",10,"ORDER:0",true,List.of())); + when(mapper.command(eq("ORDER:1"),eq("A"),eq("DRAYAGE_CONTACT"),anyString())).thenAnswer(call -> saved.get(call.getArgument(3))); + when(mapper.version("ORDER:1","A","DRAYAGE_CONTACT")).thenAnswer(call -> version[0]); + when(mapper.insert(any(AppointmentFollowup.class))).thenAnswer(call -> { + AppointmentFollowup row=call.getArgument(0); saved.put(row.getCommandKey(),row); version[0]=row.getVersion(); return 1; + }); + var service=new AppointmentFollowupService(cargo,mapper,new ObjectMapper().findAndRegisterModules()); + var first=new AppointmentFollowupService.Save("DRAYAGE_CONTACT",0,"first","车队A","已联系,等待回复","明日再联系", LocalDate.of(2026,10,15)); + var result=service.save("ORDER:1","A",first,"operator","运营甲"); + service.save("ORDER:1","A",new AppointmentFollowupService.Save("DRAYAGE_CONTACT",1,"second","车队A","尚未给出正式时段",null,null),"operator","运营甲"); + var replay=service.save("ORDER:1","A",first,"operator","运营甲"); + assertEquals(result,replay); assertEquals(1,replay.version()); assertEquals(2,saved.size()); + assertEquals("运营甲",result.operatorName()); assertEquals("已联系,等待回复",result.result()); + verify(mapper,times(2)).insert(any(AppointmentFollowup.class)); + verify(cargo,times(2)).resolve("ORDER:1","A",null); + var conflict=assertThrows(ResponseStatusException.class, () -> service.save("ORDER:1","A", + new AppointmentFollowupService.Save("DRAYAGE_CONTACT",0,"first",null,"不同结果",null,null),"operator","运营甲")); + assertEquals(409,conflict.getStatusCode().value()); + assertThrows(ResponseStatusException.class, () -> service.save("ORDER:1","A",first,"another-operator","运营乙")); + assertThrows(ResponseStatusException.class, () -> service.save("ORDER:1","A", + new AppointmentFollowupService.Save("DRAYAGE_CONTACT",0,"stale",null,"并发旧版本",null,null),"operator","运营甲")); + assertEquals(2,saved.size()); + } + + @Test void followupDateRequiresAnActionAndInvalidStagesDoNotWrite() { + var cargo=mock(AppointmentSourceService.class); var mapper=mock(AppointmentFollowupMapper.class); + var service=new AppointmentFollowupService(cargo,mapper,new ObjectMapper()); + assertThrows(ResponseStatusException.class, () -> service.save("ORDER:1","A", + new AppointmentFollowupService.Save("FBA_RECEIVING",0,"key",null,"等待材料",null,LocalDate.of(2026,10,15)),"operator","运营")); + assertThrows(ResponseStatusException.class, () -> service.save("ORDER:1","A", + new AppointmentFollowupService.Save("CONFIRM_ALL",0,"key",null,"不能混用",null,null),"operator","运营")); + verifyNoInteractions(cargo,mapper); + } + + @Test void existingHistoryRemainsReadableWhenTheCargoHasLeftTheActivePlanningList() { + var cargo=mock(AppointmentSourceService.class); var mapper=mock(AppointmentFollowupMapper.class); + when(mapper.count("ORDER:1","A")).thenReturn(21L); when(mapper.page("ORDER:1","A",20,20)).thenReturn(List.of()); + var service=new AppointmentFollowupService(cargo,mapper,new ObjectMapper()); + var history=service.history("ORDER:1","A",2,20); + assertEquals(21,history.history().total()); assertEquals(2,history.history().pages()); + verifyNoInteractions(cargo); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentPlanningAuthorizationTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentPlanningAuthorizationTest.java new file mode 100644 index 000000000..99777fa8f --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentPlanningAuthorizationTest.java @@ -0,0 +1,56 @@ +package com.baiyue.lastleg.appointmentplanning; + +import com.baiyue.common.security.AuthenticatedUser; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.*; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.server.ResponseStatusException; +import java.util.List; +import java.util.Set; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class AppointmentPlanningAuthorizationTest { + private static final String LIST="platform:appointment:list", CARGO="platform:overseas-warehouse:last-mile-order:list", WRITE="platform:appointment:followup"; + @Test void readRequiresBothSourcePermissionsAndCannotWrite() { + try (var context=new AnnotationConfigApplicationContext(Config.class)) { + var controller=context.getBean(AppointmentPlanningController.class); + var user=user(0L,"PLATFORM"); + for (String permission:List.of(LIST,CARGO)) { + login(permission); + assertThrows(AccessDeniedException.class,() -> controller.cargo(user,1,20,null,null,null)); + assertThrows(AccessDeniedException.class,() -> controller.history(user,"ORDER:1","A",1,20)); + } + login(LIST,CARGO); + controller.cargo(user,1,20,null,null,null); + verify(context.getBean(AppointmentCargoService.class)).page(1,20,null,null,null); + assertThrows(AccessDeniedException.class,() -> controller.followup(user,"ORDER:1","A",null)); + verifyNoInteractions(context.getBean(AppointmentFollowupService.class)); + } finally { SecurityContextHolder.clearContext(); } + } + @Test void permissionDoesNotAllowClientIdentityAndOperatorComesFromLogin() { + try (var context=new AnnotationConfigApplicationContext(Config.class)) { + var controller=context.getBean(AppointmentPlanningController.class); + login(LIST,CARGO,WRITE); + var request=new AppointmentFollowupService.Save("FBA_RECEIVING",0,"key",null,"已联系",null,null); + assertThrows(ResponseStatusException.class,() -> controller.followup(user(1L,"CLIENT"),"BOOKING:B-1","A",request)); + assertThrows(ResponseStatusException.class,() -> controller.cargo(null,1,20,null,null,null)); + controller.followup(user(0L,"PLATFORM"),"BOOKING:B-1","A",request); + verify(context.getBean(AppointmentFollowupService.class)).save("BOOKING:B-1","A",request,"7","operator"); + } finally { SecurityContextHolder.clearContext(); } + } + private static AuthenticatedUser user(Long customer,String app) { return new AuthenticatedUser(7L,customer,0L,8L,"operator",app,"WEB",Set.of()); } + private static void login(String... permissions) { SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("operator","unused",java.util.Arrays.stream(permissions).map(SimpleGrantedAuthority::new).toList())); } + @Configuration @EnableMethodSecurity + static class Config { + @Bean AppointmentCargoService cargo() { return mock(AppointmentCargoService.class); } + @Bean AppointmentFollowupService followups() { return mock(AppointmentFollowupService.class); } + @Bean AppointmentSourceService source() { return mock(AppointmentSourceService.class); } + @Bean AppointmentCoverageService coverage() { return mock(AppointmentCoverageService.class); } + @Bean AppointmentPlanningController controller(AppointmentCargoService cargo,AppointmentFollowupService followups,AppointmentSourceService source,AppointmentCoverageService coverage) { return new AppointmentPlanningController(cargo,followups,source,coverage); } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentPlanningPolicyTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentPlanningPolicyTest.java new file mode 100644 index 000000000..2cf46a7ce --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentPlanningPolicyTest.java @@ -0,0 +1,86 @@ +package com.baiyue.lastleg.appointmentplanning; + +import org.junit.jupiter.api.Test; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; + +class AppointmentPlanningPolicyTest { + private static Instant at(int hour) { return Instant.parse("2026-10-15T" + String.format("%02d", hour) + ":00:00Z"); } + private static AppointmentPlanningPolicy.Cargo cargo(String source, String customer, String origin, String destination, + Integer packages, Instant from, Instant to) { + return new AppointmentPlanningPolicy.Cargo(source, customer, origin, destination, "US", "US", packages, from, to, true, null); + } + + @Test void twoCustomersCanPlanOneDestinationWithoutAPlanNumberDriverOrExternalConfirmation() { + var result = AppointmentPlanningPolicy.group(List.of( + cargo("ORDER:1", "A", "WH-1", "FBA:ONT8", 10, at(1), at(5)), + cargo("BOOKING:B-1", "B", "WH-1", "FBA:ONT8", 8, at(3), at(8)))); + assertEquals(18, result.packages()); assertEquals(2, result.customerCount()); + assertEquals(at(3), result.readyFrom()); assertEquals(at(5), result.readyTo()); + assertFalse(result.readinessIncomplete()); + } + + @Test void unknownReadinessIsPreservedInsteadOfInventingAnArrivalOffset() { + var result = AppointmentPlanningPolicy.group(List.of(cargo("ORDER:1", "A", "WH-1", "FBA:ONT8", 10, null, null))); + assertTrue(result.readinessIncomplete()); assertNull(result.readyFrom()); assertNull(result.readyTo()); + } + + @Test void differentOriginsDestinationsAndDisjointWindowsAreNotSilentlyMerged() { + var first = cargo("ORDER:1", "A", "WH-1", "FBA:ONT8", 10, at(1), at(3)); + assertThrows(IllegalArgumentException.class, () -> AppointmentPlanningPolicy.group(List.of(first, + cargo("ORDER:2", "B", "WH-2", "FBA:ONT8", 8, at(1), at(3))))); + assertThrows(IllegalArgumentException.class, () -> AppointmentPlanningPolicy.group(List.of(first, + cargo("ORDER:2", "B", "WH-1", "FBA:LAX9", 8, at(1), at(3))))); + assertThrows(IllegalArgumentException.class, () -> AppointmentPlanningPolicy.group(List.of(first, + cargo("ORDER:2", "B", "WH-1", "FBA:ONT8", 8, at(5), at(8))))); + } + + @Test void sourceIdentityMustNotBeCountedAgainAfterMaterialization() { + var first = cargo("BOOKING:B-1", "A", "WH-1", "FBA:ONT8", 10, null, null); + assertThrows(IllegalArgumentException.class, () -> AppointmentPlanningPolicy.group(List.of(first, first))); + assertThrows(IllegalArgumentException.class, () -> AppointmentPlanningPolicy.group(List.of( + cargo("ORDER:1", "A", "WH-1", "FBA:ONT8", null, null, null)))); + } + + @Test void nonUsAndBlockedSourcesCannotBeTreatedAsEligibleAmericanCargo() { + assertThrows(IllegalArgumentException.class, () -> AppointmentPlanningPolicy.group(List.of( + new AppointmentPlanningPolicy.Cargo("ORDER:1", "A", "WH-1", "FBA:YYZ", "US", "CA", 1, null, null, true, null)))); + var error = assertThrows(IllegalArgumentException.class, () -> AppointmentPlanningPolicy.group(List.of( + new AppointmentPlanningPolicy.Cargo("ORDER:1", "A", "WH-1", "FBA:ONT8", "US", "US", 1, null, null, false, "货物已被有效安排占用")))); + assertEquals("货物已被有效安排占用", error.getMessage()); + } + + @Test void changedReadinessIdentifiesImpactWithoutOverwritingTheConfirmedTime() { + var before = new AppointmentPlanningPolicy.Forecast("ORDER:1", "1", at(1), at(3)); + var after = new AppointmentPlanningPolicy.Forecast("ORDER:1", "2", at(4), at(8)); + var impact = AppointmentPlanningPolicy.compare(before, after, at(6)); + assertTrue(impact.reviewRequired()); assertEquals("READINESS_AFTER_APPOINTMENT", impact.reason()); + assertEquals(at(6), impact.confirmedAt()); assertEquals(at(1), impact.previousEta()); assertEquals(at(4), impact.latestEta()); + assertFalse(AppointmentPlanningPolicy.compare(after, after, at(6)).reviewRequired()); + } + + @Test void lostForecastNeedsReviewAndUnrelatedSourcesCannotBeCompared() { + var before = new AppointmentPlanningPolicy.Forecast("ORDER:1", "1", at(1), at(3)); + assertEquals("FORECAST_MISSING", AppointmentPlanningPolicy.compare(before, + new AppointmentPlanningPolicy.Forecast("ORDER:1", "2", null, null), at(6)).reason()); + assertThrows(IllegalArgumentException.class, () -> AppointmentPlanningPolicy.compare(before, + new AppointmentPlanningPolicy.Forecast("ORDER:2", "2", at(2), at(3)), at(6))); + } + + @Test void losAngelesTimesResolveToTheLocationZoneAndKeepAnUnknownEndUnknown() { + var result = AppointmentTimePolicy.resolve(LocalDateTime.parse("2026-10-15T09:00:00"), null, "America/Los_Angeles", null, null); + assertEquals(Instant.parse("2026-10-15T16:00:00Z"), result.start()); assertNull(result.end()); + assertThrows(IllegalArgumentException.class, () -> AppointmentTimePolicy.resolve(LocalDateTime.parse("2026-10-15T09:00:00"), null, null, null, null)); + } + + @Test void daylightSavingGapIsRejectedAndOverlapRequiresAnExplicitValidOffset() { + assertThrows(IllegalArgumentException.class, () -> AppointmentTimePolicy.resolve(LocalDateTime.parse("2026-03-08T02:30:00"), null, "America/Los_Angeles", null, null)); + var overlap = LocalDateTime.parse("2026-11-01T01:30:00"); + assertThrows(IllegalArgumentException.class, () -> AppointmentTimePolicy.resolve(overlap, null, "America/Los_Angeles", null, null)); + assertEquals(Instant.parse("2026-11-01T09:30:00Z"), AppointmentTimePolicy.resolve(overlap, null, "America/Los_Angeles", ZoneOffset.ofHours(-8), null).start()); + assertThrows(IllegalArgumentException.class, () -> AppointmentTimePolicy.resolve(overlap, null, "America/Los_Angeles", ZoneOffset.ofHours(8), null)); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentSourceServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentSourceServiceTest.java new file mode 100644 index 000000000..34b269222 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentSourceServiceTest.java @@ -0,0 +1,40 @@ +package com.baiyue.lastleg.appointmentplanning; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper; +import org.junit.jupiter.api.Test; +import org.springframework.web.server.ResponseStatusException; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class AppointmentSourceServiceTest { + @Test void confirmedExternalBookingCanBeFollowedWithoutCreatingAnOrderAndKeepsItsKeyAfterMaterialization() { + var orders=mock(LastlegOrderMapper.class); var gateway=mock(AppointmentExternalSourceGateway.class); var warehouse=mock(AppointmentWarehouseClient.class); + when(warehouse.catalog("token")).thenReturn(ApiResponse.success(List.of( + new AppointmentWarehouseClient.Warehouse(100L,"US-WH","美国仓","OVERSEAS","US"), + new AppointmentWarehouseClient.Warehouse(200L,"ONT8","ONT8","FBA","US")))); + var service=new AppointmentSourceService(orders,mock(ContainerShippingGateMapper.class),gateway,warehouse,"token"); + when(gateway.resolve(anyList())).thenReturn(List.of(new AppointmentExternalSourceGateway.Source("A","B-1","TR-1","CONFIRMED","100","FBA:ONT8",null,null,1L))); + var source=service.resolve("BOOKING:B-1","A","FBA:ONT8"); assertTrue(source.canFollowup()); assertNull(source.orderId()); assertNull(source.packages()); verifyNoInteractions(orders); + var order=new LastlegOrder(); order.setId(1L); order.setCustomerId("A"); order.setOverseasWarehouseId("100"); order.setPackageCount(12); order.setInboundCode("IN-A"); order.setDestinationType("FBA"); order.setFbaWarehouseCode("ONT8"); + when(orders.selectById(1L)).thenReturn(order); + when(gateway.resolve(anyList())).thenReturn(List.of(new AppointmentExternalSourceGateway.Source("A","B-1","TR-1","RECEIVED","100","FBA:ONT8",1L,10,2L))); + var received=service.resolve("BOOKING:B-1","A","FBA:ONT8"); assertEquals("BOOKING:B-1",received.sourceKey()); assertEquals(12,received.packages()); assertEquals("IN-A",received.inboundCode()); + assertThrows(ResponseStatusException.class, () -> service.resolve("BOOKING:B-1","A","FBA:LAX9")); + assertThrows(ResponseStatusException.class, () -> service.resolve("BOOKING:B-1","OTHER","FBA:ONT8")); + order.setCurrentStatus("CANCELLED"); assertFalse(service.resolve("BOOKING:B-1","A","FBA:ONT8").canFollowup()); + order.setFbaWarehouseCode("LAX9"); assertThrows(ResponseStatusException.class, () -> service.resolve("BOOKING:B-1","A","FBA:ONT8")); + } + @Test void draftBookingCannotBePresentedAsConfirmedCarrierCapacity() { + var gateway=mock(AppointmentExternalSourceGateway.class); var warehouse=mock(AppointmentWarehouseClient.class); + when(warehouse.catalog("token")).thenReturn(ApiResponse.success(List.of( + new AppointmentWarehouseClient.Warehouse(100L,"US-WH","美国仓","OVERSEAS","US"),new AppointmentWarehouseClient.Warehouse(200L,"ONT8","ONT8","FBA","US")))); + when(gateway.resolve(anyList())).thenReturn(List.of(new AppointmentExternalSourceGateway.Source("A","B-1","TR-1","DRAFT","100","FBA:ONT8",null,null,0L))); + var source=new AppointmentSourceService(mock(LastlegOrderMapper.class),mock(ContainerShippingGateMapper.class),gateway,warehouse,"token").resolve("BOOKING:B-1","A","FBA:ONT8"); + assertFalse(source.canFollowup()); assertFalse(source.blockReasons().isEmpty()); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/fleet/FleetDispatchPlanningAdapterTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/fleet/FleetDispatchPlanningAdapterTest.java new file mode 100644 index 000000000..3034312e9 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/fleet/FleetDispatchPlanningAdapterTest.java @@ -0,0 +1,32 @@ +package com.baiyue.lastleg.dispatch.fleet; + +import com.baiyue.lastleg.dispatch.service.DispatchService; +import com.baiyue.lastleg.fleet.trip.*; +import org.junit.jupiter.api.Test; +import java.math.BigDecimal; +import java.util.List; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class FleetDispatchPlanningAdapterTest { + private final FleetDispatchPlanningMapper mapper=mock(FleetDispatchPlanningMapper.class); + private final DispatchService service=mock(DispatchService.class); + private final FleetDispatchGateway gateway=mock(FleetDispatchGateway.class); + private final FleetDispatchPlanningAdapter adapter=new FleetDispatchPlanningAdapter(mapper,service,gateway); + private FleetBookingIncomingGateway.Evidence receipt(){return new FleetBookingIncomingGateway.Evidence("WT-1","IN-1","92","FBA:ACY2","READY_FOR_DISPATCH","hash",List.of(new FleetBookingIncomingGateway.Pallet("1","P-1",10,BigDecimal.TEN,BigDecimal.ONE)),10);} + @Test void refusesAmbiguousSourcesWithoutCreatingAnotherDispatch() { + when(mapper.sourceDispatches("9","IN-1")).thenReturn(List.of("DS-1","DS-2")); + assertThatThrownBy(()->adapter.resolveIncoming("9",receipt(),"1")).hasMessageContaining("多张排车"); + verifyNoInteractions(service); + } + @Test void reusesOnlyAnExactExistingShipment() { + when(mapper.sourceDispatches("9","IN-1")).thenReturn(List.of("DS-1")); + when(gateway.lockForPlanning("DS-1")).thenReturn(new FleetDispatchGateway.Snapshot(1,"DS-1","9","92",10)); + when(gateway.sourceCargo("DS-1")).thenReturn(List.of(new FleetDispatchGateway.SourceCargo("IN-1","1","P-1",10,"FBA:ACY2"))); + assertThat(adapter.resolveIncoming("9",receipt(),"1")).isEqualTo("DS-1");verifyNoInteractions(service); + when(gateway.sourceCargo("DS-1")).thenReturn(List.of(new FleetDispatchGateway.SourceCargo("IN-1","1","P-1",9,"FBA:ACY2"))); + assertThatThrownBy(()->adapter.resolveIncoming("9",receipt(),"1")).hasMessageContaining("全部实收"); + when(gateway.sourceCargo("DS-1")).thenReturn(List.of(new FleetDispatchGateway.SourceCargo("IN-other","1","P-1",10,"FBA:ACY2"))); + assertThatThrownBy(()->adapter.resolveIncoming("9",receipt(),"1")).hasMessageContaining("其他来源"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/fleet/FleetPlanningReadPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/fleet/FleetPlanningReadPersistenceTest.java new file mode 100644 index 000000000..732e9c9cf --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/fleet/FleetPlanningReadPersistenceTest.java @@ -0,0 +1,51 @@ +package com.baiyue.lastleg.dispatch.fleet; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; +import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baiyue.lastleg.fleet.business.*; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import java.util.List; +import java.util.UUID; +import static org.assertj.core.api.Assertions.*; + +class FleetPlanningReadPersistenceTest { + @Test void candidatesPaginateOnlyEligibleSameWarehouseUnboundCargoAndSourcesPreserveUnknowns() throws Exception { + var ds=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa",""); + var jdbc=new JdbcTemplate(ds); + jdbc.execute("CREATE TABLE ll_dispatch_order(id BIGINT PRIMARY KEY,dispatch_no VARCHAR(64),customer_id VARCHAR(64),overseas_warehouse_id VARCHAR(64),package_count INT,status VARCHAR(32),picked_package_count INT,shipped_package_count INT,updated_at TIMESTAMP,route VARCHAR(500))"); + jdbc.execute("CREATE TABLE ll_dispatch_item(dispatch_order_id BIGINT,customer_id VARCHAR(64),inbound_code VARCHAR(64))"); + jdbc.execute("CREATE TABLE ll_fleet_trip_dispatch(dispatch_id BIGINT)"); + jdbc.execute("CREATE TABLE ll_dispatch_pda_task(dispatch_order_id BIGINT,status VARCHAR(32))"); + jdbc.execute("CREATE TABLE ll_outbound_order(dispatch_order_id BIGINT,status VARCHAR(32))"); + jdbc.execute("CREATE TABLE ll_fleet_trip(id BIGINT PRIMARY KEY,warehouse_id BIGINT)"); + jdbc.execute("CREATE TABLE ll_fleet_external_booking(id BIGINT,trip_id BIGINT,customer_id VARCHAR(64),booking_no VARCHAR(64),trip_no VARCHAR(64),status VARCHAR(32),offer_id BIGINT,destination_key VARCHAR(160),destination_label VARCHAR(100),incoming_no VARCHAR(64),record_version BIGINT,cargo_json VARCHAR(2000),expected_pallet_count INT,estimated_arrival_at TIMESTAMP)"); + jdbc.execute("CREATE TABLE ll_fleet_pool_order_link(booking_id BIGINT,customer_id VARCHAR(64),lastleg_order_id BIGINT,business_order_no VARCHAR(64),fulfillment_stage VARCHAR(32))"); + for(int i=1;i<=6;i++)jdbc.update("INSERT INTO ll_dispatch_order(id,dispatch_no,customer_id,overseas_warehouse_id,package_count,status,picked_package_count,shipped_package_count,updated_at) VALUES(?,?,?,'92',10,'PENDING_DEPARTURE',0,0,CURRENT_TIMESTAMP)",i,"DS-"+i,"9"); + jdbc.update("INSERT INTO ll_fleet_trip_dispatch VALUES(2)"); + jdbc.update("UPDATE ll_dispatch_order SET overseas_warehouse_id='93' WHERE id=3"); + jdbc.update("INSERT INTO ll_dispatch_pda_task VALUES(4,'PICKING')"); + jdbc.update("INSERT INTO ll_dispatch_item VALUES(5,'9','IN-5'),(6,'9','IN-6')"); + jdbc.update("INSERT INTO ll_fleet_trip VALUES(1,92)"); + jdbc.update("INSERT INTO ll_fleet_external_booking(id,trip_id,customer_id,booking_no,trip_no,status,offer_id,destination_key,destination_label,incoming_no,record_version,cargo_json) VALUES(5,1,'9','FB-5','TR-OTHER','RECEIVED',1,'FBA:ACY2','ACY2','IN-5',1,'{\"packageCount\":null}'),(6,1,'9','FB-6','TR-1','RECEIVED',1,'FBA:ACY2','ACY2','IN-6',2,'{\"packageCount\":10}')"); + var configuration=new MybatisConfiguration();configuration.addMapper(FleetDispatchPlanningMapper.class);configuration.addMapper(FleetPoolOrderMapper.class); + var interceptor=new MybatisPlusInterceptor();interceptor.addInnerInterceptor(new PaginationInnerInterceptor(com.baomidou.mybatisplus.annotation.DbType.H2));configuration.addInterceptor(interceptor); + var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(configuration); + var session=new SqlSessionTemplate(factory.getObject());var mapper=session.getMapper(FleetDispatchPlanningMapper.class); + var page=mapper.candidates(new Page<>(1,1),"TR-1","92",null); + assertThat(page.getTotal()).isEqualTo(2);assertThat(page.getRecords()).hasSize(1); + assertThat(mapper.candidates(new Page<>(1,100),"TR-1","92","DS-1").getRecords()).extracting(r->r.dispatchNo()).containsExactly("DS-1"); + var resolver=new FleetPoolSourceResolver(session.getMapper(FleetPoolOrderMapper.class),new ObjectMapper()); + var sources=resolver.resolve(List.of(new FleetPoolSourceResolver.SourceRef("9","FB-5"),new FleetPoolSourceResolver.SourceRef("9","FB-6"),new FleetPoolSourceResolver.SourceRef("OTHER","FB-6"))); + assertThat(sources).hasSize(2); + assertThat(sources.stream().filter(s->s.bookingNo().equals("FB-5")).findFirst().orElseThrow().packageCount()).isNull(); + assertThat(sources.stream().filter(s->s.bookingNo().equals("FB-6")).findFirst().orElseThrow().packageCount()).isEqualTo(10); + assertThat(sources).allMatch(s->s.lastlegOrderId()==null&&s.expectedPalletCount()==null); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetInquiryProgressTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetInquiryProgressTest.java new file mode 100644 index 000000000..c3d6b1820 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetInquiryProgressTest.java @@ -0,0 +1,47 @@ +package com.baiyue.lastleg.fleet.trip; + +import com.baiyue.lastleg.fleet.domain.TripCapacityPolicy; +import org.junit.jupiter.api.Test; +import java.math.BigDecimal; +import java.time.*; +import java.util.List; +import static org.assertj.core.api.Assertions.*; + +class FleetInquiryProgressTest { + private final Instant cutoff=Instant.parse("2026-10-20T10:00:00Z"); + private FleetCapacityOfferModels.Offer offer() { + return new FleetCapacityOfferModels.Offer("FO-1","TR-1","OPEN",1,1,"1","仓库",cutoff,cutoff,null,null,null, + List.of(new FleetCapacityOfferModels.Destination("FBA:ACY2","ACY2",1,new TripCapacityPolicy.Load(4,new BigDecimal("1000"),new BigDecimal("10")))),List.of(),true,true,true,null,null); + } + private FleetBookingModels.Cargo cargo(Integer spaces) { + return new FleetBookingModels.Cargo("FBA:ACY2","货物",1,spaces,new BigDecimal("100"),BigDecimal.ONE,"PALLET",BigDecimal.ZERO,"USD","客户联系人","联系信息",null); + } + private FleetBookingEntity booking(String status) { + var row=new FleetBookingEntity();row.setStatus(status);row.setBusinessType("LOCAL_LOOSEBOARD");row.setEstimatedArrivalAt(LocalDateTime.ofInstant(cutoff.minusSeconds(60),ZoneOffset.UTC));return row; + } + @Test void unknownQuantityIsMissingAndTruckNeedsPhysicalPalletCount() { + var row=booking("DRAFT");row.setBusinessType("US_TRUCK"); + assertThat(FleetInquiryProgress.missing(row,cargo(null),offer())).containsExactly("占用标准托位","预计实托数"); + row.setExpectedPalletCount(1); + assertThat(FleetInquiryProgress.missing(row,cargo(1),offer())).isEmpty(); + } + @Test void capacityIsCheckedAgainstThisDestinationAndThisShipment() { + assertThat(FleetInquiryProgress.capacityBlock(cargo(5),offer())).contains("超过"); + assertThat(FleetInquiryProgress.capacityBlock(cargo(4),offer())).isNull(); + } + @Test void expiryDoesNotTellSalesToEditAnUnsavableDraft() { + var p=FleetInquiryProgress.project(booking("DRAFT"),List.of("预计重量"),"已截止",false,false,null,null); + assertThat(p.primaryAction()).isEqualTo("VIEW");assertThat(p.reservation()).isEqualTo("尚未保位"); + } + @Test void receivingIsNotDispatchWorkUntilActualMeasurementCompletes() { + var row=booking("CONFIRMED");row.setIncomingNo("WT-1"); + var waiting=FleetInquiryProgress.project(row,List.of(),null,false,false,"WAIT_PDA_RECEIVING",null); + assertThat(waiting.owner()).isEqualTo("仓库");assertThat(waiting.primaryAction()).isEqualTo("VIEW"); + var received=FleetInquiryProgress.project(row,List.of(),null,false,false,"WAIT_CUSTOMER_CONFIRMATION",null); + assertThat(received.owner()).isEqualTo("调度");assertThat(received.primaryAction()).isEqualTo("RECEIPT"); + } + @Test void bindingIsNotDepartureAndOnlyActualFulfillmentAdvancesTracking() { + assertThat(FleetInquiryProgress.project(booking("BOUND"),List.of(),null,false,false,null,"PENDING_DISPATCH").label()).isEqualTo("已排入本车"); + assertThat(FleetInquiryProgress.project(booking("BOUND"),List.of(),null,false,false,null,"IN_TRANSIT").label()).isEqualTo("已发车"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripApiContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripApiContractTest.java index 74ab60280..faddeed17 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripApiContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripApiContractTest.java @@ -13,11 +13,15 @@ import static org.assertj.core.api.Assertions.*; class FleetTripApiContractTest { @Test void allTripOperationsHaveOpenApiAndRealPermissions() throws Exception { Map document = new Yaml().load(Files.readString(Path.of("src/main/resources/openapi/fleet-trips-v1.yaml"))); - Map paths = (Map) document.get("paths"); + Map preparation = new Yaml().load(Files.readString(Path.of("src/main/resources/openapi/fleet-preparation-v1.yaml"))); + Map pool = new Yaml().load(Files.readString(Path.of("src/main/resources/openapi/fleet-capacity-offers-v1.yaml"))); + var paths = new java.util.HashMap(); + for(var spec:java.util.List.of(document,preparation,pool))paths.putAll((Map)spec.get("paths")); String module = Files.readString(Path.of("../iam/src/main/resources/db/lastleg-permissions.sql")); + module += Files.readString(Path.of("../iam/src/main/resources/db/fleet-capacity-offer-permissions.sql")); String deployed = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql")); int count = 0; - for (var controller : java.util.List.of(PlatformFleetTripController.class, PlatformFleetBookingController.class)) { + for (var controller : java.util.List.of(PlatformFleetTripController.class, PlatformFleetBookingController.class, PlatformFleetCapacityOfferController.class)) { String base = controller.getAnnotation(RequestMapping.class).value()[0]; for (var method : controller.getDeclaredMethods()) { String verb = null; String[] suffix = null; @@ -42,8 +46,10 @@ class FleetTripApiContractTest { count++; } } - assertThat(count).isEqualTo(29); + assertThat(count).isEqualTo(52); verifyRefs(document, document); + verifyRefs(preparation, preparation); + verifyRefs(pool, pool); } @Test void schemasMatchAndUseUniqueResourceClaimsAndVersionedHistory() throws Exception { @@ -68,7 +74,7 @@ class FleetTripApiContractTest { if(!reference.startsWith("#/")) { String[] parts=reference.split("#",2); assertThat(parts).hasSize(2); - assertThat(parts[0]).isEqualTo("fleet-quote-candidates-v1.yaml"); + assertThat(Path.of(parts[0]).getFileName().toString()).isIn("fleet-quote-candidates-v1.yaml","fleet-trips-v1.yaml","fleet-capacity-offers-v1.yaml"); try { value=new Yaml().load(Files.readString(Path.of("src/main/resources/openapi",parts[0]))); } catch(java.io.IOException error) { throw new java.io.UncheckedIOException(error); } pointer="#"+parts[1]; diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripAuthorizationTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripAuthorizationTest.java index e2dc143b7..a7e68abb8 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripAuthorizationTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripAuthorizationTest.java @@ -36,6 +36,7 @@ class FleetTripAuthorizationTest { assertThatThrownBy(() -> controller.cancel(null, "TR-TEST", null)).isInstanceOf(AccessDeniedException.class); assertThatThrownBy(() -> controller.plan(null, "TR-TEST", null)).isInstanceOf(AccessDeniedException.class); assertThatThrownBy(() -> controller.bind(null, "TR-TEST", null)).isInstanceOf(AccessDeniedException.class); + assertThatThrownBy(() -> controller.prepare(null,null)).isInstanceOf(AccessDeniedException.class); assertThatThrownBy(() -> controller.unbind(null, "TR-TEST", null)).isInstanceOf(AccessDeniedException.class); assertThatThrownBy(() -> controller.confirmLoading(null, "TR-TEST", null)).isInstanceOf(AccessDeniedException.class); assertThatThrownBy(() -> controller.reviewDispatch(null, "TR-TEST", "DP-1", null)).isInstanceOf(AccessDeniedException.class); @@ -55,8 +56,25 @@ class FleetTripAuthorizationTest { verifyNoMoreInteractions(service); } finally { SecurityContextHolder.clearContext(); } } + @Test void poolReadDoesNotGrantIncomingReceiptOrDispatchCommands() { + try(var context=new AnnotationConfigApplicationContext(Config.class)) { + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("sales","unused",List.of(new SimpleGrantedAuthority(FleetCapacityOfferService.LIST)))); + var pool=context.getBean(PlatformFleetCapacityOfferController.class); + pool.fulfillment("FB-1");verify(context.getBean(FleetCapacityOfferService.class)).fulfillment("FB-1"); + assertThatThrownBy(()->pool.incoming("FB-1",null)).isInstanceOf(AccessDeniedException.class); + assertThatThrownBy(()->pool.receipt("FB-1",null)).isInstanceOf(AccessDeniedException.class); + assertThatThrownBy(()->pool.arrange("FB-1",null)).isInstanceOf(AccessDeniedException.class); + assertThatThrownBy(()->pool.saveAndConfirm("FB-1",null)).isInstanceOf(AccessDeniedException.class); + } finally {SecurityContextHolder.clearContext();} + } @Configuration @EnableMethodSecurity static class Config { + @Bean FleetCapacityOfferCommandMapper commands() { return mock(FleetCapacityOfferCommandMapper.class); } + @Bean FleetDispatchPlanningGateway planning() { return mock(FleetDispatchPlanningGateway.class); } + @Bean FleetTripPreparationService preparation() { return mock(FleetTripPreparationService.class); } + @Bean com.baiyue.lastleg.appointmentplanning.AppointmentCoverageService appointmentCoverage() { return mock(com.baiyue.lastleg.appointmentplanning.AppointmentCoverageService.class); } + @Bean FleetCapacityOfferService capacityOffers() { return mock(FleetCapacityOfferService.class); } + @Bean PlatformFleetCapacityOfferController poolController(FleetCapacityOfferService service) { return new PlatformFleetCapacityOfferController(service); } @Bean com.baiyue.lastleg.fleet.business.FleetPoolOrderBridge poolOrders() { return mock(com.baiyue.lastleg.fleet.business.FleetPoolOrderBridge.class); } @Bean FleetBookingService bookingService() { return mock(FleetBookingService.class); } @Bean com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory customers() { return mock(com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory.class); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripPersistenceTest.java index 615002116..2c5d7d03b 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripPersistenceTest.java @@ -56,6 +56,8 @@ import static org.mockito.Mockito.*; class FleetTripPersistenceTest { private JdbcTemplate jdbc; private FleetTripService service; + private FleetTripPreparationService preparation; + private FleetDispatchPlanningGateway planning; private DriverService drivers; private VehicleRepository vehicles; private VehicleApplicationService vehicleService; @@ -141,6 +143,9 @@ class FleetTripPersistenceTest { transactional(workflow, DispatchPdaWorkflowService.class), session.getMapper(DeliveryOperationMapper.class)), DispatchFleetAdapter.class); service = transactional(new FleetTripService(session.getMapper(FleetTripMapper.class), resources, session.getMapper(FleetTripHistoryMapper.class), vehicles, drivers, directory, json, bindingMapper, dispatchGateway, reservations), FleetTripService.class); + preparation = transactional(new FleetTripPreparationService(service,session.getMapper(FleetTripMapper.class),session.getMapper(FleetCapacityOfferCommandMapper.class),json),FleetTripPreparationService.class); + planning=mock(FleetDispatchPlanningGateway.class); + ReflectionTestUtils.setField(service,"planning",planning); execution = transactional(new FleetTripExecutionService(session.getMapper(FleetTripMapper.class), bindingMapper, resources, dispatchGateway, service, drivers, vehicles, json, reservations), FleetTripExecutionService.class); var customerDirectory = mock(com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory.class); when(customerDirectory.required(anyLong())).thenAnswer(call -> new com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory.Customer( @@ -150,11 +155,47 @@ class FleetTripPersistenceTest { quoteCandidates=mock(com.baiyue.lastleg.fleet.quote.FleetQuoteCandidateService.class); bookingService = transactional(new FleetBookingService(session.getMapper(FleetTripMapper.class), bookingMapper, reservations, service, execution, customerDirectory, json, incoming, quoteContext, quoteCandidates), FleetBookingService.class); ReflectionTestUtils.setField(bookingService,"poolOrders",poolOrders); + ReflectionTestUtils.setField(bookingService,"planning",planning); + ReflectionTestUtils.setField(bookingService,"commands",session.getMapper(FleetCapacityOfferCommandMapper.class)); capacityOffers=transactional(new FleetCapacityOfferService(session.getMapper(FleetCapacityOfferMapper.class),session.getMapper(FleetCapacityOfferCommandMapper.class), session.getMapper(FleetTripMapper.class),bookingMapper,bookingService,reservations,execution,customerDirectory,poolOrders,json),FleetCapacityOfferService.class); + ReflectionTestUtils.setField(capacityOffers,"appointmentCoverage",mock(com.baiyue.lastleg.appointmentplanning.AppointmentCoverageService.class)); completion = transactional(new FleetTripCompletionService(session.getMapper(FleetTripMapper.class), bindingMapper, resources, dispatchGateway, service, vehicles, vehicleService, json), FleetTripCompletionService.class); } + @Test void preparationRollsBackNewTripAndResourcesIfAnyBindingFailsThenReplaysSuccess() { + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("1","unused",List.of("platform:fleet:trip:create","platform:fleet:trip:assign","platform:fleet:trip:edit").stream().map(SimpleGrantedAuthority::new).toList())); + long vehicle=vehicle(true), driver=driver(true);dispatch(21,"A","92");dispatch(22,"B","93"); + var request=new FleetTripPreparationService.Prepare(null,null,createRequest("prepare-atomic"),vehicle,driver,List.of("DP-21","DP-22"),"prepare-atomic"); + assertThatThrownBy(()->preparation.prepare("1",request)).hasMessageContaining("同一起运仓"); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip",Integer.class)).isZero(); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip_dispatch",Integer.class)).isZero(); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment",Integer.class)).isZero(); + jdbc.update("UPDATE ll_dispatch_order SET overseas_warehouse_id='92' WHERE id=22"); + jdbc.update("UPDATE ll_dispatch_pda_task SET warehouse_id='92' WHERE dispatch_order_id=22"); + var saved=preparation.prepare("1",request); + assertThat(saved.dispatches()).hasSize(2); + assertThat(preparation.prepare("1",request)).isEqualTo(saved); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip",Integer.class)).isEqualTo(1); + assertThatThrownBy(()->preparation.prepare("1",new FleetTripPreparationService.Prepare(null,null,request.plan(),vehicle,driver,List.of("DP-21"),request.idempotencyKey()))).hasMessageContaining("不能更换内容"); + } + + @Test void sameWindowSaveAndConfirmIsAtomicAndReplaysOriginalResult() { + var offer=publishedOffer();var draft=capacityOffers.createInquiry(offer.offerNo(),poolDraft(offer,2,"prepare-register")); + var input=poolDraft(draft.offer(),2,"same-window-confirm"); + var update=new FleetCapacityOfferModels.InquiryWrite(input.tripRecordVersion(),input.offerRecordVersion(),draft.inquiry().recordVersion(),input.customerId(),input.businessType(),input.deliveryMode(),input.estimatedArrivalAt(),input.expectedPalletCount(),input.cargo(),input.quoteRef(),input.idempotencyKey()); + doThrow(new IllegalStateException("bridge failed")).when(poolOrders).confirm(any(),anyString()); + assertThatThrownBy(()->capacityOffers.saveAndConfirm(draft.inquiry().bookingNo(),update)).hasMessageContaining("bridge failed"); + assertThat(capacityOffers.inquiry(draft.inquiry().bookingNo()).recordVersion()).isEqualTo(draft.inquiry().recordVersion()); + assertThat(capacityOffers.inquiry(draft.inquiry().bookingNo()).status()).isEqualTo("DRAFT"); + reset(poolOrders); + var confirmed=capacityOffers.saveAndConfirm(draft.inquiry().bookingNo(),update); + assertThat(confirmed.inquiry().status()).isEqualTo("CONFIRMED"); + assertThat(confirmed.inquiry().progress().reservation()).isEqualTo("已保留货位"); + assertThat(capacityOffers.saveAndConfirm(draft.inquiry().bookingNo(),update).replayed()).isTrue(); + verify(poolOrders,times(1)).confirm(any(),anyString()); + } + @Test void poolSalesAreIsolatedByUserIdAndCannotConfirmOrReadAllInquiries() { var offer=publishedOffer();poolLogin(101,false); var first=capacityOffers.createInquiry(offer.offerNo(),poolDraft(offer,2,"owner-first")); @@ -443,7 +484,8 @@ class FleetTripPersistenceTest { } finally { executor.shutdownNow(); } } - @Test void externalReceiptChangesReservationOnlyAfterExplicitActualQuantityAndPriceConfirmation() { + @org.junit.jupiter.params.ParameterizedTest @org.junit.jupiter.params.provider.ValueSource(booleans={false,true}) + void externalReceiptChangesReservationOnlyAfterExplicitActualQuantityAndPriceConfirmation(boolean automatic) { var source=enableQuote(); String number=readyTrip();var basis=execution.capacityPlan(number); execution.confirmCapacityPlan(number,"1",new FleetExecutionModels.Confirm(basis.recordVersion(),basis.manifestHash(),List.of(measurement()),"计划")); @@ -451,7 +493,7 @@ class FleetTripPersistenceTest { var confirmed=bookingService.confirm(number,draft.booking().bookingNo(),"1",new FleetBookingModels.Command(draft.tripRecordVersion(),0L,"receipt-reserve",null)); var awaiting=new FleetBookingIncomingGateway.Evidence("WT-EXT","WT-EXT","92","FBA:ONT8","WAIT_PDA_RECEIVING","empty",List.of(),0); when(incoming.create(eq("1"),any())).thenReturn(awaiting); - var request=new FleetBookingModels.StartIncoming(confirmed.tripRecordVersion(),confirmed.booking().recordVersion(),null,null,null,null,null,null,"incoming-start"); + var request=new FleetBookingModels.StartIncoming(confirmed.tripRecordVersion(),confirmed.booking().recordVersion(),departure.minusSeconds(120),null,null,null,null,null,"incoming-start"); var started=bookingService.startIncoming(number,draft.booking().bookingNo(),"1",request); assertThat(bookingService.startIncoming(number,draft.booking().bookingNo(),"1",request).replayed()).isTrue(); verify(incoming,times(1)).create(eq("1"),any()); @@ -473,11 +515,20 @@ class FleetTripPersistenceTest { assertThat(execution.capacityPlan(number).availableSegments().get(0).remaining().palletSpaces()).isEqualTo(28); dispatch(13,"9","92"); jdbc.update("INSERT INTO ll_dispatch_item(id,dispatch_order_id,customer_id,warehouse_pallet_id,pallet_no,package_count,source_stage,destination_warehouse_code,overseas_warehouse_id,inbound_code) VALUES(3013,13,'9',902,'PALLET-902',22,'RECEIVED','ONT8','92','WT-EXT')"); - var linked=bookingService.bindDispatch(number,draft.booking().bookingNo(),"1",new FleetBookingModels.BindDispatch(accepted.tripRecordVersion(),accepted.booking().recordVersion(),"DP-13","link-external")); + when(incoming.inspect("9","WT-EXT",true)).thenReturn(new FleetBookingIncomingGateway.Evidence(actual.incomingNo(),actual.inboundCode(),actual.warehouseId(),actual.destinationKey(),"READY_FOR_DISPATCH",actual.receiptHash(),actual.pallets(),actual.receivedPackages())); + when(planning.resolveIncoming(eq("9"),any(),eq("1"))).thenReturn("DP-13"); + var arrange=new FleetBookingModels.Command(accepted.tripRecordVersion(),accepted.booking().recordVersion(),"auto-link-external",null); + var linked=automatic?bookingService.arrangeDispatch(number,draft.booking().bookingNo(),"1",arrange):bookingService.bindDispatch(number,draft.booking().bookingNo(),"1",new FleetBookingModels.BindDispatch(accepted.tripRecordVersion(),accepted.booking().recordVersion(),"DP-13","link-external")); + if(automatic){assertThat(bookingService.arrangeDispatch(number,draft.booking().bookingNo(),"1",arrange).replayed()).isTrue();verify(planning,times(1)).resolveIncoming(eq("9"),any(),eq("1"));} assertThat(linked.booking().status()).isEqualTo("BOUND");assertThat(linked.booking().dispatchNo()).isEqualTo("DP-13"); assertThat(execution.capacityPlan(number).planValid()).isFalse(); assertThat(service.detail(number).dispatches()).hasSize(3); - service.unbindDispatch(number,"1",new FleetTripModels.BindingCommand(linked.tripRecordVersion(),"DP-13","撤销未开工关联")); + var recheck=execution.capacityPlan(number); + var planned=execution.confirmCapacityPlan(number,"1",new FleetExecutionModels.Confirm(recheck.recordVersion(),recheck.manifestHash(),List.of(measurement(),new FleetExecutionModels.Measurement("902",1,new BigDecimal("150"),new BigDecimal("1.5"),1)),"关联实收后核对")); + assertThat(planned.capacityPlan().segments().get(0).occupied().palletSpaces()).isEqualTo(2); + assertThat(planned.availableSegments().get(0).remaining().palletSpaces()).isEqualTo(28); + if(automatic){var replay=bookingService.arrangeDispatch(number,draft.booking().bookingNo(),"1",arrange);assertThat(replay.tripRecordVersion()).isEqualTo(linked.tripRecordVersion());assertThat(replay.booking()).isEqualTo(linked.booking());} + service.unbindDispatch(number,"1",new FleetTripModels.BindingCommand(planned.recordVersion(),"DP-13","撤销未开工关联")); assertThat(bookingService.list(number).records().get(0).status()).isEqualTo("RECEIVED"); assertThat(bookingService.list(number).records().get(0).dispatchNo()).isNull(); } From 040f3bbd93cf32afdb7aaf3bbec51c9e6a9733c6 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 9 Oct 2026 22:31:51 +0800 Subject: [PATCH 14/40] fix(lastleg): preserve followup timestamp on replay --- .../appointmentplanning/AppointmentFollowupService.java | 4 +++- .../AppointmentFollowupPersistenceTest.java | 8 ++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentFollowupService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentFollowupService.java index 053d082a9..9999d11d4 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentFollowupService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentFollowupService.java @@ -67,7 +67,9 @@ public class AppointmentFollowupService { value.setStage(request.stage()); value.setVersion(request.expectedVersion()+1); value.setContactedParty(clean(request.contactedParty())); value.setResult(request.result().trim()); value.setNextAction(clean(request.nextAction())); value.setFollowupDate(request.followupDate()); value.setOperatorId(operatorId); value.setOperatorName(clean(operatorName)); value.setCommandKey(request.idempotencyKey()); - value.setRequestHash(hash); value.setCreatedAt(LocalDateTime.now(ZoneOffset.UTC)); + value.setRequestHash(hash); + // Return the same precision that DATETIME(3) can persist, including the first response. + value.setCreatedAt(LocalDateTime.now(ZoneOffset.UTC).truncatedTo(java.time.temporal.ChronoUnit.MILLIS)); try { mapper.insert(value); } catch (org.springframework.dao.DuplicateKeyException conflict) { var raced = mapper.command(sourceKey,customerId,request.stage(),request.idempotencyKey()); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentFollowupPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentFollowupPersistenceTest.java index 885ccc328..922d9dfdb 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentFollowupPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentFollowupPersistenceTest.java @@ -35,6 +35,14 @@ class AppointmentFollowupPersistenceTest { assertThrows(RuntimeException.class, () -> mapper.insert(record(13L,"DRAYAGE_CONTACT",2,"key-a"))); assertThrows(RuntimeException.class, () -> mapper.insert(record(14L,"DRAYAGE_CONTACT",1,"key-b"))); assertEquals(2,mapper.count("ORDER:1","A")); + var sources=org.mockito.Mockito.mock(AppointmentSourceService.class); + org.mockito.Mockito.when(sources.resolve("ORDER:1","A",null)).thenReturn(new AppointmentSourceService.Source( + "ORDER:1","A",1L,"IN-1",null,"CONTAINER","100","US warehouse","FBA:ONT8","ONT8",20,"ORDER:1",true,java.util.List.of())); + var service=new AppointmentFollowupService(sources,mapper,new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules()); + var command=new AppointmentFollowupService.Save("FBA_RECEIVING",1,"timestamp-replay",null,"Test contact",null,null); + var saved=service.save("ORDER:1","A",command,"operator","Operator"); + var replay=service.save("ORDER:1","A",command,"operator","Operator"); + assertEquals(saved,replay,"Persisted timestamp precision must not change the response on retry"); } } private static AppointmentFollowup record(long id,String stage,int version,String key) { From a973be21033b46392e1d85af66e8b39bc836e44e Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 10 Oct 2026 02:19:38 +0800 Subject: [PATCH 15/40] feat(procurement): implement sample fulfillment and settled fee workflow --- .../event/BusinessFeeSourceChangedV1.java | 3 +- .../event/WalletCommandCompletedV1.java | 4 +- .../event/WalletCommandRequestedV1.java | 11 +- .../event/WalletSettlementReference.java | 4 + deploy/sql/README.md | 18 +- .../V5__procurement_node_permissions.sql | 28 + .../V14__procurement_wallet_reference.sql | 5 + .../V15__procurement_fee_versions.sql | 48 + .../V16__procurement_purchase_facts.sql | 57 + .../V17__procurement_physical_facts.sql | 65 + .../V18__procurement_transport_facts.sql | 40 + .../V19__procurement_fee_correction.sql | 20 + .../V20__procurement_exception_control.sql | 31 + ...1__procurement_fee_confirmation_origin.sql | 4 + deploy/sql/头程数据库统一增量-MySQL8.sql | 31 + deploy/sql/海外区域统一增量-MySQL8.sql | 278 +- .../file/service/BusinessFileCategory.java | 4 + .../service/ProcurementFilePolicyTest.java | 36 + .../BusinessFeeSourceEventService.java | 45 +- .../finance/service/FinanceWalletService.java | 107 + .../service/WalletCommandEventService.java | 49 +- .../BusinessFeeSourceEventServiceTest.java | 67 +- .../WalletCommandEventServiceTest.java | 69 +- .../finance/WalletCommandTransactionTest.java | 2 +- .../ProcurementActualSettlementTest.java | 91 + .../ProcurementSettlementTransactionTest.java | 200 + .../resources/db/procurement-permissions.sql | 28 + lastleg-service/AGENTS.md | 2 +- .../DomesticWarehouseDirectoryClient.java | 11 +- .../financefee/entity/OverseasFeeSource.java | 2 + .../mapper/OverseasFeeSourceMapper.java | 1 + .../service/OverseasFeeOutboxService.java | 2 +- .../service/OverseasFeeSourceService.java | 55 +- ...MerchantOverseasProcurementController.java | 10 +- ...PlatformOverseasProcurementController.java | 24 +- .../ProcurementChargeController.java | 77 + .../ProcurementControlController.java | 45 + .../controller/ProcurementFileController.java | 49 + .../ProcurementLifecycleController.java | 64 + .../ProcurementPhysicalController.java | 82 + .../ProcurementTransportController.java | 59 + .../dto/OverseasProcurementRequests.java | 43 +- .../dto/OverseasProcurementResponses.java | 8 +- .../dto/ProcurementChargeRequests.java | 35 + .../dto/ProcurementControlRequests.java | 25 + .../dto/ProcurementLifecycleRequests.java | 23 + .../dto/ProcurementPhysicalRequests.java | 37 + .../dto/ProcurementTransportRequests.java | 38 + .../entity/OverseasProcurementFee.java | 21 + .../entity/OverseasProcurementOrder.java | 5 - .../gateway/HttpProcurementFileGateway.java | 13 +- .../gateway/ProcurementFileCategory.java | 14 + .../gateway/ProcurementFileGateway.java | 2 + .../mapper/OverseasProcurementFeeMapper.java | 7 +- .../service/OverseasProcurementService.java | 211 +- .../service/ProcurementAdjustmentService.java | 110 + .../service/ProcurementChargeService.java | 113 + .../service/ProcurementControlService.java | 145 + .../service/ProcurementFeePurpose.java | 14 + .../service/ProcurementFinanceService.java | 532 +++ .../service/ProcurementLifecycleService.java | 244 ++ .../service/ProcurementPhysicalService.java | 332 ++ .../service/ProcurementQueryService.java | 132 + .../service/ProcurementTransportService.java | 277 ++ .../ProcurementWarehouseDirectory.java | 53 + .../lastleg/security/OverseasPermissions.java | 13 + .../wallet/WalletCommandOutboxService.java | 41 +- .../src/main/resources/db/lastleg.sql | 272 +- .../openapi/overseas-business-v1.yaml | 95 +- .../openapi/overseas-finance-fees-v1.yaml | 8 +- .../openapi/procurement-fulfillment-v1.yaml | 3505 +++++++++++++++++ ...cWarehouseDirectoryClientContractTest.java | 2 +- .../service/DispatchBolPrintServiceTest.java | 4 +- .../ProcurementSettledFeeSourceTest.java | 48 + .../MerchantOverseasOpenApiContractTest.java | 6 +- ...rseasProcurementInventoryBoundaryTest.java | 4 +- ...OverseasProcurementSchemaContractTest.java | 7 +- .../ProcurementRequestBoundaryTest.java | 40 + .../OverseasProcurementControllerTest.java | 19 +- ...ProcurementLifecycleAuthorizationTest.java | 141 + .../OverseasProcurementServiceTest.java | 149 +- .../ProcurementChargePersistenceTest.java | 257 ++ .../ProcurementControlPersistenceTest.java | 129 + .../ProcurementFinancePersistenceTest.java | 204 + .../ProcurementPhysicalPersistenceTest.java | 188 + .../ProcurementPurchasePersistenceTest.java | 155 + .../service/ProcurementQueryServiceTest.java | 66 + .../ProcurementTransportPersistenceTest.java | 173 + .../ProcurementWarehouseDirectoryTest.java | 35 + .../service/FbaWarehouseValidatorTest.java | 2 +- ...rehouseTransferReceiptCoordinatorTest.java | 8 +- .../WalletCommandOutboxServiceTest.java | 51 + scripts/README.md | 3 + scripts/admin-test-env-modules/README.md | 8 +- ...phase4-overseas-business-contract.test.mjs | 43 +- .../phase4-overseas-business.mjs | 134 +- ...eas-finance-cross-region-contract.test.mjs | 12 +- .../phase4-overseas-finance-cross-region.mjs | 9 +- ...s-wallet-and-pallet-cost-contract.test.mjs | 12 +- ...phase4-overseas-wallet-and-pallet-cost.mjs | 129 +- ...ement-sample-fulfillment-contract.test.mjs | 50 + .../procurement-sample-fulfillment.mjs | 125 + scripts/procurement-sample-test-env.mjs | 111 + scripts/procurement-sample-test-env.test.mjs | 17 + 104 files changed, 9823 insertions(+), 714 deletions(-) create mode 100644 common-event-contract/src/main/java/com/baiyue/common/event/WalletSettlementReference.java create mode 100644 deploy/sql/migrations/cn/baiyue_iam/V5__procurement_node_permissions.sql create mode 100644 deploy/sql/migrations/overseas/baiyue_lastleg/V14__procurement_wallet_reference.sql create mode 100644 deploy/sql/migrations/overseas/baiyue_lastleg/V15__procurement_fee_versions.sql create mode 100644 deploy/sql/migrations/overseas/baiyue_lastleg/V16__procurement_purchase_facts.sql create mode 100644 deploy/sql/migrations/overseas/baiyue_lastleg/V17__procurement_physical_facts.sql create mode 100644 deploy/sql/migrations/overseas/baiyue_lastleg/V18__procurement_transport_facts.sql create mode 100644 deploy/sql/migrations/overseas/baiyue_lastleg/V19__procurement_fee_correction.sql create mode 100644 deploy/sql/migrations/overseas/baiyue_lastleg/V20__procurement_exception_control.sql create mode 100644 deploy/sql/migrations/overseas/baiyue_lastleg/V21__procurement_fee_confirmation_origin.sql create mode 100644 file/src/test/java/com/baiyue/file/service/ProcurementFilePolicyTest.java create mode 100644 finance/src/test/java/com/baiyue/finance/service/ProcurementActualSettlementTest.java create mode 100644 finance/src/test/java/com/baiyue/finance/service/ProcurementSettlementTransactionTest.java create mode 100644 iam/src/main/resources/db/procurement-permissions.sql create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/ProcurementChargeController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/ProcurementControlController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/ProcurementFileController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/ProcurementLifecycleController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/ProcurementPhysicalController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/ProcurementTransportController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/ProcurementChargeRequests.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/ProcurementControlRequests.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/ProcurementLifecycleRequests.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/ProcurementPhysicalRequests.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/ProcurementTransportRequests.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/procurement/gateway/ProcurementFileCategory.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementAdjustmentService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementChargeService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementControlService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementFeePurpose.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementFinanceService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementLifecycleService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementPhysicalService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementQueryService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementTransportService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementWarehouseDirectory.java create mode 100644 lastleg-service/src/main/resources/openapi/procurement-fulfillment-v1.yaml create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/ProcurementSettledFeeSourceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/procurement/ProcurementRequestBoundaryTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/procurement/controller/ProcurementLifecycleAuthorizationTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementChargePersistenceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementControlPersistenceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementFinancePersistenceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementPhysicalPersistenceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementPurchasePersistenceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementQueryServiceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementTransportPersistenceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementWarehouseDirectoryTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/wallet/WalletCommandOutboxServiceTest.java create mode 100644 scripts/admin-test-env-modules/procurement-sample-fulfillment-contract.test.mjs create mode 100644 scripts/admin-test-env-modules/procurement-sample-fulfillment.mjs create mode 100644 scripts/procurement-sample-test-env.mjs create mode 100644 scripts/procurement-sample-test-env.test.mjs diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/BusinessFeeSourceChangedV1.java b/common-event-contract/src/main/java/com/baiyue/common/event/BusinessFeeSourceChangedV1.java index 012d99fc3..e16c34162 100644 --- a/common-event-contract/src/main/java/com/baiyue/common/event/BusinessFeeSourceChangedV1.java +++ b/common-event-contract/src/main/java/com/baiyue/common/event/BusinessFeeSourceChangedV1.java @@ -56,6 +56,7 @@ public record BusinessFeeSourceChangedV1( String reviewStatus, String publicationStatus, String sourceStatus, - String sourceHash + String sourceHash, + WalletSettlementReference walletSettlement ) { } diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/WalletCommandCompletedV1.java b/common-event-contract/src/main/java/com/baiyue/common/event/WalletCommandCompletedV1.java index f786e58c1..914384f8e 100644 --- a/common-event-contract/src/main/java/com/baiyue/common/event/WalletCommandCompletedV1.java +++ b/common-event-contract/src/main/java/com/baiyue/common/event/WalletCommandCompletedV1.java @@ -15,7 +15,9 @@ public record WalletCommandCompletedV1( BigDecimal amount, String errorCode, String errorMessage, - Instant completedAt + Instant completedAt, + String walletHoldId, + String walletLedgerId ) { public static final int VERSION = 1; } diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/WalletCommandRequestedV1.java b/common-event-contract/src/main/java/com/baiyue/common/event/WalletCommandRequestedV1.java index 8c3514ec4..7fd36de91 100644 --- a/common-event-contract/src/main/java/com/baiyue/common/event/WalletCommandRequestedV1.java +++ b/common-event-contract/src/main/java/com/baiyue/common/event/WalletCommandRequestedV1.java @@ -3,7 +3,13 @@ package com.baiyue.common.event; import java.math.BigDecimal; import java.time.Instant; -/** 海外业务向国内 Finance 发起的客户资金冻结、结算或释放指令。 */ +/** + * 海外业务向 Finance 发起的客户资金指令。 + * FREEZE 的 amount 为预留金额;SETTLE 全额结算;RELEASE 释放原冻结。 + * SETTLE_ACTUAL 的 amount 为最终正额 CNY 实扣,原子补差或释放余款,原冻结金额保留。 + * 实际零元使用 RELEASE,不发送零元结算。结算和释放按原业务冻结身份关联。 + * REFUND 使用独立业务退款号并携带 originalLedgerId,关联原成功消费,不创建充值。 + */ public record WalletCommandRequestedV1( String operation, long customerId, @@ -12,7 +18,8 @@ public record WalletCommandRequestedV1( BigDecimal amount, String remark, String idempotencyKey, - Instant requestedAt + Instant requestedAt, + String originalLedgerId ) { public static final int VERSION = 1; } diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/WalletSettlementReference.java b/common-event-contract/src/main/java/com/baiyue/common/event/WalletSettlementReference.java new file mode 100644 index 000000000..71fe1fd86 --- /dev/null +++ b/common-event-contract/src/main/java/com/baiyue/common/event/WalletSettlementReference.java @@ -0,0 +1,4 @@ +package com.baiyue.common.event; + +/** Finance-issued identity of a successful consumption, carried by its immutable fee source. */ +public record WalletSettlementReference(String commandEventId, String holdId, String ledgerId) { } diff --git a/deploy/sql/README.md b/deploy/sql/README.md index 881d90f8e..5eec1d2a4 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -1,5 +1,19 @@ # 单测试环境数据库空库初始化 +`migrations/overseas/baiyue_lastleg/V21__procurement_fee_confirmation_origin.sql` 增加采购费用核定入口,区分业务节点与财务操作,用于独立新增费用、替代核定及其重试权限。同步源基线和海外统一空库基线;不回填测试存量,不表示已执行。 + +`migrations/overseas/baiyue_lastleg/V20__procurement_exception_control.sql` 增加采购事实更正/阻断说明、异常关闭及逐来源最终去向三表。服务端保持原收货和发运记录,关闭不产生库存、退款或退货运输;需与采购详情聚合和节点阻断校验的匹配版本发布。源基线和海外统一基线同步,本文件存在不表示已执行。 + +`migrations/overseas/baiyue_lastleg/V19__procurement_fee_correction.sql` 保存费用链修正目标、原汇率及确认前净实扣,并关联逐笔正差额/退款费用。同步进入 Lastleg 源基线与海外统一空库基线,不新增资金账本,不重算或回填旧消费;只有匹配版本能够发送本次费用链请求。本文件存在不代表已执行。 + +采购实物与运输结构分别见 `migrations/overseas/baiyue_lastleg/V17__procurement_physical_facts.sql` 和 `V18__procurement_transport_facts.sql`:V17 保存分次实收、差异、装箱及两段整批交寄七表;V18 保存面单版本、逐箱贴单、预估核对和一对一换包装四表,并补齐采购附件的 LABEL/PURCHASE/BUDGET/EXCEPTION 分类约束。源基线及海外统一空库基线同步,节点权限统一维护于采购 V5 权限脚本。File 同时发布对应文件类型约束;不补写历史收货、装箱或发运事实,不代表已执行或已验收。 + +采购购买节点结构使用 `migrations/overseas/baiyue_lastleg/V16__procurement_purchase_facts.sql`,新增单批履约事实、节点幂等审计及预算授权历史三表。独立购买/取消权限使用 `migrations/cn/baiyue_iam/V5__procurement_node_permissions.sql`,源种子为 IAM `procurement-permissions.sql`,同步进入国内统一空库基线;只给现有平台管理员角色默认授权,不新增岗位。上述结构与权限必须随采购节点匹配版本发布,不补造旧订单购买事实,不代表已执行或已验收。 + +`migrations/overseas/baiyue_lastleg/V15__procurement_fee_versions.sql` 为采购费用增加用途、确认版本、原币汇率、原冻结及实际流水引用,并建立 `ll_procurement_wallet_attempt` 保存每次资金请求及最终结果。零元费用可以落核定事实但不生成资金命令;字段和约束与源基线、海外统一空库基线同步。执行前核对新增列、索引和表均未存在,先发布准备完整匹配版本;不回填旧费用、不释放或重扣测试存量。该脚本仅完成费用结构,后续履约表按相应变更单独核对。 + +`migrations/overseas/baiyue_lastleg/V14__procurement_wallet_reference.sql` 为采购已实扣费用来源新增 `wallet_settlement` JSON 引用,保存 Finance 原资金命令、冻结及消费流水身份。源基线与海外统一空库基线同步维护;指定测试环境执行前核对列不存在,不回填历史费用,不重新收费。需与 Finance 资金回执、费用关联及 Lastleg 采购生产端的匹配版本一起发布;本文件存在不表示已经执行。 + `reset-test-fba-appointments.sql` 仅用于明确授权的 TEST 预约模型重建:删除并按当前基线重建 `ll_inbound_fba_appointment`、`ll_inbound_fba_appointment_item`。它会丢弃旧预约及关联明细,但不修改订单、入仓计划、库存和排车。调用者必须核对测试连接并取得对这两张表的具体重置授权;不得作为自动启动脚本或用于生产。结构必须与匹配的 Lastleg 新版一同发布,失败立即停止,不对旧数据回填。权限从 IAM 种子核对;本文件存在不表示已执行。 `migrations/overseas/baiyue_lastleg/V13__widen_inbound_plan_creation_mode.sql` 仅将 `ll_inbound_plan.creation_mode` 从旧统一基线的 `VARCHAR(16)` 扩至模块基线已有的 `VARCHAR(32)`,容纳实际来货审核写入的 `WAREHOUSE_TRANSFER`(18字符)。保留 `utf8mb4_unicode_ci`、非空及 `AUTO` 默认值;不更新已有计划或状态。执行前核对目标库该列的长度、排序规则、可空性和默认值,已符合最终定义则核对后跳过;执行结果不确定时先只读复查,不盲目重试。统一空库基线同步最终定义,不重放模块历史初始化脚本。 @@ -15,7 +29,7 @@ | 区域 | 完整入口 | 服务 schema | 表数 | |---|---|---|---:| | 国内 | `头程数据库统一增量-MySQL8.sql` | `baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_finance` | 226 | -| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 159 | +| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 181 | - 所有服务位于同一测试环境;两份文件只分组组织服务 schema,不代表两个部署区域。`baiyue_lastlag_warehouse` 保留当前正式的 `lastlag` 拼写。 - 两份文件必须在同一测试数据库实例中完成初始化;业务服务仍只访问本服务 schema,经内部接口访问 IAM/Warehouse。已移除事件桥两个 schema 的创建,不对已有数据库执行 DROP。 @@ -37,7 +51,7 @@ 5. DDL 隐式提交,不可整体回滚。失败后停止并保留错误;经另行授权准备空库后从头执行,不针对半初始化库追加迁移或从失败行续跑。 6. Firstleg 测量项与拆分项互为外键:建空表期间暂设会话 `foreign_key_checks=0`,随后立即恢复 `1`,再插入种子。失败时关闭连接。两边外键完整保留在 CREATE TABLE 内。 -成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=226/157`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。 +成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=226/181`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。 ## 平台管理员密码 diff --git a/deploy/sql/migrations/cn/baiyue_iam/V5__procurement_node_permissions.sql b/deploy/sql/migrations/cn/baiyue_iam/V5__procurement_node_permissions.sql new file mode 100644 index 000000000..43e496c1b --- /dev/null +++ b/deploy/sql/migrations/cn/baiyue_iam/V5__procurement_node_permissions.sql @@ -0,0 +1,28 @@ +-- 采购节点动作独立授权;不新建岗位,不以通用编辑权限代替购买与取消。 +INSERT INTO iam_permission(id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,deleted) +VALUES + (5117103,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:buy','采购购买及货款核定',NULL,'POST',803,'ACTIVE',1,0), + (5117104,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:cancel','购买前取消采购订单',NULL,'POST',804,'ACTIVE',1,0), + (5117105,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:receive-overseas','采购海外收货',NULL,'POST',805,'ACTIVE',1,0), + (5117106,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:receive-domestic','采购国内收货',NULL,'POST',806,'ACTIVE',1,0), + (5117107,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:pack','采购装箱与确认',NULL,'POST',807,'ACTIVE',1,0), + (5117108,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:manage','采购差异处置与事实更正',NULL,'POST',808,'ACTIVE',1,0), + (5117109,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:label','采购面单维护',NULL,'POST',809,'ACTIVE',1,0), + (5117110,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:charge','采购国内核费',NULL,'POST',810,'ACTIVE',1,0), + (5117111,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:finance-adjust','采购财务调整',NULL,'POST',811,'ACTIVE',1,0), + (5117112,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:estimate','采购预估运费核对',NULL,'POST',812,'ACTIVE',1,0), + (5117113,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:apply-label','采购逐箱贴单',NULL,'POST',813,'ACTIVE',1,0), + (5117114,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:ship-overseas','采购海外整批交寄',NULL,'POST',814,'ACTIVE',1,0), + (5117115,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:ship-customer','采购国内寄客户',NULL,'POST',815,'ACTIVE',1,0) +ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name),status='ACTIVE',deleted=0; +INSERT IGNORE INTO iam_role_permission(tenant_id,role_id,permission_id,created_at) +SELECT 0,r.id,p.id,NOW(3) FROM iam_role r JOIN iam_permission p + ON p.tenant_id=0 AND p.app_code='PLATFORM' AND p.permission_code IN + ('platform:overseas-business:purchase:buy','platform:overseas-business:purchase:cancel', + 'platform:overseas-business:purchase:receive-overseas','platform:overseas-business:purchase:receive-domestic', + 'platform:overseas-business:purchase:pack','platform:overseas-business:purchase:manage', + 'platform:overseas-business:purchase:label','platform:overseas-business:purchase:charge','platform:overseas-business:purchase:finance-adjust', + 'platform:overseas-business:purchase:estimate','platform:overseas-business:purchase:apply-label', + 'platform:overseas-business:purchase:ship-overseas','platform:overseas-business:purchase:ship-customer') +WHERE r.tenant_id=0 AND r.app_code='PLATFORM' AND UPPER(r.role_code) IN('PLATFORM_ADMIN','PLATFORM_CUSTOM_ADMIN') + AND r.status='ACTIVE' AND r.deleted=0; diff --git a/deploy/sql/migrations/overseas/baiyue_lastleg/V14__procurement_wallet_reference.sql b/deploy/sql/migrations/overseas/baiyue_lastleg/V14__procurement_wallet_reference.sql new file mode 100644 index 000000000..544590859 --- /dev/null +++ b/deploy/sql/migrations/overseas/baiyue_lastleg/V14__procurement_wallet_reference.sql @@ -0,0 +1,5 @@ +-- 采购实扣费用来源关联;仅补充结构,不回填历史费用,不修改资金或业务记录。 +-- 执行前核对指定测试环境、baiyue_lastleg schema 与该列尚不存在。 +ALTER TABLE ll_finance_fee_source + ADD COLUMN wallet_settlement JSON NULL COMMENT '已实扣应收的Finance资金命令、冻结和消费流水引用' + AFTER payment_voucher_file_ids; diff --git a/deploy/sql/migrations/overseas/baiyue_lastleg/V15__procurement_fee_versions.sql b/deploy/sql/migrations/overseas/baiyue_lastleg/V15__procurement_fee_versions.sql new file mode 100644 index 000000000..1fcc56763 --- /dev/null +++ b/deploy/sql/migrations/overseas/baiyue_lastleg/V15__procurement_fee_versions.sql @@ -0,0 +1,48 @@ +-- 采购费用确认版本及资金尝试。仅变更结构,不回填、重扣或清理测试存量。 +ALTER TABLE ll_overseas_procurement_fee + ADD COLUMN purpose VARCHAR(32) NULL COMMENT '费用业务用途;由履约节点确定', + ADD COLUMN fee_version INT NULL COMMENT '同用途费用确认版本', + ADD COLUMN active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否当前有效版本', + ADD COLUMN determination VARCHAR(24) NULL COMMENT 'POSITIVE正额、ZERO零元、NOT_APPLICABLE不适用', + ADD COLUMN original_currency CHAR(3) NULL COMMENT '核定原币CNY或USD', + ADD COLUMN original_amount DECIMAL(20,2) NULL COMMENT '核定原币金额', + ADD COLUMN exchange_rate DECIMAL(20,6) NULL COMMENT '已确认汇率快照', + ADD COLUMN exchange_rate_month CHAR(7) NULL COMMENT '已确认汇率所属月份', + ADD COLUMN business_date DATE NULL COMMENT '实际费用业务日期', + ADD COLUMN wallet_business_id BIGINT NULL COMMENT '资金冻结对应采购费用记录', + ADD COLUMN wallet_business_no VARCHAR(128) NULL COMMENT '资金冻结业务号;实际国际运费引用原预估', + ADD COLUMN wallet_hold_id VARCHAR(64) NULL COMMENT 'Finance冻结引用', + ADD COLUMN wallet_ledger_id VARCHAR(64) NULL COMMENT 'Finance实际处理流水引用', + ADD COLUMN settled_command_event_id VARCHAR(64) NULL COMMENT '成功实扣或退款的资金命令事件', + ADD COLUMN source_fee_id BIGINT NULL COMMENT '已实扣费用来源ID', + ADD COLUMN parent_fee_id BIGINT NULL COMMENT '原费用或预估引用', + ADD COLUMN request_hash CHAR(64) NULL COMMENT '确认请求摘要', + MODIFY COLUMN voucher_file_id BIGINT NULL COMMENT '凭证文件ID;零元可空', + MODIFY COLUMN voucher_file_name VARCHAR(255) NULL COMMENT '凭证文件名;零元可空', + DROP CHECK chk_ll_procurement_fee_amount, + ADD CONSTRAINT chk_ll_procurement_fee_amount CHECK(amount>=0), + ADD UNIQUE KEY uk_ll_procurement_fee_version(order_id,purpose,fee_item_code,fee_version); + +CREATE TABLE ll_procurement_wallet_attempt ( + id BIGINT NOT NULL COMMENT '请求尝试ID', + order_id BIGINT NOT NULL COMMENT '采购订单ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', + fee_id BIGINT NOT NULL COMMENT '不可变费用版本ID', + attempt_no BIGINT NOT NULL COMMENT '费用版本内请求序号', + operation VARCHAR(32) NOT NULL COMMENT '资金操作:FREEZE冻结、SETTLE结算、SETTLE_ACTUAL实际结算、RELEASE释放、REFUND退款', + business_no VARCHAR(128) NOT NULL COMMENT 'Finance资金业务号', + amount DECIMAL(20,2) NULL COMMENT '请求金额;全额结算或释放可空', + command_event_id VARCHAR(64) NOT NULL COMMENT '资金指令事件唯一引用', + status VARCHAR(16) NOT NULL COMMENT '资金请求结果:PENDING未决、SUCCEEDED成功、FAILED明确失败', + result_hash CHAR(64) NULL COMMENT '原业务回执摘要', + wallet_hold_id VARCHAR(64) NULL COMMENT 'Finance冻结引用', + wallet_ledger_id VARCHAR(64) NULL COMMENT 'Finance处理流水引用', + error_code VARCHAR(64) NULL COMMENT '业务失败码', + error_message VARCHAR(500) NULL COMMENT '内部失败诊断,不直接对客展示', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '请求时间', + completed_at DATETIME(3) NULL COMMENT '最终结果时间', + PRIMARY KEY(id), UNIQUE KEY uk_procurement_attempt_event(command_event_id), + UNIQUE KEY uk_procurement_attempt_number(fee_id,attempt_no), + KEY idx_procurement_attempt_order(customer_id,order_id,status), + CONSTRAINT chk_procurement_attempt_status CHECK(status IN('PENDING','SUCCEEDED','FAILED')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购费用资金命令尝试与不可变结果'; diff --git a/deploy/sql/migrations/overseas/baiyue_lastleg/V16__procurement_purchase_facts.sql b/deploy/sql/migrations/overseas/baiyue_lastleg/V16__procurement_purchase_facts.sql new file mode 100644 index 000000000..61e7bf813 --- /dev/null +++ b/deploy/sql/migrations/overseas/baiyue_lastleg/V16__procurement_purchase_facts.sql @@ -0,0 +1,57 @@ +-- 采购节点事实与操作幂等;不补写历史订单的购买、授权或收货事实。 +CREATE TABLE ll_procurement_flow ( + order_id BIGINT NOT NULL COMMENT '采购订单ID,一单一个履约批次', + customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', + purchased_at DATETIME(3) NULL COMMENT '真实购买时间', + purchased_product_name VARCHAR(255) NULL COMMENT '实际购买商品', + purchased_specification VARCHAR(500) NULL COMMENT '实际购买规格', + purchased_quantity BIGINT NULL COMMENT '实际购买件数', + purchase_amount DECIMAL(20,2) NULL COMMENT '供应商实付原币金额', + purchase_currency CHAR(3) NULL COMMENT '供应商实付原币CNY或USD', + purchase_voucher_id BIGINT NULL COMMENT '购买凭证引用', + purchase_issue VARCHAR(500) NULL COMMENT '购买未核定或异常说明', + purchase_confirmed_at DATETIME(3) NULL COMMENT '货款核定时间,不代表扣费成功', + actual_warehouse_id BIGINT NULL COMMENT '首次实际收货冻结的仓库ID', + actual_warehouse_code VARCHAR(128) NULL COMMENT '实际收货仓编码快照', + actual_warehouse_name VARCHAR(255) NULL COMMENT '实际收货仓名称快照', + overseas_received_at DATETIME(3) NULL COMMENT '海外整批收货完成时间', + packing_version INT NULL COMMENT '当前确认装箱清单版本', + overseas_shipped_at DATETIME(3) NULL COMMENT '国际段整批实际交寄时间', + domestic_received_at DATETIME(3) NULL COMMENT '国内整批收货完成时间', + charges_confirmed_at DATETIME(3) NULL COMMENT '国内整单费用核定时间', + customer_shipped_at DATETIME(3) NULL COMMENT '国内寄客户实际交寄时间', + domestic_receiver_name VARCHAR(128) NULL COMMENT '国际段国内收件人', + domestic_receiver_phone VARCHAR(64) NULL COMMENT '国际段国内收件电话', + domestic_receiver_address VARCHAR(1000) NULL COMMENT '国际段国内接收地址', + cancelled_at DATETIME(3) NULL COMMENT '购买前取消时间', + closed_at DATETIME(3) NULL COMMENT '异常关闭时间', + control_reason VARCHAR(1000) NULL COMMENT '取消或关闭原因', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY(order_id), KEY idx_procurement_flow_customer(customer_id,order_id), + CONSTRAINT chk_procurement_purchase_quantity CHECK(purchased_quantity IS NULL OR purchased_quantity>0), + CONSTRAINT chk_procurement_purchase_amount CHECK(purchase_amount IS NULL OR purchase_amount>=0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购单一批次履约事实'; + +CREATE TABLE ll_procurement_command ( + id BIGINT NOT NULL COMMENT '操作记录ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', action VARCHAR(48) NOT NULL COMMENT '采购节点动作', + idempotency_key VARCHAR(128) NOT NULL COMMENT '操作幂等键', request_hash CHAR(64) NOT NULL COMMENT '请求摘要', + operator_id VARCHAR(128) NOT NULL COMMENT '执行人', reason VARCHAR(1000) NOT NULL COMMENT '操作依据', + detail_json JSON NOT NULL COMMENT '内部操作及更正依据快照,不直接对客展示', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '执行时间', + PRIMARY KEY(id), UNIQUE KEY uk_procurement_command(order_id,action,idempotency_key), + KEY idx_procurement_command_customer(customer_id,order_id,created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购节点幂等与不可变审计'; + +CREATE TABLE ll_procurement_budget_authorization ( + id BIGINT NOT NULL COMMENT '预算授权ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', + previous_amount DECIMAL(20,2) NULL COMMENT '原货款预算总上限;未授权保持空', previous_currency CHAR(3) NOT NULL COMMENT '原预算币种', + authorized_amount DECIMAL(20,2) NOT NULL COMMENT '客户授权的新货款总上限', currency CHAR(3) NOT NULL COMMENT '客户授权币种', + voucher_file_id BIGINT NOT NULL COMMENT '客户确认书面或聊天凭证', reason VARCHAR(1000) NOT NULL COMMENT '授权依据', + reviewed_by VARCHAR(128) NOT NULL COMMENT '核验人员', reviewed_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '核验时间', + PRIMARY KEY(id), KEY idx_procurement_budget_order(customer_id,order_id,id), + CONSTRAINT chk_procurement_budget_amount CHECK(authorized_amount>=0 AND previous_amount>=0), + CONSTRAINT chk_procurement_budget_currency CHECK(currency IN('CNY','USD') AND previous_currency IN('CNY','USD')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购货款预算授权核验历史'; diff --git a/deploy/sql/migrations/overseas/baiyue_lastleg/V17__procurement_physical_facts.sql b/deploy/sql/migrations/overseas/baiyue_lastleg/V17__procurement_physical_facts.sql new file mode 100644 index 000000000..084f7b810 --- /dev/null +++ b/deploy/sql/migrations/overseas/baiyue_lastleg/V17__procurement_physical_facts.sql @@ -0,0 +1,65 @@ +-- 采购实收、差异、装箱和两段整批发运事实。不产生WMS库存流水,不回填历史采购数量。 +CREATE TABLE ll_procurement_receipt ( + id BIGINT NOT NULL COMMENT '实收明细ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', + receipt_no VARCHAR(64) NOT NULL COMMENT '采购收货业务编号', leg VARCHAR(16) NOT NULL COMMENT 'OVERSEAS海外或DOMESTIC国内', + source_carton_id BIGINT NULL COMMENT '国内实收关联原国际交寄箱,海外为空', warehouse_id BIGINT NULL COMMENT '海外实际收货仓', + good_quantity BIGINT NOT NULL COMMENT '可发合格件数', damaged_quantity BIGINT NOT NULL COMMENT '破损待处置件数', extra_quantity BIGINT NOT NULL COMMENT '多到件数,不增加可发数量', + received_at DATETIME(3) NOT NULL COMMENT '实际收货时间', active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '当前有效实收事实', + supersedes_id BIGINT NULL COMMENT '受控更正所替代的原实收记录', created_by VARCHAR(128) NOT NULL COMMENT '登记人员', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '登记时间', + PRIMARY KEY(id), UNIQUE KEY uk_procurement_receipt_no(receipt_no), KEY idx_procurement_receipt_source(order_id,leg,source_carton_id,active), + CONSTRAINT chk_procurement_receipt_quantity CHECK(good_quantity>=0 AND damaged_quantity>=0 AND extra_quantity>=0 AND good_quantity+damaged_quantity+extra_quantity>0), + CONSTRAINT chk_procurement_receipt_leg CHECK((leg='OVERSEAS' AND source_carton_id IS NULL) OR (leg='DOMESTIC' AND source_carton_id IS NOT NULL)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购分次实收与受控更正事实'; + +CREATE TABLE ll_procurement_disposition ( + id BIGINT NOT NULL COMMENT '差异处理ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', + leg VARCHAR(16) NOT NULL COMMENT 'OVERSEAS海外或DOMESTIC国内', source_carton_id BIGINT NULL COMMENT '国内原交寄箱来源', + receipt_id BIGINT NULL COMMENT '破损或多货对应实收记录,短少为空', kind VARCHAR(24) NOT NULL COMMENT 'SHORTAGE短少、DAMAGED破损、EXTRA多货', + quantity BIGINT NOT NULL COMMENT '已批准处置件数', decision VARCHAR(32) NOT NULL COMMENT 'ACCEPT_SHORTAGE确认短少、DISPOSE_DAMAGED拒收或处置、RETURN_EXTRA退回多货', + active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '当前有效处置;撤销只改变有效性并保留原事实', + voucher_file_id BIGINT NOT NULL COMMENT '本单客户确认或实际处置凭证', reason VARCHAR(1000) NOT NULL COMMENT '处置依据', + created_by VARCHAR(128) NOT NULL COMMENT '采购管理人员', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '确认时间', + PRIMARY KEY(id), KEY idx_procurement_disposition_source(order_id,leg,source_carton_id,receipt_id,kind), + CONSTRAINT chk_procurement_disposition_quantity CHECK(quantity>0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购单内差异处置,不自动退款'; + +CREATE TABLE ll_procurement_manifest ( + id BIGINT NOT NULL COMMENT '装箱清单ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', + version INT NOT NULL COMMENT '订单内装箱版本', status VARCHAR(16) NOT NULL COMMENT 'DRAFT草稿、CONFIRMED已确认、SUPERSEDED已替代', + confirmed_at DATETIME(3) NULL COMMENT '确认时间', created_by VARCHAR(128) NOT NULL COMMENT '制单人', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '版本保存时间', + PRIMARY KEY(id), UNIQUE KEY uk_procurement_manifest_version(order_id,version), + CONSTRAINT chk_procurement_manifest_status CHECK(status IN('DRAFT','CONFIRMED','SUPERSEDED')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购装箱清单不可变版本'; + +CREATE TABLE ll_procurement_carton ( + id BIGINT NOT NULL COMMENT '版本内箱记录ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', manifest_id BIGINT NOT NULL COMMENT '装箱清单版本', + carton_no VARCHAR(64) NOT NULL COMMENT '采购订单内业务箱号', length_cm DECIMAL(12,2) NOT NULL COMMENT '外箱长厘米', + width_cm DECIMAL(12,2) NOT NULL COMMENT '外箱宽厘米', height_cm DECIMAL(12,2) NOT NULL COMMENT '外箱高厘米', + gross_weight_kg DECIMAL(12,3) NOT NULL COMMENT '封箱毛重千克', quantity BIGINT NOT NULL COMMENT '本箱合格商品总件数', + PRIMARY KEY(id), UNIQUE KEY uk_procurement_carton_version(manifest_id,carton_no), KEY idx_procurement_carton_order(order_id,manifest_id), + CONSTRAINT chk_procurement_carton_measurement CHECK(length_cm>0 AND width_cm>0 AND height_cm>0 AND gross_weight_kg>0 AND quantity>0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购版本化逐箱实测与件数'; + +CREATE TABLE ll_procurement_carton_source ( + carton_id BIGINT NOT NULL COMMENT '版本内箱ID', receipt_id BIGINT NOT NULL COMMENT '海外实收明细来源', quantity BIGINT NOT NULL COMMENT '从该实收装入本箱的件数', + PRIMARY KEY(carton_id,receipt_id), KEY idx_procurement_carton_receipt(receipt_id), CONSTRAINT chk_procurement_carton_source_quantity CHECK(quantity>0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购箱内货物的实收来源与数量'; + +CREATE TABLE ll_procurement_shipment ( + id BIGINT NOT NULL COMMENT '采购交寄ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', + shipment_no VARCHAR(64) NOT NULL COMMENT '交寄业务编号', leg VARCHAR(16) NOT NULL COMMENT 'OVERSEAS国际交寄或DOMESTIC寄客户', + manifest_id BIGINT NOT NULL COMMENT '原海外装箱清单版本', shipped_at DATETIME(3) NOT NULL COMMENT '整批真实交承运方时间', + address_snapshot JSON NOT NULL COMMENT '本段交寄时的收寄姓名电话地址快照', created_by VARCHAR(128) NOT NULL COMMENT '交寄确认人', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '确认时间', + PRIMARY KEY(id), UNIQUE KEY uk_procurement_shipment_leg(order_id,leg), UNIQUE KEY uk_procurement_shipment_no(shipment_no), + CONSTRAINT chk_procurement_shipment_leg CHECK(leg IN('OVERSEAS','DOMESTIC')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购两段各一次整批真实交寄'; + +CREATE TABLE ll_procurement_shipment_item ( + shipment_id BIGINT NOT NULL COMMENT '采购交寄ID', carton_id BIGINT NOT NULL COMMENT '原海外装箱版本内箱ID', quantity BIGINT NOT NULL COMMENT '本次实际交寄件数', + carrier VARCHAR(128) NOT NULL COMMENT '承运商快照', tracking_no VARCHAR(128) NOT NULL COMMENT '运单号快照', + label_snapshot JSON NOT NULL COMMENT '当次箱规、面单及货物来源快照', + PRIMARY KEY(shipment_id,carton_id), CONSTRAINT chk_procurement_shipment_item_quantity CHECK(quantity>0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购整批交寄逐箱事实'; diff --git a/deploy/sql/migrations/overseas/baiyue_lastleg/V18__procurement_transport_facts.sql b/deploy/sql/migrations/overseas/baiyue_lastleg/V18__procurement_transport_facts.sql new file mode 100644 index 000000000..cef0f7d88 --- /dev/null +++ b/deploy/sql/migrations/overseas/baiyue_lastleg/V18__procurement_transport_facts.sql @@ -0,0 +1,40 @@ +-- 采购面单、逐箱贴单、预估核对与国内一对一换包装;不重建历史发运事实。 +CREATE TABLE ll_procurement_label_set ( + id BIGINT NOT NULL COMMENT '面单批版本ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', + leg VARCHAR(16) NOT NULL COMMENT 'OVERSEAS国际段或DOMESTIC寄客户段', version INT NOT NULL COMMENT '订单段内面单版本', + manifest_id BIGINT NOT NULL COMMENT '原海外确认装箱版本', cargo_hash CHAR(64) NOT NULL COMMENT '箱规及有效货物来源摘要', + sender_snapshot JSON NOT NULL COMMENT '寄件人姓名电话地址快照', receiver_snapshot JSON NOT NULL COMMENT '收件人姓名电话地址快照', + created_by VARCHAR(128) NOT NULL COMMENT '制单操作人', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '上传确认时间', + PRIMARY KEY(id), UNIQUE KEY uk_procurement_label_version(order_id,leg,version), + CONSTRAINT chk_procurement_label_leg CHECK(leg IN('OVERSEAS','DOMESTIC')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购两段面单不可变版本和地址快照'; + +CREATE TABLE ll_procurement_label_item ( + label_set_id BIGINT NOT NULL COMMENT '面单批版本ID', carton_id BIGINT NOT NULL COMMENT '原海外清单箱ID', + file_id BIGINT NOT NULL COMMENT '本采购订单面单文件', page_no INT NOT NULL COMMENT '该箱对应文件页码,从1开始', + carrier VARCHAR(128) NOT NULL COMMENT '承运商', tracking_no VARCHAR(128) NOT NULL COMMENT '箱主或子运单号', + applied_at DATETIME(3) NULL COMMENT '真实贴单时间', applied_by VARCHAR(128) NULL COMMENT '贴单确认人', + PRIMARY KEY(label_set_id,carton_id), CONSTRAINT chk_procurement_label_page CHECK(page_no>0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购逐箱文件页关联及贴单事实'; + +CREATE TABLE ll_procurement_estimate_review ( + id BIGINT NOT NULL COMMENT '预估核对ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', label_set_id BIGINT NOT NULL COMMENT '已核对国际面单版本', + fee_id BIGINT NOT NULL COMMENT '唯一有效预估费用版本', reviewed_by VARCHAR(128) NOT NULL COMMENT '核价人员', + reviewed_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '核对时间', + PRIMARY KEY(id), UNIQUE KEY uk_procurement_estimate_review(label_set_id,fee_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='面单清单与预估冻结的显式核对关联'; + +CREATE TABLE ll_procurement_repack ( + id BIGINT NOT NULL COMMENT '换包装版本ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', carton_id BIGINT NOT NULL COMMENT '原国际交寄箱ID,保持一对一来源', + length_cm DECIMAL(12,2) NOT NULL COMMENT '新外箱长厘米', width_cm DECIMAL(12,2) NOT NULL COMMENT '新外箱宽厘米', + height_cm DECIMAL(12,2) NOT NULL COMMENT '新外箱高厘米', gross_weight_kg DECIMAL(12,3) NOT NULL COMMENT '新封箱毛重千克', + voucher_file_id BIGINT NOT NULL COMMENT '本单换包装证据', reason VARCHAR(1000) NOT NULL COMMENT '换包装依据', + created_by VARCHAR(128) NOT NULL COMMENT '国内作业人员', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '登记时间', + PRIMARY KEY(id), KEY idx_procurement_repack_carton(order_id,carton_id,id), + CONSTRAINT chk_procurement_repack_measurement CHECK(length_cm>0 AND width_cm>0 AND height_cm>0 AND gross_weight_kg>0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购国内原箱一对一换包装不可变历史'; + +ALTER TABLE ll_overseas_procurement_file + DROP CHECK chk_ll_procurement_file_category, + MODIFY COLUMN category VARCHAR(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'RECEIPT实收、VOUCHER费用、LABEL面单、PURCHASE采购、BUDGET预算、EXCEPTION异常凭证', + ADD CONSTRAINT chk_ll_procurement_file_category CHECK(category IN ('RECEIPT','VOUCHER','LABEL','PURCHASE','BUDGET','EXCEPTION')); diff --git a/deploy/sql/migrations/overseas/baiyue_lastleg/V19__procurement_fee_correction.sql b/deploy/sql/migrations/overseas/baiyue_lastleg/V19__procurement_fee_correction.sql new file mode 100644 index 000000000..a90a59396 --- /dev/null +++ b/deploy/sql/migrations/overseas/baiyue_lastleg/V19__procurement_fee_correction.sql @@ -0,0 +1,20 @@ +-- 费用链目标更正快照与关联;资金流水仍唯一归Finance,不回填旧费用。 +CREATE TABLE ll_procurement_fee_correction ( + id BIGINT NOT NULL COMMENT '费用链更正ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', + correction_no VARCHAR(64) NOT NULL COMMENT '财务更正业务号', root_fee_id BIGINT NOT NULL COMMENT '原实际费用链根', + idempotency_key VARCHAR(128) NOT NULL COMMENT '本单更正请求幂等键', request_hash CHAR(64) NOT NULL COMMENT '更正目标及凭证请求摘要', + target_original_amount DECIMAL(20,2) NOT NULL COMMENT '本次正确原币总额', original_currency CHAR(3) NOT NULL COMMENT '沿用原费用币种', + exchange_rate DECIMAL(20,6) NOT NULL COMMENT '沿用原费用汇率快照', exchange_rate_month CHAR(7) NOT NULL COMMENT '原汇率月份,不取本次操作月份', + target_amount DECIMAL(20,2) NOT NULL COMMENT '正确总额折算CNY', previous_net_amount DECIMAL(20,2) NOT NULL COMMENT '确认前整链净实扣CNY', + difference_amount DECIMAL(20,2) NOT NULL COMMENT 'CNY正差额追加、负差额退款、零差额仅留核对记录', + voucher_file_id BIGINT NOT NULL COMMENT '本订单财务更正凭证', reason VARCHAR(500) NOT NULL COMMENT '更正依据', + created_by VARCHAR(128) NOT NULL COMMENT '授权财务操作人', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '确认时间', + PRIMARY KEY(id), UNIQUE KEY uk_procurement_correction_no(correction_no), UNIQUE KEY uk_procurement_correction_request(order_id,idempotency_key), + KEY idx_procurement_correction_root(order_id,root_fee_id,id), + CONSTRAINT chk_procurement_correction_amount CHECK(target_original_amount>=0 AND target_amount>=0 AND previous_net_amount>=0 AND exchange_rate>0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购费用链更正目标与原汇率快照'; + +CREATE TABLE ll_procurement_fee_correction_item ( + correction_id BIGINT NOT NULL COMMENT '本次费用链更正ID', fee_id BIGINT NOT NULL COMMENT '本次产生的独立正差额或退款费用ID', + PRIMARY KEY(correction_id,fee_id), UNIQUE KEY uk_procurement_correction_fee(fee_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购费用目标更正与逐笔资金事实关联'; diff --git a/deploy/sql/migrations/overseas/baiyue_lastleg/V20__procurement_exception_control.sql b/deploy/sql/migrations/overseas/baiyue_lastleg/V20__procurement_exception_control.sql new file mode 100644 index 000000000..1014956d7 --- /dev/null +++ b/deploy/sql/migrations/overseas/baiyue_lastleg/V20__procurement_exception_control.sql @@ -0,0 +1,31 @@ +-- 单采购订单内的更正说明、阻断异常与关闭证据。不生成WMS库存或退货运输任务。 +CREATE TABLE ll_procurement_control_note ( + id BIGINT NOT NULL COMMENT '采购更正说明ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', + note_no VARCHAR(64) NOT NULL COMMENT '更正说明业务号', target_type VARCHAR(24) NOT NULL COMMENT '购买、仓库、实收或交寄事实类型', + target_id BIGINT NULL COMMENT '本单事实对象引用,购买为空', blocking TINYINT(1) NOT NULL COMMENT '是否阻断后续计划履约', + voucher_file_id BIGINT NOT NULL COMMENT '本单内部事实更正凭证', reason VARCHAR(1000) NOT NULL COMMENT '内部更正说明', + client_summary VARCHAR(500) NULL COMMENT '人工确认的对客摘要,不复制内部原始内容', + original_value_json JSON NULL COMMENT '服务端记录的原事实快照', corrected_value_json JSON NULL COMMENT '服务端记录的更正事实快照', + created_by VARCHAR(128) NOT NULL COMMENT '采购管理操作人', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '登记时间', + resolved_by VARCHAR(128) NULL COMMENT '处理确认人', resolved_at DATETIME(3) NULL COMMENT '阻断解除时间', + resolution_reason VARCHAR(1000) NULL COMMENT '内部处理结果', resolution_voucher_id BIGINT NULL COMMENT '本单处理凭证', + PRIMARY KEY(id), UNIQUE KEY uk_procurement_note_no(note_no), KEY idx_procurement_note_block(order_id,blocking,resolved_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购不可覆盖事实的更正说明及阻断异常'; + +CREATE TABLE ll_procurement_closure ( + id BIGINT NOT NULL COMMENT '采购异常关闭ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', + closure_no VARCHAR(64) NOT NULL COMMENT '异常关闭业务号', customer_voucher_file_id BIGINT NOT NULL COMMENT '客户同意停止履约凭证', + financial_conclusion VARCHAR(1000) NOT NULL COMMENT '已知费用及后续无需收费的处理结论,不改变资金事实', + reason VARCHAR(1000) NOT NULL COMMENT '内部关闭原因', client_summary VARCHAR(500) NOT NULL COMMENT '人工确认的对客关闭摘要', + created_by VARCHAR(128) NOT NULL COMMENT '采购管理确认人', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '关闭确认时间', + PRIMARY KEY(id), UNIQUE KEY uk_procurement_closure_order(order_id), UNIQUE KEY uk_procurement_closure_no(closure_no) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购实物资金收口后的异常关闭事实'; + +CREATE TABLE ll_procurement_closure_item ( + id BIGINT NOT NULL COMMENT '关闭货物处置ID', closure_id BIGINT NOT NULL COMMENT '采购关闭ID', + source_kind VARCHAR(32) NOT NULL COMMENT '购买未达、在途未达、合格、破损或多货来源', source_id BIGINT NULL COMMENT '原实收或国际交寄箱,购买未达为空', + quantity BIGINT NOT NULL COMMENT '本来源最终明确去向件数', decision VARCHAR(40) NOT NULL COMMENT '供应商保管、退还、报失、客户授权保管或处置', + destination VARCHAR(500) NOT NULL COMMENT '实际去向及接收方说明', voucher_file_id BIGINT NOT NULL COMMENT '本单实物处置证明', + PRIMARY KEY(id), KEY idx_procurement_closure_source(closure_id,source_kind,source_id), + CONSTRAINT chk_procurement_closure_quantity CHECK(quantity>0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='异常关闭逐来源剩余货物最终去向,不产生新发运'; diff --git a/deploy/sql/migrations/overseas/baiyue_lastleg/V21__procurement_fee_confirmation_origin.sql b/deploy/sql/migrations/overseas/baiyue_lastleg/V21__procurement_fee_confirmation_origin.sql new file mode 100644 index 000000000..1183a6083 --- /dev/null +++ b/deploy/sql/migrations/overseas/baiyue_lastleg/V21__procurement_fee_confirmation_origin.sql @@ -0,0 +1,4 @@ +-- 采购费用核定入口。仅结构变更,不回填旧财务事实,不重试或补扣测试存量。 +ALTER TABLE ll_overseas_procurement_fee + ADD COLUMN confirmation_origin VARCHAR(16) NOT NULL DEFAULT 'BUSINESS' COMMENT '核定入口:BUSINESS业务节点、FINANCIAL财务操作', + ADD CONSTRAINT chk_procurement_confirmation_origin CHECK(confirmation_origin IN('BUSINESS','FINANCIAL')); diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 65f30529d..860c8170b 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -466,6 +466,8 @@ CREATE TABLE sys_audit_outbox ( -- 服务 schema:baiyue_order CREATE DATABASE IF NOT EXISTS baiyue_order DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; + + USE baiyue_order; CREATE TABLE ord_customer_address ( @@ -8284,4 +8286,33 @@ CREATE TABLE event_delivery_attempt ( KEY idx_delivery_attempt_outcome (outcome,started_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='本服务业务事件逐次投递审计'; +USE baiyue_iam; +-- 采购节点动作独立授权;不新建岗位,不以通用编辑权限代替购买与取消。 +INSERT INTO iam_permission(id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,deleted) +VALUES + (5117103,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:buy','采购购买及货款核定',NULL,'POST',803,'ACTIVE',1,0), + (5117104,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:cancel','购买前取消采购订单',NULL,'POST',804,'ACTIVE',1,0), + (5117105,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:receive-overseas','采购海外收货',NULL,'POST',805,'ACTIVE',1,0), + (5117106,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:receive-domestic','采购国内收货',NULL,'POST',806,'ACTIVE',1,0), + (5117107,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:pack','采购装箱与确认',NULL,'POST',807,'ACTIVE',1,0), + (5117108,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:manage','采购差异处置与事实更正',NULL,'POST',808,'ACTIVE',1,0), + (5117109,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:label','采购面单维护',NULL,'POST',809,'ACTIVE',1,0), + (5117110,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:charge','采购国内核费',NULL,'POST',810,'ACTIVE',1,0), + (5117111,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:finance-adjust','采购财务调整',NULL,'POST',811,'ACTIVE',1,0), + (5117112,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:estimate','采购预估运费核对',NULL,'POST',812,'ACTIVE',1,0), + (5117113,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:apply-label','采购逐箱贴单',NULL,'POST',813,'ACTIVE',1,0), + (5117114,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:ship-overseas','采购海外整批交寄',NULL,'POST',814,'ACTIVE',1,0), + (5117115,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:ship-customer','采购国内寄客户',NULL,'POST',815,'ACTIVE',1,0); +INSERT INTO iam_role_permission(tenant_id,role_id,permission_id,created_at) +SELECT 0,r.id,p.id,NOW(3) FROM iam_role r JOIN iam_permission p + ON p.tenant_id=0 AND p.app_code='PLATFORM' AND p.permission_code IN + ('platform:overseas-business:purchase:buy','platform:overseas-business:purchase:cancel', + 'platform:overseas-business:purchase:receive-overseas','platform:overseas-business:purchase:receive-domestic', + 'platform:overseas-business:purchase:pack','platform:overseas-business:purchase:manage', + 'platform:overseas-business:purchase:label','platform:overseas-business:purchase:charge','platform:overseas-business:purchase:finance-adjust', + 'platform:overseas-business:purchase:estimate','platform:overseas-business:purchase:apply-label', + 'platform:overseas-business:purchase:ship-overseas','platform:overseas-business:purchase:ship-customer') +WHERE r.tenant_id=0 AND r.app_code='PLATFORM' AND UPPER(r.role_code) IN('PLATFORM_ADMIN','PLATFORM_CUSTOM_ADMIN') + AND r.status='ACTIVE' AND r.deleted=0; + SELECT 'PASS' AS initialization_status, 'CN' AS region, 226 AS initialized_tables; diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 4d87ae203..744b33f45 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -805,6 +805,7 @@ CREATE TABLE ll_finance_fee_source ( paid_at datetime(3) DEFAULT NULL COMMENT '直接已付时间', payment_method varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '直接已付方式', payment_reference_no varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '直接已付流水号', + wallet_settlement json DEFAULT NULL COMMENT '已实扣应收的Finance资金命令、冻结和消费流水引用', payment_voucher_file_ids json DEFAULT NULL COMMENT '直接已付凭证文件ID快照', business_date date NOT NULL COMMENT '费用业务日期', currency char(3) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'CNY' COMMENT '财务结算币种,固定CNY', @@ -1740,6 +1741,24 @@ CREATE TABLE ll_outbound_pda_task ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='一件代发出库PDA拣货任务'; CREATE TABLE ll_overseas_procurement_fee ( + purpose VARCHAR(32) NULL COMMENT '费用业务用途;由履约节点确定', + confirmation_origin VARCHAR(16) NOT NULL DEFAULT 'BUSINESS' COMMENT '核定入口:BUSINESS业务节点、FINANCIAL财务操作', + fee_version INT NULL COMMENT '同用途费用确认版本', + active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否当前有效版本', + determination VARCHAR(24) NULL COMMENT 'POSITIVE正额、ZERO零元、NOT_APPLICABLE不适用', + original_currency CHAR(3) NULL COMMENT '核定原币CNY或USD', + original_amount DECIMAL(20,2) NULL COMMENT '核定原币金额', + exchange_rate DECIMAL(20,6) NULL COMMENT '已确认汇率快照', + exchange_rate_month CHAR(7) NULL COMMENT '已确认汇率所属月份', + business_date DATE NULL COMMENT '实际费用业务日期', + wallet_business_id BIGINT NULL COMMENT '资金冻结对应采购费用记录', + wallet_business_no VARCHAR(128) NULL COMMENT '资金冻结业务号;实际国际运费引用原预估', + wallet_hold_id VARCHAR(64) NULL COMMENT 'Finance冻结引用', + wallet_ledger_id VARCHAR(64) NULL COMMENT 'Finance实际处理流水引用', + settled_command_event_id VARCHAR(64) NULL COMMENT '成功实扣或退款的资金命令事件', + source_fee_id BIGINT NULL COMMENT '已实扣费用来源ID', + parent_fee_id BIGINT NULL COMMENT '原费用或预估引用', + request_hash CHAR(64) NULL COMMENT '确认请求摘要', id bigint NOT NULL COMMENT '主键ID', order_id bigint NOT NULL COMMENT '采购订单ID', customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID', @@ -1748,8 +1767,8 @@ CREATE TABLE ll_overseas_procurement_fee ( fee_item_name varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '费用项名称', amount decimal(20,2) NOT NULL COMMENT 'CNY金额', currency char(3) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'CNY' COMMENT '费用币种', - voucher_file_id bigint NOT NULL COMMENT '凭证文件ID', - voucher_file_name varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '凭证文件名', + voucher_file_id bigint NULL COMMENT '凭证文件ID', + voucher_file_name varchar(255) COLLATE utf8mb4_unicode_ci NULL COMMENT '凭证文件名', remark varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '说明', wallet_status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '资金状态', wallet_error_code varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '资金错误码', @@ -1758,10 +1777,12 @@ CREATE TABLE ll_overseas_procurement_fee ( created_by varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '登记人', created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + CONSTRAINT chk_procurement_confirmation_origin CHECK(confirmation_origin IN('BUSINESS','FINANCIAL')), + UNIQUE KEY uk_ll_procurement_fee_version(order_id,purpose,fee_item_code,fee_version), PRIMARY KEY (id), UNIQUE KEY uk_ll_procurement_fee_no (fee_no), KEY idx_ll_procurement_fee_order (customer_id,order_id,created_at), - CONSTRAINT chk_ll_procurement_fee_amount CHECK ((amount > 0)), + CONSTRAINT chk_ll_procurement_fee_amount CHECK ((amount >= 0)), CONSTRAINT chk_ll_procurement_fee_currency CHECK ((currency = _utf8mb4'CNY')) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外采购费用明细及资金生命周期'; @@ -1769,7 +1790,7 @@ CREATE TABLE ll_overseas_procurement_file ( id bigint NOT NULL COMMENT '主键ID', order_id bigint NOT NULL COMMENT '采购订单ID', customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID', - category varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'RECEIPT签收文件或VOUCHER费用凭证', + category varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'RECEIPT实收、VOUCHER费用、LABEL面单、PURCHASE采购、BUDGET预算、EXCEPTION异常凭证', file_id bigint NOT NULL COMMENT 'File服务文件ID', file_name varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '文件名快照', content_type varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '媒体类型', @@ -1779,7 +1800,7 @@ CREATE TABLE ll_overseas_procurement_file ( PRIMARY KEY (id), UNIQUE KEY uk_ll_procurement_file (order_id,file_id), KEY idx_ll_procurement_file_order (customer_id,order_id,category,created_at), - CONSTRAINT chk_ll_procurement_file_category CHECK ((category in (_utf8mb4'RECEIPT',_utf8mb4'VOUCHER'))) + CONSTRAINT chk_ll_procurement_file_category CHECK (category IN ('RECEIPT','VOUCHER','LABEL','PURCHASE','BUDGET','EXCEPTION')) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外采购签收文件及费用凭证'; CREATE TABLE ll_overseas_procurement_order ( @@ -1799,12 +1820,7 @@ CREATE TABLE ll_overseas_procurement_order ( contact_phone varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '联系人电话', purchase_warehouse_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '采购入仓仓库ID', purchase_warehouse_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '采购入仓仓库名称', - outbound_details json DEFAULT NULL COMMENT '出库明细', inbound_quantity bigint NOT NULL DEFAULT '0' COMMENT '采购单据累计入库数量', - domestic_carrier varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '境内承运商', - domestic_tracking_no varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '境内物流单号', - overseas_carrier varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '海外承运商', - overseas_tracking_no varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '海外物流单号', estimated_arrival_at datetime(3) DEFAULT NULL COMMENT '预计到达时间', status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PENDING_REVIEW' COMMENT '业务状态', remark varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注', @@ -3625,4 +3641,244 @@ CREATE TABLE ll_return_carton_count_session ( UNIQUE KEY uk_ll_return_count_session_receipt(receipt_id) ) ENGINE=InnoDB COMMENT='退货箱内清点单人单任务持久会话'; -SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 161 AS initialized_tables; +CREATE TABLE ll_procurement_wallet_attempt ( + id BIGINT NOT NULL COMMENT '请求尝试ID', + order_id BIGINT NOT NULL COMMENT '采购订单ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', + fee_id BIGINT NOT NULL COMMENT '不可变费用版本ID', + attempt_no BIGINT NOT NULL COMMENT '费用版本内请求序号', + operation VARCHAR(32) NOT NULL COMMENT '资金操作:FREEZE冻结、SETTLE结算、SETTLE_ACTUAL实际结算、RELEASE释放、REFUND退款', + business_no VARCHAR(128) NOT NULL COMMENT 'Finance资金业务号', + amount DECIMAL(20,2) NULL COMMENT '请求金额;全额结算或释放可空', + command_event_id VARCHAR(64) NOT NULL COMMENT '资金指令事件唯一引用', + status VARCHAR(16) NOT NULL COMMENT '资金请求结果:PENDING未决、SUCCEEDED成功、FAILED明确失败', + result_hash CHAR(64) NULL COMMENT '原业务回执摘要', + wallet_hold_id VARCHAR(64) NULL COMMENT 'Finance冻结引用', + wallet_ledger_id VARCHAR(64) NULL COMMENT 'Finance处理流水引用', + error_code VARCHAR(64) NULL COMMENT '业务失败码', + error_message VARCHAR(500) NULL COMMENT '内部失败诊断,不直接对客展示', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '请求时间', + completed_at DATETIME(3) NULL COMMENT '最终结果时间', + PRIMARY KEY(id), UNIQUE KEY uk_procurement_attempt_event(command_event_id), + UNIQUE KEY uk_procurement_attempt_number(fee_id,attempt_no), + KEY idx_procurement_attempt_order(customer_id,order_id,status), + CONSTRAINT chk_procurement_attempt_status CHECK(status IN('PENDING','SUCCEEDED','FAILED')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购费用资金命令尝试与不可变结果'; + +-- 采购节点事实与操作幂等;不补写历史订单的购买、授权或收货事实。 +CREATE TABLE ll_procurement_flow ( + order_id BIGINT NOT NULL COMMENT '采购订单ID,一单一个履约批次', + customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', + purchased_at DATETIME(3) NULL COMMENT '真实购买时间', + purchased_product_name VARCHAR(255) NULL COMMENT '实际购买商品', + purchased_specification VARCHAR(500) NULL COMMENT '实际购买规格', + purchased_quantity BIGINT NULL COMMENT '实际购买件数', + purchase_amount DECIMAL(20,2) NULL COMMENT '供应商实付原币金额', + purchase_currency CHAR(3) NULL COMMENT '供应商实付原币CNY或USD', + purchase_voucher_id BIGINT NULL COMMENT '购买凭证引用', + purchase_issue VARCHAR(500) NULL COMMENT '购买未核定或异常说明', + purchase_confirmed_at DATETIME(3) NULL COMMENT '货款核定时间,不代表扣费成功', + actual_warehouse_id BIGINT NULL COMMENT '首次实际收货冻结的仓库ID', + actual_warehouse_code VARCHAR(128) NULL COMMENT '实际收货仓编码快照', + actual_warehouse_name VARCHAR(255) NULL COMMENT '实际收货仓名称快照', + overseas_received_at DATETIME(3) NULL COMMENT '海外整批收货完成时间', + packing_version INT NULL COMMENT '当前确认装箱清单版本', + overseas_shipped_at DATETIME(3) NULL COMMENT '国际段整批实际交寄时间', + domestic_received_at DATETIME(3) NULL COMMENT '国内整批收货完成时间', + charges_confirmed_at DATETIME(3) NULL COMMENT '国内整单费用核定时间', + customer_shipped_at DATETIME(3) NULL COMMENT '国内寄客户实际交寄时间', + domestic_receiver_name VARCHAR(128) NULL COMMENT '国际段国内收件人', + domestic_receiver_phone VARCHAR(64) NULL COMMENT '国际段国内收件电话', + domestic_receiver_address VARCHAR(1000) NULL COMMENT '国际段国内接收地址', + cancelled_at DATETIME(3) NULL COMMENT '购买前取消时间', + closed_at DATETIME(3) NULL COMMENT '异常关闭时间', + control_reason VARCHAR(1000) NULL COMMENT '取消或关闭原因', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY(order_id), KEY idx_procurement_flow_customer(customer_id,order_id), + CONSTRAINT chk_procurement_purchase_quantity CHECK(purchased_quantity IS NULL OR purchased_quantity>0), + CONSTRAINT chk_procurement_purchase_amount CHECK(purchase_amount IS NULL OR purchase_amount>=0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购单一批次履约事实'; + +CREATE TABLE ll_procurement_command ( + id BIGINT NOT NULL COMMENT '操作记录ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', action VARCHAR(48) NOT NULL COMMENT '采购节点动作', + idempotency_key VARCHAR(128) NOT NULL COMMENT '操作幂等键', request_hash CHAR(64) NOT NULL COMMENT '请求摘要', + operator_id VARCHAR(128) NOT NULL COMMENT '执行人', reason VARCHAR(1000) NOT NULL COMMENT '操作依据', + detail_json JSON NOT NULL COMMENT '内部操作及更正依据快照,不直接对客展示', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '执行时间', + PRIMARY KEY(id), UNIQUE KEY uk_procurement_command(order_id,action,idempotency_key), + KEY idx_procurement_command_customer(customer_id,order_id,created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购节点幂等与不可变审计'; + +CREATE TABLE ll_procurement_budget_authorization ( + id BIGINT NOT NULL COMMENT '预算授权ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', + previous_amount DECIMAL(20,2) NULL COMMENT '原货款预算总上限;未授权保持空', previous_currency CHAR(3) NOT NULL COMMENT '原预算币种', + authorized_amount DECIMAL(20,2) NOT NULL COMMENT '客户授权的新货款总上限', currency CHAR(3) NOT NULL COMMENT '客户授权币种', + voucher_file_id BIGINT NOT NULL COMMENT '客户确认书面或聊天凭证', reason VARCHAR(1000) NOT NULL COMMENT '授权依据', + reviewed_by VARCHAR(128) NOT NULL COMMENT '核验人员', reviewed_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '核验时间', + PRIMARY KEY(id), KEY idx_procurement_budget_order(customer_id,order_id,id), + CONSTRAINT chk_procurement_budget_amount CHECK(authorized_amount>=0 AND previous_amount>=0), + CONSTRAINT chk_procurement_budget_currency CHECK(currency IN('CNY','USD') AND previous_currency IN('CNY','USD')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购货款预算授权核验历史'; + +-- 采购实收、差异、装箱和两段整批发运事实。不产生WMS库存流水,不回填历史采购数量。 +CREATE TABLE ll_procurement_receipt ( + id BIGINT NOT NULL COMMENT '实收明细ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', + receipt_no VARCHAR(64) NOT NULL COMMENT '采购收货业务编号', leg VARCHAR(16) NOT NULL COMMENT 'OVERSEAS海外或DOMESTIC国内', + source_carton_id BIGINT NULL COMMENT '国内实收关联原国际交寄箱,海外为空', warehouse_id BIGINT NULL COMMENT '海外实际收货仓', + good_quantity BIGINT NOT NULL COMMENT '可发合格件数', damaged_quantity BIGINT NOT NULL COMMENT '破损待处置件数', extra_quantity BIGINT NOT NULL COMMENT '多到件数,不增加可发数量', + received_at DATETIME(3) NOT NULL COMMENT '实际收货时间', active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '当前有效实收事实', + supersedes_id BIGINT NULL COMMENT '受控更正所替代的原实收记录', created_by VARCHAR(128) NOT NULL COMMENT '登记人员', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '登记时间', + PRIMARY KEY(id), UNIQUE KEY uk_procurement_receipt_no(receipt_no), KEY idx_procurement_receipt_source(order_id,leg,source_carton_id,active), + CONSTRAINT chk_procurement_receipt_quantity CHECK(good_quantity>=0 AND damaged_quantity>=0 AND extra_quantity>=0 AND good_quantity+damaged_quantity+extra_quantity>0), + CONSTRAINT chk_procurement_receipt_leg CHECK((leg='OVERSEAS' AND source_carton_id IS NULL) OR (leg='DOMESTIC' AND source_carton_id IS NOT NULL)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购分次实收与受控更正事实'; + +CREATE TABLE ll_procurement_disposition ( + id BIGINT NOT NULL COMMENT '差异处理ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', + leg VARCHAR(16) NOT NULL COMMENT 'OVERSEAS海外或DOMESTIC国内', source_carton_id BIGINT NULL COMMENT '国内原交寄箱来源', + receipt_id BIGINT NULL COMMENT '破损或多货对应实收记录,短少为空', kind VARCHAR(24) NOT NULL COMMENT 'SHORTAGE短少、DAMAGED破损、EXTRA多货', + quantity BIGINT NOT NULL COMMENT '已批准处置件数', decision VARCHAR(32) NOT NULL COMMENT 'ACCEPT_SHORTAGE确认短少、DISPOSE_DAMAGED拒收或处置、RETURN_EXTRA退回多货', + active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '当前有效处置;撤销只改变有效性并保留原事实', + voucher_file_id BIGINT NOT NULL COMMENT '本单客户确认或实际处置凭证', reason VARCHAR(1000) NOT NULL COMMENT '处置依据', + created_by VARCHAR(128) NOT NULL COMMENT '采购管理人员', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '确认时间', + PRIMARY KEY(id), KEY idx_procurement_disposition_source(order_id,leg,source_carton_id,receipt_id,kind), + CONSTRAINT chk_procurement_disposition_quantity CHECK(quantity>0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购单内差异处置,不自动退款'; + +CREATE TABLE ll_procurement_manifest ( + id BIGINT NOT NULL COMMENT '装箱清单ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', + version INT NOT NULL COMMENT '订单内装箱版本', status VARCHAR(16) NOT NULL COMMENT 'DRAFT草稿、CONFIRMED已确认、SUPERSEDED已替代', + confirmed_at DATETIME(3) NULL COMMENT '确认时间', created_by VARCHAR(128) NOT NULL COMMENT '制单人', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '版本保存时间', + PRIMARY KEY(id), UNIQUE KEY uk_procurement_manifest_version(order_id,version), + CONSTRAINT chk_procurement_manifest_status CHECK(status IN('DRAFT','CONFIRMED','SUPERSEDED')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购装箱清单不可变版本'; + +CREATE TABLE ll_procurement_carton ( + id BIGINT NOT NULL COMMENT '版本内箱记录ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', manifest_id BIGINT NOT NULL COMMENT '装箱清单版本', + carton_no VARCHAR(64) NOT NULL COMMENT '采购订单内业务箱号', length_cm DECIMAL(12,2) NOT NULL COMMENT '外箱长厘米', + width_cm DECIMAL(12,2) NOT NULL COMMENT '外箱宽厘米', height_cm DECIMAL(12,2) NOT NULL COMMENT '外箱高厘米', + gross_weight_kg DECIMAL(12,3) NOT NULL COMMENT '封箱毛重千克', quantity BIGINT NOT NULL COMMENT '本箱合格商品总件数', + PRIMARY KEY(id), UNIQUE KEY uk_procurement_carton_version(manifest_id,carton_no), KEY idx_procurement_carton_order(order_id,manifest_id), + CONSTRAINT chk_procurement_carton_measurement CHECK(length_cm>0 AND width_cm>0 AND height_cm>0 AND gross_weight_kg>0 AND quantity>0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购版本化逐箱实测与件数'; + +CREATE TABLE ll_procurement_carton_source ( + carton_id BIGINT NOT NULL COMMENT '版本内箱ID', receipt_id BIGINT NOT NULL COMMENT '海外实收明细来源', quantity BIGINT NOT NULL COMMENT '从该实收装入本箱的件数', + PRIMARY KEY(carton_id,receipt_id), KEY idx_procurement_carton_receipt(receipt_id), CONSTRAINT chk_procurement_carton_source_quantity CHECK(quantity>0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购箱内货物的实收来源与数量'; + +CREATE TABLE ll_procurement_shipment ( + id BIGINT NOT NULL COMMENT '采购交寄ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', + shipment_no VARCHAR(64) NOT NULL COMMENT '交寄业务编号', leg VARCHAR(16) NOT NULL COMMENT 'OVERSEAS国际交寄或DOMESTIC寄客户', + manifest_id BIGINT NOT NULL COMMENT '原海外装箱清单版本', shipped_at DATETIME(3) NOT NULL COMMENT '整批真实交承运方时间', + address_snapshot JSON NOT NULL COMMENT '本段交寄时的收寄姓名电话地址快照', created_by VARCHAR(128) NOT NULL COMMENT '交寄确认人', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '确认时间', + PRIMARY KEY(id), UNIQUE KEY uk_procurement_shipment_leg(order_id,leg), UNIQUE KEY uk_procurement_shipment_no(shipment_no), + CONSTRAINT chk_procurement_shipment_leg CHECK(leg IN('OVERSEAS','DOMESTIC')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购两段各一次整批真实交寄'; + +CREATE TABLE ll_procurement_shipment_item ( + shipment_id BIGINT NOT NULL COMMENT '采购交寄ID', carton_id BIGINT NOT NULL COMMENT '原海外装箱版本内箱ID', quantity BIGINT NOT NULL COMMENT '本次实际交寄件数', + carrier VARCHAR(128) NOT NULL COMMENT '承运商快照', tracking_no VARCHAR(128) NOT NULL COMMENT '运单号快照', + label_snapshot JSON NOT NULL COMMENT '当次箱规、面单及货物来源快照', + PRIMARY KEY(shipment_id,carton_id), CONSTRAINT chk_procurement_shipment_item_quantity CHECK(quantity>0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购整批交寄逐箱事实'; + +USE baiyue_lastleg; +-- 采购面单、逐箱贴单、预估核对与国内一对一换包装;不重建历史发运事实。 +CREATE TABLE ll_procurement_label_set ( + id BIGINT NOT NULL COMMENT '面单批版本ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', + leg VARCHAR(16) NOT NULL COMMENT 'OVERSEAS国际段或DOMESTIC寄客户段', version INT NOT NULL COMMENT '订单段内面单版本', + manifest_id BIGINT NOT NULL COMMENT '原海外确认装箱版本', cargo_hash CHAR(64) NOT NULL COMMENT '箱规及有效货物来源摘要', + sender_snapshot JSON NOT NULL COMMENT '寄件人姓名电话地址快照', receiver_snapshot JSON NOT NULL COMMENT '收件人姓名电话地址快照', + created_by VARCHAR(128) NOT NULL COMMENT '制单操作人', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '上传确认时间', + PRIMARY KEY(id), UNIQUE KEY uk_procurement_label_version(order_id,leg,version), + CONSTRAINT chk_procurement_label_leg CHECK(leg IN('OVERSEAS','DOMESTIC')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购两段面单不可变版本和地址快照'; + +CREATE TABLE ll_procurement_label_item ( + label_set_id BIGINT NOT NULL COMMENT '面单批版本ID', carton_id BIGINT NOT NULL COMMENT '原海外清单箱ID', + file_id BIGINT NOT NULL COMMENT '本采购订单面单文件', page_no INT NOT NULL COMMENT '该箱对应文件页码,从1开始', + carrier VARCHAR(128) NOT NULL COMMENT '承运商', tracking_no VARCHAR(128) NOT NULL COMMENT '箱主或子运单号', + applied_at DATETIME(3) NULL COMMENT '真实贴单时间', applied_by VARCHAR(128) NULL COMMENT '贴单确认人', + PRIMARY KEY(label_set_id,carton_id), CONSTRAINT chk_procurement_label_page CHECK(page_no>0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购逐箱文件页关联及贴单事实'; + +CREATE TABLE ll_procurement_estimate_review ( + id BIGINT NOT NULL COMMENT '预估核对ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', label_set_id BIGINT NOT NULL COMMENT '已核对国际面单版本', + fee_id BIGINT NOT NULL COMMENT '唯一有效预估费用版本', reviewed_by VARCHAR(128) NOT NULL COMMENT '核价人员', + reviewed_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '核对时间', + PRIMARY KEY(id), UNIQUE KEY uk_procurement_estimate_review(label_set_id,fee_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='面单清单与预估冻结的显式核对关联'; + +CREATE TABLE ll_procurement_repack ( + id BIGINT NOT NULL COMMENT '换包装版本ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', carton_id BIGINT NOT NULL COMMENT '原国际交寄箱ID,保持一对一来源', + length_cm DECIMAL(12,2) NOT NULL COMMENT '新外箱长厘米', width_cm DECIMAL(12,2) NOT NULL COMMENT '新外箱宽厘米', + height_cm DECIMAL(12,2) NOT NULL COMMENT '新外箱高厘米', gross_weight_kg DECIMAL(12,3) NOT NULL COMMENT '新封箱毛重千克', + voucher_file_id BIGINT NOT NULL COMMENT '本单换包装证据', reason VARCHAR(1000) NOT NULL COMMENT '换包装依据', + created_by VARCHAR(128) NOT NULL COMMENT '国内作业人员', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '登记时间', + PRIMARY KEY(id), KEY idx_procurement_repack_carton(order_id,carton_id,id), + CONSTRAINT chk_procurement_repack_measurement CHECK(length_cm>0 AND width_cm>0 AND height_cm>0 AND gross_weight_kg>0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购国内原箱一对一换包装不可变历史'; + +USE baiyue_lastleg; +-- 费用链目标更正快照与关联;资金流水仍唯一归Finance,不回填旧费用。 +CREATE TABLE ll_procurement_fee_correction ( + id BIGINT NOT NULL COMMENT '费用链更正ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', + correction_no VARCHAR(64) NOT NULL COMMENT '财务更正业务号', root_fee_id BIGINT NOT NULL COMMENT '原实际费用链根', + idempotency_key VARCHAR(128) NOT NULL COMMENT '本单更正请求幂等键', request_hash CHAR(64) NOT NULL COMMENT '更正目标及凭证请求摘要', + target_original_amount DECIMAL(20,2) NOT NULL COMMENT '本次正确原币总额', original_currency CHAR(3) NOT NULL COMMENT '沿用原费用币种', + exchange_rate DECIMAL(20,6) NOT NULL COMMENT '沿用原费用汇率快照', exchange_rate_month CHAR(7) NOT NULL COMMENT '原汇率月份,不取本次操作月份', + target_amount DECIMAL(20,2) NOT NULL COMMENT '正确总额折算CNY', previous_net_amount DECIMAL(20,2) NOT NULL COMMENT '确认前整链净实扣CNY', + difference_amount DECIMAL(20,2) NOT NULL COMMENT 'CNY正差额追加、负差额退款、零差额仅留核对记录', + voucher_file_id BIGINT NOT NULL COMMENT '本订单财务更正凭证', reason VARCHAR(500) NOT NULL COMMENT '更正依据', + created_by VARCHAR(128) NOT NULL COMMENT '授权财务操作人', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '确认时间', + PRIMARY KEY(id), UNIQUE KEY uk_procurement_correction_no(correction_no), UNIQUE KEY uk_procurement_correction_request(order_id,idempotency_key), + KEY idx_procurement_correction_root(order_id,root_fee_id,id), + CONSTRAINT chk_procurement_correction_amount CHECK(target_original_amount>=0 AND target_amount>=0 AND previous_net_amount>=0 AND exchange_rate>0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购费用链更正目标与原汇率快照'; + +CREATE TABLE ll_procurement_fee_correction_item ( + correction_id BIGINT NOT NULL COMMENT '本次费用链更正ID', fee_id BIGINT NOT NULL COMMENT '本次产生的独立正差额或退款费用ID', + PRIMARY KEY(correction_id,fee_id), UNIQUE KEY uk_procurement_correction_fee(fee_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购费用目标更正与逐笔资金事实关联'; + +USE baiyue_lastleg; +-- 单采购订单内的更正说明、阻断异常与关闭证据。不生成WMS库存或退货运输任务。 +CREATE TABLE ll_procurement_control_note ( + id BIGINT NOT NULL COMMENT '采购更正说明ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', + note_no VARCHAR(64) NOT NULL COMMENT '更正说明业务号', target_type VARCHAR(24) NOT NULL COMMENT '购买、仓库、实收或交寄事实类型', + target_id BIGINT NULL COMMENT '本单事实对象引用,购买为空', blocking TINYINT(1) NOT NULL COMMENT '是否阻断后续计划履约', + voucher_file_id BIGINT NOT NULL COMMENT '本单内部事实更正凭证', reason VARCHAR(1000) NOT NULL COMMENT '内部更正说明', + client_summary VARCHAR(500) NULL COMMENT '人工确认的对客摘要,不复制内部原始内容', + original_value_json JSON NULL COMMENT '服务端记录的原事实快照', corrected_value_json JSON NULL COMMENT '服务端记录的更正事实快照', + created_by VARCHAR(128) NOT NULL COMMENT '采购管理操作人', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '登记时间', + resolved_by VARCHAR(128) NULL COMMENT '处理确认人', resolved_at DATETIME(3) NULL COMMENT '阻断解除时间', + resolution_reason VARCHAR(1000) NULL COMMENT '内部处理结果', resolution_voucher_id BIGINT NULL COMMENT '本单处理凭证', + PRIMARY KEY(id), UNIQUE KEY uk_procurement_note_no(note_no), KEY idx_procurement_note_block(order_id,blocking,resolved_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购不可覆盖事实的更正说明及阻断异常'; + +CREATE TABLE ll_procurement_closure ( + id BIGINT NOT NULL COMMENT '采购异常关闭ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', + closure_no VARCHAR(64) NOT NULL COMMENT '异常关闭业务号', customer_voucher_file_id BIGINT NOT NULL COMMENT '客户同意停止履约凭证', + financial_conclusion VARCHAR(1000) NOT NULL COMMENT '已知费用及后续无需收费的处理结论,不改变资金事实', + reason VARCHAR(1000) NOT NULL COMMENT '内部关闭原因', client_summary VARCHAR(500) NOT NULL COMMENT '人工确认的对客关闭摘要', + created_by VARCHAR(128) NOT NULL COMMENT '采购管理确认人', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '关闭确认时间', + PRIMARY KEY(id), UNIQUE KEY uk_procurement_closure_order(order_id), UNIQUE KEY uk_procurement_closure_no(closure_no) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购实物资金收口后的异常关闭事实'; + +CREATE TABLE ll_procurement_closure_item ( + id BIGINT NOT NULL COMMENT '关闭货物处置ID', closure_id BIGINT NOT NULL COMMENT '采购关闭ID', + source_kind VARCHAR(32) NOT NULL COMMENT '购买未达、在途未达、合格、破损或多货来源', source_id BIGINT NULL COMMENT '原实收或国际交寄箱,购买未达为空', + quantity BIGINT NOT NULL COMMENT '本来源最终明确去向件数', decision VARCHAR(40) NOT NULL COMMENT '供应商保管、退还、报失、客户授权保管或处置', + destination VARCHAR(500) NOT NULL COMMENT '实际去向及接收方说明', voucher_file_id BIGINT NOT NULL COMMENT '本单实物处置证明', + PRIMARY KEY(id), KEY idx_procurement_closure_source(closure_id,source_kind,source_id), + CONSTRAINT chk_procurement_closure_quantity CHECK(quantity>0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='异常关闭逐来源剩余货物最终去向,不产生新发运'; + +SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 181 AS initialized_tables; diff --git a/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java b/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java index 7bc39d2e8..d88862760 100644 --- a/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java +++ b/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java @@ -22,6 +22,10 @@ enum BusinessFileCategory { INBOUND_HOLD_EVIDENCE(true, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_PROCUREMENT_RECEIPT(true, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_PROCUREMENT_VOUCHER(true, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), + OVERSEAS_PROCUREMENT_LABEL(true, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), + OVERSEAS_PROCUREMENT_PURCHASE(true, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), + OVERSEAS_PROCUREMENT_BUDGET(true, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), + OVERSEAS_PROCUREMENT_EXCEPTION(true, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), CONTAINER_CLEARANCE_DOCUMENT(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), LOADING_CUSTOMS_DECLARATION(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), CONTAINER_DELIVERY_ORDER(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), diff --git a/file/src/test/java/com/baiyue/file/service/ProcurementFilePolicyTest.java b/file/src/test/java/com/baiyue/file/service/ProcurementFilePolicyTest.java new file mode 100644 index 000000000..04c7b8864 --- /dev/null +++ b/file/src/test/java/com/baiyue/file/service/ProcurementFilePolicyTest.java @@ -0,0 +1,36 @@ +package com.baiyue.file.service; + +import com.baiyue.common.core.exception.BizException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.multipart.MultipartFile; +import java.nio.file.Path; +import java.util.List; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class ProcurementFilePolicyTest { + @TempDir Path root; + @Test void procurementLabelAndPrivateProofUsePdfImagesAndPositiveCustomerOwnership() { + var storage=new BusinessFileStorage();ReflectionTestUtils.setField(storage,"configuredRoot",root);storage.initializeRoot(); + for(var category:List.of(BusinessFileCategory.OVERSEAS_PROCUREMENT_LABEL,BusinessFileCategory.OVERSEAS_PROCUREMENT_PURCHASE, + BusinessFileCategory.OVERSEAS_PROCUREMENT_BUDGET,BusinessFileCategory.OVERSEAS_PROCUREMENT_EXCEPTION)) { + category.validateOwner(9); + assertThatThrownBy(()->category.validateOwner(0)).isInstanceOf(BizException.class); + assertThatThrownBy(()->category.storageBusinessId("0")).isInstanceOf(BizException.class); + byte[] pdf="%PDF-1.4\nprocurement test".getBytes(java.nio.charset.StandardCharsets.UTF_8); + var saved=storage.store(9,"11",new MockMultipartFile("file","label.pdf","application/pdf",pdf),category); + assertThat(storage.read(saved.objectKey())).isEqualTo(pdf); + assertThatThrownBy(()->storage.store(9,"11",new MockMultipartFile("file","fake.pdf","application/pdf","fake".getBytes()),category)).isInstanceOf(BizException.class); + assertThatThrownBy(()->storage.store(9,"11",new MockMultipartFile("file","notes.txt","text/plain","notes".getBytes()),category)).isInstanceOf(BizException.class); + } + } + @Test void labelOverTenMibIsRejectedBeforeReadingBody()throws Exception { + var storage=new BusinessFileStorage();ReflectionTestUtils.setField(storage,"configuredRoot",root);storage.initializeRoot(); + MultipartFile file=mock(MultipartFile.class);when(file.isEmpty()).thenReturn(false);when(file.getSize()).thenReturn(10L*1024*1024+1); + assertThatThrownBy(()->storage.store(9,"11",file,BusinessFileCategory.OVERSEAS_PROCUREMENT_LABEL)).isInstanceOf(BizException.class); + verify(file,never()).getBytes();verify(file,never()).getInputStream(); + } +} 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 9049e00da..1312e6dc0 100644 --- a/finance/src/main/java/com/baiyue/finance/service/BusinessFeeSourceEventService.java +++ b/finance/src/main/java/com/baiyue/finance/service/BusinessFeeSourceEventService.java @@ -130,6 +130,14 @@ public class BusinessFeeSourceEventService { } positive(value.sourceFeeId(), "sourceFeeId"); positive(value.customerId(), "customerId"); + if (value.walletSettlement() != null) { + if (!"RECEIVABLE".equals(value.direction())) throw error("FIN-EVT-012", "钱包消费只能关联应收费用"); + positive(value.walletSettlement().holdId(), "walletSettlement.holdId"); + positive(value.walletSettlement().ledgerId(), "walletSettlement.ledgerId"); + if (blank(value.walletSettlement().commandEventId())) throw error("FIN-EVT-012", "缺少原资金命令引用"); + } else if ("OVERSEAS_PROCUREMENT_FEE".equals(value.sourceType()) && "RECEIVABLE".equals(value.direction())) { + throw error("FIN-EVT-012", "采购应收必须引用成功实扣资金,不能进入待收费池"); + } if ("OVERSEAS_CONTAINER_FEE".equals(value.sourceType())) { if (!"LASTLEG".equals(value.sourceSystem()) || !"OVERSEAS_CONTAINER".equals(value.businessType())) throw error("FIN-EVT-012", "海外柜费来源不一致"); @@ -160,7 +168,7 @@ public class BusinessFeeSourceEventService { value.paymentReferenceNo(), value.paymentVoucherFileIds(), value.businessDate(), value.currency(), value.totalAmount(), value.originalCurrency(), value.originalAmount(), value.exchangeRate(), value.exchangeRateMonth(), value.chargeableQuantity(), value.quantityUnit(), value.unitPrice(), - value.periodStart(), value.periodEnd(), value.reviewStatus(), "PUBLISHED", "ACTIVE", null); + value.periodStart(), value.periodEnd(), value.reviewStatus(), "PUBLISHED", "ACTIVE", null, value.walletSettlement()); String expected = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(objectMapper.valueToTree(unsigned))); if (!expected.equals(value.sourceHash())) throw error("FIN-EVT-004", "费用来源快照哈希不一致"); } @@ -189,7 +197,40 @@ public class BusinessFeeSourceEventService { private void linkSettledWalletHold(long feeSourceId, BusinessFeeSourceChangedV1 source) { if (!"RECEIVABLE".equals(source.direction())) return; - jdbc.update("UPDATE fin_fee_source f JOIN fin_wallet_hold h ON h.customer_id=f.customer_id AND h.business_type=f.business_type AND h.business_no=f.business_no SET f.wallet_hold_id=h.id,f.status='SETTLED' WHERE f.id=? AND f.status='AVAILABLE' AND h.status='SETTLED' AND h.amount=f.amount", + if (source.walletSettlement() != null) { + var reference = source.walletSettlement(); + long customerId = positive(source.customerId(), "customerId"); + long holdId = positive(reference.holdId(), "holdId"); + long ledgerId = positive(reference.ledgerId(), "ledgerId"); + List> rows = jdbc.queryForList( + "SELECT l.amount,c.result_json FROM fin_wallet_hold h JOIN fin_wallet_ledger l ON l.id=h.action_ledger_id AND l.customer_id=h.customer_id JOIN fin_wallet_command_inbox c ON c.event_id=? AND c.status='SUCCEEDED' WHERE h.id=? AND l.id=? AND h.customer_id=? AND h.business_type=? AND h.business_no=? AND h.status='SETTLED' AND l.change_type='CONSUMPTION' FOR UPDATE", + reference.commandEventId(), holdId, ledgerId, customerId, source.businessType(), source.businessNo()); + if (rows.size() != 1 || !(rows.get(0).get("amount") instanceof java.math.BigDecimal amount) + || amount.negate().compareTo(source.totalAmount()) != 0) { + throw error("FIN-EVT-017", "费用来源与成功扣款的客户、业务或金额不匹配"); + } + com.fasterxml.jackson.databind.JsonNode result; + try { result = objectMapper.readTree(String.valueOf(rows.get(0).get("result_json"))); } + catch (JsonProcessingException exception) { throw error("FIN-EVT-017", "原资金结果无法核对"); } + if (!"SUCCEEDED".equals(result.path("result").asText()) + || !Set.of("SETTLE", "SETTLE_ACTUAL").contains(result.path("operation").asText()) + || !reference.commandEventId().equals(result.path("commandEventId").asText()) + || !reference.holdId().equals(result.path("walletHoldId").asText()) + || !reference.ledgerId().equals(result.path("walletLedgerId").asText()) + || result.path("customerId").asLong() != customerId + || !source.businessType().equals(result.path("businessType").asText()) + || !source.businessNo().equals(result.path("businessNo").asText()) + || result.path("amount").decimalValue().compareTo(source.totalAmount()) != 0) { + throw error("FIN-EVT-017", "费用来源引用的资金结果不匹配"); + } + List used = jdbc.queryForList("SELECT id FROM fin_fee_source WHERE wallet_hold_id=? AND id<>? FOR UPDATE", + Long.class, holdId, feeSourceId); + if (!used.isEmpty()) throw error("FIN-EVT-017", "同一成功扣款已关联其他费用来源"); + jdbc.update("UPDATE fin_fee_source SET wallet_hold_id=?,status='SETTLED' WHERE id=? AND customer_id=? AND status='AVAILABLE'", + holdId, feeSourceId, customerId); + return; + } + jdbc.update("UPDATE fin_fee_source f JOIN fin_wallet_hold h ON h.customer_id=f.customer_id AND h.business_type=f.business_type AND h.business_no=f.business_no JOIN fin_wallet_ledger l ON l.id=h.action_ledger_id AND l.customer_id=h.customer_id AND l.change_type='CONSUMPTION' SET f.wallet_hold_id=h.id,f.status='SETTLED' WHERE f.id=? AND f.status='AVAILABLE' AND h.status='SETTLED' AND f.amount=-l.amount", feeSourceId); } diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceWalletService.java b/finance/src/main/java/com/baiyue/finance/service/FinanceWalletService.java index 6a1d3475e..970f1c44c 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinanceWalletService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceWalletService.java @@ -300,6 +300,113 @@ public class FinanceWalletService { return completeHold(customerId, businessType, businessNo, idempotencyKey, request, false); } + /** Settles one estimate without releasing its protection before checking any shortfall. */ + @Transactional + public WalletHold settleActual(long customerId, String rawBusinessType, String rawBusinessNo, + String idempotencyKey, BigDecimal actualAmount, String remark) { + requirePositive(customerId, "客户"); + BigDecimal amount = money(actualAmount); + String businessType = businessType(rawBusinessType); + String businessNo = businessNo(rawBusinessNo); + String operation = "WALLET_HOLD_SETTLE_ACTUAL"; + String requestHash = hash(Map.of("customerId", customerId, "businessType", businessType, + "businessNo", businessNo, "amount", amount, "remark", String.valueOf(remark))); + Long replay = reserve(customerId, 0, operation, idempotencyKey, requestHash); + if (replay != null) return requiredHold(replay, customerId); + WalletAccount before = accountForUpdate(customerId); + WalletHold hold = findHold(customerId, businessType, businessNo, true); + if (hold == null) throw error("FIN-WALLET-HOLD-404", "资金冻结记录不存在"); + if (!"FROZEN".equals(hold.status())) throw error("FIN-WALLET-HOLD-409", "资金冻结记录已处理"); + WalletAccount after = settleActualAccount(before, hold, amount); + updateAccount(customerId, after, before.version(), 0); + WalletLedgerEntry ledger = insertLedger(customerId, "CONSUMPTION", amount.negate(), after, + businessType, businessNo, LocalDate.now(), null, businessNo, 0, "业务系统", remark); + // The hold retains the original estimate; its action ledger records the actual charge. + int updated = jdbc.update("UPDATE fin_wallet_hold SET status='SETTLED',action_ledger_id=?,completed_at=CURRENT_TIMESTAMP(3) WHERE id=? AND status='FROZEN'", + ledger.ledgerId(), hold.holdId()); + if (updated != 1) throw error("FIN-WALLET-HOLD-409", "资金冻结记录已处理"); + audit(customerId, "WALLET_HOLD", hold.holdId(), "SETTLE_ACTUAL", remark, before, after, 0, "业务系统"); + complete(customerId, 0, operation, idempotencyKey, hold.holdId()); + return requiredHold(hold.holdId(), customerId); + } + + /** Refunds a successful consumption; recharge allocation rules apply without creating a recharge. */ + @Transactional + public WalletLedgerEntry refundConsumption(long customerId, long originalLedgerId, String refundNo, + String idempotencyKey, BigDecimal refundAmount, + long operatorId, String operatorName, String remark) { + requirePositive(customerId, "客户"); + requirePositive(originalLedgerId, "原扣款流水"); + String reference = businessNo(refundNo); + BigDecimal amount = money(refundAmount); + if (!StringUtils.hasText(remark)) throw bad("退款原因不能为空"); + String requestHash = hash(Map.of("customerId", customerId, "originalLedgerId", originalLedgerId, + "refundNo", reference, "amount", amount, "remark", remark)); + Long replay = reserve(customerId, 0, "WALLET_BUSINESS_REFUND", idempotencyKey, requestHash); + if (replay != null) return requiredLedger(replay, customerId); + WalletAccount before = accountForUpdate(customerId); + List originals = jdbc.query( + "SELECT * FROM fin_wallet_ledger WHERE id=? AND customer_id=? FOR UPDATE", + (rs, n) -> ledgerRow(rs), originalLedgerId, customerId); + if (originals.isEmpty()) throw error("FIN-WALLET-404", "原扣款流水不存在"); + WalletLedgerEntry original = originals.get(0); + if (!"CONSUMPTION".equals(original.changeType()) || original.amount().signum() >= 0) { + throw bad("仅可对成功业务扣款发起关联退款"); + } + List existing = jdbc.query( + "SELECT * FROM fin_wallet_ledger WHERE customer_id=? AND source_type='BUSINESS_REFUND' AND source_no=? FOR UPDATE", + (rs, n) -> ledgerRow(rs), customerId, reference); + if (!existing.isEmpty()) { + WalletLedgerEntry refunded = existing.get(0); + if (refunded.amount().compareTo(amount) != 0 || !original.businessNo().equals(refunded.referenceNo())) { + throw error("FIN-IDEMPOTENCY-CONFLICT", "同一退款单对应的原扣款或金额不一致"); + } + complete(customerId, 0, "WALLET_BUSINESS_REFUND", idempotencyKey, refunded.ledgerId()); + return refunded; + } + // Locking reads see refunds committed while this transaction waited for the account lock, + // even under MySQL REPEATABLE READ after the idempotency lookup established a snapshot. + BigDecimal refunded = jdbc.queryForList( + "SELECT amount FROM fin_wallet_ledger WHERE customer_id=? AND source_type='BUSINESS_REFUND' AND reference_no=? FOR UPDATE", + BigDecimal.class, customerId, original.businessNo()).stream().reduce(BigDecimal.ZERO, BigDecimal::add); + if (refunded.add(amount).compareTo(original.amount().negate()) > 0) { + throw error("FIN-WALLET-REFUND-EXCEEDED", "退款金额超过原扣款剩余可退金额"); + } + WalletAccount after = rechargeAccount(before, amount); + updateAccount(customerId, after, before.version(), operatorId); + WalletLedgerEntry result = insertLedger(customerId, "REFUND", amount, after, + "BUSINESS_REFUND", reference, LocalDate.now(), null, original.businessNo(), + operatorId, operatorName, remark); + audit(customerId, "WALLET_LEDGER", originalLedgerId, "REFUND", remark, before, after, operatorId, operatorName); + complete(customerId, 0, "WALLET_BUSINESS_REFUND", idempotencyKey, result.ledgerId()); + return result; + } + + WalletAccount settleActualAccount(WalletAccount before, WalletHold hold, BigDecimal actualAmount) { + BigDecimal amount = money(actualAmount); + BigDecimal extra = amount.subtract(hold.amount()).max(BigDecimal.ZERO); + if (extra.signum() > 0 && before.availableBalance().compareTo(extra) < 0) { + throw error("FIN-WALLET-INSUFFICIENT", "实际费用补差所需的可用余额和授信额度不足"); + } + BigDecimal actual = before.actualBalance(); + BigDecimal creditUsed = before.creditUsed(); + if (before.creditActive()) { + BigDecimal reservedCharge = amount.min(hold.amount()); + BigDecimal cashCharge = reservedCharge.min(hold.actualFrozenAmount()); + BigDecimal freeCash = actual.subtract(before.frozenBalance().subtract(before.creditFrozen())) + .max(BigDecimal.ZERO); + cashCharge = cashCharge.add(extra.min(freeCash)); + actual = actual.subtract(cashCharge); + creditUsed = creditUsed.add(amount.subtract(cashCharge)); + } else { + // Previously reserved credit still settles after expiry, without granting new credit. + actual = actual.subtract(amount); + } + return accountState(actual, before.frozenBalance().subtract(hold.amount()), before.creditLimit(), + creditUsed, before.creditFrozen().subtract(hold.creditFrozenAmount()), + before.creditExpiresOn(), before.version()); + } + private WalletHold completeHold(long customerId, String rawBusinessType, String rawBusinessNo, String idempotencyKey, WalletHoldActionRequest request, boolean settle) { requirePositive(customerId, "客户"); diff --git a/finance/src/main/java/com/baiyue/finance/service/WalletCommandEventService.java b/finance/src/main/java/com/baiyue/finance/service/WalletCommandEventService.java index e42137930..64a930dc5 100644 --- a/finance/src/main/java/com/baiyue/finance/service/WalletCommandEventService.java +++ b/finance/src/main/java/com/baiyue/finance/service/WalletCommandEventService.java @@ -29,9 +29,10 @@ import java.util.Set; @Service public class WalletCommandEventService { - private static final Set OPERATIONS = Set.of("FREEZE", "SETTLE", "RELEASE"); + private static final Set OPERATIONS = Set.of("FREEZE", "SETTLE", "SETTLE_ACTUAL", "RELEASE", "REFUND"); private static final Set FINAL_REJECTIONS = Set.of( - "FIN-WALLET-INSUFFICIENT", "FIN-WALLET-HOLD-409", "FIN-IDEMPOTENCY-CONFLICT", "FIN-REQ-400"); + "FIN-WALLET-INSUFFICIENT", "FIN-WALLET-HOLD-409", "FIN-IDEMPOTENCY-CONFLICT", "FIN-REQ-400", + "FIN-WALLET-REFUND-EXCEEDED", "FIN-WALLET-404"); private final JdbcTemplate jdbc; private final ObjectMapper objectMapper; private final FinanceWalletService walletService; @@ -56,10 +57,9 @@ public class WalletCommandEventService { if (resultExists(envelope.eventId())) return DeliveryAck.of(envelope, DeliveryAck.Outcome.DUPLICATE); // Wallet methods join this transaction: money, the Inbox outcome and // the result Outbox commit together, or all roll back. - WalletHold hold = execute(command); - if (hold == null) throw new IllegalStateException("资金操作未返回处理结果"); + ExecutionResult execution = execute(command); finalizeResult(envelope, completed(envelope.eventId(), command, "SUCCEEDED", - hold.status(), hold.amount(), null, null)); + execution.status(), execution.amount(), null, null, execution.holdId(), execution.ledgerId())); return DeliveryAck.of(envelope, DeliveryAck.Outcome.APPLIED); }); } catch (FinanceEventException exception) { @@ -70,7 +70,7 @@ public class WalletCommandEventService { requireLockedIdentity(envelope); if (resultExists(envelope.eventId())) return DeliveryAck.of(envelope, DeliveryAck.Outcome.DUPLICATE); finalizeResult(envelope, completed(envelope.eventId(), command, "FAILED", - null, command.amount(), exception.code(), exception.getMessage())); + null, command.amount(), exception.code(), exception.getMessage(), null, null)); return DeliveryAck.of(envelope, DeliveryAck.Outcome.APPLIED); }); } @@ -86,21 +86,33 @@ public class WalletCommandEventService { } } - private WalletHold execute(WalletCommandRequestedV1 command) { + private record ExecutionResult(String status, BigDecimal amount, String holdId, String ledgerId) { } + + private ExecutionResult execute(WalletCommandRequestedV1 command) { + if ("REFUND".equals(command.operation())) { + var refund = walletService.refundConsumption(command.customerId(), Long.parseLong(command.originalLedgerId()), + command.businessNo(), command.idempotencyKey(), command.amount(), 0, "业务系统", command.remark()); + return new ExecutionResult("REFUNDED", refund.amount(), null, String.valueOf(refund.ledgerId())); + } WalletHold hold = switch (command.operation()) { case "FREEZE" -> walletService.freeze(command.customerId(), command.idempotencyKey(), new WalletFreezeRequest(command.amount(), command.businessType(), command.businessNo(), command.remark())); case "SETTLE" -> walletService.settle(command.customerId(), command.businessType(), command.businessNo(), command.idempotencyKey(), new WalletHoldActionRequest(command.remark())); + case "SETTLE_ACTUAL" -> walletService.settleActual(command.customerId(), command.businessType(), command.businessNo(), + command.idempotencyKey(), command.amount(), command.remark()); case "RELEASE" -> walletService.release(command.customerId(), command.businessType(), command.businessNo(), command.idempotencyKey(), new WalletHoldActionRequest(command.remark())); default -> throw new FinanceEventException("FIN-EVT-001", "资金指令操作无效"); }; - if ("SETTLE".equals(command.operation())) { - jdbc.update("UPDATE fin_fee_source f JOIN fin_wallet_hold h ON h.customer_id=f.customer_id AND h.business_type=f.business_type AND h.business_no=f.business_no SET f.wallet_hold_id=h.id,f.status='SETTLED' WHERE h.id=? AND f.direction='RECEIVABLE' AND f.status='AVAILABLE' AND f.amount=h.amount", + if (hold == null) throw new IllegalStateException("资金操作未返回处理结果"); + if (Set.of("SETTLE", "SETTLE_ACTUAL").contains(command.operation())) { + jdbc.update("UPDATE fin_fee_source f JOIN fin_wallet_hold h ON h.customer_id=f.customer_id AND h.business_type=f.business_type AND h.business_no=f.business_no JOIN fin_wallet_ledger l ON l.id=h.action_ledger_id AND l.customer_id=h.customer_id AND l.change_type='CONSUMPTION' SET f.wallet_hold_id=h.id,f.status='SETTLED' WHERE h.id=? AND f.direction='RECEIVABLE' AND f.status='AVAILABLE' AND f.amount=-l.amount", hold.holdId()); } - return hold; + Long ledgerId = "FREEZE".equals(command.operation()) ? hold.freezeLedgerId() : hold.actionLedgerId(); + return new ExecutionResult(hold.status(), "SETTLE_ACTUAL".equals(command.operation()) ? command.amount() : hold.amount(), + String.valueOf(hold.holdId()), ledgerId == null ? null : String.valueOf(ledgerId)); } private WalletCommandRequestedV1 validate(EventEnvelope envelope) { @@ -118,14 +130,22 @@ public class WalletCommandEventService { String operation = value.operation() == null ? "" : value.operation().trim().toUpperCase(Locale.ROOT); if (!OPERATIONS.contains(operation) || value.customerId() <= 0 || blank(value.businessType()) || blank(value.businessNo()) || blank(value.idempotencyKey()) - || ("FREEZE".equals(operation) && (value.amount() == null || value.amount().signum() <= 0))) { + || (Set.of("FREEZE", "SETTLE_ACTUAL", "REFUND").contains(operation) + && (value.amount() == null || value.amount().signum() <= 0 || value.amount().scale() > 2))) { throw new FinanceEventException("FIN-EVT-001", "资金指令字段无效"); } + if ("REFUND".equals(operation)) { + if (blank(value.originalLedgerId()) || Long.parseLong(value.originalLedgerId()) <= 0 || blank(value.remark())) { + throw new FinanceEventException("FIN-EVT-001", "退款必须关联原扣款并填写原因"); + } + } else if (value.originalLedgerId() != null) { + throw new FinanceEventException("FIN-EVT-001", "非退款指令不能指定原扣款"); + } if (!String.valueOf(value.customerId()).equals(envelope.tenantId())) { throw new FinanceEventException("FIN-EVT-007", "资金指令客户归属不一致"); } return new WalletCommandRequestedV1(operation, value.customerId(), value.businessType(), value.businessNo(), - value.amount(), value.remark(), value.idempotencyKey(), value.requestedAt()); + value.amount(), value.remark(), value.idempotencyKey(), value.requestedAt(), value.originalLedgerId()); } catch (FinanceEventException exception) { throw exception; } catch (Exception exception) { throw new FinanceEventException("FIN-EVT-001", "资金指令载荷无法解析"); } } @@ -159,9 +179,10 @@ public class WalletCommandEventService { return count != null && count > 0; } private WalletCommandCompletedV1 completed(String eventId, WalletCommandRequestedV1 command, String result, - String holdStatus, BigDecimal amount, String errorCode, String errorMessage) { + String holdStatus, BigDecimal amount, String errorCode, String errorMessage, + String holdId, String ledgerId) { return new WalletCommandCompletedV1(eventId, command.operation(), command.customerId(), command.businessType(), - command.businessNo(), result, holdStatus, amount, errorCode, errorMessage, Instant.now()); + command.businessNo(), result, holdStatus, amount, errorCode, errorMessage, Instant.now(), holdId, ledgerId); } private String json(Object value) { try { return objectMapper.writeValueAsString(value); } diff --git a/finance/src/test/java/com/baiyue/finance/BusinessFeeSourceEventServiceTest.java b/finance/src/test/java/com/baiyue/finance/BusinessFeeSourceEventServiceTest.java index 7e6fd2a2e..3d69bc90a 100644 --- a/finance/src/test/java/com/baiyue/finance/BusinessFeeSourceEventServiceTest.java +++ b/finance/src/test/java/com/baiyue/finance/BusinessFeeSourceEventServiceTest.java @@ -45,11 +45,11 @@ class BusinessFeeSourceEventServiceTest { when(jdbc.queryForList(startsWith("SELECT payload_hash,status,result_resource_id"), eq("LASTLEG"), eq(event.eventId()))).thenReturn(List.of()); when(jdbc.queryForList(startsWith("SELECT id,source_hash,status FROM fin_fee_source"), - eq("LASTLEG"), eq("OVERSEAS_PROCUREMENT_FEE"), eq(701L), eq(1))).thenReturn(List.of()); + eq("LASTLEG"), eq("OVERSEAS_WAREHOUSE_FEE"), eq(701L), eq(1))).thenReturn(List.of()); when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_fee_source f WHERE f.source_system=?"), - eq(Integer.class), eq("LASTLEG"), eq("OVERSEAS_PROCUREMENT_FEE"), eq(701L))).thenReturn(0); + eq(Integer.class), eq("LASTLEG"), eq("OVERSEAS_WAREHOUSE_FEE"), eq(701L))).thenReturn(0); 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); + eq("LASTLEG"), eq("OVERSEAS_WAREHOUSE_FEE"), eq(701L), eq(1))).thenReturn(901L); assertThat(service.consume(event).eventId()).isEqualTo(event.eventId()); @@ -65,21 +65,21 @@ class BusinessFeeSourceEventServiceTest { private EventEnvelope eventWithoutInbound() { BusinessFeeSourceChangedV1 unsigned = new BusinessFeeSourceChangedV1(null, "LASTLEG", - "OVERSEAS_PROCUREMENT_FEE", "701", 1, "10001", "客户A", "PO-US-001", - "OVERSEAS_PROCUREMENT", "801", "PO-US-001", null, null, null, null, null, + "OVERSEAS_WAREHOUSE_FEE", "701", 1, "10001", "客户A", "PO-US-001", + "WAREHOUSE_SERVICE", "801", "PO-US-001", null, null, null, null, null, null, "91", "PROCUREMENT_SERVICE", "采购服务费", "RECEIVABLE", null, null, null, null, null, null, null, List.of(), LocalDate.of(2026, 9, 2), "CNY", new BigDecimal("120.00"), "CNY", new BigDecimal("120.00"), BigDecimal.ONE, - "2026-09", null, null, null, null, null, "APPROVED", "PUBLISHED", "ACTIVE", null); + "2026-09", null, null, null, null, null, "APPROVED", "PUBLISHED", "ACTIVE", null, null); String sourceHash = EventPayloadHash.sha256( EventPayloadCanonicalizer.canonicalize(json.valueToTree(unsigned))); BusinessFeeSourceChangedV1 payload = new BusinessFeeSourceChangedV1("PUBLISHED", "LASTLEG", - "OVERSEAS_PROCUREMENT_FEE", "701", 1, "10001", "客户A", "PO-US-001", - "OVERSEAS_PROCUREMENT", "801", "PO-US-001", null, null, null, null, null, + "OVERSEAS_WAREHOUSE_FEE", "701", 1, "10001", "客户A", "PO-US-001", + "WAREHOUSE_SERVICE", "801", "PO-US-001", null, null, null, null, null, null, "91", "PROCUREMENT_SERVICE", "采购服务费", "RECEIVABLE", null, null, null, null, null, null, null, List.of(), LocalDate.of(2026, 9, 2), "CNY", new BigDecimal("120.00"), "CNY", new BigDecimal("120.00"), BigDecimal.ONE, - "2026-09", null, null, null, null, null, "APPROVED", "PUBLISHED", "ACTIVE", sourceHash); + "2026-09", null, null, null, null, null, "APPROVED", "PUBLISHED", "ACTIVE", sourceHash, null); JsonNode node = json.valueToTree(payload); String payloadHash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); return new EventEnvelope("123e4567-e89b-12d3-a456-426614174701", @@ -237,4 +237,53 @@ class BusinessFeeSourceEventServiceTest { verify(jdbc, never()).update(startsWith("INSERT INTO fin_cost"), any(Object[].class)); verify(jdbc, never()).update(startsWith("UPDATE fin_cost"), any(Object[].class)); } + + @Test + void procurementWithoutConsumptionReferenceNeverEntersReceivablePool() { + EventEnvelope event = procurementEvent(false); + assertThatThrownBy(() -> service.consume(event)).hasMessageContaining("必须引用成功实扣"); + noFinancialWrites(); + } + + @Test + void settledProcurementUsesVerifiedActualConsumptionAndNeverOriginalEstimate() { + EventEnvelope event = procurementEvent(true); + 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); + when(jdbc.queryForList(startsWith("SELECT l.amount,c.result_json"), eq("command-1"), eq(9L), eq(72L), + eq(10001L), eq("PROCUREMENT_FEE"), eq("PO-US-001"))).thenReturn(List.of(Map.of( + "amount", new BigDecimal("-120.00"), "result_json", """ + {"result":"SUCCEEDED","operation":"SETTLE_ACTUAL","commandEventId":"command-1", + "walletHoldId":"9","walletLedgerId":"72","customerId":10001, + "businessType":"PROCUREMENT_FEE","businessNo":"PO-US-001","amount":120.00} + """))); + service.consume(event); + verify(jdbc).update(startsWith("UPDATE fin_fee_source SET wallet_hold_id="), eq(9L), eq(901L), eq(10001L)); + verify(jdbc, never()).update(startsWith("UPDATE fin_fee_source f JOIN fin_wallet_hold"), any(Object[].class)); + } + + @Test + void incorrectOrCrossCustomerConsumptionIsRejectedInsteadOfBecomingAvailable() { + EventEnvelope event = procurementEvent(true); + 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); + assertThatThrownBy(() -> service.consume(event)).hasMessageContaining("不匹配"); + verify(jdbc, never()).update(startsWith("UPDATE fin_fee_source SET wallet_hold_id="), any(Object[].class)); + } + + private EventEnvelope procurementEvent(boolean reference) { + EventEnvelope base = eventWithoutInbound(); + ObjectNode payload = (ObjectNode) base.payload().deepCopy(); + payload.put("sourceType", "OVERSEAS_PROCUREMENT_FEE"); + payload.put("businessType", "PROCUREMENT_FEE"); + if (reference) { + payload.putObject("walletSettlement").put("commandEventId", "command-1").put("holdId", "9").put("ledgerId", "72"); + } + 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))); + } } diff --git a/finance/src/test/java/com/baiyue/finance/WalletCommandEventServiceTest.java b/finance/src/test/java/com/baiyue/finance/WalletCommandEventServiceTest.java index c0425eeae..b7711d577 100644 --- a/finance/src/test/java/com/baiyue/finance/WalletCommandEventServiceTest.java +++ b/finance/src/test/java/com/baiyue/finance/WalletCommandEventServiceTest.java @@ -101,6 +101,38 @@ class WalletCommandEventServiceTest { verify(jdbc, never()).update(startsWith("INSERT INTO fin_wallet_event_outbox"), any(Object[].class)); } + @Test + void actualSettlementReportsActualChargeInsteadOfOriginalEstimate() throws Exception { + EventEnvelope event = envelope("SETTLE_ACTUAL", 31L, new BigDecimal("60.00")); + when(jdbc.queryForList(startsWith("SELECT payload_hash"), eq(String.class), eq(event.eventId()))) + .thenReturn(List.of(event.payloadHash())); + when(jdbc.queryForObject(startsWith("SELECT COUNT(*)"), eq(Integer.class), anyString())).thenReturn(0); + when(wallet.settleActual(eq(31L), eq("OVERSEAS_PROCUREMENT_FEE"), eq("PO-1"), + eq("freeze-procurement-1"), argThat((BigDecimal amount) -> amount.compareTo(new BigDecimal("60.00")) == 0), any())).thenReturn( + new WalletHold(9L, 31L, "OVERSEAS_PROCUREMENT_FEE", "PO-1", new BigDecimal("100.00"), + new BigDecimal("100.00"), BigDecimal.ZERO, "SETTLED", 71L, 72L, null, null, null)); + service.consume(event); + var resultJson = org.mockito.ArgumentCaptor.forClass(String.class); + verify(jdbc).update(startsWith("UPDATE fin_wallet_command_inbox SET status="), eq("SUCCEEDED"), + resultJson.capture(), eq(event.eventId())); + JsonNode result = mapper.readTree(resultJson.getValue()); + assertThat(result.path("amount").decimalValue()).isEqualByComparingTo("60.00"); + assertThat(result.path("holdStatus").asText()).isEqualTo("SETTLED"); + assertThat(result.path("walletHoldId").asText()).isEqualTo("9"); + assertThat(result.path("walletLedgerId").asText()).isEqualTo("72"); + verify(jdbc).update(contains("f.amount=-l.amount"), eq(9L)); + verify(wallet, never()).release(anyLong(), anyString(), anyString(), anyString(), any()); + } + + @Test + void actualSettlementRejectsZeroAndMissingAmountsBeforeOpeningTransaction() { + for (BigDecimal amount : new BigDecimal[]{null, BigDecimal.ZERO, new BigDecimal("0.001")}) { + assertThatThrownBy(() -> service.consume(envelope("SETTLE_ACTUAL", 31L, amount))) + .isInstanceOf(FinanceEventException.class); + } + verifyNoInteractions(jdbc, wallet, transactions); + } + @Test void losingInboxInsertMustRecheckThePersistedHash() { EventEnvelope event = envelope("FREEZE", 31L, new BigDecimal("10.00")); @@ -133,9 +165,44 @@ class WalletCommandEventServiceTest { } private EventEnvelope envelope(String operation, long customerId, BigDecimal amount) { + return envelope(operation, customerId, amount, null); + } + + @Test + void refundRequiresOriginalConsumptionBeforeAnyWalletCall() { + assertThatThrownBy(() -> service.consume(envelope("REFUND", 31L, BigDecimal.TEN))) + .isInstanceOf(FinanceEventException.class); + verifyNoInteractions(jdbc, wallet); + } + + @Test + void refundHasItsOwnLedgerReferenceAndNeverPretendsToSettleAHold() throws Exception { + EventEnvelope event = envelope("REFUND", 31L, BigDecimal.TEN, "72"); + when(jdbc.queryForList(startsWith("SELECT payload_hash"), eq(String.class), eq(event.eventId()))) + .thenReturn(List.of(event.payloadHash())); + when(jdbc.queryForObject(startsWith("SELECT COUNT(*)"), eq(Integer.class), anyString())).thenReturn(0); + var refunded = new com.baiyue.finance.dto.FinanceWalletDtos.WalletLedgerEntry(73L, "AC-TEST", "REFUND", + BigDecimal.TEN, BigDecimal.TEN, BigDecimal.ZERO, BigDecimal.TEN, BigDecimal.ZERO, BigDecimal.ZERO, + BigDecimal.ZERO, null, "BUSINESS_REFUND", "PO-1", java.time.LocalDate.now(), null, + "AC-ORIGINAL", "业务系统", "采购快递费", null); + when(wallet.refundConsumption(eq(31L), eq(72L), eq("PO-1"), eq("freeze-procurement-1"), + any(), eq(0L), anyString(), anyString())).thenReturn(refunded); + service.consume(event); + var capture = org.mockito.ArgumentCaptor.forClass(String.class); + verify(jdbc).update(startsWith("UPDATE fin_wallet_command_inbox SET status="), eq("SUCCEEDED"), + capture.capture(), eq(event.eventId())); + JsonNode result = mapper.readTree(capture.getValue()); + assertThat(result.path("walletLedgerId").asText()).isEqualTo("73"); + assertThat(result.path("walletHoldId").isNull()).isTrue(); + assertThat(result.path("holdStatus").asText()).isEqualTo("REFUNDED"); + verify(wallet, never()).settle(anyLong(), anyString(), anyString(), anyString(), any()); + verify(jdbc, never()).update(startsWith("UPDATE fin_fee_source"), any(Object[].class)); + } + + private EventEnvelope envelope(String operation, long customerId, BigDecimal amount, String originalLedgerId) { WalletCommandRequestedV1 command = new WalletCommandRequestedV1(operation, customerId, "OVERSEAS_PROCUREMENT_FEE", "PO-1", amount, "采购快递费", - "freeze-procurement-1", Instant.parse("2026-09-05T01:00:00Z")); + "freeze-procurement-1", Instant.parse("2026-09-05T01:00:00Z"), originalLedgerId); JsonNode payload = mapper.valueToTree(command); return new EventEnvelope(UUID.randomUUID().toString(), EventTypes.WALLET_COMMAND_REQUESTED, WalletCommandRequestedV1.VERSION, Instant.parse("2026-09-05T01:00:00Z"), String.valueOf(customerId), "WALLET_HOLD", "PO-1", 1, diff --git a/finance/src/test/java/com/baiyue/finance/WalletCommandTransactionTest.java b/finance/src/test/java/com/baiyue/finance/WalletCommandTransactionTest.java index 9d27000cc..402447670 100644 --- a/finance/src/test/java/com/baiyue/finance/WalletCommandTransactionTest.java +++ b/finance/src/test/java/com/baiyue/finance/WalletCommandTransactionTest.java @@ -190,7 +190,7 @@ class WalletCommandTransactionTest { private EventEnvelope event(String id, String amount) { var command = new WalletCommandRequestedV1("FREEZE", 31L, "OVERSEAS_PROCUREMENT_FEE", "PO-1", - new BigDecimal(amount), "fee", "freeze-1", Instant.parse("2026-09-05T01:00:00Z")); + new BigDecimal(amount), "fee", "freeze-1", Instant.parse("2026-09-05T01:00:00Z"), null); var payload = mapper.valueToTree(command); return new EventEnvelope(id, EventTypes.WALLET_COMMAND_REQUESTED, WalletCommandRequestedV1.VERSION, Instant.now(), "31", "WALLET_HOLD", "PO-1", 1, diff --git a/finance/src/test/java/com/baiyue/finance/service/ProcurementActualSettlementTest.java b/finance/src/test/java/com/baiyue/finance/service/ProcurementActualSettlementTest.java new file mode 100644 index 000000000..ebe8d3817 --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/service/ProcurementActualSettlementTest.java @@ -0,0 +1,91 @@ +package com.baiyue.finance.service; + +import com.baiyue.finance.client.FinanceFileClient; +import com.baiyue.finance.dto.FinanceWalletDtos.WalletAccount; +import com.baiyue.finance.dto.FinanceWalletDtos.WalletHold; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.math.BigDecimal; +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.mock; + +class ProcurementActualSettlementTest { + private final FinanceWalletService service = new FinanceWalletService(mock(JdbcTemplate.class), + new ObjectMapper().findAndRegisterModules(), mock(FinanceFileClient.class), + mock(FinanceDocumentNumberGenerator.class), "token", "", "test", "test"); + + @Test + void smallerActualChargeReleasesRemainderAndKeepsOtherReservations() { + WalletAccount before = account("200", "150", "0", "0", "0", null); + WalletAccount after = service.settleActualAccount(before, hold("100", "100", "0"), n("60")); + assertThat(after.actualBalance()).isEqualByComparingTo("140"); + assertThat(after.frozenBalance()).isEqualByComparingTo("50"); + assertThat(after.availableBalance()).isEqualByComparingTo("90"); + } + + @Test + void excessUsesAvailableCashThenActiveCreditWithoutTouchingOtherHolds() { + WalletAccount before = account("120", "130", "100", "0", "30", LocalDate.now().plusDays(1)); + WalletAccount after = service.settleActualAccount(before, hold("100", "70", "30"), n("150")); + assertThat(after.actualBalance()).isEqualByComparingTo("30"); + assertThat(after.creditUsed()).isEqualByComparingTo("60"); + assertThat(after.frozenBalance()).isEqualByComparingTo("30"); + assertThat(after.creditFrozen()).isZero(); + assertThat(after.availableBalance()).isEqualByComparingTo("40"); + } + + @Test + void insufficientExcessRejectsWithoutChangingOriginalAccount() { + WalletAccount before = account("120", "100", "0", "0", "0", null); + assertThatThrownBy(() -> service.settleActualAccount(before, hold("100", "100", "0"), n("120.01"))) + .isInstanceOf(FinanceEventException.class) + .extracting(e -> ((FinanceEventException) e).code()).isEqualTo("FIN-WALLET-INSUFFICIENT"); + assertThat(before.actualBalance()).isEqualByComparingTo("120"); + assertThat(before.frozenBalance()).isEqualByComparingTo("100"); + } + + @Test + void partialChargeUsesReservedCashBeforeCredit() { + WalletAccount before = account("70", "100", "100", "0", "30", LocalDate.now().plusDays(1)); + WalletAccount after = service.settleActualAccount(before, hold("100", "70", "30"), n("60")); + assertThat(after.actualBalance()).isEqualByComparingTo("10"); + assertThat(after.creditUsed()).isZero(); + assertThat(after.creditFrozen()).isZero(); + assertThat(after.frozenBalance()).isZero(); + } + + @Test + void expiredCreditAllowsOriginalReservationButNeverNewCredit() { + WalletAccount before = account("20", "100", "100", "0", "80", LocalDate.now().minusDays(1)); + WalletAccount after = service.settleActualAccount(before, hold("100", "20", "80"), n("100")); + assertThat(after.actualBalance()).isEqualByComparingTo("-80"); + assertThat(after.creditUsed()).isZero(); + assertThat(after.frozenBalance()).isZero(); + assertThatThrownBy(() -> service.settleActualAccount(before, hold("100", "20", "80"), n("100.01"))) + .isInstanceOf(FinanceEventException.class); + } + + @Test + void invalidActualAmountMustUseReleaseOrCorrectPrecision() { + WalletAccount before = account("200", "100", "0", "0", "0", null); + for (String value : new String[]{"0", "-1", "0.001"}) { + assertThatThrownBy(() -> service.settleActualAccount(before, hold("100", "100", "0"), n(value))) + .isInstanceOf(FinanceEventException.class); + } + } + + private WalletAccount account(String actual, String frozen, String limit, String used, String creditFrozen, LocalDate expiry) { + return service.accountState(n(actual), n(frozen), n(limit), n(used), n(creditFrozen), expiry, 1); + } + + private WalletHold hold(String amount, String cash, String credit) { + return new WalletHold(1L, 9L, "PROCUREMENT_FEE", "PF-TEST", n(amount), n(cash), n(credit), + "FROZEN", 1L, null, null, null, null); + } + + private static BigDecimal n(String value) { return new BigDecimal(value); } +} diff --git a/finance/src/test/java/com/baiyue/finance/service/ProcurementSettlementTransactionTest.java b/finance/src/test/java/com/baiyue/finance/service/ProcurementSettlementTransactionTest.java new file mode 100644 index 000000000..d70870e39 --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/service/ProcurementSettlementTransactionTest.java @@ -0,0 +1,200 @@ +package com.baiyue.finance.service; + +import com.baiyue.finance.client.FinanceFileClient; +import com.baiyue.finance.dto.FinanceWalletDtos.WalletHold; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.h2.jdbcx.JdbcDataSource; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.dao.TransientDataAccessResourceException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +import java.math.BigDecimal; +import java.util.UUID; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** Executes the real wallet service against transactional JDBC; no mocked money mutations. */ +class ProcurementSettlementTransactionTest { + private JdbcTemplate jdbc; + private TransactionTemplate tx; + private FinanceWalletService wallet; + + @BeforeEach + void setup() { + JdbcDataSource source = new JdbcDataSource(); + source.setURL("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=5000"); + jdbc = spy(new JdbcTemplate(source)); + tx = new TransactionTemplate(new DataSourceTransactionManager(source)); + jdbc.execute("CREATE TABLE fin_wallet_account(customer_id BIGINT PRIMARY KEY,currency_code VARCHAR(3),actual_balance DECIMAL(18,2),frozen_balance DECIMAL(18,2),available_balance DECIMAL(18,2),credit_limit DECIMAL(18,2),credit_used DECIMAL(18,2),credit_frozen DECIMAL(18,2),credit_expires_on DATE,version INT,created_by BIGINT,updated_by BIGINT,updated_at TIMESTAMP)"); + jdbc.execute("CREATE TABLE fin_wallet_hold(id BIGINT AUTO_INCREMENT PRIMARY KEY,customer_id BIGINT,business_type VARCHAR(32),business_no VARCHAR(128),amount DECIMAL(18,2),actual_frozen_amount DECIMAL(18,2),credit_frozen_amount DECIMAL(18,2),status VARCHAR(32),freeze_ledger_id BIGINT,action_ledger_id BIGINT,created_at TIMESTAMP,completed_at TIMESTAMP,remark VARCHAR(500),UNIQUE(customer_id,business_type,business_no))"); + jdbc.execute("CREATE TABLE fin_idempotency_record(tenant_id BIGINT,operator_id BIGINT,operation VARCHAR(64),idempotency_key VARCHAR(128),request_hash VARCHAR(128),status VARCHAR(32),result_resource_id BIGINT,error_code VARCHAR(128),UNIQUE(tenant_id,operator_id,operation,idempotency_key))"); + jdbc.execute("CREATE TABLE fin_wallet_ledger(id BIGINT AUTO_INCREMENT PRIMARY KEY,customer_id BIGINT,business_no VARCHAR(128),change_type VARCHAR(32),amount DECIMAL(18,2),actual_balance_after DECIMAL(18,2),frozen_balance_after DECIMAL(18,2),available_balance_after DECIMAL(18,2),credit_limit_after DECIMAL(18,2),credit_used_after DECIMAL(18,2),available_credit_after DECIMAL(18,2),credit_expires_on_after DATE,source_type VARCHAR(32),source_no VARCHAR(128),occurred_date DATE,payment_method VARCHAR(32),reference_no VARCHAR(128),operator_id BIGINT,operator_name VARCHAR(128),remark VARCHAR(500),created_at TIMESTAMP)"); + jdbc.execute("CREATE TABLE fin_audit_log(tenant_id BIGINT,resource_type VARCHAR(64),resource_id BIGINT,action VARCHAR(64),reason VARCHAR(500),before_data TEXT,after_data TEXT,operator_id BIGINT,operator_name_snapshot VARCHAR(128),request_id VARCHAR(128),trace_id VARCHAR(128))"); + jdbc.update("INSERT INTO fin_wallet_account VALUES(9,'CNY',200,100,100,0,0,0,NULL,0,0,0,CURRENT_TIMESTAMP)"); + jdbc.update("INSERT INTO fin_wallet_hold(id,customer_id,business_type,business_no,amount,actual_frozen_amount,credit_frozen_amount,status) VALUES(1,9,'PROCUREMENT_FEE','PF-1',100,100,0,'FROZEN')"); + FinanceDocumentNumberGenerator numbers = mock(FinanceDocumentNumberGenerator.class); + AtomicInteger sequence = new AtomicInteger(); + when(numbers.next("AC")).thenAnswer(i -> "AC-TEST-" + sequence.incrementAndGet()); + wallet = new FinanceWalletService(jdbc, new ObjectMapper().findAndRegisterModules(), + mock(FinanceFileClient.class), numbers, "token", "", "test", "test"); + } + + @Test + void partialSettlementKeepsEstimateAndRecordsActualExactlyOnce() { + WalletHold first = settle("request-1", "60"); + WalletHold replay = settle("request-1", "60"); + assertThat(replay.actionLedgerId()).isEqualTo(first.actionLedgerId()); + assertThat(first.amount()).isEqualByComparingTo("100"); + assertThat(first.account().actualBalance()).isEqualByComparingTo("140"); + assertThat(first.account().frozenBalance()).isZero(); + assertThat(jdbc.queryForObject("SELECT amount FROM fin_wallet_ledger", BigDecimal.class)).isEqualByComparingTo("-60"); + assertThatThrownBy(() -> settle("different-request", "60")).isInstanceOf(FinanceEventException.class); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fin_wallet_ledger", Integer.class)).isEqualTo(1); + } + + @Test + void sameKeyWithDifferentAmountCannotChangeCharge() { + settle("request-1", "60"); + assertThatThrownBy(() -> settle("request-1", "70")).isInstanceOf(FinanceEventException.class) + .extracting(e -> ((FinanceEventException) e).code()).isEqualTo("FIN-IDEMPOTENCY-CONFLICT"); + assertThat(wallet.account(9).actualBalance()).isEqualByComparingTo("140"); + } + + @Test + void insufficientDifferenceLeavesOriginalHoldAndAccountUntouched() { + assertThatThrownBy(() -> settle("request-1", "201")).isInstanceOf(FinanceEventException.class); + assertUntouched(); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fin_idempotency_record", Integer.class)).isZero(); + } + + @Test + void auditFailureRollsBackDebitLedgerHoldAndIdempotencyReservation() { + doThrow(new TransientDataAccessResourceException("audit storage unavailable")) + .when(jdbc).update(startsWith("INSERT INTO fin_audit_log"), any(Object[].class)); + assertThatThrownBy(() -> settle("request-1", "150")) + .isInstanceOf(TransientDataAccessResourceException.class); + assertUntouched(); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fin_idempotency_record", Integer.class)).isZero(); + } + + @Test + void concurrentDifferentRequestsCannotSettleTheSameGoodsTwice() throws Exception { + ExecutorService executor = Executors.newFixedThreadPool(2); + CountDownLatch start = new CountDownLatch(1); + try { + Future one = executor.submit(() -> attempt(start, "one")); + Future two = executor.submit(() -> attempt(start, "two")); + start.countDown(); + assertThat((one.get(10, TimeUnit.SECONDS) ? 1 : 0) + (two.get(10, TimeUnit.SECONDS) ? 1 : 0)).isEqualTo(1); + assertThat(wallet.account(9).actualBalance()).isEqualByComparingTo("50"); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fin_wallet_ledger", Integer.class)).isEqualTo(1); + } finally { + executor.shutdownNow(); + assertThat(executor.awaitTermination(5, TimeUnit.SECONDS)).isTrue(); + } + } + + private boolean attempt(CountDownLatch start, String key) throws InterruptedException { + if (!start.await(5, TimeUnit.SECONDS)) throw new AssertionError("start timeout"); + try { settle(key, "150"); return true; } + catch (FinanceEventException expected) { + assertThat(expected.code()).isEqualTo("FIN-WALLET-HOLD-409"); + return false; + } + } + + @Test + void refundsAreBoundedByOriginalConsumptionAndCannotReplayWithNewKey() { + WalletHold settled = settle("settle", "60"); + var refund = tx.execute(s -> wallet.refundConsumption(9, settled.actionLedgerId(), "RF-1", "refund-1", + new BigDecimal("40"), 7, "finance", "confirmed correction")); + var replay = tx.execute(s -> wallet.refundConsumption(9, settled.actionLedgerId(), "RF-1", "new-key", + new BigDecimal("40"), 7, "finance", "confirmed correction")); + assertThat(replay.ledgerId()).isEqualTo(refund.ledgerId()); + assertThat(wallet.account(9).actualBalance()).isEqualByComparingTo("180"); + assertThatThrownBy(() -> tx.execute(s -> wallet.refundConsumption(9, settled.actionLedgerId(), "RF-2", "refund-2", + new BigDecimal("20.01"), 7, "finance", "too much"))) + .isInstanceOf(FinanceEventException.class) + .extracting(e -> ((FinanceEventException) e).code()).isEqualTo("FIN-WALLET-REFUND-EXCEEDED"); + tx.execute(s -> wallet.refundConsumption(9, settled.actionLedgerId(), "RF-2", "refund-2", + new BigDecimal("20"), 7, "finance", "remaining refund")); + assertThat(wallet.account(9).actualBalance()).isEqualByComparingTo("200"); + assertThat(jdbc.queryForObject("SELECT status FROM fin_wallet_hold", String.class)).isEqualTo("SETTLED"); + assertThat(jdbc.queryForObject("SELECT SUM(amount) FROM fin_wallet_ledger WHERE source_type='BUSINESS_REFUND'", BigDecimal.class)) + .isEqualByComparingTo("60"); + } + + @Test + void refundCannotReferenceAnotherCustomerOrAnUnsettledReservation() { + WalletHold settled = settle("settle", "60"); + assertThatThrownBy(() -> tx.execute(s -> wallet.refundConsumption(10, settled.actionLedgerId(), "RF-1", "refund-1", + BigDecimal.ONE, 7, "finance", "wrong customer"))).isInstanceOf(FinanceEventException.class); + assertThatThrownBy(() -> tx.execute(s -> wallet.refundConsumption(9, settled.actionLedgerId(), "RF-1", "refund-1", + BigDecimal.ZERO, 7, "finance", "zero refund"))).isInstanceOf(FinanceEventException.class); + assertThat(wallet.account(9).actualBalance()).isEqualByComparingTo("140"); + } + + @Test + void refundFailureRollsBackBalanceAndCanRetryOriginalRequest() { + WalletHold settled = settle("settle", "60"); + doThrow(new TransientDataAccessResourceException("audit unavailable")) + .when(jdbc).update(startsWith("INSERT INTO fin_audit_log"), any(Object[].class)); + assertThatThrownBy(() -> tx.execute(s -> wallet.refundConsumption(9, settled.actionLedgerId(), "RF-1", "refund-1", + new BigDecimal("20"), 7, "finance", "correction"))).isInstanceOf(TransientDataAccessResourceException.class); + assertThat(wallet.account(9).actualBalance()).isEqualByComparingTo("140"); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fin_wallet_ledger WHERE change_type='REFUND'", Integer.class)).isZero(); + doCallRealMethod().when(jdbc).update(startsWith("INSERT INTO fin_audit_log"), any(Object[].class)); + tx.execute(s -> wallet.refundConsumption(9, settled.actionLedgerId(), "RF-1", "refund-1", + new BigDecimal("20"), 7, "finance", "correction")); + assertThat(wallet.account(9).actualBalance()).isEqualByComparingTo("160"); + } + + private WalletHold settle(String key, String amount) { + return tx.execute(s -> wallet.settleActual(9, "PROCUREMENT_FEE", "PF-1", key, new BigDecimal(amount), "actual freight")); + } + + @Test + void concurrentRefundsCannotExceedTheOriginalConsumption() throws Exception { + WalletHold original = settle("settle", "60"); + ExecutorService executor = Executors.newFixedThreadPool(2); + CountDownLatch start = new CountDownLatch(1); + try { + Future one = executor.submit(() -> refundAttempt(start, original.actionLedgerId(), "RF-A")); + Future two = executor.submit(() -> refundAttempt(start, original.actionLedgerId(), "RF-B")); + start.countDown(); + assertThat((one.get(10, TimeUnit.SECONDS) ? 1 : 0) + (two.get(10, TimeUnit.SECONDS) ? 1 : 0)).isEqualTo(1); + assertThat(wallet.account(9).actualBalance()).isEqualByComparingTo("180"); + assertThat(jdbc.queryForObject("SELECT SUM(amount) FROM fin_wallet_ledger WHERE change_type='REFUND'", BigDecimal.class)) + .isEqualByComparingTo("40"); + } finally { + executor.shutdownNow(); + assertThat(executor.awaitTermination(5, TimeUnit.SECONDS)).isTrue(); + } + } + + private boolean refundAttempt(CountDownLatch start, long originalId, String number) throws InterruptedException { + if (!start.await(5, TimeUnit.SECONDS)) throw new AssertionError("start timeout"); + try { + tx.execute(s -> wallet.refundConsumption(9, originalId, number, number, new BigDecimal("40"), + 7, "finance", "correction")); + return true; + } catch (FinanceEventException expected) { + assertThat(expected.code()).isEqualTo("FIN-WALLET-REFUND-EXCEEDED"); + return false; + } + } + + private void assertUntouched() { + assertThat(wallet.account(9).actualBalance()).isEqualByComparingTo("200"); + assertThat(wallet.account(9).frozenBalance()).isEqualByComparingTo("100"); + assertThat(jdbc.queryForObject("SELECT status FROM fin_wallet_hold", String.class)).isEqualTo("FROZEN"); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fin_wallet_ledger", Integer.class)).isZero(); + } +} diff --git a/iam/src/main/resources/db/procurement-permissions.sql b/iam/src/main/resources/db/procurement-permissions.sql new file mode 100644 index 000000000..43e496c1b --- /dev/null +++ b/iam/src/main/resources/db/procurement-permissions.sql @@ -0,0 +1,28 @@ +-- 采购节点动作独立授权;不新建岗位,不以通用编辑权限代替购买与取消。 +INSERT INTO iam_permission(id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,deleted) +VALUES + (5117103,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:buy','采购购买及货款核定',NULL,'POST',803,'ACTIVE',1,0), + (5117104,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:cancel','购买前取消采购订单',NULL,'POST',804,'ACTIVE',1,0), + (5117105,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:receive-overseas','采购海外收货',NULL,'POST',805,'ACTIVE',1,0), + (5117106,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:receive-domestic','采购国内收货',NULL,'POST',806,'ACTIVE',1,0), + (5117107,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:pack','采购装箱与确认',NULL,'POST',807,'ACTIVE',1,0), + (5117108,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:manage','采购差异处置与事实更正',NULL,'POST',808,'ACTIVE',1,0), + (5117109,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:label','采购面单维护',NULL,'POST',809,'ACTIVE',1,0), + (5117110,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:charge','采购国内核费',NULL,'POST',810,'ACTIVE',1,0), + (5117111,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:finance-adjust','采购财务调整',NULL,'POST',811,'ACTIVE',1,0), + (5117112,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:estimate','采购预估运费核对',NULL,'POST',812,'ACTIVE',1,0), + (5117113,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:apply-label','采购逐箱贴单',NULL,'POST',813,'ACTIVE',1,0), + (5117114,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:ship-overseas','采购海外整批交寄',NULL,'POST',814,'ACTIVE',1,0), + (5117115,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:ship-customer','采购国内寄客户',NULL,'POST',815,'ACTIVE',1,0) +ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name),status='ACTIVE',deleted=0; +INSERT IGNORE INTO iam_role_permission(tenant_id,role_id,permission_id,created_at) +SELECT 0,r.id,p.id,NOW(3) FROM iam_role r JOIN iam_permission p + ON p.tenant_id=0 AND p.app_code='PLATFORM' AND p.permission_code IN + ('platform:overseas-business:purchase:buy','platform:overseas-business:purchase:cancel', + 'platform:overseas-business:purchase:receive-overseas','platform:overseas-business:purchase:receive-domestic', + 'platform:overseas-business:purchase:pack','platform:overseas-business:purchase:manage', + 'platform:overseas-business:purchase:label','platform:overseas-business:purchase:charge','platform:overseas-business:purchase:finance-adjust', + 'platform:overseas-business:purchase:estimate','platform:overseas-business:purchase:apply-label', + 'platform:overseas-business:purchase:ship-overseas','platform:overseas-business:purchase:ship-customer') +WHERE r.tenant_id=0 AND r.app_code='PLATFORM' AND UPPER(r.role_code) IN('PLATFORM_ADMIN','PLATFORM_CUSTOM_ADMIN') + AND r.status='ACTIVE' AND r.deleted=0; diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index 332e16471..96083fde7 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -146,7 +146,7 @@ - 列表、详情、导出和更新必须使用同一租户边界,不能仅在列表过滤而让详情或更新越权。 - 目的仓变更必须写状态/操作历史或审计证据,并使用版本控制防止并发覆盖。 - 一件代发废弃:申请只锁箱 `AVAILABLE→DISCARD_PENDING`;平台批准(`lastleg:cargo-discard:approve`)只授权。绑定仓的海外 PDA(`lastleg:pda:operate`)实际确认时,同事务调用 discard adjustment,扣 SKU 件数及库存余额/位置,实际箱转 `DISCARDED` 并写唯一逐箱流水,数量须守恒;未废弃预留箱释放,失败整体回滚。按实际数量汇总费用,平台结案不再扣减。终态同完整载荷重放不新增流水/历史,异数量拒绝;驳回只释放锁箱。完整流程见 `../../docs/头程到尾程分阶段流程/阶段五-海外接柜库存与库内服务.md` 第9.5节。 -- 海外采购客户下单固定进入 `PENDING_REVIEW`;采购编辑可维护入库数量和采购出库明细,出库量为明细数量汇总,审核通过且有入库量后按 `PENDING_OUTBOUND`、`PARTIALLY_OUTBOUND`、`OUTBOUNDED` 派生展示状态。采购出库只记录采购单据流转,不得写入或冒充 `available_packages`;只有海外仓 PDA 完成实收后,`InboundStockReceiptService` 才能以 `PDA_STOCK_IN:{taskId}:{allocationId}` 幂等键调用库存 `stockIn` 转入可用库存。禁止通过通用平台接口绕过 PDA 入可用库存。 +- 海外采购样品履约按 `../../docs/海外仓业务/采购订单样品履约流程变更需求.md` 执行:通用编辑仅限内部备注,实收、装箱、面单、两段交寄和资金走独立节点。采购使用 Admin 履约台账,不接入 PDA、不写 WMS 可用库存。 - 派车状态只允许 PDA 拣货、完成和出库动作推进,普通派车编辑接口不得修改状态。PDA 写操作要求 `lastleg:pda:operate`;后台库存表单操作要求 `lastleg:inventory:manage`,盘点调整另要求 `lastleg:inventory:adjust`,转仓库存操作要求 `lastleg:warehouse-transfer:manage`。 - `ll_order.current_status` 只保存尾程主流程状态;换标、转仓、出库和派送等子单精确状态保存到各自单据并汇总到 `sub_order_status`,不得再用子单状态覆盖主流程状态。PDA 入仓及库存落账完成后,主流程状态固定推进为 `INBOUND_COMPLETED`。 diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/client/DomesticWarehouseDirectoryClient.java b/lastleg-service/src/main/java/com/baiyue/lastleg/client/DomesticWarehouseDirectoryClient.java index 048206462..2c77fb221 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/client/DomesticWarehouseDirectoryClient.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/client/DomesticWarehouseDirectoryClient.java @@ -12,6 +12,15 @@ import java.util.List; @FeignClient(name = "warehouse-service", contextId = "domesticWarehouseDirectoryClient", url = "${baiyue.clients.domestic-warehouse.url:}") public interface DomesticWarehouseDirectoryClient { + @GetMapping("/internal/warehouses/shared-overseas") + ApiResponse> receivingWarehouses(@RequestHeader("X-Internal-Service-Token") String internalToken); + + @GetMapping("/internal/warehouses/shared-overseas/{warehouseId}") + ApiResponse receivingWarehouse(@PathVariable("warehouseId") long warehouseId, + @RequestHeader("X-Internal-Service-Token") String internalToken); + + record ReceivingWarehouse(Long warehouseId, String warehouseCode, String warehouseName, String countryCode, String status) { } + @GetMapping("/internal/warehouses/0/{warehouseId}/active") ApiResponse activeWarehouse( @PathVariable("warehouseId") long warehouseId, @@ -20,7 +29,7 @@ public interface DomesticWarehouseDirectoryClient { record Detail(Warehouse warehouse, List
addresses) { } record Warehouse(Long id, Long tenantId, String warehouseCode, String warehouseName, - String warehouseType, String countryCode, String status) { } + String warehouseType, String countryCode, String status, String contactName, String contactPhone) { } record Address(String addressType, String countryCode, String stateProvince, String city, String district, String postalCode, String addressLine1, String addressLine2) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/entity/OverseasFeeSource.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/entity/OverseasFeeSource.java index da1af7cf5..ebae08ff7 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/entity/OverseasFeeSource.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/entity/OverseasFeeSource.java @@ -43,6 +43,8 @@ public class OverseasFeeSource { private String paymentMethod; private String paymentReferenceNo; @TableField(typeHandler = JacksonTypeHandler.class) private List paymentVoucherFileIds; + @TableField(typeHandler = JacksonTypeHandler.class) + private com.baiyue.common.event.WalletSettlementReference walletSettlement; private LocalDate businessDate; private String currency; private BigDecimal amount; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/mapper/OverseasFeeSourceMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/mapper/OverseasFeeSourceMapper.java index db82e9b59..f107f08ee 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/mapper/OverseasFeeSourceMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/mapper/OverseasFeeSourceMapper.java @@ -9,5 +9,6 @@ import org.apache.ibatis.annotations.Select; @Mapper public interface OverseasFeeSourceMapper extends BaseMapper { @Select("SELECT * FROM ll_finance_fee_source WHERE source_fee_id=#{sourceFeeId} ORDER BY source_version DESC LIMIT 1 FOR UPDATE") + @org.apache.ibatis.annotations.ResultMap("mybatis-plus_OverseasFeeSource") OverseasFeeSource lockCurrent(@Param("sourceFeeId") long sourceFeeId); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxService.java index 98296a317..286079b6a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxService.java @@ -69,7 +69,7 @@ public class OverseasFeeOutboxService { value.getOriginalCurrency(), value.getOriginalAmount(), value.getExchangeRate(), value.getExchangeRateMonth(), value.getChargeableQuantity(), value.getQuantityUnit(), value.getUnitPrice(), value.getPeriodStart(), value.getPeriodEnd(), value.getReviewStatus(), - "PUBLISHED", action == null && hash == null ? "ACTIVE" : value.getSourceStatus(), hash); + "PUBLISHED", action == null && hash == null ? "ACTIVE" : value.getSourceStatus(), hash, value.getWalletSettlement()); } private static String text(Long value) { return value == null ? null : String.valueOf(value); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeSourceService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeSourceService.java index b6a4ab9ff..7f3df7aee 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeSourceService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeSourceService.java @@ -79,14 +79,20 @@ public class OverseasFeeSourceService { if (existing > 0) throw conflict("外接货已有派送应收尚未确认作废,请先完成原费用处理再重新转派送"); } + private static void requireNonProcurement(String sourceType,String businessType) { + if("OVERSEAS_PROCUREMENT_FEE".equals(sourceType) || "OVERSEAS_PROCUREMENT".equals(businessType) || "PROCUREMENT_FEE".equals(businessType)) + throw conflict("采购费用必须通过采购履约或财务节点确认,实扣成功后自动关联来源"); + } + @Transactional public OverseasFeeSource create(String operatorId, String key, SaveRequest request) { + requireNonProcurement(request.sourceType(),request.businessType()); OverseasFeeCommand replay = replay(operatorId, "CREATE", key, request); if (replay != null) return current(replay.getSourceFeeId()); validate(request); businessGuard.requireOwned(request); long sourceFeeId = IdWorker.getId(); - OverseasFeeSource value = build(IdWorker.getId(), sourceFeeId, 1, operatorId, request); + OverseasFeeSource value = build(IdWorker.getId(), sourceFeeId, 1, operatorId, request, null); mapper.insert(value); command(operatorId, "CREATE", key, request, sourceFeeId, 1); return value; @@ -108,11 +114,45 @@ public class OverseasFeeSourceService { return value; } + private record SettledRequest(SaveRequest fee, OverseasFeeCurrencyConverter.Conversion conversion, + com.baiyue.common.event.WalletSettlementReference settlement) { } + + /** Internal business entry: money has already succeeded and its confirmed FX must not be recalculated. */ + @Transactional + public OverseasFeeSource publishSettled(String operatorId, String key, SaveRequest request, + OverseasFeeCurrencyConverter.Conversion conversion, + com.baiyue.common.event.WalletSettlementReference settlement) { + if (!"RECEIVABLE".equals(request.direction()) || conversion == null || settlement == null + || text(settlement.commandEventId()) == null || text(settlement.holdId()) == null || text(settlement.ledgerId()) == null + || !request.currency().equals(conversion.originalCurrency()) + || request.amount().compareTo(conversion.originalAmount()) != 0 + || conversion.settlementAmount().signum() <= 0 || conversion.exchangeRate().signum() <= 0 + || !request.businessDate().toString().substring(0, 7).equals(conversion.exchangeRateMonth())) { + throw bad("已扣费用的资金引用或确认汇率快照不完整"); + } + var confirmed = new SettledRequest(request, conversion, settlement); + OverseasFeeCommand replay = replay(operatorId, "PUBLISH_SETTLED", key, confirmed); + if (replay != null) return current(replay.getSourceFeeId()); + validate(request); + businessGuard.requireOwned(request); + long sourceFeeId = IdWorker.getId(); + OverseasFeeSource value = build(IdWorker.getId(), sourceFeeId, 1, operatorId, request, conversion); + value.setWalletSettlement(settlement); + value.setReviewStatus("APPROVED"); value.setPublicationStatus("PUBLISHED"); + value.setReviewerId(operatorId); value.setReviewedAt(now()); + value.setReviewReason("已核定业务费用扣款成功后发布"); + mapper.insert(value); + outbox.publish(value, "PUBLISHED"); + command(operatorId, "PUBLISH_SETTLED", key, confirmed, sourceFeeId, 1); + return value; + } + @Transactional public OverseasFeeSource submit(long sourceFeeId, String operatorId, String key) { OverseasFeeCommand replay = replay(operatorId, "SUBMIT", key, sourceFeeId); if (replay != null) return current(replay.getSourceFeeId()); OverseasFeeSource value = lock(sourceFeeId); + requireNonProcurement(value.getSourceType(),value.getBusinessType()); if (!"DRAFT".equals(value.getReviewStatus()) && !"REJECTED".equals(value.getReviewStatus())) throw conflict("只有草稿或已驳回费用可以提交"); value.setReviewStatus("PENDING"); value.setReviewReason(null); @@ -126,6 +166,7 @@ public class OverseasFeeSourceService { OverseasFeeCommand replay = replay(operatorId, "REVIEW", request.idempotencyKey(), request); if (replay != null) return current(replay.getSourceFeeId()); OverseasFeeSource value = lock(sourceFeeId); + requireNonProcurement(value.getSourceType(),value.getBusinessType()); if (!"PENDING".equals(value.getReviewStatus()) || !"ACTIVE".equals(value.getSourceStatus())) throw conflict("只有待审核的有效费用可以审核"); boolean approved = "APPROVE".equals(request.decision()); @@ -143,6 +184,8 @@ public class OverseasFeeSourceService { OverseasFeeCommand replay = replay(operatorId, "ADJUST", request.idempotencyKey(), request); if (replay != null) return current(replay.getSourceFeeId()); OverseasFeeSource old = lock(sourceFeeId); + if (old.getWalletSettlement() != null) throw conflict("已扣费用只能通过关联的财务差额或退款调整"); + requireNonProcurement(old.getSourceType(),old.getBusinessType()); if (cancellation.requested(old)) throw conflict("该费用已申请作废,请先处理财务结果"); if (!"APPROVED".equals(old.getReviewStatus()) || !"ACTIVE".equals(old.getSourceStatus()) || old.getSourceVersion() != request.expectedVersion()) throw conflict("只有当前已发布版本可以调整"); @@ -157,7 +200,7 @@ public class OverseasFeeSourceService { || !Objects.equals(old.getContainerNo(), text(request.fee().containerNo()))) throw conflict("调整不得改变费用来源或业务归属"); old.setSourceStatus("SUPERSEDED"); old.setUpdatedBy(operatorId); old.setUpdatedAt(now()); mapper.updateById(old); - OverseasFeeSource next = build(IdWorker.getId(), sourceFeeId, old.getSourceVersion() + 1, operatorId, request.fee()); + OverseasFeeSource next = build(IdWorker.getId(), sourceFeeId, old.getSourceVersion() + 1, operatorId, request.fee(), null); next.setReviewStatus("PENDING"); mapper.insert(next); command(operatorId, "ADJUST", request.idempotencyKey(), request, sourceFeeId, next.getSourceVersion()); @@ -169,6 +212,8 @@ public class OverseasFeeSourceService { OverseasFeeCommand replay = replay(operatorId, "VOID", request.idempotencyKey(), request); if (replay != null) return current(replay.getSourceFeeId()); OverseasFeeSource value = lock(sourceFeeId); + requireNonProcurement(value.getSourceType(),value.getBusinessType()); + if (value.getWalletSettlement() != null) throw conflict("已扣费用不能作废,请使用关联退款"); if (!"APPROVED".equals(value.getReviewStatus()) || !"ACTIVE".equals(value.getSourceStatus()) || value.getSourceVersion() != request.expectedVersion()) throw conflict("只有当前已发布版本可以作废"); if (cancellation.requested(value)) return cancellation.describe(List.of(value)).get(0); @@ -183,7 +228,8 @@ public class OverseasFeeSourceService { return value; } - private OverseasFeeSource build(long id, long sourceFeeId, int version, String operatorId, SaveRequest request) { + private OverseasFeeSource build(long id, long sourceFeeId, int version, String operatorId, SaveRequest request, + OverseasFeeCurrencyConverter.Conversion confirmedConversion) { OverseasFeeSource value = new OverseasFeeSource(); value.setId(id); value.setSourceFeeId(sourceFeeId); value.setSourceVersion(version); value.setCustomerId(request.customerId().trim()); value.setCustomerName(text(request.customerName())); @@ -196,7 +242,8 @@ public class OverseasFeeSourceService { value.setSupplierCategory(text(request.supplierCategory())); value.setSettlementMode(text(request.settlementMode())); value.setPaidAt(request.paidAt()); value.setPaymentMethod(text(request.paymentMethod())); value.setPaymentReferenceNo(text(request.paymentReferenceNo())); value.setPaymentVoucherFileIds(request.paymentVoucherFileIds()); value.setBusinessDate(request.businessDate()); - OverseasFeeCurrencyConverter.Conversion conversion = currencyConverter.convert(request.currency(), request.amount(), request.businessDate()); + OverseasFeeCurrencyConverter.Conversion conversion = confirmedConversion != null ? confirmedConversion + : currencyConverter.convert(request.currency(), request.amount(), request.businessDate()); value.setCurrency("CNY"); value.setAmount(conversion.settlementAmount()); value.setOriginalCurrency(conversion.originalCurrency()); value.setOriginalAmount(conversion.originalAmount()); value.setExchangeRate(conversion.exchangeRate()); value.setExchangeRateMonth(conversion.exchangeRateMonth()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/MerchantOverseasProcurementController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/MerchantOverseasProcurementController.java index e674063fd..2ab349d27 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/MerchantOverseasProcurementController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/MerchantOverseasProcurementController.java @@ -24,13 +24,15 @@ import org.springframework.web.server.ResponseStatusException; @RequestMapping("/api/merchant/v1/last-leg/overseas-procurements") public class MerchantOverseasProcurementController { @Resource private OverseasProcurementService service; + @Resource private com.baiyue.lastleg.procurement.service.ProcurementQueryService queryService; @PostMapping @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_PROCUREMENT_CREATE + "')") - public ApiResponse create( + public ApiResponse create( @AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody OverseasProcurementRequests.Create request) { - return ApiResponse.success(service.create(customer(user), operator(user), request)); + var created=service.create(customer(user),operator(user),request); + return ApiResponse.success(queryService.customer(customer(user),created.summary().orderNo())); } @GetMapping @@ -42,9 +44,9 @@ public class MerchantOverseasProcurementController { @GetMapping("/{orderNo}") @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_PROCUREMENT_LIST + "')") - public ApiResponse detail( + public ApiResponse detail( @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String orderNo) { - return ApiResponse.success(service.detail(customer(user), orderNo)); + return ApiResponse.success(queryService.customer(customer(user),orderNo)); } private String customer(AuthenticatedUser user) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/PlatformOverseasProcurementController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/PlatformOverseasProcurementController.java index a444577ea..80af7c406 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/PlatformOverseasProcurementController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/PlatformOverseasProcurementController.java @@ -32,6 +32,7 @@ import java.util.List; @RequestMapping("/api/platform/v1/last-leg/overseas-procurements") public class PlatformOverseasProcurementController { @Resource private OverseasProcurementService service; + @Resource private com.baiyue.lastleg.procurement.service.ProcurementQueryService queryService; @GetMapping @PreAuthorize("hasAuthority('" + OverseasPermissions.PURCHASE_LIST + "')") @@ -67,11 +68,11 @@ public class PlatformOverseasProcurementController { @GetMapping("/{orderNo}") @PreAuthorize("hasAuthority('" + OverseasPermissions.PURCHASE_LIST + "')") @Operation(summary = "Get an overseas procurement order", description = "Required authority: " + OverseasPermissions.PURCHASE_LIST) - public ApiResponse detail( + public ApiResponse detail( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId, @PathVariable String orderNo) { requirePlatform(user); - return ApiResponse.success(service.detail(requiredCustomer(customerId), orderNo)); + return ApiResponse.success(queryService.platform(requiredCustomer(customerId), orderNo,user.permissions())); } @PostMapping("/{orderNo}/review") @@ -94,25 +95,6 @@ public class PlatformOverseasProcurementController { return ApiResponse.success(service.update(requiredCustomer(customerId), orderNo, operator(user), request)); } - @PostMapping(value = "/{orderNo}/files/{category}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - @PreAuthorize("hasAuthority('" + OverseasPermissions.PURCHASE_EDIT + "')") - public ApiResponse uploadFile( - @AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId, - @PathVariable String orderNo, @PathVariable String category, @RequestPart("file") MultipartFile file) { - requirePermission(user, OverseasProcurementService.MANAGE_PERMISSION); - return ApiResponse.success(service.uploadFile(requiredCustomer(customerId), orderNo, - String.valueOf(user.userId()), category.trim().toUpperCase(), file)); - } - - @PostMapping("/{orderNo}/fees") - @PreAuthorize("hasAuthority('" + OverseasPermissions.PURCHASE_EDIT + "')") - public ApiResponse createFee( - @AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId, - @PathVariable String orderNo, @Valid @RequestBody OverseasProcurementRequests.CreateFee request) { - requirePermission(user, OverseasProcurementService.MANAGE_PERMISSION); - return ApiResponse.success(service.createFee(requiredCustomer(customerId), orderNo, operator(user), request)); - } - private void requirePlatform(AuthenticatedUser user) { if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); if (!"PLATFORM".equals(user.appCode()) || user.tenantId() != 0L) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/ProcurementChargeController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/ProcurementChargeController.java new file mode 100644 index 000000000..1234d76b9 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/ProcurementChargeController.java @@ -0,0 +1,77 @@ +package com.baiyue.lastleg.procurement.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.procurement.dto.ProcurementChargeRequests.*; +import com.baiyue.lastleg.procurement.dto.ProcurementLifecycleRequests.Command; +import com.baiyue.lastleg.procurement.entity.OverseasProcurementFee; +import com.baiyue.lastleg.procurement.service.ProcurementChargeService; +import com.baiyue.lastleg.procurement.service.ProcurementAdjustmentService; +import com.baiyue.lastleg.security.OverseasPermissions; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; +import static com.baiyue.lastleg.procurement.controller.ProcurementLifecycleController.platform; + +@RestController @RequiredArgsConstructor +@RequestMapping("/api/platform/v1/last-leg/overseas-procurements") +public class ProcurementChargeController { + private final ProcurementChargeService service; + private final ProcurementAdjustmentService adjustments; + @GetMapping("/{orderNo}/charges") + @PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_LIST+"')") + public ApiResponse read(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo) { + platform(user,customerId);return ApiResponse.success(service.read(customerId,orderNo)); + } + @GetMapping("/{orderNo}/fee-corrections") + @PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_LIST+"')") + public ApiResponse> corrections(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo) { + platform(user,customerId);return ApiResponse.success(adjustments.history(customerId,orderNo)); + } + @PostMapping("/{orderNo}/charges/confirm") + @PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_CHARGE+"')") + public ApiResponse confirm(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@Valid @RequestBody Confirm request) { + return ApiResponse.success(service.confirm(customerId,orderNo,platform(user,customerId),request)); + } + @PostMapping("/{orderNo}/fees/additional") + @PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_FINANCE_ADJUST+"')") + public ApiResponse additional(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@Valid @RequestBody Additional request) { + return ApiResponse.success(adjustments.additional(customerId,orderNo,platform(user,customerId),request)); + } + @PostMapping("/{orderNo}/fees/{feeId}/retry") + @PreAuthorize("isAuthenticated()") + public ApiResponse retry(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@PathVariable long feeId,@Valid @RequestBody Command request) { + String operator=platform(user,customerId);String permission=service.retryPermission(customerId,orderNo,feeId); + if(user.permissions()==null || !user.permissions().contains(permission))throw new ResponseStatusException(HttpStatus.FORBIDDEN,"没有该费用用途的重试权限"); + return ApiResponse.success(service.retry(customerId,orderNo,operator,feeId,request)); + } + @PostMapping("/{orderNo}/fees/{feeId}/terminate") + @PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_FINANCE_ADJUST+"')") + public ApiResponse terminate(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@PathVariable long feeId,@Valid @RequestBody Command request) { + return ApiResponse.success(service.terminate(customerId,orderNo,platform(user,customerId),feeId,request)); + } + @PostMapping("/{orderNo}/fees/{feeId}/release-estimate") + @PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_FINANCE_ADJUST+"')") + public ApiResponse releaseEstimate(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@PathVariable long feeId,@Valid @RequestBody Command request) { + return ApiResponse.success(service.releaseEstimate(customerId,orderNo,platform(user,customerId),feeId,request)); + } + @PostMapping("/{orderNo}/fees/{feeId}/correct-target") + @PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_FINANCE_ADJUST+"')") + public ApiResponse correct(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@PathVariable long feeId,@Valid @RequestBody Correct request) { + return ApiResponse.success(adjustments.correct(customerId,orderNo,platform(user,customerId),feeId,request)); + } + @PostMapping("/{orderNo}/fees/{feeId}/replace-failed") + @PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_FINANCE_ADJUST+"')") + public ApiResponse replaceFailed(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@PathVariable long feeId,@Valid @RequestBody Correct request) { + return ApiResponse.success(adjustments.replaceFailed(customerId,orderNo,platform(user,customerId),feeId,request)); + } + @PostMapping("/{orderNo}/fees/{feeId}/refund") + @PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_FINANCE_ADJUST+"')") + public ApiResponse refund(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@PathVariable long feeId,@Valid @RequestBody Refund request) { + return ApiResponse.success(adjustments.refund(customerId,orderNo,platform(user,customerId),feeId,request)); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/ProcurementControlController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/ProcurementControlController.java new file mode 100644 index 000000000..fb203bbe2 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/ProcurementControlController.java @@ -0,0 +1,45 @@ +package com.baiyue.lastleg.procurement.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.procurement.dto.ProcurementControlRequests.*; +import com.baiyue.lastleg.procurement.service.ProcurementControlService; +import com.baiyue.lastleg.procurement.service.ProcurementControlService.Snapshot; +import com.baiyue.lastleg.security.OverseasPermissions; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import static com.baiyue.lastleg.procurement.controller.ProcurementLifecycleController.platform; + +@RestController @RequiredArgsConstructor +@RequestMapping("/api/platform/v1/last-leg/overseas-procurements") +public class ProcurementControlController { + private final ProcurementControlService service; + @GetMapping("/{orderNo}/controls") + @PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_LIST+"')") + public ApiResponse read(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo) { + platform(user,customerId);return ApiResponse.success(service.read(customerId,orderNo)); + } + @PostMapping("/{orderNo}/control-notes") + @PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_MANAGE+"')") + public ApiResponse note(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@Valid @RequestBody Note request) { + return ApiResponse.success(service.note(customerId,orderNo,platform(user,customerId),request)); + } + @PostMapping("/{orderNo}/control-notes/{noteId}/resolve") + @PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_MANAGE+"')") + public ApiResponse resolve(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@PathVariable long noteId,@Valid @RequestBody Resolve request) { + return ApiResponse.success(service.resolve(customerId,orderNo,platform(user,customerId),noteId,request)); + } + @PostMapping("/{orderNo}/actual-warehouse/correct") + @PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_MANAGE+"')") + public ApiResponse warehouse(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@Valid @RequestBody WarehouseCorrection request) { + return ApiResponse.success(service.correctWarehouse(customerId,orderNo,platform(user,customerId),request)); + } + @PostMapping("/{orderNo}/exception-close") + @PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_MANAGE+"')") + public ApiResponse close(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@Valid @RequestBody Close request) { + return ApiResponse.success(service.close(customerId,orderNo,platform(user,customerId),request)); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/ProcurementFileController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/ProcurementFileController.java new file mode 100644 index 000000000..bd18fac9d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/ProcurementFileController.java @@ -0,0 +1,49 @@ +package com.baiyue.lastleg.procurement.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.procurement.dto.OverseasProcurementResponses.FileInfo; +import com.baiyue.lastleg.procurement.gateway.ProcurementFileCategory; +import com.baiyue.lastleg.procurement.service.OverseasProcurementService; +import com.baiyue.lastleg.security.OverseasPermissions; +import lombok.RequiredArgsConstructor; +import org.springframework.http.*; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.server.ResponseStatusException; +import java.nio.charset.StandardCharsets; +import java.util.Set; +import static com.baiyue.lastleg.procurement.controller.ProcurementLifecycleController.platform; + +@RestController @RequiredArgsConstructor +@RequestMapping("/api/platform/v1/last-leg/overseas-procurements") +public class ProcurementFileController { + private final OverseasProcurementService service; + @PostMapping(value="/{orderNo}/files/{category}",consumes=MediaType.MULTIPART_FORM_DATA_VALUE) + @PreAuthorize("isAuthenticated()") + public ApiResponse upload(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId, + @PathVariable String orderNo,@PathVariable String category,@RequestPart("file") MultipartFile file) { + String operator=platform(user,customerId);var kind=ProcurementFileCategory.parse(category); + Set allowed=switch(kind) { + case PURCHASE->Set.of(OverseasPermissions.PURCHASE_BUY); + case BUDGET->Set.of(OverseasPermissions.PURCHASE_REVIEW); + case LABEL->Set.of(OverseasPermissions.PURCHASE_LABEL); + case EXCEPTION->Set.of(OverseasPermissions.PURCHASE_MANAGE); + case RECEIPT->Set.of(OverseasPermissions.PURCHASE_RECEIVE_OVERSEAS,OverseasPermissions.PURCHASE_RECEIVE_DOMESTIC); + case VOUCHER->Set.of(OverseasPermissions.PURCHASE_CHARGE,OverseasPermissions.PURCHASE_FINANCE_ADJUST); + }; + if(user.permissions()==null || allowed.stream().noneMatch(user.permissions()::contains))throw new ResponseStatusException(HttpStatus.FORBIDDEN,"没有该采购文件用途的上传权限"); + return ApiResponse.success(service.uploadFile(customerId,orderNo,operator,kind.name(),file)); + } + @GetMapping("/{orderNo}/files/{fileId}/content") + @PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_LIST+"')") + public ResponseEntity download(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId, + @PathVariable String orderNo,@PathVariable long fileId) { + platform(user,customerId);var file=service.downloadFile(customerId,orderNo,fileId); + return ResponseEntity.ok().cacheControl(CacheControl.noStore()).contentType(MediaType.parseMediaType(file.contentType())) + .header(HttpHeaders.CONTENT_DISPOSITION,ContentDisposition.attachment().filename(file.fileName(),StandardCharsets.UTF_8).build().toString()) + .contentLength(file.content().length).body(file.content()); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/ProcurementLifecycleController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/ProcurementLifecycleController.java new file mode 100644 index 000000000..b7ec2fd45 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/ProcurementLifecycleController.java @@ -0,0 +1,64 @@ +package com.baiyue.lastleg.procurement.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.procurement.dto.ProcurementLifecycleRequests.*; +import com.baiyue.lastleg.procurement.service.ProcurementLifecycleService; +import com.baiyue.lastleg.procurement.service.ProcurementLifecycleService.Outcome; +import com.baiyue.lastleg.security.OverseasPermissions; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/platform/v1/last-leg/overseas-procurements") +public class ProcurementLifecycleController { + private final ProcurementLifecycleService service; + + @GetMapping("/{orderNo}/fulfillment") + @PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_LIST+"')") + public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo) { + platform(user,customerId); return ApiResponse.success(service.read(customerId,orderNo)); + } + @PostMapping("/{orderNo}/purchase") + @PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_BUY+"')") + public ApiResponse purchase(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId, + @PathVariable String orderNo,@Valid @RequestBody Purchase request) { + return ApiResponse.success(service.recordPurchase(customerId,orderNo,platform(user,customerId),request)); + } + @PostMapping("/{orderNo}/purchase/confirm") + @PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_BUY+"')") + public ApiResponse confirmPurchase(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId, + @PathVariable String orderNo,@Valid @RequestBody Command request) { + return ApiResponse.success(service.confirmPurchase(customerId,orderNo,platform(user,customerId),request)); + } + @PostMapping("/{orderNo}/budget-authorizations") + @PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_REVIEW+"')") + public ApiResponse budget(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId, + @PathVariable String orderNo,@Valid @RequestBody Budget request) { + return ApiResponse.success(service.authorizeBudget(customerId,orderNo,platform(user,customerId),request)); + } + @PostMapping("/{orderNo}/planned-warehouse") + @PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_REVIEW+"')") + public ApiResponse warehouse(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId, + @PathVariable String orderNo,@Valid @RequestBody Warehouse request) { + return ApiResponse.success(service.selectWarehouse(customerId,orderNo,platform(user,customerId),request)); + } + @PostMapping("/{orderNo}/cancel") + @PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_CANCEL+"')") + public ApiResponse cancel(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId, + @PathVariable String orderNo,@Valid @RequestBody Command request) { + return ApiResponse.success(service.cancel(customerId,orderNo,platform(user,customerId),request)); + } + static String platform(AuthenticatedUser user,String customerId) { + if(user==null || user.tenantId()==null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + if(user.tenantId()!=0 || !"PLATFORM".equals(user.appCode())) throw new ResponseStatusException(HttpStatus.FORBIDDEN); + if(customerId==null || !customerId.matches("[1-9][0-9]*")) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"请指定采购订单客户"); + return user.userId()==null?user.username():String.valueOf(user.userId()); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/ProcurementPhysicalController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/ProcurementPhysicalController.java new file mode 100644 index 000000000..355e94602 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/ProcurementPhysicalController.java @@ -0,0 +1,82 @@ +package com.baiyue.lastleg.procurement.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.procurement.dto.ProcurementLifecycleRequests.Command; +import com.baiyue.lastleg.procurement.dto.ProcurementPhysicalRequests.*; +import com.baiyue.lastleg.procurement.service.ProcurementPhysicalService; +import com.baiyue.lastleg.procurement.service.ProcurementPhysicalService.Snapshot; +import com.baiyue.lastleg.security.OverseasPermissions; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; +import static com.baiyue.lastleg.procurement.controller.ProcurementLifecycleController.platform; + +@RestController @RequiredArgsConstructor +@RequestMapping("/api/platform/v1/last-leg/overseas-procurements") +public class ProcurementPhysicalController { + private final ProcurementPhysicalService service; + @GetMapping("/{orderNo}/physical") + @PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_LIST+"')") + public ApiResponse read(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo) { + platform(user,customerId);return ApiResponse.success(service.read(customerId,orderNo)); + } + @PostMapping("/{orderNo}/overseas-receipts") + @PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_RECEIVE_OVERSEAS+"')") + public ApiResponse overseas(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@Valid @RequestBody Receipt request) { + String operator=platform(user,customerId);leg(request.leg(),Leg.OVERSEAS);return ApiResponse.success(service.receive(customerId,orderNo,operator,request)); + } + @PostMapping("/{orderNo}/domestic-receipts") + @PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_RECEIVE_DOMESTIC+"')") + public ApiResponse domestic(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@Valid @RequestBody Receipt request) { + String operator=platform(user,customerId);leg(request.leg(),Leg.DOMESTIC);return ApiResponse.success(service.receive(customerId,orderNo,operator,request)); + } + @PostMapping("/{orderNo}/overseas-receipts/complete") + @PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_RECEIVE_OVERSEAS+"')") + public ApiResponse completeOverseas(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@Valid @RequestBody FinishReceipt request) { + String operator=platform(user,customerId);leg(request.leg(),Leg.OVERSEAS);return ApiResponse.success(service.finishReceipt(customerId,orderNo,operator,request)); + } + @PostMapping("/{orderNo}/domestic-receipts/complete") + @PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_RECEIVE_DOMESTIC+"')") + public ApiResponse completeDomestic(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@Valid @RequestBody FinishReceipt request) { + String operator=platform(user,customerId);leg(request.leg(),Leg.DOMESTIC);return ApiResponse.success(service.finishReceipt(customerId,orderNo,operator,request)); + } + @PostMapping("/{orderNo}/dispositions") + @PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_MANAGE+"')") + public ApiResponse dispose(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@Valid @RequestBody Disposition request) { + return ApiResponse.success(service.dispose(customerId,orderNo,platform(user,customerId),request)); + } + @PostMapping("/{orderNo}/packing") + @PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_PACK+"')") + public ApiResponse packing(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@Valid @RequestBody Packing request) { + return ApiResponse.success(service.savePacking(customerId,orderNo,platform(user,customerId),request)); + } + @PostMapping("/{orderNo}/packing/confirm") + @PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_PACK+"')") + public ApiResponse confirmPacking(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@Valid @RequestBody ConfirmPacking request) { + return ApiResponse.success(service.confirmPacking(customerId,orderNo,platform(user,customerId),request)); + } + @PostMapping("/{orderNo}/packing/withdraw") + @PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_MANAGE+"')") + public ApiResponse withdraw(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@Valid @RequestBody Command request) { + return ApiResponse.success(service.withdrawPacking(customerId,orderNo,platform(user,customerId),request)); + } + private static void leg(Leg actual,Leg expected){if(actual!=expected)throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"收货段别与操作入口不一致");} + + @PostMapping("/{orderNo}/receipts/{receiptId}/correct") + @PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_MANAGE+"')") + public ApiResponse correct(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo, + @PathVariable long receiptId,@Valid @RequestBody Correction request) { + return ApiResponse.success(service.correctReceipt(customerId,orderNo,platform(user,customerId),receiptId,request)); + } + @PostMapping("/{orderNo}/dispositions/{dispositionId}/revoke") + @PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_MANAGE+"')") + public ApiResponse revoke(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo, + @PathVariable long dispositionId,@Valid @RequestBody RevokeDisposition request) { + return ApiResponse.success(service.revokeDisposition(customerId,orderNo,platform(user,customerId),dispositionId,request)); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/ProcurementTransportController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/ProcurementTransportController.java new file mode 100644 index 000000000..0110c7f85 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/ProcurementTransportController.java @@ -0,0 +1,59 @@ +package com.baiyue.lastleg.procurement.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.procurement.dto.ProcurementPhysicalRequests.Leg; +import com.baiyue.lastleg.procurement.dto.ProcurementTransportRequests.*; +import com.baiyue.lastleg.procurement.service.ProcurementTransportService; +import com.baiyue.lastleg.procurement.service.ProcurementTransportService.Snapshot; +import com.baiyue.lastleg.security.OverseasPermissions; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; +import static com.baiyue.lastleg.procurement.controller.ProcurementLifecycleController.platform; + +@RestController @RequiredArgsConstructor +@RequestMapping("/api/platform/v1/last-leg/overseas-procurements") +public class ProcurementTransportController { + private final ProcurementTransportService service; + @GetMapping("/{orderNo}/transport") + @PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_LIST+"')") + public ApiResponse read(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo) { + platform(user,customerId);return ApiResponse.success(service.read(customerId,orderNo)); + } + @PostMapping("/{orderNo}/labels") + @PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_LABEL+"')") + public ApiResponse labels(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@Valid @RequestBody Labels request) { + return ApiResponse.success(service.saveLabels(customerId,orderNo,platform(user,customerId),request)); + } + @PostMapping("/{orderNo}/international-estimate") + @PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_ESTIMATE+"')") + public ApiResponse estimate(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@Valid @RequestBody Estimate request) { + return ApiResponse.success(service.reviewEstimate(customerId,orderNo,platform(user,customerId),request)); + } + @PostMapping("/{orderNo}/labels/apply") + @PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_APPLY_LABEL+"')") + public ApiResponse apply(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@Valid @RequestBody ApplyLabels request) { + return ApiResponse.success(service.applyLabels(customerId,orderNo,platform(user,customerId),request)); + } + @PostMapping("/{orderNo}/domestic-repack") + @PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_RECEIVE_DOMESTIC+"')") + public ApiResponse repack(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@Valid @RequestBody Repack request) { + return ApiResponse.success(service.repack(customerId,orderNo,platform(user,customerId),request)); + } + @PostMapping("/{orderNo}/overseas-shipment") + @PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_SHIP_OVERSEAS+"')") + public ApiResponse overseas(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@Valid @RequestBody Ship request) { + String operator=platform(user,customerId);leg(request.leg(),Leg.OVERSEAS);return ApiResponse.success(service.ship(customerId,orderNo,operator,request)); + } + @PostMapping("/{orderNo}/customer-shipment") + @PreAuthorize("hasAuthority('"+OverseasPermissions.PURCHASE_SHIP_CUSTOMER+"')") + public ApiResponse customer(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String customerId,@PathVariable String orderNo,@Valid @RequestBody Ship request) { + String operator=platform(user,customerId);leg(request.leg(),Leg.DOMESTIC);return ApiResponse.success(service.ship(customerId,orderNo,operator,request)); + } + private static void leg(Leg actual,Leg expected){if(actual!=expected)throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"运输段与操作入口不一致");} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementRequests.java index 453e9a0bd..0b9b3f9d7 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementRequests.java @@ -22,11 +22,11 @@ public final class OverseasProcurementRequests { @Size(max = 500) String productSpecification, @Size(max = 2000) String productUrl, @NotNull @Positive Long purchaseQuantity, - @DecimalMin(value = "0.00") BigDecimal budgetUnitPrice, - @Size(min = 3, max = 3) String currency, - @Size(max = 1000) String receivingAddress, + @NotNull @DecimalMin(value = "0.00") @jakarta.validation.constraints.Digits(integer=16,fraction=2) BigDecimal budgetUnitPrice, + @NotBlank @jakarta.validation.constraints.Pattern(regexp="CNY|USD") String currency, + @NotBlank @Size(max = 1000) String receivingAddress, @NotBlank @Size(max = 128) String contactName, - @NotBlank @Size(max = 64) String contactPhone, + @NotBlank @Size(max = 64) @jakarta.validation.constraints.Pattern(regexp="[+0-9][0-9 ()-]{5,63}") String contactPhone, @Size(max = 1000) String remark, @NotBlank @Size(max = 255) String idempotencyKey) {} @@ -51,38 +51,15 @@ public final class OverseasProcurementRequests { @NotNull @Min(0) Long version, @NotBlank String decision, @NotBlank @Size(max = 255) String idempotencyKey, - @Size(max = 1000) String remark) {} - - public record CreateFee( - @NotBlank @Size(max = 32) String feeItemCode, - @NotBlank @Size(max = 128) String feeItemName, - @NotNull @DecimalMin(value = "0.01") BigDecimal amount, - @NotNull @Positive Long voucherFileId, - @Size(max = 500) String remark) {} + @Size(max = 1000) String remark, + @Positive Long purchaseWarehouseId) {} public record Update( @NotNull @Min(0) Long version, - @Size(max = 128) String purchaseCategory, - @Size(max = 255) String productName, - JsonNode productSpecification, - JsonNode productUrl, - @Min(0) Long inboundQuantity, - JsonNode purchaseWarehouseId, - JsonNode purchaseWarehouseName, - JsonNode domesticCarrier, - JsonNode domesticTrackingNo, - JsonNode overseasCarrier, - JsonNode overseasTrackingNo, - JsonNode estimatedArrivalAt, - JsonNode remark, - JsonNode outboundDetails) { - public Update(Long version, String purchaseCategory, String productName, JsonNode productSpecification, - JsonNode productUrl, Long inboundQuantity, JsonNode purchaseWarehouseId, JsonNode purchaseWarehouseName, - JsonNode domesticCarrier, JsonNode domesticTrackingNo, JsonNode overseasCarrier, JsonNode overseasTrackingNo, - JsonNode estimatedArrivalAt, JsonNode remark) { - this(version, purchaseCategory, productName, productSpecification, productUrl, inboundQuantity, - purchaseWarehouseId, purchaseWarehouseName, domesticCarrier, domesticTrackingNo, overseasCarrier, - overseasTrackingNo, estimatedArrivalAt, remark, null); + JsonNode remark) { + @com.fasterxml.jackson.annotation.JsonAnySetter + public void rejectUnknownField(String name, JsonNode value) { + throw new IllegalArgumentException("采购通用编辑仅支持备注,业务字段请使用对应履约操作"); } } } 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 9a48f9b4a..685c764d8 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 @@ -7,14 +7,11 @@ import java.util.List; public final class OverseasProcurementResponses { private OverseasProcurementResponses() {} - public record OutboundDetail(String id, long quantity, String carrier, String trackingNo, LocalDateTime shippedAt, String remark) {} - public record Summary( Long id, String orderNo, String customerId, String customerName, String purchaseCategory, String productName, String productSpecification, long purchaseQuantity, BigDecimal budgetUnitPrice, String currency, String purchaseWarehouseId, String purchaseWarehouseName, - long inboundQuantity, long outboundQuantity, long inventoryQuantity, String status, long version, LocalDateTime createdAt, LocalDateTime updatedAt) {} public record CustomerOption(String customerId, String customerName) {} @@ -38,10 +35,9 @@ public final class OverseasProcurementResponses { public record Detail( Summary summary, String productUrl, String receivingAddress, String contactName, String contactPhone, - String domesticCarrier, String domesticTrackingNo, - String overseasCarrier, String overseasTrackingNo, LocalDateTime estimatedArrivalAt, String remark, String createdBy, String reviewedBy, String reviewRemark, LocalDateTime reviewedAt, - List histories, List outboundDetails, + List histories, List files, List fees) {} + } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/ProcurementChargeRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/ProcurementChargeRequests.java new file mode 100644 index 000000000..ca91847fa --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/ProcurementChargeRequests.java @@ -0,0 +1,35 @@ +package com.baiyue.lastleg.procurement.dto; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; + +public final class ProcurementChargeRequests { + private ProcurementChargeRequests() { } + public record Amount(@NotBlank @Pattern(regexp="POSITIVE|ZERO|NOT_APPLICABLE") String determination, + @NotNull @DecimalMin("0") @Digits(integer=16,fraction=2) BigDecimal amount, + @NotBlank @Pattern(regexp="CNY|USD") String currency,@NotNull LocalDate businessDate, + @Positive Long voucherFileId,@NotBlank @Size(max=500) String reason) { } + /** Existing immutable fee OR a newly determined amount; never both. */ + public record Selection(@Positive Long feeId,@Valid Amount value) { } + public record Other(@NotBlank @Pattern(regexp="[A-Z][A-Z0-9_]{0,31}") String code, + @NotBlank @Size(max=128) String name,@NotNull @Valid Selection selection) { } + public record Confirm(@NotNull @Min(0) Long version,@NotBlank @Size(max=128) String idempotencyKey, + @NotNull @Valid Selection internationalFreight,@NotNull @Valid Selection duty, + @NotNull @Valid Selection domesticFreight, + @NotBlank @Pattern(regexp="NOT_APPLICABLE|ITEMIZED") String otherDetermination, + @NotNull List<@NotNull @Valid Other> otherFees,@NotBlank @Size(max=1000) String reason) { } + public record Additional(@NotNull @Min(0) Long version,@NotBlank @Size(max=128) String idempotencyKey, + @NotBlank @Size(max=128) String name, + @NotNull @DecimalMin(value="0",inclusive=false) @Digits(integer=16,fraction=2) BigDecimal amount, + @NotBlank @Pattern(regexp="CNY|USD") String currency,@NotNull @PastOrPresent LocalDate businessDate, + @Positive long voucherFileId,@NotBlank @Size(max=500) String reason) { } + public record Correct(@NotNull @Min(0) Long version,@NotBlank @Size(max=128) String idempotencyKey, + @NotNull @DecimalMin("0") @Digits(integer=16,fraction=2) BigDecimal targetOriginalAmount, + @Positive long voucherFileId,@NotBlank @Size(max=500) String reason) { } + public record Refund(@NotNull @Min(0) Long version,@NotBlank @Size(max=128) String idempotencyKey, + @NotNull @DecimalMin(value="0",inclusive=false) @Digits(integer=16,fraction=2) BigDecimal amount, + @Positive long voucherFileId,@NotBlank @Size(max=500) String reason) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/ProcurementControlRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/ProcurementControlRequests.java new file mode 100644 index 000000000..1c7a9dd4b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/ProcurementControlRequests.java @@ -0,0 +1,25 @@ +package com.baiyue.lastleg.procurement.dto; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import java.util.List; + +public final class ProcurementControlRequests { + private ProcurementControlRequests() { } + public record Note(@NotNull @Min(0) Long version,@NotBlank @Size(max=128) String idempotencyKey, + @NotBlank @Pattern(regexp="PURCHASE|WAREHOUSE|RECEIPT|SHIPMENT") String targetType,@Positive Long targetId, + boolean blocking,@Positive long voucherFileId,@NotBlank @Size(max=1000) String reason, + @Size(max=500) String clientSummary) { } + public record Resolve(@NotNull @Min(0) Long version,@NotBlank @Size(max=128) String idempotencyKey, + @Positive long voucherFileId,@NotBlank @Size(max=1000) String reason) { } + public record WarehouseCorrection(@NotNull @Min(0) Long version,@NotBlank @Size(max=128) String idempotencyKey, + @Positive long warehouseId,@Positive long voucherFileId,@NotBlank @Size(max=1000) String reason) { } + public record Resolution(@NotBlank @Pattern(regexp="PURCHASE_REMAINDER|TRANSIT_REMAINDER|GOOD|DAMAGED|EXTRA") String kind, + @Positive Long sourceId,@Positive long quantity, + @NotBlank @Pattern(regexp="SUPPLIER_RETAINED|RETURNED_TO_SUPPLIER|RETURNED_TO_SENDER|LOSS_CONFIRMED|CUSTOMER_AUTHORIZED_RETENTION|DISPOSED") String decision, + @NotBlank @Size(max=500) String destination,@Positive long voucherFileId) { } + public record Close(@NotNull @Min(0) Long version,@NotBlank @Size(max=128) String idempotencyKey, + @NotNull List<@NotNull @Valid Resolution> resolutions,@Positive long customerVoucherFileId, + @NotBlank @Size(max=1000) String financialConclusion,@NotBlank @Size(max=1000) String reason, + @NotBlank @Size(max=500) String clientSummary) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/ProcurementLifecycleRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/ProcurementLifecycleRequests.java new file mode 100644 index 000000000..87dc28d75 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/ProcurementLifecycleRequests.java @@ -0,0 +1,23 @@ +package com.baiyue.lastleg.procurement.dto; + +import jakarta.validation.constraints.*; +import java.math.BigDecimal; +import java.time.LocalDateTime; + +public final class ProcurementLifecycleRequests { + private ProcurementLifecycleRequests() { } + public record Command(@NotNull @Min(0) Long version, @NotBlank @Size(max=128) String idempotencyKey, + @NotBlank @Size(max=1000) String reason) { } + public record Purchase(@NotNull @Min(0) Long version, @NotBlank @Size(max=128) String idempotencyKey, + @NotBlank @Size(max=255) String productName, @Size(max=500) String specification, + @Positive long quantity, @NotNull @DecimalMin("0.00") @Digits(integer=16,fraction=2) BigDecimal amount, + @NotBlank @Pattern(regexp="CNY|USD") String currency, + @NotNull @PastOrPresent LocalDateTime purchasedAt, @NotNull LocalDateTime estimatedArrivalAt, + @Positive long voucherFileId, @NotBlank @Size(max=1000) String reason) { } + public record Budget(@NotNull @Min(0) Long version, @NotBlank @Size(max=128) String idempotencyKey, + @NotNull @DecimalMin("0.00") @Digits(integer=16,fraction=2) BigDecimal amount, + @NotBlank @Pattern(regexp="CNY|USD") String currency, @Positive long voucherFileId, + @NotBlank @Size(max=1000) String reason) { } + public record Warehouse(@NotNull @Min(0) Long version, @NotBlank @Size(max=128) String idempotencyKey, + @Positive long warehouseId, @NotBlank @Size(max=1000) String reason) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/ProcurementPhysicalRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/ProcurementPhysicalRequests.java new file mode 100644 index 000000000..4f84eb561 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/ProcurementPhysicalRequests.java @@ -0,0 +1,37 @@ +package com.baiyue.lastleg.procurement.dto; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +public final class ProcurementPhysicalRequests { + private ProcurementPhysicalRequests() { } + public enum Leg { OVERSEAS, DOMESTIC } + public record Receipt(@NotNull @Min(0) Long version,@NotBlank @Size(max=128) String idempotencyKey,@NotNull Leg leg, + @Positive Long warehouseId,@Positive Long sourceCartonId, + @Min(0) long goodQuantity,@Min(0) long damagedQuantity,@Min(0) long extraQuantity, + @NotNull @PastOrPresent LocalDateTime receivedAt,@NotBlank @Size(max=1000) String reason) { } + public record FinishReceipt(@NotNull @Min(0) Long version,@NotBlank @Size(max=128) String idempotencyKey,@NotNull Leg leg, + @NotBlank @Size(max=1000) String reason) { } + public record Disposition(@NotNull @Min(0) Long version,@NotBlank @Size(max=128) String idempotencyKey,@NotNull Leg leg, + @Positive Long sourceCartonId,@Positive Long receiptId, + @NotBlank @Pattern(regexp="SHORTAGE|DAMAGED|EXTRA") String kind, + @Positive long quantity,@NotBlank @Pattern(regexp="ACCEPT_SHORTAGE|DISPOSE_DAMAGED|RETURN_EXTRA") String decision, + @Positive long voucherFileId,@NotBlank @Size(max=1000) String reason) { } + public record Source(@Positive long receiptId,@Positive long quantity) { } + public record Correction(@NotNull @Valid Receipt receipt,@Positive long voucherFileId) { } + public record RevokeDisposition(@NotNull @Min(0) Long version,@NotBlank @Size(max=128) String idempotencyKey, + @Positive long voucherFileId,@NotBlank @Size(max=1000) String reason) { } + public record Carton(@NotBlank @Size(max=64) String cartonNo, + @NotNull @DecimalMin(value="0",inclusive=false) @Digits(integer=10,fraction=2) BigDecimal lengthCm, + @NotNull @DecimalMin(value="0",inclusive=false) @Digits(integer=10,fraction=2) BigDecimal widthCm, + @NotNull @DecimalMin(value="0",inclusive=false) @Digits(integer=10,fraction=2) BigDecimal heightCm, + @NotNull @DecimalMin(value="0",inclusive=false) @Digits(integer=9,fraction=3) BigDecimal grossWeightKg, + @NotEmpty List<@NotNull @Valid Source> sources) { } + public record Packing(@NotNull @Min(0) Long version,@NotBlank @Size(max=128) String idempotencyKey, + @NotEmpty List<@NotNull @Valid Carton> cartons,@NotBlank @Size(max=1000) String reason) { } + public record ConfirmPacking(@NotNull @Min(0) Long version,@NotBlank @Size(max=128) String idempotencyKey, + @Min(1) int packingVersion,@NotBlank @Size(max=1000) String reason) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/ProcurementTransportRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/ProcurementTransportRequests.java new file mode 100644 index 000000000..6ca574939 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/ProcurementTransportRequests.java @@ -0,0 +1,38 @@ +package com.baiyue.lastleg.procurement.dto; + +import com.baiyue.lastleg.procurement.dto.ProcurementPhysicalRequests.Leg; +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +public final class ProcurementTransportRequests { + private ProcurementTransportRequests() { } + public record Address(@NotBlank @Size(max=128) String name, + @NotBlank @Pattern(regexp="[+0-9 ()-]{5,64}") String phone, + @NotBlank @Size(max=1000) String address) { } + public record Label(@Positive long cartonId,@Positive long fileId,@Min(1) int page, + @NotBlank @Size(max=128) String carrier,@NotBlank @Size(max=128) String trackingNo) { } + public record Labels(@NotNull @Min(0) Long version,@NotBlank @Size(max=128) String idempotencyKey, + @NotNull Leg leg,@Min(1) int packingVersion,@NotNull @Valid Address receiver, + @Positive Long addressVoucherFileId,@NotEmpty List<@NotNull @Valid Label> labels, + @NotBlank @Size(max=1000) String reason) { } + public record Estimate(@NotNull @Min(0) Long version,@NotBlank @Size(max=128) String idempotencyKey, + @Min(1) int labelVersion,@NotNull @DecimalMin("0") @Digits(integer=16,fraction=2) BigDecimal amount, + @NotNull LocalDate businessDate,@NotBlank @Size(max=500) String reason) { } + public record ApplyLabels(@NotNull @Min(0) Long version,@NotBlank @Size(max=128) String idempotencyKey, + @Min(1) int labelVersion,@NotEmpty List<@NotNull @Positive Long> cartonIds, + @NotNull @PastOrPresent LocalDateTime appliedAt,@NotBlank @Size(max=1000) String reason) { } + public record Ship(@NotNull @Min(0) Long version,@NotBlank @Size(max=128) String idempotencyKey, + @NotNull Leg leg,@Min(1) int labelVersion,@NotEmpty List<@NotNull @Positive Long> cartonIds, + @NotNull @PastOrPresent LocalDateTime shippedAt,@NotBlank @Size(max=1000) String reason) { } + public record Repack(@NotNull @Min(0) Long version,@NotBlank @Size(max=128) String idempotencyKey, + @Positive long cartonId, + @NotNull @DecimalMin(value="0",inclusive=false) @Digits(integer=10,fraction=2) BigDecimal lengthCm, + @NotNull @DecimalMin(value="0",inclusive=false) @Digits(integer=10,fraction=2) BigDecimal widthCm, + @NotNull @DecimalMin(value="0",inclusive=false) @Digits(integer=10,fraction=2) BigDecimal heightCm, + @NotNull @DecimalMin(value="0",inclusive=false) @Digits(integer=9,fraction=3) BigDecimal grossWeightKg, + @Positive long voucherFileId,@NotBlank @Size(max=1000) String reason) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementFee.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementFee.java index 89a289562..ac50d6b2f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementFee.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementFee.java @@ -6,6 +6,7 @@ import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; import java.math.BigDecimal; import java.time.LocalDateTime; +import java.time.LocalDate; @Data @TableName("ll_overseas_procurement_fee") @@ -14,6 +15,24 @@ public class OverseasProcurementFee { private Long orderId; private String customerId; private String feeNo; + private String purpose; + private String confirmationOrigin; + private Integer feeVersion; + private Boolean active; + private String determination; + private String originalCurrency; + private BigDecimal originalAmount; + private BigDecimal exchangeRate; + private String exchangeRateMonth; + private LocalDate businessDate; + private Long walletBusinessId; + private String walletBusinessNo; + private String walletHoldId; + private String walletLedgerId; + private String settledCommandEventId; + private Long sourceFeeId; + private Long parentFeeId; + private String requestHash; private String feeItemCode; private String feeItemName; private BigDecimal amount; @@ -22,7 +41,9 @@ public class OverseasProcurementFee { private String voucherFileName; private String remark; private String walletStatus; + @com.baomidou.mybatisplus.annotation.TableField(updateStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS) private String walletErrorCode; + @com.baomidou.mybatisplus.annotation.TableField(updateStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS) private String walletErrorMessage; private String walletCommandEventId; private String createdBy; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementOrder.java index df14cd381..4cf2bce87 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementOrder.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementOrder.java @@ -30,11 +30,6 @@ public class OverseasProcurementOrder { @TableField(updateStrategy = FieldStrategy.ALWAYS) private String purchaseWarehouseId; @TableField(updateStrategy = FieldStrategy.ALWAYS) private String purchaseWarehouseName; private Long inboundQuantity; - @TableField(updateStrategy = FieldStrategy.ALWAYS) private String outboundDetails; - @TableField(updateStrategy = FieldStrategy.ALWAYS) private String domesticCarrier; - @TableField(updateStrategy = FieldStrategy.ALWAYS) private String domesticTrackingNo; - @TableField(updateStrategy = FieldStrategy.ALWAYS) private String overseasCarrier; - @TableField(updateStrategy = FieldStrategy.ALWAYS) private String overseasTrackingNo; @TableField(updateStrategy = FieldStrategy.ALWAYS) private LocalDateTime estimatedArrivalAt; private String status; @TableField(updateStrategy = FieldStrategy.ALWAYS) private String remark; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/gateway/HttpProcurementFileGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/gateway/HttpProcurementFileGateway.java index b054a93f7..53953819c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/gateway/HttpProcurementFileGateway.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/gateway/HttpProcurementFileGateway.java @@ -37,7 +37,7 @@ public class HttpProcurementFileGateway implements ProcurementFileGateway { @Override public String getFilename() { return file.getOriginalFilename(); } }).contentType(file.getContentType() == null ? MediaType.APPLICATION_OCTET_STREAM : MediaType.parseMediaType(file.getContentType())); } catch (Exception exception) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "采购文件无法读取", exception); } - String businessType = "RECEIPT".equals(category) ? "OVERSEAS_PROCUREMENT_RECEIPT" : "OVERSEAS_PROCUREMENT_VOUCHER"; + String businessType = ProcurementFileCategory.parse(category).businessType(); Envelope response = client.post().uri("/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}", Long.parseLong(customerId), businessType, orderId) .header("X-Internal-Service-Token", token).header("X-Operator-Id", String.valueOf(operatorId)) @@ -51,5 +51,16 @@ public class HttpProcurementFileGateway implements ProcurementFileGateway { return new UploadedFile(data.fileId(), data.fileName(), data.contentType(), data.sizeBytes() == null ? 0 : data.sizeBytes()); } private record Envelope(String code, String message, Metadata data) { } + @Override + public Download download(String customerId,long orderId,String category,long fileId) { + var response=client.get().uri("/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}/{fileId}/content", + Long.parseLong(customerId),ProcurementFileCategory.parse(category).businessType(),orderId,fileId) + .header("X-Internal-Service-Token",token).retrieve() + .onStatus(HttpStatusCode::isError,(request,result)->{throw new ResponseStatusException( + result.getStatusCode().is4xxClientError()?HttpStatus.NOT_FOUND:HttpStatus.SERVICE_UNAVAILABLE,"采购文件暂不可读取");}) + .toEntity(byte[].class); + if(response.getBody()==null)throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"文件服务没有返回文件内容"); + return new Download(response.getBody(),response.getHeaders().getContentType()==null?MediaType.APPLICATION_OCTET_STREAM_VALUE:response.getHeaders().getContentType().toString()); + } private record Metadata(Long fileId, String fileName, String contentType, Long sizeBytes) { } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/gateway/ProcurementFileCategory.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/gateway/ProcurementFileCategory.java new file mode 100644 index 000000000..9aa3f4000 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/gateway/ProcurementFileCategory.java @@ -0,0 +1,14 @@ +package com.baiyue.lastleg.procurement.gateway; + +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; +import java.util.Locale; + +public enum ProcurementFileCategory { + RECEIPT,VOUCHER,LABEL,PURCHASE,BUDGET,EXCEPTION; + public String businessType(){return "OVERSEAS_PROCUREMENT_"+name();} + public static ProcurementFileCategory parse(String value){ + try{return valueOf(value==null?"":value.trim().toUpperCase(Locale.ROOT));} + catch(IllegalArgumentException exception){throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"采购文件用途不受支持");} + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/gateway/ProcurementFileGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/gateway/ProcurementFileGateway.java index 07eddfe5e..2f4a51014 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/gateway/ProcurementFileGateway.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/gateway/ProcurementFileGateway.java @@ -2,5 +2,7 @@ package com.baiyue.lastleg.procurement.gateway; import org.springframework.web.multipart.MultipartFile; public interface ProcurementFileGateway { UploadedFile upload(String customerId, long orderId, long operatorId, String category, MultipartFile file); + Download download(String customerId,long orderId,String category,long fileId); + record Download(byte[] content,String contentType) { } record UploadedFile(long fileId, String fileName, String contentType, long sizeBytes) { } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/mapper/OverseasProcurementFeeMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/mapper/OverseasProcurementFeeMapper.java index 57db8b5fe..bc2e8f7ad 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/mapper/OverseasProcurementFeeMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/mapper/OverseasProcurementFeeMapper.java @@ -2,4 +2,9 @@ package com.baiyue.lastleg.procurement.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.lastleg.procurement.entity.OverseasProcurementFee; import org.apache.ibatis.annotations.Mapper; -@Mapper public interface OverseasProcurementFeeMapper extends BaseMapper { } +@Mapper public interface OverseasProcurementFeeMapper extends BaseMapper { + @org.apache.ibatis.annotations.Select("SELECT * FROM ll_overseas_procurement_fee WHERE id=#{id} AND order_id=#{orderId} AND customer_id=#{customerId} FOR UPDATE") + OverseasProcurementFee lock(@org.apache.ibatis.annotations.Param("id") long id, + @org.apache.ibatis.annotations.Param("orderId") long orderId, + @org.apache.ibatis.annotations.Param("customerId") String customerId); +} 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 dc93177b3..5e13eca43 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 @@ -4,7 +4,6 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.toolkit.IdWorker; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baiyue.common.core.api.PageResult; -import com.baiyue.lastleg.masterdata.quote.service.PartnerWarehouseDirectory; import com.baiyue.lastleg.procurement.dto.OverseasProcurementRequests; import com.baiyue.lastleg.procurement.dto.OverseasProcurementResponses; import com.baiyue.lastleg.procurement.entity.OverseasProcurementOrder; @@ -14,11 +13,6 @@ import com.baiyue.lastleg.procurement.entity.OverseasProcurementFee; import com.baiyue.lastleg.procurement.mapper.OverseasProcurementFileMapper; import com.baiyue.lastleg.procurement.mapper.OverseasProcurementFeeMapper; import com.baiyue.lastleg.procurement.gateway.ProcurementFileGateway; -import com.baiyue.lastleg.wallet.WalletCommandOutboxService; -import com.baiyue.lastleg.wallet.WalletResultHandler; -import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos; -import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService; -import com.baiyue.common.event.WalletCommandCompletedV1; import com.baiyue.lastleg.procurement.mapper.OverseasProcurementOrderMapper; import com.baiyue.lastleg.procurement.mapper.OverseasProcurementStatusHistoryMapper; import com.baiyue.lastleg.security.OverseasPermissions; @@ -39,7 +33,6 @@ import java.math.BigDecimal; import java.time.LocalDateTime; import java.time.LocalDate; import java.time.format.DateTimeFormatter; -import java.time.format.DateTimeParseException; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -49,24 +42,24 @@ import java.util.Set; import org.springframework.web.multipart.MultipartFile; @Service -public class OverseasProcurementService implements WalletResultHandler { +public class OverseasProcurementService { @Resource private ProcurementOrderNumberGenerator orderNoGenerator; public static final String REVIEW_PERMISSION = OverseasPermissions.PURCHASE_REVIEW; public static final String MANAGE_PERMISSION = OverseasPermissions.PURCHASE_EDIT; public static final String PENDING_REVIEW = "PENDING_REVIEW"; - public static final String APPROVED = "APPROVED"; + public static final String WAIT_PURCHASE = "WAIT_PURCHASE"; public static final String REJECTED = "REJECTED"; - private static final Set STATUSES = Set.of(PENDING_REVIEW, APPROVED, REJECTED); + private static final Set STATUSES = Set.of(PENDING_REVIEW, WAIT_PURCHASE, REJECTED, "WAIT_OVERSEAS_RECEIPT", + "OVERSEAS_RECEIVING", "WAIT_PACKING", "WAIT_LABELS", "WAIT_OVERSEAS_DISPATCH", "INTERNATIONAL_TRANSIT", + "DOMESTIC_RECEIVING", "WAIT_CHARGES", "WAIT_CUSTOMER_DISPATCH", "SHIPPED_TO_CUSTOMER", "CANCELLED", "EXCEPTION_CLOSED"); @Resource private OverseasProcurementOrderMapper orders; @Resource private OverseasProcurementStatusHistoryMapper histories; - @Resource private PartnerWarehouseDirectory warehouseDirectory; + @Resource private ProcurementWarehouseDirectory warehouseDirectory; @Resource private ObjectMapper objectMapper; @Resource private OverseasProcurementFileMapper procurementFiles; @Resource private OverseasProcurementFeeMapper procurementFees; @Resource private ProcurementFileGateway fileGateway; - @Resource private WalletCommandOutboxService walletCommands; - @Resource private OverseasFeeSourceService feeSources; @Transactional public OverseasProcurementResponses.Detail create(String customerId, String operatorId, @@ -117,13 +110,13 @@ public class OverseasProcurementService implements WalletResultHandler { if (limit < 1 || limit > 200) throw badRequest("limit must be between 1 and 200"); return orders.customerOptions(trim(keyword), limit).stream() .map(value -> new OverseasProcurementResponses.CustomerOption(value.getCustomerId(), - Objects.requireNonNullElse(trim(value.getCustomerName()), value.getCustomerId()))) + Objects.requireNonNullElse(trim(value.getCustomerName()), "—"))) .toList(); } public List warehouseOptions() { return warehouseDirectory.options().stream() - .map(value -> new OverseasProcurementResponses.WarehouseOption(value.warehouseId(), + .map(value -> new OverseasProcurementResponses.WarehouseOption(String.valueOf(value.warehouseId()), value.warehouseCode(), value.warehouseName(), value.countryCode())) .toList(); } @@ -159,21 +152,20 @@ public class OverseasProcurementService implements WalletResultHandler { if (order == null) throw notFound("Overseas procurement order not found"); return new OverseasProcurementResponses.Detail(summary(order), order.getProductUrl(), order.getReceivingAddress(), order.getContactName(), order.getContactPhone(), - order.getDomesticCarrier(), order.getDomesticTrackingNo(), order.getOverseasCarrier(), - order.getOverseasTrackingNo(), order.getEstimatedArrivalAt(), order.getRemark(), + order.getEstimatedArrivalAt(), order.getRemark(), order.getCreatedBy(), order.getReviewedBy(), order.getReviewRemark(), order.getReviewedAt(), histories.listOrder(customerId, order.getId()).stream().map(value -> new OverseasProcurementResponses.History(value.getOperationType(), value.getFromStatus(), value.getToStatus(), value.getOperatorId(), value.getIdempotencyKey(), value.getChangeDetail(), value.getRemark(), - value.getCreatedAt())).toList(), outboundDetails(order), files(order), fees(order)); + value.getCreatedAt())).toList(), files(order), fees(order)); } @Transactional public OverseasProcurementResponses.FileInfo uploadFile(String customerId, String orderNo, String operatorId, String category, MultipartFile file) { OverseasProcurementOrder order = lock(customerId, orderNo); - if (!Set.of("RECEIPT", "VOUCHER").contains(category)) throw badRequest("File category must be RECEIPT or VOUCHER"); + category = com.baiyue.lastleg.procurement.gateway.ProcurementFileCategory.parse(category).name(); ProcurementFileGateway.UploadedFile uploaded = fileGateway.upload(customerId, order.getId(), Long.parseLong(operatorId), category, file); OverseasProcurementFile row = new OverseasProcurementFile(); row.setId(IdWorker.getId()); row.setOrderId(order.getId()); row.setCustomerId(customerId); row.setCategory(category); @@ -183,54 +175,15 @@ public class OverseasProcurementService implements WalletResultHandler { return file(row); } - @Transactional - public OverseasProcurementResponses.Fee createFee(String customerId, String orderNo, String operatorId, - OverseasProcurementRequests.CreateFee request) { - OverseasProcurementOrder order = lock(customerId, orderNo); - if (!APPROVED.equals(order.getStatus())) throw conflict("Only an approved procurement order can add fees"); - OverseasProcurementFile voucher = procurementFiles.selectOne(new LambdaQueryWrapper() - .eq(OverseasProcurementFile::getOrderId, order.getId()).eq(OverseasProcurementFile::getCustomerId, customerId) - .eq(OverseasProcurementFile::getCategory, "VOUCHER").eq(OverseasProcurementFile::getFileId, request.voucherFileId())); - if (voucher == null) throw badRequest("Fee voucher does not belong to this procurement order"); - LocalDateTime now = LocalDateTime.now(); - OverseasProcurementFee row = new OverseasProcurementFee(); - row.setId(IdWorker.getId()); row.setOrderId(order.getId()); row.setCustomerId(customerId); - row.setFeeNo("PF" + row.getId()); row.setFeeItemCode(request.feeItemCode().trim().toUpperCase()); - row.setFeeItemName(request.feeItemName().trim()); row.setAmount(request.amount()); row.setCurrency("CNY"); - row.setVoucherFileId(voucher.getFileId()); row.setVoucherFileName(voucher.getFileName()); row.setRemark(trim(request.remark())); - row.setWalletStatus("FREEZE_PENDING"); row.setCreatedBy(operatorId); row.setCreatedAt(now); row.setUpdatedAt(now); - procurementFees.insert(row); - String eventId = walletCommands.publish("FREEZE", Long.parseLong(customerId), "PROCUREMENT_FEE", row.getFeeNo(), - row.getAmount(), "海外采购费用冻结:" + orderNo, 1L); - row.setWalletCommandEventId(eventId); procurementFees.updateById(row); - return fee(row); - } - - @Override public boolean supports(String businessType) { return "PROCUREMENT_FEE".equals(businessType); } - - @Override - @Transactional - public void handle(WalletCommandCompletedV1 result) { - OverseasProcurementFee row = procurementFees.selectOne(new LambdaQueryWrapper() - .eq(OverseasProcurementFee::getCustomerId, String.valueOf(result.customerId())) - .eq(OverseasProcurementFee::getFeeNo, result.businessNo())); - if (row == null) throw notFound("Procurement fee not found for wallet result"); - if ("SUCCEEDED".equals(result.result())) { - row.setWalletStatus(switch (result.operation()) { case "FREEZE" -> "FROZEN"; case "SETTLE" -> "SETTLED"; case "RELEASE" -> "RELEASED"; default -> result.holdStatus(); }); - row.setWalletErrorCode(null); row.setWalletErrorMessage(null); - } else { - row.setWalletStatus(result.operation() + "_FAILED"); row.setWalletErrorCode(result.errorCode()); row.setWalletErrorMessage(result.errorMessage()); - } - row.setUpdatedAt(LocalDateTime.now()); procurementFees.updateById(row); - if ("FREEZE".equals(result.operation()) && "SUCCEEDED".equals(result.result())) { - OverseasProcurementOrder order = orders.selectById(row.getOrderId()); - if (order != null && order.getInboundQuantity() >= order.getPurchaseQuantity()) { - publishFeeSource(order, row); - walletCommands.publish("SETTLE", result.customerId(), "PROCUREMENT_FEE", row.getFeeNo(), null, - "海外采购完成入库,结算费用", 2L); - row.setWalletStatus("SETTLE_PENDING"); procurementFees.updateById(row); - } - } + public record DownloadedFile(String fileName,String contentType,byte[] content) { } + public DownloadedFile downloadFile(String customerId,String orderNo,long fileId) { + var order=orders.findNo(customerId,orderNo); + if(order==null)throw notFound("采购订单不存在或不属于当前客户"); + var file=procurementFiles.selectOne(new LambdaQueryWrapper() + .eq(OverseasProcurementFile::getCustomerId,customerId).eq(OverseasProcurementFile::getOrderId,order.getId()).eq(OverseasProcurementFile::getFileId,fileId)); + if(file==null)throw notFound("文件未关联本采购订单"); + var content=fileGateway.download(customerId,order.getId(),file.getCategory(),fileId); + return new DownloadedFile(file.getFileName(),content.contentType(),content.content()); } private List files(OverseasProcurementOrder order) { @@ -257,8 +210,8 @@ public class OverseasProcurementService implements WalletResultHandler { public OverseasProcurementResponses.Detail review(String customerId, String orderNo, String operatorId, OverseasProcurementRequests.Review request) { String next = switch (request.decision().trim().toUpperCase()) { - case "APPROVE", APPROVED -> APPROVED; - case "REJECT", REJECTED -> REJECTED; + case "APPROVE" -> WAIT_PURCHASE; + case "REJECT" -> REJECTED; default -> throw badRequest("Review decision must be APPROVE or REJECT"); }; OverseasProcurementOrder order = lock(customerId, orderNo); @@ -272,6 +225,12 @@ public class OverseasProcurementService implements WalletResultHandler { } if (!request.version().equals(order.getVersion())) throw conflict("Order was changed; refresh and retry"); if (!PENDING_REVIEW.equals(order.getStatus())) throw conflict("Overseas procurement order was already reviewed"); + if (WAIT_PURCHASE.equals(next)) { + if (request.purchaseWarehouseId() == null) throw badRequest("审核通过前请选择有效海外收货仓"); + var warehouse = warehouseDirectory.required(request.purchaseWarehouseId()); + order.setPurchaseWarehouseId(String.valueOf(warehouse.warehouseId())); + order.setPurchaseWarehouseName(warehouse.warehouseName()); + } LocalDateTime now = LocalDateTime.now(); order.setStatus(next); order.setReviewedBy(operatorId); @@ -292,23 +251,7 @@ public class OverseasProcurementService implements WalletResultHandler { OverseasProcurementOrder order = lock(customerId, orderNo); if (!request.version().equals(order.getVersion())) throw conflict("Order was changed; refresh and retry"); Map before = editableSnapshot(order); - if (request.inboundQuantity() != null) { - if (!APPROVED.equals(order.getStatus())) throw conflict("Only an approved procurement order can receive stock"); - if (request.inboundQuantity() > order.getPurchaseQuantity()) throw badRequest("Inbound quantity cannot exceed purchase quantity"); - order.setInboundQuantity(request.inboundQuantity()); - } - setIfText(request.purchaseCategory(), order::setPurchaseCategory); - setIfText(request.productName(), order::setProductName); - patchText(request.productSpecification(), 500, "productSpecification", order::setProductSpecification); - patchText(request.productUrl(), 2000, "productUrl", order::setProductUrl); - patchWarehouse(order, request.purchaseWarehouseId(), request.purchaseWarehouseName()); - patchText(request.domesticCarrier(), 128, "domesticCarrier", order::setDomesticCarrier); - patchText(request.domesticTrackingNo(), 128, "domesticTrackingNo", order::setDomesticTrackingNo); - patchText(request.overseasCarrier(), 128, "overseasCarrier", order::setOverseasCarrier); - patchText(request.overseasTrackingNo(), 128, "overseasTrackingNo", order::setOverseasTrackingNo); - patchDateTime(request.estimatedArrivalAt(), order::setEstimatedArrivalAt); patchText(request.remark(), 1000, "remark", order::setRemark); - if (request.outboundDetails() != null) order.setOutboundDetails(normalizeOutbound(request.outboundDetails(), order.getInboundQuantity())); Map after = editableSnapshot(order); if (before.equals(after)) return detail(customerId, orderNo); LocalDateTime now = LocalDateTime.now(); @@ -316,28 +259,9 @@ public class OverseasProcurementService implements WalletResultHandler { orders.updateById(order); history(order, "UPDATE", order.getStatus(), order.getStatus(), operatorId, null, changeDetail(before, after), null, now); - if (order.getInboundQuantity() >= order.getPurchaseQuantity()) { - for (OverseasProcurementFee fee : procurementFees.selectList(new LambdaQueryWrapper() - .eq(OverseasProcurementFee::getOrderId, order.getId()).eq(OverseasProcurementFee::getWalletStatus, "FROZEN"))) { - publishFeeSource(order, fee); - walletCommands.publish("SETTLE", Long.parseLong(customerId), "PROCUREMENT_FEE", fee.getFeeNo(), null, - "海外采购完成入库,结算费用", 2L); - fee.setWalletStatus("SETTLE_PENDING"); fee.setUpdatedAt(now); procurementFees.updateById(fee); - } - } return detail(customerId, orderNo); } - private void publishFeeSource(OverseasProcurementOrder order, OverseasProcurementFee fee) { - feeSources.publishApproved(StringUtils.hasText(fee.getCreatedBy()) ? fee.getCreatedBy() : "SYSTEM", "PROCUREMENT:" + fee.getFeeNo(), - new OverseasFeeSourceDtos.SaveRequest(order.getCustomerId(), order.getCustomerName(), - "OVERSEAS_PROCUREMENT_FEE", "PROCUREMENT_FEE", fee.getId(), fee.getFeeNo(), - order.getId(), null, null, null, null, null, parseLong(order.getPurchaseWarehouseId()), - fee.getFeeItemCode(), fee.getFeeItemName(), "RECEIVABLE", null, null, null, null, - null, null, null, List.of(), LocalDate.now(), fee.getCurrency(), fee.getAmount(), - BigDecimal.ONE, "ITEM", fee.getAmount(), null, null)); - } - private OverseasProcurementResponses.Detail replay(String customerId, OverseasProcurementOrder order, String hash) { if (!hash.equals(order.getRequestHash())) { throw conflict("Idempotency key was reused with different procurement data"); @@ -352,16 +276,11 @@ public class OverseasProcurementService implements WalletResultHandler { } private OverseasProcurementResponses.Summary summary(OverseasProcurementOrder order) { - long inbound = value(order.getInboundQuantity()); - long outbound = outboundDetails(order).stream().mapToLong(OverseasProcurementResponses.OutboundDetail::quantity).sum(); String displayStatus = order.getStatus(); - if (APPROVED.equals(order.getStatus()) && inbound > 0) { - displayStatus = outbound == 0 ? "PENDING_OUTBOUND" : outbound < inbound ? "PARTIALLY_OUTBOUND" : "OUTBOUNDED"; - } return new OverseasProcurementResponses.Summary(order.getId(), order.getOrderNo(), order.getCustomerId(), order.getCustomerName(), order.getPurchaseCategory(), order.getProductName(), order.getProductSpecification(), value(order.getPurchaseQuantity()), order.getBudgetUnitPrice(), - order.getCurrency(), order.getPurchaseWarehouseId(), order.getPurchaseWarehouseName(), inbound, outbound, Math.max(0, inbound - outbound), + order.getCurrency(), order.getPurchaseWarehouseId(), order.getPurchaseWarehouseName(), displayStatus, value(order.getVersion()), order.getCreatedAt(), order.getUpdatedAt()); } @@ -400,31 +319,6 @@ public class OverseasProcurementService implements WalletResultHandler { } } - private void patchWarehouse(OverseasProcurementOrder order, JsonNode warehouseId, JsonNode warehouseName) { - if (warehouseId == null) { - if (warehouseName != null) throw badRequest("purchaseWarehouseName cannot be changed without purchaseWarehouseId"); - return; - } - String id = nullableText(warehouseId, 128, "purchaseWarehouseId"); - if (id == null) { - order.setPurchaseWarehouseId(null); - order.setPurchaseWarehouseName(null); - return; - } - long numericId; - try { - numericId = Long.parseLong(id); - } catch (NumberFormatException exception) { - throw badRequest("purchaseWarehouseId must be a numeric overseas partner warehouse ID"); - } - // Keep the warehouse snapshot already frozen on a historical order. A later master-data - // deactivation must not block unrelated edits such as recording outbound details. - if (Long.toString(numericId).equals(order.getPurchaseWarehouseId())) return; - var option = warehouseDirectory.required(numericId); - order.setPurchaseWarehouseId(option.warehouseId()); - order.setPurchaseWarehouseName(option.warehouseName()); - } - private static void patchText(JsonNode node, int maxLength, String field, java.util.function.Consumer setter) { if (node != null) setter.accept(nullableText(node, maxLength, field)); @@ -438,55 +332,12 @@ public class OverseasProcurementService implements WalletResultHandler { return value; } - private static void patchDateTime(JsonNode node, java.util.function.Consumer setter) { - if (node == null) return; - String value = nullableText(node, 64, "estimatedArrivalAt"); - if (value == null) { - setter.accept(null); - return; - } - try { - setter.accept(LocalDateTime.parse(value)); - } catch (DateTimeParseException exception) { - throw badRequest("estimatedArrivalAt must be an ISO local date-time or null"); - } - } - private static Map editableSnapshot(OverseasProcurementOrder order) { - Map result = new LinkedHashMap<>(); - result.put("purchaseCategory", order.getPurchaseCategory()); - result.put("productName", order.getProductName()); - result.put("productSpecification", order.getProductSpecification()); - result.put("productUrl", order.getProductUrl()); - result.put("inboundQuantity", value(order.getInboundQuantity())); - result.put("purchaseWarehouseId", order.getPurchaseWarehouseId()); - result.put("purchaseWarehouseName", order.getPurchaseWarehouseName()); - result.put("domesticCarrier", order.getDomesticCarrier()); - result.put("domesticTrackingNo", order.getDomesticTrackingNo()); - result.put("overseasCarrier", order.getOverseasCarrier()); - result.put("overseasTrackingNo", order.getOverseasTrackingNo()); - result.put("estimatedArrivalAt", order.getEstimatedArrivalAt()); - result.put("remark", order.getRemark()); - result.put("outboundDetails", order.getOutboundDetails()); + Map result=new LinkedHashMap<>(); + result.put("remark",order.getRemark()); return result; } - private List outboundDetails(OverseasProcurementOrder order) { - if (!StringUtils.hasText(order.getOutboundDetails())) return List.of(); - try { return objectMapper.readValue(order.getOutboundDetails(), objectMapper.getTypeFactory().constructCollectionType(List.class, OverseasProcurementResponses.OutboundDetail.class)); } - catch (JsonProcessingException e) { return List.of(); } - } - private String normalizeOutbound(JsonNode node, Long inbound) { - if (!node.isArray()) throw badRequest("outboundDetails must be an array"); - long total = 0; - for (JsonNode item : node) { - if (!item.isObject() || item.path("quantity").asLong(-1) < 0) throw badRequest("outboundDetails quantity must be non-negative"); - total += item.path("quantity").asLong(); - } - if (total > value(inbound)) throw badRequest("outbound quantity cannot exceed inbound quantity"); - try { return objectMapper.writeValueAsString(node); } catch (JsonProcessingException e) { throw badRequest("Invalid outboundDetails"); } - } - private String changeDetail(Map before, Map after) { Map changes = new LinkedHashMap<>(); before.forEach((field, oldValue) -> { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementAdjustmentService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementAdjustmentService.java new file mode 100644 index 000000000..a8a5df697 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementAdjustmentService.java @@ -0,0 +1,110 @@ +package com.baiyue.lastleg.procurement.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.procurement.dto.ProcurementChargeRequests.Correct; +import com.baiyue.lastleg.procurement.dto.ProcurementChargeRequests.Additional; +import com.baiyue.lastleg.procurement.dto.ProcurementChargeRequests.Refund; +import com.baiyue.lastleg.procurement.entity.OverseasProcurementFee; +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.*; +import static com.baiyue.lastleg.procurement.service.ProcurementLifecycleService.*; + +@Service @RequiredArgsConstructor +public class ProcurementAdjustmentService { + private final ProcurementLifecycleService lifecycle; + private final ProcurementFinanceService finance; + private final JdbcTemplate jdbc; + public record Result(String correctionNo,String rootFeeNo,BigDecimal targetOriginalAmount,String originalCurrency, + BigDecimal exchangeRate,String exchangeRateMonth,BigDecimal targetAmount,BigDecimal previousNetAmount, + BigDecimal differenceAmount,List feeNos) { } + private record Identity(long rootFeeId,Correct request) { } + @Transactional + public Result correct(String customer,String orderNo,String operator,long rootFeeId,Correct request) { + var order=lifecycle.lock(customer,orderNo);var identity=new Identity(rootFeeId,request); + if(lifecycle.replay(order,"CORRECT_FEE_TARGET",request.idempotencyKey(),identity))return result(order.getId(),request.idempotencyKey()); + version(order,request.version());var root=finance.required(order,rootFeeId); + if(Set.of("INTERNATIONAL_ESTIMATE","ADJUSTMENT","REFUND").contains(root.getPurpose()))throw bad("目标更正须选择原实际费用,不能选择预估或差额单"); + var chain=finance.readyChain(order,root); + if(request.targetOriginalAmount()==null || request.targetOriginalAmount().signum()<0 || request.targetOriginalAmount().scale()>2)throw bad("正确原币总额须为非负两位小数"); + lifecycle.requireFile(order,request.voucherFileId(),Set.of("VOUCHER")); + if(root.getExchangeRate()==null || root.getExchangeRate().signum()<=0)throw conflict("原费用汇率快照缺失,不能重新取汇率替代"); + BigDecimal net=chain.stream().filter(f->"SETTLED".equals(f.getWalletStatus())).map(OverseasProcurementFee::getAmount).reduce(BigDecimal.ZERO,BigDecimal::add) + .subtract(chain.stream().filter(f->"REFUNDED".equals(f.getWalletStatus())).map(OverseasProcurementFee::getAmount).reduce(BigDecimal.ZERO,BigDecimal::add)); + BigDecimal target=request.targetOriginalAmount().multiply(root.getExchangeRate()).setScale(2,RoundingMode.HALF_UP),delta=target.subtract(net); + if(target.precision()-target.scale()>18)throw bad("折算人民币金额超出支持范围"); + long id=IdWorker.getId();String no="PC-"+UUID.randomUUID().toString().replace("-","").substring(0,24).toUpperCase(); + // The node command carries the exact request hash; this table stores the computed immutable monetary facts. + jdbc.update("INSERT INTO ll_procurement_fee_correction(id,order_id,customer_id,correction_no,root_fee_id,idempotency_key,request_hash,target_original_amount,original_currency,exchange_rate,exchange_rate_month,target_amount,previous_net_amount,difference_amount,voucher_file_id,reason,created_by) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + id,order.getId(),customer,no,rootFeeId,request.idempotencyKey(),lifecycle.requestHash(identity),request.targetOriginalAmount(),root.getOriginalCurrency(),root.getExchangeRate(),root.getExchangeRateMonth(),target,net,delta,request.voucherFileId(),request.reason(),operator); + List created=delta.signum()>0?List.of(finance.supplement(customer,orderNo,operator,rootFeeId,delta,request.voucherFileId(),request.reason(),no)): + delta.signum()<0?finance.reduceChain(customer,orderNo,operator,rootFeeId,delta.negate(),request.voucherFileId(),request.reason(),no):List.of(); + for(var fee:created)jdbc.update("INSERT INTO ll_procurement_fee_correction_item(correction_id,fee_id) VALUES(?,?)",id,fee.getId()); + lifecycle.complete(order,operator,"CORRECT_FEE_TARGET",request.idempotencyKey(),request.reason(),identity); + return result(order.getId(),request.idempotencyKey()); + } + @Transactional + public Result replaceFailed(String customer,String orderNo,String operator,long originalFeeId,Correct request) { + var order=lifecycle.lock(customer,orderNo);var identity=new Identity(originalFeeId,request); + if(lifecycle.replay(order,"REPLACE_FAILED_FEE",request.idempotencyKey(),identity))return result(order.getId(),request.idempotencyKey()); + version(order,request.version());var original=finance.requireFailedReplacement(order,originalFeeId); + if(request.targetOriginalAmount()==null || request.targetOriginalAmount().signum()<0 || request.targetOriginalAmount().scale()>2)throw bad("正确原币总额须为非负两位小数"); + lifecycle.requireFile(order,request.voucherFileId(),Set.of("VOUCHER")); + BigDecimal target=request.targetOriginalAmount().multiply(original.getExchangeRate()).setScale(2,RoundingMode.HALF_UP); + if("SAMPLE_PAYMENT".equals(original.getPurpose()))lifecycle.requireReplacementBudget(order,request.targetOriginalAmount(),original.getOriginalCurrency(),original.getBusinessDate(),target); + if(target.precision()-target.scale()>18)throw bad("折算人民币金额超出支持范围"); + long id=IdWorker.getId();String no="PC-"+UUID.randomUUID().toString().replace("-","").substring(0,24).toUpperCase(); + jdbc.update("INSERT INTO ll_procurement_fee_correction(id,order_id,customer_id,correction_no,root_fee_id,idempotency_key,request_hash,target_original_amount,original_currency,exchange_rate,exchange_rate_month,target_amount,previous_net_amount,difference_amount,voucher_file_id,reason,created_by) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + id,order.getId(),customer,no,originalFeeId,request.idempotencyKey(),lifecycle.requestHash(identity),request.targetOriginalAmount(),original.getOriginalCurrency(),original.getExchangeRate(),original.getExchangeRateMonth(),target,BigDecimal.ZERO,target,request.voucherFileId(),request.reason(),operator); + var fee=finance.replaceFailed(customer,orderNo,operator,originalFeeId,request.targetOriginalAmount(),request.voucherFileId(),request.reason()); + jdbc.update("INSERT INTO ll_procurement_fee_correction_item(correction_id,fee_id) VALUES(?,?)",id,fee.getId()); + if(!Set.of("SHIPPED_TO_CUSTOMER","EXCEPTION_CLOSED","REJECTED","CANCELLED").contains(order.getStatus())) { + jdbc.update("UPDATE ll_procurement_flow SET charges_confirmed_at=NULL WHERE order_id=?",order.getId()); + if(lifecycle.flow(order.getId()).get("domestic_received_at")!=null)order.setStatus("WAIT_CHARGES"); + } + lifecycle.complete(order,operator,"REPLACE_FAILED_FEE",request.idempotencyKey(),request.reason(),identity); + return result(order.getId(),request.idempotencyKey()); + } + + @Transactional + public OverseasProcurementFee additional(String customer,String orderNo,String operator,Additional request) { + var order=lifecycle.lock(customer,orderNo);String code="ADD_"+lifecycle.requestHash(List.of(customer,orderNo,request.idempotencyKey())).substring(0,24).toUpperCase(); + if(lifecycle.replay(order,"ADD_ACTUAL_FEE",request.idempotencyKey(),request))return finance.all(order.getId()).stream().filter(f->code.equals(f.getFeeItemCode())).min(Comparator.comparing(OverseasProcurementFee::getFeeVersion)).orElseThrow(()->conflict("原新增费用记录缺失")); + version(order,request.version()); + if(lifecycle.flow(order.getId()).get("purchased_at")==null)throw conflict("独立新增费用须关联已发生购买的采购订单"); + if(request.businessDate()==null || request.businessDate().isAfter(java.time.LocalDate.now()))throw bad("独立费用业务日期须为已发生日期"); + lifecycle.requireFile(order,request.voucherFileId(),Set.of("VOUCHER")); + var fee=finance.confirmAdditional(customer,orderNo,operator,new ProcurementFinanceService.Confirmation(ProcurementFeePurpose.OTHER,code,request.name(),"POSITIVE",request.amount(),request.currency(),request.businessDate(),request.voucherFileId(),request.reason())); + if(!Set.of("SHIPPED_TO_CUSTOMER","EXCEPTION_CLOSED","REJECTED","CANCELLED").contains(order.getStatus())) { + jdbc.update("UPDATE ll_procurement_flow SET charges_confirmed_at=NULL WHERE order_id=?",order.getId()); + if(lifecycle.flow(order.getId()).get("domestic_received_at")!=null)order.setStatus("WAIT_CHARGES"); + } + lifecycle.complete(order,operator,"ADD_ACTUAL_FEE",request.idempotencyKey(),request.reason(),request);return fee; + } + + private record RefundIdentity(long originalFeeId,Refund request) { } + @Transactional + public OverseasProcurementFee refund(String customer,String orderNo,String operator,long originalFeeId,Refund request) { + var order=lifecycle.lock(customer,orderNo);var identity=new RefundIdentity(originalFeeId,request); + if(lifecycle.replay(order,"REFUND_FEE",request.idempotencyKey(),identity))return finance.refund(customer,orderNo,operator,originalFeeId,request.amount(),request.voucherFileId(),request.reason(),request.idempotencyKey()); + version(order,request.version()); + var fee=finance.refund(customer,orderNo,operator,originalFeeId,request.amount(),request.voucherFileId(),request.reason(),request.idempotencyKey()); + lifecycle.complete(order,operator,"REFUND_FEE",request.idempotencyKey(),request.reason(),identity);return fee; + } + private Result result(long orderId,String key) { + var row=jdbc.queryForMap("SELECT c.*,f.fee_no AS root_fee_no FROM ll_procurement_fee_correction c JOIN ll_overseas_procurement_fee f ON f.id=c.root_fee_id WHERE c.order_id=? AND c.idempotency_key=?",orderId,key); + var numbers=jdbc.queryForList("SELECT f.fee_no FROM ll_procurement_fee_correction_item i JOIN ll_overseas_procurement_fee f ON f.id=i.fee_id WHERE i.correction_id=? ORDER BY f.id",String.class,row.get("id")); + return new Result((String)row.get("correction_no"),(String)row.get("root_fee_no"),(BigDecimal)row.get("target_original_amount"),(String)row.get("original_currency"), + (BigDecimal)row.get("exchange_rate"),(String)row.get("exchange_rate_month"),(BigDecimal)row.get("target_amount"),(BigDecimal)row.get("previous_net_amount"),(BigDecimal)row.get("difference_amount"),numbers); + } + @Transactional(readOnly=true) + public List history(String customer,String orderNo) { + var order=lifecycle.requireOrder(customer,orderNo); + return jdbc.queryForList("SELECT idempotency_key FROM ll_procurement_fee_correction WHERE order_id=? ORDER BY id DESC",String.class,order.getId()) + .stream().map(key->result(order.getId(),key)).toList(); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementChargeService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementChargeService.java new file mode 100644 index 000000000..9e105c487 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementChargeService.java @@ -0,0 +1,113 @@ +package com.baiyue.lastleg.procurement.service; + +import com.baiyue.lastleg.procurement.dto.ProcurementChargeRequests.*; +import com.baiyue.lastleg.procurement.dto.ProcurementLifecycleRequests.Command; +import com.baiyue.lastleg.procurement.entity.OverseasProcurementOrder; +import com.baiyue.lastleg.procurement.entity.OverseasProcurementFee; +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDateTime; +import java.util.*; +import static com.baiyue.lastleg.procurement.service.ProcurementLifecycleService.*; + +@Service @RequiredArgsConstructor +public class ProcurementChargeService { + private final ProcurementLifecycleService lifecycle; + private final ProcurementFinanceService finance; + private final JdbcTemplate jdbc; + + public record Fee(OverseasProcurementFee detail,boolean sourceLinked) { } + public record Snapshot(String orderNo,long version,String status,LocalDateTime confirmedAt,List fees) { } + + @Transactional + public Snapshot confirm(String customer,String orderNo,String operator,Confirm request) { + var order=lifecycle.lock(customer,orderNo); + if(lifecycle.replay(order,"CONFIRM_CHARGES",request.idempotencyKey(),request))return snapshot(order); + version(order,request.version());open(order);lifecycle.requireNoBlockers(order); + if(lifecycle.flow(order.getId()).get("domestic_received_at")==null)throw conflict("须国内收货完成且差异处置明确后确认费用"); + if(request.otherFees()==null || !Set.of("NOT_APPLICABLE","ITEMIZED").contains(request.otherDetermination()))throw bad("其他费用须明确核定,不能将未知当作零元"); + if("NOT_APPLICABLE".equals(request.otherDetermination())!=request.otherFees().isEmpty())throw bad("其他费用不适用时不登记收费行,适用时须逐项核定"); + Integer confirmed=jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_command WHERE order_id=? AND action='CONFIRM_CHARGES'",Integer.class,order.getId()); + if(confirmed!=null && confirmed>0 && (Arrays.asList(request.internationalFreight(),request.duty(),request.domesticFreight()).stream().anyMatch(item->item!=null && item.value()!=null) + || request.otherFees().stream().anyMatch(item->item!=null && item.selection()!=null && item.selection().value()!=null)))throw conflict("首次核费后只可引用已有费用版本,新增或替代费用须使用财务操作"); + Set selected=new HashSet<>(); + selected.add(select(order,operator,ProcurementFeePurpose.INTERNATIONAL_FREIGHT,null,null,request.internationalFreight()).getId()); + selected.add(select(order,operator,ProcurementFeePurpose.DUTY,null,null,request.duty()).getId()); + selected.add(select(order,operator,ProcurementFeePurpose.DOMESTIC_FREIGHT,null,null,request.domesticFreight()).getId()); + Set codes=new HashSet<>(); + for(var item:request.otherFees()) { + if(item==null || item.code()==null || !codes.add(item.code()))throw bad("其他费用编码不能重复"); + selected.add(select(order,operator,ProcurementFeePurpose.OTHER,item.code(),item.name(),item.selection()).getId()); + } + for(var fee:finance.all(order.getId())) { + if(Boolean.TRUE.equals(fee.getActive()) && Set.of("INTERNATIONAL_FREIGHT","DUTY","DOMESTIC_FREIGHT","OTHER").contains(fee.getPurpose()) && !selected.contains(fee.getId())) { + throw conflict("存在未列入本次核对的有效实际费用,不能通过省略行隐藏原收费"); + } + } + jdbc.update("UPDATE ll_procurement_flow SET charges_confirmed_at=CURRENT_TIMESTAMP(3) WHERE order_id=?",order.getId()); + order.setStatus("WAIT_CUSTOMER_SHIPMENT"); + lifecycle.complete(order,operator,"CONFIRM_CHARGES",request.idempotencyKey(),request.reason(),request); + return snapshot(order); + } + + private OverseasProcurementFee select(OverseasProcurementOrder order,String operator,ProcurementFeePurpose purpose,String code,String name,Selection selection) { + if(selection==null || (selection.feeId()==null)==(selection.value()==null))throw bad("每项费用须选择原已核定版本或填写新的核定结果,不能同时选择"); + if(selection.feeId()!=null) { + var fee=finance.required(order,selection.feeId()); + if(!Boolean.TRUE.equals(fee.getActive()) || !purpose.name().equals(fee.getPurpose()) + || (purpose==ProcurementFeePurpose.OTHER && !Objects.equals(code,fee.getFeeItemCode())))throw bad("引用费用不是本单该用途的有效版本"); + return fee; + } + var value=selection.value(); + var request=new ProcurementFinanceService.Confirmation(purpose,code,name,value.determination(),value.amount(),value.currency(),value.businessDate(),value.voucherFileId(),value.reason()); + return purpose==ProcurementFeePurpose.INTERNATIONAL_FREIGHT?finance.confirmInternational(order.getCustomerId(),order.getOrderNo(),operator,request) + :finance.confirm(order.getCustomerId(),order.getOrderNo(),operator,request); + } + + private record FeeCommand(long feeId,Command command) { } + @Transactional + public Snapshot retry(String customer,String orderNo,String operator,long feeId,Command request) { + var order=lifecycle.lock(customer,orderNo);var identity=new FeeCommand(feeId,request); + if(lifecycle.replay(order,"RETRY_FEE",request.idempotencyKey(),identity))return snapshot(order); + version(order,request.version());finance.retry(customer,orderNo,feeId); + lifecycle.complete(order,operator,"RETRY_FEE",request.idempotencyKey(),request.reason(),identity);return snapshot(order); + } + @Transactional + public Snapshot terminate(String customer,String orderNo,String operator,long feeId,Command request) { + var order=lifecycle.lock(customer,orderNo);var identity=new FeeCommand(feeId,request); + if(lifecycle.replay(order,"TERMINATE_FEE",request.idempotencyKey(),identity))return snapshot(order); + version(order,request.version());finance.terminateFailed(customer,orderNo,feeId); + // The terminated determination must be explicitly replaced or acknowledged before any further handoff. + if(!Set.of("SHIPPED_TO_CUSTOMER","EXCEPTION_CLOSED","REJECTED","CANCELLED").contains(order.getStatus())) { + jdbc.update("UPDATE ll_procurement_flow SET charges_confirmed_at=NULL WHERE order_id=?",order.getId()); + if(lifecycle.flow(order.getId()).get("domestic_received_at")!=null)order.setStatus("WAIT_CHARGES"); + } + lifecycle.complete(order,operator,"TERMINATE_FEE",request.idempotencyKey(),request.reason(),identity);return snapshot(order); + } + public Snapshot read(String customer,String orderNo){return snapshot(lifecycle.requireOrder(customer,orderNo));} + @Transactional + public Snapshot releaseEstimate(String customer,String orderNo,String operator,long feeId,Command request) { + var order=lifecycle.lock(customer,orderNo);var identity=new FeeCommand(feeId,request); + if(lifecycle.replay(order,"RELEASE_ESTIMATE",request.idempotencyKey(),identity))return snapshot(order); + version(order,request.version());finance.releaseEstimate(customer,orderNo,feeId); + lifecycle.complete(order,operator,"RELEASE_ESTIMATE",request.idempotencyKey(),request.reason(),identity);return snapshot(order); + } + public String retryPermission(String customer,String orderNo,long feeId) { + var order=lifecycle.requireOrder(customer,orderNo); + var fee=finance.all(order.getId()).stream().filter(f->f.getId()==feeId).findFirst().orElseThrow(()->bad("费用不属于本采购订单")); + if("FINANCIAL".equals(fee.getConfirmationOrigin()))return "platform:overseas-business:purchase:finance-adjust"; + return "platform:overseas-business:purchase:"+switch(ProcurementFeePurpose.valueOf(fee.getPurpose())) { + case SAMPLE_PAYMENT->"buy"; + case INTERNATIONAL_ESTIMATE->"estimate"; + case ADJUSTMENT,REFUND->"finance-adjust"; + default->"charge"; + }; + } + private Snapshot snapshot(OverseasProcurementOrder order) { + Object at=lifecycle.flow(order.getId()).get("charges_confirmed_at"); + return new Snapshot(order.getOrderNo(),order.getVersion(),order.getStatus(),at==null?null:((java.sql.Timestamp)at).toLocalDateTime(), + finance.all(order.getId()).stream().map(f->new Fee(f,finance.sourceLinked(f))).toList()); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementControlService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementControlService.java new file mode 100644 index 000000000..c5452b234 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementControlService.java @@ -0,0 +1,145 @@ +package com.baiyue.lastleg.procurement.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.procurement.dto.ProcurementControlRequests.*; +import com.baiyue.lastleg.procurement.entity.OverseasProcurementOrder; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.util.*; +import static com.baiyue.lastleg.procurement.service.ProcurementLifecycleService.*; + +@Service @RequiredArgsConstructor +public class ProcurementControlService { + private final ProcurementLifecycleService lifecycle; + private final ProcurementFinanceService finance; + private final ProcurementWarehouseDirectory warehouses; + private final JdbcTemplate jdbc; + private final ObjectMapper json; + public record Remaining(String kind,Long sourceId,String businessNo,long quantity) { } + public record Snapshot(String orderNo,long version,String status,List> notes, + List remaining,List> closures,List> closureItems) { } + @Transactional + public Snapshot note(String customer,String orderNo,String operator,Note request) { + var order=lifecycle.lock(customer,orderNo); + if(lifecycle.replay(order,"CONTROL_NOTE",request.idempotencyKey(),request))return snapshot(order); + version(order,request.version());validateTarget(order,request.targetType(),request.targetId());proof(order,request.voucherFileId()); + insertNote(order,operator,request.targetType(),request.targetId(),request.blocking(),request.voucherFileId(),request.reason(),request.clientSummary(),null,null); + lifecycle.complete(order,operator,"CONTROL_NOTE",request.idempotencyKey(),request.reason(),request);return snapshot(order); + } + private record ResolveIdentity(long noteId,Resolve request) { } + @Transactional + public Snapshot resolve(String customer,String orderNo,String operator,long noteId,Resolve request) { + var order=lifecycle.lock(customer,orderNo);var identity=new ResolveIdentity(noteId,request); + if(lifecycle.replay(order,"RESOLVE_CONTROL",request.idempotencyKey(),identity))return snapshot(order); + version(order,request.version());proof(order,request.voucherFileId()); + if(jdbc.update("UPDATE ll_procurement_control_note SET resolved_by=?,resolved_at=CURRENT_TIMESTAMP(3),resolution_reason=?,resolution_voucher_id=? WHERE id=? AND order_id=? AND blocking=1 AND resolved_at IS NULL", + operator,request.reason(),request.voucherFileId(),noteId,order.getId())!=1)throw conflict("本单待处理阻断异常不存在或已处理"); + lifecycle.complete(order,operator,"RESOLVE_CONTROL",request.idempotencyKey(),request.reason(),identity);return snapshot(order); + } + @Transactional + public Snapshot correctWarehouse(String customer,String orderNo,String operator,WarehouseCorrection request) { + var order=lifecycle.lock(customer,orderNo); + if(lifecycle.replay(order,"CORRECT_ACTUAL_WAREHOUSE",request.idempotencyKey(),request))return snapshot(order); + version(order,request.version());proof(order,request.voucherFileId());var flow=lifecycle.flow(order.getId()); + if(flow.get("actual_warehouse_id")==null)throw conflict("尚无实际收货仓,请修改计划仓"); + var warehouse=warehouses.required(request.warehouseId()); + long old=((Number)flow.get("actual_warehouse_id")).longValue();if(old==request.warehouseId())throw bad("更正仓库与原仓库相同"); + Integer downstream=jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_carton WHERE order_id=?",Integer.class,order.getId()); + boolean appendOnly=downstream>0 || flow.get("overseas_shipped_at")!=null || Set.of("SHIPPED_TO_CUSTOMER","EXCEPTION_CLOSED").contains(order.getStatus()); + var before=new LinkedHashMap();before.put("warehouseId",old);before.put("code",flow.get("actual_warehouse_code"));before.put("name",flow.get("actual_warehouse_name")); + insertNote(order,operator,"WAREHOUSE",old,appendOnly,request.voucherFileId(),request.reason(),null,before,warehouse); + if(!appendOnly)jdbc.update("UPDATE ll_procurement_flow SET actual_warehouse_id=?,actual_warehouse_code=?,actual_warehouse_name=? WHERE order_id=?",warehouse.warehouseId(),warehouse.warehouseCode(),warehouse.warehouseName(),order.getId()); + lifecycle.complete(order,operator,"CORRECT_ACTUAL_WAREHOUSE",request.idempotencyKey(),request.reason(),request);return snapshot(order); + } + @Transactional + public Snapshot close(String customer,String orderNo,String operator,Close request) { + var order=lifecycle.lock(customer,orderNo); + if(lifecycle.replay(order,"EXCEPTION_CLOSE",request.idempotencyKey(),request))return snapshot(order); + version(order,request.version());open(order);lifecycle.requireNoBlockers(order); + if(lifecycle.flow(order.getId()).get("purchased_at")==null)throw conflict("购买前请取消订单,不使用购买后异常关闭"); + proof(order,request.customerVoucherFileId());finance.requireShipmentReady(order.getId()); + Map expected=new LinkedHashMap<>();for(var source:remaining(order))expected.put(key(source.kind(),source.sourceId()),source); + if(request.resolutions()==null)throw bad("请逐来源核对剩余货物去向"); + for(var resolution:request.resolutions()) { + var source=expected.remove(key(resolution.kind(),resolution.sourceId())); + if(source==null || source.quantity()!=resolution.quantity())throw bad("最终处置必须恰好覆盖本单每份剩余来源数量,不能重复或跨单关联"); + if(!decisions(resolution.kind()).contains(resolution.decision()))throw bad("处置方式与货物来源不匹配"); + if(resolution.destination()==null || resolution.destination().isBlank())throw bad("请填写货物实际去向及接收方"); + proof(order,resolution.voucherFileId()); + } + if(!expected.isEmpty())throw conflict("仍有货物去向未明确,不能关闭"); + if(request.financialConclusion()==null || request.financialConclusion().isBlank() || request.clientSummary()==null || request.clientSummary().isBlank())throw bad("请填写财务处理结论及对客关闭摘要"); + long id=IdWorker.getId(); + jdbc.update("INSERT INTO ll_procurement_closure(id,order_id,customer_id,closure_no,customer_voucher_file_id,financial_conclusion,reason,client_summary,created_by) VALUES(?,?,?,?,?,?,?,?,?)", + id,order.getId(),customer,number("PX"),request.customerVoucherFileId(),request.financialConclusion(),request.reason(),request.clientSummary(),operator); + for(var r:request.resolutions())jdbc.update("INSERT INTO ll_procurement_closure_item(id,closure_id,source_kind,source_id,quantity,decision,destination,voucher_file_id) VALUES(?,?,?,?,?,?,?,?)", + IdWorker.getId(),id,r.kind(),r.sourceId(),r.quantity(),r.decision(),r.destination(),r.voucherFileId()); + jdbc.update("UPDATE ll_procurement_flow SET closed_at=CURRENT_TIMESTAMP(3),control_reason=? WHERE order_id=?",request.reason(),order.getId()); + order.setStatus("EXCEPTION_CLOSED");lifecycle.complete(order,operator,"EXCEPTION_CLOSE",request.idempotencyKey(),request.reason(),request);return snapshot(order); + } + List remaining(OverseasProcurementOrder order) { + var flow=lifecycle.flow(order.getId());if(flow.get("purchased_at")==null || flow.get("closed_at")!=null || flow.get("customer_shipped_at")!=null)return List.of(); + var result=new ArrayList(); + var receipts=jdbc.queryForList("SELECT * FROM ll_procurement_receipt WHERE order_id=? AND active=1 ORDER BY id",order.getId()); + long overseasReceived=0; + for(var row:receipts) { + long receiptId=((Number)row.get("id")).longValue();String leg=(String)row.get("leg");String no=(String)row.get("receipt_no"); + long good=((Number)row.get("good_quantity")).longValue(),damage=((Number)row.get("damaged_quantity")).longValue(),extra=((Number)row.get("extra_quantity")).longValue(); + if("OVERSEAS".equals(leg))overseasReceived=Math.addExact(overseasReceived,Math.addExact(good,damage)); + if(!"OVERSEAS".equals(leg) || flow.get("overseas_shipped_at")==null)add(result,"GOOD",receiptId,no,good); + add(result,"DAMAGED",receiptId,no,damage-resolved(order.getId(),leg,"DAMAGED",receiptId,null)); + add(result,"EXTRA",receiptId,no,extra-resolved(order.getId(),leg,"EXTRA",receiptId,null)); + } + add(result,"PURCHASE_REMAINDER",null,order.getOrderNo(),((Number)flow.get("purchased_quantity")).longValue()-overseasReceived-resolved(order.getId(),"OVERSEAS","SHORTAGE",null,null)); + if(flow.get("overseas_shipped_at")!=null) { + var shipped=jdbc.queryForList("SELECT i.carton_id,i.quantity,c.carton_no FROM ll_procurement_shipment s JOIN ll_procurement_shipment_item i ON i.shipment_id=s.id JOIN ll_procurement_carton c ON c.id=i.carton_id WHERE s.order_id=? AND s.leg='OVERSEAS'",order.getId()); + for(var row:shipped) { + long carton=((Number)row.get("carton_id")).longValue(); + long received=receipts.stream().filter(r->"DOMESTIC".equals(r.get("leg")) && ((Number)r.get("source_carton_id")).longValue()==carton) + .mapToLong(r->Math.addExact(((Number)r.get("good_quantity")).longValue(),((Number)r.get("damaged_quantity")).longValue())).reduce(0,Math::addExact); + add(result,"TRANSIT_REMAINDER",carton,(String)row.get("carton_no"),((Number)row.get("quantity")).longValue()-received-resolved(order.getId(),"DOMESTIC","SHORTAGE",null,carton)); + } + } + return result; + } + private long resolved(long orderId,String leg,String kind,Long receipt,Long carton) { + if(receipt!=null)return jdbc.queryForObject("SELECT COALESCE(SUM(quantity),0) FROM ll_procurement_disposition WHERE order_id=? AND leg=? AND kind=? AND receipt_id=? AND active=1",Long.class,orderId,leg,kind,receipt); + return carton==null?jdbc.queryForObject("SELECT COALESCE(SUM(quantity),0) FROM ll_procurement_disposition WHERE order_id=? AND leg=? AND kind=? AND source_carton_id IS NULL AND active=1",Long.class,orderId,leg,kind) + :jdbc.queryForObject("SELECT COALESCE(SUM(quantity),0) FROM ll_procurement_disposition WHERE order_id=? AND leg=? AND kind=? AND source_carton_id=? AND active=1",Long.class,orderId,leg,kind,carton); + } + private void validateTarget(OverseasProcurementOrder order,String type,Long id) { + if("PURCHASE".equals(type)) {if(id!=null || lifecycle.flow(order.getId()).get("purchased_at")==null)throw bad("购买说明须关联本单已记录购买");return;} + if("WAREHOUSE".equals(type)) {Object warehouse=lifecycle.flow(order.getId()).get("actual_warehouse_id");if(id==null || !(warehouse instanceof Number n) || n.longValue()!=id)throw bad("仓库说明须关联本单实际仓");return;} + String table=switch(type){case "RECEIPT"->"ll_procurement_receipt";case "SHIPMENT"->"ll_procurement_shipment";default->throw bad("说明对象类型不正确");}; + if(id==null || jdbc.queryForObject("SELECT COUNT(*) FROM "+table+" WHERE order_id=? AND id=?",Integer.class,order.getId(),id)!=1)throw bad("说明对象不属于本采购订单"); + } + private void insertNote(OverseasProcurementOrder order,String operator,String type,Long target,boolean blocking,long voucher,String reason,String clientSummary,Object original,Object corrected) { + jdbc.update("INSERT INTO ll_procurement_control_note(id,order_id,customer_id,note_no,target_type,target_id,blocking,voucher_file_id,reason,client_summary,original_value_json,corrected_value_json,created_by) VALUES(?,?,?,?,?,?,?,?,?,?,CAST(? AS JSON),CAST(? AS JSON),?)", + IdWorker.getId(),order.getId(),order.getCustomerId(),number("PN"),type,target,blocking,voucher,reason,clientSummary,serialize(original),serialize(corrected),operator); + } + private void proof(OverseasProcurementOrder order,long id){lifecycle.requireFile(order,id,Set.of("EXCEPTION"));} + private static String key(String kind,Long id){return kind+":"+id;} + private static void add(List result,String kind,Long source,String no,long quantity){if(quantity<0)throw conflict("货物来源与处置数量不一致,不能关闭");if(quantity>0)result.add(new Remaining(kind,source,no,quantity));} + private static Set decisions(String kind){return switch(kind){case "PURCHASE_REMAINDER"->Set.of("SUPPLIER_RETAINED","RETURNED_TO_SUPPLIER","LOSS_CONFIRMED");case "TRANSIT_REMAINDER"->Set.of("LOSS_CONFIRMED","RETURNED_TO_SENDER");case "GOOD"->Set.of("RETURNED_TO_SUPPLIER","CUSTOMER_AUTHORIZED_RETENTION","DISPOSED");case "DAMAGED"->Set.of("DISPOSED","RETURNED_TO_SUPPLIER");case "EXTRA"->Set.of("RETURNED_TO_SUPPLIER");default->Set.of();};} + @Transactional(readOnly=true) + public Snapshot read(String customer,String orderNo){return snapshot(lifecycle.requireOrder(customer,orderNo));} + private Snapshot snapshot(OverseasProcurementOrder order){return new Snapshot(order.getOrderNo(),order.getVersion(),order.getStatus(),jdbc.queryForList("SELECT * FROM ll_procurement_control_note WHERE order_id=? ORDER BY id",order.getId()).stream().map(this::noteView).toList(),remaining(order),jdbc.queryForList("SELECT * FROM ll_procurement_closure WHERE order_id=?",order.getId()),jdbc.queryForList("SELECT i.* FROM ll_procurement_closure c JOIN ll_procurement_closure_item i ON i.closure_id=c.id WHERE c.order_id=? ORDER BY i.id",order.getId()));} + private Map noteView(Map row) { + var result=new LinkedHashMap<>(row); + for(String field:List.of("original_value_json","corrected_value_json")) { + Object value=row.get(field); + if(value!=null)try { + var node=value instanceof byte[] bytes?json.readTree(bytes):json.readTree(String.valueOf(value)); + if(node.isNull()){result.put(field,null);continue;} + if(!node.isObject())throw new IllegalStateException("采购事实更正快照必须为对象"); + result.put(field,node); + }catch(java.io.IOException exception){throw new IllegalStateException("采购事实更正快照读取失败",exception);} + } + return result; + } + private String serialize(Object value){if(value==null)return null;try{return json.writeValueAsString(value);}catch(Exception e){throw new IllegalStateException(e);}} + private static String number(String prefix){return prefix+'-'+UUID.randomUUID().toString().replace("-","").substring(0,24).toUpperCase();} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementFeePurpose.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementFeePurpose.java new file mode 100644 index 000000000..71ad9f5f0 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementFeePurpose.java @@ -0,0 +1,14 @@ +package com.baiyue.lastleg.procurement.service; + +/** Fee semantics are fixed by the business node, never inferred from editable display names. */ +public enum ProcurementFeePurpose { + SAMPLE_PAYMENT("样品货款"), INTERNATIONAL_ESTIMATE("预估国际运费"), + INTERNATIONAL_FREIGHT("实际国际运费"), DUTY("关税"), DOMESTIC_FREIGHT("国内运费"), + OTHER("其他费用"), ADJUSTMENT("追加费用"), REFUND("退款"); + + private final String label; + ProcurementFeePurpose(String label) { this.label = label; } + public String label() { return label; } + public boolean estimate() { return this == INTERNATIONAL_ESTIMATE; } + public boolean immediateCharge() { return this != INTERNATIONAL_ESTIMATE && this != REFUND; } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementFinanceService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementFinanceService.java new file mode 100644 index 000000000..0aeafce03 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementFinanceService.java @@ -0,0 +1,532 @@ +package com.baiyue.lastleg.procurement.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.common.event.WalletCommandCompletedV1; +import com.baiyue.common.event.WalletSettlementReference; +import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos; +import com.baiyue.lastleg.financefee.service.OverseasFeeCurrencyConverter; +import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService; +import com.baiyue.lastleg.procurement.entity.OverseasProcurementFee; +import com.baiyue.lastleg.procurement.entity.OverseasProcurementFile; +import com.baiyue.lastleg.procurement.entity.OverseasProcurementOrder; +import com.baiyue.lastleg.procurement.mapper.OverseasProcurementFeeMapper; +import com.baiyue.lastleg.procurement.mapper.OverseasProcurementFileMapper; +import com.baiyue.lastleg.procurement.mapper.OverseasProcurementOrderMapper; +import com.baiyue.lastleg.wallet.WalletCommandOutboxService; +import com.baiyue.lastleg.wallet.WalletResultHandler; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.math.BigDecimal; +import java.security.MessageDigest; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.HexFormat; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; + +/** Procurement-specific orchestration. Called by authorized fulfillment commands while the order is locked. */ +@Service +@RequiredArgsConstructor +public class ProcurementFinanceService implements WalletResultHandler { + private final OverseasProcurementFeeMapper fees; + private final OverseasProcurementFileMapper files; + private final OverseasProcurementOrderMapper orders; + private final OverseasFeeCurrencyConverter converter; + private final OverseasFeeSourceService sources; + private final WalletCommandOutboxService commands; + private final JdbcTemplate jdbc; + private final ObjectMapper json; + + public record Confirmation(ProcurementFeePurpose purpose, String feeItemCode, String feeItemName, + String determination, BigDecimal originalAmount, String originalCurrency, + LocalDate businessDate, Long voucherFileId, String reason) { } + + @Transactional + public OverseasProcurementFee confirm(String customerId, String orderNo, String operatorId, Confirmation request) { + OverseasProcurementOrder order = lockOrder(customerId, orderNo); + return confirmLocked(order, operatorId, request, null); + } + + /** The estimate remains its own immutable fact; this actual fee consumes that same reservation. */ + @Transactional + public OverseasProcurementFee confirmInternational(String customerId, String orderNo, String operatorId, + Confirmation request) { + OverseasProcurementOrder order = lockOrder(customerId, orderNo); + if (request.purpose() != ProcurementFeePurpose.INTERNATIONAL_FREIGHT) throw bad("实际国际运费用途不正确"); + var existing = current(order.getId(), ProcurementFeePurpose.INTERNATIONAL_FREIGHT, "INTERNATIONAL_FREIGHT"); + if (existing != null) { + if (!Objects.equals(existing.getRequestHash(), hash(request))) throw conflict("国际运费已确认,请通过受控更正处理"); + return existing; + } + OverseasProcurementFee estimate = current(order.getId(), ProcurementFeePurpose.INTERNATIONAL_ESTIMATE, "INTERNATIONAL_ESTIMATE"); + if (estimate == null || !Set.of("FROZEN", "ZERO", "RELEASED").contains(estimate.getWalletStatus())) { + throw conflict("国际运费预估尚未完成冻结或零元确认"); + } + return confirmLocked(order, operatorId, request, estimate); + } + + private OverseasProcurementFee confirmLocked(OverseasProcurementOrder order, String operatorId, + Confirmation request, OverseasProcurementFee estimate) { + validate(request); + if (request.purpose() == ProcurementFeePurpose.REFUND || request.purpose() == ProcurementFeePurpose.ADJUSTMENT) { + throw bad("财务调整必须关联原费用,不得使用普通核费入口"); + } + if (request.purpose() == ProcurementFeePurpose.INTERNATIONAL_FREIGHT && estimate == null) { + throw bad("实际国际运费必须核对原预估"); + } + String code = request.purpose() == ProcurementFeePurpose.OTHER ? request.feeItemCode().trim() : request.purpose().name(); + String requestHash = hash(request); + OverseasProcurementFee existing = current(order.getId(), request.purpose(), code); + if (existing != null) { + if (!Objects.equals(existing.getRequestHash(), requestHash)) throw conflict("费用已确认,请通过受控更正处理"); + return existing; + } + if(!request.purpose().estimate()) { + Integer previous=jdbc.queryForObject("SELECT COUNT(*) FROM ll_overseas_procurement_fee WHERE order_id=? AND purpose=? AND fee_item_code=?",Integer.class,order.getId(),request.purpose().name(),code); + if(previous!=null && previous>0)throw conflict("该实际费用已有历史版本,请使用财务权限重新核定已终止费用"); + } + return createFee(order,operatorId,request,estimate,converter.convert(request.originalCurrency(),request.originalAmount(),request.businessDate()),"BUSINESS"); + } + + // Both Lastleg and Finance fee sources store DECIMAL(20,6), before any wallet command. + static void requireSourceCapacity(BigDecimal original,BigDecimal settlement) { + if(settlement.precision()-settlement.scale()>14)throw bad("折算人民币金额超出支持范围"); + if(original.precision()-original.scale()>14)throw bad("原币金额超出费用来源支持范围"); + } + + private OverseasProcurementFee createFee(OverseasProcurementOrder order,String operatorId,Confirmation request, + OverseasProcurementFee estimate,OverseasFeeCurrencyConverter.Conversion conversion,String origin) { + validate(request); + String code=request.purpose()==ProcurementFeePurpose.OTHER?request.feeItemCode().trim():request.purpose().name(); + String requestHash=hash(request); + boolean positive="POSITIVE".equals(request.determination()); + requireSourceCapacity(conversion.originalAmount(),conversion.settlementAmount()); + if (positive && conversion.settlementAmount().signum() <= 0) throw bad("折算后的实际金额必须大于零"); + OverseasProcurementFile voucher = voucher(order, request, positive); + Integer maxVersion = jdbc.queryForObject("SELECT COALESCE(MAX(fee_version),0) FROM ll_overseas_procurement_fee WHERE order_id=? AND purpose=? AND fee_item_code=?", + Integer.class, order.getId(), request.purpose().name(), code); + LocalDateTime now = LocalDateTime.now(); + OverseasProcurementFee fee = new OverseasProcurementFee(); + fee.setId(IdWorker.getId()); fee.setOrderId(order.getId()); fee.setCustomerId(order.getCustomerId()); + fee.setFeeNo("PF-" + UUID.randomUUID().toString().replace("-", "").substring(0, 24).toUpperCase()); + fee.setPurpose(request.purpose().name()); fee.setConfirmationOrigin(origin); fee.setFeeVersion(maxVersion == null ? 1 : maxVersion + 1); + fee.setActive(true); fee.setDetermination(request.determination()); fee.setFeeItemCode(code); + fee.setFeeItemName(request.purpose() == ProcurementFeePurpose.OTHER ? request.feeItemName().trim() : request.purpose().label()); + fee.setOriginalCurrency(conversion.originalCurrency()); fee.setOriginalAmount(conversion.originalAmount()); + fee.setExchangeRate(conversion.exchangeRate()); fee.setExchangeRateMonth(conversion.exchangeRateMonth()); + fee.setAmount(conversion.settlementAmount()); fee.setCurrency("CNY"); fee.setBusinessDate(request.businessDate()); + fee.setWalletBusinessId(estimate != null && "FROZEN".equals(estimate.getWalletStatus()) ? estimate.getId() : fee.getId()); + fee.setWalletBusinessNo(estimate != null && "FROZEN".equals(estimate.getWalletStatus()) ? estimate.getFeeNo() : fee.getFeeNo()); + fee.setParentFeeId(estimate == null ? null : estimate.getId()); + fee.setVoucherFileId(voucher == null ? null : voucher.getFileId()); + fee.setVoucherFileName(voucher == null ? null : voucher.getFileName()); + fee.setRemark(request.reason()); fee.setRequestHash(requestHash); fee.setCreatedBy(operatorId); + fee.setCreatedAt(now); fee.setUpdatedAt(now); + fee.setWalletStatus(positive ? "PENDING" : request.determination()); + fees.insert(fee); + if (estimate != null && "FROZEN".equals(estimate.getWalletStatus())) { + fee.setWalletHoldId(estimate.getWalletHoldId()); + issue(fee, positive ? "SETTLE_ACTUAL" : "RELEASE", positive ? fee.getAmount() : null); + } else if (positive) { + issue(fee, "FREEZE", fee.getAmount()); + } + return fee; + } + + /** Only the latest definitively terminated, never-consumed actual version can be replaced. */ + public OverseasProcurementFee requireFailedReplacement(OverseasProcurementOrder order,long feeId) { + var original=required(order,feeId); + if(Set.of("INTERNATIONAL_ESTIMATE","ADJUSTMENT","REFUND").contains(original.getPurpose()) + || Boolean.TRUE.equals(original.getActive()) || !Set.of("TERMINATED","RELEASED").contains(original.getWalletStatus()) + || original.getSettledCommandEventId()!=null || original.getSourceFeeId()!=null)throw conflict("须先终止未成功实扣的原实际费用,并完成有效冻结释放"); + var attempts=jdbc.queryForList("SELECT operation,status FROM ll_procurement_wallet_attempt WHERE fee_id=? ORDER BY attempt_no DESC",feeId); + if(attempts.isEmpty())throw conflict("原费用缺少可核验的失败及终止记录"); + var last=attempts.get(0); + boolean terminated="TERMINATED".equals(original.getWalletStatus()) && "FREEZE".equals(last.get("operation")) && "FAILED".equals(last.get("status")); + boolean released="RELEASED".equals(original.getWalletStatus()) && "RELEASE".equals(last.get("operation")) && "SUCCEEDED".equals(last.get("status")); + if((!terminated && !released) || attempts.stream().anyMatch(row->Set.of("SETTLE","SETTLE_ACTUAL").contains(row.get("operation")) && "SUCCEEDED".equals(row.get("status"))))throw conflict("原费用未明确结束或已经实扣,不能创建替代费用"); + Integer pending=jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_wallet_attempt WHERE order_id=? AND business_no=? AND status='PENDING'",Integer.class,order.getId(),original.getWalletBusinessNo()); + if(pending!=null && pending>0)throw conflict("原费用有关资金请求仍未决"); + Integer latest=jdbc.queryForObject("SELECT MAX(fee_version) FROM ll_overseas_procurement_fee WHERE order_id=? AND purpose=? AND fee_item_code=?",Integer.class,order.getId(),original.getPurpose(),original.getFeeItemCode()); + if(!Objects.equals(latest,original.getFeeVersion()) || current(order.getId(),ProcurementFeePurpose.valueOf(original.getPurpose()),original.getFeeItemCode())!=null)throw conflict("已有后续费用版本,请处理最新版本,不能重复替代旧费用"); + if(original.getExchangeRate()==null || original.getExchangeRate().signum()<=0 || original.getExchangeRateMonth()==null || original.getBusinessDate()==null)throw conflict("原费用汇率或业务日期快照缺失"); + return original; + } + @Transactional + public OverseasProcurementFee replaceFailed(String customer,String orderNo,String operator,long originalFeeId,BigDecimal target,long voucherId,String reason) { + var order=lockOrder(customer,orderNo);var original=requireFailedReplacement(order,originalFeeId); + if(target==null || target.signum()<0 || target.scale()>2)throw bad("替代费用原币总额须为非负两位小数"); + var purpose=ProcurementFeePurpose.valueOf(original.getPurpose()); + var request=new Confirmation(purpose,original.getFeeItemCode(),original.getFeeItemName(),target.signum()>0?"POSITIVE":"ZERO",target, + original.getOriginalCurrency(),original.getBusinessDate(),voucherId,reason); + var conversion=new OverseasFeeCurrencyConverter.Conversion(original.getOriginalCurrency(),target,original.getExchangeRate(),original.getExchangeRateMonth(),target.multiply(original.getExchangeRate()).setScale(2,java.math.RoundingMode.HALF_UP)); + // The original reservation has already ended; the replacement owns a new wallet business identity. + var replacement=createFee(order,operator,request,null,conversion,"FINANCIAL"); + replacement.setParentFeeId(original.getParentFeeId());fees.updateById(replacement); + return replacement; + } + + @Transactional + public OverseasProcurementFee confirmAdditional(String customer,String orderNo,String operator,Confirmation request) { + var order=lockOrder(customer,orderNo);validate(request); + if(request.purpose()!=ProcurementFeePurpose.OTHER || !"POSITIVE".equals(request.determination()))throw bad("独立新增费用须为正额其他费用"); + Integer previous=jdbc.queryForObject("SELECT COUNT(*) FROM ll_overseas_procurement_fee WHERE order_id=? AND fee_item_code=?",Integer.class,order.getId(),request.feeItemCode()); + if(previous!=null && previous>0)throw conflict("独立费用编号已使用,不能重复新增"); + return createFee(order,operator,request,null,converter.convert(request.originalCurrency(),request.originalAmount(),request.businessDate()),"FINANCIAL"); + } + + public OverseasProcurementFee current(long orderId, ProcurementFeePurpose purpose, String code) { + return fees.selectOne(new LambdaQueryWrapper() + .eq(OverseasProcurementFee::getOrderId, orderId).eq(OverseasProcurementFee::getPurpose, purpose.name()) + .eq(OverseasProcurementFee::getFeeItemCode, code).eq(OverseasProcurementFee::getActive, true)); + } + + public List all(long orderId) { + return fees.selectList(new LambdaQueryWrapper().eq(OverseasProcurementFee::getOrderId,orderId).orderByAsc(OverseasProcurementFee::getId)); + } + public OverseasProcurementFee required(OverseasProcurementOrder order,long feeId) { + var fee=fees.lock(feeId,order.getId(),order.getCustomerId()); + if(fee==null)throw bad("费用不属于本采购订单");return fee; + } + + @Transactional + public OverseasProcurementFee refund(String customerId, String orderNo, String operatorId, long originalFeeId, + BigDecimal amount, long voucherFileId, String reason, String idempotencyKey) { + var order = lockOrder(customerId, orderNo); + return refundLocked(order,operatorId,originalFeeId,amount,voucherFileId,reason,idempotencyKey,true); + } + private OverseasProcurementFee refundLocked(OverseasProcurementOrder order,String operatorId,long originalFeeId, + BigDecimal amount,long voucherFileId,String reason,String idempotencyKey,boolean checkChain) { + String customerId=order.getCustomerId(),orderNo=order.getOrderNo(); + if (idempotencyKey == null || idempotencyKey.isBlank() || idempotencyKey.length() > 128) throw bad("退款幂等键无效"); + var request = new Confirmation(ProcurementFeePurpose.REFUND, "REFUND", "退款", "POSITIVE", amount, + "CNY", LocalDate.now(), voucherFileId, reason); + validate(request); + // Identity is independent of wall-clock time, so retrying after midnight returns the same refund. + String number = "RF-" + UUID.nameUUIDFromBytes((customerId + ':' + orderNo + ':' + idempotencyKey) + .getBytes(java.nio.charset.StandardCharsets.UTF_8)).toString().replace("-", "").toUpperCase(); + String requestHash = hash(List.of(originalFeeId, amount, voucherFileId, reason)); + var existing = fees.selectOne(new LambdaQueryWrapper().eq(OverseasProcurementFee::getFeeNo, number)); + if (existing != null) { + if (!requestHash.equals(existing.getRequestHash())) throw conflict("退款幂等键对应不同原扣款或金额"); + return existing; + } + var original = fees.lock(originalFeeId, order.getId(), customerId); + if (original == null || !"SETTLED".equals(original.getWalletStatus()) || original.getWalletLedgerId() == null + || ProcurementFeePurpose.INTERNATIONAL_ESTIMATE.name().equals(original.getPurpose())) { + throw conflict("退款必须关联本单已成功实扣的实际费用"); + } + if(checkChain)readyChain(order,original); + List refunds = fees.selectList(new LambdaQueryWrapper() + .eq(OverseasProcurementFee::getParentFeeId, originalFeeId).eq(OverseasProcurementFee::getPurpose, "REFUND")); + if (refunds.stream().anyMatch(f -> !Set.of("REFUNDED", "TERMINATED").contains(f.getWalletStatus()))) { + throw conflict("原费用存在未完成退款,须先处理原请求"); + } + BigDecimal already = refunds.stream().filter(f -> "REFUNDED".equals(f.getWalletStatus())) + .map(OverseasProcurementFee::getAmount).reduce(BigDecimal.ZERO, BigDecimal::add); + if (already.add(amount).compareTo(original.getAmount()) > 0) throw bad("退款超过原扣款可退余额"); + var voucher = voucher(order, request, true); + var fee = new OverseasProcurementFee(); + fee.setId(IdWorker.getId()); fee.setOrderId(order.getId()); fee.setCustomerId(customerId); fee.setFeeNo(number); + fee.setConfirmationOrigin("FINANCIAL");fee.setPurpose("REFUND"); fee.setFeeItemCode("REFUND"); fee.setFeeItemName("关联费用退款"); + Integer last = jdbc.queryForObject("SELECT COALESCE(MAX(fee_version),0) FROM ll_overseas_procurement_fee WHERE order_id=? AND purpose='REFUND'", Integer.class, order.getId()); + fee.setFeeVersion(last == null ? 1 : last + 1); fee.setActive(true); fee.setDetermination("POSITIVE"); + fee.setAmount(amount); fee.setCurrency("CNY"); fee.setOriginalAmount(amount); fee.setOriginalCurrency("CNY"); + fee.setExchangeRate(BigDecimal.ONE); fee.setExchangeRateMonth(request.businessDate().toString().substring(0, 7)); + fee.setBusinessDate(request.businessDate()); fee.setParentFeeId(originalFeeId); fee.setWalletBusinessId(fee.getId()); + fee.setWalletBusinessNo(number); fee.setVoucherFileId(voucher.getFileId()); fee.setVoucherFileName(voucher.getFileName()); + fee.setRemark(reason); fee.setRequestHash(requestHash); fee.setCreatedBy(operatorId); fee.setWalletStatus("PENDING"); + fee.setCreatedAt(LocalDateTime.now()); fee.setUpdatedAt(fee.getCreatedAt()); fees.insert(fee); + issue(fee, "REFUND", amount); + return fee; + } + + /** Includes the base fee, all positive corrections and refunds against each actual consumption. */ + public List readyChain(OverseasProcurementOrder order,OverseasProcurementFee selected) { + var root="ADJUSTMENT".equals(selected.getPurpose())?required(order,selected.getParentFeeId()):selected; + if(Set.of("REFUND","ADJUSTMENT","INTERNATIONAL_ESTIMATE").contains(root.getPurpose()) + || !Boolean.TRUE.equals(root.getActive()) || !sourceLinked(root))throw conflict("原费用尚未实扣及关联完成,不能更正费用链"); + var all=all(order.getId()); + Set originals=new java.util.HashSet<>();originals.add(root.getId()); + all.stream().filter(f->"ADJUSTMENT".equals(f.getPurpose()) && Objects.equals(f.getParentFeeId(),root.getId())).forEach(f->originals.add(f.getId())); + var chain=all.stream().filter(f->originals.contains(f.getId()) || ("REFUND".equals(f.getPurpose()) && originals.contains(f.getParentFeeId()))).toList(); + if(chain.stream().anyMatch(f->Boolean.TRUE.equals(f.getActive()) && !sourceLinked(f)))throw conflict("原费用链存在未完成的调整或退款,须先完成或终止旧请求"); + return chain; + } + + /** The correction target is converted with the original rate; this new charge is only the exact CNY difference. */ + @Transactional + public OverseasProcurementFee supplement(String customer,String orderNo,String operator,long rootId,BigDecimal delta,long voucherId,String reason,String correctionNo) { + var order=lockOrder(customer,orderNo);var root=required(order,rootId);readyChain(order,root); + if(delta==null || delta.signum()<=0 || delta.scale()>2)throw bad("追加费用仅允许正额CNY差额"); + requireSourceCapacity(delta,delta); + var proof=voucher(order,new Confirmation(ProcurementFeePurpose.ADJUSTMENT,"ADJUSTMENT","追加费用","POSITIVE",delta,"CNY",root.getBusinessDate(),voucherId,reason),true); + var fee=new OverseasProcurementFee();fee.setId(IdWorker.getId());fee.setOrderId(order.getId());fee.setCustomerId(customer); + fee.setFeeNo("PF-"+UUID.randomUUID().toString().replace("-","").substring(0,24).toUpperCase());fee.setConfirmationOrigin("FINANCIAL");fee.setPurpose("ADJUSTMENT"); + fee.setFeeItemCode("ADJ_"+root.getId());fee.setFeeItemName("原费用正额调整"); + fee.setFeeVersion(jdbc.queryForObject("SELECT COALESCE(MAX(fee_version),0)+1 FROM ll_overseas_procurement_fee WHERE order_id=? AND purpose='ADJUSTMENT' AND fee_item_code=?",Integer.class,order.getId(),fee.getFeeItemCode())); + fee.setActive(true);fee.setDetermination("POSITIVE");fee.setAmount(delta);fee.setCurrency("CNY");fee.setOriginalCurrency("CNY");fee.setOriginalAmount(delta); + fee.setExchangeRate(BigDecimal.ONE.setScale(6));fee.setExchangeRateMonth(root.getExchangeRateMonth());fee.setBusinessDate(root.getBusinessDate()); + fee.setWalletBusinessId(fee.getId());fee.setWalletBusinessNo(fee.getFeeNo());fee.setParentFeeId(rootId);fee.setVoucherFileId(proof.getFileId());fee.setVoucherFileName(proof.getFileName()); + fee.setRemark(reason);fee.setRequestHash(hash(List.of(correctionNo,rootId,delta,voucherId,reason)));fee.setCreatedBy(operator);fee.setCreatedAt(LocalDateTime.now());fee.setUpdatedAt(fee.getCreatedAt());fee.setWalletStatus("PENDING"); + fees.insert(fee);issue(fee,"FREEZE",delta);return fee; + } + + @Transactional + public List reduceChain(String customer,String orderNo,String operator,long rootId,BigDecimal amount,long voucherId,String reason,String correctionNo) { + var order=lockOrder(customer,orderNo);var root=required(order,rootId);var chain=readyChain(order,root); + if(amount==null || amount.signum()<=0 || amount.scale()>2)throw bad("减少费用须填写正额CNY退款差额"); + BigDecimal remaining=amount;var created=new java.util.ArrayList(); + for(var original:chain) { + if(!"SETTLED".equals(original.getWalletStatus()) || "REFUND".equals(original.getPurpose()))continue; + BigDecimal returned=chain.stream().filter(f->"REFUNDED".equals(f.getWalletStatus()) && Objects.equals(f.getParentFeeId(),original.getId())).map(OverseasProcurementFee::getAmount).reduce(BigDecimal.ZERO,BigDecimal::add); + BigDecimal available=original.getAmount().subtract(returned),part=available.min(remaining); + if(part.signum()>0) { + created.add(refundLocked(order,operator,original.getId(),part,voucherId,reason,correctionNo+":"+original.getId(),false));remaining=remaining.subtract(part); + } + if(remaining.signum()==0)break; + } + if(remaining.signum()>0)throw bad("减少金额超过费用链净实扣");return created; + } + + /** Called only after Finance has reported a definite failure. Unknown outcomes keep their original request. */ + @Transactional + public OverseasProcurementFee retry(String customerId, String orderNo, long feeId) { + OverseasProcurementOrder order = lockOrder(customerId, orderNo); + OverseasProcurementFee fee = fees.lock(feeId, order.getId(), customerId); + if (fee == null || !Boolean.TRUE.equals(fee.getActive())) throw bad("有效费用不存在"); + List> attempts = jdbc.queryForList("SELECT operation,amount,status FROM ll_procurement_wallet_attempt WHERE fee_id=? ORDER BY attempt_no DESC LIMIT 1 FOR UPDATE", feeId); + if (attempts.isEmpty() || !"FAILED".equals(attempts.get(0).get("status"))) throw conflict("资金结果未明确失败,不能发起新尝试"); + Map last = attempts.get(0); + issue(fee, String.valueOf(last.get("operation")), (BigDecimal) last.get("amount")); + return fee; + } + + @Transactional + public void releaseEstimate(String customerId, String orderNo, long feeId) { + var order = lockOrder(customerId, orderNo); + var fee = fees.lock(feeId, order.getId(), customerId); + if (fee == null || !Boolean.TRUE.equals(fee.getActive()) || !"INTERNATIONAL_ESTIMATE".equals(fee.getPurpose())) { + throw bad("当前预估费用不存在"); + } + Integer pending = jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_wallet_attempt WHERE order_id=? AND business_no=? AND status='PENDING'", + Integer.class, order.getId(), fee.getWalletBusinessNo()); + if (pending != null && pending > 0) throw conflict("冻结有关资金请求未决,不能释放"); + if ("ZERO".equals(fee.getWalletStatus())) { + fee.setActive(false); fee.setWalletStatus("RELEASED"); fees.updateById(fee); + } else if ("FROZEN".equals(fee.getWalletStatus())) { + issue(fee, "RELEASE", null); + } else { + throw conflict("预估不处于可释放状态"); + } + } + + @Transactional + public void terminateFailed(String customerId, String orderNo, long feeId) { + var order = lockOrder(customerId, orderNo); + var fee = fees.lock(feeId, order.getId(), customerId); + if (fee == null || !Boolean.TRUE.equals(fee.getActive())) throw bad("有效费用不存在"); + var attempts = jdbc.queryForList("SELECT status,operation FROM ll_procurement_wallet_attempt WHERE fee_id=? ORDER BY attempt_no DESC LIMIT 1 FOR UPDATE", feeId); + if (attempts.isEmpty() || !"FAILED".equals(attempts.get(0).get("status"))) throw conflict("只能终止已明确失败的资金版本"); + String operation = String.valueOf(attempts.get(0).get("operation")); + if (Set.of("FREEZE", "REFUND").contains(operation)) { + fee.setActive(false); fee.setWalletStatus("TERMINATED"); fees.updateById(fee); + } else { + issue(fee, "RELEASE", null); + } + } + + private void issue(OverseasProcurementFee fee, String operation, BigDecimal amount) { + Long previous = jdbc.queryForObject("SELECT COALESCE(MAX(attempt_no),0) FROM ll_procurement_wallet_attempt WHERE fee_id=?", Long.class, fee.getId()); + long attempt = previous == null ? 1 : previous + 1; + // A shared reservation can be settled by a different fee version, so the aggregate key includes the fee identity. + long attemptId = IdWorker.getId(); + long aggregateVersion = attemptId; + String event; + if ("REFUND".equals(operation)) { + var original = fees.lock(fee.getParentFeeId(), fee.getOrderId(), fee.getCustomerId()); + if (original == null || original.getWalletLedgerId() == null) throw conflict("原退款扣款引用缺失"); + event = commands.publishRefund(Long.parseLong(fee.getCustomerId()), "PROCUREMENT_FEE", fee.getWalletBusinessNo(), + amount, original.getWalletLedgerId(), fee.getRemark(), aggregateVersion); + } else { + event = commands.publish(operation, Long.parseLong(fee.getCustomerId()), "PROCUREMENT_FEE", fee.getWalletBusinessNo(), + amount, fee.getFeeItemName() + ":" + fee.getFeeNo(), aggregateVersion); + } + jdbc.update("INSERT INTO ll_procurement_wallet_attempt(id,order_id,customer_id,fee_id,attempt_no,operation,business_no,amount,command_event_id,status) VALUES(?,?,?,?,?,?,?,?,?,'PENDING')", + attemptId, fee.getOrderId(), fee.getCustomerId(), fee.getId(), attempt, operation, fee.getWalletBusinessNo(), amount, event); + fee.setWalletStatus(operation + "_PENDING"); fee.setWalletCommandEventId(event); fee.setUpdatedAt(LocalDateTime.now()); + fees.updateById(fee); + } + + @Override public boolean supports(String businessType) { return "PROCUREMENT_FEE".equals(businessType); } + + @Override + @Transactional + public void handle(WalletCommandCompletedV1 result) { + List> found = jdbc.queryForList("SELECT order_id,fee_id FROM ll_procurement_wallet_attempt WHERE command_event_id=? AND customer_id=?", + result.commandEventId(), String.valueOf(result.customerId())); + if (found.size() != 1) throw conflict("资金结果不属于已登记的采购请求"); + long orderId = ((Number) found.get(0).get("order_id")).longValue(); + var order = orders.selectById(orderId); + if (order == null) throw bad("采购订单不存在"); + order = lockOrder(String.valueOf(result.customerId()), order.getOrderNo()); + long feeId = ((Number) found.get(0).get("fee_id")).longValue(); + var fee = fees.lock(feeId, orderId, String.valueOf(result.customerId())); + if (fee == null) throw conflict("资金结果对应的采购费用不存在"); + Map attempt = jdbc.queryForMap("SELECT * FROM ll_procurement_wallet_attempt WHERE command_event_id=? FOR UPDATE", result.commandEventId()); + String resultHash = hash(result); + if (!"PENDING".equals(attempt.get("status"))) { + if (!resultHash.equals(attempt.get("result_hash"))) throw conflict("同一资金请求存在不同最终结果"); + return; + } + if (!result.operation().equals(attempt.get("operation")) || !result.businessNo().equals(attempt.get("business_no")) + || !Set.of("SUCCEEDED", "FAILED").contains(result.result())) throw conflict("资金结果与原请求不匹配"); + boolean success = "SUCCEEDED".equals(result.result()); + if (success) { + if (result.walletLedgerId() == null || (!"REFUND".equals(result.operation()) && result.walletHoldId() == null)) throw conflict("成功资金结果缺少权威引用"); + String expectedStatus = switch (result.operation()) { + case "FREEZE" -> "FROZEN"; + case "SETTLE", "SETTLE_ACTUAL" -> "SETTLED"; + case "RELEASE" -> "RELEASED"; + case "REFUND" -> "REFUNDED"; + default -> throw conflict("采购资金操作不受支持"); + }; + if (!expectedStatus.equals(result.holdStatus())) throw conflict("资金结果状态与操作不匹配"); + BigDecimal expected = (BigDecimal) attempt.get("amount"); + if ("SETTLE".equals(result.operation())) expected = fee.getAmount(); + if (expected != null && (result.amount() == null || expected.compareTo(result.amount()) != 0)) { + throw conflict("资金结果金额与原请求不匹配"); + } + } + jdbc.update("UPDATE ll_procurement_wallet_attempt SET status=?,result_hash=?,wallet_hold_id=?,wallet_ledger_id=?,error_code=?,error_message=?,completed_at=CURRENT_TIMESTAMP(3) WHERE command_event_id=? AND status='PENDING'", + result.result(), resultHash, result.walletHoldId(), result.walletLedgerId(), result.errorCode(), result.errorMessage(), result.commandEventId()); + // Terminal results always remain on their original attempt. Never apply an old result to a newer fee request. + if (!Objects.equals(fee.getWalletCommandEventId(), result.commandEventId())) return; + fee.setWalletStatus(success ? switch (result.operation()) { + case "FREEZE" -> "FROZEN"; + case "SETTLE", "SETTLE_ACTUAL" -> "SETTLED"; + case "RELEASE" -> "RELEASED"; + case "REFUND" -> "REFUNDED"; + default -> throw conflict("采购资金操作不受支持"); + } : result.operation() + "_FAILED"); + fee.setWalletErrorCode(success ? null : result.errorCode()); fee.setWalletErrorMessage(success ? null : result.errorMessage()); + if (success) { fee.setWalletHoldId(result.walletHoldId()); fee.setWalletLedgerId(result.walletLedgerId()); } + fee.setUpdatedAt(LocalDateTime.now()); fees.updateById(fee); + if (!success) return; + if ("RELEASE".equals(result.operation()) && ("INTERNATIONAL_ESTIMATE".equals(fee.getPurpose()) + || "POSITIVE".equals(fee.getDetermination()))) { + fee.setActive(false); fees.updateById(fee); + } + if ("REFUND".equals(result.operation())) { + fee.setSettledCommandEventId(result.commandEventId()); fees.updateById(fee); return; + } + if ("FREEZE".equals(result.operation()) && ProcurementFeePurpose.valueOf(fee.getPurpose()).immediateCharge()) { + issue(fee, "SETTLE", null); + } else if (Set.of("SETTLE", "SETTLE_ACTUAL").contains(result.operation())) { + fee.setSettledCommandEventId(result.commandEventId()); + publishSource(order, fee); + fees.updateById(fee); + } + if (fee.getParentFeeId() != null && Set.of("SETTLE_ACTUAL", "RELEASE").contains(result.operation())) { + var estimate = fees.lock(fee.getParentFeeId(), orderId, fee.getCustomerId()); + if (estimate != null && ProcurementFeePurpose.INTERNATIONAL_ESTIMATE.name().equals(estimate.getPurpose())) { + estimate.setWalletStatus(fee.getWalletStatus()); estimate.setUpdatedAt(LocalDateTime.now()); fees.updateById(estimate); + } + } + } + + private void publishSource(OverseasProcurementOrder order, OverseasProcurementFee fee) { + var request = new OverseasFeeSourceDtos.SaveRequest(fee.getCustomerId(), order.getCustomerName(), + "OVERSEAS_PROCUREMENT_FEE", "PROCUREMENT_FEE", fee.getWalletBusinessId(), fee.getWalletBusinessNo(), + order.getId(), null, null, null, null, null, null, fee.getFeeItemCode(), fee.getFeeItemName(), "RECEIVABLE", + null, null, null, null, null, null, null, List.of(), fee.getBusinessDate(), fee.getOriginalCurrency(), + fee.getOriginalAmount(), BigDecimal.ONE, "ITEM", fee.getOriginalAmount(), null, null); + var source = sources.publishSettled(fee.getCreatedBy(), "PROCUREMENT:" + fee.getFeeNo(), request, + new OverseasFeeCurrencyConverter.Conversion(fee.getOriginalCurrency(), fee.getOriginalAmount(), fee.getExchangeRate(), + fee.getExchangeRateMonth(), fee.getAmount()), + new WalletSettlementReference(fee.getSettledCommandEventId(), fee.getWalletHoldId(), fee.getWalletLedgerId())); + fee.setSourceFeeId(source.getSourceFeeId()); + } + + public boolean sourceLinked(OverseasProcurementFee fee) { + if(fee==null || fee.getWalletStatus()==null)return false; + if (Set.of("ZERO", "NOT_APPLICABLE", "REFUNDED").contains(fee.getWalletStatus())) return true; + if ("RELEASED".equals(fee.getWalletStatus()) && fee.getAmount().signum() == 0) return true; + if (!"SETTLED".equals(fee.getWalletStatus()) || fee.getSourceFeeId() == null) return false; + Integer count = jdbc.queryForObject("SELECT COUNT(*) FROM ll_event_outbox WHERE aggregate_type='OVERSEAS_PROCUREMENT_FEE' AND aggregate_id=? AND status='PUBLISHED'", + Integer.class, String.valueOf(fee.getSourceFeeId())); + return count != null && count > 0; + } + + /** Called under the procurement order lock before the final physical handoff or exception closure. */ + public void requireShipmentReady(long orderId) { + Integer pending=jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_wallet_attempt WHERE order_id=? AND status='PENDING'",Integer.class,orderId); + if(pending!=null && pending>0)throw conflict("采购存在未决资金请求,不能交寄或关闭"); + var all=fees.selectList(new LambdaQueryWrapper().eq(OverseasProcurementFee::getOrderId,orderId)); + for(var fee:all) { + if("INTERNATIONAL_ESTIMATE".equals(fee.getPurpose())) { + if(!Set.of("ZERO","RELEASED","SETTLED","TERMINATED").contains(fee.getWalletStatus()))throw conflict("预估冻结尚未结清或释放"); + } else if(Boolean.TRUE.equals(fee.getActive()) || "SETTLED".equals(fee.getWalletStatus())) { + if(!sourceLinked(fee))throw conflict("仍有费用扣款、退款、调整或费用来源关联尚未完成"); + } + } + } + + public void requireAdjustmentsComplete(long orderId) { + var adjustments=fees.selectList(new LambdaQueryWrapper() + .eq(OverseasProcurementFee::getOrderId,orderId).eq(OverseasProcurementFee::getActive,true) + .eq(OverseasProcurementFee::getConfirmationOrigin,"FINANCIAL")); + if(adjustments.stream().anyMatch(f->!sourceLinked(f)))throw conflict("存在未完成的财务调整或退款,不能继续交寄"); + } + + private OverseasProcurementFile voucher(OverseasProcurementOrder order, Confirmation request, boolean positive) { + if (request.voucherFileId() == null) { + if (positive && !request.purpose().estimate()) throw bad("正额实际费用必须关联本单凭证"); + return null; + } + var file = files.selectOne(new LambdaQueryWrapper() + .eq(OverseasProcurementFile::getOrderId, order.getId()).eq(OverseasProcurementFile::getCustomerId, order.getCustomerId()) + .eq(OverseasProcurementFile::getFileId, request.voucherFileId())); + if (file == null || !Set.of("VOUCHER", "PURCHASE").contains(file.getCategory())) throw bad("费用凭证不属于当前采购订单或分类不正确"); + return file; + } + + private static void validate(Confirmation request) { + if (request == null || request.purpose() == null || request.businessDate() == null || request.reason() == null + || request.reason().isBlank() || request.reason().length() > 500 || request.originalAmount() == null + || request.originalAmount().signum() < 0 || request.originalAmount().scale() > 2 + || request.originalCurrency() == null || !Set.of("CNY", "USD").contains(request.originalCurrency()) + || request.determination() == null || !Set.of("POSITIVE", "ZERO", "NOT_APPLICABLE").contains(request.determination())) { + throw bad("费用核定信息不完整或精度不正确"); + } + if ("POSITIVE".equals(request.determination()) != (request.originalAmount().signum() > 0)) throw bad("费用核定情况与金额不一致"); + if (request.purpose().estimate() && !"CNY".equals(request.originalCurrency())) throw bad("预估国际运费仅支持CNY"); + if (Set.of(ProcurementFeePurpose.INTERNATIONAL_ESTIMATE, ProcurementFeePurpose.SAMPLE_PAYMENT).contains(request.purpose()) + && "NOT_APPLICABLE".equals(request.determination())) throw bad("货款和预估须明确正额或零元,不能标为不适用"); + if (request.purpose() == ProcurementFeePurpose.OTHER && (request.feeItemCode() == null + || !request.feeItemCode().matches("[A-Z][A-Z0-9_]{0,31}") || request.feeItemName() == null + || request.feeItemName().isBlank() || request.feeItemName().length() > 128)) throw bad("其他费用必须填写编码和名称"); + } + + private OverseasProcurementOrder lockOrder(String customerId, String orderNo) { + var order = orders.lockNo(customerId, orderNo); + if (order == null) throw bad("采购订单不存在或不属于当前客户"); + return order; + } + private String hash(Object value) { + try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json.writeValueAsBytes(value))); } + catch (Exception exception) { throw new IllegalStateException("采购请求摘要生成失败", exception); } + } + private static ResponseStatusException bad(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); } + private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementLifecycleService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementLifecycleService.java new file mode 100644 index 000000000..a9fed57ba --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementLifecycleService.java @@ -0,0 +1,244 @@ +package com.baiyue.lastleg.procurement.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.financefee.service.OverseasFeeCurrencyConverter; +import com.baiyue.lastleg.procurement.dto.ProcurementLifecycleRequests.*; +import com.baiyue.lastleg.procurement.entity.OverseasProcurementFile; +import com.baiyue.lastleg.procurement.entity.OverseasProcurementOrder; +import com.baiyue.lastleg.procurement.mapper.OverseasProcurementFileMapper; +import com.baiyue.lastleg.procurement.mapper.OverseasProcurementOrderMapper; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; +import java.math.BigDecimal; +import java.security.MessageDigest; +import java.sql.Timestamp; +import java.time.LocalDateTime; +import java.util.HexFormat; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** One procurement order, one physical batch. Warehouse and wallet remain authoritative outside this aggregate. */ +@Service +@RequiredArgsConstructor +public class ProcurementLifecycleService { + private final OverseasProcurementOrderMapper orders; + private final OverseasProcurementFileMapper files; + private final ProcurementWarehouseDirectory warehouses; + private final ProcurementFinanceService finance; + private final OverseasFeeCurrencyConverter converter; + private final JdbcTemplate jdbc; + private final ObjectMapper json; + + public record PurchaseStatus(boolean recorded, boolean confirmed, String issue, String feeNo, + String walletStatus, boolean sourceLinked) { } + public record Outcome(String orderNo, long version, String status, PurchaseStatus purchase) { } + + @Transactional + public Outcome recordPurchase(String customerId,String orderNo,String operator,Purchase request) { + var order=lock(customerId,orderNo); + if(replay(order,"PURCHASE",request.idempotencyKey(),request)) return outcome(order); + version(order,request.version()); open(order); + ensureFlow(order); + var flow=flow(order.getId()); + if(flow.get("purchased_at")!=null) throw conflict("本单已登记购买事实,不能再次购买或覆盖原记录"); + if(request.purchasedAt()==null || request.purchasedAt().isAfter(LocalDateTime.now()) + || request.estimatedArrivalAt()==null || request.estimatedArrivalAt().isBefore(request.purchasedAt()) + || request.quantity()<=0 || request.productName()==null || request.productName().isBlank() + || request.amount()==null || request.amount().signum()<0 || request.amount().scale()>2 + || request.currency()==null || !Set.of("CNY","USD").contains(request.currency())) throw bad("实际购买信息不完整"); + requireFile(order,request.voucherFileId(),Set.of("VOUCHER","PURCHASE")); + jdbc.update("UPDATE ll_procurement_flow SET purchased_at=?,purchased_product_name=?,purchased_specification=?,purchased_quantity=?,purchase_amount=?,purchase_currency=?,purchase_voucher_id=? WHERE order_id=?", + request.purchasedAt(),request.productName().trim(),trim(request.specification()),request.quantity(),request.amount(),request.currency(),request.voucherFileId(),order.getId()); + order.setEstimatedArrivalAt(request.estimatedArrivalAt()); + confirmRecordedPurchase(order,operator); + if(order.getReviewedAt()!=null && !"REJECTED".equals(order.getStatus())) order.setStatus("WAIT_OVERSEAS_RECEIPT"); + complete(order,operator,"PURCHASE",request.idempotencyKey(),request.reason(),request); + return outcome(order); + } + + @Transactional + public Outcome confirmPurchase(String customerId,String orderNo,String operator,Command request) { + var order=lock(customerId,orderNo); + if(replay(order,"CONFIRM_PURCHASE",request.idempotencyKey(),request)) return outcome(order); + version(order,request.version());open(order); + if(flow(order.getId()).get("purchased_at")==null) throw conflict("请先登记真实购买事实"); + confirmRecordedPurchase(order,operator); + if(order.getReviewedAt()!=null && Set.of("PENDING_REVIEW","WAIT_PURCHASE").contains(order.getStatus())) order.setStatus("WAIT_OVERSEAS_RECEIPT"); + complete(order,operator,"CONFIRM_PURCHASE",request.idempotencyKey(),request.reason(),request); + return outcome(order); + } + + @Transactional + public Outcome authorizeBudget(String customerId,String orderNo,String operator,Budget request) { + var order=lock(customerId,orderNo); + if(replay(order,"AUTHORIZE_BUDGET",request.idempotencyKey(),request)) return outcome(order); + version(order,request.version());open(order); + if(request.amount()==null || request.amount().signum()<0 || request.amount().scale()>2 + || !Set.of("CNY","USD").contains(request.currency())) throw bad("预算金额或币种无效"); + if(finance.current(order.getId(),ProcurementFeePurpose.SAMPLE_PAYMENT,"SAMPLE_PAYMENT")!=null) { + throw conflict("货款已经核定,不能通过修改预算改变原费用"); + } + requireFile(order,request.voucherFileId(),Set.of("VOUCHER","BUDGET")); + var previous=budget(order); + jdbc.update("INSERT INTO ll_procurement_budget_authorization(id,order_id,customer_id,previous_amount,previous_currency,authorized_amount,currency,voucher_file_id,reason,reviewed_by) VALUES(?,?,?,?,?,?,?,?,?,?)", + IdWorker.getId(),order.getId(),customerId,previous.amount(),previous.currency(),request.amount(),request.currency(),request.voucherFileId(),request.reason(),operator); + complete(order,operator,"AUTHORIZE_BUDGET",request.idempotencyKey(),request.reason(),request); + return outcome(order); + } + + @Transactional + public Outcome selectWarehouse(String customerId,String orderNo,String operator,Warehouse request) { + var order=lock(customerId,orderNo); + if(replay(order,"PLAN_WAREHOUSE",request.idempotencyKey(),request)) return outcome(order); + version(order,request.version());open(order);ensureFlow(order); + if(flow(order.getId()).get("actual_warehouse_id")!=null) throw conflict("首次实收已冻结实际仓库,请使用事实更正操作"); + var warehouse=warehouses.required(request.warehouseId()); + order.setPurchaseWarehouseId(String.valueOf(warehouse.warehouseId()));order.setPurchaseWarehouseName(warehouse.warehouseName()); + complete(order,operator,"PLAN_WAREHOUSE",request.idempotencyKey(),request.reason(),request); + return outcome(order); + } + + @Transactional + public Outcome cancel(String customerId,String orderNo,String operator,Command request) { + var order=lock(customerId,orderNo); + if(replay(order,"CANCEL",request.idempotencyKey(),request)) return outcome(order); + version(order,request.version());open(order);ensureFlow(order); + if(flow(order.getId()).get("purchased_at")!=null) throw conflict("购买后不能取消原单,请处理实物及资金后异常关闭"); + order.setStatus("CANCELLED"); + jdbc.update("UPDATE ll_procurement_flow SET cancelled_at=CURRENT_TIMESTAMP(3),control_reason=? WHERE order_id=?",request.reason(),order.getId()); + complete(order,operator,"CANCEL",request.idempotencyKey(),request.reason(),request); + return outcome(order); + } + + private void confirmRecordedPurchase(OverseasProcurementOrder order,String operator) { + var flow=flow(order.getId()); + if(flow.get("purchase_confirmed_at")!=null) return; + String issue=null; + if(order.getReviewedAt()==null || "REJECTED".equals(order.getStatus())) issue="采购订单尚未审核通过"; + if(!Objects.equals(trim(order.getProductName()),flow.get("purchased_product_name")) + || !Objects.equals(trim(order.getProductSpecification()),flow.get("purchased_specification")) + || ((Number)flow.get("purchased_quantity")).longValue()!=order.getPurchaseQuantity()) { + issue="实际购买商品、规格或数量与审核内容不一致,须异常处置,不能通过提高预算改写"; + } + var budget=budget(order); + if(budget.amount()==null) issue="客户货款预算尚未明确,请取得凭证并完成预算核验"; + var businessDate=((Timestamp)flow.get("purchased_at")).toLocalDateTime().toLocalDate(); + if(issue==null) { + try { + var payment=converter.convert(String.valueOf(flow.get("purchase_currency")),(BigDecimal)flow.get("purchase_amount"),businessDate); + var limit=converter.convert(budget.currency(),budget.amount(),businessDate); + try { ProcurementFinanceService.requireSourceCapacity(payment.originalAmount(),payment.settlementAmount()); } + catch(ResponseStatusException capacity) { issue=capacity.getReason()+",已保留真实购买事实"; } + if(issue==null && payment.settlementAmount().compareTo(limit.settlementAmount())>0) issue="实际货款超过客户已授权预算,请先取得凭证并完成预算核验"; + } catch(ResponseStatusException exception) { + if(exception.getStatusCode().is4xxClientError()) issue="业务月份汇率尚未核定,已保留真实购买事实"; + else throw exception; + } + } + if(issue!=null) { + jdbc.update("UPDATE ll_procurement_flow SET purchase_issue=? WHERE order_id=?",issue,order.getId()); + return; + } + BigDecimal amount=(BigDecimal)flow.get("purchase_amount"); + finance.confirm(order.getCustomerId(),order.getOrderNo(),operator,new ProcurementFinanceService.Confirmation( + ProcurementFeePurpose.SAMPLE_PAYMENT,"SAMPLE_PAYMENT","样品货款",amount.signum()==0?"ZERO":"POSITIVE",amount, + String.valueOf(flow.get("purchase_currency")),businessDate,((Number)flow.get("purchase_voucher_id")).longValue(),"购买完成核定样品货款")); + jdbc.update("UPDATE ll_procurement_flow SET purchase_confirmed_at=CURRENT_TIMESTAMP(3),purchase_issue=NULL WHERE order_id=?",order.getId()); + } + + void requireReplacementBudget(OverseasProcurementOrder order,BigDecimal target,String currency, + java.time.LocalDate businessDate,BigDecimal settlementAmount) { + var facts=flow(order.getId()); + if(facts.get("purchased_at")==null || facts.get("purchase_confirmed_at")==null)throw conflict("替代货款须关联本单已核定的真实购买事实"); + var limit=budget(order); + if(limit.amount()==null)throw conflict("客户预算尚未明确,请先取得凭证并核验预算"); + boolean within=currency.equals(limit.currency())?target.compareTo(limit.amount())<=0 + :settlementAmount.compareTo("CNY".equals(limit.currency())?limit.amount():converter.convert(limit.currency(),limit.amount(),businessDate).settlementAmount())<=0; + if(!within)throw conflict("替代货款超过客户已授权预算,请先取得凭证并完成预算核验"); + } + + private record BudgetLimit(BigDecimal amount,String currency) { } + private BudgetLimit budget(OverseasProcurementOrder order) { + var rows=jdbc.query("SELECT authorized_amount,currency FROM ll_procurement_budget_authorization WHERE order_id=? ORDER BY id DESC LIMIT 1", + (rs,n)->new BudgetLimit(rs.getBigDecimal(1),rs.getString(2)),order.getId()); + if(!rows.isEmpty()) return rows.get(0); + if(order.getBudgetUnitPrice()==null) return new BudgetLimit(null,order.getCurrency()); + return new BudgetLimit(order.getBudgetUnitPrice().multiply(BigDecimal.valueOf(order.getPurchaseQuantity())),order.getCurrency()); + } + public PurchaseStatus purchaseStatus(OverseasProcurementOrder order) { + var flow=flow(order.getId()); + var fee=finance.current(order.getId(),ProcurementFeePurpose.SAMPLE_PAYMENT,"SAMPLE_PAYMENT"); + return new PurchaseStatus(flow.get("purchased_at")!=null,flow.get("purchase_confirmed_at")!=null, + (String)flow.get("purchase_issue"),fee==null?null:fee.getFeeNo(),fee==null?null:fee.getWalletStatus(),fee!=null && finance.sourceLinked(fee)); + } + public Outcome read(String customerId,String orderNo) { + return outcome(requireOrder(customerId,orderNo)); + } + OverseasProcurementOrder requireOrder(String customerId,String orderNo) { + var order=orders.findNo(customerId,orderNo); + if(order==null) throw bad("采购订单不存在或不属于当前客户"); + return order; + } + Outcome outcome(OverseasProcurementOrder order) { + return new Outcome(order.getOrderNo(),order.getVersion(),order.getStatus(),purchaseStatus(order)); + } + Map flow(long orderId) { + var rows=jdbc.queryForList("SELECT * FROM ll_procurement_flow WHERE order_id=?",orderId); + return rows.isEmpty()?Map.of():rows.get(0); + } + void ensureFlow(OverseasProcurementOrder order) { + if(flow(order.getId()).isEmpty()) jdbc.update("INSERT INTO ll_procurement_flow(order_id,customer_id) VALUES(?,?)",order.getId(),order.getCustomerId()); + } + void requireNoBlockers(OverseasProcurementOrder order) { + Integer count=jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_control_note WHERE order_id=? AND blocking=1 AND resolved_at IS NULL",Integer.class,order.getId()); + if(count!=null && count>0)throw conflict("存在未处理的采购阻断异常,请先完成有凭证的异常处理"); + } + void requireFile(OverseasProcurementOrder order,long id,Set categories) { + var file=files.selectOne(new LambdaQueryWrapper().eq(OverseasProcurementFile::getOrderId,order.getId()) + .eq(OverseasProcurementFile::getCustomerId,order.getCustomerId()).eq(OverseasProcurementFile::getFileId,id)); + if(file==null || !categories.contains(file.getCategory())) throw bad("凭证不属于本采购订单或文件用途不正确"); + } + OverseasProcurementOrder lock(String customerId,String orderNo) { + var order=orders.lockNo(customerId,orderNo); + if(order==null) throw bad("采购订单不存在或不属于当前客户"); + return order; + } + boolean replay(OverseasProcurementOrder order,String action,String key,Object request) { + if(key==null || key.isBlank() || key.length()>128) throw bad("操作幂等键无效"); + var previous=jdbc.queryForList("SELECT request_hash FROM ll_procurement_command WHERE order_id=? AND action=? AND idempotency_key=?",String.class,order.getId(),action,key); + if(previous.isEmpty()) return false; + if(!previous.get(0).equals(hash(request))) throw conflict("相同操作幂等键不能用于不同内容"); + return true; + } + void complete(OverseasProcurementOrder order,String operator,String action,String key,String reason,Object request) { + if(reason==null || reason.isBlank() || reason.length()>1000) throw bad("请填写操作依据"); + jdbc.update("INSERT INTO ll_procurement_command(id,order_id,customer_id,action,idempotency_key,request_hash,operator_id,reason,detail_json) VALUES(?,?,?,?,?,?,?,?,CAST(? AS JSON))", + IdWorker.getId(),order.getId(),order.getCustomerId(),action,key,hash(request),operator,reason,serialize(request)); + order.setVersion(order.getVersion()+1);order.setUpdatedAt(LocalDateTime.now());orders.updateById(order); + } + static void version(OverseasProcurementOrder order,long version) { + if(order.getVersion()!=version) throw conflict("订单已被修改,请刷新后重试"); + } + static void open(OverseasProcurementOrder order) { + if(Set.of("REJECTED","CANCELLED","EXCEPTION_CLOSED","SHIPPED_TO_CUSTOMER").contains(order.getStatus())) throw conflict("订单已结束,不能执行该履约操作"); + } + String requestHash(Object value) {return hash(value);} + private String hash(Object value) { + try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(serialize(value).getBytes(java.nio.charset.StandardCharsets.UTF_8))); } + catch(Exception exception) {throw new IllegalStateException(exception);} + } + private String serialize(Object value) { + try {return json.writeValueAsString(value);}catch(Exception exception){throw new IllegalStateException(exception);} + } + private static String trim(String value){return value==null || value.isBlank()?null:value.trim();} + static ResponseStatusException bad(String message){return new ResponseStatusException(HttpStatus.BAD_REQUEST,message);} + static ResponseStatusException conflict(String message){return new ResponseStatusException(HttpStatus.CONFLICT,message);} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementPhysicalService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementPhysicalService.java new file mode 100644 index 000000000..629272bb2 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementPhysicalService.java @@ -0,0 +1,332 @@ +package com.baiyue.lastleg.procurement.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.procurement.dto.ProcurementLifecycleRequests.Command; +import com.baiyue.lastleg.procurement.dto.ProcurementPhysicalRequests.*; +import com.baiyue.lastleg.procurement.entity.OverseasProcurementOrder; +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.math.BigDecimal; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.time.LocalDateTime; +import java.util.*; +import static com.baiyue.lastleg.procurement.service.ProcurementLifecycleService.*; + +/** Physical procurement ledger only. Never writes WMS balances, pallets or stock movements. */ +@Service +@RequiredArgsConstructor +public class ProcurementPhysicalService { + private final ProcurementLifecycleService lifecycle; + private final ProcurementWarehouseDirectory warehouses; + private final ProcurementFinanceService finance; + private final JdbcTemplate jdbc; + + public record ReceiptRow(long id,String receiptNo,String leg,Long sourceCartonId,long goodQuantity,long damagedQuantity, + long extraQuantity,LocalDateTime receivedAt,boolean active) { } + public record CartonRow(long id,String cartonNo,BigDecimal lengthCm,BigDecimal widthCm,BigDecimal heightCm, + BigDecimal grossWeightKg,long quantity,List sources) { } + public record Snapshot(String orderNo,long version,String status,List receipts, + List> dispositions,Integer packingVersion,String packingStatus,List cartons) { } + + @Transactional + public Snapshot receive(String customer,String orderNo,String operator,Receipt request) { + var order=lifecycle.lock(customer,orderNo); + if(lifecycle.replay(order,"RECEIVE",request.idempotencyKey(),request)) return snapshot(order); + version(order,request.version());open(order); + insertReceipt(order,operator,request,null); + lifecycle.complete(order,operator,"RECEIVE",request.idempotencyKey(),request.reason(),request); + return snapshot(order); + } + + private record CorrectionIdentity(long receiptId,Correction correction) { } + @Transactional + public Snapshot correctReceipt(String customer,String orderNo,String operator,long receiptId,Correction correction) { + var order=lifecycle.lock(customer,orderNo);var request=correction.receipt();var identity=new CorrectionIdentity(receiptId,correction); + if(lifecycle.replay(order,"CORRECT_RECEIPT",request.idempotencyKey(),identity))return snapshot(order); + version(order,request.version());open(order);requireCorrectable(order,request.leg()); + var rows=jdbc.query("SELECT * FROM ll_procurement_receipt WHERE id=? AND order_id=? AND customer_id=? AND active=1",this::receiptRow,receiptId,order.getId(),customer); + if(rows.size()!=1)throw bad("有效实收记录不存在或不属于当前订单"); + var old=rows.get(0); + if(!request.leg().name().equals(old.leg()) || !Objects.equals(request.sourceCartonId(),old.sourceCartonId()))throw bad("更正不得改变实收的运输段和货物来源"); + lifecycle.requireFile(order,correction.voucherFileId(),Set.of("VOUCHER","EXCEPTION")); + jdbc.update("UPDATE ll_procurement_receipt SET active=0 WHERE id=?",receiptId); + jdbc.update("UPDATE ll_procurement_disposition SET active=0 WHERE order_id=? AND receipt_id=?",order.getId(),receiptId); + invalidateShortage(order.getId(),request.leg(),request.sourceCartonId()); + reopenReceipt(order,request.leg()); + insertReceipt(order,operator,request,receiptId); + lifecycle.complete(order,operator,"CORRECT_RECEIPT",request.idempotencyKey(),request.reason(),identity); + return snapshot(order); + } + + private record RevokeIdentity(long dispositionId,RevokeDisposition request) { } + @Transactional + public Snapshot revokeDisposition(String customer,String orderNo,String operator,long dispositionId,RevokeDisposition request) { + var order=lifecycle.lock(customer,orderNo);var identity=new RevokeIdentity(dispositionId,request); + if(lifecycle.replay(order,"REVOKE_DISPOSITION",request.idempotencyKey(),identity))return snapshot(order); + version(order,request.version());open(order); + var rows=jdbc.queryForList("SELECT leg FROM ll_procurement_disposition WHERE id=? AND order_id=? AND active=1",dispositionId,order.getId()); + if(rows.size()!=1)throw bad("有效差异处置不存在"); + Leg leg=Leg.valueOf(String.valueOf(rows.get(0).get("leg")));requireCorrectable(order,leg); + lifecycle.requireFile(order,request.voucherFileId(),Set.of("VOUCHER","EXCEPTION")); + jdbc.update("UPDATE ll_procurement_disposition SET active=0 WHERE id=?",dispositionId);reopenReceipt(order,leg); + lifecycle.complete(order,operator,"REVOKE_DISPOSITION",request.idempotencyKey(),request.reason(),identity); + return snapshot(order); + } + + private void requireCorrectable(OverseasProcurementOrder order,Leg leg) { + var flow=lifecycle.flow(order.getId()); + if(flow.get(leg==Leg.OVERSEAS?"overseas_shipped_at":"customer_shipped_at")!=null)throw conflict("本段已实际交寄,只能追加更正说明,不能改写数量"); + if(leg==Leg.OVERSEAS && flow.get("packing_version")!=null)throw conflict("须先撤回尚未交寄的确认清单,再更正海外实收"); + } + private void reopenReceipt(OverseasProcurementOrder order,Leg leg) { + if(leg==Leg.OVERSEAS) { + jdbc.update("UPDATE ll_procurement_flow SET overseas_received_at=NULL,packing_version=NULL WHERE order_id=?",order.getId()); + jdbc.update("UPDATE ll_procurement_manifest SET status='SUPERSEDED' WHERE order_id=? AND status<>'SUPERSEDED'",order.getId()); + order.setStatus("OVERSEAS_RECEIVING"); + } else { + jdbc.update("UPDATE ll_procurement_flow SET domestic_received_at=NULL,charges_confirmed_at=NULL WHERE order_id=?",order.getId()); + order.setStatus("DOMESTIC_RECEIVING"); + } + } + private void invalidateShortage(long orderId,Leg leg,Long source) { + if(source==null)jdbc.update("UPDATE ll_procurement_disposition SET active=0 WHERE order_id=? AND leg=? AND kind='SHORTAGE' AND source_carton_id IS NULL",orderId,leg.name()); + else jdbc.update("UPDATE ll_procurement_disposition SET active=0 WHERE order_id=? AND leg=? AND kind='SHORTAGE' AND source_carton_id=?",orderId,leg.name(),source); + } + + private void insertReceipt(OverseasProcurementOrder order,String operator,Receipt request,Long supersedes) { + if(request.leg()==null || request.receivedAt()==null || request.receivedAt().isAfter(LocalDateTime.now()) + || request.goodQuantity()<0 || request.damagedQuantity()<0 || request.extraQuantity()<0 + || add(add(request.goodQuantity(),request.damagedQuantity()),request.extraQuantity())<=0) throw bad("实收数量或时间无效"); + var flow=lifecycle.flow(order.getId()); + String completed=request.leg()==Leg.OVERSEAS?"overseas_received_at":"domestic_received_at"; + if(flow.get(completed)!=null) throw conflict("本段收货已完成,请使用受控更正操作"); + long expected=expected(order,request.leg(),request.sourceCartonId()); + LocalDateTime originAt; + if(request.leg()==Leg.OVERSEAS) { + if(request.warehouseId()==null || request.sourceCartonId()!=null) throw bad("海外实收须选择实际收货仓,不能指定国内来源箱"); + var warehouse=warehouses.required(request.warehouseId()); + if(flow.get("actual_warehouse_id")!=null && ((Number)flow.get("actual_warehouse_id")).longValue()!=request.warehouseId()) throw conflict("本单实际收货仓已冻结,不支持转仓"); + jdbc.update("UPDATE ll_procurement_flow SET actual_warehouse_id=?,actual_warehouse_code=?,actual_warehouse_name=? WHERE order_id=?", + warehouse.warehouseId(),warehouse.warehouseCode(),warehouse.warehouseName(),order.getId()); + originAt=((Timestamp)flow.get("purchased_at")).toLocalDateTime(); + } else { + if(request.warehouseId()!=null) throw bad("国内实收不应指定海外仓"); + originAt=((Timestamp)flow.get("overseas_shipped_at")).toLocalDateTime(); + } + if(request.receivedAt().isBefore(originAt)) throw bad("实收时间不能早于来源购买或国际交寄时间"); + long counted=received(order.getId(),request.leg(),request.sourceCartonId()).stream() + .mapToLong(r->add(r.goodQuantity(),r.damagedQuantity())).reduce(0,ProcurementPhysicalService::add); + if(add(counted,add(request.goodQuantity(),request.damagedQuantity()))>expected) throw bad("正常及破损实收超过来源数量,多到货物必须单独记录为多货"); + jdbc.update("INSERT INTO ll_procurement_receipt(id,order_id,customer_id,receipt_no,leg,source_carton_id,warehouse_id,good_quantity,damaged_quantity,extra_quantity,received_at,active,supersedes_id,created_by) VALUES(?,?,?,?,?,?,?,?,?,?,?,1,?,?)", + IdWorker.getId(),order.getId(),order.getCustomerId(),number("PR"),request.leg().name(),request.sourceCartonId(),request.warehouseId(), + request.goodQuantity(),request.damagedQuantity(),request.extraQuantity(),request.receivedAt(),supersedes,operator); + order.setStatus(request.leg()==Leg.OVERSEAS?"OVERSEAS_RECEIVING":"DOMESTIC_RECEIVING"); + } + + @Transactional + public Snapshot dispose(String customer,String orderNo,String operator,Disposition request) { + var order=lifecycle.lock(customer,orderNo); + if(lifecycle.replay(order,"DISPOSE",request.idempotencyKey(),request)) return snapshot(order); + version(order,request.version());open(order); + var flow=lifecycle.flow(order.getId()); + if(flow.get(request.leg()==Leg.OVERSEAS?"overseas_received_at":"domestic_received_at")!=null) throw conflict("已完成的收货需先受控更正,不能直接改差异结果"); + long target=expected(order,request.leg(),request.sourceCartonId()); + long available; + if("SHORTAGE".equals(request.kind())) { + if(request.receiptId()!=null || !"ACCEPT_SHORTAGE".equals(request.decision())) throw bad("短少须按来源确认,不关联单条实收"); + long counted=received(order.getId(),request.leg(),request.sourceCartonId()).stream() + .mapToLong(r->add(r.goodQuantity(),r.damagedQuantity())).reduce(0,ProcurementPhysicalService::add); + available=target-counted-resolved(order.getId(),request.leg(),request.sourceCartonId(),null,"SHORTAGE"); + } else { + if(request.receiptId()==null) throw bad("破损或多货须关联实际收货记录"); + var receipt=received(order.getId(),request.leg(),request.sourceCartonId()).stream().filter(r->r.id()==request.receiptId()).findFirst() + .orElseThrow(()->bad("实收记录不属于当前来源")); + if("DAMAGED".equals(request.kind()) && "DISPOSE_DAMAGED".equals(request.decision())) available=receipt.damagedQuantity(); + else if("EXTRA".equals(request.kind()) && "RETURN_EXTRA".equals(request.decision())) available=receipt.extraQuantity(); + else throw bad("差异类型和处置动作不匹配"); + available-=resolved(order.getId(),request.leg(),request.sourceCartonId(),request.receiptId(),request.kind()); + } + if(request.quantity()<=0 || request.quantity()>available) throw bad("处置数量超过未处理差异"); + lifecycle.requireFile(order,request.voucherFileId(),Set.of("VOUCHER","EXCEPTION")); + jdbc.update("INSERT INTO ll_procurement_disposition(id,order_id,customer_id,leg,source_carton_id,receipt_id,kind,quantity,decision,voucher_file_id,reason,created_by) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)", + IdWorker.getId(),order.getId(),customer,request.leg().name(),request.sourceCartonId(),request.receiptId(),request.kind(),request.quantity(),request.decision(),request.voucherFileId(),request.reason(),operator); + lifecycle.complete(order,operator,"DISPOSE",request.idempotencyKey(),request.reason(),request); + return snapshot(order); + } + + @Transactional + public Snapshot finishReceipt(String customer,String orderNo,String operator,FinishReceipt request) { + var order=lifecycle.lock(customer,orderNo); + if(lifecycle.replay(order,"FINISH_RECEIPT",request.idempotencyKey(),request)) return snapshot(order); + version(order,request.version());open(order); + var flow=lifecycle.flow(order.getId()); + String field=request.leg()==Leg.OVERSEAS?"overseas_received_at":"domestic_received_at"; + if(flow.get(field)!=null) throw conflict("本段收货已完成"); + if(request.leg()==Leg.OVERSEAS) validateReceiptSource(order,Leg.OVERSEAS,null); + else { + var sourceIds=jdbc.queryForList("SELECT i.carton_id FROM ll_procurement_shipment s JOIN ll_procurement_shipment_item i ON i.shipment_id=s.id WHERE s.order_id=? AND s.leg='OVERSEAS'",Long.class,order.getId()); + if(sourceIds.isEmpty()) throw conflict("尚无国际交寄事实"); + for(long sourceId:sourceIds) validateReceiptSource(order,Leg.DOMESTIC,sourceId); + } + jdbc.update("UPDATE ll_procurement_flow SET "+field+"=CURRENT_TIMESTAMP(3) WHERE order_id=?",order.getId()); + long good=goodTotal(order.getId(),request.leg()); + if(request.leg()==Leg.OVERSEAS) { + order.setInboundQuantity(good); + order.setStatus(good>0?"WAIT_PACKING":"OVERSEAS_RECEIVING"); + } else order.setStatus(good>0?"WAIT_CHARGES":"DOMESTIC_RECEIVING"); + lifecycle.complete(order,operator,"FINISH_RECEIPT",request.idempotencyKey(),request.reason(),request); + return snapshot(order); + } + + private void validateReceiptSource(OverseasProcurementOrder order,Leg leg,Long source) { + long target=expected(order,leg,source),counted=0; + for(var receipt:received(order.getId(),leg,source)) { + counted=add(counted,add(receipt.goodQuantity(),receipt.damagedQuantity())); + if(resolved(order.getId(),leg,source,receipt.id(),"DAMAGED")!=receipt.damagedQuantity() + || resolved(order.getId(),leg,source,receipt.id(),"EXTRA")!=receipt.extraQuantity()) throw conflict("存在未处理的破损或多到货物"); + } + if(add(counted,resolved(order.getId(),leg,source,null,"SHORTAGE"))!=target) throw conflict("货物未收齐或短少处置与来源数量不一致"); + } + + @Transactional + public Snapshot savePacking(String customer,String orderNo,String operator,Packing request) { + var order=lifecycle.lock(customer,orderNo); + if(lifecycle.replay(order,"SAVE_PACKING",request.idempotencyKey(),request)) return snapshot(order); + version(order,request.version());open(order); + if(lifecycle.flow(order.getId()).get("overseas_shipped_at")!=null) throw conflict("国际段已实际交寄,不能改写原装箱事实"); + if(request.cartons()==null || request.cartons().isEmpty()) throw bad("装箱清单不能为空"); + Map available=new HashMap<>(),used=new HashMap<>(); + for(var receipt:received(order.getId(),Leg.OVERSEAS,null)) available.put(receipt.id(),receipt.goodQuantity()); + Set names=new HashSet<>(); + for(var carton:request.cartons()) { + if(carton.cartonNo()==null || carton.cartonNo().isBlank() || carton.cartonNo().length()>64 || !names.add(carton.cartonNo().trim())) throw bad("箱号为空、重复或过长"); + measure(carton.lengthCm(),2);measure(carton.widthCm(),2);measure(carton.heightCm(),2);measure(carton.grossWeightKg(),3); + if(carton.sources()==null || carton.sources().isEmpty()) throw bad("每箱必须有明确实收货物来源"); + Set unique=new HashSet<>(); + for(var source:carton.sources()) { + if(source.quantity()<=0 || !unique.add(source.receiptId()) || !available.containsKey(source.receiptId())) throw bad("箱内来源无效、重复或不属于本单有效实收"); + used.merge(source.receiptId(),source.quantity(),ProcurementPhysicalService::add); + if(used.get(source.receiptId())>available.get(source.receiptId())) throw bad("装箱数量超过来源合格实收,不能重复分配"); + } + } + Integer previous=jdbc.queryForObject("SELECT COALESCE(MAX(version),0) FROM ll_procurement_manifest WHERE order_id=?",Integer.class,order.getId()); + int packingVersion=previous==null?1:previous+1;long manifestId=IdWorker.getId(); + jdbc.update("UPDATE ll_procurement_manifest SET status='SUPERSEDED' WHERE order_id=? AND status<>'SUPERSEDED'",order.getId()); + jdbc.update("INSERT INTO ll_procurement_manifest(id,order_id,customer_id,version,status,created_by) VALUES(?,?,?,?,'DRAFT',?)",manifestId,order.getId(),customer,packingVersion,operator); + for(var carton:request.cartons()) { + long cartonId=IdWorker.getId(),quantity=carton.sources().stream().mapToLong(Source::quantity).reduce(0,ProcurementPhysicalService::add); + jdbc.update("INSERT INTO ll_procurement_carton(id,order_id,manifest_id,carton_no,length_cm,width_cm,height_cm,gross_weight_kg,quantity) VALUES(?,?,?,?,?,?,?,?,?)", + cartonId,order.getId(),manifestId,carton.cartonNo().trim(),carton.lengthCm(),carton.widthCm(),carton.heightCm(),carton.grossWeightKg(),quantity); + for(var source:carton.sources()) jdbc.update("INSERT INTO ll_procurement_carton_source(carton_id,receipt_id,quantity) VALUES(?,?,?)",cartonId,source.receiptId(),source.quantity()); + } + jdbc.update("UPDATE ll_procurement_flow SET packing_version=NULL WHERE order_id=?",order.getId()); + if(lifecycle.flow(order.getId()).get("overseas_received_at")!=null) order.setStatus("WAIT_PACKING"); + lifecycle.complete(order,operator,"SAVE_PACKING",request.idempotencyKey(),request.reason(),request); + return snapshot(order); + } + + @Transactional + public Snapshot confirmPacking(String customer,String orderNo,String operator,ConfirmPacking request) { + var order=lifecycle.lock(customer,orderNo); + if(lifecycle.replay(order,"CONFIRM_PACKING",request.idempotencyKey(),request)) return snapshot(order); + version(order,request.version());open(order);lifecycle.requireNoBlockers(order); + var flow=lifecycle.flow(order.getId()); + if(flow.get("overseas_received_at")==null || flow.get("overseas_shipped_at")!=null) throw conflict("须海外收货完成且尚未国际交寄"); + var payment=finance.current(order.getId(),ProcurementFeePurpose.SAMPLE_PAYMENT,"SAMPLE_PAYMENT"); + if(payment==null || !finance.sourceLinked(payment)) throw conflict("样品货款扣费及来源关联未完成,不能确认装箱"); + validateReceiptSource(order,Leg.OVERSEAS,null); + var manifest=currentManifest(order.getId()); + if(manifest==null || ((Number)manifest.get("version")).intValue()!=request.packingVersion() || !"DRAFT".equals(manifest.get("status"))) throw conflict("装箱草稿版本已变化"); + long manifestId=((Number)manifest.get("id")).longValue(); + Map assigned=new HashMap<>(); + jdbc.query("SELECT s.receipt_id,SUM(s.quantity) AS quantity FROM ll_procurement_carton c JOIN ll_procurement_carton_source s ON s.carton_id=c.id WHERE c.manifest_id=? GROUP BY s.receipt_id", + (org.springframework.jdbc.core.RowCallbackHandler)rs->assigned.put(rs.getLong("receipt_id"),rs.getLong("quantity")),manifestId); + long total=0; + for(var receipt:received(order.getId(),Leg.OVERSEAS,null)) { + if(assigned.getOrDefault(receipt.id(),0L)!=receipt.goodQuantity()) throw conflict("确认装箱须覆盖本批全部合格实收,且每份来源数量一致"); + assigned.remove(receipt.id());total=add(total,receipt.goodQuantity()); + } + if(!assigned.isEmpty() || total<=0) throw conflict("清单包含失效来源或没有可发货物"); + jdbc.update("UPDATE ll_procurement_manifest SET status='CONFIRMED',confirmed_at=CURRENT_TIMESTAMP(3) WHERE id=?",manifestId); + jdbc.update("UPDATE ll_procurement_flow SET packing_version=? WHERE order_id=?",request.packingVersion(),order.getId()); + order.setStatus("WAIT_LABELS"); + lifecycle.complete(order,operator,"CONFIRM_PACKING",request.idempotencyKey(),request.reason(),request); + return snapshot(order); + } + + @Transactional + public Snapshot withdrawPacking(String customer,String orderNo,String operator,Command request) { + var order=lifecycle.lock(customer,orderNo); + if(lifecycle.replay(order,"WITHDRAW_PACKING",request.idempotencyKey(),request)) return snapshot(order); + version(order,request.version());open(order); + if(lifecycle.flow(order.getId()).get("overseas_shipped_at")!=null) throw conflict("已交寄装箱事实不能撤回"); + jdbc.update("UPDATE ll_procurement_manifest SET status='SUPERSEDED' WHERE order_id=? AND status<>'SUPERSEDED'",order.getId()); + jdbc.update("UPDATE ll_procurement_flow SET packing_version=NULL WHERE order_id=?",order.getId()); + order.setStatus(lifecycle.flow(order.getId()).get("overseas_received_at")==null?"OVERSEAS_RECEIVING":"WAIT_PACKING"); + lifecycle.complete(order,operator,"WITHDRAW_PACKING",request.idempotencyKey(),request.reason(),request); + return snapshot(order); + } + + public Snapshot read(String customer,String orderNo) { + var outcome=lifecycle.read(customer,orderNo); + // Read the same authorized aggregate without obtaining a write lock. + var rows=jdbc.queryForList("SELECT id FROM ll_overseas_procurement_order WHERE customer_id=? AND order_no=?",Long.class,customer,orderNo); + if(rows.size()!=1) throw bad("采购订单不存在"); + return snapshot(rows.get(0),outcome.orderNo(),outcome.version(),outcome.status()); + } + private Snapshot snapshot(OverseasProcurementOrder order){return snapshot(order.getId(),order.getOrderNo(),order.getVersion(),order.getStatus());} + private Snapshot snapshot(long orderId,String orderNo,long version,String status) { + var receipts=jdbc.query("SELECT * FROM ll_procurement_receipt WHERE order_id=? ORDER BY created_at,id",this::receiptRow,orderId); + var dispositions=jdbc.queryForList("SELECT id,leg,source_carton_id,receipt_id,kind,quantity,decision,reason,active,created_at FROM ll_procurement_disposition WHERE order_id=? ORDER BY id",orderId); + var manifests=jdbc.queryForList("SELECT * FROM ll_procurement_manifest WHERE order_id=? ORDER BY version DESC LIMIT 1",orderId); + if(manifests.isEmpty()) return new Snapshot(orderNo,version,status,receipts,dispositions,null,null,List.of()); + var manifest=manifests.get(0);long manifestId=((Number)manifest.get("id")).longValue(); + Map> sources=new HashMap<>(); + jdbc.query("SELECT s.carton_id,s.receipt_id,s.quantity FROM ll_procurement_carton_source s JOIN ll_procurement_carton c ON c.id=s.carton_id WHERE c.manifest_id=? ORDER BY s.receipt_id", + (org.springframework.jdbc.core.RowCallbackHandler)rs->sources.computeIfAbsent(rs.getLong(1),x->new ArrayList<>()).add(new Source(rs.getLong(2),rs.getLong(3))),manifestId); + var cartons=jdbc.query("SELECT * FROM ll_procurement_carton WHERE manifest_id=? ORDER BY carton_no",(rs,n)->new CartonRow(rs.getLong("id"),rs.getString("carton_no"), + rs.getBigDecimal("length_cm"),rs.getBigDecimal("width_cm"),rs.getBigDecimal("height_cm"),rs.getBigDecimal("gross_weight_kg"),rs.getLong("quantity"),sources.getOrDefault(rs.getLong("id"),List.of())),manifestId); + return new Snapshot(orderNo,version,status,receipts,dispositions,((Number)manifest.get("version")).intValue(),String.valueOf(manifest.get("status")),cartons); + } + Map currentManifest(long orderId) { + var rows=jdbc.queryForList("SELECT * FROM ll_procurement_manifest WHERE order_id=? AND status<>'SUPERSEDED' ORDER BY version DESC LIMIT 1",orderId); + return rows.isEmpty()?null:rows.get(0); + } + long goodTotal(long orderId,Leg leg){return received(orderId,leg,null,true).stream().mapToLong(ReceiptRow::goodQuantity).reduce(0,ProcurementPhysicalService::add);} + List received(long orderId,Leg leg,Long source){return received(orderId,leg,source,false);} + private List received(long orderId,Leg leg,Long source,boolean allSources) { + String sql="SELECT * FROM ll_procurement_receipt WHERE order_id=? AND leg=? AND active=1"; + if(allSources) return jdbc.query(sql,this::receiptRow,orderId,leg.name()); + return source==null?jdbc.query(sql+" AND source_carton_id IS NULL",this::receiptRow,orderId,leg.name()) + :jdbc.query(sql+" AND source_carton_id=?",this::receiptRow,orderId,leg.name(),source); + } + private ReceiptRow receiptRow(ResultSet rs,int index)throws SQLException { + Number source=(Number)rs.getObject("source_carton_id"); + return new ReceiptRow(rs.getLong("id"),rs.getString("receipt_no"),rs.getString("leg"),source==null?null:source.longValue(), + rs.getLong("good_quantity"),rs.getLong("damaged_quantity"),rs.getLong("extra_quantity"),rs.getTimestamp("received_at").toLocalDateTime(),rs.getBoolean("active")); + } + private long resolved(long orderId,Leg leg,Long source,Long receipt,String kind) { + String sql="SELECT COALESCE(SUM(quantity),0) FROM ll_procurement_disposition WHERE order_id=? AND leg=? AND kind=? AND active=1 AND "; + List args=new ArrayList<>(List.of(orderId,leg.name(),kind)); + sql+=source==null?"source_carton_id IS NULL":"source_carton_id=?";if(source!=null)args.add(source); + sql+=receipt==null?" AND receipt_id IS NULL":" AND receipt_id=?";if(receipt!=null)args.add(receipt); + Long value=jdbc.queryForObject(sql,Long.class,args.toArray());return value==null?0:value; + } + private long expected(OverseasProcurementOrder order,Leg leg,Long source) { + var flow=lifecycle.flow(order.getId()); + if(leg==Leg.OVERSEAS) { + if(source!=null || flow.get("purchased_at")==null) throw conflict("海外实收必须关联本单真实购买"); + return ((Number)flow.get("purchased_quantity")).longValue(); + } + if(source==null || flow.get("overseas_shipped_at")==null) throw conflict("国内实收必须关联本单原国际交寄箱"); + var quantities=jdbc.queryForList("SELECT i.quantity FROM ll_procurement_shipment s JOIN ll_procurement_shipment_item i ON i.shipment_id=s.id WHERE s.order_id=? AND s.customer_id=? AND s.leg='OVERSEAS' AND i.carton_id=?",Long.class,order.getId(),order.getCustomerId(),source); + if(quantities.size()!=1) throw bad("来源箱不属于本单国际交寄");return quantities.get(0); + } + private static void measure(BigDecimal value,int scale){if(value==null || value.signum()<=0 || value.scale()>scale)throw bad("箱规须为正数且最多两位小数,毛重须为正数且最多三位小数");} + private static long add(long a,long b){try{return Math.addExact(a,b);}catch(ArithmeticException e){throw bad("数量超过可处理范围");}} + private static String number(String prefix){return prefix+'-'+UUID.randomUUID().toString().replace("-","").substring(0,24).toUpperCase();} +} 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 new file mode 100644 index 000000000..cc722df4b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementQueryService.java @@ -0,0 +1,132 @@ +package com.baiyue.lastleg.procurement.service; + +import com.baiyue.lastleg.procurement.dto.OverseasProcurementResponses; +import com.baiyue.lastleg.procurement.dto.ProcurementPhysicalRequests.Leg; +import com.baiyue.lastleg.procurement.entity.OverseasProcurementOrder; +import com.baiyue.lastleg.procurement.entity.OverseasProcurementFee; +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.*; + +/** Browser detail aggregation; the customer result is a separate allowlist, never a filtered platform map. */ +@Service @RequiredArgsConstructor +public class ProcurementQueryService { + private final ProcurementLifecycleService lifecycle; + private final ProcurementPhysicalService physical; + private final ProcurementTransportService transport; + private final ProcurementFinanceService finance; + private final ProcurementAdjustmentService adjustments; + private final ProcurementControlService controls; + private final OverseasProcurementService orders; + private final JdbcTemplate jdbc; + + public record Header(Long id,String orderNo,String customerId,String customerName,String purchaseCategory,String productName, + 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, + LocalDateTime createdAt,LocalDateTime updatedAt) { } + public record Budget(BigDecimal totalAmount,String currency,LocalDateTime authorizedAt) { } + public record Progress(String key,String title,LocalDateTime completedAt) { } + public record Box(String cartonNo,long quantity,BigDecimal lengthCm,BigDecimal widthCm,BigDecimal heightCm,BigDecimal grossWeightKg) { } + public record Shipment(String shipmentNo,String leg,LocalDateTime shippedAt,String cartonNo,long quantity,String carrier,String trackingNo) { } + public record CustomerFee(String feeNo,String purpose,String name,Integer version,boolean active,String determination, + BigDecimal amount,String currency,BigDecimal originalAmount,String originalCurrency, + BigDecimal exchangeRate,String exchangeRateMonth,LocalDate businessDate,String parentFeeNo, + String walletStatus,boolean sourceLinked) { + public static CustomerFee from(OverseasProcurementFee fee,String parentNo,boolean linked) { + return new CustomerFee(fee.getFeeNo(),fee.getPurpose(),fee.getFeeItemName(),fee.getFeeVersion(),Boolean.TRUE.equals(fee.getActive()), + fee.getDetermination(),fee.getAmount(),fee.getCurrency(),fee.getOriginalAmount(),fee.getOriginalCurrency(),fee.getExchangeRate(),fee.getExchangeRateMonth(),fee.getBusinessDate(),parentNo,fee.getWalletStatus(),linked); + } + } + public record CustomerView(Header order,Budget budget,boolean purchased,boolean purchaseConfirmed,boolean purchaseNeedsAttention, + List progress,List boxes,List shipments,List fees,List exceptionSummaries) { } + public record Action(String key,String label,String permission,boolean allowed,String reason) { } + public record PlatformView(CustomerView customerView,Map purchaseFacts,ProcurementPhysicalService.Snapshot physical, + ProcurementTransportService.Snapshot transport,List fees, + List feeCorrections,ProcurementControlService.Snapshot controls, + List files,List reviewHistory, + List> operations,List actions,String internalRemark) { } + + @Transactional(readOnly=true) + public CustomerView customer(String customer,String orderNo) { + var order=lifecycle.requireOrder(customer,orderNo);return customer(order); + } + private CustomerView customer(OverseasProcurementOrder order) { + var flow=lifecycle.flow(order.getId());var fees=finance.all(order.getId());Map feeNos=new HashMap<>();fees.forEach(f->feeNos.put(f.getId(),f.getFeeNo())); + 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(), + (String)flow.get("actual_warehouse_code"),(String)flow.get("actual_warehouse_name"),order.getStatus(),order.getVersion(),order.getCreatedAt(),order.getUpdatedAt()); + var budgets=jdbc.query("SELECT authorized_amount,currency,reviewed_at FROM ll_procurement_budget_authorization WHERE order_id=? ORDER BY id DESC LIMIT 1", + (rs,n)->new Budget(rs.getBigDecimal(1),rs.getString(2),rs.getTimestamp(3).toLocalDateTime()),order.getId()); + var budget=budgets.isEmpty()?new Budget(order.getBudgetUnitPrice()==null?null:order.getBudgetUnitPrice().multiply(BigDecimal.valueOf(order.getPurchaseQuantity())),order.getCurrency(),null):budgets.get(0); + List progress=new ArrayList<>();progress.add(new Progress("CREATED","客户下单",order.getCreatedAt()));progress.add(new Progress("REVIEWED","采购审核",order.getReviewedAt())); + for(var step:List.of(new String[]{"purchased_at","购买完成"},new String[]{"overseas_received_at","海外收货完成"},new String[]{"overseas_shipped_at","海外实际交寄"}, + new String[]{"domestic_received_at","国内收货完成"},new String[]{"charges_confirmed_at","国内费用核定"},new String[]{"customer_shipped_at","已寄客户"},new String[]{"closed_at","异常关闭"})) { + progress.add(new Progress(step[0],step[1],date(flow.get(step[0])))); + } + var manifest=physical.currentManifest(order.getId()); + boolean confirmed=manifest!=null && "CONFIRMED".equals(manifest.get("status")); + progress.add(4,new Progress("packing_confirmed","装箱确认",confirmed?date(manifest.get("confirmed_at")):null)); + var labelDates=confirmed?jdbc.queryForList("SELECT l.id,l.created_at FROM ll_procurement_label_set l WHERE l.order_id=? AND l.leg='OVERSEAS' AND l.manifest_id=? ORDER BY l.version DESC LIMIT 1",order.getId(),manifest.get("id")):List.>of(); + progress.add(5,new Progress("international_labels","国际面单上传",labelDates.isEmpty()?null:date(labelDates.get(0).get("created_at")))); + LocalDateTime applied=null; + if(!labelDates.isEmpty()) { + var states=jdbc.queryForMap("SELECT COUNT(*) AS total,SUM(CASE WHEN applied_at IS NULL THEN 1 ELSE 0 END) AS missing,MAX(applied_at) AS applied_at FROM ll_procurement_label_item WHERE label_set_id=?",labelDates.get(0).get("id")); + if(((Number)states.get("total")).longValue()>0 && ((Number)states.get("missing")).longValue()==0)applied=date(states.get("applied_at")); + } + progress.add(6,new Progress("labels_applied","全部箱贴单",applied)); + var boxes=jdbc.query("SELECT c.carton_no,c.quantity,c.length_cm,c.width_cm,c.height_cm,c.gross_weight_kg FROM ll_procurement_manifest m JOIN ll_procurement_carton c ON c.manifest_id=m.id WHERE m.order_id=? AND m.status='CONFIRMED' ORDER BY c.carton_no", + (rs,n)->new Box(rs.getString(1),rs.getLong(2),rs.getBigDecimal(3),rs.getBigDecimal(4),rs.getBigDecimal(5),rs.getBigDecimal(6)),order.getId()); + var shipments=jdbc.query("SELECT s.shipment_no,s.leg,s.shipped_at,c.carton_no,i.quantity,i.carrier,i.tracking_no FROM ll_procurement_shipment s JOIN ll_procurement_shipment_item i ON i.shipment_id=s.id JOIN ll_procurement_carton c ON c.id=i.carton_id WHERE s.order_id=? ORDER BY s.id,c.carton_no", + (rs,n)->new Shipment(rs.getString(1),rs.getString(2),rs.getTimestamp(3).toLocalDateTime(),rs.getString(4),rs.getLong(5),rs.getString(6),rs.getString(7)),order.getId()); + var summaries=new ArrayList<>(jdbc.queryForList("SELECT client_summary FROM ll_procurement_control_note WHERE order_id=? AND client_summary IS NOT NULL AND TRIM(client_summary)<>'' ORDER BY id",String.class,order.getId())); + summaries.addAll(jdbc.queryForList("SELECT client_summary FROM ll_procurement_closure WHERE order_id=?",String.class,order.getId())); + return new CustomerView(header,budget,flow.get("purchased_at")!=null,flow.get("purchase_confirmed_at")!=null,flow.get("purchase_issue")!=null,List.copyOf(progress),boxes,shipments, + fees.stream().map(f->CustomerFee.from(f,feeNos.get(f.getParentFeeId()),finance.sourceLinked(f))).toList(),List.copyOf(summaries)); + } + @Transactional(readOnly=true) + public PlatformView platform(String customer,String orderNo,Set permissions) { + var order=lifecycle.requireOrder(customer,orderNo);var base=orders.detail(customer,orderNo);var flow=lifecycle.flow(order.getId()); + var physicalView=physical.read(customer,orderNo);var transportView=transport.read(customer,orderNo); + return new PlatformView(customer(order),flow,physicalView,transportView,finance.all(order.getId()),adjustments.history(customer,orderNo),controls.read(customer,orderNo),base.files(),base.histories(), + jdbc.queryForList("SELECT action,operator_id,reason,detail_json,created_at FROM ll_procurement_command WHERE order_id=? ORDER BY id DESC",order.getId()),actions(order,flow,physicalView,transportView,permissions),base.remark()); + } + private List actions(OverseasProcurementOrder order,Map flow,ProcurementPhysicalService.Snapshot physicalView,ProcurementTransportService.Snapshot transportView,Set permissions) { + boolean closed=Set.of("REJECTED","CANCELLED","EXCEPTION_CLOSED","SHIPPED_TO_CUSTOMER").contains(order.getStatus()),purchased=flow.get("purchased_at")!=null,international=flow.get("overseas_shipped_at")!=null; + String stopped=closed?"订单已结束":null; + String blockers=reason(()->lifecycle.requireNoBlockers(order)); + var sample=finance.current(order.getId(),ProcurementFeePurpose.SAMPLE_PAYMENT,"SAMPLE_PAYMENT");boolean paid=sample!=null && finance.sourceLinked(sample); + List result=new ArrayList<>(); + action(result,permissions,"REVIEW","审核采购","review",closed?stopped:!"PENDING_REVIEW".equals(order.getStatus())?"订单不处于待审核":null); + action(result,permissions,"PURCHASE","登记购买完成","buy",closed?stopped:purchased?"已经记录购买事实":order.getReviewedAt()==null?"须审核通过":null); + action(result,permissions,"CONFIRM_PURCHASE","重新核定原购买","buy",closed?stopped:!purchased?"尚无真实购买事实":flow.get("purchase_confirmed_at")!=null?"原购买已核定":null); + action(result,permissions,"AUTHORIZE_BUDGET","核验预算上限","review",closed?stopped:sample!=null?"货款已核定,不能改原费用":null); + action(result,permissions,"PLAN_WAREHOUSE","修改计划仓","review",closed?stopped:flow.get("actual_warehouse_id")!=null?"实际仓已冻结,请使用受控更正":null); + action(result,permissions,"RECEIVE_OVERSEAS","登记海外实收","receive-overseas",closed?stopped:!purchased?"尚无购买来源":flow.get("overseas_received_at")!=null?"海外收货已完成":null); + action(result,permissions,"FINISH_OVERSEAS","确认海外收货完成","receive-overseas",closed?stopped:!purchased?"尚无购买来源":flow.get("overseas_received_at")!=null?"海外收货已完成":null); + action(result,permissions,"PACK","保存装箱草稿","pack",closed?stopped:international?"国际段已交寄":!purchased?"尚无购买来源":null); + action(result,permissions,"CONFIRM_PACKING","确认装箱","pack",closed?stopped:international?"国际段已交寄":blockers!=null?blockers:flow.get("overseas_received_at")==null?"须完成海外收货":!paid?"样品货款或来源关联未完成":!"DRAFT".equals(physicalView.packingStatus())?"须有当前装箱草稿":null); + action(result,permissions,"LABELS","维护两段面单","label",closed?stopped:!"CONFIRMED".equals(physicalView.packingStatus())?"须确认海外装箱":international && flow.get("domestic_received_at")==null?"须完成国内收货后制作寄客户面单":null); + action(result,permissions,"ESTIMATE","核对预估运费","estimate",closed?stopped:international?"国际段已交寄":blockers!=null?blockers:!paid?"样品货款或来源关联未完成":transportView.overseasLabels()==null?"请先维护国际面单":null); + action(result,permissions,"APPLY_LABELS","确认逐箱贴单","apply-label",closed?stopped:international?"国际段已交寄":transportView.overseasLabels()==null?"请先维护国际面单":null); + action(result,permissions,"SHIP_OVERSEAS","确认海外交寄","ship-overseas",transport.shipmentBlockReason(order,Leg.OVERSEAS)); + action(result,permissions,"RECEIVE_DOMESTIC","登记国内实收","receive-domestic",closed?stopped:!international?"尚无国际交寄来源":flow.get("domestic_received_at")!=null?"国内收货已完成":null); + action(result,permissions,"FINISH_DOMESTIC","确认国内收货完成","receive-domestic",closed?stopped:!international?"尚无国际交寄来源":flow.get("domestic_received_at")!=null?"国内收货已完成":null); + action(result,permissions,"REPACK","国内一对一换包装","receive-domestic",closed?stopped:flow.get("domestic_received_at")==null?"须完成国内收货":null); + action(result,permissions,"CONFIRM_CHARGES","确认国内费用","charge",closed?stopped:blockers!=null?blockers:flow.get("domestic_received_at")==null?"须完成国内收货":null); + action(result,permissions,"SHIP_CUSTOMER","确认寄客户","ship-customer",transport.shipmentBlockReason(order,Leg.DOMESTIC)); + action(result,permissions,"MANAGE","事实更正及异常处置","manage",null); + action(result,permissions,"FINANCE_ADJUST","费用更正与退款","finance-adjust",null); + action(result,permissions,"CANCEL","购买前取消","cancel",closed?stopped:purchased?"购买后不能取消,请处理实物资金后异常关闭":null); + action(result,permissions,"EXCEPTION_CLOSE","异常关闭","manage",closed?stopped:!purchased?"购买前请取消订单":blockers!=null?blockers:reason(()->finance.requireShipmentReady(order.getId()))); + return List.copyOf(result); + } + private static void action(List result,Set permissions,String key,String label,String suffix,String block){String code="platform:overseas-business:purchase:"+suffix;String reason=permissions==null || !permissions.contains(code)?"无此操作权限":block;result.add(new Action(key,label,code,reason==null,reason));} + private static String reason(Runnable action){try{action.run();return null;}catch(org.springframework.web.server.ResponseStatusException e){return e.getReason();}} + private static LocalDateTime date(Object value){return value==null?null:((java.sql.Timestamp)value).toLocalDateTime();} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementTransportService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementTransportService.java new file mode 100644 index 000000000..afe3bdaa8 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementTransportService.java @@ -0,0 +1,277 @@ +package com.baiyue.lastleg.procurement.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.procurement.dto.ProcurementPhysicalRequests.Leg; +import com.baiyue.lastleg.procurement.dto.ProcurementPhysicalRequests.Source; +import com.baiyue.lastleg.procurement.dto.ProcurementTransportRequests.*; +import com.baiyue.lastleg.procurement.entity.OverseasProcurementOrder; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.math.BigDecimal; +import java.security.MessageDigest; +import java.sql.Timestamp; +import java.time.LocalDateTime; +import java.util.*; +import static com.baiyue.lastleg.procurement.service.ProcurementLifecycleService.*; + +/** Every shipment is a single immutable consumption of this order's entire eligible batch. */ +@Service @RequiredArgsConstructor +public class ProcurementTransportService { + private final ProcurementLifecycleService lifecycle; + private final ProcurementPhysicalService physical; + private final ProcurementFinanceService finance; + private final ProcurementWarehouseDirectory warehouses; + private final JdbcTemplate jdbc; + private final ObjectMapper json; + + public record Cargo(long cartonId,String cartonNo,long quantity,BigDecimal lengthCm,BigDecimal widthCm, + BigDecimal heightCm,BigDecimal grossWeightKg,Long packagingRecordId,List sources) { } + public record LabelRow(long cartonId,long fileId,int page,String carrier,String trackingNo,LocalDateTime appliedAt) { } + public record LabelSet(long id,int version,long manifestId,String cargoHash,Address sender,Address receiver,List labels) { } + public record Shipment(long id,String shipmentNo,String leg,LocalDateTime shippedAt) { } + public record Snapshot(String orderNo,long version,String status,LabelSet overseasLabels,LabelSet domesticLabels, + List shipments,List overseasCargo,List domesticCargo) { } + + @Transactional + public Snapshot saveLabels(String customer,String orderNo,String operator,Labels request) { + var order=lifecycle.lock(customer,orderNo); + if(lifecycle.replay(order,"LABELS",request.idempotencyKey(),request))return snapshot(order); + version(order,request.version());open(order);notShipped(order,request.leg()); + var manifest=manifest(order,request.leg()); + if(((Number)manifest.get("version")).intValue()!=request.packingVersion())throw conflict("清单版本已变化,请重新核对"); + var cargo=cargo(order,request.leg(),((Number)manifest.get("id")).longValue()); + if(cargo.isEmpty())throw conflict("没有可寄货物,不能制作面单"); + if(request.labels()==null || request.labels().isEmpty())throw bad("请关联本批全部箱的面单"); + exactCartons(cargo,request.labels().stream().map(Label::cartonId).toList()); + address(request.receiver()); + Address sender; + if(request.leg()==Leg.OVERSEAS) { + var id=(Number)lifecycle.flow(order.getId()).get("actual_warehouse_id"); + if(id==null)throw conflict("尚无实际海外收货仓"); + sender=warehouses.sender(id.longValue()); + } else { + var original=internationalAddresses(order); + sender=original.receiver(); + var customerAddress=new Address(order.getContactName(),order.getContactPhone(),order.getReceivingAddress()); + if(!customerAddress.equals(request.receiver())) { + if(request.addressVoucherFileId()==null)throw bad("最终收件信息变更须关联本单客户确认凭证"); + lifecycle.requireFile(order,request.addressVoucherFileId(),Set.of("EXCEPTION")); + } + } + address(sender); + for(var label:request.labels()) { + lifecycle.requireFile(order,label.fileId(),Set.of("LABEL")); + var types=jdbc.queryForList("SELECT content_type FROM ll_overseas_procurement_file WHERE order_id=? AND file_id=? AND category='LABEL'",String.class,order.getId(),label.fileId()); + if(types.size()!=1 || !Set.of("application/pdf","image/jpeg","image/png").contains(types.get(0)) + || label.page()<1 || (!"application/pdf".equals(types.get(0)) && label.page()!=1))throw bad("面单类型或对应页码不正确"); + text(label.carrier(),128,"承运商");text(label.trackingNo(),128,"运单号"); + } + int next=jdbc.queryForObject("SELECT COALESCE(MAX(version),0)+1 FROM ll_procurement_label_set WHERE order_id=? AND leg=?",Integer.class,order.getId(),request.leg().name()); + long id=IdWorker.getId(); + jdbc.update("INSERT INTO ll_procurement_label_set(id,order_id,customer_id,leg,version,manifest_id,cargo_hash,sender_snapshot,receiver_snapshot,created_by) VALUES(?,?,?,?,?,?,?,CAST(? AS JSON),CAST(? AS JSON),?)", + id,order.getId(),customer,request.leg().name(),next,manifest.get("id"),hash(cargo),serialize(sender),serialize(request.receiver()),operator); + for(var label:request.labels())jdbc.update("INSERT INTO ll_procurement_label_item(label_set_id,carton_id,file_id,page_no,carrier,tracking_no) VALUES(?,?,?,?,?,?)", + id,label.cartonId(),label.fileId(),label.page(),label.carrier().trim(),label.trackingNo().trim()); + if(request.leg()==Leg.OVERSEAS)order.setStatus("WAIT_LABELS"); + lifecycle.complete(order,operator,"LABELS",request.idempotencyKey(),request.reason(),request); + return snapshot(order); + } + + /** Different prices release first; callers retry after the authoritative release result to create the replacement. */ + @Transactional + public Snapshot reviewEstimate(String customer,String orderNo,String operator,Estimate request) { + var order=lifecycle.lock(customer,orderNo); + if(lifecycle.replay(order,"ESTIMATE",request.idempotencyKey(),request))return snapshot(order); + version(order,request.version());open(order);notShipped(order,Leg.OVERSEAS);samplePaid(order);lifecycle.requireNoBlockers(order); + var labels=validLabels(order,Leg.OVERSEAS,request.labelVersion()); + if(request.amount()==null || request.amount().signum()<0 || request.amount().scale()>2 || request.businessDate()==null)throw bad("请填写准确的CNY预估金额和业务日期"); + var existing=finance.current(order.getId(),ProcurementFeePurpose.INTERNATIONAL_ESTIMATE,"INTERNATIONAL_ESTIMATE"); + if(existing!=null) { + if(!Set.of("FROZEN","ZERO").contains(existing.getWalletStatus()))throw conflict("原预估资金未完成,请先处理原请求"); + if(existing.getAmount().compareTo(request.amount())!=0) { + finance.releaseEstimate(customer,orderNo,existing.getId()); + order.setStatus("WAIT_LABELS"); + lifecycle.complete(order,operator,"ESTIMATE",request.idempotencyKey(),request.reason(),request); + return snapshot(order); + } + } else existing=finance.confirm(customer,orderNo,operator,new ProcurementFinanceService.Confirmation( + ProcurementFeePurpose.INTERNATIONAL_ESTIMATE,"INTERNATIONAL_ESTIMATE","预估国际运费",request.amount().signum()>0?"POSITIVE":"ZERO", + request.amount(),"CNY",request.businessDate(),null,request.reason())); + Integer count=jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_estimate_review WHERE label_set_id=? AND fee_id=?",Integer.class,labels.id(),existing.getId()); + if(count==0)jdbc.update("INSERT INTO ll_procurement_estimate_review(id,order_id,label_set_id,fee_id,reviewed_by) VALUES(?,?,?,?,?)",IdWorker.getId(),order.getId(),labels.id(),existing.getId(),operator); + order.setStatus("WAIT_OVERSEAS_SHIPMENT"); + lifecycle.complete(order,operator,"ESTIMATE",request.idempotencyKey(),request.reason(),request); + return snapshot(order); + } + + @Transactional + public Snapshot applyLabels(String customer,String orderNo,String operator,ApplyLabels request) { + var order=lifecycle.lock(customer,orderNo); + if(lifecycle.replay(order,"APPLY_LABELS",request.idempotencyKey(),request))return snapshot(order); + version(order,request.version());open(order);notShipped(order,Leg.OVERSEAS); + var labels=validLabels(order,Leg.OVERSEAS,request.labelVersion()); + if(request.appliedAt()==null || request.appliedAt().isAfter(LocalDateTime.now()))throw bad("请填写真实贴单时间"); + var flow=lifecycle.flow(order.getId()); + if(request.appliedAt().isBefore(((Timestamp)flow.get("overseas_received_at")).toLocalDateTime()))throw bad("贴单时间不能早于海外收货完成"); + if(request.cartonIds()==null || request.cartonIds().isEmpty() || new HashSet<>(request.cartonIds()).size()!=request.cartonIds().size())throw bad("请选择不重复的实际贴单箱"); + for(long carton:request.cartonIds()) { + var label=labels.labels().stream().filter(l->l.cartonId()==carton).findFirst().orElseThrow(()->bad("贴单箱不属于当前面单")); + if(label.appliedAt()!=null)throw conflict("该箱已确认贴单,不能重复登记"); + jdbc.update("UPDATE ll_procurement_label_item SET applied_at=?,applied_by=? WHERE label_set_id=? AND carton_id=? AND applied_at IS NULL",request.appliedAt(),operator,labels.id(),carton); + } + lifecycle.complete(order,operator,"APPLY_LABELS",request.idempotencyKey(),request.reason(),request); + return snapshot(order); + } + + @Transactional + public Snapshot repack(String customer,String orderNo,String operator,Repack request) { + var order=lifecycle.lock(customer,orderNo); + if(lifecycle.replay(order,"REPACK",request.idempotencyKey(),request))return snapshot(order); + version(order,request.version());open(order);notShipped(order,Leg.DOMESTIC); + var manifest=manifest(order,Leg.DOMESTIC); + if(cargo(order,Leg.DOMESTIC,((Number)manifest.get("id")).longValue()).stream().noneMatch(c->c.cartonId()==request.cartonId()))throw bad("换包装箱不属于本单国内可寄原箱"); + measure(request.lengthCm(),2);measure(request.widthCm(),2);measure(request.heightCm(),2);measure(request.grossWeightKg(),3); + lifecycle.requireFile(order,request.voucherFileId(),Set.of("EXCEPTION","RECEIPT")); + jdbc.update("INSERT INTO ll_procurement_repack(id,order_id,carton_id,length_cm,width_cm,height_cm,gross_weight_kg,voucher_file_id,reason,created_by) VALUES(?,?,?,?,?,?,?,?,?,?)", + IdWorker.getId(),order.getId(),request.cartonId(),request.lengthCm(),request.widthCm(),request.heightCm(),request.grossWeightKg(),request.voucherFileId(),request.reason(),operator); + // New measurements require the domestic operator to reconfirm all current cost determinations. + jdbc.update("UPDATE ll_procurement_flow SET charges_confirmed_at=NULL WHERE order_id=?",order.getId());order.setStatus("WAIT_CHARGES"); + lifecycle.complete(order,operator,"REPACK",request.idempotencyKey(),request.reason(),request); + return snapshot(order); + } + + @Transactional + public Snapshot ship(String customer,String orderNo,String operator,Ship request) { + var order=lifecycle.lock(customer,orderNo); + if(lifecycle.replay(order,"SHIP",request.idempotencyKey(),request))return snapshot(order); + version(order,request.version());var labels=requireShipmentReady(order,request.leg(),request.labelVersion()); + var cargo=cargo(order,request.leg(),labels.manifestId());exactCartons(cargo,request.cartonIds()); + if(request.shippedAt()==null || request.shippedAt().isAfter(LocalDateTime.now()))throw bad("请填写真实交承运方时间"); + var flow=lifecycle.flow(order.getId()); + if(request.leg()==Leg.OVERSEAS) { + if(labels.labels().stream().anyMatch(l->request.shippedAt().isBefore(l.appliedAt())))throw bad("交寄时间不能早于贴单时间"); + } else { + if(request.shippedAt().isBefore(((Timestamp)flow.get("domestic_received_at")).toLocalDateTime()))throw bad("交寄时间不能早于国内收货完成"); + } + long id=IdWorker.getId();String number="PS-"+UUID.randomUUID().toString().replace("-","").substring(0,24).toUpperCase(); + jdbc.update("INSERT INTO ll_procurement_shipment(id,order_id,customer_id,shipment_no,leg,manifest_id,shipped_at,address_snapshot,created_by) VALUES(?,?,?,?,?,?,?,CAST(? AS JSON),?)", + id,order.getId(),customer,number,request.leg().name(),labels.manifestId(),request.shippedAt(),serialize(new Addresses(labels.sender(),labels.receiver())),operator); + for(var carton:cargo) { + var label=labels.labels().stream().filter(l->l.cartonId()==carton.cartonId()).findFirst().orElseThrow(); + jdbc.update("INSERT INTO ll_procurement_shipment_item(shipment_id,carton_id,quantity,carrier,tracking_no,label_snapshot) VALUES(?,?,?,?,?,CAST(? AS JSON))", + id,carton.cartonId(),carton.quantity(),label.carrier(),label.trackingNo(),serialize(new ShipmentItem(carton,label,labels.version()))); + } + String field=request.leg()==Leg.OVERSEAS?"overseas_shipped_at":"customer_shipped_at"; + jdbc.update("UPDATE ll_procurement_flow SET "+field+"=? WHERE order_id=?",request.shippedAt(),order.getId()); + order.setStatus(request.leg()==Leg.OVERSEAS?"INTERNATIONAL_TRANSIT":"SHIPPED_TO_CUSTOMER"); + lifecycle.complete(order,operator,"SHIP",request.idempotencyKey(),request.reason(),request); + return snapshot(order); + } + + private LabelSet requireShipmentReady(OverseasProcurementOrder order,Leg leg,int labelVersion) { + open(order);notShipped(order,leg);samplePaid(order);lifecycle.requireNoBlockers(order);finance.requireAdjustmentsComplete(order.getId()); + var labels=validLabels(order,leg,labelVersion); + if(leg==Leg.OVERSEAS) { + var estimate=finance.current(order.getId(),ProcurementFeePurpose.INTERNATIONAL_ESTIMATE,"INTERNATIONAL_ESTIMATE"); + if(estimate==null || !Set.of("FROZEN","ZERO").contains(estimate.getWalletStatus()))throw conflict("预估国际运费尚未完成冻结或零元核定"); + Integer reviewed=jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_estimate_review WHERE label_set_id=? AND fee_id=?",Integer.class,labels.id(),estimate.getId()); + if(reviewed!=1)throw conflict("当前面单和清单尚未核对有效预估"); + if(labels.labels().stream().anyMatch(l->l.appliedAt()==null))throw conflict("本批全部箱须完成贴单确认"); + } else { + if(lifecycle.flow(order.getId()).get("charges_confirmed_at")==null)throw conflict("国内整单费用尚未确认"); + finance.requireShipmentReady(order.getId()); + } + return labels; + } + String shipmentBlockReason(OverseasProcurementOrder order,Leg leg) { + try { + var labels=labels(order.getId(),leg); + requireShipmentReady(order,leg,labels==null?0:labels.version());return null; + } catch(org.springframework.web.server.ResponseStatusException e){return e.getReason();} + } + + public record Addresses(Address sender,Address receiver) { } + public record ShipmentItem(Cargo cargo,LabelRow label,int labelVersion) { } + private Addresses internationalAddresses(OverseasProcurementOrder order) { + var values=jdbc.queryForList("SELECT address_snapshot FROM ll_procurement_shipment WHERE order_id=? AND leg='OVERSEAS'",String.class,order.getId()); + if(values.size()!=1)throw conflict("原国际交寄地址快照缺失"); + return deserialize(values.get(0),Addresses.class); + } + private void samplePaid(OverseasProcurementOrder order) { + var fee=finance.current(order.getId(),ProcurementFeePurpose.SAMPLE_PAYMENT,"SAMPLE_PAYMENT"); + if(fee==null || !finance.sourceLinked(fee))throw conflict("样品货款或费用来源关联尚未完成"); + if(lifecycle.flow(order.getId()).get("purchase_issue")!=null)throw conflict("真实购买异常尚未处理"); + } + private void notShipped(OverseasProcurementOrder order,Leg leg) { + if(leg==null)throw bad("运输段不能为空"); + if(lifecycle.flow(order.getId()).get(leg==Leg.OVERSEAS?"overseas_shipped_at":"customer_shipped_at")!=null)throw conflict("本段已实际交寄,不能重复发出或覆盖历史"); + } + private Map manifest(OverseasProcurementOrder order,Leg leg) { + var flow=lifecycle.flow(order.getId()); + if(leg==Leg.DOMESTIC && flow.get("domestic_received_at")==null)throw conflict("须国内收货完成后核对本批可寄货物"); + var manifest=physical.currentManifest(order.getId()); + if(flow.get("overseas_received_at")==null || flow.get("packing_version")==null || manifest==null || !"CONFIRMED".equals(manifest.get("status")))throw conflict("须使用本单已确认的装箱清单"); + return manifest; + } + List cargo(OverseasProcurementOrder order,Leg leg,long manifestId) { + var rows=jdbc.queryForList("SELECT * FROM ll_procurement_carton WHERE order_id=? AND manifest_id=? ORDER BY id",order.getId(),manifestId); + List result=new ArrayList<>(); + for(var row:rows) { + long id=((Number)row.get("id")).longValue(); + List sources; + if(leg==Leg.OVERSEAS)sources=jdbc.query("SELECT receipt_id,quantity FROM ll_procurement_carton_source WHERE carton_id=? ORDER BY receipt_id",(rs,n)->new Source(rs.getLong(1),rs.getLong(2)),id); + else sources=physical.received(order.getId(),Leg.DOMESTIC,id).stream().filter(r->r.goodQuantity()>0) + .sorted(Comparator.comparingLong(ProcurementPhysicalService.ReceiptRow::id)).map(r->new Source(r.id(),r.goodQuantity())).toList(); + long quantity=sources.stream().mapToLong(Source::quantity).reduce(0,Math::addExact); + if(quantity<=0)continue; + var measurements=row;Long packagingRecordId=null; + if(leg==Leg.DOMESTIC) { + var replacements=jdbc.queryForList("SELECT * FROM ll_procurement_repack WHERE order_id=? AND carton_id=? ORDER BY id DESC LIMIT 1",order.getId(),id); + if(!replacements.isEmpty()) {measurements=replacements.get(0);packagingRecordId=((Number)measurements.get("id")).longValue();} + } + result.add(new Cargo(id,String.valueOf(row.get("carton_no")),quantity,(BigDecimal)measurements.get("length_cm"), + (BigDecimal)measurements.get("width_cm"),(BigDecimal)measurements.get("height_cm"),(BigDecimal)measurements.get("gross_weight_kg"),packagingRecordId,sources)); + } + return result; + } + private LabelSet validLabels(OverseasProcurementOrder order,Leg leg,int requestedVersion) { + var manifest=manifest(order,leg);var labels=labels(order.getId(),leg); + if(labels==null || labels.version()!=requestedVersion || labels.manifestId()!=((Number)manifest.get("id")).longValue())throw conflict("面单或清单版本已变化,请重新制单核对"); + var cargo=cargo(order,leg,labels.manifestId()); + if(!labels.cargoHash().equals(hash(cargo)))throw conflict("实收或包装已变化,旧面单不能用于交寄,请重新制单"); + exactCartons(cargo,labels.labels().stream().map(LabelRow::cartonId).toList());return labels; + } + private static void exactCartons(List cargo,List ids) { + Set expected=new HashSet<>(cargo.stream().map(Cargo::cartonId).toList()); + if(cargo.isEmpty() || ids==null || new HashSet<>(ids).size()!=ids.size() || !expected.equals(new HashSet<>(ids)))throw bad("必须覆盖本批全部可寄原箱,不能重复、拆箱、合箱或关联其他订单箱"); + } + @Transactional(readOnly=true) + public Snapshot read(String customer,String orderNo) { + return snapshot(lifecycle.requireOrder(customer,orderNo)); + } + private Snapshot snapshot(OverseasProcurementOrder order) { + var shipments=jdbc.query("SELECT id,shipment_no,leg,shipped_at FROM ll_procurement_shipment WHERE order_id=? ORDER BY id", + (rs,n)->new Shipment(rs.getLong(1),rs.getString(2),rs.getString(3),rs.getTimestamp(4).toLocalDateTime()),order.getId()); + var manifest=physical.currentManifest(order.getId()); + Long manifestId=manifest==null?null:((Number)manifest.get("id")).longValue(); + return new Snapshot(order.getOrderNo(),order.getVersion(),order.getStatus(),labels(order.getId(),Leg.OVERSEAS),labels(order.getId(),Leg.DOMESTIC),shipments, + manifestId==null?List.of():cargo(order,Leg.OVERSEAS,manifestId),manifestId==null?List.of():cargo(order,Leg.DOMESTIC,manifestId)); + } + private LabelSet labels(long orderId,Leg leg) { + var rows=jdbc.queryForList("SELECT * FROM ll_procurement_label_set WHERE order_id=? AND leg=? ORDER BY version DESC LIMIT 1",orderId,leg.name()); + if(rows.isEmpty())return null;var row=rows.get(0);long id=((Number)row.get("id")).longValue(); + var items=jdbc.query("SELECT * FROM ll_procurement_label_item WHERE label_set_id=? ORDER BY carton_id",(rs,n)->new LabelRow(rs.getLong("carton_id"),rs.getLong("file_id"),rs.getInt("page_no"),rs.getString("carrier"),rs.getString("tracking_no"),rs.getTimestamp("applied_at")==null?null:rs.getTimestamp("applied_at").toLocalDateTime()),id); + return new LabelSet(id,((Number)row.get("version")).intValue(),((Number)row.get("manifest_id")).longValue(),String.valueOf(row.get("cargo_hash")), + deserialize(jsonText(row.get("sender_snapshot")),Address.class),deserialize(jsonText(row.get("receiver_snapshot")),Address.class),items); + } + private static String jsonText(Object value){return value instanceof byte[] b?new String(b,java.nio.charset.StandardCharsets.UTF_8):String.valueOf(value);} + private static void address(Address value){if(value==null)throw bad("收寄资料不完整");text(value.name(),128,"收寄姓名");text(value.address(),1000,"收寄地址");if(value.phone()==null || !value.phone().matches("[+0-9 ()-]{5,64}"))throw bad("请填写有效快递联系电话");} + private static void text(String value,int max,String name){if(value==null || value.isBlank() || value.length()>max)throw bad(name+"不能为空或超过长度限制");} + private static void measure(BigDecimal value,int scale){if(value==null || value.signum()<=0 || value.scale()>scale)throw bad("换包装箱规及重量须符合正数与精度要求");} + private String serialize(Object value){try{return json.writeValueAsString(value);}catch(Exception e){throw new IllegalStateException(e);}} + private T deserialize(String value,Class type){try{return json.readValue(value,type);}catch(Exception e){throw new IllegalStateException("采购运输快照无效",e);}} + private String hash(Object value){try{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(serialize(value).getBytes(java.nio.charset.StandardCharsets.UTF_8)));}catch(Exception e){throw new IllegalStateException(e);}} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementWarehouseDirectory.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementWarehouseDirectory.java new file mode 100644 index 000000000..36040d990 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementWarehouseDirectory.java @@ -0,0 +1,53 @@ +package com.baiyue.lastleg.procurement.service; + +import com.baiyue.lastleg.client.DomesticWarehouseDirectoryClient; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ResponseStatusException; +import java.util.List; + +@Component +@RequiredArgsConstructor +public class ProcurementWarehouseDirectory { + private final DomesticWarehouseDirectoryClient client; + @Value("${baiyue.security.internal-service-token}") private String token; + public List options() { + var result=client.receivingWarehouses(token); + if(result==null || !result.successful() || result.data()==null) throw unavailable(); + return result.data(); + } + public DomesticWarehouseDirectoryClient.ReceivingWarehouse required(long id) { + var result=client.receivingWarehouse(id,token); + if(result==null || !result.successful() || result.data()==null) throw unavailable(); + var warehouse=result.data(); + if(!Long.valueOf(id).equals(warehouse.warehouseId()) || !"ACTIVE".equals(warehouse.status())) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"所选仓库不是有效海外收货仓"); + } + return warehouse; + } + public com.baiyue.lastleg.procurement.dto.ProcurementTransportRequests.Address sender(long id) { + required(id); + var result=client.activeWarehouse(id,token); + if(result==null || !result.successful() || result.data()==null || result.data().warehouse()==null) throw unavailable(); + var warehouse=result.data().warehouse(); + if(!Long.valueOf(id).equals(warehouse.id()) || !Long.valueOf(0).equals(warehouse.tenantId()) + || !"OVERSEAS".equals(warehouse.warehouseType()) || !"ACTIVE".equals(warehouse.status())) throw unavailable(); + var addresses=result.data().addresses(); + var operating=addresses==null?List.of():addresses.stream() + .filter(a->"OPERATING".equals(a.addressType())).toList(); + if(operating.size()!=1 || warehouse.contactName()==null || warehouse.contactName().isBlank() + || warehouse.contactPhone()==null || !warehouse.contactPhone().matches("[+0-9 ()-]{5,64}") + || operating.get(0).addressLine1()==null || operating.get(0).addressLine1().isBlank()) { + throw new ResponseStatusException(HttpStatus.CONFLICT,"实际海外仓的联系人、电话或唯一作业地址不完整,请先维护仓库资料"); + } + var a=operating.get(0); + String address=java.util.stream.Stream.of(a.countryCode(),a.stateProvince(),a.city(),a.district(),a.addressLine1(),a.addressLine2(),a.postalCode()) + .filter(v->v!=null && !v.isBlank()).collect(java.util.stream.Collectors.joining(" ")); + return new com.baiyue.lastleg.procurement.dto.ProcurementTransportRequests.Address(warehouse.contactName(),warehouse.contactPhone(),address); + } + private static ResponseStatusException unavailable() { + return new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"海外收货仓目录暂不可用,请稍后重试"); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/security/OverseasPermissions.java b/lastleg-service/src/main/java/com/baiyue/lastleg/security/OverseasPermissions.java index 35769be0a..9da6b4fdf 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/security/OverseasPermissions.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/security/OverseasPermissions.java @@ -73,6 +73,19 @@ public final class OverseasPermissions { public static final String PURCHASE_LIST = "platform:overseas-business:purchase:list"; public static final String PURCHASE_EDIT = "platform:overseas-business:purchase:edit"; public static final String PURCHASE_REVIEW = "platform:overseas-business:purchase:review"; + public static final String PURCHASE_BUY = "platform:overseas-business:purchase:buy"; + public static final String PURCHASE_CANCEL = "platform:overseas-business:purchase:cancel"; + public static final String PURCHASE_RECEIVE_OVERSEAS = "platform:overseas-business:purchase:receive-overseas"; + public static final String PURCHASE_RECEIVE_DOMESTIC = "platform:overseas-business:purchase:receive-domestic"; + public static final String PURCHASE_PACK = "platform:overseas-business:purchase:pack"; + public static final String PURCHASE_ESTIMATE = "platform:overseas-business:purchase:estimate"; + public static final String PURCHASE_APPLY_LABEL = "platform:overseas-business:purchase:apply-label"; + public static final String PURCHASE_SHIP_OVERSEAS = "platform:overseas-business:purchase:ship-overseas"; + public static final String PURCHASE_SHIP_CUSTOMER = "platform:overseas-business:purchase:ship-customer"; + public static final String PURCHASE_MANAGE = "platform:overseas-business:purchase:manage"; + public static final String PURCHASE_LABEL = "platform:overseas-business:purchase:label"; + public static final String PURCHASE_CHARGE = "platform:overseas-business:purchase:charge"; + public static final String PURCHASE_FINANCE_ADJUST = "platform:overseas-business:purchase:finance-adjust"; public static final String TRUCK_LIST = "platform:overseas-business:truck:list"; public static final String TRUCK_CREATE = "platform:overseas-business:truck:create"; public static final String TRUCK_EDIT = "platform:overseas-business:truck:edit"; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/wallet/WalletCommandOutboxService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/wallet/WalletCommandOutboxService.java index bf5320cdb..df578a8ef 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/wallet/WalletCommandOutboxService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/wallet/WalletCommandOutboxService.java @@ -27,12 +27,41 @@ public class WalletCommandOutboxService { public String publish(String operation, long customerId, String businessType, String businessNo, BigDecimal amount, String remark, long aggregateVersion) { - String eventKey = "WALLET_COMMAND:" + businessType + ':' + businessNo + ':' + operation + ':' + aggregateVersion; + if ("REFUND".equals(operation)) throw new IllegalArgumentException("退款必须关联原扣款流水"); + return publishCommand(operation, customerId, businessType, businessNo, amount, remark, aggregateVersion, null); + } + + public String publishRefund(long customerId, String businessType, String refundNo, BigDecimal amount, + String originalLedgerId, String remark, long aggregateVersion) { + if (originalLedgerId == null || !originalLedgerId.matches("[1-9][0-9]*")) { + throw new IllegalArgumentException("退款原扣款流水无效"); + } + return publishCommand("REFUND", customerId, businessType, refundNo, amount, remark, aggregateVersion, originalLedgerId); + } + + private String publishCommand(String operation, long customerId, String businessType, String businessNo, + BigDecimal amount, String remark, long aggregateVersion, String originalLedgerId) { + String eventKey = "WALLET_COMMAND:" + customerId + ':' + businessType + ':' + businessNo + ':' + operation + ':' + aggregateVersion; LastlegEventOutbox existing = mapper.findByEventKey(eventKey); - if (existing != null) return eventId(existing.getPayloadJson()); - String idempotencyKey = businessType + ':' + businessNo + ':' + operation + ':' + aggregateVersion; + if (existing != null) { + try { + EventEnvelope saved = objectMapper.readValue(existing.getPayloadJson(), EventEnvelope.class); + WalletCommandRequestedV1 command = objectMapper.treeToValue(saved.payload(), WalletCommandRequestedV1.class); + if (command.customerId() != customerId || !operation.equals(command.operation()) + || !businessType.equals(command.businessType()) || !businessNo.equals(command.businessNo()) + || !java.util.Objects.equals(remark, command.remark()) + || !java.util.Objects.equals(originalLedgerId, command.originalLedgerId()) + || (amount == null ? command.amount() != null : command.amount() == null || amount.compareTo(command.amount()) != 0)) { + throw new IllegalStateException("同一资金请求对应不同费用事实"); + } + return saved.eventId(); + } catch (java.io.IOException exception) { + throw new IllegalStateException("资金指令事件快照损坏", exception); + } + } + String idempotencyKey = customerId + ":" + businessType + ':' + businessNo + ':' + operation + ':' + aggregateVersion; WalletCommandRequestedV1 value = new WalletCommandRequestedV1(operation, customerId, businessType, - businessNo, amount, remark, idempotencyKey, Instant.now()); + businessNo, amount, remark, idempotencyKey, Instant.now(), originalLedgerId); JsonNode payload = objectMapper.valueToTree(value); EventEnvelope envelope = new EventEnvelope(EventIdentifiers.newUuid(), EventTypes.WALLET_COMMAND_REQUESTED, WalletCommandRequestedV1.VERSION, Instant.now(), @@ -49,10 +78,6 @@ public class WalletCommandOutboxService { return envelope.eventId(); } - private String eventId(String json) { - try { return objectMapper.readValue(json, EventEnvelope.class).eventId(); } - catch (Exception exception) { throw new IllegalStateException("资金指令事件快照损坏", exception); } - } private String json(Object value) { try { return objectMapper.writeValueAsString(value); } catch (Exception exception) { throw new IllegalStateException("资金指令事件序列化失败", exception); } diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 855d8538d..c21744461 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -885,6 +885,7 @@ CREATE TABLE IF NOT EXISTS ll_finance_fee_source ( supplier_category VARCHAR(32) NULL COMMENT '应付供应商类别约束', settlement_mode VARCHAR(16) NULL COMMENT 'ON_ACCOUNT账期应付或DIRECT_PAID业务已付', paid_at DATETIME(3) NULL COMMENT '直接已付时间', payment_method VARCHAR(32) NULL COMMENT '直接已付方式', payment_reference_no VARCHAR(128) NULL COMMENT '直接已付流水号', payment_voucher_file_ids JSON NULL COMMENT '直接已付凭证文件ID快照', + wallet_settlement JSON NULL COMMENT '已实扣应收的Finance资金命令、冻结和消费流水引用', business_date DATE NOT NULL COMMENT '费用业务日期', currency CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT '财务结算币种,固定CNY', amount DECIMAL(20,6) NOT NULL COMMENT '人民币结算金额', original_currency CHAR(3) NOT NULL COMMENT '业务原币种,支持CNY或USD', original_amount DECIMAL(20,6) NOT NULL COMMENT '业务原币金额', exchange_rate DECIMAL(20,6) NOT NULL COMMENT '原币折算人民币汇率,人民币固定为1', @@ -2631,10 +2632,8 @@ CREATE TABLE IF NOT EXISTS ll_overseas_procurement_order ( purchase_quantity BIGINT NOT NULL COMMENT '采购数量', budget_unit_price DECIMAL(18,2) NULL COMMENT '预算单价', currency CHAR(3) NOT NULL DEFAULT 'USD' COMMENT '币种', receiving_address VARCHAR(1000) NULL COMMENT '收货地址', contact_name VARCHAR(128) NOT NULL COMMENT '联系人姓名', contact_phone VARCHAR(64) NOT NULL COMMENT '联系人电话', - purchase_warehouse_id VARCHAR(128) NULL COMMENT '采购入仓仓库ID', purchase_warehouse_name VARCHAR(255) NULL COMMENT '采购入仓仓库名称', outbound_details JSON NULL COMMENT '出库明细', + purchase_warehouse_id VARCHAR(128) NULL COMMENT '采购入仓仓库ID', purchase_warehouse_name VARCHAR(255) NULL COMMENT '采购入仓仓库名称', inbound_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '采购单据累计入库数量', - domestic_carrier VARCHAR(128) NULL COMMENT '境内承运商', domestic_tracking_no VARCHAR(128) NULL COMMENT '境内物流单号', - overseas_carrier VARCHAR(128) NULL COMMENT '海外承运商', overseas_tracking_no VARCHAR(128) NULL COMMENT '海外物流单号', estimated_arrival_at DATETIME(3) NULL COMMENT '预计到达时间', status VARCHAR(32) NOT NULL DEFAULT 'PENDING_REVIEW' COMMENT '业务状态', remark VARCHAR(1000) NULL COMMENT '备注', idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', request_hash CHAR(64) NOT NULL COMMENT '请求内容哈希', created_by VARCHAR(128) NULL COMMENT '创建人ID', reviewed_by VARCHAR(128) NULL COMMENT '审核人ID', review_remark VARCHAR(1000) NULL COMMENT '审核备注', @@ -2880,23 +2879,43 @@ CREATE TABLE IF NOT EXISTS ll_procurement_daily_sequence ( CREATE TABLE IF NOT EXISTS ll_overseas_procurement_file ( id BIGINT NOT NULL COMMENT '主键ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID', - category VARCHAR(16) NOT NULL COMMENT 'RECEIPT签收文件或VOUCHER费用凭证', file_id BIGINT NOT NULL COMMENT 'File服务文件ID', + category VARCHAR(16) NOT NULL COMMENT 'RECEIPT实收、VOUCHER费用、LABEL面单、PURCHASE采购、BUDGET预算、EXCEPTION异常凭证', file_id BIGINT NOT NULL COMMENT 'File服务文件ID', file_name VARCHAR(255) NOT NULL COMMENT '文件名快照', content_type VARCHAR(128) NULL COMMENT '媒体类型', size_bytes BIGINT NOT NULL DEFAULT 0 COMMENT '文件大小', created_by VARCHAR(128) NULL COMMENT '上传人', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '上传时间', PRIMARY KEY(id), UNIQUE KEY uk_ll_procurement_file(order_id,file_id), KEY idx_ll_procurement_file_order(customer_id,order_id,category,created_at), - CONSTRAINT chk_ll_procurement_file_category CHECK(category IN ('RECEIPT','VOUCHER')) + CONSTRAINT chk_ll_procurement_file_category CHECK(category IN ('RECEIPT','VOUCHER','LABEL','PURCHASE','BUDGET','EXCEPTION')) ) ENGINE=InnoDB COMMENT='海外采购签收文件及费用凭证'; CREATE TABLE IF NOT EXISTS ll_overseas_procurement_fee ( + purpose VARCHAR(32) NULL COMMENT '费用业务用途;由履约节点确定', + confirmation_origin VARCHAR(16) NOT NULL DEFAULT 'BUSINESS' COMMENT '核定入口:BUSINESS业务节点、FINANCIAL财务操作', + fee_version INT NULL COMMENT '同用途费用确认版本', + active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否当前有效版本', + determination VARCHAR(24) NULL COMMENT 'POSITIVE正额、ZERO零元、NOT_APPLICABLE不适用', + original_currency CHAR(3) NULL COMMENT '核定原币CNY或USD', + original_amount DECIMAL(20,2) NULL COMMENT '核定原币金额', + exchange_rate DECIMAL(20,6) NULL COMMENT '已确认汇率快照', + exchange_rate_month CHAR(7) NULL COMMENT '已确认汇率所属月份', + business_date DATE NULL COMMENT '实际费用业务日期', + wallet_business_id BIGINT NULL COMMENT '资金冻结对应采购费用记录', + wallet_business_no VARCHAR(128) NULL COMMENT '资金冻结业务号;实际国际运费引用原预估', + wallet_hold_id VARCHAR(64) NULL COMMENT 'Finance冻结引用', + wallet_ledger_id VARCHAR(64) NULL COMMENT 'Finance实际处理流水引用', + settled_command_event_id VARCHAR(64) NULL COMMENT '成功实扣或退款的资金命令事件', + source_fee_id BIGINT NULL COMMENT '已实扣费用来源ID', + parent_fee_id BIGINT NULL COMMENT '原费用或预估引用', + request_hash CHAR(64) NULL COMMENT '确认请求摘要', id BIGINT NOT NULL COMMENT '主键ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID', fee_no VARCHAR(64) NOT NULL COMMENT '费用业务号', fee_item_code VARCHAR(32) NOT NULL COMMENT '费用项编码', fee_item_name VARCHAR(128) NOT NULL COMMENT '费用项名称', - amount DECIMAL(20,2) NOT NULL COMMENT 'CNY金额', currency CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT '费用币种', voucher_file_id BIGINT NOT NULL COMMENT '凭证文件ID', - voucher_file_name VARCHAR(255) NOT NULL COMMENT '凭证文件名', remark VARCHAR(500) NULL COMMENT '说明', + amount DECIMAL(20,2) NOT NULL COMMENT 'CNY金额', currency CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT '费用币种', voucher_file_id BIGINT NULL COMMENT '凭证文件ID', + voucher_file_name VARCHAR(255) NULL COMMENT '凭证文件名', remark VARCHAR(500) NULL COMMENT '说明', wallet_status VARCHAR(32) NOT NULL COMMENT '资金状态', wallet_error_code VARCHAR(64) NULL COMMENT '资金错误码', wallet_error_message VARCHAR(500) NULL COMMENT '资金错误说明', wallet_command_event_id VARCHAR(64) NULL COMMENT '当前资金指令事件ID', created_by VARCHAR(128) NULL COMMENT '登记人', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + CONSTRAINT chk_procurement_confirmation_origin CHECK(confirmation_origin IN('BUSINESS','FINANCIAL')), + UNIQUE KEY uk_ll_procurement_fee_version(order_id,purpose,fee_item_code,fee_version), PRIMARY KEY(id), UNIQUE KEY uk_ll_procurement_fee_no(fee_no), KEY idx_ll_procurement_fee_order(customer_id,order_id,created_at), - CONSTRAINT chk_ll_procurement_fee_amount CHECK(amount>0), CONSTRAINT chk_ll_procurement_fee_currency CHECK(currency='CNY') + CONSTRAINT chk_ll_procurement_fee_amount CHECK(amount>=0), CONSTRAINT chk_ll_procurement_fee_currency CHECK(currency='CNY') ) ENGINE=InnoDB COMMENT='海外采购费用明细及资金生命周期'; @@ -3218,3 +3237,240 @@ CREATE TABLE IF NOT EXISTS ll_return_carton_count_session ( PRIMARY KEY(operator_id), UNIQUE KEY uk_ll_return_count_session_receipt(receipt_id) ) ENGINE=InnoDB COMMENT='退货箱内清点单人单任务持久会话'; + +CREATE TABLE IF NOT EXISTS ll_procurement_wallet_attempt ( + id BIGINT NOT NULL COMMENT '请求尝试ID', + order_id BIGINT NOT NULL COMMENT '采购订单ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', + fee_id BIGINT NOT NULL COMMENT '不可变费用版本ID', + attempt_no BIGINT NOT NULL COMMENT '费用版本内请求序号', + operation VARCHAR(32) NOT NULL COMMENT '资金操作:FREEZE冻结、SETTLE结算、SETTLE_ACTUAL实际结算、RELEASE释放、REFUND退款', + business_no VARCHAR(128) NOT NULL COMMENT 'Finance资金业务号', + amount DECIMAL(20,2) NULL COMMENT '请求金额;全额结算或释放可空', + command_event_id VARCHAR(64) NOT NULL COMMENT '资金指令事件唯一引用', + status VARCHAR(16) NOT NULL COMMENT '资金请求结果:PENDING未决、SUCCEEDED成功、FAILED明确失败', + result_hash CHAR(64) NULL COMMENT '原业务回执摘要', + wallet_hold_id VARCHAR(64) NULL COMMENT 'Finance冻结引用', + wallet_ledger_id VARCHAR(64) NULL COMMENT 'Finance处理流水引用', + error_code VARCHAR(64) NULL COMMENT '业务失败码', + error_message VARCHAR(500) NULL COMMENT '内部失败诊断,不直接对客展示', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '请求时间', + completed_at DATETIME(3) NULL COMMENT '最终结果时间', + PRIMARY KEY(id), UNIQUE KEY uk_procurement_attempt_event(command_event_id), + UNIQUE KEY uk_procurement_attempt_number(fee_id,attempt_no), + KEY idx_procurement_attempt_order(customer_id,order_id,status), + CONSTRAINT chk_procurement_attempt_status CHECK(status IN('PENDING','SUCCEEDED','FAILED')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购费用资金命令尝试与不可变结果'; + +-- 采购节点事实与操作幂等;不补写历史订单的购买、授权或收货事实。 +CREATE TABLE IF NOT EXISTS ll_procurement_flow ( + order_id BIGINT NOT NULL COMMENT '采购订单ID,一单一个履约批次', + customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', + purchased_at DATETIME(3) NULL COMMENT '真实购买时间', + purchased_product_name VARCHAR(255) NULL COMMENT '实际购买商品', + purchased_specification VARCHAR(500) NULL COMMENT '实际购买规格', + purchased_quantity BIGINT NULL COMMENT '实际购买件数', + purchase_amount DECIMAL(20,2) NULL COMMENT '供应商实付原币金额', + purchase_currency CHAR(3) NULL COMMENT '供应商实付原币CNY或USD', + purchase_voucher_id BIGINT NULL COMMENT '购买凭证引用', + purchase_issue VARCHAR(500) NULL COMMENT '购买未核定或异常说明', + purchase_confirmed_at DATETIME(3) NULL COMMENT '货款核定时间,不代表扣费成功', + actual_warehouse_id BIGINT NULL COMMENT '首次实际收货冻结的仓库ID', + actual_warehouse_code VARCHAR(128) NULL COMMENT '实际收货仓编码快照', + actual_warehouse_name VARCHAR(255) NULL COMMENT '实际收货仓名称快照', + overseas_received_at DATETIME(3) NULL COMMENT '海外整批收货完成时间', + packing_version INT NULL COMMENT '当前确认装箱清单版本', + overseas_shipped_at DATETIME(3) NULL COMMENT '国际段整批实际交寄时间', + domestic_received_at DATETIME(3) NULL COMMENT '国内整批收货完成时间', + charges_confirmed_at DATETIME(3) NULL COMMENT '国内整单费用核定时间', + customer_shipped_at DATETIME(3) NULL COMMENT '国内寄客户实际交寄时间', + domestic_receiver_name VARCHAR(128) NULL COMMENT '国际段国内收件人', + domestic_receiver_phone VARCHAR(64) NULL COMMENT '国际段国内收件电话', + domestic_receiver_address VARCHAR(1000) NULL COMMENT '国际段国内接收地址', + cancelled_at DATETIME(3) NULL COMMENT '购买前取消时间', + closed_at DATETIME(3) NULL COMMENT '异常关闭时间', + control_reason VARCHAR(1000) NULL COMMENT '取消或关闭原因', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY(order_id), KEY idx_procurement_flow_customer(customer_id,order_id), + CONSTRAINT chk_procurement_purchase_quantity CHECK(purchased_quantity IS NULL OR purchased_quantity>0), + CONSTRAINT chk_procurement_purchase_amount CHECK(purchase_amount IS NULL OR purchase_amount>=0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购单一批次履约事实'; + +CREATE TABLE IF NOT EXISTS ll_procurement_command ( + id BIGINT NOT NULL COMMENT '操作记录ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', action VARCHAR(48) NOT NULL COMMENT '采购节点动作', + idempotency_key VARCHAR(128) NOT NULL COMMENT '操作幂等键', request_hash CHAR(64) NOT NULL COMMENT '请求摘要', + operator_id VARCHAR(128) NOT NULL COMMENT '执行人', reason VARCHAR(1000) NOT NULL COMMENT '操作依据', + detail_json JSON NOT NULL COMMENT '内部操作及更正依据快照,不直接对客展示', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '执行时间', + PRIMARY KEY(id), UNIQUE KEY uk_procurement_command(order_id,action,idempotency_key), + KEY idx_procurement_command_customer(customer_id,order_id,created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购节点幂等与不可变审计'; + +CREATE TABLE IF NOT EXISTS ll_procurement_budget_authorization ( + id BIGINT NOT NULL COMMENT '预算授权ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', + previous_amount DECIMAL(20,2) NULL COMMENT '原货款预算总上限;未授权保持空', previous_currency CHAR(3) NOT NULL COMMENT '原预算币种', + authorized_amount DECIMAL(20,2) NOT NULL COMMENT '客户授权的新货款总上限', currency CHAR(3) NOT NULL COMMENT '客户授权币种', + voucher_file_id BIGINT NOT NULL COMMENT '客户确认书面或聊天凭证', reason VARCHAR(1000) NOT NULL COMMENT '授权依据', + reviewed_by VARCHAR(128) NOT NULL COMMENT '核验人员', reviewed_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '核验时间', + PRIMARY KEY(id), KEY idx_procurement_budget_order(customer_id,order_id,id), + CONSTRAINT chk_procurement_budget_amount CHECK(authorized_amount>=0 AND previous_amount>=0), + CONSTRAINT chk_procurement_budget_currency CHECK(currency IN('CNY','USD') AND previous_currency IN('CNY','USD')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购货款预算授权核验历史'; + +-- 采购实收、差异、装箱和两段整批发运事实。不产生WMS库存流水,不回填历史采购数量。 +CREATE TABLE IF NOT EXISTS ll_procurement_receipt ( + id BIGINT NOT NULL COMMENT '实收明细ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', + receipt_no VARCHAR(64) NOT NULL COMMENT '采购收货业务编号', leg VARCHAR(16) NOT NULL COMMENT 'OVERSEAS海外或DOMESTIC国内', + source_carton_id BIGINT NULL COMMENT '国内实收关联原国际交寄箱,海外为空', warehouse_id BIGINT NULL COMMENT '海外实际收货仓', + good_quantity BIGINT NOT NULL COMMENT '可发合格件数', damaged_quantity BIGINT NOT NULL COMMENT '破损待处置件数', extra_quantity BIGINT NOT NULL COMMENT '多到件数,不增加可发数量', + received_at DATETIME(3) NOT NULL COMMENT '实际收货时间', active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '当前有效实收事实', + supersedes_id BIGINT NULL COMMENT '受控更正所替代的原实收记录', created_by VARCHAR(128) NOT NULL COMMENT '登记人员', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '登记时间', + PRIMARY KEY(id), UNIQUE KEY uk_procurement_receipt_no(receipt_no), KEY idx_procurement_receipt_source(order_id,leg,source_carton_id,active), + CONSTRAINT chk_procurement_receipt_quantity CHECK(good_quantity>=0 AND damaged_quantity>=0 AND extra_quantity>=0 AND good_quantity+damaged_quantity+extra_quantity>0), + CONSTRAINT chk_procurement_receipt_leg CHECK((leg='OVERSEAS' AND source_carton_id IS NULL) OR (leg='DOMESTIC' AND source_carton_id IS NOT NULL)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购分次实收与受控更正事实'; + +CREATE TABLE IF NOT EXISTS ll_procurement_disposition ( + id BIGINT NOT NULL COMMENT '差异处理ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', + leg VARCHAR(16) NOT NULL COMMENT 'OVERSEAS海外或DOMESTIC国内', source_carton_id BIGINT NULL COMMENT '国内原交寄箱来源', + receipt_id BIGINT NULL COMMENT '破损或多货对应实收记录,短少为空', kind VARCHAR(24) NOT NULL COMMENT 'SHORTAGE短少、DAMAGED破损、EXTRA多货', + quantity BIGINT NOT NULL COMMENT '已批准处置件数', decision VARCHAR(32) NOT NULL COMMENT 'ACCEPT_SHORTAGE确认短少、DISPOSE_DAMAGED拒收或处置、RETURN_EXTRA退回多货', + active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '当前有效处置;撤销只改变有效性并保留原事实', + voucher_file_id BIGINT NOT NULL COMMENT '本单客户确认或实际处置凭证', reason VARCHAR(1000) NOT NULL COMMENT '处置依据', + created_by VARCHAR(128) NOT NULL COMMENT '采购管理人员', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '确认时间', + PRIMARY KEY(id), KEY idx_procurement_disposition_source(order_id,leg,source_carton_id,receipt_id,kind), + CONSTRAINT chk_procurement_disposition_quantity CHECK(quantity>0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购单内差异处置,不自动退款'; + +CREATE TABLE IF NOT EXISTS ll_procurement_manifest ( + id BIGINT NOT NULL COMMENT '装箱清单ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', + version INT NOT NULL COMMENT '订单内装箱版本', status VARCHAR(16) NOT NULL COMMENT 'DRAFT草稿、CONFIRMED已确认、SUPERSEDED已替代', + confirmed_at DATETIME(3) NULL COMMENT '确认时间', created_by VARCHAR(128) NOT NULL COMMENT '制单人', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '版本保存时间', + PRIMARY KEY(id), UNIQUE KEY uk_procurement_manifest_version(order_id,version), + CONSTRAINT chk_procurement_manifest_status CHECK(status IN('DRAFT','CONFIRMED','SUPERSEDED')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购装箱清单不可变版本'; + +CREATE TABLE IF NOT EXISTS ll_procurement_carton ( + id BIGINT NOT NULL COMMENT '版本内箱记录ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', manifest_id BIGINT NOT NULL COMMENT '装箱清单版本', + carton_no VARCHAR(64) NOT NULL COMMENT '采购订单内业务箱号', length_cm DECIMAL(12,2) NOT NULL COMMENT '外箱长厘米', + width_cm DECIMAL(12,2) NOT NULL COMMENT '外箱宽厘米', height_cm DECIMAL(12,2) NOT NULL COMMENT '外箱高厘米', + gross_weight_kg DECIMAL(12,3) NOT NULL COMMENT '封箱毛重千克', quantity BIGINT NOT NULL COMMENT '本箱合格商品总件数', + PRIMARY KEY(id), UNIQUE KEY uk_procurement_carton_version(manifest_id,carton_no), KEY idx_procurement_carton_order(order_id,manifest_id), + CONSTRAINT chk_procurement_carton_measurement CHECK(length_cm>0 AND width_cm>0 AND height_cm>0 AND gross_weight_kg>0 AND quantity>0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购版本化逐箱实测与件数'; + +CREATE TABLE IF NOT EXISTS ll_procurement_carton_source ( + carton_id BIGINT NOT NULL COMMENT '版本内箱ID', receipt_id BIGINT NOT NULL COMMENT '海外实收明细来源', quantity BIGINT NOT NULL COMMENT '从该实收装入本箱的件数', + PRIMARY KEY(carton_id,receipt_id), KEY idx_procurement_carton_receipt(receipt_id), CONSTRAINT chk_procurement_carton_source_quantity CHECK(quantity>0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购箱内货物的实收来源与数量'; + +CREATE TABLE IF NOT EXISTS ll_procurement_shipment ( + id BIGINT NOT NULL COMMENT '采购交寄ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', + shipment_no VARCHAR(64) NOT NULL COMMENT '交寄业务编号', leg VARCHAR(16) NOT NULL COMMENT 'OVERSEAS国际交寄或DOMESTIC寄客户', + manifest_id BIGINT NOT NULL COMMENT '原海外装箱清单版本', shipped_at DATETIME(3) NOT NULL COMMENT '整批真实交承运方时间', + address_snapshot JSON NOT NULL COMMENT '本段交寄时的收寄姓名电话地址快照', created_by VARCHAR(128) NOT NULL COMMENT '交寄确认人', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '确认时间', + PRIMARY KEY(id), UNIQUE KEY uk_procurement_shipment_leg(order_id,leg), UNIQUE KEY uk_procurement_shipment_no(shipment_no), + CONSTRAINT chk_procurement_shipment_leg CHECK(leg IN('OVERSEAS','DOMESTIC')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购两段各一次整批真实交寄'; + +CREATE TABLE IF NOT EXISTS ll_procurement_shipment_item ( + shipment_id BIGINT NOT NULL COMMENT '采购交寄ID', carton_id BIGINT NOT NULL COMMENT '原海外装箱版本内箱ID', quantity BIGINT NOT NULL COMMENT '本次实际交寄件数', + carrier VARCHAR(128) NOT NULL COMMENT '承运商快照', tracking_no VARCHAR(128) NOT NULL COMMENT '运单号快照', + label_snapshot JSON NOT NULL COMMENT '当次箱规、面单及货物来源快照', + PRIMARY KEY(shipment_id,carton_id), CONSTRAINT chk_procurement_shipment_item_quantity CHECK(quantity>0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购整批交寄逐箱事实'; + +-- 采购面单、逐箱贴单、预估核对与国内一对一换包装;不重建历史发运事实。 +CREATE TABLE IF NOT EXISTS ll_procurement_label_set ( + id BIGINT NOT NULL COMMENT '面单批版本ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', + leg VARCHAR(16) NOT NULL COMMENT 'OVERSEAS国际段或DOMESTIC寄客户段', version INT NOT NULL COMMENT '订单段内面单版本', + manifest_id BIGINT NOT NULL COMMENT '原海外确认装箱版本', cargo_hash CHAR(64) NOT NULL COMMENT '箱规及有效货物来源摘要', + sender_snapshot JSON NOT NULL COMMENT '寄件人姓名电话地址快照', receiver_snapshot JSON NOT NULL COMMENT '收件人姓名电话地址快照', + created_by VARCHAR(128) NOT NULL COMMENT '制单操作人', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '上传确认时间', + PRIMARY KEY(id), UNIQUE KEY uk_procurement_label_version(order_id,leg,version), + CONSTRAINT chk_procurement_label_leg CHECK(leg IN('OVERSEAS','DOMESTIC')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购两段面单不可变版本和地址快照'; + +CREATE TABLE IF NOT EXISTS ll_procurement_label_item ( + label_set_id BIGINT NOT NULL COMMENT '面单批版本ID', carton_id BIGINT NOT NULL COMMENT '原海外清单箱ID', + file_id BIGINT NOT NULL COMMENT '本采购订单面单文件', page_no INT NOT NULL COMMENT '该箱对应文件页码,从1开始', + carrier VARCHAR(128) NOT NULL COMMENT '承运商', tracking_no VARCHAR(128) NOT NULL COMMENT '箱主或子运单号', + applied_at DATETIME(3) NULL COMMENT '真实贴单时间', applied_by VARCHAR(128) NULL COMMENT '贴单确认人', + PRIMARY KEY(label_set_id,carton_id), CONSTRAINT chk_procurement_label_page CHECK(page_no>0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购逐箱文件页关联及贴单事实'; + +CREATE TABLE IF NOT EXISTS ll_procurement_estimate_review ( + id BIGINT NOT NULL COMMENT '预估核对ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', label_set_id BIGINT NOT NULL COMMENT '已核对国际面单版本', + fee_id BIGINT NOT NULL COMMENT '唯一有效预估费用版本', reviewed_by VARCHAR(128) NOT NULL COMMENT '核价人员', + reviewed_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '核对时间', + PRIMARY KEY(id), UNIQUE KEY uk_procurement_estimate_review(label_set_id,fee_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='面单清单与预估冻结的显式核对关联'; + +CREATE TABLE IF NOT EXISTS ll_procurement_repack ( + id BIGINT NOT NULL COMMENT '换包装版本ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', carton_id BIGINT NOT NULL COMMENT '原国际交寄箱ID,保持一对一来源', + length_cm DECIMAL(12,2) NOT NULL COMMENT '新外箱长厘米', width_cm DECIMAL(12,2) NOT NULL COMMENT '新外箱宽厘米', + height_cm DECIMAL(12,2) NOT NULL COMMENT '新外箱高厘米', gross_weight_kg DECIMAL(12,3) NOT NULL COMMENT '新封箱毛重千克', + voucher_file_id BIGINT NOT NULL COMMENT '本单换包装证据', reason VARCHAR(1000) NOT NULL COMMENT '换包装依据', + created_by VARCHAR(128) NOT NULL COMMENT '国内作业人员', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '登记时间', + PRIMARY KEY(id), KEY idx_procurement_repack_carton(order_id,carton_id,id), + CONSTRAINT chk_procurement_repack_measurement CHECK(length_cm>0 AND width_cm>0 AND height_cm>0 AND gross_weight_kg>0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购国内原箱一对一换包装不可变历史'; + +-- 费用链目标更正快照与关联;资金流水仍唯一归Finance,不回填旧费用。 +CREATE TABLE IF NOT EXISTS ll_procurement_fee_correction ( + id BIGINT NOT NULL COMMENT '费用链更正ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', + correction_no VARCHAR(64) NOT NULL COMMENT '财务更正业务号', root_fee_id BIGINT NOT NULL COMMENT '原实际费用链根', + idempotency_key VARCHAR(128) NOT NULL COMMENT '本单更正请求幂等键', request_hash CHAR(64) NOT NULL COMMENT '更正目标及凭证请求摘要', + target_original_amount DECIMAL(20,2) NOT NULL COMMENT '本次正确原币总额', original_currency CHAR(3) NOT NULL COMMENT '沿用原费用币种', + exchange_rate DECIMAL(20,6) NOT NULL COMMENT '沿用原费用汇率快照', exchange_rate_month CHAR(7) NOT NULL COMMENT '原汇率月份,不取本次操作月份', + target_amount DECIMAL(20,2) NOT NULL COMMENT '正确总额折算CNY', previous_net_amount DECIMAL(20,2) NOT NULL COMMENT '确认前整链净实扣CNY', + difference_amount DECIMAL(20,2) NOT NULL COMMENT 'CNY正差额追加、负差额退款、零差额仅留核对记录', + voucher_file_id BIGINT NOT NULL COMMENT '本订单财务更正凭证', reason VARCHAR(500) NOT NULL COMMENT '更正依据', + created_by VARCHAR(128) NOT NULL COMMENT '授权财务操作人', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '确认时间', + PRIMARY KEY(id), UNIQUE KEY uk_procurement_correction_no(correction_no), UNIQUE KEY uk_procurement_correction_request(order_id,idempotency_key), + KEY idx_procurement_correction_root(order_id,root_fee_id,id), + CONSTRAINT chk_procurement_correction_amount CHECK(target_original_amount>=0 AND target_amount>=0 AND previous_net_amount>=0 AND exchange_rate>0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购费用链更正目标与原汇率快照'; + +CREATE TABLE IF NOT EXISTS ll_procurement_fee_correction_item ( + correction_id BIGINT NOT NULL COMMENT '本次费用链更正ID', fee_id BIGINT NOT NULL COMMENT '本次产生的独立正差额或退款费用ID', + PRIMARY KEY(correction_id,fee_id), UNIQUE KEY uk_procurement_correction_fee(fee_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购费用目标更正与逐笔资金事实关联'; + +-- 单采购订单内的更正说明、阻断异常与关闭证据。不生成WMS库存或退货运输任务。 +CREATE TABLE IF NOT EXISTS ll_procurement_control_note ( + id BIGINT NOT NULL COMMENT '采购更正说明ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', + note_no VARCHAR(64) NOT NULL COMMENT '更正说明业务号', target_type VARCHAR(24) NOT NULL COMMENT '购买、仓库、实收或交寄事实类型', + target_id BIGINT NULL COMMENT '本单事实对象引用,购买为空', blocking TINYINT(1) NOT NULL COMMENT '是否阻断后续计划履约', + voucher_file_id BIGINT NOT NULL COMMENT '本单内部事实更正凭证', reason VARCHAR(1000) NOT NULL COMMENT '内部更正说明', + client_summary VARCHAR(500) NULL COMMENT '人工确认的对客摘要,不复制内部原始内容', + original_value_json JSON NULL COMMENT '服务端记录的原事实快照', corrected_value_json JSON NULL COMMENT '服务端记录的更正事实快照', + created_by VARCHAR(128) NOT NULL COMMENT '采购管理操作人', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '登记时间', + resolved_by VARCHAR(128) NULL COMMENT '处理确认人', resolved_at DATETIME(3) NULL COMMENT '阻断解除时间', + resolution_reason VARCHAR(1000) NULL COMMENT '内部处理结果', resolution_voucher_id BIGINT NULL COMMENT '本单处理凭证', + PRIMARY KEY(id), UNIQUE KEY uk_procurement_note_no(note_no), KEY idx_procurement_note_block(order_id,blocking,resolved_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购不可覆盖事实的更正说明及阻断异常'; + +CREATE TABLE IF NOT EXISTS ll_procurement_closure ( + id BIGINT NOT NULL COMMENT '采购异常关闭ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', + closure_no VARCHAR(64) NOT NULL COMMENT '异常关闭业务号', customer_voucher_file_id BIGINT NOT NULL COMMENT '客户同意停止履约凭证', + financial_conclusion VARCHAR(1000) NOT NULL COMMENT '已知费用及后续无需收费的处理结论,不改变资金事实', + reason VARCHAR(1000) NOT NULL COMMENT '内部关闭原因', client_summary VARCHAR(500) NOT NULL COMMENT '人工确认的对客关闭摘要', + created_by VARCHAR(128) NOT NULL COMMENT '采购管理确认人', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '关闭确认时间', + PRIMARY KEY(id), UNIQUE KEY uk_procurement_closure_order(order_id), UNIQUE KEY uk_procurement_closure_no(closure_no) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购实物资金收口后的异常关闭事实'; + +CREATE TABLE IF NOT EXISTS ll_procurement_closure_item ( + id BIGINT NOT NULL COMMENT '关闭货物处置ID', closure_id BIGINT NOT NULL COMMENT '采购关闭ID', + source_kind VARCHAR(32) NOT NULL COMMENT '购买未达、在途未达、合格、破损或多货来源', source_id BIGINT NULL COMMENT '原实收或国际交寄箱,购买未达为空', + quantity BIGINT NOT NULL COMMENT '本来源最终明确去向件数', decision VARCHAR(40) NOT NULL COMMENT '供应商保管、退还、报失、客户授权保管或处置', + destination VARCHAR(500) NOT NULL COMMENT '实际去向及接收方说明', voucher_file_id BIGINT NOT NULL COMMENT '本单实物处置证明', + PRIMARY KEY(id), KEY idx_procurement_closure_source(closure_id,source_kind,source_id), + CONSTRAINT chk_procurement_closure_quantity CHECK(quantity>0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='异常关闭逐来源剩余货物最终去向,不产生新发运'; diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index 415c8879f..74a9e31fe 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -1012,8 +1012,8 @@ paths: content: { application/json: { schema: { $ref: '#/components/schemas/CustomerOptionResponse' } } } /api/platform/v1/last-leg/overseas-procurements/warehouse-options: get: - summary: 查询启用的海外合作采购仓候选 - description: 权限 platform:overseas-business:purchase:list;复用服务端缓存目录,浏览器不访问国内内部 API。 + summary: 查询有效公共海外收货仓候选 + description: 权限 platform:overseas-business:purchase:list;固定国内权威公共 OVERSEAS、ACTIVE、RECEIVING 仓,浏览器不访问内部 API。 security: [{ bearerAuth: [] }] responses: '200': @@ -1027,10 +1027,10 @@ paths: summary: 查询海外采购订单详情及操作流水 description: 权限 platform:overseas-business:purchase:list。 security: [{ bearerAuth: [] }] - responses: { '200': { description: 统一 ApiResponse 包装的采购订单详情 } } + responses: { '200': { description: 后台采购聚合详情及服务端操作资格, content: { application/json: { schema: { $ref: 'procurement-fulfillment-v1.yaml#/components/schemas/ProcurementPlatformResponse' } } } } } patch: summary: 编辑海外采购订单 - description: 权限 platform:overseas-business:purchase:edit;可选字段缺失表示不修改,显式 null 或空串表示清空。采购仓名称由 warehouse ID 对应候选快照决定。采购出库不属于本 API。 + description: 权限 platform:overseas-business:purchase:edit;仅编辑内部备注,字段缺失表示不修改,显式 null 或空串表示清空。商品、仓库、数量和运输字段由各履约节点维护,提交这些旧字段会被拒绝。 security: [{ bearerAuth: [] }] requestBody: required: true @@ -1246,7 +1246,7 @@ paths: description: 权限 merchant:cross-border:procurement:create;客户 ID 只取认证身份且不接收 customerId;新单固定进入 PENDING_REVIEW。同幂等键同请求返回既有订单,异请求返回 A0409。 security: [{ bearerAuth: [] }] requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ProcurementCreate' } } } } - responses: { '200': { description: 采购订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/ProcurementDetailResponse' } } } }, '409': { description: 幂等冲突 } } + responses: { '200': { description: 采购订单详情, content: { application/json: { schema: { $ref: 'procurement-fulfillment-v1.yaml#/components/schemas/ProcurementCustomerResponse' } } } }, '409': { description: 幂等冲突 } } get: summary: 客户分页查询自己的海外采购订单 description: 权限 merchant:cross-border:procurement:list;客户范围只取认证 Token,不接收 customerId。 @@ -1264,11 +1264,11 @@ paths: responses: { '200': { description: 当前客户采购订单分页, content: { application/json: { schema: { $ref: '#/components/schemas/ProcurementPageResponse' } } } } } /api/merchant/v1/last-leg/overseas-procurements/{orderNo}: get: - summary: 客户查询自己的海外采购订单详情及流水 + summary: 客户查询自己的采购履约、箱明细、运单及费用 description: 权限 merchant:cross-border:procurement:list;列表权限包含详情只读,客户范围只取认证 Token。 security: [{ bearerAuth: [] }] parameters: [{ name: orderNo, in: path, required: true, schema: { type: string } }] - responses: { '200': { description: 当前客户采购订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/ProcurementDetailResponse' } } } }, '404': { description: 当前客户下订单不存在 } } + responses: { '200': { description: 当前客户采购订单详情, content: { application/json: { schema: { $ref: 'procurement-fulfillment-v1.yaml#/components/schemas/ProcurementCustomerResponse' } } } }, '404': { description: 当前客户下订单不存在 } } /api/merchant/v1/last-leg/us-truck-orders: get: summary: 客户分页查询自己的美国本土卡订单 @@ -3220,11 +3220,6 @@ components: - { $ref: '#/components/schemas/MerchantApiResponse' } - type: object properties: { data: { $ref: '#/components/schemas/ProcurementPage' } } - ProcurementDetailResponse: - allOf: - - { $ref: '#/components/schemas/MerchantApiResponse' } - - type: object - properties: { data: { $ref: '#/components/schemas/ProcurementDetail' } } TruckPageResponse: allOf: - { $ref: '#/components/schemas/MerchantApiResponse' } @@ -3327,7 +3322,7 @@ components: properties: { records: { type: array, items: { $ref: '#/components/schemas/ProcurementSummary' } } } ProcurementSummary: type: object - required: [id, orderNo, customerId, purchaseCategory, productName, purchaseQuantity, inboundQuantity, outboundQuantity, inventoryQuantity, status, version, createdAt, updatedAt] + required: [id, orderNo, customerId, purchaseCategory, productName, purchaseQuantity, status, version, createdAt, updatedAt] properties: id: { type: string, pattern: '^[0-9]+$' } orderNo: { type: string } @@ -3341,43 +3336,10 @@ components: currency: { type: string, nullable: true } purchaseWarehouseId: { type: string, nullable: true } purchaseWarehouseName: { type: string, nullable: true } - inboundQuantity: { type: integer, format: int64 } - outboundQuantity: { type: integer, format: int64 } - inventoryQuantity: { type: integer, format: int64 } - status: { type: string, description: PENDING_REVIEW/APPROVED/REJECTED 或按审核后出库数量派生的 PENDING_OUTBOUND/PARTIALLY_OUTBOUND/OUTBOUNDED } + status: { type: string, description: 以 ProcurementStatus 中的真实履约阶段返回,不按通用出库数量推断 } version: { type: integer, format: int64 } createdAt: { type: string, format: date-time } updatedAt: { type: string, format: date-time } - ProcurementDetail: - type: object - required: [summary, histories, outboundDetails] - properties: - summary: { $ref: '#/components/schemas/ProcurementSummary' } - productUrl: { type: string, nullable: true } - receivingAddress: { type: string, nullable: true } - contactName: { type: string, nullable: true } - contactPhone: { type: string, nullable: true } - domesticCarrier: { type: string, nullable: true } - domesticTrackingNo: { type: string, nullable: true } - overseasCarrier: { type: string, nullable: true } - overseasTrackingNo: { type: string, nullable: true } - estimatedArrivalAt: { type: string, format: date-time, nullable: true } - remark: { type: string, nullable: true } - createdBy: { type: string, nullable: true } - reviewedBy: { type: string, nullable: true } - reviewRemark: { type: string, nullable: true } - reviewedAt: { type: string, format: date-time, nullable: true } - histories: { type: array, items: { $ref: '#/components/schemas/BusinessHistory' } } - outboundDetails: { type: array, items: { $ref: '#/components/schemas/ProcurementOutboundDetail' } } - ProcurementOutboundDetail: - type: object - properties: - id: { type: string } - quantity: { type: integer, format: int64 } - carrier: { type: string, nullable: true } - trackingNo: { type: string, nullable: true } - shippedAt: { type: string, format: date-time, nullable: true } - remark: { type: string, nullable: true } TruckPage: allOf: - { $ref: '#/components/schemas/PageMetadata' } @@ -3720,18 +3682,20 @@ components: actualVolumeCbm: { type: number, nullable: true } status: { type: string } version: { type: integer } - ProcurementStatus: { type: string, enum: [PENDING_REVIEW, APPROVED, REJECTED] } + ProcurementStatus: { type: string, enum: [PENDING_REVIEW, WAIT_PURCHASE, WAIT_OVERSEAS_RECEIPT, OVERSEAS_RECEIVING, WAIT_PACKING, WAIT_LABELS, WAIT_OVERSEAS_DISPATCH, INTERNATIONAL_TRANSIT, DOMESTIC_RECEIVING, WAIT_CHARGES, WAIT_CUSTOMER_DISPATCH, SHIPPED_TO_CUSTOMER, REJECTED, CANCELLED, EXCEPTION_CLOSED] } ProcurementReview: type: object required: [version, decision, idempotencyKey] + description: 审核通过必须选择有效公共海外收货仓;拒绝不要求仓库。 properties: version: { type: integer, format: int64, minimum: 0 } decision: { type: string, enum: [APPROVE, REJECT] } idempotencyKey: { type: string, minLength: 1, maxLength: 255 } remark: { type: string, nullable: true, maxLength: 1000 } + purchaseWarehouseId: { type: integer, format: int64, minimum: 1, nullable: true } ProcurementCreate: type: object - required: [purchaseCategory, productName, purchaseQuantity, contactName, contactPhone, idempotencyKey] + required: [purchaseCategory, productName, purchaseQuantity, budgetUnitPrice, currency, receivingAddress, contactName, contactPhone, idempotencyKey] properties: customerName: { type: string, nullable: true, maxLength: 255 } purchaseCategory: { type: string, maxLength: 128 } @@ -3739,9 +3703,9 @@ components: productSpecification: { type: string, nullable: true, maxLength: 500 } productUrl: { type: string, nullable: true, maxLength: 2000 } purchaseQuantity: { type: integer, format: int64, minimum: 1 } - budgetUnitPrice: { type: number, nullable: true, minimum: 0 } - currency: { type: string, minLength: 3, maxLength: 3, default: USD } - receivingAddress: { type: string, nullable: true, maxLength: 1000 } + budgetUnitPrice: { type: number, minimum: 0, multipleOf: 0.01, description: 客户授权的货款预算单价;零表示仅授权免费样品 } + currency: { type: string, enum: [CNY, USD] } + receivingAddress: { type: string, minLength: 1, maxLength: 1000, description: 最终客户国内收件完整地址 } contactName: { type: string, maxLength: 128 } contactPhone: { type: string, maxLength: 64 } remark: { type: string, nullable: true, maxLength: 1000 } @@ -3749,35 +3713,12 @@ components: NullableTextPatch: { type: string, nullable: true, description: 缺失不修改;null 或空串清空 } ProcurementUpdate: type: object + additionalProperties: false + description: 只维护内部备注;商品、数量、仓库、实收和运输必须通过独立节点操作。 required: [version] properties: version: { type: integer, format: int64, minimum: 0 } - purchaseCategory: { type: string, maxLength: 128 } - productName: { type: string, maxLength: 255 } - productSpecification: { $ref: '#/components/schemas/NullableTextPatch' } - productUrl: { $ref: '#/components/schemas/NullableTextPatch' } - inboundQuantity: { type: integer, format: int64, minimum: 0 } - purchaseWarehouseId: { $ref: '#/components/schemas/NullableTextPatch' } - purchaseWarehouseName: { $ref: '#/components/schemas/NullableTextPatch' } - domesticCarrier: { $ref: '#/components/schemas/NullableTextPatch' } - domesticTrackingNo: { $ref: '#/components/schemas/NullableTextPatch' } - overseasCarrier: { $ref: '#/components/schemas/NullableTextPatch' } - overseasTrackingNo: { $ref: '#/components/schemas/NullableTextPatch' } - estimatedArrivalAt: { type: string, format: date-time, nullable: true, description: 缺失不修改;null 或空串清空 } remark: { $ref: '#/components/schemas/NullableTextPatch' } - outboundDetails: - type: array - description: 出库明细,可一次提交多条;总出库量不得超过入库量 - items: - type: object - required: [quantity] - properties: - id: { type: string } - quantity: { type: integer, format: int64, minimum: 0 } - carrier: { type: string, nullable: true, maxLength: 128 } - trackingNo: { type: string, nullable: true, maxLength: 128 } - shippedAt: { type: string, format: date-time, nullable: true } - remark: { type: string, nullable: true, maxLength: 500 } CustomerOptionResponse: type: object properties: diff --git a/lastleg-service/src/main/resources/openapi/overseas-finance-fees-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-finance-fees-v1.yaml index 617b106ca..1257dc662 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-finance-fees-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-finance-fees-v1.yaml @@ -13,7 +13,7 @@ paths: responses: { '200': { description: '当前版本列表;voidStatus为NOT_REQUESTED、PENDING_FINANCE、FINANCE_ATTENTION或CONFIRMED,批量查询作废确认状态,不逐行读取。等待或拒绝时sourceStatus保留ACTIVE,只有Finance持久化确认后为VOIDED。' } } post: summary: 创建海外业务费用来源草稿 - description: 客户和业务编号必填;入仓ID、入仓码均按业务事实选填,不得为补字段伪造。海外货柜费用必须以相同的businessId和containerBookingId引用真实来源柜,且businessNo与containerNo均为该柜真实集装箱号。 + description: 采购费用仅由采购履约或财务节点实扣后自动发布,本入口拒绝采购来源或采购业务类型。客户和业务编号必填;入仓ID、入仓码均按业务事实选填,不得为补字段伪造。海外货柜费用必须以相同的businessId和containerBookingId引用真实来源柜,且businessNo与containerNo均为该柜真实集装箱号。 security: [{ bearerAuth: [] }] parameters: [{ in: header, name: Idempotency-Key, required: true, schema: { type: string, maxLength: 128 } }] requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/SaveRequest' } } } } @@ -34,7 +34,7 @@ paths: /api/platform/v1/last-leg/finance-fee-sources/{sourceFeeId}/{action}: post: summary: 提交、审核、调整或作废海外业务费用来源 - description: 审核通过与作废均事务性写入Outbox;此处作废仅指申请,直接可靠投递至独立Finance;action为submit、review、adjust、void。作废申请不立即更改sourceStatus,voidStatus为PENDING_FINANCE;等待原发布成功后才投递作废,收到匹配APPLIED/DUPLICATE确认时在投递审计事务内更新VOIDED。失败/结果未知仍保留原来源,拒绝或投递耗尽显示FINANCE_ATTENTION,禁止未决作废期间调整版本。 + description: 采购来源不接受通用提交、审核、调整或作废,须回到采购财务节点处理。审核通过与作废均事务性写入Outbox;此处作废仅指申请,直接可靠投递至独立Finance;action为submit、review、adjust、void。作废申请不立即更改sourceStatus,voidStatus为PENDING_FINANCE;等待原发布成功后才投递作废,收到匹配APPLIED/DUPLICATE确认时在投递审计事务内更新VOIDED。失败/结果未知仍保留原来源,拒绝或投递耗尽显示FINANCE_ATTENTION,禁止未决作废期间调整版本。 security: [{ bearerAuth: [] }] parameters: - { in: path, name: sourceFeeId, required: true, schema: { type: string } } @@ -70,8 +70,8 @@ components: required: [customerId, sourceType, businessType, businessNo, feeItemCode, feeItemName, direction, businessDate, currency, amount] properties: customerId: { type: string } - sourceType: { type: string, enum: [OVERSEAS_INBOUND_FEE, OVERSEAS_PROCUREMENT_FEE, OVERSEAS_TRUCK_FEE, OVERSEAS_LOOSEBOARD_FEE, OVERSEAS_WAREHOUSE_FEE, OVERSEAS_CONTAINER_FEE, OVERSEAS_ORDER_FEE, OVERSEAS_DELIVERY_FEE, OVERSEAS_RELABEL_FEE] } - businessType: { $ref: '#/components/schemas/BusinessType' } + sourceType: { type: string, enum: [OVERSEAS_INBOUND_FEE, OVERSEAS_TRUCK_FEE, OVERSEAS_LOOSEBOARD_FEE, OVERSEAS_WAREHOUSE_FEE, OVERSEAS_CONTAINER_FEE, OVERSEAS_ORDER_FEE, OVERSEAS_DELIVERY_FEE, OVERSEAS_RELABEL_FEE] } + businessType: { type: string, enum: [OVERSEAS_INBOUND, US_TRUCK, LOCAL_LOOSEBOARD, WAREHOUSE_SERVICE, OVERSEAS_CONTAINER, LASTLEG_ORDER, DELIVERY_ORDER, RELABEL_ORDER] } businessId: { type: string, pattern: '^[1-9][0-9]*$', nullable: true } businessNo: { type: string, maxLength: 128 } containerBookingId: { type: string, pattern: '^[1-9][0-9]*$', nullable: true } diff --git a/lastleg-service/src/main/resources/openapi/procurement-fulfillment-v1.yaml b/lastleg-service/src/main/resources/openapi/procurement-fulfillment-v1.yaml new file mode 100644 index 000000000..159d70577 --- /dev/null +++ b/lastleg-service/src/main/resources/openapi/procurement-fulfillment-v1.yaml @@ -0,0 +1,3505 @@ +openapi: 3.0.3 +info: + title: 采购样品履约节点 API + version: 1.0.0 + description: 客户范围、节点资格、版本与幂等由服务端校验;购买事实与资金结果分开保存。 +security: +- bearerAuth: [] +paths: + /api/platform/v1/last-leg/overseas-procurements/{orderNo}/fulfillment: + get: + summary: 读取采购履约与货款结果 + description: 权限 platform:overseas-business:purchase:list;平台主体。真实已发生的购买超预算或缺汇率时保留事实并返回 purchase.issue,不伪造扣款成功。 + parameters: + - name: orderNo + in: path + required: true + schema: + type: string + - name: customerId + in: query + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + responses: + '200': + description: 统一业务响应,须检查 code;成功不等于资金已扣款 + content: + application/json: + schema: + $ref: '#/components/schemas/Response' + '401': + description: 未认证 + '403': + description: 主体或动作权限不允许 + /api/platform/v1/last-leg/overseas-procurements/{orderNo}/purchase: + post: + summary: 登记真实购买并核定样品货款 + description: 权限 platform:overseas-business:purchase:buy;平台主体。真实已发生的购买超预算或缺汇率时保留事实并返回 purchase.issue,不伪造扣款成功。 + parameters: + - name: orderNo + in: path + required: true + schema: + type: string + - name: customerId + in: query + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Purchase' + responses: + '200': + description: 统一业务响应,须检查 code;成功不等于资金已扣款 + content: + application/json: + schema: + $ref: '#/components/schemas/Response' + '401': + description: 未认证 + '403': + description: 主体或动作权限不允许 + /api/platform/v1/last-leg/overseas-procurements/{orderNo}/purchase/confirm: + post: + summary: 补齐授权或汇率后核定原购买 + description: 权限 platform:overseas-business:purchase:buy;平台主体。真实已发生的购买超预算或缺汇率时保留事实并返回 purchase.issue,不伪造扣款成功。 + parameters: + - name: orderNo + in: path + required: true + schema: + type: string + - name: customerId + in: query + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Command' + responses: + '200': + description: 统一业务响应,须检查 code;成功不等于资金已扣款 + content: + application/json: + schema: + $ref: '#/components/schemas/Response' + '401': + description: 未认证 + '403': + description: 主体或动作权限不允许 + /api/platform/v1/last-leg/overseas-procurements/{orderNo}/budget-authorizations: + post: + summary: 核验客户预算上限授权 + description: 权限 platform:overseas-business:purchase:review;平台主体。真实已发生的购买超预算或缺汇率时保留事实并返回 purchase.issue,不伪造扣款成功。 + parameters: + - name: orderNo + in: path + required: true + schema: + type: string + - name: customerId + in: query + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Budget' + responses: + '200': + description: 统一业务响应,须检查 code;成功不等于资金已扣款 + content: + application/json: + schema: + $ref: '#/components/schemas/Response' + '401': + description: 未认证 + '403': + description: 主体或动作权限不允许 + /api/platform/v1/last-leg/overseas-procurements/{orderNo}/planned-warehouse: + post: + summary: 首次实收前选择计划采购仓 + description: 权限 platform:overseas-business:purchase:review;平台主体。真实已发生的购买超预算或缺汇率时保留事实并返回 purchase.issue,不伪造扣款成功。 + parameters: + - name: orderNo + in: path + required: true + schema: + type: string + - name: customerId + in: query + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Warehouse' + responses: + '200': + description: 统一业务响应,须检查 code;成功不等于资金已扣款 + content: + application/json: + schema: + $ref: '#/components/schemas/Response' + '401': + description: 未认证 + '403': + description: 主体或动作权限不允许 + /api/platform/v1/last-leg/overseas-procurements/{orderNo}/cancel: + post: + summary: 购买前取消采购订单 + description: 权限 platform:overseas-business:purchase:cancel;平台主体。真实已发生的购买超预算或缺汇率时保留事实并返回 purchase.issue,不伪造扣款成功。 + parameters: + - name: orderNo + in: path + required: true + schema: + type: string + - name: customerId + in: query + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Command' + responses: + '200': + description: 统一业务响应,须检查 code;成功不等于资金已扣款 + content: + application/json: + schema: + $ref: '#/components/schemas/Response' + '401': + description: 未认证 + '403': + description: 主体或动作权限不允许 + /api/platform/v1/last-leg/overseas-procurements/{orderNo}/physical: + get: + summary: 查询采购实收与当前装箱版本 + description: 权限 platform:overseas-business:purchase:list;平台身份、订单范围、版本、幂等及节点准入均由服务端复验。 + parameters: + - name: orderNo + in: path + required: true + schema: + type: string + - name: customerId + in: query + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + responses: + '200': + description: 检查业务code;返回最新采购实收及装箱快照 + content: + application/json: + schema: + $ref: '#/components/schemas/PhysicalResponse' + '403': + description: 无动作权限 + /api/platform/v1/last-leg/overseas-procurements/{orderNo}/overseas-receipts: + post: + summary: 登记海外分次实收 + description: 权限 platform:overseas-business:purchase:receive-overseas;平台身份、订单范围、版本、幂等及节点准入均由服务端复验。 + parameters: + - name: orderNo + in: path + required: true + schema: + type: string + - name: customerId + in: query + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + responses: + '200': + description: 检查业务code;返回最新采购实收及装箱快照 + content: + application/json: + schema: + $ref: '#/components/schemas/PhysicalResponse' + '403': + description: 无动作权限 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Receipt' + /api/platform/v1/last-leg/overseas-procurements/{orderNo}/domestic-receipts: + post: + summary: 按原交寄箱登记国内实收 + description: 权限 platform:overseas-business:purchase:receive-domestic;平台身份、订单范围、版本、幂等及节点准入均由服务端复验。 + parameters: + - name: orderNo + in: path + required: true + schema: + type: string + - name: customerId + in: query + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + responses: + '200': + description: 检查业务code;返回最新采购实收及装箱快照 + content: + application/json: + schema: + $ref: '#/components/schemas/PhysicalResponse' + '403': + description: 无动作权限 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Receipt' + /api/platform/v1/last-leg/overseas-procurements/{orderNo}/overseas-receipts/complete: + post: + summary: 确认海外收货完成 + description: 权限 platform:overseas-business:purchase:receive-overseas;平台身份、订单范围、版本、幂等及节点准入均由服务端复验。 + parameters: + - name: orderNo + in: path + required: true + schema: + type: string + - name: customerId + in: query + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + responses: + '200': + description: 检查业务code;返回最新采购实收及装箱快照 + content: + application/json: + schema: + $ref: '#/components/schemas/PhysicalResponse' + '403': + description: 无动作权限 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/FinishReceipt' + /api/platform/v1/last-leg/overseas-procurements/{orderNo}/domestic-receipts/complete: + post: + summary: 确认国内收货完成 + description: 权限 platform:overseas-business:purchase:receive-domestic;平台身份、订单范围、版本、幂等及节点准入均由服务端复验。 + parameters: + - name: orderNo + in: path + required: true + schema: + type: string + - name: customerId + in: query + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + responses: + '200': + description: 检查业务code;返回最新采购实收及装箱快照 + content: + application/json: + schema: + $ref: '#/components/schemas/PhysicalResponse' + '403': + description: 无动作权限 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/FinishReceipt' + /api/platform/v1/last-leg/overseas-procurements/{orderNo}/dispositions: + post: + summary: 确认单内差异处置 + description: 权限 platform:overseas-business:purchase:manage;平台身份、订单范围、版本、幂等及节点准入均由服务端复验。 + parameters: + - name: orderNo + in: path + required: true + schema: + type: string + - name: customerId + in: query + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + responses: + '200': + description: 检查业务code;返回最新采购实收及装箱快照 + content: + application/json: + schema: + $ref: '#/components/schemas/PhysicalResponse' + '403': + description: 无动作权限 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Disposition' + /api/platform/v1/last-leg/overseas-procurements/{orderNo}/packing: + post: + summary: 保存新的装箱清单版本 + description: 权限 platform:overseas-business:purchase:pack;平台身份、订单范围、版本、幂等及节点准入均由服务端复验。 + parameters: + - name: orderNo + in: path + required: true + schema: + type: string + - name: customerId + in: query + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + responses: + '200': + description: 检查业务code;返回最新采购实收及装箱快照 + content: + application/json: + schema: + $ref: '#/components/schemas/PhysicalResponse' + '403': + description: 无动作权限 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Packing' + /api/platform/v1/last-leg/overseas-procurements/{orderNo}/packing/confirm: + post: + summary: 确认整批装箱 + description: 权限 platform:overseas-business:purchase:pack;平台身份、订单范围、版本、幂等及节点准入均由服务端复验。 + parameters: + - name: orderNo + in: path + required: true + schema: + type: string + - name: customerId + in: query + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + responses: + '200': + description: 检查业务code;返回最新采购实收及装箱快照 + content: + application/json: + schema: + $ref: '#/components/schemas/PhysicalResponse' + '403': + description: 无动作权限 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ConfirmPacking' + /api/platform/v1/last-leg/overseas-procurements/{orderNo}/packing/withdraw: + post: + summary: 撤回尚未交寄的确认清单 + description: 权限 platform:overseas-business:purchase:manage;平台身份、订单范围、版本、幂等及节点准入均由服务端复验。 + parameters: + - name: orderNo + in: path + required: true + schema: + type: string + - name: customerId + in: query + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + responses: + '200': + description: 检查业务code;返回最新采购实收及装箱快照 + content: + application/json: + schema: + $ref: '#/components/schemas/PhysicalResponse' + '403': + description: 无动作权限 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Command' + /api/platform/v1/last-leg/overseas-procurements/{orderNo}/receipts/{receiptId}/correct: + post: + summary: 保留原记录并受控更正实收 + description: 权限 platform:overseas-business:purchase:manage;平台身份、订单范围、版本、幂等及节点准入均由服务端复验。 + parameters: + - name: orderNo + in: path + required: true + schema: + type: string + - name: customerId + in: query + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + - name: receiptId + in: path + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + responses: + '200': + description: 检查业务code;返回最新采购实收及装箱快照 + content: + application/json: + schema: + $ref: '#/components/schemas/PhysicalResponse' + '403': + description: 无动作权限 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Correction' + /api/platform/v1/last-leg/overseas-procurements/{orderNo}/dispositions/{dispositionId}/revoke: + post: + summary: 凭证支持的差异处置更正 + description: 权限 platform:overseas-business:purchase:manage;平台身份、订单范围、版本、幂等及节点准入均由服务端复验。 + parameters: + - name: orderNo + in: path + required: true + schema: + type: string + - name: customerId + in: query + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + - name: dispositionId + in: path + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + responses: + '200': + description: 检查业务code;返回最新采购实收及装箱快照 + content: + application/json: + schema: + $ref: '#/components/schemas/PhysicalResponse' + '403': + description: 无动作权限 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/RevokeDisposition' + /api/platform/v1/last-leg/overseas-procurements/{orderNo}/transport: + get: + summary: 读取两段面单及实际交寄事实 + description: 权限 platform:overseas-business:purchase:list。平台内部资料,不直接对客输出。 + parameters: + - name: orderNo + in: path + required: true + schema: + type: string + minLength: 1 + maxLength: 64 + - name: customerId + in: query + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + responses: + '200': + description: 业务结果,须检查code;资金处理中不代表已完成 + content: + application/json: + schema: + $ref: '#/components/schemas/TransportResponse' + '400': + description: 字段、来源或数量不合法 + '403': + description: 主体或动作权限不足 + '409': + description: 版本冲突、重复发运或节点尚未满足 + /api/platform/v1/last-leg/overseas-procurements/{orderNo}/labels: + post: + summary: 保存本段全批面单新版本 + description: 权限 platform:overseas-business:purchase:label。清单、文件、页码与全部可寄原箱关联;国际寄件取实际仓主档。国内改最终收件信息须有本单EXCEPTION客户确认凭证。新版本不沿用旧贴单。 + parameters: + - name: orderNo + in: path + required: true + schema: + type: string + minLength: 1 + maxLength: 64 + - name: customerId + in: query + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + responses: + '200': + description: 业务结果,须检查code;资金处理中不代表已完成 + content: + application/json: + schema: + $ref: '#/components/schemas/TransportResponse' + '400': + description: 字段、来源或数量不合法 + '403': + description: 主体或动作权限不足 + '409': + description: 版本冲突、重复发运或节点尚未满足 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Labels' + /api/platform/v1/last-leg/overseas-procurements/{orderNo}/international-estimate: + post: + summary: 核对国际预估并申请冻结或释放 + description: 权限 platform:overseas-business:purchase:estimate。仅CNY。同金额可显式沿用原FROZEN/ZERO;变价先申请释放,等待成功后以新请求确认新金额,未决不得交寄。 + parameters: + - name: orderNo + in: path + required: true + schema: + type: string + minLength: 1 + maxLength: 64 + - name: customerId + in: query + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + responses: + '200': + description: 业务结果,须检查code;资金处理中不代表已完成 + content: + application/json: + schema: + $ref: '#/components/schemas/TransportResponse' + '400': + description: 字段、来源或数量不合法 + '403': + description: 主体或动作权限不足 + '409': + description: 版本冲突、重复发运或节点尚未满足 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Estimate' + /api/platform/v1/last-leg/overseas-procurements/{orderNo}/labels/apply: + post: + summary: 逐箱登记真实贴单 + description: 权限 platform:overseas-business:purchase:apply-label。仅海外段;可以分次贴单,不能重复记录相同版本相同箱。 + parameters: + - name: orderNo + in: path + required: true + schema: + type: string + minLength: 1 + maxLength: 64 + - name: customerId + in: query + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + responses: + '200': + description: 业务结果,须检查code;资金处理中不代表已完成 + content: + application/json: + schema: + $ref: '#/components/schemas/TransportResponse' + '400': + description: 字段、来源或数量不合法 + '403': + description: 主体或动作权限不足 + '409': + description: 版本冲突、重复发运或节点尚未满足 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ApplyLabels' + /api/platform/v1/last-leg/overseas-procurements/{orderNo}/domestic-repack: + post: + summary: 国内原箱一对一换包装 + description: 权限 platform:overseas-business:purchase:receive-domestic。不改变来源及件数,不拆箱合箱;使旧国内面单失效,并要求重新核对国内费用。 + parameters: + - name: orderNo + in: path + required: true + schema: + type: string + minLength: 1 + maxLength: 64 + - name: customerId + in: query + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + responses: + '200': + description: 业务结果,须检查code;资金处理中不代表已完成 + content: + application/json: + schema: + $ref: '#/components/schemas/TransportResponse' + '400': + description: 字段、来源或数量不合法 + '403': + description: 主体或动作权限不足 + '409': + description: 版本冲突、重复发运或节点尚未满足 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Repack' + /api/platform/v1/last-leg/overseas-procurements/{orderNo}/overseas-shipment: + post: + summary: 确认整批海外实际交寄 + description: 权限 platform:overseas-business:purchase:ship-overseas。leg固定OVERSEAS;全部箱、当前面单、逐箱贴单、样品实扣关联、唯一有效预估均须通过。 + parameters: + - name: orderNo + in: path + required: true + schema: + type: string + minLength: 1 + maxLength: 64 + - name: customerId + in: query + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + responses: + '200': + description: 业务结果,须检查code;资金处理中不代表已完成 + content: + application/json: + schema: + $ref: '#/components/schemas/TransportResponse' + '400': + description: 字段、来源或数量不合法 + '403': + description: 主体或动作权限不足 + '409': + description: 版本冲突、重复发运或节点尚未满足 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Ship' + /api/platform/v1/last-leg/overseas-procurements/{orderNo}/customer-shipment: + post: + summary: 确认整批国内寄客户 + description: 权限 platform:overseas-business:purchase:ship-customer。leg固定DOMESTIC;核费及全部资金完成才可交寄。每单每段最多一次,重复不同请求不能再发货。 + parameters: + - name: orderNo + in: path + required: true + schema: + type: string + minLength: 1 + maxLength: 64 + - name: customerId + in: query + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + responses: + '200': + description: 业务结果,须检查code;资金处理中不代表已完成 + content: + application/json: + schema: + $ref: '#/components/schemas/TransportResponse' + '400': + description: 字段、来源或数量不合法 + '403': + description: 主体或动作权限不足 + '409': + description: 版本冲突、重复发运或节点尚未满足 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Ship' + /api/platform/v1/last-leg/overseas-procurements/{orderNo}/files/{category}: + post: + summary: 上传采购内部附件 + description: 按类别独立授权:PURCHASE=buy;BUDGET=review;LABEL=label;EXCEPTION=manage;RECEIPT=receive-overseas或receive-domestic;VOUCHER=charge或finance-adjust。新增四类只允许PDF/JPG/PNG,最大10MiB。 + parameters: + - name: orderNo + in: path + required: true + schema: + type: string + minLength: 1 + maxLength: 64 + - name: customerId + in: query + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + - name: category + in: path + required: true + schema: + type: string + enum: + - RECEIPT + - VOUCHER + - LABEL + - PURCHASE + - BUDGET + - EXCEPTION + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: + - file + properties: + file: + type: string + format: binary + responses: + '200': + description: 本单附件元数据业务响应 + '403': + description: 主体或类别权限不足 + /api/platform/v1/last-leg/overseas-procurements/{orderNo}/files/{fileId}/content: + get: + summary: 读取本采购单内部文件 + description: 权限 platform:overseas-business:purchase:list;校验客户、订单、分类与文件归属。附件下载,Cache-Control no-store。客户入口不开放这些内部文件。 + parameters: + - name: orderNo + in: path + required: true + schema: + type: string + minLength: 1 + maxLength: 64 + - name: customerId + in: query + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + - name: fileId + in: path + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + responses: + '200': + description: 文件内容 + content: + application/octet-stream: + schema: + type: string + format: binary + '400': + description: 文件不属于本单 + '403': + description: 无平台采购只读权限 + /api/platform/v1/last-leg/overseas-procurements/{orderNo}/charges: + get: + summary: 读取核费与资金处理结果 + description: 权限 platform:overseas-business:purchase:list。仅平台使用,内部费用及诊断字段不得直接透传客户。 + parameters: + - name: orderNo + in: path + required: true + schema: + type: string + minLength: 1 + maxLength: 64 + - name: customerId + in: query + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + responses: + '200': + description: 业务结果,须检查code + content: + application/json: + schema: + $ref: '#/components/schemas/ChargesResponse' + '400': + description: 参数或本单费用归属错误 + '403': + description: 主体或动作权限不足 + '409': + description: 版本冲突、原请求未决或节点未满足 + /api/platform/v1/last-leg/overseas-procurements/{orderNo}/fee-corrections: + get: + summary: 读取费用链更正历史 + description: 权限 platform:overseas-business:purchase:list。不可变目标、原汇率、确认时净额及产生的逐笔差额或退款业务号。 + parameters: + - name: orderNo + in: path + required: true + schema: + type: string + minLength: 1 + maxLength: 64 + - name: customerId + in: query + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + responses: + '200': + description: 业务结果,须检查code + content: + application/json: + schema: + $ref: '#/components/schemas/FeeCorrectionsResponse' + '400': + description: 参数或本单费用归属错误 + '403': + description: 主体或动作权限不足 + '409': + description: 版本冲突、原请求未决或节点未满足 + /api/platform/v1/last-leg/overseas-procurements/{orderNo}/charges/confirm: + post: + summary: 确认国内整单费用 + description: 权限 platform:overseas-business:purchase:charge。须国内实收完成。三项固定费用明确核定,其他不适用不产生虚构收费行;重核引用原feeId不再次扣款,不可省略已有有效实际费用。确认不等于资金成功。 + parameters: + - name: orderNo + in: path + required: true + schema: + type: string + minLength: 1 + maxLength: 64 + - name: customerId + in: query + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + responses: + '200': + description: 业务结果,须检查code + content: + application/json: + schema: + $ref: '#/components/schemas/ChargesResponse' + '400': + description: 参数或本单费用归属错误 + '403': + description: 主体或动作权限不足 + '409': + description: 版本冲突、原请求未决或节点未满足 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ConfirmCharges' + /api/platform/v1/last-leg/overseas-procurements/{orderNo}/fees/{feeId}/retry: + post: + summary: 重试已明确失败的费用请求 + description: 样品货款要求buy,预估要求estimate,实际国际运费/关税/国内运费/其他要求charge,调整/退款要求finance-adjust;未知结果拒绝新请求,已冻结仅重试结算。 + parameters: + - name: orderNo + in: path + required: true + schema: + type: string + minLength: 1 + maxLength: 64 + - name: customerId + in: query + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + - name: feeId + in: path + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + responses: + '200': + description: 业务结果,须检查code + content: + application/json: + schema: + $ref: '#/components/schemas/ChargesResponse' + '400': + description: 参数或本单费用归属错误 + '403': + description: 主体或动作权限不足 + '409': + description: 版本冲突、原请求未决或节点未满足 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Command' + /api/platform/v1/last-leg/overseas-procurements/{orderNo}/fees/{feeId}/terminate: + post: + summary: 终止明确失败的费用版本 + description: 权限 platform:overseas-business:purchase:finance-adjust。终止旧版本重试资格;有效冻结必须释放成功后才可替代,重新核对整单费用。 + parameters: + - name: orderNo + in: path + required: true + schema: + type: string + minLength: 1 + maxLength: 64 + - name: customerId + in: query + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + - name: feeId + in: path + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + responses: + '200': + description: 业务结果,须检查code + content: + application/json: + schema: + $ref: '#/components/schemas/ChargesResponse' + '400': + description: 参数或本单费用归属错误 + '403': + description: 主体或动作权限不足 + '409': + description: 版本冲突、原请求未决或节点未满足 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Command' + /api/platform/v1/last-leg/overseas-procurements/{orderNo}/fees/{feeId}/correct-target: + post: + summary: 按正确原币总额更正费用链 + description: 权限 platform:overseas-business:purchase:finance-adjust。选择原实际费用根。沿用原汇率计算正确CNY总额,以成功原扣+成功差额-成功退款求净额;正差额新扣、负差额逐笔关联退款,未决旧调整阻断。CNY差额单自身汇率为1,目标原币汇率保留在更正快照,不因分差反推原币。已寄客户不回退履约。 + parameters: + - name: orderNo + in: path + required: true + schema: + type: string + minLength: 1 + maxLength: 64 + - name: customerId + in: query + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + - name: feeId + in: path + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + responses: + '200': + description: 业务结果,须检查code + content: + application/json: + schema: + $ref: '#/components/schemas/FeeCorrectionResponse' + '400': + description: 参数或本单费用归属错误 + '403': + description: 主体或动作权限不足 + '409': + description: 版本冲突、原请求未决或节点未满足 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CorrectFeeTarget' + /api/platform/v1/last-leg/overseas-procurements/{orderNo}/fees/additional: + post: + summary: 登记独立新发生费用 + description: 权限 platform:overseas-business:purchase:finance-adjust。仅已发生购买订单;按新费用真实业务日冻结汇率,以独立费用号发起扣款,核定入口为 FINANCIAL。保留原费用;普通核费首次确认后只引用已有版本。未交寄订单需重新核费,已交寄事实不回退。 + parameters: + - name: orderNo + in: path + required: true + schema: + type: string + minLength: 1 + maxLength: 64 + - name: customerId + in: query + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + responses: + '200': + description: 业务结果,须检查code + content: + application/json: + schema: + $ref: '#/components/schemas/FeeVersionResponse' + '400': + description: 参数或本单费用归属错误 + '403': + description: 主体或动作权限不足 + '409': + description: 版本冲突、原请求未决或节点未满足 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AdditionalFee' + /api/platform/v1/last-leg/overseas-procurements/{orderNo}/fees/{feeId}/replace-failed: + post: + summary: 重新核定已终止且未实扣的实际费用 + description: 权限 platform:overseas-business:purchase:finance-adjust。仅允许最新原实际费用已明确失败、终止重试且有效冻结已释放后替代;拒绝已实扣、未决或已有后续版本。沿用原币、业务日和汇率快照,使用新资金业务身份;货款仍核验客户预算。以更正记录关联旧版本与新实际费用,零元不发起资金请求。原购买与履约事实不回退。 + parameters: + - name: orderNo + in: path + required: true + schema: + type: string + minLength: 1 + maxLength: 64 + - name: customerId + in: query + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + - name: feeId + in: path + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + responses: + '200': + description: 业务结果,须检查code + content: + application/json: + schema: + $ref: '#/components/schemas/FeeCorrectionResponse' + '400': + description: 参数或本单费用归属错误 + '403': + description: 主体或动作权限不足 + '409': + description: 版本冲突、原请求未决或节点未满足 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CorrectFeeTarget' + /api/platform/v1/last-leg/overseas-procurements/{orderNo}/fees/{feeId}/refund: + post: + summary: 关联原实扣退回客户钱包 + description: 权限 platform:overseas-business:purchase:finance-adjust。amount为正额CNY。仅本单已结算消费可退;整链有未完成调整/退款时不接受新请求,重放原请求仍返回原退款。累计金额由Finance原子封顶,不走外部支付。 + parameters: + - name: orderNo + in: path + required: true + schema: + type: string + minLength: 1 + maxLength: 64 + - name: customerId + in: query + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + - name: feeId + in: path + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + responses: + '200': + description: 业务结果,须检查code + content: + application/json: + schema: + $ref: '#/components/schemas/FeeVersionResponse' + '400': + description: 参数或本单费用归属错误 + '403': + description: 主体或动作权限不足 + '409': + description: 版本冲突、原请求未决或节点未满足 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/RefundFee' + /api/platform/v1/last-leg/overseas-procurements/{orderNo}/controls: + get: + summary: 读取异常、更正与剩余货物来源 + description: 权限 platform:overseas-business:purchase:list。内部记录,剩余来源用于逐项填写最终去向。 + parameters: + - name: orderNo + in: path + required: true + schema: + type: string + minLength: 1 + maxLength: 64 + - name: customerId + in: query + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + responses: + '200': + description: 统一业务响应,须检查code + content: + application/json: + schema: + $ref: '#/components/schemas/ControlResponse' + '400': + description: 来源、证据或数量不匹配 + '403': + description: 无当前操作权限 + '409': + description: 版本、实物或资金前置不满足 + /api/platform/v1/last-leg/overseas-procurements/{orderNo}/control-notes: + post: + summary: 追加事实更正说明或阻断异常 + description: 权限 platform:overseas-business:purchase:manage。校验本单事实和EXCEPTION凭证;已交寄允许追加说明,不改原货物、运单或状态。 + parameters: + - name: orderNo + in: path + required: true + schema: + type: string + minLength: 1 + maxLength: 64 + - name: customerId + in: query + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + responses: + '200': + description: 统一业务响应,须检查code + content: + application/json: + schema: + $ref: '#/components/schemas/ControlResponse' + '400': + description: 来源、证据或数量不匹配 + '403': + description: 无当前操作权限 + '409': + description: 版本、实物或资金前置不满足 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ControlNote' + /api/platform/v1/last-leg/overseas-procurements/{orderNo}/control-notes/{noteId}/resolve: + post: + summary: 有凭证解除采购阻断异常 + description: 权限 platform:overseas-business:purchase:manage。不覆盖原说明,记录处理人和证据;不改变资金结果。 + parameters: + - name: orderNo + in: path + required: true + schema: + type: string + minLength: 1 + maxLength: 64 + - name: customerId + in: query + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + - name: noteId + in: path + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + responses: + '200': + description: 统一业务响应,须检查code + content: + application/json: + schema: + $ref: '#/components/schemas/ControlResponse' + '400': + description: 来源、证据或数量不匹配 + '403': + description: 无当前操作权限 + '409': + description: 版本、实物或资金前置不满足 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ResolveControl' + /api/platform/v1/last-leg/overseas-procurements/{orderNo}/actual-warehouse/correct: + post: + summary: 受控更正实际收货仓 + description: 权限 platform:overseas-business:purchase:manage。未有后续箱明细时更新有效实际仓并留原快照及原实收;已有后续实物或已结束时仅追加阻断说明,不重归属货物。 + parameters: + - name: orderNo + in: path + required: true + schema: + type: string + minLength: 1 + maxLength: 64 + - name: customerId + in: query + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + responses: + '200': + description: 统一业务响应,须检查code + content: + application/json: + schema: + $ref: '#/components/schemas/ControlResponse' + '400': + description: 来源、证据或数量不匹配 + '403': + description: 无当前操作权限 + '409': + description: 版本、实物或资金前置不满足 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CorrectWarehouse' + /api/platform/v1/last-leg/overseas-procurements/{orderNo}/exception-close: + post: + summary: 实物资金收口后异常关闭 + description: 权限 platform:overseas-business:purchase:manage。购买后逐来源核平剩余货物最终去向,并提供客户停止履约证据、财务结论及对客摘要。全部已知资金与费用来源处理结束且无阻断异常才可关闭;不退款、不生成退货或零数量发运。 + parameters: + - name: orderNo + in: path + required: true + schema: + type: string + minLength: 1 + maxLength: 64 + - name: customerId + in: query + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + responses: + '200': + description: 统一业务响应,须检查code + content: + application/json: + schema: + $ref: '#/components/schemas/ControlResponse' + '400': + description: 来源、证据或数量不匹配 + '403': + description: 无当前操作权限 + '409': + description: 版本、实物或资金前置不满足 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ExceptionClose' + /api/platform/v1/last-leg/overseas-procurements/{orderNo}/fees/{feeId}/release-estimate: + post: + summary: 释放不再使用的预估冻结 + description: 权限 platform:overseas-business:purchase:finance-adjust。只作用于本单有效预估;未决或已实际结算不允许释放。供异常处理使用,不能将实际消费当冻结释放。 + parameters: + - name: orderNo + in: path + required: true + schema: + type: string + minLength: 1 + maxLength: 64 + - name: customerId + in: query + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + - name: feeId + in: path + required: true + schema: + type: string + pattern: ^[1-9][0-9]*$ + responses: + '200': + description: 统一业务响应,须检查code + content: + application/json: + schema: + $ref: '#/components/schemas/ChargesResponse' + '400': + description: 来源、证据或数量不匹配 + '403': + description: 无当前操作权限 + '409': + description: 版本、实物或资金前置不满足 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Command' +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + schemas: + Command: + type: object + required: + - version + - idempotencyKey + - reason + properties: + version: + type: integer + format: int64 + minimum: 0 + idempotencyKey: + type: string + minLength: 1 + maxLength: 128 + reason: + type: string + minLength: 1 + maxLength: 1000 + Purchase: + allOf: + - $ref: '#/components/schemas/Command' + - type: object + required: + - productName + - quantity + - amount + - currency + - purchasedAt + - estimatedArrivalAt + - voucherFileId + properties: + productName: + type: string + minLength: 1 + maxLength: 255 + specification: + type: string + nullable: true + maxLength: 500 + quantity: + type: integer + format: int64 + minimum: 1 + amount: + type: number + minimum: 0 + multipleOf: 0.01 + currency: + type: string + enum: + - CNY + - USD + purchasedAt: + type: string + format: date-time + description: 真实购买时间,不得在未来 + estimatedArrivalAt: + type: string + format: date-time + voucherFileId: + type: string + description: 必须属于本订单的购买或费用凭证 + Budget: + allOf: + - $ref: '#/components/schemas/Command' + - type: object + required: + - amount + - currency + - voucherFileId + properties: + amount: + type: number + minimum: 0 + multipleOf: 0.01 + description: 新授权货款总上限 + currency: + type: string + enum: + - CNY + - USD + voucherFileId: + type: string + description: 本订单客户书面或聊天确认凭证 + Warehouse: + allOf: + - $ref: '#/components/schemas/Command' + - type: object + required: + - warehouseId + properties: + warehouseId: + type: string + description: 国内权威目录的有效公共海外收货仓引用 + Response: + type: object + properties: + code: + type: string + message: + type: string + data: + type: object + properties: + orderNo: + type: string + version: + type: integer + format: int64 + status: + type: string + purchase: + type: object + properties: + recorded: + type: boolean + confirmed: + type: boolean + description: 货款已核定,不代表已扣款 + issue: + type: string + nullable: true + feeNo: + type: string + nullable: true + walletStatus: + type: string + nullable: true + sourceLinked: + type: boolean + description: 已实扣费用成功入池关联或明确免费 + Receipt: + allOf: + - $ref: '#/components/schemas/Command' + - type: object + required: + - leg + - receivedAt + properties: + leg: + type: string + enum: + - OVERSEAS + - DOMESTIC + warehouseId: + type: string + pattern: ^[1-9][0-9]*$ + nullable: true + description: 海外段必填的实际公共收货仓,国内为空 + sourceCartonId: + type: string + pattern: ^[1-9][0-9]*$ + nullable: true + description: 国内段必填的本单原国际交寄箱,海外为空 + goodQuantity: + type: integer + format: int64 + minimum: 0 + damagedQuantity: + type: integer + format: int64 + minimum: 0 + extraQuantity: + type: integer + format: int64 + minimum: 0 + receivedAt: + type: string + format: date-time + FinishReceipt: + allOf: + - $ref: '#/components/schemas/Command' + - type: object + required: + - leg + properties: + leg: + type: string + enum: + - OVERSEAS + - DOMESTIC + Disposition: + allOf: + - $ref: '#/components/schemas/Command' + - type: object + required: + - leg + - kind + - quantity + - decision + - voucherFileId + properties: + leg: + type: string + enum: + - OVERSEAS + - DOMESTIC + sourceCartonId: + type: string + pattern: ^[1-9][0-9]*$ + nullable: true + receiptId: + type: string + pattern: ^[1-9][0-9]*$ + nullable: true + kind: + type: string + enum: + - SHORTAGE + - DAMAGED + - EXTRA + quantity: + type: integer + format: int64 + minimum: 1 + decision: + type: string + enum: + - ACCEPT_SHORTAGE + - DISPOSE_DAMAGED + - RETURN_EXTRA + voucherFileId: + type: string + pattern: ^[1-9][0-9]*$ + Source: + type: object + required: + - receiptId + - quantity + properties: + receiptId: + type: string + pattern: ^[1-9][0-9]*$ + quantity: + type: integer + format: int64 + minimum: 1 + Carton: + type: object + required: + - cartonNo + - lengthCm + - widthCm + - heightCm + - grossWeightKg + - sources + properties: + cartonNo: + type: string + minLength: 1 + maxLength: 64 + lengthCm: + type: number + minimum: 0 + exclusiveMinimum: true + multipleOf: 0.01 + widthCm: + type: number + minimum: 0 + exclusiveMinimum: true + multipleOf: 0.01 + heightCm: + type: number + minimum: 0 + exclusiveMinimum: true + multipleOf: 0.01 + grossWeightKg: + type: number + minimum: 0 + exclusiveMinimum: true + multipleOf: 0.001 + sources: + type: array + minItems: 1 + items: + $ref: '#/components/schemas/Source' + Packing: + allOf: + - $ref: '#/components/schemas/Command' + - type: object + required: + - cartons + properties: + cartons: + type: array + minItems: 1 + items: + $ref: '#/components/schemas/Carton' + ConfirmPacking: + allOf: + - $ref: '#/components/schemas/Command' + - type: object + required: + - packingVersion + properties: + packingVersion: + type: integer + minimum: 1 + Correction: + type: object + required: + - receipt + - voucherFileId + properties: + receipt: + $ref: '#/components/schemas/Receipt' + voucherFileId: + type: string + pattern: ^[1-9][0-9]*$ + RevokeDisposition: + allOf: + - $ref: '#/components/schemas/Command' + - type: object + required: + - voucherFileId + properties: + voucherFileId: + type: string + pattern: ^[1-9][0-9]*$ + PhysicalResponse: + type: object + properties: + code: + type: string + message: + type: string + data: + type: object + properties: + orderNo: + type: string + version: + type: integer + format: int64 + minimum: 0 + status: + type: string + packingVersion: + type: integer + nullable: true + packingStatus: + type: string + nullable: true + enum: + - DRAFT + - CONFIRMED + - SUPERSEDED + receipts: + type: array + items: + type: object + properties: + id: + type: string + pattern: ^[1-9][0-9]*$ + receiptNo: + type: string + leg: + type: string + enum: + - OVERSEAS + - DOMESTIC + sourceCartonId: + type: string + pattern: ^[1-9][0-9]*$ + nullable: true + goodQuantity: + type: integer + format: int64 + minimum: 0 + damagedQuantity: + type: integer + format: int64 + minimum: 0 + extraQuantity: + type: integer + format: int64 + minimum: 0 + receivedAt: + type: string + format: date-time + active: + type: boolean + dispositions: + type: array + description: 平台处置历史,active=0为已撤销记录;不直接对客输出内部依据 + items: + type: object + cartons: + type: array + items: + allOf: + - $ref: '#/components/schemas/Carton' + - type: object + properties: + id: + type: string + pattern: ^[1-9][0-9]*$ + quantity: + type: integer + format: int64 + minimum: 1 + PostalAddress: + type: object + required: + - name + - phone + - address + properties: + name: + type: string + minLength: 1 + maxLength: 128 + phone: + type: string + pattern: ^[+0-9 ()-]{5,64}$ + address: + type: string + minLength: 1 + maxLength: 1000 + Label: + type: object + required: + - cartonId + - fileId + - page + - carrier + - trackingNo + properties: + cartonId: + type: string + pattern: ^[1-9][0-9]*$ + fileId: + type: string + pattern: ^[1-9][0-9]*$ + page: + type: integer + minimum: 1 + carrier: + type: string + minLength: 1 + maxLength: 128 + trackingNo: + type: string + minLength: 1 + maxLength: 128 + Labels: + type: object + required: + - version + - idempotencyKey + - reason + - leg + - packingVersion + - receiver + - labels + properties: + version: + type: integer + format: int64 + minimum: 0 + idempotencyKey: + type: string + minLength: 1 + maxLength: 128 + reason: + type: string + minLength: 1 + maxLength: 1000 + leg: + type: string + enum: + - OVERSEAS + - DOMESTIC + packingVersion: + type: integer + minimum: 1 + receiver: + $ref: '#/components/schemas/PostalAddress' + addressVoucherFileId: + type: string + pattern: ^[1-9][0-9]*$ + nullable: true + labels: + type: array + minItems: 1 + items: + $ref: '#/components/schemas/Label' + Estimate: + type: object + required: + - version + - idempotencyKey + - reason + - labelVersion + - amount + - businessDate + properties: + version: + type: integer + format: int64 + minimum: 0 + idempotencyKey: + type: string + minLength: 1 + maxLength: 128 + reason: + type: string + minLength: 1 + maxLength: 1000 + labelVersion: + type: integer + minimum: 1 + amount: + type: number + minimum: 0 + multipleOf: 0.01 + businessDate: + type: string + format: date + ApplyLabels: + type: object + required: + - version + - idempotencyKey + - reason + - labelVersion + - cartonIds + - appliedAt + properties: + version: + type: integer + format: int64 + minimum: 0 + idempotencyKey: + type: string + minLength: 1 + maxLength: 128 + reason: + type: string + minLength: 1 + maxLength: 1000 + labelVersion: + type: integer + minimum: 1 + cartonIds: + type: array + minItems: 1 + items: + type: string + pattern: ^[1-9][0-9]*$ + uniqueItems: true + appliedAt: + type: string + format: date-time + Ship: + type: object + required: + - version + - idempotencyKey + - reason + - leg + - labelVersion + - cartonIds + - shippedAt + properties: + version: + type: integer + format: int64 + minimum: 0 + idempotencyKey: + type: string + minLength: 1 + maxLength: 128 + reason: + type: string + minLength: 1 + maxLength: 1000 + leg: + type: string + enum: + - OVERSEAS + - DOMESTIC + labelVersion: + type: integer + minimum: 1 + cartonIds: + type: array + minItems: 1 + items: + type: string + pattern: ^[1-9][0-9]*$ + uniqueItems: true + shippedAt: + type: string + format: date-time + Repack: + type: object + required: + - version + - idempotencyKey + - reason + - cartonId + - lengthCm + - widthCm + - heightCm + - grossWeightKg + - voucherFileId + properties: + version: + type: integer + format: int64 + minimum: 0 + idempotencyKey: + type: string + minLength: 1 + maxLength: 128 + reason: + type: string + minLength: 1 + maxLength: 1000 + cartonId: + type: string + pattern: ^[1-9][0-9]*$ + lengthCm: + type: number + exclusiveMinimum: true + minimum: 0 + multipleOf: 0.01 + widthCm: + type: number + exclusiveMinimum: true + minimum: 0 + multipleOf: 0.01 + heightCm: + type: number + exclusiveMinimum: true + minimum: 0 + multipleOf: 0.01 + grossWeightKg: + type: number + exclusiveMinimum: true + minimum: 0 + multipleOf: 0.001 + voucherFileId: + type: string + pattern: ^[1-9][0-9]*$ + LabelRow: + allOf: + - $ref: '#/components/schemas/Label' + - type: object + required: + - appliedAt + properties: + appliedAt: + type: string + format: date-time + nullable: true + LabelSet: + type: object + required: + - id + - version + - manifestId + - cargoHash + - sender + - receiver + - labels + properties: + id: + type: string + pattern: ^[1-9][0-9]*$ + version: + type: integer + manifestId: + type: string + pattern: ^[1-9][0-9]*$ + cargoHash: + type: string + sender: + $ref: '#/components/schemas/PostalAddress' + receiver: + $ref: '#/components/schemas/PostalAddress' + labels: + type: array + minItems: 1 + items: + $ref: '#/components/schemas/LabelRow' + TransportCargo: + type: object + required: [cartonId, cartonNo, quantity, lengthCm, widthCm, heightCm, grossWeightKg, packagingRecordId, sources] + properties: + cartonId: + type: string + pattern: '^[1-9][0-9]*$' + cartonNo: + type: string + quantity: + type: integer + format: int64 + minimum: 1 + lengthCm: + type: number + minimum: 0 + exclusiveMinimum: true + multipleOf: 0.01 + widthCm: + type: number + minimum: 0 + exclusiveMinimum: true + multipleOf: 0.01 + heightCm: + type: number + minimum: 0 + exclusiveMinimum: true + multipleOf: 0.01 + grossWeightKg: + type: number + minimum: 0 + exclusiveMinimum: true + multipleOf: 0.001 + packagingRecordId: + type: string + nullable: true + sources: + type: array + items: + $ref: '#/components/schemas/Source' + TransportResponse: + type: object + required: + - code + - message + - data + properties: + code: + type: string + message: + type: string + data: + type: object + required: + - orderNo + - version + - status + - overseasLabels + - domesticLabels + - overseasCargo + - domesticCargo + - shipments + properties: + orderNo: + type: string + minLength: 1 + maxLength: 64 + version: + type: integer + format: int64 + minimum: 0 + status: + type: string + minLength: 1 + maxLength: 128 + overseasCargo: + type: array + items: + $ref: '#/components/schemas/TransportCargo' + domesticCargo: + type: array + description: 国内有效合格实收及最新一对一换包装箱规,不包含已处置的破损或多货 + items: + $ref: '#/components/schemas/TransportCargo' + overseasLabels: + allOf: + - $ref: '#/components/schemas/LabelSet' + nullable: true + domesticLabels: + allOf: + - $ref: '#/components/schemas/LabelSet' + nullable: true + shipments: + type: array + items: + type: object + required: + - id + - shipmentNo + - leg + - shippedAt + properties: + id: + type: string + pattern: '^[1-9][0-9]*$' + shipmentNo: + type: string + minLength: 1 + maxLength: 64 + leg: + type: string + enum: + - OVERSEAS + - DOMESTIC + shippedAt: + type: string + format: date-time + ChargeAmount: + type: object + required: + - determination + - amount + - currency + - businessDate + - reason + properties: + determination: + type: string + enum: + - POSITIVE + - ZERO + - NOT_APPLICABLE + amount: + type: number + minimum: 0 + multipleOf: 0.01 + currency: + type: string + enum: + - CNY + - USD + businessDate: + type: string + format: date + voucherFileId: + type: string + pattern: ^[1-9][0-9]*$ + nullable: true + reason: + type: string + minLength: 1 + maxLength: 500 + ChargeSelection: + type: object + required: [] + properties: + feeId: + type: string + pattern: ^[1-9][0-9]*$ + nullable: true + value: + allOf: + - $ref: '#/components/schemas/ChargeAmount' + nullable: true + description: feeId与value恰有一个。引用本单同用途有效费用用于重新核对,不产生新扣款;新核定用value,未知不能当零元。 + oneOf: + - required: + - feeId + properties: + value: + type: object + nullable: true + enum: + - null + feeId: + type: string + pattern: ^[1-9][0-9]*$ + - required: + - value + properties: + feeId: + type: string + nullable: true + enum: + - null + value: + $ref: '#/components/schemas/ChargeAmount' + OtherCharge: + type: object + required: + - code + - name + - selection + properties: + code: + type: string + pattern: ^[A-Z][A-Z0-9_]{0,31}$ + name: + type: string + minLength: 1 + maxLength: 128 + selection: + $ref: '#/components/schemas/ChargeSelection' + ConfirmCharges: + type: object + required: + - version + - idempotencyKey + - reason + - internationalFreight + - duty + - domesticFreight + - otherDetermination + - otherFees + properties: + version: + type: integer + format: int64 + minimum: 0 + idempotencyKey: + type: string + minLength: 1 + maxLength: 128 + reason: + type: string + minLength: 1 + maxLength: 1000 + internationalFreight: + $ref: '#/components/schemas/ChargeSelection' + duty: + $ref: '#/components/schemas/ChargeSelection' + domesticFreight: + $ref: '#/components/schemas/ChargeSelection' + otherDetermination: + type: string + enum: + - NOT_APPLICABLE + - ITEMIZED + otherFees: + type: array + items: + $ref: '#/components/schemas/OtherCharge' + AdditionalFee: + type: object + additionalProperties: false + required: [version, idempotencyKey, name, amount, currency, businessDate, voucherFileId, reason] + properties: + version: + type: integer + format: int64 + minimum: 0 + idempotencyKey: + type: string + minLength: 1 + maxLength: 128 + name: + type: string + minLength: 1 + maxLength: 128 + amount: + type: number + minimum: 0 + exclusiveMinimum: true + multipleOf: 0.01 + description: 正额原币金额,原币及折算 CNY 均须在费用来源容量内(最多14位整数)。 + currency: + type: string + enum: [CNY, USD] + businessDate: + type: string + format: date + description: 已发生费用的实际业务日期,不允许未来日期。 + voucherFileId: + type: string + pattern: ^[1-9][0-9]*$ + reason: + type: string + minLength: 1 + maxLength: 500 + CorrectFeeTarget: + type: object + required: + - version + - idempotencyKey + - reason + - targetOriginalAmount + - voucherFileId + properties: + version: + type: integer + format: int64 + minimum: 0 + idempotencyKey: + type: string + minLength: 1 + maxLength: 128 + reason: + type: string + minLength: 1 + maxLength: 500 + targetOriginalAmount: + type: number + minimum: 0 + multipleOf: 0.01 + voucherFileId: + type: string + pattern: ^[1-9][0-9]*$ + RefundFee: + type: object + required: + - version + - idempotencyKey + - reason + - amount + - voucherFileId + properties: + version: + type: integer + format: int64 + minimum: 0 + idempotencyKey: + type: string + minLength: 1 + maxLength: 128 + reason: + type: string + minLength: 1 + maxLength: 500 + amount: + type: number + minimum: 0 + multipleOf: 0.01 + exclusiveMinimum: true + voucherFileId: + type: string + pattern: ^[1-9][0-9]*$ + ProcurementFeeVersion: + type: object + required: + - id + - feeNo + - purpose + - amount + - walletStatus + properties: + id: + type: string + pattern: ^[1-9][0-9]*$ + feeNo: + type: string + minLength: 1 + maxLength: 64 + purpose: + type: string + enum: + - SAMPLE_PAYMENT + - INTERNATIONAL_ESTIMATE + - INTERNATIONAL_FREIGHT + - DUTY + - DOMESTIC_FREIGHT + - OTHER + - ADJUSTMENT + - REFUND + confirmationOrigin: + type: string + enum: [BUSINESS, FINANCIAL] + description: 核定入口决定重试权限;客户响应不包含此字段。 + feeVersion: + type: integer + active: + type: boolean + determination: + type: string + feeItemCode: + type: string + minLength: 1 + maxLength: 64 + feeItemName: + type: string + minLength: 1 + maxLength: 128 + amount: + type: number + minimum: 0 + multipleOf: 0.01 + currency: + type: string + enum: + - CNY + originalCurrency: + type: string + enum: + - CNY + - USD + originalAmount: + type: number + minimum: 0 + multipleOf: 0.01 + exchangeRate: + type: number + exchangeRateMonth: + type: string + businessDate: + type: string + format: date + parentFeeId: + type: string + pattern: ^[1-9][0-9]*$ + nullable: true + walletStatus: + type: string + voucherFileId: + type: string + pattern: ^[1-9][0-9]*$ + nullable: true + remark: + type: string + nullable: true + ChargesSnapshot: + type: object + required: + - orderNo + - version + - status + - confirmedAt + - fees + properties: + orderNo: + type: string + minLength: 1 + maxLength: 64 + version: + type: integer + format: int64 + minimum: 0 + status: + type: string + minLength: 1 + maxLength: 128 + confirmedAt: + type: string + format: date-time + nullable: true + fees: + type: array + items: + type: object + required: + - detail + - sourceLinked + properties: + detail: + $ref: '#/components/schemas/ProcurementFeeVersion' + sourceLinked: + type: boolean + FeeCorrectionResult: + type: object + required: + - correctionNo + - rootFeeNo + - targetOriginalAmount + - originalCurrency + - exchangeRate + - exchangeRateMonth + - targetAmount + - previousNetAmount + - differenceAmount + - feeNos + properties: + correctionNo: + type: string + minLength: 1 + maxLength: 64 + rootFeeNo: + type: string + minLength: 1 + maxLength: 64 + targetOriginalAmount: + type: number + minimum: 0 + multipleOf: 0.01 + originalCurrency: + type: string + exchangeRate: + type: number + exchangeRateMonth: + type: string + targetAmount: + type: number + minimum: 0 + multipleOf: 0.01 + previousNetAmount: + type: number + minimum: 0 + multipleOf: 0.01 + differenceAmount: + type: number + multipleOf: 0.01 + feeNos: + type: array + items: + type: string + minLength: 1 + maxLength: 64 + ChargesResponse: + type: object + required: + - code + - message + - data + properties: + code: + type: string + message: + type: string + data: + $ref: '#/components/schemas/ChargesSnapshot' + FeeCorrectionResponse: + type: object + required: + - code + - message + - data + properties: + code: + type: string + message: + type: string + data: + $ref: '#/components/schemas/FeeCorrectionResult' + FeeCorrectionsResponse: + type: object + required: + - code + - message + - data + properties: + code: + type: string + message: + type: string + data: + type: array + items: + $ref: '#/components/schemas/FeeCorrectionResult' + FeeVersionResponse: + type: object + required: + - code + - message + - data + properties: + code: + type: string + message: + type: string + data: + $ref: '#/components/schemas/ProcurementFeeVersion' + ControlNote: + type: object + required: + - version + - idempotencyKey + - reason + - targetType + - blocking + - voucherFileId + properties: + version: + type: integer + format: int64 + minimum: 0 + idempotencyKey: + type: string + minLength: 1 + maxLength: 128 + reason: + type: string + minLength: 1 + maxLength: 1000 + targetType: + type: string + enum: + - PURCHASE + - WAREHOUSE + - RECEIPT + - SHIPMENT + targetId: + type: string + pattern: ^[1-9][0-9]*$ + nullable: true + blocking: + type: boolean + voucherFileId: + type: string + pattern: ^[1-9][0-9]*$ + clientSummary: + type: string + minLength: 1 + maxLength: 500 + nullable: true + ResolveControl: + type: object + required: + - version + - idempotencyKey + - reason + - voucherFileId + properties: + version: + type: integer + format: int64 + minimum: 0 + idempotencyKey: + type: string + minLength: 1 + maxLength: 128 + reason: + type: string + minLength: 1 + maxLength: 1000 + voucherFileId: + type: string + pattern: ^[1-9][0-9]*$ + CorrectWarehouse: + type: object + required: + - version + - idempotencyKey + - reason + - warehouseId + - voucherFileId + properties: + version: + type: integer + format: int64 + minimum: 0 + idempotencyKey: + type: string + minLength: 1 + maxLength: 128 + reason: + type: string + minLength: 1 + maxLength: 1000 + warehouseId: + type: string + pattern: ^[1-9][0-9]*$ + voucherFileId: + type: string + pattern: ^[1-9][0-9]*$ + CloseResolution: + type: object + required: + - kind + - quantity + - decision + - destination + - voucherFileId + properties: + kind: + type: string + enum: + - PURCHASE_REMAINDER + - TRANSIT_REMAINDER + - GOOD + - DAMAGED + - EXTRA + sourceId: + type: string + pattern: ^[1-9][0-9]*$ + nullable: true + quantity: + type: integer + format: int64 + minimum: 1 + decision: + type: string + enum: + - SUPPLIER_RETAINED + - RETURNED_TO_SUPPLIER + - RETURNED_TO_SENDER + - LOSS_CONFIRMED + - CUSTOMER_AUTHORIZED_RETENTION + - DISPOSED + destination: + type: string + minLength: 1 + maxLength: 500 + voucherFileId: + type: string + pattern: ^[1-9][0-9]*$ + ExceptionClose: + type: object + required: + - version + - idempotencyKey + - reason + - resolutions + - customerVoucherFileId + - financialConclusion + - clientSummary + properties: + version: + type: integer + format: int64 + minimum: 0 + idempotencyKey: + type: string + minLength: 1 + maxLength: 128 + reason: + type: string + minLength: 1 + maxLength: 1000 + resolutions: + type: array + items: + $ref: '#/components/schemas/CloseResolution' + customerVoucherFileId: + type: string + pattern: ^[1-9][0-9]*$ + financialConclusion: + type: string + minLength: 1 + maxLength: 1000 + clientSummary: + type: string + minLength: 1 + maxLength: 500 + ControlRemaining: + type: object + required: + - kind + - sourceId + - businessNo + - quantity + properties: + kind: + type: string + enum: + - PURCHASE_REMAINDER + - TRANSIT_REMAINDER + - GOOD + - DAMAGED + - EXTRA + sourceId: + type: string + pattern: ^[1-9][0-9]*$ + nullable: true + businessNo: + type: string + minLength: 1 + maxLength: 64 + quantity: + type: integer + format: int64 + minimum: 1 + ControlSnapshot: + type: object + required: + - orderNo + - version + - status + - notes + - remaining + - closures + - closureItems + properties: + orderNo: + type: string + minLength: 1 + maxLength: 64 + version: + type: integer + format: int64 + minimum: 0 + status: + type: string + minLength: 1 + maxLength: 128 + notes: + type: array + items: + type: object + description: 平台内部更正事实,含原始凭证和处理说明;字段为表结构snake_case,不对客透传 + remaining: + type: array + items: + $ref: '#/components/schemas/ControlRemaining' + closures: + type: array + items: + type: object + closureItems: + type: array + items: + type: object + ControlResponse: + type: object + required: + - code + - message + - data + properties: + code: + type: string + message: + type: string + data: + $ref: '#/components/schemas/ControlSnapshot' + ProcurementHeader: + type: object + required: + - id + - orderNo + - productName + - purchaseQuantity + - status + - version + properties: + orderNo: + type: string + nullable: true + customerId: + type: string + nullable: true + customerName: + type: string + nullable: true + purchaseCategory: + type: string + nullable: true + productName: + type: string + nullable: true + productSpecification: + type: string + nullable: true + productUrl: + type: string + nullable: true + currency: + type: string + nullable: true + receivingAddress: + type: string + nullable: true + contactName: + type: string + nullable: true + contactPhone: + type: string + nullable: true + plannedWarehouseName: + type: string + nullable: true + actualWarehouseCode: + type: string + nullable: true + actualWarehouseName: + type: string + nullable: true + status: + type: string + nullable: true + id: + type: string + pattern: ^[1-9][0-9]*$ + purchaseQuantity: + type: integer + format: int64 + budgetUnitPrice: + type: number + nullable: true + version: + type: integer + format: int64 + minimum: 0 + estimatedArrivalAt: + type: string + format: date-time + nullable: true + createdAt: + type: string + format: date-time + nullable: true + updatedAt: + type: string + format: date-time + nullable: true + ProcurementBudget: + type: object + required: + - totalAmount + - currency + - authorizedAt + properties: + totalAmount: + type: number + nullable: true + currency: + type: string + authorizedAt: + type: string + format: date-time + nullable: true + ProcurementProgress: + type: object + required: + - key + - title + - completedAt + properties: + key: + type: string + minLength: 1 + maxLength: 128 + title: + type: string + minLength: 1 + maxLength: 128 + completedAt: + type: string + format: date-time + nullable: true + CustomerBox: + type: object + required: + - cartonNo + - quantity + - lengthCm + - widthCm + - heightCm + - grossWeightKg + properties: + cartonNo: + type: string + minLength: 1 + maxLength: 64 + quantity: + type: integer + format: int64 + lengthCm: + type: number + widthCm: + type: number + heightCm: + type: number + grossWeightKg: + type: number + CustomerShipment: + type: object + required: + - shipmentNo + - leg + - shippedAt + - cartonNo + - quantity + - carrier + - trackingNo + properties: + shipmentNo: + type: string + minLength: 1 + maxLength: 64 + leg: + type: string + enum: + - OVERSEAS + - DOMESTIC + shippedAt: + type: string + format: date-time + cartonNo: + type: string + minLength: 1 + maxLength: 64 + quantity: + type: integer + format: int64 + carrier: + type: string + minLength: 1 + maxLength: 128 + trackingNo: + type: string + minLength: 1 + maxLength: 128 + CustomerFee: + type: object + required: + - feeNo + - purpose + - name + - determination + - currency + - originalCurrency + - exchangeRateMonth + - parentFeeNo + - walletStatus + - version + - active + - amount + - originalAmount + - exchangeRate + - businessDate + - sourceLinked + properties: + feeNo: + type: string + nullable: true + purpose: + type: string + nullable: true + name: + type: string + nullable: true + determination: + type: string + nullable: true + currency: + type: string + nullable: true + originalCurrency: + type: string + nullable: true + exchangeRateMonth: + type: string + nullable: true + parentFeeNo: + type: string + nullable: true + walletStatus: + type: string + nullable: true + version: + type: integer + nullable: true + active: + type: boolean + amount: + type: number + originalAmount: + type: number + nullable: true + exchangeRate: + type: number + nullable: true + businessDate: + type: string + format: date + nullable: true + sourceLinked: + type: boolean + additionalProperties: false + description: 客户费用白名单:不含凭证、备注、资金命令/冻结/流水引用和原始诊断 + ProcurementCustomerView: + type: object + required: + - order + - budget + - purchased + - purchaseConfirmed + - purchaseNeedsAttention + - progress + - boxes + - shipments + - fees + - exceptionSummaries + properties: + order: + $ref: '#/components/schemas/ProcurementHeader' + budget: + $ref: '#/components/schemas/ProcurementBudget' + purchased: + type: boolean + purchaseConfirmed: + type: boolean + purchaseNeedsAttention: + type: boolean + progress: + type: array + items: + $ref: '#/components/schemas/ProcurementProgress' + boxes: + type: array + items: + $ref: '#/components/schemas/CustomerBox' + shipments: + type: array + items: + $ref: '#/components/schemas/CustomerShipment' + fees: + type: array + items: + $ref: '#/components/schemas/CustomerFee' + exceptionSummaries: + type: array + items: + type: string + additionalProperties: false + description: 客户仅获取此独立投影;boxes只包含已确认海外清单。内部采购异常只提供需要处理标记,异常摘要来自人工明确填写的对客字段。 + ProcurementAction: + type: object + required: + - key + - label + - permission + - allowed + - reason + properties: + key: + type: string + minLength: 1 + maxLength: 128 + label: + type: string + minLength: 1 + maxLength: 128 + permission: + type: string + minLength: 1 + maxLength: 128 + allowed: + type: boolean + reason: + type: string + nullable: true + ProcurementPlatformView: + type: object + required: + - customerView + - purchaseFacts + - physical + - transport + - fees + - feeCorrections + - controls + - files + - reviewHistory + - operations + - actions + - internalRemark + properties: + customerView: + $ref: '#/components/schemas/ProcurementCustomerView' + purchaseFacts: + type: object + description: 平台内部履约事实,字段snake_case + physical: + type: object + properties: + orderNo: + type: string + version: + type: integer + format: int64 + minimum: 0 + status: + type: string + packingVersion: + type: integer + nullable: true + packingStatus: + type: string + nullable: true + enum: + - DRAFT + - CONFIRMED + - SUPERSEDED + receipts: + type: array + items: + type: object + properties: + id: + type: string + pattern: ^[1-9][0-9]*$ + receiptNo: + type: string + leg: + type: string + enum: + - OVERSEAS + - DOMESTIC + sourceCartonId: + type: string + pattern: ^[1-9][0-9]*$ + nullable: true + goodQuantity: + type: integer + format: int64 + minimum: 0 + damagedQuantity: + type: integer + format: int64 + minimum: 0 + extraQuantity: + type: integer + format: int64 + minimum: 0 + receivedAt: + type: string + format: date-time + active: + type: boolean + dispositions: + type: array + description: 平台处置历史,active=0为已撤销记录;不直接对客输出内部依据 + items: + type: object + cartons: + type: array + items: + allOf: + - $ref: '#/components/schemas/Carton' + - type: object + properties: + id: + type: string + pattern: ^[1-9][0-9]*$ + quantity: + type: integer + format: int64 + minimum: 1 + transport: + type: object + required: + - orderNo + - version + - status + - overseasLabels + - domesticLabels + - overseasCargo + - domesticCargo + - shipments + properties: + orderNo: + type: string + minLength: 1 + maxLength: 64 + version: + type: integer + format: int64 + minimum: 0 + status: + type: string + minLength: 1 + maxLength: 128 + overseasCargo: + type: array + items: + $ref: '#/components/schemas/TransportCargo' + domesticCargo: + type: array + description: 国内有效合格实收及最新一对一换包装箱规,不包含已处置的破损或多货 + items: + $ref: '#/components/schemas/TransportCargo' + overseasLabels: + allOf: + - $ref: '#/components/schemas/LabelSet' + nullable: true + domesticLabels: + allOf: + - $ref: '#/components/schemas/LabelSet' + nullable: true + shipments: + type: array + items: + type: object + required: + - id + - shipmentNo + - leg + - shippedAt + properties: + id: + type: string + pattern: '^[1-9][0-9]*$' + shipmentNo: + type: string + minLength: 1 + maxLength: 64 + leg: + type: string + enum: + - OVERSEAS + - DOMESTIC + shippedAt: + type: string + format: date-time + fees: + type: array + items: + $ref: '#/components/schemas/ProcurementFeeVersion' + feeCorrections: + type: array + items: + $ref: '#/components/schemas/FeeCorrectionResult' + controls: + $ref: '#/components/schemas/ControlSnapshot' + files: + type: array + items: + type: object + reviewHistory: + type: array + items: + type: object + operations: + type: array + items: + type: object + actions: + type: array + items: + $ref: '#/components/schemas/ProcurementAction' + internalRemark: + type: string + nullable: true + ProcurementCustomerResponse: + type: object + required: + - code + - message + - data + properties: + code: + type: string + message: + type: string + data: + $ref: '#/components/schemas/ProcurementCustomerView' + ProcurementPlatformResponse: + type: object + required: + - code + - message + - data + properties: + code: + type: string + message: + type: string + data: + $ref: '#/components/schemas/ProcurementPlatformView' diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/client/DomesticWarehouseDirectoryClientContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/client/DomesticWarehouseDirectoryClientContractTest.java index f9573422f..9a502a018 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/client/DomesticWarehouseDirectoryClientContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/client/DomesticWarehouseDirectoryClientContractTest.java @@ -30,7 +30,7 @@ class DomesticWarehouseDirectoryClientContractTest { assertThat(Arrays.stream(DomesticWarehouseDirectoryClient.Warehouse.class.getRecordComponents()) .map(value -> value.getName() + ":" + value.getType().getSimpleName())) .containsExactly("id:Long", "tenantId:Long", "warehouseCode:String", "warehouseName:String", - "warehouseType:String", "countryCode:String", "status:String"); + "warehouseType:String", "countryCode:String", "status:String", "contactName:String", "contactPhone:String"); assertThat(Arrays.stream(DomesticWarehouseDirectoryClient.Address.class.getRecordComponents()) .map(value -> value.getName() + ":" + value.getType().getSimpleName())) .containsExactly("addressType:String", "countryCode:String", "stateProvince:String", "city:String", diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintServiceTest.java index bda740e5f..2bd6c0966 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintServiceTest.java @@ -59,7 +59,7 @@ class DispatchBolPrintServiceTest { var address = new DomesticWarehouseDirectoryClient.Address("OPERATING", "US", "California", "Rancho Cucamonga", null, "91730", "10767 7th Street", null); var warehouse = new DomesticWarehouseDirectoryClient.Warehouse(Long.parseLong(warehouseId), 0L, - "US-LAX", "洛杉矶仓", "OVERSEAS", "US", "ACTIVE"); + "US-LAX", "洛杉矶仓", "OVERSEAS", "US", "ACTIVE", null, null); when(dispatches.platformDetail(detail.dispatchNo())).thenReturn(detail); when(warehouses.activeWarehouse(Long.parseLong(warehouseId), "internal-test-token")) .thenReturn(ApiResponse.success(new DomesticWarehouseDirectoryClient.Detail(warehouse, List.of(address)))); @@ -89,7 +89,7 @@ class DispatchBolPrintServiceTest { new DispatchResponse.BolLine("TSTU3539519", "DP-002", "ONT8", "ONT8 Warehouse", "NYEXX2609110215", null, null, 1)), null); var warehouse = new DomesticWarehouseDirectoryClient.Warehouse(Long.parseLong(warehouseId), 0L, - "US-LAX", "洛杉矶仓", "OVERSEAS", "US", "ACTIVE"); + "US-LAX", "洛杉矶仓", "OVERSEAS", "US", "ACTIVE", null, null); when(dispatches.platformDetail(detail.dispatchNo())).thenReturn(detail); when(warehouses.activeWarehouse(Long.parseLong(warehouseId), "internal-test-token")) .thenReturn(ApiResponse.success(new DomesticWarehouseDirectoryClient.Detail(warehouse, List.of()))); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/ProcurementSettledFeeSourceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/ProcurementSettledFeeSourceTest.java new file mode 100644 index 000000000..14fd1c29b --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/ProcurementSettledFeeSourceTest.java @@ -0,0 +1,48 @@ +package com.baiyue.lastleg.financefee.service; + +import com.baiyue.common.event.WalletSettlementReference; +import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.SaveRequest; +import com.baiyue.lastleg.financefee.entity.OverseasFeeSource; +import com.baiyue.lastleg.financefee.mapper.OverseasFeeCommandMapper; +import com.baiyue.lastleg.financefee.mapper.OverseasFeeSourceMapper; +import com.baiyue.lastleg.financefee.state.OverseasFeeCancellation; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class ProcurementSettledFeeSourceTest { + @Test + void publishesActualFeeUsingConfirmedFxAndConsumptionReference() { + var mapper = mock(OverseasFeeSourceMapper.class); + var outbox = mock(OverseasFeeOutboxService.class); + var converter = mock(OverseasFeeCurrencyConverter.class); + var service = new OverseasFeeSourceService(mapper, mock(OverseasFeeCommandMapper.class), + mock(OverseasFeeBusinessGuard.class), outbox, converter, new ObjectMapper().findAndRegisterModules(), + mock(OverseasFeeCancellation.class)); + SaveRequest fee = new SaveRequest("9", "customer", "OVERSEAS_PROCUREMENT_FEE", "PROCUREMENT_FEE", 11L, + "PF-1", 12L, null, null, null, null, null, null, "SAMPLE_PAYMENT", "样品货款", "RECEIVABLE", + null, null, null, null, null, null, null, List.of(), LocalDate.of(2026, 10, 9), "USD", + new BigDecimal("10.00"), BigDecimal.ONE, "ITEM", new BigDecimal("10.00"), null, null); + assertThatThrownBy(() -> service.create("7", "manual-source", fee)).hasMessageContaining("采购费用必须通过"); + assertThatThrownBy(() -> service.publishApproved("7", "manual-approved", fee)).hasMessageContaining("采购费用必须通过"); + verifyNoInteractions(mapper,outbox,converter); + var conversion = new OverseasFeeCurrencyConverter.Conversion("USD", new BigDecimal("10.00"), + new BigDecimal("7.200000"), "2026-10", new BigDecimal("72.00")); + var reference = new WalletSettlementReference("command-1", "1", "2"); + OverseasFeeSource result = service.publishSettled("7", "source-1", fee, conversion, reference); + assertThat(result.getAmount()).isEqualByComparingTo("72"); + assertThat(result.getWalletSettlement()).isEqualTo(reference); + assertThat(result.getReviewStatus()).isEqualTo("APPROVED"); + assertThat(result.getPublicationStatus()).isEqualTo("PUBLISHED"); + verify(outbox).publish(result, "PUBLISHED"); + verify(mapper).insert(result); + verifyNoInteractions(converter); + assertThatThrownBy(() -> service.publishSettled("7", "source-2", fee, conversion, null)) + .hasMessageContaining("快照不完整"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/merchant/MerchantOverseasOpenApiContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/merchant/MerchantOverseasOpenApiContractTest.java index 0d3e23529..bbbbacb1a 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/merchant/MerchantOverseasOpenApiContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/merchant/MerchantOverseasOpenApiContractTest.java @@ -60,7 +60,7 @@ class MerchantOverseasOpenApiContractTest { .contains("merchant:overseas-warehouse:return-order:create") .contains("merchant:overseas-warehouse:return-order:relabel") .contains("merchant:warehouse:inbound:list") - .contains("#/components/schemas/ProcurementDetailResponse") + .contains("procurement-fulfillment-v1.yaml#/components/schemas/ProcurementCustomerResponse") .contains("#/components/schemas/TruckDetailResponse") .contains("#/components/schemas/LooseboardDetailResponse") .contains("#/components/schemas/InboundPlanDetailResponse") @@ -68,6 +68,10 @@ class MerchantOverseasOpenApiContractTest { .contains("overseasWarehouseName: { type: string, description: 接柜仓名称,取自尾程订单预分配接收仓事实 }") .contains("PENDING_REVIEW").contains("PENDING_ACCEPTANCE") .contains("version 必填").contains("幂等冲突"); + for(String path:new String[]{"/api/merchant/v1/last-leg/overseas-procurements:","/api/merchant/v1/last-leg/overseas-procurements/{orderNo}:"}) { + assertThat(pathBlock(yaml,path)).contains("ProcurementCustomerResponse") + .doesNotContain("ProcurementPlatformResponse", "ProcurementDetailResponse"); + } assertThat(yaml).contains("/api/platform/v1/last-leg/inbound-plans:") .contains("/api/platform/v1/last-leg/inbound-plans/{planNo}:") .contains("overseasWarehouseCode/overseasWarehouseName 只取该客户尾程订单预分配接柜仓事实") diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementInventoryBoundaryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementInventoryBoundaryTest.java index 6288bad5f..99f3e2aaa 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementInventoryBoundaryTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementInventoryBoundaryTest.java @@ -26,6 +26,8 @@ class OverseasProcurementInventoryBoundaryTest { String responses = Files.readString(Path.of( "src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementResponses.java"), StandardCharsets.UTF_8); - assertTrue(responses.contains("outboundQuantity")); + assertFalse(responses.contains("outboundQuantity")); + assertFalse(responses.contains("inventoryQuantity")); + assertFalse(responses.contains("OutboundDetail")); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementSchemaContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementSchemaContractTest.java index ee27b71af..b541da683 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementSchemaContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementSchemaContractTest.java @@ -22,7 +22,9 @@ class OverseasProcurementSchemaContractTest { assertTrue(sql.contains("operation_type VARCHAR(32) NOT NULL")); assertTrue(sql.contains("review_idempotency_key VARCHAR(255) NULL")); assertTrue(sql.contains("review_request_hash CHAR(64) NULL")); - assertTrue(sql.contains("outbound_details JSON NULL COMMENT '出库明细'")); + org.junit.jupiter.api.Assertions.assertFalse(sql.contains("outbound_details")); + assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_procurement_shipment")); + assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_procurement_shipment_item")); assertTrue(sql.contains("idempotency_key VARCHAR(255) NULL COMMENT '操作幂等键'")); assertTrue(sql.contains("change_detail JSON NULL")); org.junit.jupiter.api.Assertions.assertFalse(sql.contains("CREATE TABLE IF NOT EXISTS ll_overseas_procurement_outbound")); @@ -41,7 +43,8 @@ class OverseasProcurementSchemaContractTest { String order = tableBlock(lower, "ll_overseas_procurement_order"); assertTrue(order.contains("review_idempotency_key varchar(255)")); assertTrue(order.contains("review_request_hash char(64)")); - assertTrue(order.contains("outbound_details json")); + org.junit.jupiter.api.Assertions.assertFalse(order.contains("outbound_details")); + org.junit.jupiter.api.Assertions.assertFalse(order.contains("tracking_no")); assertTrue(tableBlock(lower, "ll_wallet_event_inbox").contains("payload_hash varchar(71)")); org.junit.jupiter.api.Assertions.assertFalse(sql.contains("ALTER TABLE ll_overseas_procurement")); org.junit.jupiter.api.Assertions.assertFalse(sql.contains("ALTER TABLE ll_wallet_event_inbox")); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/ProcurementRequestBoundaryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/ProcurementRequestBoundaryTest.java new file mode 100644 index 000000000..5cf726db8 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/ProcurementRequestBoundaryTest.java @@ -0,0 +1,40 @@ +package com.baiyue.lastleg.procurement; + +import com.baiyue.lastleg.procurement.dto.OverseasProcurementRequests; +import com.baiyue.lastleg.procurement.dto.OverseasProcurementResponses.*; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import java.math.BigDecimal; +import java.util.List; +import static org.assertj.core.api.Assertions.*; + +class ProcurementRequestBoundaryTest { + @Test void missingCommandVersionCannotDefaultToZero() throws Exception { + var json=new ObjectMapper().findAndRegisterModules(); + try(var factory=jakarta.validation.Validation.buildDefaultValidatorFactory()) { + var validator=factory.getValidator(); + for(var type:List.of(com.baiyue.lastleg.procurement.dto.ProcurementLifecycleRequests.Command.class, + com.baiyue.lastleg.procurement.dto.ProcurementLifecycleRequests.Purchase.class, + com.baiyue.lastleg.procurement.dto.ProcurementLifecycleRequests.Budget.class, + com.baiyue.lastleg.procurement.dto.ProcurementLifecycleRequests.Warehouse.class)) { + assertThat(validator.validate(json.readValue("{}",type))).anyMatch(v->"version".equals(v.getPropertyPath().toString())); + } + } + } + @Test void legacyGenericFieldsCannotSilentlyBypassDedicatedNodes() { + var json=new ObjectMapper().disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); + for(String field:List.of("inboundQuantity","outboundDetails","productName","purchaseWarehouseId","domesticTrackingNo")) { + assertThatThrownBy(()->json.readValue("{\"version\":0,\""+field+"\":null}",OverseasProcurementRequests.Update.class)) + .hasMessageContaining("采购通用编辑仅支持备注"); + } + } + + @Test void customerProjectionExcludesInternalFilesAndFinancialDiagnostics() throws Exception { + var fee=new com.baiyue.lastleg.procurement.entity.OverseasProcurementFee(); + fee.setFeeNo("PF-1");fee.setFeeItemName("关税");fee.setAmount(BigDecimal.TEN);fee.setCurrency("CNY");fee.setWalletStatus("FREEZE_FAILED"); + fee.setVoucherFileId(2L);fee.setVoucherFileName("private.pdf");fee.setWalletErrorMessage("database error");fee.setRemark("internal note");fee.setCreatedBy("operator");fee.setWalletLedgerId("private-ledger"); + var output=new ObjectMapper().writeValueAsString(com.baiyue.lastleg.procurement.service.ProcurementQueryService.CustomerFee.from(fee,null,false)); + assertThat(output).contains("PF-1","FREEZE_FAILED").doesNotContain("private.pdf","voucherFileId","internal note","database error","operator","private-ledger"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/controller/OverseasProcurementControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/controller/OverseasProcurementControllerTest.java index d8c84b626..3d90f3932 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/controller/OverseasProcurementControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/controller/OverseasProcurementControllerTest.java @@ -20,6 +20,7 @@ import static org.mockito.Mockito.verify; @ExtendWith(MockitoExtension.class) class OverseasProcurementControllerTest { @Mock OverseasProcurementService service; + @Mock com.baiyue.lastleg.procurement.service.ProcurementQueryService queryService; private MerchantOverseasProcurementController merchant; private PlatformOverseasProcurementController platform; @@ -29,6 +30,8 @@ class OverseasProcurementControllerTest { platform = new PlatformOverseasProcurementController(); ReflectionTestUtils.setField(merchant, "service", service); ReflectionTestUtils.setField(platform, "service", service); + ReflectionTestUtils.setField(merchant,"queryService",queryService); + ReflectionTestUtils.setField(platform,"queryService",queryService); } @Test @@ -37,10 +40,19 @@ class OverseasProcurementControllerTest { merchant.page(user(100L, "MERCHANT", Set.of()), query); verify(service).page("100", query.scoped()); } + @Test void customerDetailUsesOnlyTrustedCustomerAndDedicatedProjection() { + merchant.detail(user(100L,"MERCHANT",Set.of()),"PO-1"); + verify(queryService).customer("100","PO-1"); + } + @Test void platformDetailAggregatesWithCurrentActionPermissions() { + var permissions=Set.of("platform:overseas-business:purchase:list"); + platform.detail(user(0L,"PLATFORM",permissions),"100","PO-1"); + verify(queryService).platform("100","PO-1",permissions); + } @Test void reviewRequiresExplicitPermission() { - var request = new OverseasProcurementRequests.Review(0L, "APPROVE", "review-1", null); + var request = new OverseasProcurementRequests.Review(0L, "APPROVE", "review-1", null, 10L); ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> platform.review(user(0L, "PLATFORM", Set.of()), "100", "HC1", request)); assertEquals(403, error.getStatusCode().value()); @@ -56,7 +68,7 @@ class OverseasProcurementControllerTest { @Test void platformReviewUsesExplicitTargetTenant() { - var request = new OverseasProcurementRequests.Review(0L, "APPROVE", "review-1", "ok"); + var request = new OverseasProcurementRequests.Review(0L, "APPROVE", "review-1", "ok", 10L); platform.review(user(0L, "PLATFORM", Set.of(OverseasProcurementService.REVIEW_PERMISSION)), "100", "HC1", request); verify(service).review("100", "HC1", "7", request); @@ -64,8 +76,7 @@ class OverseasProcurementControllerTest { @Test void platformUpdatePassesAuditedOperator() { - var request = new OverseasProcurementRequests.Update(0L, null, null, null, null, - null, null, null, null, null, null, null, null, null); + var request = new OverseasProcurementRequests.Update(0L, null); platform.update(user(0L, "PLATFORM", Set.of(OverseasProcurementService.MANAGE_PERMISSION)), "100", "HC1", request); verify(service).update("100", "HC1", "7", request); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/controller/ProcurementLifecycleAuthorizationTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/controller/ProcurementLifecycleAuthorizationTest.java new file mode 100644 index 000000000..92e19711a --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/controller/ProcurementLifecycleAuthorizationTest.java @@ -0,0 +1,141 @@ +package com.baiyue.lastleg.procurement.controller; + +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.procurement.dto.ProcurementLifecycleRequests.*; +import com.baiyue.lastleg.procurement.service.ProcurementLifecycleService; +import com.baiyue.lastleg.procurement.service.ProcurementPhysicalService; +import com.baiyue.lastleg.procurement.service.OverseasProcurementService; +import com.baiyue.lastleg.procurement.dto.ProcurementPhysicalRequests; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.*; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.server.ResponseStatusException; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.Set; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class ProcurementLifecycleAuthorizationTest { + private final AnnotationConfigApplicationContext context=new AnnotationConfigApplicationContext(Config.class); + private final ProcurementLifecycleController controller=context.getBean(ProcurementLifecycleController.class); + private final ProcurementLifecycleService service=context.getBean(ProcurementLifecycleService.class); + @Configuration @EnableMethodSecurity(proxyTargetClass=true) + static class Config { + @Bean ProcurementLifecycleService service(){return mock(ProcurementLifecycleService.class);} + @Bean ProcurementLifecycleController controller(ProcurementLifecycleService service){return new ProcurementLifecycleController(service);} + @Bean ProcurementPhysicalService physicalService(){return mock(ProcurementPhysicalService.class);} + @Bean ProcurementPhysicalController physicalController(ProcurementPhysicalService service){return new ProcurementPhysicalController(service);} + // Register the mock directly: its legacy @Resource fields must not be autowired in this controller-only test. + @Bean static org.springframework.beans.factory.config.BeanFactoryPostProcessor orderMock(){ + return factory -> factory.registerSingleton("orders",mock(OverseasProcurementService.class)); + } + @Bean ProcurementFileController files(OverseasProcurementService service){return new ProcurementFileController(service);} + @Bean com.baiyue.lastleg.procurement.service.ProcurementTransportService transportService(){return mock(com.baiyue.lastleg.procurement.service.ProcurementTransportService.class);} + @Bean ProcurementTransportController transport(com.baiyue.lastleg.procurement.service.ProcurementTransportService service){return new ProcurementTransportController(service);} + @Bean com.baiyue.lastleg.procurement.service.ProcurementChargeService chargeService(){return mock(com.baiyue.lastleg.procurement.service.ProcurementChargeService.class);} + @Bean com.baiyue.lastleg.procurement.service.ProcurementAdjustmentService adjustmentService(){return mock(com.baiyue.lastleg.procurement.service.ProcurementAdjustmentService.class);} + @Bean ProcurementChargeController charges(com.baiyue.lastleg.procurement.service.ProcurementChargeService service,com.baiyue.lastleg.procurement.service.ProcurementAdjustmentService adjustments){return new ProcurementChargeController(service,adjustments);} + @Bean com.baiyue.lastleg.procurement.service.ProcurementControlService controlService(){return mock(com.baiyue.lastleg.procurement.service.ProcurementControlService.class);} + @Bean ProcurementControlController controls(com.baiyue.lastleg.procurement.service.ProcurementControlService service){return new ProcurementControlController(service);} + } + @AfterEach void cleanup(){SecurityContextHolder.clearContext();context.close();} + @Test void listPermissionDoesNotGrantBuyingOrCancelling() { + var user=login(0,"PLATFORM","list");controller.detail(user,"9","PO-1"); + verify(service).read("9","PO-1"); + assertThatThrownBy(()->controller.purchase(user,"9","PO-1",purchase())).isInstanceOf(AccessDeniedException.class); + assertThatThrownBy(()->controller.cancel(user,"9","PO-1",new Command(0L,"key","reason"))).isInstanceOf(AccessDeniedException.class); + verifyNoMoreInteractions(service); + } + @Test void buyerCannotAuthorizeItsOwnBudgetWithoutReviewPermission() { + var user=login(0,"PLATFORM","buy");var purchase=purchase();controller.purchase(user,"9","PO-1",purchase); + verify(service).recordPurchase("9","PO-1","7",purchase); + assertThatThrownBy(()->controller.budget(user,"9","PO-1",new Budget(0L,"budget",BigDecimal.TEN,"CNY",1,"reason"))) + .isInstanceOf(AccessDeniedException.class); + verifyNoMoreInteractions(service); + } + @Test void customerIdentityCannotUsePlatformActionEvenWithInjectedAuthority() { + var user=login(9,"MERCHANT","buy"); + assertThatThrownBy(()->controller.purchase(user,"9","PO-1",purchase())).isInstanceOf(ResponseStatusException.class); + verifyNoInteractions(service); + } + @Test void overseasReceiverCannotPerformDomesticReceivingOrApproveDiscrepancy() { + var physical=context.getBean(ProcurementPhysicalController.class);var implementation=context.getBean(ProcurementPhysicalService.class); + var user=login(0,"PLATFORM","receive-overseas"); + var receipt=new ProcurementPhysicalRequests.Receipt(0L,"r",ProcurementPhysicalRequests.Leg.OVERSEAS,91L,null,1,0,0,LocalDateTime.now().minusHours(1),"actual"); + physical.overseas(user,"9","PO-1",receipt);verify(implementation).receive("9","PO-1","7",receipt); + assertThatThrownBy(()->physical.domestic(user,"9","PO-1",receipt)).isInstanceOf(AccessDeniedException.class); + var decision=new ProcurementPhysicalRequests.Disposition(0L,"d",ProcurementPhysicalRequests.Leg.OVERSEAS,null,null,"SHORTAGE",1,"ACCEPT_SHORTAGE",1,"confirmed"); + assertThatThrownBy(()->physical.dispose(user,"9","PO-1",decision)).isInstanceOf(AccessDeniedException.class); + verifyNoMoreInteractions(implementation); + } + @Test void labelUploadDoesNotGrantFinancialVoucherOrBudgetUpload() { + var files=context.getBean(ProcurementFileController.class);var orders=context.getBean(OverseasProcurementService.class); + var user=login(0,"PLATFORM","label");var file=new org.springframework.mock.web.MockMultipartFile("file","label.pdf","application/pdf","%PDF-1.4".getBytes()); + files.upload(user,"9","PO-1","LABEL",file);verify(orders).uploadFile("9","PO-1","7","LABEL",file); + assertThatThrownBy(()->files.upload(user,"9","PO-1","VOUCHER",file)).isInstanceOf(ResponseStatusException.class); + assertThatThrownBy(()->files.upload(user,"9","PO-1","BUDGET",file)).isInstanceOf(ResponseStatusException.class); + verifyNoMoreInteractions(orders); + } + @Test void overseasShippingDoesNotGrantDomesticHandoffOrEstimatePermission() { + var transport=context.getBean(ProcurementTransportController.class); + var service=context.getBean(com.baiyue.lastleg.procurement.service.ProcurementTransportService.class); + var user=login(0,"PLATFORM","ship-overseas"); + var request=new com.baiyue.lastleg.procurement.dto.ProcurementTransportRequests.Ship(0L,"ship",ProcurementPhysicalRequests.Leg.OVERSEAS,1,java.util.List.of(1L),LocalDateTime.now(),"actual"); + transport.overseas(user,"9","PO-1",request);verify(service).ship("9","PO-1","7",request); + assertThatThrownBy(()->transport.customer(user,"9","PO-1",request)).isInstanceOf(AccessDeniedException.class); + assertThatThrownBy(()->transport.estimate(user,"9","PO-1",null)).isInstanceOf(AccessDeniedException.class); + verifyNoMoreInteractions(service); + } + @Test void costConfirmationDoesNotGrantFinancialCorrectionAndRetryMatchesFeePurpose() { + var charges=context.getBean(ProcurementChargeController.class); + var service=context.getBean(com.baiyue.lastleg.procurement.service.ProcurementChargeService.class); + var user=login(0,"PLATFORM","charge"); + assertThatThrownBy(()->charges.additional(user,"9","PO-1",null)).isInstanceOf(AccessDeniedException.class); + assertThatThrownBy(()->charges.correct(user,"9","PO-1",1,null)).isInstanceOf(AccessDeniedException.class); + assertThatThrownBy(()->charges.refund(user,"9","PO-1",1,null)).isInstanceOf(AccessDeniedException.class); + assertThatThrownBy(()->charges.replaceFailed(user,"9","PO-1",1,null)).isInstanceOf(AccessDeniedException.class); + when(service.retryPermission("9","PO-1",1)).thenReturn("platform:overseas-business:purchase:finance-adjust"); + var command=new Command(0L,"retry","retry confirmed failure"); + assertThatThrownBy(()->charges.retry(user,"9","PO-1",1,command)).isInstanceOf(ResponseStatusException.class); + verify(service,never()).retry(anyString(),anyString(),anyString(),anyLong(),any()); + when(service.retryPermission("9","PO-1",2)).thenReturn("platform:overseas-business:purchase:charge"); + charges.retry(user,"9","PO-1",2,command);verify(service).retry("9","PO-1","7",2,command); + } + @Test void financeMayReplaceFailedFeeButCustomerIdentityStillCannotUseThePlatformBoundary() { + var charges=context.getBean(ProcurementChargeController.class);var adjustments=context.getBean(com.baiyue.lastleg.procurement.service.ProcurementAdjustmentService.class); + var request=new com.baiyue.lastleg.procurement.dto.ProcurementChargeRequests.Correct(0L,"replacement",BigDecimal.TEN,1,"replacement proof"); + var user=login(0,"PLATFORM","finance-adjust");charges.replaceFailed(user,"9","PO-1",1,request);verify(adjustments).replaceFailed("9","PO-1","7",1,request); + var customer=login(9,"MERCHANT","finance-adjust");assertThatThrownBy(()->charges.replaceFailed(customer,"9","PO-1",1,request)).isInstanceOf(ResponseStatusException.class);verifyNoMoreInteractions(adjustments); + } + @Test void financialAdditionalUsesPlatformOnlyFinancialBoundary() { + var charges=context.getBean(ProcurementChargeController.class); + var adjustments=context.getBean(com.baiyue.lastleg.procurement.service.ProcurementAdjustmentService.class); + var request=new com.baiyue.lastleg.procurement.dto.ProcurementChargeRequests.Additional(0L,"additional","Storage",BigDecimal.TEN,"CNY",java.time.LocalDate.now(),1,"invoice"); + var user=login(0,"PLATFORM","finance-adjust");charges.additional(user,"9","PO-1",request); + verify(adjustments).additional("9","PO-1","7",request); + var customer=login(9,"MERCHANT","finance-adjust"); + assertThatThrownBy(()->charges.additional(customer,"9","PO-1",request)).isInstanceOf(ResponseStatusException.class); + verifyNoMoreInteractions(adjustments); + } + @Test void receivingPermissionCannotCloseOrderAndManagerCannotReleaseFunds() { + var controls=context.getBean(ProcurementControlController.class); + var receiver=login(0,"PLATFORM","receive-overseas"); + assertThatThrownBy(()->controls.close(receiver,"9","PO-1",null)).isInstanceOf(AccessDeniedException.class); + assertThatThrownBy(()->controls.warehouse(receiver,"9","PO-1",null)).isInstanceOf(AccessDeniedException.class); + var manager=login(0,"PLATFORM","manage");var charges=context.getBean(ProcurementChargeController.class); + assertThatThrownBy(()->charges.releaseEstimate(manager,"9","PO-1",1,new Command(0L,"release","close order"))).isInstanceOf(AccessDeniedException.class); + } + private AuthenticatedUser login(long customer,String app,String suffix) { + String permission="platform:overseas-business:purchase:"+suffix; + var user=new AuthenticatedUser(7L,customer,customer,8L,"operator",app,"WEB",Set.of(permission)); + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(user,"",java.util.List.of(new SimpleGrantedAuthority(permission)))); + return user; + } + private Purchase purchase(){return new Purchase(0L,"purchase","Sample",null,1,BigDecimal.TEN,"CNY",LocalDateTime.now().minusDays(1),LocalDateTime.now().plusDays(1),1,"actual purchase");} +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/OverseasProcurementServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/OverseasProcurementServiceTest.java index dc87b1865..7f11a6952 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/OverseasProcurementServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/OverseasProcurementServiceTest.java @@ -42,7 +42,7 @@ import static org.mockito.Mockito.when; class OverseasProcurementServiceTest { @Mock OverseasProcurementOrderMapper orders; @Mock OverseasProcurementStatusHistoryMapper histories; - @Mock PartnerWarehouseDirectory warehouseDirectory; + @Mock ProcurementWarehouseDirectory warehouseDirectory; @Mock ProcurementOrderNumberGenerator orderNoGenerator; @Mock OverseasProcurementFileMapper procurementFiles; @Mock OverseasProcurementFeeMapper procurementFees; @@ -62,9 +62,6 @@ class OverseasProcurementServiceTest { ReflectionTestUtils.setField(service, "procurementFiles", procurementFiles); ReflectionTestUtils.setField(service, "procurementFees", procurementFees); ReflectionTestUtils.setField(service, "fileGateway", fileGateway); - ReflectionTestUtils.setField(service, "walletCommands", walletCommands); - ReflectionTestUtils.setField(service, "feeSources", feeSources); - lenient().when(feeSources.publishApproved(anyString(), anyString(), any())).thenReturn(new OverseasFeeSource()); lenient().when(orderNoGenerator.next(anyString(), any())).thenReturn("ACME202609050001"); lenient().when(procurementFiles.selectList(any())).thenReturn(List.of()); lenient().when(procurementFees.selectList(any())).thenReturn(List.of()); @@ -85,117 +82,34 @@ class OverseasProcurementServiceTest { new BigDecimal("26.50"), "USD", "Ningbo", "Buyer", "13800000000", null, "key-1")); assertEquals(OverseasProcurementService.PENDING_REVIEW, detail.summary().status()); - assertEquals(0, detail.summary().inboundQuantity()); - assertEquals(0, detail.summary().outboundQuantity()); - assertEquals(0, detail.summary().inventoryQuantity()); } @Test - void outboundQuantityAndDisplayStatusFollowOutboundDetails() { - OverseasProcurementOrder order = order(OverseasProcurementService.APPROVED, 90, 0); - when(orders.findNo("100", "HC1")).thenReturn(order); - when(histories.listOrder("100", 1L)).thenReturn(List.of()); - - order.setOutboundDetails("[]"); - assertEquals("PENDING_OUTBOUND", service.detail("100", "HC1").summary().status()); - - order.setOutboundDetails("[{\"id\":\"1\",\"quantity\":1}]"); - var partial = service.detail("100", "HC1").summary(); - assertEquals(1, partial.outboundQuantity()); - assertEquals(89, partial.inventoryQuantity()); - assertEquals("PARTIALLY_OUTBOUND", partial.status()); - - order.setOutboundDetails("[{\"id\":\"1\",\"quantity\":90}]"); - var completed = service.detail("100", "HC1").summary(); - assertEquals(90, completed.outboundQuantity()); - assertEquals(0, completed.inventoryQuantity()); - assertEquals("OUTBOUNDED", completed.status()); - } - - @Test - void pendingOrderCannotReceiveInventory() { - OverseasProcurementOrder order = order(OverseasProcurementService.PENDING_REVIEW, 0, 0); - when(orders.lockNo("100", "HC1")).thenReturn(order); - var request = new OverseasProcurementRequests.Update(0L, null, null, null, null, - 100L, null, null, null, null, null, null, null, null); - - ResponseStatusException error = assertThrows(ResponseStatusException.class, - () -> service.update("100", "HC1", "7", request)); - assertEquals(409, error.getStatusCode().value()); - } - - @Test - void explicitNullClearsOptionalFieldsAndWritesOperationHistory() { - OverseasProcurementOrder order = order(OverseasProcurementService.APPROVED, 100, 0); - order.setProductUrl("https://example.test/old"); + void genericPatchOnlyChangesInternalRemarkAndCannotChangeFulfillment() { + OverseasProcurementOrder order = order(OverseasProcurementService.WAIT_PURCHASE, 100, 0); + order.setRemark("old"); + order.setEstimatedArrivalAt(LocalDateTime.of(2026,10,10,12,0)); when(orders.lockNo("100", "HC1")).thenReturn(order); when(orders.findNo("100", "HC1")).thenReturn(order); when(histories.listOrder("100", 1L)).thenReturn(List.of()); - var request = new OverseasProcurementRequests.Update(0L, null, null, - new ObjectMapper().nullNode(), new ObjectMapper().nullNode(), null, - null, null, null, null, null, null, null, null); - - service.update("100", "HC1", "7", request); - - assertEquals(null, order.getProductUrl()); - verify(histories).insert(org.mockito.ArgumentMatchers.argThat(value -> - "UPDATE".equals(value.getOperationType()) && value.getChangeDetail().contains("productUrl"))); + service.update("100", "HC1", "7", new OverseasProcurementRequests.Update(0L, new ObjectMapper().nullNode())); + assertEquals(null, order.getRemark()); + assertEquals(LocalDateTime.of(2026,10,10,12,0), order.getEstimatedArrivalAt()); + assertEquals(100L, order.getInboundQuantity()); + assertEquals(List.of("version", "remark"), java.util.Arrays.stream(OverseasProcurementRequests.Update.class.getRecordComponents()).map(java.lang.reflect.RecordComponent::getName).toList()); } @Test - void omittedOptionalFieldKeepsExistingValue() { - OverseasProcurementOrder order = order(OverseasProcurementService.APPROVED, 100, 0); - order.setProductUrl("https://example.test/keep"); + void omittedRemarkKeepsExistingValueAndStaleVersionCannotWrite() { + OverseasProcurementOrder order = order(OverseasProcurementService.WAIT_PURCHASE, 100, 0); + order.setRemark("keep"); when(orders.lockNo("100", "HC1")).thenReturn(order); when(orders.findNo("100", "HC1")).thenReturn(order); when(histories.listOrder("100", 1L)).thenReturn(List.of()); - var request = new OverseasProcurementRequests.Update(0L, null, null, null, null, - null, null, null, null, null, null, null, null, null); - - service.update("100", "HC1", "7", request); - - assertEquals("https://example.test/keep", order.getProductUrl()); + service.update("100", "HC1", "7", new OverseasProcurementRequests.Update(0L, null)); + assertEquals("keep", order.getRemark()); verify(orders, never()).updateById(org.mockito.ArgumentMatchers.any()); - } - - @Test - void warehouseSelectionFreezesCanonicalCandidateName() { - OverseasProcurementOrder order = order(OverseasProcurementService.APPROVED, 100, 0); - when(orders.lockNo("100", "HC1")).thenReturn(order); - when(orders.findNo("100", "HC1")).thenReturn(order); - when(histories.listOrder("100", 1L)).thenReturn(List.of()); - when(warehouseDirectory.required(10L)).thenReturn( - new OverseasQuoteResponses.PartnerWarehouseOption("10", "LAX-01", "洛杉矶采购仓", "US")); - ObjectMapper mapper = new ObjectMapper(); - var request = new OverseasProcurementRequests.Update(0L, null, null, null, null, - null, mapper.getNodeFactory().textNode("10"), mapper.getNodeFactory().textNode("伪造名称"), - null, null, null, null, null, null); - - service.update("100", "HC1", "7", request); - - assertEquals("10", order.getPurchaseWarehouseId()); - assertEquals("洛杉矶采购仓", order.getPurchaseWarehouseName()); - } - - @Test - void unchangedHistoricalWarehouseDoesNotBlockUnrelatedEditsAfterDeactivation() { - OverseasProcurementOrder order = order(OverseasProcurementService.APPROVED, 100, 0); - order.setPurchaseWarehouseId("10"); - order.setPurchaseWarehouseName("历史采购仓"); - when(orders.lockNo("100", "HC1")).thenReturn(order); - when(orders.findNo("100", "HC1")).thenReturn(order); - when(histories.listOrder("100", 1L)).thenReturn(List.of()); - ObjectMapper mapper = new ObjectMapper(); - var request = new OverseasProcurementRequests.Update(0L, null, null, null, null, - null, mapper.getNodeFactory().textNode("10"), null, - null, null, null, null, null, mapper.getNodeFactory().textNode("更新出库信息")); - - service.update("100", "HC1", "7", request); - - assertEquals("10", order.getPurchaseWarehouseId()); - assertEquals("历史采购仓", order.getPurchaseWarehouseName()); - verify(warehouseDirectory, never()).required(10L); - verify(orders).updateById(order); + assertThrows(ResponseStatusException.class, () -> service.update("100", "HC1", "7", new OverseasProcurementRequests.Update(2L, null))); } @Test @@ -218,25 +132,26 @@ class OverseasProcurementServiceTest { when(orders.lockNo("100", "HC1")).thenReturn(order); when(orders.findNo("100", "HC1")).thenReturn(order); when(histories.listOrder("100", 1L)).thenReturn(List.of()); - var request = new OverseasProcurementRequests.Review(0L, "APPROVE", "approve-1", "approved"); + when(warehouseDirectory.required(10L)).thenReturn(new com.baiyue.lastleg.client.DomesticWarehouseDirectoryClient.ReceivingWarehouse(10L,"LAX","洛杉矶","US","ACTIVE")); + var request = new OverseasProcurementRequests.Review(0L, "APPROVE", "approve-1", "approved", 10L); var approved = service.review("100", "HC1", "operator-7", request); - assertEquals(OverseasProcurementService.APPROVED, approved.summary().status()); + assertEquals(OverseasProcurementService.WAIT_PURCHASE, approved.summary().status()); assertEquals(1L, approved.summary().version()); assertEquals(1L, service.review("100", "HC1", "operator-7", request).summary().version()); verify(histories).insert(org.mockito.ArgumentMatchers .argThat(value -> "REVIEW".equals(value.getOperationType()) && "operator-7".equals(value.getOperatorId()) && "approve-1".equals(value.getIdempotencyKey()) - && OverseasProcurementService.APPROVED.equals(value.getToStatus()))); + && OverseasProcurementService.WAIT_PURCHASE.equals(value.getToStatus()))); assertThrows(ResponseStatusException.class, () -> service.review("100", "HC1", "operator-7", - new OverseasProcurementRequests.Review(1L, "REJECT", "approve-1", "different payload"))); + new OverseasProcurementRequests.Review(1L, "REJECT", "approve-1", "different payload", 10L))); assertThrows(ResponseStatusException.class, () -> service.review("100", "HC1", "operator-7", - new OverseasProcurementRequests.Review(0L, "REJECT", "stale-review", "old version"))); + new OverseasProcurementRequests.Review(0L, "REJECT", "stale-review", "old version", 10L))); assertThrows(ResponseStatusException.class, () -> service.review("100", "HC1", "operator-7", - new OverseasProcurementRequests.Review(1L, "REJECT", "reject-after-approved", "reverse"))); - assertEquals(OverseasProcurementService.APPROVED, order.getStatus()); + new OverseasProcurementRequests.Review(1L, "REJECT", "reject-after-approved", "reverse", 10L))); + assertEquals(OverseasProcurementService.WAIT_PURCHASE, order.getStatus()); } private OverseasProcurementOrder order(String status, long inbound, long outbound) { @@ -256,11 +171,23 @@ class OverseasProcurementServiceTest { @Test void nullablePatchFieldsArePersistedWhenExplicitlyCleared() throws Exception { for (String field : List.of("productSpecification", "productUrl", "purchaseWarehouseId", - "purchaseWarehouseName", "domesticCarrier", "domesticTrackingNo", "overseasCarrier", - "overseasTrackingNo", "estimatedArrivalAt", "remark")) { + "purchaseWarehouseName", "estimatedArrivalAt", "remark")) { TableField annotation = OverseasProcurementOrder.class.getDeclaredField(field).getAnnotation(TableField.class); assertNotNull(annotation, field); assertEquals(FieldStrategy.ALWAYS, annotation.updateStrategy(), field); } } + + @Test + void fileDownloadRequiresExactOrderAssociationBeforeCallingFileService() { + var order=order(OverseasProcurementService.WAIT_PURCHASE,0,0); + when(orders.findNo("100","HC1")).thenReturn(order); + assertThrows(ResponseStatusException.class,()->service.downloadFile("100","HC1",999)); + org.mockito.Mockito.verifyNoInteractions(fileGateway); + var file=new com.baiyue.lastleg.procurement.entity.OverseasProcurementFile();file.setCategory("LABEL");file.setFileName("label.pdf"); + when(procurementFiles.selectOne(any())).thenReturn(file); + when(fileGateway.download("100",1,"LABEL",42)).thenReturn(new ProcurementFileGateway.Download(new byte[]{1},"application/pdf")); + assertEquals("label.pdf",service.downloadFile("100","HC1",42).fileName()); + verify(fileGateway).download("100",1,"LABEL",42); + } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementChargePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementChargePersistenceTest.java new file mode 100644 index 000000000..f032b12ea --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementChargePersistenceTest.java @@ -0,0 +1,257 @@ +package com.baiyue.lastleg.procurement.service; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.baiyue.common.event.WalletCommandCompletedV1; +import com.baiyue.lastleg.financefee.entity.OverseasFeeSource; +import com.baiyue.lastleg.financefee.service.*; +import com.baiyue.lastleg.procurement.dto.ProcurementChargeRequests.*; +import com.baiyue.lastleg.procurement.dto.ProcurementLifecycleRequests.Command; +import com.baiyue.lastleg.procurement.entity.*; +import com.baiyue.lastleg.procurement.mapper.*; +import com.baiyue.lastleg.wallet.WalletCommandOutboxService; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.transaction.support.TransactionTemplate; +import java.math.BigDecimal; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.*; +import java.util.*; +import java.util.concurrent.atomic.AtomicLong; +import java.util.regex.Pattern; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class ProcurementChargePersistenceTest { + JdbcTemplate jdbc;TransactionTemplate tx;OverseasProcurementOrderMapper orders;OverseasProcurementFeeMapper fees; + ProcurementFinanceService finance;ProcurementLifecycleService lifecycle;ProcurementChargeService charges;ProcurementAdjustmentService adjustments; + OverseasFeeCurrencyConverter converter;WalletCommandOutboxService commands;long estimateId; + @BeforeEach void setup()throws Exception { + var ds=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1","sa",""); + jdbc=new JdbcTemplate(ds);tx=new TransactionTemplate(new DataSourceTransactionManager(ds)); + String schema=Files.readString(Path.of("src/main/resources/db/lastleg.sql")); + for(String name:List.of("ll_overseas_procurement_order","ll_overseas_procurement_file","ll_procurement_control_note","ll_procurement_flow","ll_procurement_command", + "ll_overseas_procurement_fee","ll_procurement_wallet_attempt","ll_procurement_fee_correction","ll_procurement_fee_correction_item","ll_procurement_budget_authorization")) { + var m=Pattern.compile("CREATE TABLE IF NOT EXISTS "+name+" \\(.*?\\) ENGINE=InnoDB[^;]*;",Pattern.DOTALL).matcher(schema); + assertThat(m.find()).as(name).isTrue();jdbc.execute(m.group().replaceFirst("\\) ENGINE=InnoDB[^;]*;",");")); + } + jdbc.execute("CREATE TABLE ll_event_outbox(aggregate_type VARCHAR(64),aggregate_id VARCHAR(64),status VARCHAR(32))"); + var config=new MybatisConfiguration();config.addMapper(OverseasProcurementOrderMapper.class);config.addMapper(OverseasProcurementFileMapper.class);config.addMapper(OverseasProcurementFeeMapper.class); + var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);var session=new SqlSessionTemplate(factory.getObject()); + orders=session.getMapper(OverseasProcurementOrderMapper.class);fees=session.getMapper(OverseasProcurementFeeMapper.class);var files=session.getMapper(OverseasProcurementFileMapper.class); + var order=new OverseasProcurementOrder();order.setId(11L);order.setCustomerId("9");order.setCustomerName("test");order.setOrderNo("PO-1");order.setPurchaseCategory("sample"); + order.setProductName("Sample");order.setPurchaseQuantity(10L);order.setBudgetUnitPrice(BigDecimal.TEN);order.setCurrency("CNY");order.setContactName("buyer");order.setContactPhone("13800000000"); + order.setStatus("WAIT_CHARGES");order.setVersion(0L);order.setIdempotencyKey("create");order.setRequestHash("0".repeat(64));order.setInboundQuantity(10L);orders.insert(order); + jdbc.update("INSERT INTO ll_procurement_flow(order_id,customer_id,domestic_received_at) VALUES(11,'9',?)",LocalDateTime.now().minusHours(1)); + jdbc.update("INSERT INTO ll_overseas_procurement_file(id,order_id,customer_id,category,file_id,file_name,content_type,size_bytes,created_by) VALUES(501,11,'9','VOUCHER',1001,'evidence.pdf','application/pdf',10,'7')"); + converter=mock(OverseasFeeCurrencyConverter.class);when(converter.convert(anyString(),any(),any())).thenAnswer(i->{String currency=i.getArgument(0);BigDecimal amount=i.getArgument(1);BigDecimal rate="USD".equals(currency)?new BigDecimal("7.123456"):BigDecimal.ONE;return new OverseasFeeCurrencyConverter.Conversion(currency,amount,rate,"2026-09",amount.multiply(rate).setScale(2,java.math.RoundingMode.HALF_UP));}); + var sources=mock(OverseasFeeSourceService.class);AtomicLong ids=new AtomicLong(900); + when(sources.publishSettled(anyString(),anyString(),any(),any(),any())).thenAnswer(i->{var s=new OverseasFeeSource();s.setSourceFeeId(ids.incrementAndGet());return s;}); + commands=mock(WalletCommandOutboxService.class);AtomicLong events=new AtomicLong(); + when(commands.publish(anyString(),anyLong(),anyString(),anyString(),any(),anyString(),anyLong())).thenAnswer(i->"cmd-"+events.incrementAndGet()); + when(commands.publishRefund(anyLong(),anyString(),anyString(),any(),anyString(),anyString(),anyLong())).thenAnswer(i->"cmd-"+events.incrementAndGet()); + var json=new ObjectMapper().findAndRegisterModules();finance=new ProcurementFinanceService(fees,files,orders,converter,sources,commands,jdbc,json); + lifecycle=new ProcurementLifecycleService(orders,files,mock(ProcurementWarehouseDirectory.class),finance,converter,jdbc,json); + charges=new ProcurementChargeService(lifecycle,finance,jdbc);adjustments=new ProcurementAdjustmentService(lifecycle,finance,jdbc); + tx.execute(s->finance.confirm("9","PO-1","7",new ProcurementFinanceService.Confirmation(ProcurementFeePurpose.SAMPLE_PAYMENT,"SAMPLE_PAYMENT","货款","ZERO",BigDecimal.ZERO,"CNY",LocalDate.of(2026,9,1),null,"free"))); + estimateId=tx.execute(s->finance.confirm("9","PO-1","7",new ProcurementFinanceService.Confirmation(ProcurementFeePurpose.INTERNATIONAL_ESTIMATE,"INTERNATIONAL_ESTIMATE","预估","ZERO",BigDecimal.ZERO,"CNY",LocalDate.of(2026,9,1),null,"zero estimate"))).getId(); + } + @Test void allCategoriesAreExplicitAndReconfirmationReferencesExistingMoney() { + var request=request("100","5");var first=tx.execute(s->charges.confirm("9","PO-1","7",request)); + assertThat(first.confirmedAt()).isNotNull();assertThat(first.status()).isEqualTo("WAIT_CUSTOMER_SHIPMENT"); + assertThat(finance.all(11)).hasSize(5);assertThat(finance.all(11)).noneMatch(f->"OTHER".equals(f.getPurpose())); + assertThatThrownBy(()->finance.requireShipmentReady(11)).hasMessageContaining("未决资金"); + var international=current(ProcurementFeePurpose.INTERNATIONAL_FREIGHT);var domestic=current(ProcurementFeePurpose.DOMESTIC_FREIGHT);settle(international);settle(domestic); + finance.requireShipmentReady(11);int count=countAttempts(); + var again=new Confirm(version(),"reconfirm",new Selection(international.getId(),null),new Selection(current(ProcurementFeePurpose.DUTY).getId(),null),new Selection(domestic.getId(),null),"NOT_APPLICABLE",List.of(),"same expenses after repack"); + tx.execute(s->charges.confirm("9","PO-1","7",again));assertThat(countAttempts()).isEqualTo(count); + tx.execute(s->charges.confirm("9","PO-1","7",request));assertThat(countAttempts()).isEqualTo(count); + } + @Test void missingDeterminationWrongPurposeAndUnreceivedOrdersCannotConfirm() { + var bad=new Confirm(0L,"missing",null,selection("0"),selection("0"),"NOT_APPLICABLE",List.of(),"missing international"); + assertThatThrownBy(()->tx.execute(s->charges.confirm("9","PO-1","7",bad))).hasMessageContaining("每项费用"); + var wrong=new Confirm(0L,"wrong",new Selection(estimateId,null),selection("0"),selection("0"),"NOT_APPLICABLE",List.of(),"estimate is not actual"); + assertThatThrownBy(()->tx.execute(s->charges.confirm("9","PO-1","7",wrong))).hasMessageContaining("该用途"); + jdbc.update("UPDATE ll_procurement_flow SET domestic_received_at=NULL WHERE order_id=11"); + assertThatThrownBy(()->tx.execute(s->charges.confirm("9","PO-1","7",request("100","0")))).hasMessageContaining("国内收货完成"); + assertThat(countAttempts()).isZero(); + } + @Test void omittedExistingOtherChargeCannotDisappearFromReconfirmation() { + var other=new Other("HANDLING","Handling",selection("10")); + tx.execute(s->charges.confirm("9","PO-1","7",new Confirm(0L,"first",selection("0"),selection("0"),selection("0"),"ITEMIZED",List.of(other),"actual"))); + assertThatThrownBy(()->tx.execute(s->charges.confirm("9","PO-1","7",new Confirm(version(),"omit-other",new Selection(current(ProcurementFeePurpose.INTERNATIONAL_FREIGHT).getId(),null),new Selection(current(ProcurementFeePurpose.DUTY).getId(),null),new Selection(current(ProcurementFeePurpose.DOMESTIC_FREIGHT).getId(),null),"NOT_APPLICABLE",List.of(),"omit")))).hasMessageContaining("未列入"); + assertThat(finance.all(11)).hasSize(6); + } + @Test void additionalExpenseUsesOwnBusinessDateFinancialPermissionAndImmutableReplay() { + jdbc.update("UPDATE ll_procurement_flow SET purchased_at=? WHERE order_id=11",LocalDateTime.now().minusDays(2)); + tx.execute(s->charges.confirm("9","PO-1","7",request("0","0"))); + var request=new Additional(version(),"additional","Storage",new BigDecimal("3.25"),"USD",LocalDate.of(2026,9,2),1001,"new expense"); + var fee=tx.execute(s->adjustments.additional("9","PO-1","7",request)); + assertThat(fee.getConfirmationOrigin()).isEqualTo("FINANCIAL"); + assertThat(fee.getBusinessDate()).isEqualTo(LocalDate.of(2026,9,2)); + assertThat(fee.getAmount()).isEqualByComparingTo("23.15"); + assertThat(charges.retryPermission("9","PO-1",fee.getId())).endsWith(":finance-adjust"); + assertThat(lifecycle.flow(11).get("charges_confirmed_at")).isNull(); + assertThat(orders.selectById(11).getStatus()).isEqualTo("WAIT_CHARGES"); + int attempts=countAttempts(); + assertThat(tx.execute(s->adjustments.additional("9","PO-1","7",request)).getId()).isEqualTo(fee.getId()); + assertThat(countAttempts()).isEqualTo(attempts); + assertThatThrownBy(()->tx.execute(s->charges.confirm("9","PO-1","7",request("0","0")))).hasMessageContaining("首次核费后"); + settle(fee);finance.requireShipmentReady(11); + } + @Test void additionalExpenseRequiresPurchaseAndCannotEmitOutOfRangeMoney() { + var request=new Additional(version(),"additional","Storage",new BigDecimal("3.25"),"USD",LocalDate.of(2026,9,2),1001,"new expense"); + assertThatThrownBy(()->tx.execute(s->adjustments.additional("9","PO-1","7",request))).hasMessageContaining("已发生购买"); + jdbc.update("UPDATE ll_procurement_flow SET purchased_at=? WHERE order_id=11",LocalDateTime.now().minusDays(2)); + var excessive=new Additional(version(),"large","Storage",new BigDecimal("99999999999999.99"),"USD",LocalDate.of(2026,9,2),1001,"large"); + assertThatThrownBy(()->tx.execute(s->adjustments.additional("9","PO-1","7",excessive))).hasMessageContaining("超出支持范围"); + assertThat(countAttempts()).isZero();assertThat(finance.all(11)).hasSize(2); + } + @Test void target100To120To130To110ChargesOnlyDifferencesAndKeepsOriginal() { + tx.execute(s->charges.confirm("9","PO-1","7",request("100","0")));var root=current(ProcurementFeePurpose.INTERNATIONAL_FREIGHT);settle(root); + var first=correct(root,"120","first");assertThat(first.differenceAmount()).isEqualByComparingTo("20");settle(byNo(first.feeNos().get(0))); + var second=correct(root,"130","second");assertThat(second.differenceAmount()).isEqualByComparingTo("10");settle(byNo(second.feeNos().get(0))); + var third=correct(root,"110","third");assertThat(third.differenceAmount()).isEqualByComparingTo("-20"); + var refund=byNo(third.feeNos().get(0));assertThat(refund.getPurpose()).isEqualTo("REFUND");assertThat(refund.getAmount()).isEqualByComparingTo("20"); + assertThatThrownBy(()->correct(root,"115","pending")).hasMessageContaining("未完成");complete(refund,"REFUND"); + assertThat(finance.readyChain(orders.findNo("9","PO-1"),fees.selectById(root.getId())).stream().filter(f->"SETTLED".equals(f.getWalletStatus())).map(OverseasProcurementFee::getAmount).reduce(BigDecimal.ZERO,BigDecimal::add)).isEqualByComparingTo("130"); + assertThat(fees.selectById(root.getId()).getAmount()).isEqualByComparingTo("100"); + var zero=correct(root,"0","all-refund");assertThat(zero.previousNetAmount()).isEqualByComparingTo("110");assertThat(zero.feeNos()).hasSize(3); + assertThat(zero.feeNos().stream().map(this::byNo).map(OverseasProcurementFee::getAmount).reduce(BigDecimal.ZERO,BigDecimal::add)).isEqualByComparingTo("110"); + } + @Test void failedOldTargetMustBeTerminatedBeforeAnotherTargetAndCannotRetryLater() { + tx.execute(s->charges.confirm("9","PO-1","7",request("100","0")));var root=current(ProcurementFeePurpose.INTERNATIONAL_FREIGHT);settle(root); + var adjustment=byNo(correct(root,"120","first").feeNos().get(0)); + tx.executeWithoutResult(s->finance.handle(new WalletCommandCompletedV1(adjustment.getWalletCommandEventId(),"FREEZE",9,"PROCUREMENT_FEE",adjustment.getFeeNo(),"FAILED",null,adjustment.getAmount(),"INSUFFICIENT","insufficient",Instant.now(),null,null))); + assertThatThrownBy(()->correct(root,"130","blocked")).hasMessageContaining("未完成"); + tx.execute(s->charges.terminate("9","PO-1","7",adjustment.getId(),new Command(version(),"terminate","replace failed target"))); + assertThatThrownBy(()->tx.execute(s->finance.retry("9","PO-1",adjustment.getId()))).hasMessageContaining("有效费用"); + assertThat(correct(root,"130","new").differenceAmount()).isEqualByComparingTo("30"); + } + @Test void originalUsdSnapshotIsUsedForTargetWithoutReloadingMonthlyRate() { + var usd=new Selection(null,new Amount("POSITIVE",new BigDecimal("10"),"USD",LocalDate.of(2026,9,1),1001L,"original USD")); + tx.execute(s->charges.confirm("9","PO-1","7",new Confirm(0L,"usd",usd,selection("0"),selection("0"),"NOT_APPLICABLE",List.of(),"original"))); + var root=current(ProcurementFeePurpose.INTERNATIONAL_FREIGHT);settle(root);clearInvocations(converter); + var corrected=correct(root,"11","usd-correction");assertThat(corrected.exchangeRate()).isEqualByComparingTo("7.123456");assertThat(corrected.exchangeRateMonth()).isEqualTo("2026-09"); + assertThat(corrected.targetAmount()).isEqualByComparingTo("78.36");assertThat(corrected.differenceAmount()).isEqualByComparingTo("7.13"); + var delta=byNo(corrected.feeNos().get(0));assertThat(delta.getOriginalCurrency()).isEqualTo("CNY");assertThat(delta.getAmount()).isEqualByComparingTo("7.13");verifyNoInteractions(converter); + } + @Test void correctionReplayReturnsOriginalTargetAndDoesNotRegressCompletedFulfillment() { + tx.execute(s->charges.confirm("9","PO-1","7",request("100","0")));var root=current(ProcurementFeePurpose.INTERNATIONAL_FREIGHT);settle(root); + jdbc.update("UPDATE ll_overseas_procurement_order SET status='SHIPPED_TO_CUSTOMER' WHERE id=11"); + var request=new Correct(version(),"same",new BigDecimal("120"),1001,"correction"); + var original=tx.execute(s->adjustments.correct("9","PO-1","7",root.getId(),request));int attempts=countAttempts(); + var replay=tx.execute(s->adjustments.correct("9","PO-1","7",root.getId(),request));assertThat(replay).isEqualTo(original); + assertThat(countAttempts()).isEqualTo(attempts);assertThat(orders.findNo("9","PO-1").getStatus()).isEqualTo("SHIPPED_TO_CUSTOMER"); + assertThatThrownBy(()->tx.execute(s->adjustments.correct("9","PO-1","7",root.getId(),new Correct(version(),"same",new BigDecimal("130"),1001,"correction")))).hasMessageContaining("相同操作幂等键"); + } + @Test void terminatedUnpaidActualCanBeReplacedWithFrozenFxAndNewWalletIdentity() { + var usd=new Selection(null,new Amount("POSITIVE",new BigDecimal("10"),"USD",LocalDate.of(2026,9,1),1001L,"USD original")); + tx.execute(s->charges.confirm("9","PO-1","7",new Confirm(version(),"original-usd",usd,selection("0"),selection("0"),"NOT_APPLICABLE",List.of(),"all"))); + var original=current(ProcurementFeePurpose.INTERNATIONAL_FREIGHT);var failure=failed(original,"FREEZE");tx.executeWithoutResult(s->finance.handle(failure)); + assertThatThrownBy(()->replace(original,"11","too-soon")).hasMessageContaining("先终止"); + tx.execute(s->charges.terminate("9","PO-1","7",original.getId(),new Command(version(),"terminate-unpaid","correct actual"))); + assertThatThrownBy(()->tx.execute(s->finance.confirmInternational("9","PO-1","7",new ProcurementFinanceService.Confirmation(ProcurementFeePurpose.INTERNATIONAL_FREIGHT,"INTERNATIONAL_FREIGHT","actual","POSITIVE",new BigDecimal("11"),"USD",LocalDate.of(2026,9,1),1001L,"ordinary cannot replace")))).hasMessageContaining("财务权限"); + assertThatThrownBy(()->replace(original,"9999999999999999.00","too-large")).hasMessageContaining("人民币金额超出支持范围"); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_fee_correction",Integer.class)).isZero(); + clearInvocations(converter);var request=new Correct(version(),"replace-unpaid",new BigDecimal("11"),1001,"correct actual amount"); + var result=tx.execute(s->adjustments.replaceFailed("9","PO-1","7",original.getId(),request));var replacement=byNo(result.feeNos().get(0)); + assertThat(result.previousNetAmount()).isZero();assertThat(result.targetAmount()).isEqualByComparingTo("78.36"); + assertThat(replacement.getFeeVersion()).isEqualTo(original.getFeeVersion()+1);assertThat(replacement.getExchangeRate()).isEqualByComparingTo(original.getExchangeRate()); + assertThat(replacement.getExchangeRateMonth()).isEqualTo("2026-09");assertThat(replacement.getWalletBusinessNo()).isNotEqualTo(original.getWalletBusinessNo()); + assertThat(replacement.getWalletStatus()).isEqualTo("FREEZE_PENDING");assertThat(replacement.getPurpose()).isEqualTo("INTERNATIONAL_FREIGHT"); + assertThat(charges.retryPermission("9","PO-1",replacement.getId())).isEqualTo("platform:overseas-business:purchase:finance-adjust"); + assertThat(fees.selectById(original.getId()).getWalletStatus()).isEqualTo("TERMINATED");assertThat(fees.selectById(original.getId()).getAmount()).isEqualByComparingTo("71.23"); + int count=countAttempts();var replay=tx.execute(s->adjustments.replaceFailed("9","PO-1","7",original.getId(),request));assertThat(replay).isEqualTo(result);assertThat(countAttempts()).isEqualTo(count); + tx.executeWithoutResult(s->finance.handle(failure));assertThat(fees.selectById(replacement.getId()).getWalletStatus()).isEqualTo("FREEZE_PENDING"); + assertThatThrownBy(()->replace(original,"12","duplicate-old")).hasMessageContaining("后续费用版本"); + assertThatThrownBy(()->tx.execute(s->finance.retry("9","PO-1",original.getId()))).hasMessageContaining("有效费用");verifyNoInteractions(converter); + } + @Test void replacementWaitsForReleaseOfTheOriginalInternationalReservation() { + tx.executeWithoutResult(s->finance.releaseEstimate("9","PO-1",estimateId)); + var estimate=tx.execute(s->finance.confirm("9","PO-1","7",new ProcurementFinanceService.Confirmation(ProcurementFeePurpose.INTERNATIONAL_ESTIMATE,"INTERNATIONAL_ESTIMATE","estimate","POSITIVE",new BigDecimal("100"),"CNY",LocalDate.of(2026,9,1),null,"estimate"))); + complete(estimate,"FREEZE");tx.execute(s->charges.confirm("9","PO-1","7",request("180","0")));var original=current(ProcurementFeePurpose.INTERNATIONAL_FREIGHT); + tx.executeWithoutResult(s->finance.handle(failed(original,"SETTLE_ACTUAL"))); + tx.execute(s->charges.terminate("9","PO-1","7",original.getId(),new Command(version(),"terminate-held","end failed settlement"))); + assertThat(fees.selectById(original.getId()).getWalletStatus()).isEqualTo("RELEASE_PENDING"); + assertThatThrownBy(()->replace(original,"120","before-release")).hasMessageContaining("释放"); + var releasing=fees.selectById(original.getId());tx.executeWithoutResult(s->finance.handle(new WalletCommandCompletedV1(releasing.getWalletCommandEventId(),"RELEASE",9,"PROCUREMENT_FEE",releasing.getWalletBusinessNo(),"SUCCEEDED","RELEASED",new BigDecimal("100"),null,null,Instant.now(),"original-hold","release-ledger"))); + var replacement=byNo(replace(original,"120","after-release").feeNos().get(0)); + assertThat(replacement.getWalletStatus()).isEqualTo("FREEZE_PENDING");assertThat(replacement.getWalletBusinessNo()).isEqualTo(replacement.getFeeNo()); + assertThat(replacement.getParentFeeId()).isEqualTo(estimate.getId());assertThat(replacement.getAmount()).isEqualByComparingTo("120"); + assertThat(jdbc.queryForObject("SELECT charges_confirmed_at FROM ll_procurement_flow",java.sql.Timestamp.class)).isNull(); + assertThat(orders.findNo("9","PO-1").getStatus()).isEqualTo("WAIT_CHARGES"); + } + @Test void zeroReplacementDoesNotCreateMoneyAndReplacementRollbackLeavesNoVersionOrAudit() { + tx.execute(s->charges.confirm("9","PO-1","7",request("100","0")));var original=current(ProcurementFeePurpose.INTERNATIONAL_FREIGHT); + tx.executeWithoutResult(s->finance.handle(failed(original,"FREEZE")));tx.execute(s->charges.terminate("9","PO-1","7",original.getId(),new Command(version(),"terminate","end failed"))); + doThrow(new IllegalStateException("outbox unavailable")).when(commands).publish(anyString(),anyLong(),anyString(),anyString(),any(),anyString(),anyLong()); + assertThatThrownBy(()->replace(original,"90","rollback")).hasMessageContaining("outbox unavailable"); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_fee_correction",Integer.class)).isZero();assertThat(finance.current(11,ProcurementFeePurpose.INTERNATIONAL_FREIGHT,"INTERNATIONAL_FREIGHT")).isNull(); + int attempts=countAttempts();var result=replace(original,"0","waive");var replacement=byNo(result.feeNos().get(0)); + assertThat(replacement.getWalletStatus()).isEqualTo("ZERO");assertThat(countAttempts()).isEqualTo(attempts);assertThat(finance.sourceLinked(replacement)).isTrue(); + } + @Test void failedSampleReplacementStillRespectsCustomerBudgetAndPreservesPurchaseFacts() { + jdbc.update("DELETE FROM ll_overseas_procurement_fee WHERE purpose='SAMPLE_PAYMENT'"); + jdbc.update("UPDATE ll_procurement_flow SET purchased_at=?,purchase_confirmed_at=?,purchase_amount=10,purchase_currency='USD' WHERE order_id=11",LocalDateTime.of(2026,9,1,10,0),LocalDateTime.of(2026,9,1,10,0)); + var original=tx.execute(s->finance.confirm("9","PO-1","7",new ProcurementFinanceService.Confirmation(ProcurementFeePurpose.SAMPLE_PAYMENT,"SAMPLE_PAYMENT","sample","POSITIVE",new BigDecimal("10"),"USD",LocalDate.of(2026,9,1),1001L,"actual sample"))); + tx.executeWithoutResult(s->finance.handle(failed(original,"FREEZE")));tx.execute(s->charges.terminate("9","PO-1","7",original.getId(),new Command(version(),"terminate-sample","correct price"))); + assertThatThrownBy(()->replace(original,"20","over-budget")).hasMessageContaining("超过客户已授权预算"); + tx.execute(s->lifecycle.authorizeBudget("9","PO-1","7",new com.baiyue.lastleg.procurement.dto.ProcurementLifecycleRequests.Budget(version(),"new-budget",new BigDecimal("200"),"CNY",1001,"customer authorization"))); + var replacement=byNo(replace(original,"20","within-budget").feeNos().get(0));assertThat(replacement.getPurpose()).isEqualTo("SAMPLE_PAYMENT");assertThat(replacement.getAmount()).isEqualByComparingTo("142.47"); + assertThat(jdbc.queryForObject("SELECT purchase_amount FROM ll_procurement_flow",BigDecimal.class)).isEqualByComparingTo("10"); + assertThat(jdbc.queryForObject("SELECT purchase_confirmed_at FROM ll_procurement_flow",java.sql.Timestamp.class)).isNotNull(); + } + @Test void concurrentReplacementsCannotBothChargeForTheSameTerminatedVersion()throws Exception { + tx.execute(s->charges.confirm("9","PO-1","7",request("100","0")));var original=current(ProcurementFeePurpose.INTERNATIONAL_FREIGHT); + tx.executeWithoutResult(s->finance.handle(failed(original,"FREEZE")));tx.execute(s->charges.terminate("9","PO-1","7",original.getId(),new Command(version(),"stop","end original"))); + var executor=java.util.concurrent.Executors.newFixedThreadPool(2);var gate=new java.util.concurrent.CountDownLatch(1);long version=version(); + try { + var futures=new ArrayList>(); + for(String key:List.of("replace-a","replace-b"))futures.add(executor.submit(()->{gate.await();try{tx.execute(s->adjustments.replaceFailed("9","PO-1","7",original.getId(),new Correct(version,key,new BigDecimal("90"),1001,"verified replacement")));return true;}catch(org.springframework.web.server.ResponseStatusException error){assertThat(error.getStatusCode().value()).isEqualTo(409);return false;}})); + gate.countDown();int succeeded=0;for(var future:futures)if(future.get(10,java.util.concurrent.TimeUnit.SECONDS))succeeded++; + assertThat(succeeded).isEqualTo(1);assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_fee_correction",Integer.class)).isEqualTo(1); + assertThat(finance.all(11).stream().filter(f->"INTERNATIONAL_FREIGHT".equals(f.getPurpose()) && Boolean.TRUE.equals(f.getActive())).count()).isEqualTo(1); + }finally{executor.shutdownNow();assertThat(executor.awaitTermination(5,java.util.concurrent.TimeUnit.SECONDS)).isTrue();} + } + @Test void financialTerminationAfterHandoffPreservesOriginalChargeConfirmationAndFulfillment() { + tx.execute(s->charges.confirm("9","PO-1","7",request("100","0")));var root=current(ProcurementFeePurpose.INTERNATIONAL_FREIGHT);settle(root); + var originalConfirmed=jdbc.queryForObject("SELECT charges_confirmed_at FROM ll_procurement_flow",java.sql.Timestamp.class); + jdbc.update("UPDATE ll_overseas_procurement_order SET status='SHIPPED_TO_CUSTOMER' WHERE id=11"); + var adjustment=byNo(correct(root,"120","after-handoff").feeNos().get(0));tx.executeWithoutResult(s->finance.handle(failed(adjustment,"FREEZE"))); + tx.execute(s->charges.terminate("9","PO-1","7",adjustment.getId(),new Command(version(),"terminate-after-handoff","end failed correction"))); + assertThat(orders.findNo("9","PO-1").getStatus()).isEqualTo("SHIPPED_TO_CUSTOMER"); + assertThat(jdbc.queryForObject("SELECT charges_confirmed_at FROM ll_procurement_flow",java.sql.Timestamp.class)).isEqualTo(originalConfirmed); + } + private WalletCommandCompletedV1 failed(OverseasProcurementFee fee,String operation){return new WalletCommandCompletedV1(fee.getWalletCommandEventId(),operation,9,"PROCUREMENT_FEE",fee.getWalletBusinessNo(),"FAILED",null,fee.getAmount(),"FAILED","definitive failure",Instant.now(),null,null);} + private ProcurementAdjustmentService.Result replace(OverseasProcurementFee original,String target,String key){return tx.execute(s->adjustments.replaceFailed("9","PO-1","7",original.getId(),new Correct(version(),key,new BigDecimal(target),1001,"replacement verified")));} + + private ProcurementAdjustmentService.Result correct(OverseasProcurementFee root,String target,String key){return tx.execute(s->adjustments.correct("9","PO-1","7",root.getId(),new Correct(version(),key,new BigDecimal(target),1001,"verified target")));} + @Test void concurrentTargetsCannotBothSupplementTheSameOldNetAmount()throws Exception { + tx.execute(s->charges.confirm("9","PO-1","7",request("100","0")));var root=current(ProcurementFeePurpose.INTERNATIONAL_FREIGHT);settle(root); + var executor=java.util.concurrent.Executors.newFixedThreadPool(2);var gate=new java.util.concurrent.CountDownLatch(1);long version=version(); + try { + var futures=new ArrayList>(); + for(String target:List.of("120","130"))futures.add(executor.submit(()->{gate.await();try{tx.execute(s->adjustments.correct("9","PO-1",target,root.getId(),new Correct(version,"parallel-"+target,new BigDecimal(target),1001,"correct target")));return true;} + catch(org.springframework.web.server.ResponseStatusException e){assertThat(e.getStatusCode().value()).isEqualTo(409);return false;}})); + gate.countDown();int succeeded=0;for(var result:futures)if(result.get(10,java.util.concurrent.TimeUnit.SECONDS))succeeded++; + assertThat(succeeded).isEqualTo(1);assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_fee_correction",Integer.class)).isEqualTo(1); + assertThat(finance.all(11).stream().filter(f->"ADJUSTMENT".equals(f.getPurpose())).count()).isEqualTo(1); + }finally{executor.shutdownNow();assertThat(executor.awaitTermination(5,java.util.concurrent.TimeUnit.SECONDS)).isTrue();} + } + private Selection selection(String amount){return new Selection(null,new Amount(new BigDecimal(amount).signum()>0?"POSITIVE":"ZERO",new BigDecimal(amount),"CNY",LocalDate.of(2026,9,1),new BigDecimal(amount).signum()>0?1001L:null,"actual cost"));} + private Confirm request(String international,String domestic){return new Confirm(version(),"charges-"+version(),selection(international),selection("0"),selection(domestic),"NOT_APPLICABLE",List.of(),"all determined");} + private long version(){return orders.findNo("9","PO-1").getVersion();} + private int countAttempts(){return jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_wallet_attempt",Integer.class);} + private OverseasProcurementFee current(ProcurementFeePurpose purpose){return finance.current(11,purpose,purpose.name());} + private OverseasProcurementFee byNo(String no){return finance.all(11).stream().filter(f->no.equals(f.getFeeNo())).findFirst().orElseThrow();} + private void settle(OverseasProcurementFee fee){complete(fee,"FREEZE");complete(fees.selectById(fee.getId()),"SETTLE");var saved=fees.selectById(fee.getId());jdbc.update("INSERT INTO ll_event_outbox VALUES('OVERSEAS_PROCUREMENT_FEE',?,'PUBLISHED')",String.valueOf(saved.getSourceFeeId()));} + private void complete(OverseasProcurementFee fee,String operation){String status="REFUND".equals(operation)?"REFUNDED":"FREEZE".equals(operation)?"FROZEN":"SETTLED";tx.executeWithoutResult(s->finance.handle(new WalletCommandCompletedV1(fee.getWalletCommandEventId(),operation,9,"PROCUREMENT_FEE",fee.getWalletBusinessNo(),"SUCCEEDED",status,fee.getAmount(),null,null,Instant.now(),"REFUND".equals(operation)?null:"hold-"+fee.getId(),"ledger-"+fee.getId()+"-"+operation)));} +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementControlPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementControlPersistenceTest.java new file mode 100644 index 000000000..6a7fce90f --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementControlPersistenceTest.java @@ -0,0 +1,129 @@ +package com.baiyue.lastleg.procurement.service; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.baiyue.lastleg.client.DomesticWarehouseDirectoryClient.ReceivingWarehouse; +import com.baiyue.lastleg.financefee.service.OverseasFeeCurrencyConverter; +import com.baiyue.lastleg.procurement.dto.ProcurementControlRequests.*; +import com.baiyue.lastleg.procurement.entity.*; +import com.baiyue.lastleg.procurement.mapper.*; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.transaction.support.TransactionTemplate; +import java.math.BigDecimal; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.LocalDateTime; +import java.util.*; +import java.util.regex.Pattern; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; + +class ProcurementControlPersistenceTest { + JdbcTemplate jdbc;TransactionTemplate tx;OverseasProcurementOrderMapper orders;ProcurementFinanceService finance; + ProcurementControlService service;ProcurementLifecycleService lifecycle;ProcurementWarehouseDirectory warehouses; + @BeforeEach void setup()throws Exception { + var ds=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1","sa",""); + jdbc=new JdbcTemplate(ds){ + @Override public int update(String sql,Object... args){return super.update(sql.replaceAll("(?i)CAST\\(\\? AS JSON\\)","? FORMAT JSON"),args);} + };tx=new TransactionTemplate(new DataSourceTransactionManager(ds)); + String schema=Files.readString(Path.of("src/main/resources/db/lastleg.sql")); + for(String name:List.of("ll_overseas_procurement_order","ll_overseas_procurement_file","ll_procurement_flow","ll_procurement_command","ll_procurement_receipt","ll_procurement_disposition", + "ll_procurement_carton","ll_procurement_shipment","ll_procurement_shipment_item","ll_procurement_control_note","ll_procurement_closure","ll_procurement_closure_item")) { + var m=Pattern.compile("CREATE TABLE IF NOT EXISTS "+name+" \\(.*?\\) ENGINE=InnoDB[^;]*;",Pattern.DOTALL).matcher(schema); + assertThat(m.find()).as(name).isTrue();jdbc.execute(m.group().replaceFirst("\\) ENGINE=InnoDB[^;]*;",");")); + } + var config=new MybatisConfiguration();config.addMapper(OverseasProcurementOrderMapper.class);config.addMapper(OverseasProcurementFileMapper.class); + var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);var session=new SqlSessionTemplate(factory.getObject()); + orders=session.getMapper(OverseasProcurementOrderMapper.class);var files=session.getMapper(OverseasProcurementFileMapper.class); + var order=new OverseasProcurementOrder();order.setId(11L);order.setCustomerId("9");order.setCustomerName("test");order.setOrderNo("PO-1");order.setPurchaseCategory("sample"); + order.setProductName("Sample");order.setPurchaseQuantity(10L);order.setBudgetUnitPrice(BigDecimal.TEN);order.setCurrency("CNY");order.setContactName("buyer");order.setContactPhone("13800000000"); + order.setStatus("WAIT_OVERSEAS_RECEIPT");order.setVersion(0L);order.setIdempotencyKey("create");order.setRequestHash("0".repeat(64));order.setInboundQuantity(0L);orders.insert(order); + jdbc.update("INSERT INTO ll_procurement_flow(order_id,customer_id,purchased_at,purchased_quantity,purchase_amount,purchase_currency) VALUES(11,'9',?,10,100,'CNY')",LocalDateTime.now().minusDays(1)); + jdbc.update("INSERT INTO ll_overseas_procurement_file(id,order_id,customer_id,category,file_id,file_name,content_type,size_bytes,created_by) VALUES(501,11,'9','EXCEPTION',1001,'proof.pdf','application/pdf',10,'7')"); + finance=mock(ProcurementFinanceService.class);warehouses=mock(ProcurementWarehouseDirectory.class); + when(warehouses.required(92)).thenReturn(new ReceivingWarehouse(92L,"NEW","Correct receiving warehouse","US","ACTIVE")); + var json=new ObjectMapper().findAndRegisterModules();lifecycle=new ProcurementLifecycleService(orders,files,warehouses,finance,mock(OverseasFeeCurrencyConverter.class),jdbc,json); + service=new ProcurementControlService(lifecycle,finance,warehouses,jdbc,json); + } + @Test void purchasedButUnreceivedGoodsCanCloseOnlyWithAllSupplierDispositionAndCustomerEvidence() { + assertThat(service.read("9","PO-1").remaining()).containsExactly(new ProcurementControlService.Remaining("PURCHASE_REMAINDER",null,"PO-1",10)); + assertThatThrownBy(()->close(List.of())).hasMessageContaining("货物去向未明确"); + var request=request(List.of(resolution("PURCHASE_REMAINDER",null,10,"SUPPLIER_RETAINED"))); + var result=tx.execute(s->service.close("9","PO-1","7",request));assertThat(result.status()).isEqualTo("EXCEPTION_CLOSED");assertThat(result.remaining()).isEmpty(); + tx.execute(s->service.close("9","PO-1","7",request));assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_closure",Integer.class)).isEqualTo(1); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_shipment",Integer.class)).isZero();verify(finance,times(2)).requireShipmentReady(11);verifyNoMoreInteractions(finance); + } + @Test void partialReceiptsRequireExactGoodDamagedExtraAndUnreceivedSources() { + receipt(101,"OVERSEAS",null,6,2,1); + assertThat(service.read("9","PO-1").remaining()).hasSize(4); + assertThatThrownBy(()->close(List.of(resolution("GOOD",101L,5,"RETURNED_TO_SUPPLIER")))).hasMessageContaining("恰好覆盖"); + var all=List.of(resolution("GOOD",101L,6,"RETURNED_TO_SUPPLIER"),resolution("DAMAGED",101L,2,"DISPOSED"),resolution("EXTRA",101L,1,"RETURNED_TO_SUPPLIER"),resolution("PURCHASE_REMAINDER",null,2,"SUPPLIER_RETAINED")); + assertThat(close(all).status()).isEqualTo("EXCEPTION_CLOSED"); + assertThat(jdbc.queryForObject("SELECT SUM(quantity) FROM ll_procurement_closure_item",Long.class)).isEqualTo(11L); + assertThat(jdbc.queryForObject("SELECT good_quantity FROM ll_procurement_receipt",Long.class)).isEqualTo(6L); + } + @Test void internationalShipmentIsNotRecountedAndDomesticRemainingKeepsOriginalCartonSources() { + receipt(101,"OVERSEAS",null,10,0,0);international();receipt(102,"DOMESTIC",201L,3,1,1); + var remaining=service.read("9","PO-1").remaining();assertThat(remaining).hasSize(5);assertThat(remaining).noneMatch(r->r.sourceId()!=null && r.sourceId()==101); + assertThat(remaining).contains(new ProcurementControlService.Remaining("TRANSIT_REMAINDER",201L,"C1",2),new ProcurementControlService.Remaining("TRANSIT_REMAINDER",202L,"C2",4)); + close(List.of(resolution("GOOD",102L,3,"RETURNED_TO_SUPPLIER"),resolution("DAMAGED",102L,1,"DISPOSED"),resolution("EXTRA",102L,1,"RETURNED_TO_SUPPLIER"),resolution("TRANSIT_REMAINDER",201L,2,"LOSS_CONFIRMED"),resolution("TRANSIT_REMAINDER",202L,4,"RETURNED_TO_SENDER"))); + assertThat(jdbc.queryForObject("SELECT SUM(quantity) FROM ll_procurement_shipment_item",Long.class)).isEqualTo(10L); + assertThat(jdbc.queryForObject("SELECT overseas_shipped_at FROM ll_procurement_flow",java.sql.Timestamp.class)).isNotNull(); + } + @Test void unresolvedMoneyCannotBeErasedByClosing() { + doThrow(ProcurementLifecycleService.conflict("资金未决")).when(finance).requireShipmentReady(11); + assertThatThrownBy(()->close(List.of(resolution("PURCHASE_REMAINDER",null,10,"SUPPLIER_RETAINED")))).hasMessageContaining("资金未决"); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_closure",Integer.class)).isZero();assertThat(orders.findNo("9","PO-1").getStatus()).isEqualTo("WAIT_OVERSEAS_RECEIPT"); + } + @Test void evidenceBackedBlockerMustBeResolvedBeforePlannedOperationsAndClosure() { + tx.execute(s->service.note("9","PO-1","7",new Note(version(),"note","PURCHASE",null,true,1001,"internal evidence","Waiting for agreed handling"))); + assertThatThrownBy(()->lifecycle.requireNoBlockers(orders.findNo("9","PO-1"))).hasMessageContaining("阻断异常"); + assertThatThrownBy(()->close(List.of(resolution("PURCHASE_REMAINDER",null,10,"SUPPLIER_RETAINED")))).hasMessageContaining("阻断异常"); + long id=jdbc.queryForObject("SELECT id FROM ll_procurement_control_note",Long.class); + tx.execute(s->service.resolve("9","PO-1","7",id,new Resolve(version(),"resolve",1001,"customer confirms supplier retention"))); + lifecycle.requireNoBlockers(orders.findNo("9","PO-1"));assertThat(close(List.of(resolution("PURCHASE_REMAINDER",null,10,"SUPPLIER_RETAINED"))).status()).isEqualTo("EXCEPTION_CLOSED"); + assertThat(jdbc.queryForObject("SELECT reason FROM ll_procurement_control_note",String.class)).isEqualTo("internal evidence"); + } + @Test void warehouseCorrectionBeforePackingChangesEffectiveWarehouseAndPreservesOriginalReceipt() { + receipt(101,"OVERSEAS",null,10,0,0);warehouse(); + tx.execute(s->service.correctWarehouse("9","PO-1","7",new WarehouseCorrection(version(),"warehouse",92,1001,"typed wrong warehouse"))); + assertThat(jdbc.queryForObject("SELECT actual_warehouse_id FROM ll_procurement_flow",Long.class)).isEqualTo(92L); + assertThat(jdbc.queryForObject("SELECT warehouse_id FROM ll_procurement_receipt",Long.class)).isEqualTo(91L); + assertThat(jdbc.queryForObject("SELECT original_value_json FROM ll_procurement_control_note",String.class)).contains("91"); + var snapshot=service.read("9","PO-1"); + assertThat(snapshot.notes().get(0).get("original_value_json")).isInstanceOf(com.fasterxml.jackson.databind.JsonNode.class); + assertThat(((com.fasterxml.jackson.databind.JsonNode)snapshot.notes().get(0).get("original_value_json")).get("warehouseId").asLong()).isEqualTo(91); + + assertThat(jdbc.queryForObject("SELECT blocking FROM ll_procurement_control_note",Boolean.class)).isFalse(); + } + @Test void warehouseAfterDownstreamAndPostShipmentCorrectionsOnlyAppendFacts() { + receipt(101,"OVERSEAS",null,10,0,0);warehouse();international(); + tx.execute(s->service.correctWarehouse("9","PO-1","7",new WarehouseCorrection(version(),"warehouse",92,1001,"observed later"))); + assertThat(jdbc.queryForObject("SELECT actual_warehouse_id FROM ll_procurement_flow",Long.class)).isEqualTo(91L); + assertThat(jdbc.queryForObject("SELECT blocking FROM ll_procurement_control_note",Boolean.class)).isTrue(); + jdbc.update("UPDATE ll_overseas_procurement_order SET status='SHIPPED_TO_CUSTOMER' WHERE id=11"); + tx.execute(s->service.note("9","PO-1","7",new Note(version(),"after","SHIPMENT",301L,false,1001,"post handoff correction","Shipment correction recorded"))); + assertThat(orders.findNo("9","PO-1").getStatus()).isEqualTo("SHIPPED_TO_CUSTOMER");assertThat(jdbc.queryForObject("SELECT SUM(quantity) FROM ll_procurement_shipment_item",Long.class)).isEqualTo(10L); + } + @Test void wrongOrderProofOrFactAndDuplicateRemainingSourceAreRejected() { + assertThatThrownBy(()->service.read("10","PO-1")).hasMessageContaining("当前客户"); + assertThatThrownBy(()->tx.execute(s->service.note("9","PO-1","7",new Note(version(),"other","SHIPMENT",999L,false,1001,"other fact",null)))).hasMessageContaining("不属于"); + var line=resolution("PURCHASE_REMAINDER",null,10,"SUPPLIER_RETAINED");assertThatThrownBy(()->close(List.of(line,line))).hasMessageContaining("恰好覆盖"); + assertThatThrownBy(()->tx.execute(s->service.close("9","PO-1","7",new Close(version(),"other-proof",List.of(line),999,"no further charges","closed","Agreement")))).hasMessageContaining("凭证不属于"); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_closure",Integer.class)).isZero(); + } + private void warehouse(){jdbc.update("UPDATE ll_procurement_flow SET actual_warehouse_id=91,actual_warehouse_code='OLD',actual_warehouse_name='Original warehouse' WHERE order_id=11");} + private void international(){jdbc.update("UPDATE ll_procurement_flow SET overseas_shipped_at=? WHERE order_id=11",LocalDateTime.now().minusHours(2));jdbc.update("INSERT INTO ll_procurement_carton VALUES(201,11,401,'C1',20,30,40,6,6),(202,11,401,'C2',20,30,40,4,4)");jdbc.update("INSERT INTO ll_procurement_shipment(id,order_id,customer_id,shipment_no,leg,manifest_id,shipped_at,address_snapshot,created_by) VALUES(301,11,'9','PS-1','OVERSEAS',401,?,'{}','7')",LocalDateTime.now().minusHours(2));jdbc.update("INSERT INTO ll_procurement_shipment_item VALUES(301,201,6,'carrier','T1','{}'),(301,202,4,'carrier','T2','{}')");} + private void receipt(long id,String leg,Long carton,long good,long damaged,long extra){jdbc.update("INSERT INTO ll_procurement_receipt(id,order_id,customer_id,receipt_no,leg,source_carton_id,warehouse_id,good_quantity,damaged_quantity,extra_quantity,received_at,created_by) VALUES(?,11,'9',?,?,?,91,?,?,?,?, '7')",id,"PR-"+id,leg,carton,good,damaged,extra,LocalDateTime.now().minusHours(1));} + private long version(){return orders.findNo("9","PO-1").getVersion();} + private Resolution resolution(String kind,Long source,long quantity,String decision){return new Resolution(kind,source,quantity,decision,"verified actual destination",1001);} + private Close request(List lines){return new Close(version(),"close-"+version(),lines,1001,"known fees resolved; no further customer charges","agreed stop","Procurement closed by agreement");} + private ProcurementControlService.Snapshot close(List lines){return tx.execute(s->service.close("9","PO-1","7",request(lines)));} +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementFinancePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementFinancePersistenceTest.java new file mode 100644 index 000000000..b0f89a4de --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementFinancePersistenceTest.java @@ -0,0 +1,204 @@ +package com.baiyue.lastleg.procurement.service; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.baiyue.common.event.WalletCommandCompletedV1; +import com.baiyue.lastleg.financefee.entity.OverseasFeeSource; +import com.baiyue.lastleg.financefee.service.OverseasFeeCurrencyConverter; +import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService; +import com.baiyue.lastleg.procurement.entity.*; +import com.baiyue.lastleg.procurement.mapper.*; +import com.baiyue.lastleg.wallet.WalletCommandOutboxService; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.transaction.support.TransactionTemplate; +import java.math.BigDecimal; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.time.LocalDate; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.regex.Pattern; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** Real fee rows, attempt rows, locking and rollback; only external boundaries are mocked. */ +class ProcurementFinancePersistenceTest { + JdbcTemplate jdbc; + TransactionTemplate tx; + OverseasProcurementFeeMapper fees; + OverseasProcurementOrder order; + OverseasFeeSourceService sources; + WalletCommandOutboxService commands; + ProcurementFinanceService service; + @BeforeEach void setup() throws Exception { + var ds = new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1", "sa", ""); + jdbc = new JdbcTemplate(ds); tx = new TransactionTemplate(new DataSourceTransactionManager(ds)); + String schema = Files.readString(Path.of("src/main/resources/db/lastleg.sql")); + for (String name : new String[]{"ll_overseas_procurement_fee", "ll_procurement_wallet_attempt"}) { + var match = Pattern.compile("CREATE TABLE IF NOT EXISTS " + name + " \\(.*?\\) ENGINE=InnoDB[^;]*;", Pattern.DOTALL).matcher(schema); + assertThat(match.find()).as("source schema contains %s",name).isTrue(); + jdbc.execute(match.group().replaceFirst("\\) ENGINE=InnoDB[^;]*;", ");")); + } + jdbc.execute("CREATE TABLE ll_event_outbox(aggregate_type VARCHAR(64),aggregate_id VARCHAR(64),status VARCHAR(32))"); + var config=new MybatisConfiguration(); config.addMapper(OverseasProcurementFeeMapper.class); + var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config); + fees=new SqlSessionTemplate(factory.getObject()).getMapper(OverseasProcurementFeeMapper.class); + var orders=mock(OverseasProcurementOrderMapper.class); var files=mock(OverseasProcurementFileMapper.class); + order=new OverseasProcurementOrder();order.setId(11L);order.setCustomerId("9");order.setCustomerName("test"); + order.setOrderNo("PO-1");order.setPurchaseQuantity(10L);order.setInboundQuantity(10L);order.setStatus("WAIT_CHARGES"); + when(orders.lockNo("9","PO-1")).thenReturn(order);when(orders.selectById(11L)).thenReturn(order); + var voucher=new OverseasProcurementFile();voucher.setFileId(1L);voucher.setFileName("voucher.pdf");voucher.setCategory("VOUCHER"); + when(files.selectOne(any())).thenReturn(voucher); + var converter=mock(OverseasFeeCurrencyConverter.class); + when(converter.convert(eq("CNY"),any(),any())).thenAnswer(i -> new OverseasFeeCurrencyConverter.Conversion("CNY",i.getArgument(1),BigDecimal.ONE,"2026-10",i.getArgument(1))); + sources=mock(OverseasFeeSourceService.class); + var source=new OverseasFeeSource();source.setSourceFeeId(901L); + when(sources.publishSettled(anyString(),anyString(),any(),any(),any())).thenReturn(source); + commands=mock(WalletCommandOutboxService.class);AtomicInteger sequence=new AtomicInteger(); + when(commands.publish(anyString(),anyLong(),anyString(),anyString(),any(),anyString(),anyLong())) + .thenAnswer(i -> "command-"+sequence.incrementAndGet()); + when(commands.publishRefund(anyLong(),anyString(),anyString(),any(),anyString(),anyString(),anyLong())) + .thenAnswer(i -> "command-"+sequence.incrementAndGet()); + service=new ProcurementFinanceService(fees,files,orders,converter,sources,commands,jdbc,new ObjectMapper().findAndRegisterModules()); + } + + @Test void actualFeePublishesOnlyAfterSuccessfulConsumption() { + var fee=confirm(ProcurementFeePurpose.SAMPLE_PAYMENT,"POSITIVE","100"); + var frozen=result(fee,"FREEZE","SUCCEEDED","FROZEN","100");handle(frozen); + verifyNoInteractions(sources); + var pending=fees.selectById(fee.getId());assertThat(pending.getWalletStatus()).isEqualTo("SETTLE_PENDING"); + var settled=result(pending,"SETTLE","SUCCEEDED","SETTLED","100");handle(settled);handle(settled); + var saved=fees.selectById(fee.getId());assertThat(saved.getWalletStatus()).isEqualTo("SETTLED"); + assertThat(saved.getSourceFeeId()).isEqualTo(901L); + verify(sources).publishSettled(eq("7"),anyString(),any(),any(),argThat(ref -> ref.commandEventId().equals(settled.commandEventId()))); + assertThat(service.sourceLinked(saved)).isFalse(); + jdbc.update("INSERT INTO ll_event_outbox VALUES('OVERSEAS_PROCUREMENT_FEE','901','PUBLISHED')"); + assertThat(service.sourceLinked(saved)).isTrue(); + } + + @Test void fullInboundNeverSettlesEstimateAndActualUsesOriginalHold() { + var estimate=confirm(ProcurementFeePurpose.INTERNATIONAL_ESTIMATE,"POSITIVE","100"); + handle(result(estimate,"FREEZE","SUCCEEDED","FROZEN","100")); + assertThat(fees.selectById(estimate.getId()).getWalletStatus()).isEqualTo("FROZEN"); + verify(commands,never()).publish(eq("SETTLE"),anyLong(),anyString(),anyString(),any(),anyString(),anyLong()); + var request=request(ProcurementFeePurpose.INTERNATIONAL_FREIGHT,"POSITIVE","60"); + var actual=tx.execute(s -> service.confirmInternational("9","PO-1","7",request)); + assertThat(actual.getWalletBusinessNo()).isEqualTo(estimate.getFeeNo()); + handle(result(actual,"SETTLE_ACTUAL","SUCCEEDED","SETTLED","60")); + assertThat(fees.selectById(estimate.getId()).getAmount()).isEqualByComparingTo("100"); + assertThat(fees.selectById(actual.getId()).getAmount()).isEqualByComparingTo("60"); + assertThat(tx.execute(s -> service.confirmInternational("9","PO-1","7",request)).getId()).isEqualTo(actual.getId()); + verify(sources).publishSettled(anyString(),anyString(),argThat(r -> r.businessNo().equals(estimate.getFeeNo()) && r.amount().compareTo(new BigDecimal("60"))==0),any(),any()); + } + + @Test void zeroActualReleasesEstimateWithoutCreatingAnActualReceivable() { + var estimate=confirm(ProcurementFeePurpose.INTERNATIONAL_ESTIMATE,"POSITIVE","100"); + handle(result(estimate,"FREEZE","SUCCEEDED","FROZEN","100")); + var actual=tx.execute(s -> service.confirmInternational("9","PO-1","7",request(ProcurementFeePurpose.INTERNATIONAL_FREIGHT,"ZERO","0"))); + handle(result(actual,"RELEASE","SUCCEEDED","RELEASED","100")); + assertThat(fees.selectById(estimate.getId()).getWalletStatus()).isEqualTo("RELEASED"); + assertThat(service.sourceLinked(fees.selectById(actual.getId()))).isTrue(); + verifyNoInteractions(sources); + } + + @Test void unknownRequestCannotRetryAndOldFailedResultCannotOverwriteNewAttempt() { + var fee=confirm(ProcurementFeePurpose.DUTY,"POSITIVE","10"); + assertThatThrownBy(() -> tx.execute(s -> service.retry("9","PO-1",fee.getId()))).hasMessageContaining("未明确失败"); + var failed=result(fee,"FREEZE","FAILED",null,"10");handle(failed); + var retry=tx.execute(s -> service.retry("9","PO-1",fee.getId())); + assertThat(retry.getWalletCommandEventId()).isNotEqualTo(failed.commandEventId()); + handle(failed); + assertThat(fees.selectById(fee.getId()).getWalletStatus()).isEqualTo("FREEZE_PENDING"); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_wallet_attempt WHERE fee_id=?",Integer.class,fee.getId())).isEqualTo(2); + } + + @Test void sourceFailureRollsBackResultProjectionAndRetriesWithoutAnotherDebit() { + var fee=confirm(ProcurementFeePurpose.DUTY,"POSITIVE","10"); + handle(result(fee,"FREEZE","SUCCEEDED","FROZEN","10")); + var pending=fees.selectById(fee.getId());var result=result(pending,"SETTLE","SUCCEEDED","SETTLED","10"); + when(sources.publishSettled(anyString(),anyString(),any(),any(),any())).thenThrow(new IllegalStateException("source unavailable")); + assertThatThrownBy(() -> handle(result)).hasMessage("source unavailable"); + assertThat(fees.selectById(fee.getId()).getWalletStatus()).isEqualTo("SETTLE_PENDING"); + assertThat(jdbc.queryForObject("SELECT status FROM ll_procurement_wallet_attempt WHERE command_event_id=?",String.class,result.commandEventId())).isEqualTo("PENDING"); + verify(commands,times(2)).publish(anyString(),anyLong(),anyString(),anyString(),any(),anyString(),anyLong()); + } + + @Test void freeSampleDoesNotPublishZeroMoneyAndCannotBeChangedByReplay() { + var fee=confirm(ProcurementFeePurpose.SAMPLE_PAYMENT,"ZERO","0"); + assertThat(fee.getWalletStatus()).isEqualTo("ZERO");assertThat(service.sourceLinked(fee)).isTrue(); + assertThatThrownBy(() -> confirm(ProcurementFeePurpose.SAMPLE_PAYMENT,"POSITIVE","1")).hasMessageContaining("已确认"); + verifyNoInteractions(commands,sources); + } + + @Test void repriceWaitsForReleaseAndCreatesANewImmutableVersion() { + var estimate=confirm(ProcurementFeePurpose.INTERNATIONAL_ESTIMATE,"POSITIVE","100"); + handle(result(estimate,"FREEZE","SUCCEEDED","FROZEN","100")); + tx.executeWithoutResult(s -> service.releaseEstimate("9","PO-1",estimate.getId())); + assertThatThrownBy(() -> confirm(ProcurementFeePurpose.INTERNATIONAL_ESTIMATE,"POSITIVE","80")).hasMessageContaining("已确认"); + handle(result(fees.selectById(estimate.getId()),"RELEASE","SUCCEEDED","RELEASED","100")); + var revised=confirm(ProcurementFeePurpose.INTERNATIONAL_ESTIMATE,"POSITIVE","80"); + assertThat(revised.getFeeVersion()).isEqualTo(2); + assertThat(fees.selectById(estimate.getId()).getAmount()).isEqualByComparingTo("100"); + assertThat(fees.selectById(estimate.getId()).getActive()).isFalse(); + } + + @Test void refundUsesOriginalConsumptionAndIsIdempotentByBusinessRequest() { + var fee=confirm(ProcurementFeePurpose.DUTY,"POSITIVE","100"); + handle(result(fee,"FREEZE","SUCCEEDED","FROZEN","100")); + handle(result(fees.selectById(fee.getId()),"SETTLE","SUCCEEDED","SETTLED","100")); + jdbc.update("INSERT INTO ll_event_outbox VALUES('OVERSEAS_PROCUREMENT_FEE','901','PUBLISHED')"); + var refund=tx.execute(s -> service.refund("9","PO-1","7",fee.getId(),new BigDecimal("20"),1,"correction","refund-key")); + assertThat(tx.execute(s -> service.refund("9","PO-1","7",fee.getId(),new BigDecimal("20"),1,"correction","refund-key")).getId()).isEqualTo(refund.getId()); + assertThatThrownBy(() -> tx.execute(s -> service.refund("9","PO-1","7",fee.getId(),new BigDecimal("20"),1,"correction","another-key"))) + .hasMessageContaining("未完成的调整或退款"); + handle(new WalletCommandCompletedV1(refund.getWalletCommandEventId(),"REFUND",9,"PROCUREMENT_FEE",refund.getFeeNo(), + "SUCCEEDED","REFUNDED",new BigDecimal("20"),null,null,Instant.parse("2026-10-09T10:00:00Z"),null,"33")); + assertThat(fees.selectById(refund.getId()).getWalletStatus()).isEqualTo("REFUNDED"); + assertThat(fees.selectById(fee.getId()).getWalletStatus()).isEqualTo("SETTLED"); + verify(commands).publishRefund(eq(9L),eq("PROCUREMENT_FEE"),eq(refund.getFeeNo()),any(),eq("32"),eq("correction"),anyLong()); + assertThatThrownBy(() -> tx.execute(s -> service.refund("9","PO-1","7",fee.getId(),new BigDecimal("81"),1,"correction","too-much"))) + .hasMessageContaining("可退余额"); + } + + @Test void finalHandoffWaitsForConsumptionSourceAndAnyPendingRefund() { + var fee=confirm(ProcurementFeePurpose.SAMPLE_PAYMENT,"POSITIVE","100"); + assertThatThrownBy(()->service.requireShipmentReady(11)).hasMessageContaining("未决资金"); + handle(result(fee,"FREEZE","SUCCEEDED","FROZEN","100")); + handle(result(fees.selectById(fee.getId()),"SETTLE","SUCCEEDED","SETTLED","100")); + assertThatThrownBy(()->service.requireShipmentReady(11)).hasMessageContaining("来源关联"); + jdbc.update("INSERT INTO ll_event_outbox VALUES('OVERSEAS_PROCUREMENT_FEE','901','PUBLISHED')"); + service.requireShipmentReady(11); + tx.execute(s->service.refund("9","PO-1","7",fee.getId(),new BigDecimal("20"),1,"correction","pending-refund")); + assertThatThrownBy(()->service.requireShipmentReady(11)).hasMessageContaining("未决资金"); + assertThatThrownBy(()->service.requireAdjustmentsComplete(11)).hasMessageContaining("财务调整或退款"); + } + + @Test void unusedFrozenEstimateMustBeReleasedBeforeFinalHandoff() { + var fee=confirm(ProcurementFeePurpose.INTERNATIONAL_ESTIMATE,"POSITIVE","100"); + handle(result(fee,"FREEZE","SUCCEEDED","FROZEN","100")); + assertThatThrownBy(()->service.requireShipmentReady(11)).hasMessageContaining("尚未结清或释放"); + tx.executeWithoutResult(s->service.releaseEstimate("9","PO-1",fee.getId())); + handle(result(fees.selectById(fee.getId()),"RELEASE","SUCCEEDED","RELEASED","100")); + service.requireShipmentReady(11); + } + + private OverseasProcurementFee confirm(ProcurementFeePurpose purpose,String determination,String amount) { + return tx.execute(s -> service.confirm("9","PO-1","7",request(purpose,determination,amount))); + } + private ProcurementFinanceService.Confirmation request(ProcurementFeePurpose purpose,String determination,String amount) { + return new ProcurementFinanceService.Confirmation(purpose,purpose.name(),purpose.label(),determination,new BigDecimal(amount),"CNY",LocalDate.of(2026,10,9),1L,"confirmed basis"); + } + private WalletCommandCompletedV1 result(OverseasProcurementFee fee,String operation,String outcome,String state,String amount) { + return new WalletCommandCompletedV1(fee.getWalletCommandEventId(),operation,9,"PROCUREMENT_FEE",fee.getWalletBusinessNo(),outcome,state,new BigDecimal(amount), + "FAILED".equals(outcome)?"FIN-WALLET-INSUFFICIENT":null,null,Instant.parse("2026-10-09T10:00:00Z"),"SUCCEEDED".equals(outcome)?"31":null,"SUCCEEDED".equals(outcome)?"32":null); + } + private void handle(WalletCommandCompletedV1 result) { tx.executeWithoutResult(s -> service.handle(result)); } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementPhysicalPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementPhysicalPersistenceTest.java new file mode 100644 index 000000000..c686dfe9a --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementPhysicalPersistenceTest.java @@ -0,0 +1,188 @@ +package com.baiyue.lastleg.procurement.service; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.baiyue.lastleg.client.DomesticWarehouseDirectoryClient.ReceivingWarehouse; +import com.baiyue.lastleg.financefee.service.OverseasFeeCurrencyConverter; +import com.baiyue.lastleg.procurement.dto.ProcurementLifecycleRequests.Command; +import com.baiyue.lastleg.procurement.dto.ProcurementPhysicalRequests.*; +import com.baiyue.lastleg.procurement.entity.*; +import com.baiyue.lastleg.procurement.mapper.*; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.transaction.support.TransactionTemplate; +import org.springframework.web.server.ResponseStatusException; +import java.math.BigDecimal; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.*; +import java.util.regex.Pattern; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class ProcurementPhysicalPersistenceTest { + JdbcTemplate jdbc;TransactionTemplate tx;OverseasProcurementOrderMapper orders; + ProcurementPhysicalService service;ProcurementFinanceService finance;OverseasProcurementFee payment; + @BeforeEach void setup()throws Exception { + var ds=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=5000","sa",""); + jdbc=new JdbcTemplate(ds);tx=new TransactionTemplate(new DataSourceTransactionManager(ds)); + String schema=Files.readString(Path.of("src/main/resources/db/lastleg.sql")); + for(String table:List.of("ll_overseas_procurement_order","ll_overseas_procurement_file","ll_procurement_control_note","ll_procurement_flow","ll_procurement_command", + "ll_procurement_budget_authorization","ll_procurement_receipt","ll_procurement_disposition","ll_procurement_manifest","ll_procurement_carton","ll_procurement_carton_source","ll_procurement_shipment","ll_procurement_shipment_item")) { + var match=Pattern.compile("CREATE TABLE IF NOT EXISTS "+table+" \\(.*?\\) ENGINE=InnoDB[^;]*;",Pattern.DOTALL).matcher(schema); + assertThat(match.find()).as(table).isTrue();jdbc.execute(match.group().replaceFirst("\\) ENGINE=InnoDB[^;]*;",");")); + } + var config=new MybatisConfiguration();config.addMapper(OverseasProcurementOrderMapper.class);config.addMapper(OverseasProcurementFileMapper.class); + var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);var session=new SqlSessionTemplate(factory.getObject()); + orders=session.getMapper(OverseasProcurementOrderMapper.class);var files=session.getMapper(OverseasProcurementFileMapper.class); + var order=new OverseasProcurementOrder();order.setId(11L);order.setCustomerId("9");order.setCustomerName("test");order.setOrderNo("PO-1");order.setPurchaseCategory("sample"); + order.setProductName("Sample");order.setProductSpecification("A");order.setPurchaseQuantity(10L);order.setBudgetUnitPrice(BigDecimal.TEN);order.setCurrency("CNY"); + order.setContactName("buyer");order.setContactPhone("13800000000");order.setStatus("WAIT_OVERSEAS_RECEIPT");order.setReviewedAt(LocalDateTime.now().minusDays(3)); + order.setVersion(0L);order.setIdempotencyKey("create");order.setRequestHash("0".repeat(64));order.setInboundQuantity(0L);orders.insert(order); + jdbc.update("INSERT INTO ll_procurement_flow(order_id,customer_id,purchased_at,purchased_quantity,purchase_confirmed_at) VALUES(11,'9',?,10,?)",LocalDateTime.now().minusDays(2),LocalDateTime.now().minusDays(2)); + jdbc.update("INSERT INTO ll_overseas_procurement_file(id,order_id,customer_id,category,file_id,file_name,content_type,size_bytes,created_by) VALUES(501,11,'9','VOUCHER',1001,'evidence.pdf','application/pdf',10,'7')"); + var warehouses=mock(ProcurementWarehouseDirectory.class);when(warehouses.required(91)).thenReturn(new ReceivingWarehouse(91L,"LAX","receiving","US","ACTIVE")); + finance=mock(ProcurementFinanceService.class);payment=new OverseasProcurementFee();payment.setWalletStatus("SETTLED"); + when(finance.current(11,ProcurementFeePurpose.SAMPLE_PAYMENT,"SAMPLE_PAYMENT")).thenReturn(payment);when(finance.sourceLinked(payment)).thenReturn(true); + var lifecycle=new ProcurementLifecycleService(orders,files,warehouses,finance,mock(OverseasFeeCurrencyConverter.class),jdbc,new ObjectMapper().findAndRegisterModules()); + service=new ProcurementPhysicalService(lifecycle,warehouses,finance,jdbc); + } + + @Test void splitReceiptsAndCartonSourcesRemainConservedWithoutWms() { + var first=receive(Leg.OVERSEAS,null,6,0,0,"r1");long source1=first.receipts().get(0).id(); + var second=receive(Leg.OVERSEAS,null,4,0,0,"r2");long source2=second.receipts().get(1).id(); + finish(Leg.OVERSEAS); + var draft=pack(List.of(carton("C1",new Source(source1,5)),carton("C2",new Source(source1,1),new Source(source2,4)))); + var confirmed=tx.execute(s->service.confirmPacking("9","PO-1","7",new ConfirmPacking(version(),"confirm",draft.packingVersion(),"checked"))); + assertThat(confirmed.status()).isEqualTo("WAIT_LABELS");assertThat(confirmed.packingStatus()).isEqualTo("CONFIRMED"); + assertThat(confirmed.cartons()).extracting(ProcurementPhysicalService.CartonRow::quantity).containsExactly(5L,5L); + assertThat(confirmed.cartons().get(1).sources()).hasSize(2); + assertThat(orders.findNo("9","PO-1").getInboundQuantity()).isEqualTo(10L); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM information_schema.tables WHERE table_name='ll_inventory_balance'",Integer.class)).isZero(); + } + + @Test void unresolvedDamageExtraAndShortageBlockCompletionUntilEvidenceExists() { + var receipt=receive(Leg.OVERSEAS,null,6,2,1,"r1").receipts().get(0); + assertThatThrownBy(()->finish(Leg.OVERSEAS)).hasMessageContaining("破损或多到"); + dispose(receipt.id(),"DAMAGED",2,"DISPOSE_DAMAGED");dispose(receipt.id(),"EXTRA",1,"RETURN_EXTRA"); + assertThatThrownBy(()->finish(Leg.OVERSEAS)).hasMessageContaining("未收齐"); + dispose(null,"SHORTAGE",2,"ACCEPT_SHORTAGE"); + assertThat(finish(Leg.OVERSEAS).status()).isEqualTo("WAIT_PACKING"); + assertThat(orders.findNo("9","PO-1").getInboundQuantity()).isEqualTo(6L); + verify(finance,never()).refund(anyString(),anyString(),anyString(),anyLong(),any(),anyLong(),anyString(),anyString()); + } + + @Test void overReceiptAndOtherOrderEvidenceAreRejectedWithoutCounting() { + var receipt=receive(Leg.OVERSEAS,null,8,2,0,"r1").receipts().get(0); + assertThatThrownBy(()->receive(Leg.OVERSEAS,null,1,0,0,"r2")).hasMessageContaining("超过来源"); + assertThatThrownBy(()->tx.execute(s->service.dispose("9","PO-1","7",new Disposition(version(),"dispose",Leg.OVERSEAS,null,receipt.id(),"DAMAGED",2,"DISPOSE_DAMAGED",999,"wrong file")))) + .hasMessageContaining("凭证不属于"); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_receipt",Integer.class)).isEqualTo(1); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_disposition",Integer.class)).isZero(); + } + + @Test void draftsAreAllowedBeforePaymentButConfirmationWaitsForMoneyAndAllSources() { + when(finance.sourceLinked(payment)).thenReturn(false); + var receipt=receive(Leg.OVERSEAS,null,10,0,0,"r1").receipts().get(0);finish(Leg.OVERSEAS); + var draft=pack(List.of(carton("C1",new Source(receipt.id(),10)))); + assertThatThrownBy(()->tx.execute(s->service.confirmPacking("9","PO-1","7",new ConfirmPacking(version(),"confirm",draft.packingVersion(),"checked")))) + .hasMessageContaining("样品货款"); + when(finance.sourceLinked(payment)).thenReturn(true); + tx.execute(s->service.confirmPacking("9","PO-1","7",new ConfirmPacking(version(),"confirm",draft.packingVersion(),"checked"))); + assertThat(service.read("9","PO-1").packingStatus()).isEqualTo("CONFIRMED"); + } + + @Test void quantityDoubleAllocationAndExcessPrecisionNeverPersist() { + var receipt=receive(Leg.OVERSEAS,null,10,0,0,"r1").receipts().get(0); + assertThatThrownBy(()->pack(List.of(carton("C1",new Source(receipt.id(),6)),carton("C2",new Source(receipt.id(),6))))).hasMessageContaining("重复分配"); + var bad=new Carton("C1",new BigDecimal("1.001"),BigDecimal.TEN,BigDecimal.TEN,BigDecimal.ONE,List.of(new Source(receipt.id(),10))); + assertThatThrownBy(()->pack(List.of(bad))).hasMessageContaining("最多两位"); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_manifest",Integer.class)).isZero(); + } + + @Test void repackingCreatesANewVersionAndCannotRewriteAnActualDispatch() { + var receipt=receive(Leg.OVERSEAS,null,10,0,0,"r1").receipts().get(0);finish(Leg.OVERSEAS); + var first=pack(List.of(carton("C1",new Source(receipt.id(),10)))); + tx.execute(s->service.confirmPacking("9","PO-1","7",new ConfirmPacking(version(),"confirm",first.packingVersion(),"checked"))); + var second=pack(List.of(carton("C1",new Source(receipt.id(),5)),carton("C2",new Source(receipt.id(),5)))); + assertThat(second.packingVersion()).isEqualTo(2);assertThat(second.packingStatus()).isEqualTo("DRAFT"); + assertThat(jdbc.queryForObject("SELECT status FROM ll_procurement_manifest WHERE version=1",String.class)).isEqualTo("SUPERSEDED"); + assertThat(jdbc.queryForObject("SELECT packing_version FROM ll_procurement_flow WHERE order_id=11",Integer.class)).isNull(); + jdbc.update("UPDATE ll_procurement_flow SET overseas_shipped_at=? WHERE order_id=11",LocalDateTime.now()); + assertThatThrownBy(()->pack(List.of(carton("C1",new Source(receipt.id(),10))))).hasMessageContaining("已实际交寄"); + } + + @Test void domesticReceiptsMustMatchEachOriginalShippedCarton() { + jdbc.update("UPDATE ll_procurement_flow SET overseas_shipped_at=? WHERE order_id=11",LocalDateTime.now().minusHours(2)); + jdbc.update("INSERT INTO ll_procurement_shipment(id,order_id,customer_id,shipment_no,leg,manifest_id,shipped_at,address_snapshot,created_by) VALUES(201,11,'9','PS-1','OVERSEAS',301,?, '{}','7')",LocalDateTime.now().minusHours(2)); + jdbc.update("INSERT INTO ll_procurement_shipment_item VALUES(201,101,6,'carrier','T1','{}'),(201,102,4,'carrier','T2','{}')"); + assertThatThrownBy(()->receive(Leg.DOMESTIC,999L,1,0,0,"wrong")).hasMessageContaining("不属于本单"); + receive(Leg.DOMESTIC,101L,4,0,0,"d1");assertThatThrownBy(()->finish(Leg.DOMESTIC)).hasMessageContaining("未收齐"); + receive(Leg.DOMESTIC,101L,2,0,0,"d2");receive(Leg.DOMESTIC,102L,4,0,0,"d3"); + assertThat(finish(Leg.DOMESTIC).status()).isEqualTo("WAIT_CHARGES"); + assertThat(service.read("9","PO-1").receipts()).hasSize(3); + } + + @Test void concurrentReceiversCannotCountTheSameWholePurchaseTwice()throws Exception { + ExecutorService executor=Executors.newFixedThreadPool(2);CountDownLatch start=new CountDownLatch(1); + try { + var one=executor.submit(()->attemptReceipt(start,"one"));var two=executor.submit(()->attemptReceipt(start,"two"));start.countDown(); + assertThat((one.get(10,TimeUnit.SECONDS)?1:0)+(two.get(10,TimeUnit.SECONDS)?1:0)).isEqualTo(1); + assertThat(jdbc.queryForObject("SELECT SUM(good_quantity) FROM ll_procurement_receipt",Long.class)).isEqualTo(10L); + }finally{executor.shutdownNow();assertThat(executor.awaitTermination(5,TimeUnit.SECONDS)).isTrue();} + } + + @Test void overseasCorrectionRequiresWithdrawalAndKeepsOriginalReceiptAndPacking() { + var receipt=receive(Leg.OVERSEAS,null,10,0,0,"r1").receipts().get(0);finish(Leg.OVERSEAS); + var packing=pack(List.of(carton("C1",new Source(receipt.id(),10)))); + tx.execute(s->service.confirmPacking("9","PO-1","7",new ConfirmPacking(version(),"confirm",packing.packingVersion(),"checked"))); + assertThatThrownBy(()->correct(receipt.id(),Leg.OVERSEAS,null,9,1)).hasMessageContaining("须先撤回"); + tx.execute(s->service.withdrawPacking("9","PO-1","7",new Command(version(),"withdraw","record correction"))); + var corrected=correct(receipt.id(),Leg.OVERSEAS,null,9,1); + assertThat(corrected.receipts()).hasSize(2);assertThat(corrected.receipts().get(0).active()).isFalse(); + assertThat(corrected.receipts().get(0).goodQuantity()).isEqualTo(10); + assertThat(corrected.receipts().get(1).goodQuantity()).isEqualTo(9); + assertThat(jdbc.queryForObject("SELECT quantity FROM ll_procurement_carton",Long.class)).isEqualTo(10L); + assertThat(jdbc.queryForObject("SELECT status FROM ll_procurement_manifest",String.class)).isEqualTo("SUPERSEDED"); + assertThatThrownBy(()->finish(Leg.OVERSEAS)).hasMessageContaining("破损"); + } + + @Test void domesticCorrectionDoesNotUndoInternationalShipmentAndReopensCharges() { + jdbc.update("UPDATE ll_procurement_flow SET overseas_shipped_at=? WHERE order_id=11",LocalDateTime.now().minusHours(2)); + jdbc.update("INSERT INTO ll_procurement_shipment(id,order_id,customer_id,shipment_no,leg,manifest_id,shipped_at,address_snapshot,created_by) VALUES(201,11,'9','PS-1','OVERSEAS',301,?, '{}','7')",LocalDateTime.now().minusHours(2)); + jdbc.update("INSERT INTO ll_procurement_shipment_item VALUES(201,101,10,'carrier','T1','{}')"); + var receipt=receive(Leg.DOMESTIC,101L,10,0,0,"d1").receipts().get(0);finish(Leg.DOMESTIC); + jdbc.update("UPDATE ll_procurement_flow SET charges_confirmed_at=CURRENT_TIMESTAMP WHERE order_id=11"); + correct(receipt.id(),Leg.DOMESTIC,101L,8,2); + assertThat(jdbc.queryForObject("SELECT overseas_shipped_at FROM ll_procurement_flow",java.sql.Timestamp.class)).isNotNull(); + assertThat(jdbc.queryForObject("SELECT domestic_received_at FROM ll_procurement_flow",java.sql.Timestamp.class)).isNull(); + assertThat(jdbc.queryForObject("SELECT charges_confirmed_at FROM ll_procurement_flow",java.sql.Timestamp.class)).isNull(); + assertThat(jdbc.queryForObject("SELECT quantity FROM ll_procurement_shipment_item",Long.class)).isEqualTo(10L); + assertThat(jdbc.queryForObject("SELECT SUM(good_quantity) FROM ll_procurement_receipt WHERE active=1",Long.class)).isEqualTo(8L); + verifyNoInteractions(finance); + } + private ProcurementPhysicalService.Snapshot correct(long id,Leg leg,Long source,long good,long damaged) { + return tx.execute(s->service.correctReceipt("9","PO-1","7",id,new Correction(new Receipt(version(),"correction",leg, + leg==Leg.OVERSEAS?91L:null,source,good,damaged,0,LocalDateTime.now().minusHours(1),"evidence-backed correction"),1001))); + } + private boolean attemptReceipt(CountDownLatch start,String key)throws Exception { + if(!start.await(5,TimeUnit.SECONDS))throw new AssertionError("timeout"); + try{tx.execute(s->service.receive("9","PO-1","7",new Receipt(0L,key,Leg.OVERSEAS,91L,null,10,0,0,LocalDateTime.now().minusHours(1),"actual")));return true;} + catch(ResponseStatusException e){assertThat(e.getStatusCode().value()).isEqualTo(409);return false;} + } + private long version(){return orders.findNo("9","PO-1").getVersion();} + private ProcurementPhysicalService.Snapshot receive(Leg leg,Long source,long good,long damaged,long extra,String key){return tx.execute(s->service.receive("9","PO-1","7",new Receipt(version(),key,leg,leg==Leg.OVERSEAS?91L:null,source,good,damaged,extra,LocalDateTime.now().minusHours(1),"actual receipt")));} + private ProcurementPhysicalService.Snapshot finish(Leg leg){return tx.execute(s->service.finishReceipt("9","PO-1","7",new FinishReceipt(version(),"finish-"+leg+"-"+version(),leg,"verified")));} + private ProcurementPhysicalService.Snapshot pack(List cartons){return tx.execute(s->service.savePacking("9","PO-1","7",new Packing(version(),"packing-"+version(),cartons,"actual packing")));} + private Carton carton(String no,Source... sources){return new Carton(no,new BigDecimal("20.00"),new BigDecimal("30.00"),new BigDecimal("40.00"),new BigDecimal("5.125"),List.of(sources));} + private void dispose(Long receipt,String kind,long quantity,String decision){tx.execute(s->service.dispose("9","PO-1","7",new Disposition(version(),"disposition-"+kind,Leg.OVERSEAS,null,receipt,kind,quantity,decision,1001,"customer and physical proof")));} +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementPurchasePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementPurchasePersistenceTest.java new file mode 100644 index 000000000..8526203c2 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementPurchasePersistenceTest.java @@ -0,0 +1,155 @@ +package com.baiyue.lastleg.procurement.service; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.baiyue.lastleg.client.DomesticWarehouseDirectoryClient.ReceivingWarehouse; +import com.baiyue.lastleg.financefee.service.OverseasFeeCurrencyConverter; +import com.baiyue.lastleg.procurement.dto.ProcurementLifecycleRequests.*; +import com.baiyue.lastleg.procurement.entity.*; +import com.baiyue.lastleg.procurement.mapper.*; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.http.HttpStatus; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.transaction.support.TransactionTemplate; +import org.springframework.web.server.ResponseStatusException; +import java.math.BigDecimal; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.LocalDateTime; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicReference; +import java.util.regex.Pattern; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class ProcurementPurchasePersistenceTest { + JdbcTemplate jdbc;TransactionTemplate tx;OverseasProcurementOrderMapper orders; + ProcurementLifecycleService service;ProcurementFinanceService finance; + OverseasFeeCurrencyConverter converter;ProcurementWarehouseDirectory warehouses; + @BeforeEach void setup() throws Exception { + var ds=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1","sa",""); + jdbc=new JdbcTemplate(ds);tx=new TransactionTemplate(new DataSourceTransactionManager(ds)); + String schema=Files.readString(Path.of("src/main/resources/db/lastleg.sql")); + for(String table:new String[]{"ll_overseas_procurement_order","ll_procurement_flow","ll_procurement_command","ll_procurement_budget_authorization"}) { + var match=Pattern.compile("CREATE TABLE IF NOT EXISTS "+table+" \\(.*?\\) ENGINE=InnoDB[^;]*;",Pattern.DOTALL).matcher(schema); + assertThat(match.find()).as(table).isTrue();jdbc.execute(match.group().replaceFirst("\\) ENGINE=InnoDB[^;]*;",");")); + } + var config=new MybatisConfiguration();config.addMapper(OverseasProcurementOrderMapper.class); + var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config); + orders=new SqlSessionTemplate(factory.getObject()).getMapper(OverseasProcurementOrderMapper.class); + var order=new OverseasProcurementOrder();order.setId(11L);order.setCustomerId("9");order.setCustomerName("test");order.setOrderNo("PO-1"); + order.setPurchaseCategory("sample");order.setProductName("Sample");order.setProductSpecification("A");order.setPurchaseQuantity(10L); + order.setBudgetUnitPrice(new BigDecimal("10"));order.setCurrency("CNY");order.setContactName("buyer");order.setContactPhone("13800000000"); + order.setStatus("WAIT_PURCHASE");order.setReviewedAt(LocalDateTime.now().minusDays(3));order.setVersion(0L); + order.setIdempotencyKey("create");order.setRequestHash("0".repeat(64));order.setInboundQuantity(0L);orders.insert(order); + var files=mock(OverseasProcurementFileMapper.class);var file=new OverseasProcurementFile();file.setCategory("VOUCHER"); + when(files.selectOne(any())).thenReturn(file); + finance=mock(ProcurementFinanceService.class);var payment=new AtomicReference(); + when(finance.current(eq(11L),eq(ProcurementFeePurpose.SAMPLE_PAYMENT),eq("SAMPLE_PAYMENT"))).thenAnswer(i->payment.get()); + when(finance.confirm(anyString(),anyString(),anyString(),any())).thenAnswer(i->{ + var fee=new OverseasProcurementFee();fee.setFeeNo("PF-1");fee.setWalletStatus("FREEZE_PENDING");payment.set(fee);return fee; + }); + converter=mock(OverseasFeeCurrencyConverter.class); + when(converter.convert(anyString(),any(),any())).thenAnswer(i->new OverseasFeeCurrencyConverter.Conversion(i.getArgument(0),i.getArgument(1),BigDecimal.ONE,"2026-10",i.getArgument(1))); + warehouses=mock(ProcurementWarehouseDirectory.class); + service=new ProcurementLifecycleService(orders,files,warehouses,finance,converter,jdbc,new ObjectMapper().findAndRegisterModules()); + } + + @Test void purchaseRecordsFactAndChargesOnceWithoutWaitingForInbound() { + Purchase request=purchase("80","Sample"); + var result=tx.execute(s->service.recordPurchase("9","PO-1","7",request)); + assertThat(result.purchase().recorded()).isTrue();assertThat(result.purchase().confirmed()).isTrue(); + assertThat(result.status()).isEqualTo("WAIT_OVERSEAS_RECEIPT");assertThat(result.version()).isEqualTo(1); + assertThat(result.purchase().walletStatus()).isEqualTo("FREEZE_PENDING"); + tx.execute(s->service.recordPurchase("9","PO-1","7",request)); + verify(finance).confirm(eq("9"),eq("PO-1"),eq("7"),argThat(r->r.originalAmount().compareTo(new BigDecimal("80"))==0)); + assertThat(orders.findNo("9","PO-1").getInboundQuantity()).isZero(); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_command",Integer.class)).isEqualTo(1); + } + + @Test void overBudgetFactSurvivesUntilAuthorizedAndCanThenConfirm() { + var result=tx.execute(s->service.recordPurchase("9","PO-1","7",purchase("120","Sample"))); + assertThat(result.purchase().recorded()).isTrue();assertThat(result.purchase().confirmed()).isFalse(); + assertThat(result.purchase().issue()).contains("超过");verify(finance,never()).confirm(anyString(),anyString(),anyString(),any()); + tx.execute(s->service.authorizeBudget("9","PO-1","8",new Budget(1L,"budget-1",new BigDecimal("130"),"CNY",1,"customer approval"))); + var confirmed=tx.execute(s->service.confirmPurchase("9","PO-1","7",new Command(2L,"confirm-1","authorized"))); + assertThat(confirmed.purchase().confirmed()).isTrue();assertThat(confirmed.purchase().issue()).isNull(); + assertThat(jdbc.queryForObject("SELECT previous_amount FROM ll_procurement_budget_authorization",BigDecimal.class)).isEqualByComparingTo("100"); + assertThat(jdbc.queryForObject("SELECT purchase_amount FROM ll_procurement_flow",BigDecimal.class)).isEqualByComparingTo("120"); + } + + @Test void missingRateKeepsActualPurchaseAndDoesNotInventCharge() { + when(converter.convert(anyString(),any(),any())).thenThrow(new ResponseStatusException(HttpStatus.BAD_REQUEST,"rate missing")); + var result=tx.execute(s->service.recordPurchase("9","PO-1","7",purchase("80","Sample"))); + assertThat(result.purchase().recorded()).isTrue();assertThat(result.purchase().confirmed()).isFalse(); + assertThat(result.purchase().issue()).contains("汇率");verify(finance,never()).confirm(anyString(),anyString(),anyString(),any()); + } + + @Test void amountBeyondSourceCapacityKeepsPurchaseFactWithoutCharging() { + jdbc.update("UPDATE ll_overseas_procurement_order SET budget_unit_price=9999999999999999 WHERE id=11"); + var request=purchase("100000000000000.00","Sample"); + var result=tx.execute(s->service.recordPurchase("9","PO-1","7",request)); + assertThat(result.purchase().recorded()).isTrue();assertThat(result.purchase().confirmed()).isFalse(); + assertThat(result.purchase().issue()).contains("超出", "已保留真实购买事实"); + assertThat(jdbc.queryForObject("SELECT purchase_amount FROM ll_procurement_flow",BigDecimal.class)).isEqualByComparingTo("100000000000000.00"); + tx.execute(s->service.recordPurchase("9","PO-1","7",request)); + verify(finance,never()).confirm(anyString(),anyString(),anyString(),any()); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_command",Integer.class)).isEqualTo(1); + } + + @Test void wrongGoodsCannotBeAuthorizedByIncreasingBudget() { + var result=tx.execute(s->service.recordPurchase("9","PO-1","7",purchase("80","Wrong item"))); + assertThat(result.purchase().issue()).contains("商品"); + tx.execute(s->service.authorizeBudget("9","PO-1","8",new Budget(1L,"budget-1",new BigDecimal("200"),"CNY",1,"customer approval"))); + var confirmed=tx.execute(s->service.confirmPurchase("9","PO-1","7",new Command(2L,"confirm-1","authorized"))); + assertThat(confirmed.purchase().confirmed()).isFalse();verify(finance,never()).confirm(anyString(),anyString(),anyString(),any()); + } + + @Test void cancellationCannotErasePurchasedFacts() { + tx.execute(s->service.recordPurchase("9","PO-1","7",purchase("80","Sample"))); + assertThatThrownBy(()->tx.execute(s->service.cancel("9","PO-1","7",new Command(1L,"cancel","reason")))).hasMessageContaining("购买后不能取消"); + assertThat(service.read("9","PO-1").purchase().recorded()).isTrue(); + } + + @Test void beforePurchaseCancellationIsIdempotentAndPreventsNewPurchases() { + var request=new Command(0L,"cancel","customer cancellation"); + tx.execute(s->service.cancel("9","PO-1","7",request));tx.execute(s->service.cancel("9","PO-1","7",request)); + assertThat(service.read("9","PO-1").status()).isEqualTo("CANCELLED"); + assertThatThrownBy(()->tx.execute(s->service.recordPurchase("9","PO-1","7",purchase("80","Sample")))).isInstanceOf(ResponseStatusException.class); + verify(finance,never()).confirm(anyString(),anyString(),anyString(),any()); + } + + @Test void warehouseSelectionUsesAuthorityAndCannotChangeAfterFirstReceipt() { + when(warehouses.required(91)).thenReturn(new ReceivingWarehouse(91L,"LAX","receiving","US","ACTIVE")); + tx.execute(s->service.selectWarehouse("9","PO-1","7",new Warehouse(0L,"warehouse",91,"plan"))); + assertThat(orders.findNo("9","PO-1").getPurchaseWarehouseName()).isEqualTo("receiving"); + jdbc.update("UPDATE ll_procurement_flow SET actual_warehouse_id=91 WHERE order_id=11"); + assertThatThrownBy(()->tx.execute(s->service.selectWarehouse("9","PO-1","7",new Warehouse(1L,"warehouse-2",92,"change")))).hasMessageContaining("首次实收"); + verify(warehouses,never()).required(92); + } + + @Test void otherCustomerCannotRecordOrReadThisOrder() { + assertThatThrownBy(()->tx.execute(s->service.recordPurchase("10","PO-1","7",purchase("80","Sample")))).hasMessageContaining("当前客户"); + assertThatThrownBy(()->service.read("10","PO-1")).hasMessageContaining("当前客户"); + verify(finance,never()).confirm(anyString(),anyString(),anyString(),any()); + } + + @Test void reconfirmingRecordedPurchaseDoesNotRegressPhysicalProgress() { + tx.execute(s->service.recordPurchase("9","PO-1","7",purchase("80","Sample"))); + jdbc.update("UPDATE ll_overseas_procurement_order SET status='INTERNATIONAL_TRANSIT' WHERE id=11"); + var result=tx.execute(s->service.confirmPurchase("9","PO-1","7",new Command(1L,"confirm-again","checked"))); + assertThat(result.status()).isEqualTo("INTERNATIONAL_TRANSIT"); + verify(finance,times(1)).confirm(anyString(),anyString(),anyString(),any()); + } + + private Purchase purchase(String amount,String name) { + return new Purchase(0L,"purchase-1",name,"A",10,new BigDecimal(amount),"CNY",LocalDateTime.now().minusDays(1).withNano(0), + LocalDateTime.now().plusDays(3).withNano(0),1,"supplier purchase"); + } +} 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 new file mode 100644 index 000000000..df56e9c73 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementQueryServiceTest.java @@ -0,0 +1,66 @@ +package com.baiyue.lastleg.procurement.service; + +import com.baiyue.lastleg.procurement.dto.OverseasProcurementResponses; +import com.baiyue.lastleg.procurement.entity.OverseasProcurementFee; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import java.math.BigDecimal; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.*; +import java.util.regex.Pattern; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class ProcurementQueryServiceTest { + final ProcurementControlPersistenceTest fixture=new ProcurementControlPersistenceTest(); + ProcurementQueryService query;ProcurementTransportService transport;OverseasProcurementService base; + ObjectMapper json=new ObjectMapper().findAndRegisterModules(); + @BeforeEach void setup()throws Exception { + fixture.setup();var jdbc=fixture.jdbc; + String schema=Files.readString(Path.of("src/main/resources/db/lastleg.sql")); + for(String name:List.of("ll_procurement_budget_authorization","ll_procurement_manifest","ll_procurement_carton_source","ll_procurement_label_set","ll_procurement_label_item")) { + var m=Pattern.compile("CREATE TABLE IF NOT EXISTS "+name+" \\(.*?\\) ENGINE=InnoDB[^;]*;",Pattern.DOTALL).matcher(schema);assertThat(m.find()).isTrue();jdbc.execute(m.group().replaceFirst("\\) ENGINE=InnoDB[^;]*;",");")); + } + var physical=new ProcurementPhysicalService(fixture.lifecycle,fixture.warehouses,fixture.finance,jdbc); + transport=new ProcurementTransportService(fixture.lifecycle,physical,fixture.finance,fixture.warehouses,jdbc,json); + base=mock(OverseasProcurementService.class); + when(base.detail("9","PO-1")).thenReturn(new OverseasProcurementResponses.Detail(null,null,null,null,null,null,"internal remark","operator","reviewer","internal review",null,List.of(),List.of(new OverseasProcurementResponses.FileInfo(1L,"EXCEPTION",1001L,"private.pdf","application/pdf",10,null)),List.of())); + query=new ProcurementQueryService(fixture.lifecycle,physical,transport,fixture.finance,mock(ProcurementAdjustmentService.class),fixture.service,base,jdbc); + } + @Test void customerResponseContainsStructuredFactsAndOnlyApprovedPublicSummaries()throws Exception { + fixture.jdbc.update("UPDATE ll_procurement_flow SET purchase_issue='private purchase diagnosis' WHERE order_id=11"); + fixture.jdbc.update("INSERT INTO ll_procurement_manifest(id,order_id,customer_id,version,status,confirmed_at,created_by) VALUES(301,11,'9',1,'CONFIRMED',CURRENT_TIMESTAMP,'7')"); + fixture.jdbc.update("INSERT INTO ll_procurement_carton VALUES(201,11,301,'BOX-A',20,30,40,5.125,10)"); + fixture.jdbc.update("INSERT INTO ll_procurement_shipment(id,order_id,customer_id,shipment_no,leg,manifest_id,shipped_at,address_snapshot,created_by) VALUES(401,11,'9','PS-1','OVERSEAS',301,CURRENT_TIMESTAMP,'{}','7')"); + fixture.jdbc.update("INSERT INTO ll_procurement_shipment_item VALUES(401,201,10,'Express','TRACK-1','{\"privateFile\":1001}')"); + fixture.jdbc.update("INSERT INTO ll_procurement_control_note(id,order_id,customer_id,note_no,target_type,blocking,voucher_file_id,reason,client_summary,created_by) VALUES(501,11,'9','PN-1','PURCHASE',0,1001,'private evidence','Customer-approved summary','internal-operator')"); + fixture.jdbc.update("INSERT INTO ll_procurement_budget_authorization(id,order_id,customer_id,previous_amount,previous_currency,authorized_amount,currency,voucher_file_id,reason,reviewed_by) VALUES(601,11,'9',100,'CNY',150,'CNY',1001,'private budget discussion','reviewer')"); + var fee=new OverseasProcurementFee();fee.setId(1L);fee.setFeeNo("PF-1");fee.setPurpose("SAMPLE_PAYMENT");fee.setFeeItemName("样品货款");fee.setFeeVersion(1);fee.setActive(true);fee.setAmount(BigDecimal.TEN);fee.setCurrency("CNY");fee.setWalletStatus("FREEZE_FAILED");fee.setWalletErrorMessage("private database error");fee.setVoucherFileName("private.pdf");fee.setVoucherFileId(1001L);fee.setWalletLedgerId("private-ledger"); + when(fixture.finance.all(11)).thenReturn(List.of(fee)); + var result=query.customer("9","PO-1");assertThat(result.budget().totalAmount()).isEqualByComparingTo("150");assertThat(result.purchaseNeedsAttention()).isTrue(); + assertThat(result.boxes()).singleElement().satisfies(b->assertThat(b.cartonNo()).isEqualTo("BOX-A")); + assertThat(result.shipments()).singleElement().satisfies(s->assertThat(s.trackingNo()).isEqualTo("TRACK-1")); + var output=json.writeValueAsString(result);assertThat(output).contains("BOX-A","TRACK-1","PF-1","Customer-approved summary") + .doesNotContain("private","voucherFileId","walletLedgerId","createdBy","internal-operator","reason","purchaseFacts"); + verifyNoInteractions(base); + } + @Test void platformCombinesDataAndAuthoritativeShippingBlockReasonsWithoutGrantingOtherActions() { + var permissions=Set.of("platform:overseas-business:purchase:receive-overseas","platform:overseas-business:purchase:ship-customer"); + var result=query.platform("9","PO-1",permissions); + assertThat(result.internalRemark()).isEqualTo("internal remark");assertThat(result.files()).hasSize(1); + assertThat(result.controls().remaining()).hasSize(1); + assertThat(result.actions()).filteredOn(a->a.key().equals("RECEIVE_OVERSEAS")).singleElement().satisfies(a->assertThat(a.allowed()).isTrue()); + assertThat(result.actions()).filteredOn(a->a.key().equals("SHIP_CUSTOMER")).singleElement().satisfies(a->{assertThat(a.allowed()).isFalse();assertThat(a.reason()).contains("样品货款");}); + assertThat(result.actions()).filteredOn(a->a.key().equals("FINANCE_ADJUST")).singleElement().satisfies(a->assertThat(a.reason()).isEqualTo("无此操作权限")); + } + @Test void anotherCustomerCannotLoadEvenPublicProjection() { + assertThatThrownBy(()->query.customer("10","PO-1")).hasMessageContaining("当前客户");verifyNoInteractions(fixture.finance,base); + } + @Test void draftAndSupersededPackingAreNotDisplayedAsConfirmedCustomerBoxes() { + fixture.jdbc.update("INSERT INTO ll_procurement_manifest(id,order_id,customer_id,version,status,created_by) VALUES(301,11,'9',1,'SUPERSEDED','7'),(302,11,'9',2,'DRAFT','7')"); + fixture.jdbc.update("INSERT INTO ll_procurement_carton VALUES(201,11,301,'OLD',20,30,40,5,10),(202,11,302,'DRAFT',20,30,40,5,10)"); + assertThat(query.customer("9","PO-1").boxes()).isEmpty(); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementTransportPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementTransportPersistenceTest.java new file mode 100644 index 000000000..ac1debecc --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementTransportPersistenceTest.java @@ -0,0 +1,173 @@ +package com.baiyue.lastleg.procurement.service; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.baiyue.lastleg.procurement.dto.ProcurementPhysicalRequests.Leg; +import com.baiyue.lastleg.procurement.dto.ProcurementTransportRequests.*; +import com.baiyue.lastleg.procurement.entity.*; +import com.baiyue.lastleg.procurement.mapper.*; +import com.baiyue.lastleg.financefee.service.OverseasFeeCurrencyConverter; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.transaction.support.TransactionTemplate; +import org.springframework.web.server.ResponseStatusException; +import java.math.BigDecimal; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.*; +import java.util.concurrent.*; +import java.util.regex.Pattern; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; + +class ProcurementTransportPersistenceTest { + JdbcTemplate jdbc;TransactionTemplate tx;OverseasProcurementOrderMapper orders; + ProcurementTransportService service;ProcurementFinanceService finance;OverseasProcurementFee estimate; + final Address warehouse=new Address("Overseas","1234567890","US operating address"); + final Address domestic=new Address("Domestic","13800000000","Shanghai receiving address"); + final Address customer=new Address("Customer","13900000000","Hangzhou customer address"); + final LocalDateTime start=LocalDateTime.now().minusHours(2); + @BeforeEach void setup()throws Exception { + var ds=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=5000","sa",""); + // H2 CAST(varchar AS JSON) creates a JSON string; FORMAT JSON matches MySQL's object parsing. + jdbc=new JdbcTemplate(ds){@Override public int update(String sql,Object...args){return super.update(sql.replace("CAST(? AS JSON)","? FORMAT JSON"),args);}}; + tx=new TransactionTemplate(new DataSourceTransactionManager(ds)); + String schema=Files.readString(Path.of("src/main/resources/db/lastleg.sql")); + for(String table:List.of("ll_overseas_procurement_order","ll_overseas_procurement_file","ll_procurement_control_note","ll_procurement_flow","ll_procurement_command", + "ll_procurement_receipt","ll_procurement_manifest","ll_procurement_carton","ll_procurement_carton_source","ll_procurement_shipment","ll_procurement_shipment_item", + "ll_procurement_label_set","ll_procurement_label_item","ll_procurement_estimate_review","ll_procurement_repack")) { + var match=Pattern.compile("CREATE TABLE IF NOT EXISTS "+table+" \\(.*?\\) ENGINE=InnoDB[^;]*;",Pattern.DOTALL).matcher(schema); + assertThat(match.find()).as(table).isTrue();jdbc.execute(match.group().replaceFirst("\\) ENGINE=InnoDB[^;]*;",");")); + } + var config=new MybatisConfiguration();config.addMapper(OverseasProcurementOrderMapper.class);config.addMapper(OverseasProcurementFileMapper.class); + var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);var session=new SqlSessionTemplate(factory.getObject()); + orders=session.getMapper(OverseasProcurementOrderMapper.class);var files=session.getMapper(OverseasProcurementFileMapper.class); + var order=new OverseasProcurementOrder();order.setId(11L);order.setCustomerId("9");order.setCustomerName("test");order.setOrderNo("PO-1");order.setPurchaseCategory("sample"); + order.setProductName("Sample");order.setPurchaseQuantity(10L);order.setBudgetUnitPrice(BigDecimal.TEN);order.setCurrency("CNY"); + order.setContactName(customer.name());order.setContactPhone(customer.phone());order.setReceivingAddress(customer.address());order.setStatus("WAIT_LABELS");order.setReviewedAt(start.minusDays(1)); + order.setVersion(0L);order.setIdempotencyKey("create");order.setRequestHash("0".repeat(64));order.setInboundQuantity(10L);orders.insert(order); + jdbc.update("INSERT INTO ll_procurement_flow(order_id,customer_id,purchased_at,purchased_quantity,purchase_confirmed_at,actual_warehouse_id,overseas_received_at,packing_version) VALUES(11,'9',?,10,?,91,?,1)",start.minusDays(1),start.minusDays(1),start); + jdbc.update("INSERT INTO ll_procurement_receipt(id,order_id,customer_id,receipt_no,leg,warehouse_id,good_quantity,damaged_quantity,extra_quantity,received_at,created_by) VALUES(101,11,'9','PR-1','OVERSEAS',91,10,0,0,?,'7')",start); + jdbc.update("INSERT INTO ll_procurement_manifest(id,order_id,customer_id,version,status,created_by) VALUES(301,11,'9',1,'CONFIRMED','7')"); + jdbc.update("INSERT INTO ll_procurement_carton VALUES(201,11,301,'C1',20,30,40,6.125,6),(202,11,301,'C2',20,30,40,4.125,4)"); + jdbc.update("INSERT INTO ll_procurement_carton_source VALUES(201,101,6),(202,101,4)"); + jdbc.update("INSERT INTO ll_overseas_procurement_file(id,order_id,customer_id,category,file_id,file_name,content_type,size_bytes,created_by) VALUES(501,11,'9','LABEL',1001,'labels.pdf','application/pdf',10,'7'),(502,11,'9','EXCEPTION',1002,'proof.pdf','application/pdf',10,'7')"); + var warehouses=mock(ProcurementWarehouseDirectory.class);when(warehouses.sender(91)).thenReturn(warehouse); + finance=mock(ProcurementFinanceService.class);var payment=new OverseasProcurementFee();payment.setWalletStatus("SETTLED"); + when(finance.current(11,ProcurementFeePurpose.SAMPLE_PAYMENT,"SAMPLE_PAYMENT")).thenReturn(payment);when(finance.sourceLinked(payment)).thenReturn(true); + estimate=new OverseasProcurementFee();estimate.setId(801L);estimate.setAmount(BigDecimal.ZERO);estimate.setWalletStatus("ZERO"); + when(finance.current(11,ProcurementFeePurpose.INTERNATIONAL_ESTIMATE,"INTERNATIONAL_ESTIMATE")).thenReturn(estimate); + var json=new ObjectMapper().findAndRegisterModules(); + var lifecycle=new ProcurementLifecycleService(orders,files,warehouses,finance,mock(OverseasFeeCurrencyConverter.class),jdbc,json); + var physical=new ProcurementPhysicalService(lifecycle,warehouses,finance,jdbc); + service=new ProcurementTransportService(lifecycle,physical,finance,warehouses,jdbc,json); + } + @Test void entireBatchShipsOnceWithFrozenAddressLabelsAndSources() { + prepare();var request=shipRequest(Leg.OVERSEAS,"ship"); + var result=tx.execute(s->service.ship("9","PO-1","7",request)); + assertThat(result.status()).isEqualTo("INTERNATIONAL_TRANSIT"); + assertThat(result.shipments()).hasSize(1);assertThat(result.shipments().get(0).id()).isPositive(); + assertThat(tx.execute(s->service.ship("9","PO-1","7",request)).shipments()).hasSize(1); + assertThatThrownBy(()->ship(Leg.OVERSEAS,"another")).hasMessageContaining("本段已实际交寄"); + assertThat(jdbc.queryForObject("SELECT SUM(quantity) FROM ll_procurement_shipment_item",Long.class)).isEqualTo(10L); + String address=jdbc.queryForObject("SELECT address_snapshot FROM ll_procurement_shipment",String.class); + assertThat(address).contains("US operating address","Shanghai receiving address"); + assertThat(jdbc.queryForList("SELECT label_snapshot FROM ll_procurement_shipment_item",String.class)).allSatisfy(v->assertThat(v).contains("receiptId","fileId","cartonNo")); + verify(finance,never()).confirm(anyString(),anyString(),anyString(),any()); + } + @Test void crossOrderFilesWrongPagesMissingCartonsAndUnappliedCartonsCannotShip() { + assertThatThrownBy(()->save(Leg.OVERSEAS,List.of(new Label(201,999,1,"carrier","T1"),label(202)))).hasMessageContaining("凭证不属于"); + assertThatThrownBy(()->save(Leg.OVERSEAS,List.of(label(201)))).hasMessageContaining("全部可寄"); + jdbc.update("UPDATE ll_overseas_procurement_file SET content_type='image/png' WHERE file_id=1001"); + assertThatThrownBy(()->save(Leg.OVERSEAS,List.of(new Label(201,1001,2,"carrier","T1"),label(202)))).hasMessageContaining("页码"); + save(Leg.OVERSEAS,List.of(label(201),label(202)));review(BigDecimal.ZERO); + assertThatThrownBy(()->ship(Leg.OVERSEAS,"before-apply")).hasMessageContaining("贴单"); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_shipment",Integer.class)).isZero(); + } + @Test void replacementLabelNeedsNewReviewAndNewAppliedFactsWithoutAnotherHold() { + prepare();save(Leg.OVERSEAS,List.of(label(201),label(202))); + assertThatThrownBy(()->ship(Leg.OVERSEAS,"stale")).hasMessageContaining("尚未核对有效预估"); + review(BigDecimal.ZERO); + assertThatThrownBy(()->ship(Leg.OVERSEAS,"not-applied")).hasMessageContaining("贴单"); + apply();ship(Leg.OVERSEAS,"ship"); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_label_set",Integer.class)).isEqualTo(2); + verify(finance,never()).confirm(anyString(),anyString(),anyString(),any()); + } + @Test void unresolvedManagementBlockerStopsBothActionEligibilityAndActualHandoff() { + prepare(); + jdbc.update("INSERT INTO ll_procurement_control_note(id,order_id,customer_id,note_no,target_type,blocking,voucher_file_id,reason,created_by) VALUES(701,11,'9','PN-1','PURCHASE',1,1002,'customer handling unresolved','7')"); + assertThat(service.shipmentBlockReason(orders.findNo("9","PO-1"),Leg.OVERSEAS)).contains("阻断异常"); + assertThatThrownBy(()->ship(Leg.OVERSEAS,"blocked")).hasMessageContaining("阻断异常"); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_shipment",Integer.class)).isZero(); + jdbc.update("UPDATE ll_procurement_control_note SET resolved_at=CURRENT_TIMESTAMP,resolved_by='7',resolution_reason='resolved',resolution_voucher_id=1002 WHERE id=701"); + assertThat(service.shipmentBlockReason(orders.findNo("9","PO-1"),Leg.OVERSEAS)).isNull(); + assertThat(ship(Leg.OVERSEAS,"after-resolution").shipments()).hasSize(1); + } + @Test void changedPriceReleasesFirstAndPendingMoneyStillBlocksShipping() { + prepare();estimate.setAmount(new BigDecimal("100.00"));estimate.setWalletStatus("FROZEN"); + review(new BigDecimal("120.00"));verify(finance).releaseEstimate("9","PO-1",801L); + verify(finance,never()).confirm(anyString(),anyString(),anyString(),any()); + estimate.setWalletStatus("RELEASE_PENDING"); + assertThatThrownBy(()->review(new BigDecimal("120.00"))).hasMessageContaining("原预估资金未完成"); + assertThatThrownBy(()->ship(Leg.OVERSEAS,"pending")).hasMessageContaining("预估国际运费"); + } + @Test void differentRequestsFromTwoOperatorsCannotCreateTwoShipments()throws Exception { + prepare();long version=version();int labels=labelVersion(Leg.OVERSEAS);var executor=Executors.newFixedThreadPool(2);var startGate=new CountDownLatch(1); + try { + var futures=new ArrayList>(); + for(String key:List.of("one","two"))futures.add(executor.submit(()->{startGate.await();try{tx.execute(s->service.ship("9","PO-1",key,new Ship(version,key,Leg.OVERSEAS,labels,List.of(201L,202L),start.plusHours(1),"actual")));return true;}catch(ResponseStatusException e){assertThat(e.getStatusCode().value()).isEqualTo(409);return false;}})); + startGate.countDown();int count=0;for(var future:futures)if(future.get(10,TimeUnit.SECONDS))count++;assertThat(count).isEqualTo(1); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_shipment",Integer.class)).isEqualTo(1); + }finally{executor.shutdownNow();assertThat(executor.awaitTermination(5,TimeUnit.SECONDS)).isTrue();} + } + @Test void domesticRepackInvalidatesOldLabelsWithoutChangingOriginalShipmentOrQuantity() { + domesticReceipts();save(Leg.DOMESTIC,List.of(label(201),label(202))); + tx.execute(s->service.repack("9","PO-1","7",new Repack(version(),"repack",201,new BigDecimal("25.00"),new BigDecimal("30.00"),new BigDecimal("40.00"),new BigDecimal("5.125"),1002,"damaged box"))); + var current=service.read("9","PO-1"); + assertThat(current.overseasCargo()).filteredOn(c->c.cartonId()==201).singleElement().satisfies(c->{ + assertThat(c.lengthCm()).isEqualByComparingTo("20");assertThat(c.quantity()).isEqualTo(6);assertThat(c.packagingRecordId()).isNull(); + }); + assertThat(current.domesticCargo()).filteredOn(c->c.cartonId()==201).singleElement().satisfies(c->{ + assertThat(c.lengthCm()).isEqualByComparingTo("25");assertThat(c.grossWeightKg()).isEqualByComparingTo("5.125"); + assertThat(c.quantity()).isEqualTo(5);assertThat(c.packagingRecordId()).isNotNull();assertThat(c.sources()).containsExactly(new com.baiyue.lastleg.procurement.dto.ProcurementPhysicalRequests.Source(102,5)); + }); + assertThatThrownBy(()->ship(Leg.DOMESTIC,"stale-label")).hasMessageContaining("旧面单"); + assertThat(jdbc.queryForObject("SELECT charges_confirmed_at FROM ll_procurement_flow",java.sql.Timestamp.class)).isNull(); + save(Leg.DOMESTIC,List.of(label(201),label(202)));jdbc.update("UPDATE ll_procurement_flow SET charges_confirmed_at=CURRENT_TIMESTAMP WHERE order_id=11"); + var result=ship(Leg.DOMESTIC,"domestic");assertThat(result.status()).isEqualTo("SHIPPED_TO_CUSTOMER"); + assertThat(jdbc.queryForObject("SELECT SUM(i.quantity) FROM ll_procurement_shipment s JOIN ll_procurement_shipment_item i ON i.shipment_id=s.id WHERE s.leg='DOMESTIC'",Long.class)).isEqualTo(9L); + assertThat(jdbc.queryForObject("SELECT SUM(i.quantity) FROM ll_procurement_shipment s JOIN ll_procurement_shipment_item i ON i.shipment_id=s.id WHERE s.leg='OVERSEAS'",Long.class)).isEqualTo(10L); + verify(finance).requireShipmentReady(11); + } + @Test void domesticHandoffRequiresConfirmedAndSettledChargesAndCustomerChangeEvidence() { + domesticReceipts(); + var badAddress=new Address("Changed","13600000000","Other street"); + assertThatThrownBy(()->tx.execute(s->service.saveLabels("9","PO-1","7",new Labels(version(),"changed-address",Leg.DOMESTIC,1,badAddress,null,List.of(label(201),label(202)),"change")))).hasMessageContaining("确认凭证"); + save(Leg.DOMESTIC,List.of(label(201),label(202))); + jdbc.update("UPDATE ll_procurement_flow SET charges_confirmed_at=NULL WHERE order_id=11"); + assertThatThrownBy(()->ship(Leg.DOMESTIC,"unknown-charge")).hasMessageContaining("整单费用尚未确认"); + jdbc.update("UPDATE ll_procurement_flow SET charges_confirmed_at=CURRENT_TIMESTAMP WHERE order_id=11"); + doThrow(ProcurementLifecycleService.conflict("未决资金请求")).when(finance).requireShipmentReady(11); + assertThatThrownBy(()->ship(Leg.DOMESTIC,"funds-pending")).hasMessageContaining("未决资金"); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_procurement_shipment WHERE leg='DOMESTIC'",Integer.class)).isZero(); + } + private void domesticReceipts(){prepare();ship(Leg.OVERSEAS,"international");jdbc.update("UPDATE ll_procurement_flow SET domestic_received_at=?,charges_confirmed_at=? WHERE order_id=11",start.plusMinutes(70),start.plusMinutes(80)); + jdbc.update("INSERT INTO ll_procurement_receipt(id,order_id,customer_id,receipt_no,leg,source_carton_id,good_quantity,damaged_quantity,extra_quantity,received_at,created_by) VALUES(102,11,'9','PR-2','DOMESTIC',201,5,1,0,?,'7'),(103,11,'9','PR-3','DOMESTIC',202,4,0,0,?,'7')",start.plusMinutes(65),start.plusMinutes(65));} + private void prepare(){save(Leg.OVERSEAS,List.of(label(201),label(202)));review(BigDecimal.ZERO);apply();} + private long version(){return orders.findNo("9","PO-1").getVersion();} + private int labelVersion(Leg leg){return jdbc.queryForObject("SELECT MAX(version) FROM ll_procurement_label_set WHERE order_id=11 AND leg=?",Integer.class,leg.name());} + private Label label(long carton){return new Label(carton,1001,1,"carrier","TRACK-"+carton);} + private ProcurementTransportService.Snapshot save(Leg leg,List