feat(procurement): implement sample fulfillment and settled fee workflow
This commit is contained in:
+10
-1
@@ -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) { }
|
||||
|
||||
+2
@@ -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;
|
||||
|
||||
+1
@@ -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);
|
||||
}
|
||||
|
||||
+1
-1
@@ -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); }
|
||||
|
||||
+51
-4
@@ -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());
|
||||
|
||||
+6
-4
@@ -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) {
|
||||
|
||||
+3
-21
@@ -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) {
|
||||
|
||||
+77
@@ -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));
|
||||
}
|
||||
}
|
||||
+45
@@ -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));
|
||||
}
|
||||
}
|
||||
+49
@@ -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());
|
||||
}
|
||||
}
|
||||
+64
@@ -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());
|
||||
}
|
||||
}
|
||||
+82
@@ -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));
|
||||
}
|
||||
}
|
||||
+59
@@ -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,"运输段与操作入口不一致");}
|
||||
}
|
||||
+10
-33
@@ -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("采购通用编辑仅支持备注,业务字段请使用对应履约操作");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-6
@@ -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) {}
|
||||
|
||||
}
|
||||
|
||||
+35
@@ -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) { }
|
||||
}
|
||||
+25
@@ -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) { }
|
||||
}
|
||||
+23
@@ -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) { }
|
||||
}
|
||||
+37
@@ -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) { }
|
||||
}
|
||||
+38
@@ -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) { }
|
||||
}
|
||||
+21
@@ -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;
|
||||
|
||||
-5
@@ -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;
|
||||
|
||||
+12
-1
@@ -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) { }
|
||||
}
|
||||
|
||||
+14
@@ -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
@@ -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) { }
|
||||
}
|
||||
|
||||
+6
-1
@@ -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);
|
||||
}
|
||||
|
||||
+31
-180
@@ -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) -> {
|
||||
|
||||
+110
@@ -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();
|
||||
}
|
||||
}
|
||||
+113
@@ -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());
|
||||
}
|
||||
}
|
||||
+145
@@ -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();}
|
||||
}
|
||||
+14
@@ -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; }
|
||||
}
|
||||
+532
@@ -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); }
|
||||
}
|
||||
+244
@@ -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);}
|
||||
}
|
||||
+332
@@ -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();}
|
||||
}
|
||||
+132
@@ -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();}
|
||||
}
|
||||
+277
@@ -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);}}
|
||||
}
|
||||
+53
@@ -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";
|
||||
|
||||
+33
-8
@@ -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); }
|
||||
|
||||
Reference in New Issue
Block a user