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); }