feat(procurement): implement sample fulfillment and settled fee workflow

This commit is contained in:
2026-10-10 02:19:38 +08:00
parent 040f3bbd93
commit a973be2103
104 changed files with 9823 additions and 714 deletions
@@ -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<List<ReceivingWarehouse>> receivingWarehouses(@RequestHeader("X-Internal-Service-Token") String internalToken);
@GetMapping("/internal/warehouses/shared-overseas/{warehouseId}")
ApiResponse<ReceivingWarehouse> 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<Detail> activeWarehouse(
@PathVariable("warehouseId") long warehouseId,
@@ -20,7 +29,7 @@ public interface DomesticWarehouseDirectoryClient {
record Detail(Warehouse warehouse, List<Address> 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) { }
@@ -43,6 +43,8 @@ public class OverseasFeeSource {
private String paymentMethod;
private String paymentReferenceNo;
@TableField(typeHandler = JacksonTypeHandler.class) private List<String> paymentVoucherFileIds;
@TableField(typeHandler = JacksonTypeHandler.class)
private com.baiyue.common.event.WalletSettlementReference walletSettlement;
private LocalDate businessDate;
private String currency;
private BigDecimal amount;
@@ -9,5 +9,6 @@ import org.apache.ibatis.annotations.Select;
@Mapper
public interface OverseasFeeSourceMapper extends BaseMapper<OverseasFeeSource> {
@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);
}
@@ -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); }
@@ -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());
@@ -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<OverseasProcurementResponses.Detail> create(
public ApiResponse<com.baiyue.lastleg.procurement.service.ProcurementQueryService.CustomerView> 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<OverseasProcurementResponses.Detail> detail(
public ApiResponse<com.baiyue.lastleg.procurement.service.ProcurementQueryService.CustomerView> 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) {
@@ -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<OverseasProcurementResponses.Detail> detail(
public ApiResponse<com.baiyue.lastleg.procurement.service.ProcurementQueryService.PlatformView> 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<OverseasProcurementResponses.FileInfo> 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<OverseasProcurementResponses.Fee> 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) {
@@ -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<ProcurementChargeService.Snapshot> 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<java.util.List<ProcurementAdjustmentService.Result>> 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<ProcurementChargeService.Snapshot> 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<OverseasProcurementFee> 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<ProcurementChargeService.Snapshot> 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<ProcurementChargeService.Snapshot> 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<ProcurementChargeService.Snapshot> 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<ProcurementAdjustmentService.Result> 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<ProcurementAdjustmentService.Result> 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<OverseasProcurementFee> 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));
}
}
@@ -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<Snapshot> 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<Snapshot> 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<Snapshot> 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<Snapshot> 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<Snapshot> 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));
}
}
@@ -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<FileInfo> 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<String> 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<byte[]> 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());
}
}
@@ -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<Outcome> 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<Outcome> 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<Outcome> 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<Outcome> 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<Outcome> 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<Outcome> 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());
}
}
@@ -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<Snapshot> 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<Snapshot> 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<Snapshot> 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<Snapshot> 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<Snapshot> 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<Snapshot> 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<Snapshot> 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<Snapshot> 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<Snapshot> 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<Snapshot> 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<Snapshot> 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));
}
}
@@ -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<Snapshot> 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<Snapshot> 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<Snapshot> 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<Snapshot> 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<Snapshot> 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<Snapshot> 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<Snapshot> 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,"运输段与操作入口不一致");}
}
@@ -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("采购通用编辑仅支持备注,业务字段请使用对应履约操作");
}
}
}
@@ -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<History> histories, List<OutboundDetail> outboundDetails,
List<History> histories,
List<FileInfo> files, List<Fee> fees) {}
}
@@ -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) { }
}
@@ -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) { }
}
@@ -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) { }
}
@@ -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) { }
}
@@ -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) { }
}
@@ -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;
@@ -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;
@@ -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) { }
}
@@ -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,"采购文件用途不受支持");}
}
}
@@ -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) { }
}
@@ -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<OverseasProcurementFee> { }
@Mapper public interface OverseasProcurementFeeMapper extends BaseMapper<OverseasProcurementFee> {
@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);
}
@@ -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<String> STATUSES = Set.of(PENDING_REVIEW, APPROVED, REJECTED);
private static final Set<String> 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<OverseasProcurementResponses.WarehouseOption> 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<OverseasProcurementFile>()
.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<OverseasProcurementFee>()
.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<OverseasProcurementFile>()
.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<OverseasProcurementResponses.FileInfo> 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<String, Object> 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<String, Object> 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<OverseasProcurementFee>()
.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<String> 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<LocalDateTime> 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<String, Object> editableSnapshot(OverseasProcurementOrder order) {
Map<String, Object> 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<String,Object> result=new LinkedHashMap<>();
result.put("remark",order.getRemark());
return result;
}
private List<OverseasProcurementResponses.OutboundDetail> 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<String, Object> before, Map<String, Object> after) {
Map<String, Object> changes = new LinkedHashMap<>();
before.forEach((field, oldValue) -> {
@@ -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<String> 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<OverseasProcurementFee> 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<Result> 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();
}
}
@@ -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<Fee> 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<Long> 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<String> 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());
}
}
@@ -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<Map<String,Object>> notes,
List<Remaining> remaining,List<Map<String,Object>> closures,List<Map<String,Object>> 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<String,Object>();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<String,Remaining> 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> 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<Remaining>();
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<Remaining> 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<String> 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<String,Object> noteView(Map<String,Object> 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();}
}
@@ -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; }
}
@@ -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<OverseasProcurementFee>()
.eq(OverseasProcurementFee::getOrderId, orderId).eq(OverseasProcurementFee::getPurpose, purpose.name())
.eq(OverseasProcurementFee::getFeeItemCode, code).eq(OverseasProcurementFee::getActive, true));
}
public List<OverseasProcurementFee> all(long orderId) {
return fees.selectList(new LambdaQueryWrapper<OverseasProcurementFee>().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<OverseasProcurementFee>().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<OverseasProcurementFee> refunds = fees.selectList(new LambdaQueryWrapper<OverseasProcurementFee>()
.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<OverseasProcurementFee> 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<Long> 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<OverseasProcurementFee> 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<OverseasProcurementFee>();
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<Map<String, Object>> 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<String, Object> 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<Map<String, Object>> 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<String, Object> 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<OverseasProcurementFee>().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<OverseasProcurementFee>()
.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<OverseasProcurementFile>()
.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); }
}
@@ -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<String,Object> 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<String> categories) {
var file=files.selectOne(new LambdaQueryWrapper<OverseasProcurementFile>().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);}
}
@@ -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<Source> sources) { }
public record Snapshot(String orderNo,long version,String status,List<ReceiptRow> receipts,
List<Map<String,Object>> dispositions,Integer packingVersion,String packingStatus,List<CartonRow> 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<Long,Long> available=new HashMap<>(),used=new HashMap<>();
for(var receipt:received(order.getId(),Leg.OVERSEAS,null)) available.put(receipt.id(),receipt.goodQuantity());
Set<String> 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<Long> 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<Long,Long> 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<Long,List<Source>> 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<String,Object> 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<ReceiptRow> received(long orderId,Leg leg,Long source){return received(orderId,leg,source,false);}
private List<ReceiptRow> 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<Object> 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();}
}
@@ -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> progress,List<Box> boxes,List<Shipment> shipments,List<CustomerFee> fees,List<String> exceptionSummaries) { }
public record Action(String key,String label,String permission,boolean allowed,String reason) { }
public record PlatformView(CustomerView customerView,Map<String,Object> purchaseFacts,ProcurementPhysicalService.Snapshot physical,
ProcurementTransportService.Snapshot transport,List<OverseasProcurementFee> fees,
List<ProcurementAdjustmentService.Result> feeCorrections,ProcurementControlService.Snapshot controls,
List<OverseasProcurementResponses.FileInfo> files,List<OverseasProcurementResponses.History> reviewHistory,
List<Map<String,Object>> operations,List<Action> 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<Long,String> 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> 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.<Map<String,Object>>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<String> 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<Action> actions(OverseasProcurementOrder order,Map<String,Object> flow,ProcurementPhysicalService.Snapshot physicalView,ProcurementTransportService.Snapshot transportView,Set<String> 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<Action> 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<Action> result,Set<String> 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();}
}
@@ -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<Source> 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<LabelRow> 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<Shipment> shipments,List<Cargo> overseasCargo,List<Cargo> 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<String,Object> 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> 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<Cargo> result=new ArrayList<>();
for(var row:rows) {
long id=((Number)row.get("id")).longValue();
List<Source> 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> cargo,List<Long> ids) {
Set<Long> 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>T deserialize(String value,Class<T> 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);}}
}
@@ -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<DomesticWarehouseDirectoryClient.ReceivingWarehouse> 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.<DomesticWarehouseDirectoryClient.Address>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,"海外收货仓目录暂不可用,请稍后重试");
}
}
@@ -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";
@@ -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); }
@@ -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='异常关闭逐来源剩余货物最终去向,不产生新发运';
@@ -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:
@@ -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 }
File diff suppressed because it is too large Load Diff
@@ -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",
@@ -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())));
@@ -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("快照不完整");
}
}
@@ -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 只取该客户尾程订单预分配接柜仓事实")
@@ -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"));
}
}
@@ -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"));
@@ -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");
}
}
@@ -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);
@@ -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");}
}
@@ -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.<com.baiyue.lastleg.procurement.entity.OverseasProcurementStatusHistory>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.<OverseasProcurementOrder>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
.<com.baiyue.lastleg.procurement.entity.OverseasProcurementStatusHistory>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);
}
}
@@ -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<java.util.concurrent.Future<Boolean>>();
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<java.util.concurrent.Future<Boolean>>();
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)));}
}
@@ -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<Resolution> 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<Resolution> lines){return tx.execute(s->service.close("9","PO-1","7",request(lines)));}
}
@@ -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)); }
}
@@ -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<Carton> 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")));}
}
@@ -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<OverseasProcurementFee>();
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");
}
}
@@ -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();
}
}
@@ -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<Future<Boolean>>();
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<Label> labels){return tx.execute(s->service.saveLabels("9","PO-1","7",new Labels(version(),"labels-"+version(),leg,1,leg==Leg.OVERSEAS?domestic:customer,null,labels,"checked labels")));}
private void review(BigDecimal amount){tx.execute(s->service.reviewEstimate("9","PO-1","7",new Estimate(version(),"estimate-"+version(),labelVersion(Leg.OVERSEAS),amount,LocalDate.now(),"quoted")));}
private void apply(){tx.execute(s->service.applyLabels("9","PO-1","7",new ApplyLabels(version(),"apply-"+version(),labelVersion(Leg.OVERSEAS),List.of(201L,202L),start.plusMinutes(30),"attached")));}
private Ship shipRequest(Leg leg,String key){return new Ship(version(),key,leg,labelVersion(leg),List.of(201L,202L),start.plusMinutes(leg==Leg.OVERSEAS?60:100),"actual handoff");}
private ProcurementTransportService.Snapshot ship(Leg leg,String key){return tx.execute(s->service.ship("9","PO-1","7",shipRequest(leg,key)));}
}
@@ -0,0 +1,35 @@
package com.baiyue.lastleg.procurement.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.lastleg.client.DomesticWarehouseDirectoryClient;
import com.baiyue.lastleg.client.DomesticWarehouseDirectoryClient.*;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class ProcurementWarehouseDirectoryTest {
private final DomesticWarehouseDirectoryClient client=mock(DomesticWarehouseDirectoryClient.class);
private final ProcurementWarehouseDirectory directory=new ProcurementWarehouseDirectory(client);
private final Address address=new Address("OPERATING","US","CA","Los Angeles",null,"90001","123 Warehouse Street",null);
private void receiving(){
ReflectionTestUtils.setField(directory,"token","test-internal");
when(client.receivingWarehouse(91,"test-internal")).thenReturn(ApiResponse.success(new ReceivingWarehouse(91L,"LAX","Receiving","US","ACTIVE")));
}
@Test void senderUsesAuthoritativeSharedWarehouseContactAndOperatingAddress() {
receiving();when(client.activeWarehouse(91,"test-internal")).thenReturn(ApiResponse.success(new Detail(new Warehouse(91L,0L,"LAX","Receiving","OVERSEAS","US","ACTIVE","Receiver","+1 1234567890"),List.of(address))));
var result=directory.sender(91);assertThat(result.name()).isEqualTo("Receiver");assertThat(result.phone()).isEqualTo("+1 1234567890");
assertThat(result.address()).contains("123 Warehouse Street","90001","Los Angeles");
}
@Test void missingContactAndAmbiguousOperatingAddressesAreNotInvented() {
receiving();when(client.activeWarehouse(91,"test-internal")).thenReturn(ApiResponse.success(new Detail(new Warehouse(91L,0L,"LAX","Receiving","OVERSEAS","US","ACTIVE",null,null),List.of(address))));
assertThatThrownBy(()->directory.sender(91)).hasMessageContaining("资料");
when(client.activeWarehouse(91,"test-internal")).thenReturn(ApiResponse.success(new Detail(new Warehouse(91L,0L,"LAX","Receiving","OVERSEAS","US","ACTIVE","Receiver","1234567890"),List.of(address,address))));
assertThatThrownBy(()->directory.sender(91)).hasMessageContaining("唯一作业地址");
}
@Test void mismatchingOrCustomerOwnedMasterCannotSupplySender() {
receiving();when(client.activeWarehouse(91,"test-internal")).thenReturn(ApiResponse.success(new Detail(new Warehouse(92L,9L,"LAX","Other","OVERSEAS","US","ACTIVE","Receiver","1234567890"),List.of(address))));
assertThatThrownBy(()->directory.sender(91)).hasMessageContaining("暂不可用");
}
}
@@ -102,7 +102,7 @@ class FbaWarehouseValidatorTest {
private DomesticWarehouseDirectoryClient.Detail detail(String type, String status, String code, String name) {
return new DomesticWarehouseDirectoryClient.Detail(
new DomesticWarehouseDirectoryClient.Warehouse(193000000000000001L, 0L, code, name,
type, "US", status),
type, "US", status, null, null),
List.of(new DomesticWarehouseDirectoryClient.Address("OPERATING", "US", "CA", "Los Angeles",
null, "90001", "123 Logistics Ave", null)));
}
@@ -83,7 +83,7 @@ class WarehouseTransferReceiptCoordinatorTest {
when(catalog.activeWarehouses("test-token")).thenReturn(ApiResponse.success(List.of(
new FleetWarehouseClient.Warehouse(30L, "ABE4", "ABE4 warehouse", "FBA", "US"))));
when(directory.activeWarehouse(30L, "test-token")).thenReturn(ApiResponse.success(new DomesticWarehouseDirectoryClient.Detail(
new DomesticWarehouseDirectoryClient.Warehouse(30L, 0L, "ABE4", "ABE4 warehouse", "FBA", "US", "ACTIVE"), List.of())));
new DomesticWarehouseDirectoryClient.Warehouse(30L, 0L, "ABE4", "ABE4 warehouse", "FBA", "US", "ACTIVE", null, null), List.of())));
when(warehouse.reserve(anyString(), anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())).thenAnswer(call -> {
assertFalse(TransactionSynchronizationManager.isActualTransactionActive(), "Remote reserve must not hold local transaction locks");
reserveCalls.incrementAndGet(); long pallet = call.getArgument(2), wh = call.getArgument(1), location = call.getArgument(3);
@@ -253,10 +253,10 @@ class WarehouseTransferReceiptCoordinatorTest {
}
@Test void inactiveOrForeignTypeFbaMasterStopsBeforeResourceOccupation() {
when(directory.activeWarehouse(30L, "test-token")).thenReturn(ApiResponse.success(new DomesticWarehouseDirectoryClient.Detail(
new DomesticWarehouseDirectoryClient.Warehouse(30L, 0L, "ABE4", "ABE4 warehouse", "OVERSEAS", "US", "ACTIVE"), List.of())));
new DomesticWarehouseDirectoryClient.Warehouse(30L, 0L, "ABE4", "ABE4 warehouse", "OVERSEAS", "US", "ACTIVE", null, null), List.of())));
assertThrows(ResponseStatusException.class, () -> receive("WT-1", request(101, 20, "KEY-1"))); assertEquals(0, occupancies.get());
when(directory.activeWarehouse(30L, "test-token")).thenReturn(ApiResponse.success(new DomesticWarehouseDirectoryClient.Detail(
new DomesticWarehouseDirectoryClient.Warehouse(30L, 0L, "ABE4", "ABE4 warehouse", "FBA", "US", "DISABLED"), List.of())));
new DomesticWarehouseDirectoryClient.Warehouse(30L, 0L, "ABE4", "ABE4 warehouse", "FBA", "US", "DISABLED", null, null), List.of())));
assertThrows(ResponseStatusException.class, () -> receive("WT-1", request(101, 20, "KEY-1"))); assertEquals(0, count("ll_warehouse_transfer_receipt_command"));
}
@Test void inactiveReceivingWarehouseOrForeignPalletCannotCreateReceiptCommand() {
@@ -271,7 +271,7 @@ class WarehouseTransferReceiptCoordinatorTest {
assertThrows(ResponseStatusException.class, () -> receive("WT-1", request(101, 20, "KEY-1"))); assertEquals(0, occupancies.get());
when(warehouse.resolvePallet(20L)).thenReturn(pallet(20, "IDLE"));
when(directory.activeWarehouse(30L, "test-token")).thenReturn(ApiResponse.success(new DomesticWarehouseDirectoryClient.Detail(
new DomesticWarehouseDirectoryClient.Warehouse(31L, 0L, "ABE4", "ABE4 warehouse", "FBA", "US", "ACTIVE"), List.of())));
new DomesticWarehouseDirectoryClient.Warehouse(31L, 0L, "ABE4", "ABE4 warehouse", "FBA", "US", "ACTIVE", null, null), List.of())));
assertThrows(ResponseStatusException.class, () -> receive("WT-1", request(101, 20, "KEY-1"))); assertEquals(0, count("ll_warehouse_transfer_receipt_command"));
}
@Test void sourceWithoutShippedOwnershipIsRejectedBeforeAnyRemoteCall() {
@@ -0,0 +1,51 @@
package com.baiyue.lastleg.wallet;
import com.baiyue.common.event.EventEnvelope;
import com.baiyue.common.event.WalletCommandRequestedV1;
import com.baiyue.lastleg.entity.LastlegEventOutbox;
import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import java.math.BigDecimal;
import java.util.HashMap;
import java.util.Map;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class WalletCommandOutboxServiceTest {
@Test
void replayRetainsEventIdentityButRejectsDifferentFinancialFacts() throws Exception {
LastlegEventOutboxMapper mapper = mock(LastlegEventOutboxMapper.class);
ObjectMapper json = new ObjectMapper().findAndRegisterModules();
Map<String, LastlegEventOutbox> saved = new HashMap<>();
when(mapper.findByEventKey(anyString())).thenAnswer(i -> saved.get(i.getArgument(0)));
when(mapper.insert(any(LastlegEventOutbox.class))).thenAnswer(i -> {
LastlegEventOutbox row = i.getArgument(0); saved.put(row.getEventKey(), row); return 1;
});
WalletCommandOutboxService service = new WalletCommandOutboxService(mapper, json);
String first = service.publishRefund(9, "PROCUREMENT_FEE", "RF-1", new BigDecimal("20"), "72", "correction", 1);
assertThat(service.publishRefund(9, "PROCUREMENT_FEE", "RF-1", new BigDecimal("20.00"), "72", "correction", 1))
.isEqualTo(first);
assertThatThrownBy(() -> service.publishRefund(9, "PROCUREMENT_FEE", "RF-1", new BigDecimal("21"), "72", "correction", 1))
.isInstanceOf(IllegalStateException.class);
assertThatThrownBy(() -> service.publishRefund(9, "PROCUREMENT_FEE", "RF-1", new BigDecimal("20"), "73", "correction", 1))
.isInstanceOf(IllegalStateException.class);
ArgumentCaptor<LastlegEventOutbox> row = ArgumentCaptor.forClass(LastlegEventOutbox.class);
verify(mapper).insert(row.capture());
EventEnvelope event = json.readValue(row.getValue().getPayloadJson(), EventEnvelope.class);
WalletCommandRequestedV1 command = json.treeToValue(event.payload(), WalletCommandRequestedV1.class);
assertThat(command.originalLedgerId()).isEqualTo("72");
assertThat(command.operation()).isEqualTo("REFUND");
}
@Test
void refundCannotUseUnreferencedGenericCommand() {
var mapper = mock(LastlegEventOutboxMapper.class);
var service = new WalletCommandOutboxService(mapper, new ObjectMapper().findAndRegisterModules());
assertThatThrownBy(() -> service.publish("REFUND", 9, "PROCUREMENT_FEE", "RF-1", BigDecimal.TEN, "reason", 1))
.isInstanceOf(IllegalArgumentException.class);
verifyNoInteractions(mapper);
}
}