Merge branch 'test' into test-02huogui
# Conflicts: # deploy/sql/头程数据库统一增量-MySQL8.sql # firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java # firstleg/src/main/resources/application.yml # firstleg/src/main/resources/openapi/fba-pricing-v1.yaml # firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java # order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java # order/src/test/java/com/baiyue/order/controller/OrderControllerPermissionTest.java
This commit is contained in:
@@ -7,6 +7,7 @@ import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
|
||||
@FeignClient(name = "order-service", contextId = "firstlegOrderCargoClient",
|
||||
url = "${baiyue.clients.order.url:}", path = "/internal/order/v1/cargoes")
|
||||
@@ -17,6 +18,12 @@ public interface OrderCargoClient {
|
||||
@PathVariable long orderId,
|
||||
@PathVariable long cargoId);
|
||||
|
||||
@GetMapping("/{tenantId}/{orderId}")
|
||||
ApiResponse<List<CargoLabelBinding>> listForCartonLabels(
|
||||
@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@PathVariable long tenantId,
|
||||
@PathVariable long orderId);
|
||||
|
||||
record CargoBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName,
|
||||
Integer originalPackageCount, BigDecimal originalGrossWeightKg,
|
||||
BigDecimal originalVolumeCbm, String skuNo, String cargoNameEn,
|
||||
@@ -29,4 +36,9 @@ public interface OrderCargoClient {
|
||||
originalGrossWeightKg, originalVolumeCbm, null, null, null, null, null, null);
|
||||
}
|
||||
}
|
||||
|
||||
record CargoLabelBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName,
|
||||
Integer packageCount, BigDecimal lengthCm, BigDecimal widthCm,
|
||||
BigDecimal heightCm) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.baiyue.firstleg.client;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import org.springframework.cloud.openfeign.FeignClient;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RequestPart;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
/** File Service contract for immutable original price-book XLSX files. */
|
||||
@FeignClient(name = "file-service", contextId = "firstlegPriceBookFileClient",
|
||||
url = "${baiyue.clients.file.url:}", path = "/internal/file/v1/price-books")
|
||||
public interface PriceBookFileClient {
|
||||
@PostMapping(value = "/{businessId}", consumes = "multipart/form-data")
|
||||
ApiResponse<FileMetadata> upload(@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@RequestHeader("X-Operator-Id") long operatorId,
|
||||
@PathVariable long businessId,
|
||||
@RequestPart("file") MultipartFile file);
|
||||
|
||||
@GetMapping("/{businessId}/{fileId}/metadata")
|
||||
ApiResponse<FileMetadata> metadata(@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@RequestHeader("X-Operator-Id") long operatorId,
|
||||
@PathVariable long businessId,
|
||||
@PathVariable long fileId);
|
||||
|
||||
@GetMapping("/{businessId}/{fileId}/content")
|
||||
ResponseEntity<byte[]> download(@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@RequestHeader("X-Operator-Id") long operatorId,
|
||||
@PathVariable long businessId,
|
||||
@PathVariable long fileId);
|
||||
|
||||
record FileMetadata(Long fileId, Long businessId, String fileName, String contentType,
|
||||
Long sizeBytes, String sha256, String businessType) {}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.baiyue.firstleg.client;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import org.springframework.cloud.openfeign.FeignClient;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@FeignClient(name = "warehouse-service", contextId = "firstlegPriceBookWarehouseClient",
|
||||
url = "${baiyue.clients.warehouse.url:}", path = "/internal/warehouses")
|
||||
public interface PriceBookWarehouseClient {
|
||||
@GetMapping("/{tenantId}/{id}/active")
|
||||
ApiResponse<Detail> active(@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@PathVariable("tenantId") long tenantId,
|
||||
@PathVariable("id") long id);
|
||||
|
||||
record Detail(Warehouse warehouse, List<Address> addresses) {}
|
||||
record Warehouse(Long id, Long tenantId, String warehouseCode, String warehouseName,
|
||||
String warehouseType, String countryCode, String status) {}
|
||||
record Address(String countryCode, String stateProvince, String city, String district,
|
||||
String postalCode, String addressLine1, String addressLine2) {}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.baiyue.firstleg.client;
|
||||
|
||||
import org.springframework.cloud.openfeign.FeignClient;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
|
||||
/** Print Service contract for stateless inbound carton label rendering. */
|
||||
@FeignClient(name = "print-service", contextId = "firstlegPrintPdfClient",
|
||||
url = "${baiyue.clients.print.url:}", path = "/internal/print/v1/pdfs")
|
||||
public interface PrintPdfClient {
|
||||
@PostMapping(value = "/inbound-marks", consumes = "application/json")
|
||||
ResponseEntity<byte[]> generateInboundMarks(
|
||||
@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@RequestHeader(value = "X-Request-Id", required = false) String requestId,
|
||||
@RequestBody InboundMarkPdfRequest request);
|
||||
|
||||
record InboundMarkPdfRequest(String templateCode, PageSpec pageSpec,
|
||||
String downloadFileName, List<Label> labels) {
|
||||
}
|
||||
|
||||
record PageSpec(BigDecimal widthMm, BigDecimal heightMm) {
|
||||
}
|
||||
|
||||
record Label(String topCode, String warehouseText, String cartonText,
|
||||
String routeText, String qrContent) {
|
||||
}
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
package com.baiyue.firstleg.controller;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.dto.CustomerDimensionConfigDtos.InitializeRequest;
|
||||
import com.baiyue.firstleg.dto.CustomerDimensionConfigDtos.InitializeResponse;
|
||||
import com.baiyue.firstleg.service.CustomerDimensionConfigService;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/internal/firstleg/v1/customer-dimension-configs")
|
||||
public class InternalCustomerDimensionConfigController {
|
||||
@Resource
|
||||
private CustomerDimensionConfigService service;
|
||||
@Value("${baiyue.security.internal-service-token}")
|
||||
private String internalToken;
|
||||
private byte[] expectedToken;
|
||||
|
||||
@PostConstruct
|
||||
void initializeToken() {
|
||||
expectedToken = internalToken.getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
@PostMapping("/defaults")
|
||||
public ApiResponse<InitializeResponse> initializeDefault(
|
||||
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||
@Valid @RequestBody InitializeRequest request) {
|
||||
requireToken(token);
|
||||
return ApiResponse.success(service.initializeDefault(
|
||||
request.tenantId(), request.operatorId(), request.initializationKey()));
|
||||
}
|
||||
|
||||
@DeleteMapping("/defaults")
|
||||
public ApiResponse<Void> compensateDefault(
|
||||
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||
@RequestParam long tenantId, @RequestParam String initializationKey) {
|
||||
requireToken(token);
|
||||
service.compensateDefault(tenantId, initializationKey);
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
|
||||
private void requireToken(String token) {
|
||||
if (token == null || !MessageDigest.isEqual(expectedToken, token.getBytes(StandardCharsets.UTF_8))) {
|
||||
throw new BizException(ResultCode.FORBIDDEN, "内部服务凭证无效");
|
||||
}
|
||||
}
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
package com.baiyue.firstleg.controller;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.dto.PriceBookChannelValidation;
|
||||
import com.baiyue.firstleg.service.MerchantPriceBookService;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/internal/firstleg/v1/pricing")
|
||||
public class InternalPriceBookController {
|
||||
@Resource private MerchantPriceBookService service;
|
||||
@Value("${baiyue.security.internal-service-token}") private String internalToken;
|
||||
private byte[] expectedToken;
|
||||
|
||||
@PostConstruct
|
||||
void initializeToken() {
|
||||
expectedToken = internalToken.getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
@PostMapping("/channels/validate")
|
||||
public ApiResponse<Void> validateChannel(
|
||||
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||
@Valid @RequestBody PriceBookChannelValidation.Request request) {
|
||||
requireToken(token);
|
||||
service.requireAvailableChannel(request.tenantId(), request.originWarehouseId(), request.channelCode());
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
|
||||
private void requireToken(String token) {
|
||||
if (token == null || !MessageDigest.isEqual(expectedToken, token.getBytes(StandardCharsets.UTF_8))) {
|
||||
throw new BizException(ResultCode.FORBIDDEN, "内部服务凭证无效");
|
||||
}
|
||||
}
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
package com.baiyue.firstleg.controller;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.firstleg.dto.PriceBookDtos.CurrentChannel;
|
||||
import com.baiyue.firstleg.dto.PriceBookDtos.EstimateRequest;
|
||||
import com.baiyue.firstleg.dto.PriceBookDtos.EstimateResponse;
|
||||
import com.baiyue.firstleg.service.FirstlegScopeGuard;
|
||||
import com.baiyue.firstleg.service.MerchantPriceBookService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Tag(name = "客户端船期渠道与参考估价")
|
||||
@RestController
|
||||
@RequestMapping("/api/merchant/v1/first-leg/pricing")
|
||||
public class MerchantPriceBookController {
|
||||
@Resource private MerchantPriceBookService service;
|
||||
|
||||
@GetMapping("/channels")
|
||||
@PreAuthorize("hasAnyAuthority('merchant:order:create','merchant:order:edit','merchant:order:view')")
|
||||
@Operation(summary = "查询当前起运仓可选渠道")
|
||||
public ApiResponse<List<CurrentChannel>> channels(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam long originWarehouseId) {
|
||||
return ApiResponse.success(service.channels(FirstlegScopeGuard.merchant(user), originWarehouseId));
|
||||
}
|
||||
|
||||
@PostMapping("/estimate")
|
||||
@PreAuthorize("hasAnyAuthority('merchant:order:create','merchant:order:edit','merchant:order:view')")
|
||||
@Operation(summary = "计算头程基础参考费用")
|
||||
public ApiResponse<EstimateResponse> estimate(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid @RequestBody EstimateRequest request) {
|
||||
return ApiResponse.success(service.estimate(FirstlegScopeGuard.merchant(user), request));
|
||||
}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package com.baiyue.firstleg.controller;
|
||||
|
||||
import com.baiyue.common.audit.BusinessAudit;
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.firstleg.dto.CustomerDimensionConfigDtos.Response;
|
||||
import com.baiyue.firstleg.dto.CustomerDimensionConfigDtos.UpdateRequest;
|
||||
import com.baiyue.firstleg.service.CustomerDimensionConfigService;
|
||||
import com.baiyue.firstleg.service.FirstlegScopeGuard;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@Tag(name = "客户尺寸计算配置")
|
||||
@RestController
|
||||
@RequestMapping("/api/platform/v1/first-leg/customer-dimension-config")
|
||||
public class PlatformCustomerDimensionConfigController {
|
||||
@Resource
|
||||
private CustomerDimensionConfigService service;
|
||||
|
||||
@GetMapping
|
||||
@PreAuthorize("hasAuthority('firstleg:dimension-config:view')")
|
||||
@Operation(summary = "查询客户尺寸计算配置")
|
||||
public ApiResponse<Response> get(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam long tenantId) {
|
||||
return ApiResponse.success(service.get(FirstlegScopeGuard.platform(user, tenantId)));
|
||||
}
|
||||
|
||||
@PutMapping
|
||||
@PreAuthorize("hasAuthority('firstleg:dimension-config:edit')")
|
||||
@BusinessAudit(event = "CUSTOMER_DIMENSION_CONFIG_UPDATE",
|
||||
resourceType = "DIMENSION_CONFIG", resourceId = "#tenantId",
|
||||
description = "平台编辑客户尺寸计算配置")
|
||||
@Operation(summary = "编辑客户尺寸计算配置")
|
||||
public ApiResponse<Response> update(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam long tenantId,
|
||||
@Valid @RequestBody UpdateRequest request) {
|
||||
long scope = FirstlegScopeGuard.platform(user, tenantId);
|
||||
return ApiResponse.success(service.update(scope, user.userId(), request));
|
||||
}
|
||||
}
|
||||
+49
-2
@@ -9,6 +9,9 @@ import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.firstleg.dto.InboundMeasurementRequests;
|
||||
import com.baiyue.firstleg.dto.InboundMeasurementResponses;
|
||||
import com.baiyue.firstleg.dto.CartonMeasurementResult;
|
||||
import com.baiyue.firstleg.dto.CartonMeasurementCommand;
|
||||
import com.baiyue.firstleg.service.CartonMeasurementService;
|
||||
import com.baiyue.firstleg.service.FirstlegScopeGuard;
|
||||
import com.baiyue.firstleg.service.InboundMeasurementService;
|
||||
import com.baiyue.firstleg.service.MeasurementSubmissionService;
|
||||
@@ -21,10 +24,13 @@ import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestPart;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
/** 平台后台入库测量录入、提交和 OCR 重量结果维护接口。 */
|
||||
@Tag(name = "平台入库测量", description = "维护测量草稿、箱规明细和可追溯 OCR 重量结果。")
|
||||
@@ -33,6 +39,7 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
public class PlatformInboundMeasurementController {
|
||||
@Resource private InboundMeasurementService service;
|
||||
@Resource private MeasurementSubmissionService submission;
|
||||
@Resource private CartonMeasurementService cartonMeasurements;
|
||||
|
||||
@Operation(summary = "查询入库测量详情", description = "返回原单、实测箱规、审核快照和 OCR 重量结果。")
|
||||
@GetMapping("/measurement")
|
||||
@@ -74,6 +81,44 @@ public class PlatformInboundMeasurementController {
|
||||
return ApiResponse.success(submission.submit(tenantId, inboundId, user.userId(), request));
|
||||
}
|
||||
|
||||
@BusinessAudit(event = "INBOUND_CARTON_MEASUREMENT_COUNT_CONFIRM",
|
||||
resourceType = "INBOUND_MEASUREMENT_ITEM", resourceId = "#itemId",
|
||||
description = "确认单箱测量记录代表箱数")
|
||||
@Operation(summary = "确认单箱测量记录代表箱数",
|
||||
description = "只修改人工确认箱数并由服务端重算当前测量批次汇总。")
|
||||
@PutMapping("/measurement/items/{itemId}/confirmed-package-count")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:inbound:measure:edit')")
|
||||
public ApiResponse<CartonMeasurementResult> updateConfirmedPackageCount(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam long tenantId,
|
||||
@PathVariable long inboundId,
|
||||
@PathVariable long itemId,
|
||||
@Valid @RequestBody InboundMeasurementRequests.ConfirmedPackageCountRequest request) {
|
||||
requirePlatformTenant(user, tenantId);
|
||||
return ApiResponse.success(cartonMeasurements.updateConfirmedPackageCount(
|
||||
tenantId, inboundId, itemId, request.measurementVersion(), request.confirmedPackageCount()));
|
||||
}
|
||||
|
||||
@BusinessAudit(event = "INBOUND_CARTON_SCAN", resourceType = "INBOUND",
|
||||
resourceId = "#inboundId", description = "平台模拟设备单箱扫描")
|
||||
@Operation(summary = "平台模拟单箱设备扫描",
|
||||
description = "联调入口;data 中传箱号和设备测量值,image 传当前箱图片,复用正式单箱覆盖规则。")
|
||||
@PostMapping(value = "/measurement/carton-scans", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:inbound:measure:edit')")
|
||||
public ApiResponse<CartonMeasurementResult> simulateCartonScan(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam long tenantId,
|
||||
@PathVariable long inboundId,
|
||||
@Valid @RequestPart("data") InboundMeasurementRequests.CartonScanRequest request,
|
||||
@RequestPart("image") MultipartFile image) {
|
||||
requirePlatformTenant(user, tenantId);
|
||||
CartonMeasurementCommand command = new CartonMeasurementCommand(
|
||||
request.boxCode(), request.length(), request.width(), request.height(), request.weight(),
|
||||
request.machineNo(), request.measuredAt(), image);
|
||||
return ApiResponse.success(cartonMeasurements.upsertForInbound(
|
||||
tenantId, user.userId(), inboundId, command));
|
||||
}
|
||||
|
||||
@BusinessAudit(event = "INBOUND_WEIGHT_OCR_RECORD", resourceType = "INBOUND",
|
||||
resourceId = "#inboundId", description = "登记称重图片 OCR 原值")
|
||||
@Operation(summary = "登记 OCR 重量结果", description = "按图片文件 ID 保存不可覆盖的 OCR 原值。")
|
||||
@@ -106,9 +151,11 @@ public class PlatformInboundMeasurementController {
|
||||
|
||||
@BusinessAudit(event = "INBOUND_WEIGHT_OCR_CONFIRM", resourceType = "INBOUND_WEIGHT_RESULT",
|
||||
resourceId = "#resultId", description = "确认 OCR 重量并累加实际总毛重")
|
||||
@Operation(summary = "确认 OCR 重量", description = "统一换算为 KG 并重算所有已确认图片的总重量。")
|
||||
@Operation(summary = "确认 OCR 重量",
|
||||
description = "可在同一事务中携带人工修改值并确认;统一换算为 KG 后重算所有已确认图片的总重量。")
|
||||
@PostMapping("/weight-results/{resultId}/confirm")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:inbound:weight:confirm')")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:inbound:weight:confirm')"
|
||||
+ " and (!#request.hasAdjustment() or hasAuthority('platform:firstleg:inbound:weight:edit'))")
|
||||
public ApiResponse<InboundMeasurementResponses.WeightResult> confirmWeightResult(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam long tenantId,
|
||||
|
||||
+24
@@ -11,6 +11,7 @@ import com.baiyue.firstleg.dto.InboundOrderDetail;
|
||||
import com.baiyue.firstleg.dto.InboundOrderQuery;
|
||||
import com.baiyue.firstleg.dto.InboundOrderSummary;
|
||||
import com.baiyue.firstleg.service.FirstlegScopeGuard;
|
||||
import com.baiyue.firstleg.service.InboundCartonLabelPrintService;
|
||||
import com.baiyue.firstleg.service.InboundOrderQueryService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
@@ -22,9 +23,14 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** 平台端入库单列表和详情只读接口。 */
|
||||
@Tag(name = "平台入库单", description = "二期入库单列表、页签、筛选和只读聚合详情")
|
||||
@@ -33,6 +39,7 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
@RequestMapping("/api/platform/inbounds")
|
||||
public class PlatformInboundOrderController {
|
||||
@Resource private InboundOrderQueryService service;
|
||||
@Resource private InboundCartonLabelPrintService cartonLabelPrintService;
|
||||
|
||||
@Operation(summary = "分页查询入库单",
|
||||
description = "默认分页查询全部企业入库单;可按目标租户和二期状态页签筛选入库单快照。")
|
||||
@@ -61,6 +68,23 @@ public class PlatformInboundOrderController {
|
||||
return ApiResponse.success(service.detail(tenantId, inboundId));
|
||||
}
|
||||
|
||||
@Operation(summary = "生成入仓箱唛 PDF",
|
||||
description = "首次调用按订单货物明细冻结整单箱号;重复打印及指定箱下标补打复用原箱号映射。")
|
||||
@PostMapping("/{inboundId}/carton-label-pdf")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:inbound:view')")
|
||||
public ResponseEntity<byte[]> printCartonLabels(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam long tenantId,
|
||||
@PathVariable long inboundId,
|
||||
@RequestParam(required = false) List<@Min(1) Integer> cartonIndex,
|
||||
@RequestHeader(value = "X-Request-Id", required = false) String requestId) {
|
||||
requirePlatformTenant(user, tenantId);
|
||||
if (inboundId <= 0) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "入库单ID必须大于零");
|
||||
}
|
||||
return cartonLabelPrintService.generate(tenantId, inboundId, cartonIndex, requestId);
|
||||
}
|
||||
|
||||
private static void requirePlatformTenant(AuthenticatedUser user, long tenantId) {
|
||||
FirstlegScopeGuard.platform(user, tenantId);
|
||||
}
|
||||
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
package com.baiyue.firstleg.controller;
|
||||
|
||||
import com.baiyue.common.audit.BusinessAudit;
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.firstleg.domain.PriceBookStatus;
|
||||
import com.baiyue.firstleg.dto.PriceBookConfirmationRequest;
|
||||
import com.baiyue.firstleg.dto.PriceBookDtos;
|
||||
import com.baiyue.firstleg.service.FirstlegScopeGuard;
|
||||
import com.baiyue.firstleg.service.PlatformPriceBookService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RequestPart;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
@Tag(name = "平台船期价格表")
|
||||
@Validated
|
||||
@RestController
|
||||
@RequestMapping("/api/platform/v1/fba-pricing/carrier-rates/price-books")
|
||||
public class PlatformPriceBookController {
|
||||
@Resource private PlatformPriceBookService service;
|
||||
|
||||
@GetMapping
|
||||
@PreAuthorize("hasAuthority('firstleg:fba-pricing:rate:view')")
|
||||
@Operation(summary = "查询船期价格表版本")
|
||||
public ApiResponse<java.util.List<PriceBookDtos.PriceBookVersionSummary>> list(
|
||||
@AuthenticationPrincipal com.baiyue.common.security.AuthenticatedUser user,
|
||||
@RequestParam(required = false) PriceBookStatus status) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return ApiResponse.success(service.list(status));
|
||||
}
|
||||
|
||||
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@PreAuthorize("hasAuthority('firstleg:fba-pricing:rate:edit')")
|
||||
@BusinessAudit(event = "FBA_PRICE_BOOK_UPLOAD", resourceType = "PRICE_BOOK_VERSION", description = "上传船期价格表并等待确认")
|
||||
public ApiResponse<PriceBookDtos.PriceBookVersionDetail> upload(
|
||||
@AuthenticationPrincipal com.baiyue.common.security.AuthenticatedUser user,
|
||||
@RequestPart("file") MultipartFile file) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return ApiResponse.success(service.upload(user.userId(), file));
|
||||
}
|
||||
|
||||
@GetMapping("/{versionId}")
|
||||
@PreAuthorize("hasAuthority('firstleg:fba-pricing:rate:view')")
|
||||
public ApiResponse<PriceBookDtos.PriceBookVersionDetail> detail(
|
||||
@AuthenticationPrincipal com.baiyue.common.security.AuthenticatedUser user,
|
||||
@PathVariable long versionId) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return ApiResponse.success(service.detail(versionId));
|
||||
}
|
||||
|
||||
@PostMapping("/{versionId}/publish")
|
||||
@PreAuthorize("hasAuthority('firstleg:fba-pricing:rate:edit')")
|
||||
@BusinessAudit(event = "FBA_PRICE_BOOK_CONFIRM", resourceType = "PRICE_BOOK_VERSION", resourceId = "#versionId", description = "确认仓库映射并生效船期价格表")
|
||||
public ApiResponse<PriceBookDtos.PriceBookVersionDetail> publish(
|
||||
@AuthenticationPrincipal com.baiyue.common.security.AuthenticatedUser user,
|
||||
@PathVariable long versionId,
|
||||
@Valid @RequestBody PriceBookConfirmationRequest request) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return ApiResponse.success(service.publish(versionId, user.userId(), request));
|
||||
}
|
||||
|
||||
@GetMapping("/{versionId}/source")
|
||||
@PreAuthorize("hasAuthority('firstleg:fba-pricing:rate:view')")
|
||||
public ResponseEntity<byte[]> source(
|
||||
@AuthenticationPrincipal com.baiyue.common.security.AuthenticatedUser user,
|
||||
@PathVariable long versionId) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return service.source(versionId, user.userId());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.baiyue.firstleg.domain;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/** 首版固定渠道目录;上传名称只能映射到这些稳定编码。 */
|
||||
public final class PriceBookChannelCatalog {
|
||||
private PriceBookChannelCatalog() {}
|
||||
|
||||
public static final List<ChannelDefinition> CHANNELS = List.of(
|
||||
fba("MATSON_CLX_TRUCK", "美森正班海卡", "美森正班海卡"),
|
||||
fba("MATSON_MAX_TRUCK", "美森加班海卡", "美森加班海卡"),
|
||||
fba("EXX_TRUCK", "EXX海卡", "EXX海卡"),
|
||||
fba("HEDE_TRUCK", "合德海卡", "合德海卡"),
|
||||
fba("EXX_RAIL_NY", "纽约EXX海铁", "纽约EXX海铁"),
|
||||
fba("OA_LA_TRUCK", "洛杉矶普船海卡", "洛杉矶普船海卡"),
|
||||
fba("OA_LA_FULL_TRUCK", "洛杉矶普船全卡", "洛杉矶普船全卡"),
|
||||
fba("OA_CHICAGO_RAIL", "芝加哥普船海铁", "芝加哥普船海铁"),
|
||||
fba("OA_HOUSTON_TRUCK", "休斯顿普船海卡", "休斯顿普船海卡"),
|
||||
fba("OA_NEW_YORK_TRUCK", "纽约普船海卡", "纽约普船海卡"),
|
||||
fba("OA_SAVANNAH_TRUCK", "萨凡纳普船海卡", "萨凡纳普船海卡"),
|
||||
parcel("MATSON_CLX_PARCEL", "美森正班海派", "海派"),
|
||||
parcel("MATSON_MAX_PARCEL", "美森加班海派", "海派"),
|
||||
parcel("HEDE_PARCEL", "合德海派", "海派"),
|
||||
parcel("OA_PARCEL", "普船海派", "海派")
|
||||
);
|
||||
|
||||
private static ChannelDefinition fba(String code, String name, String sheet) {
|
||||
return new ChannelDefinition(code, name, PriceModel.FBA_WAREHOUSE_RATE, sheet, Set.of(name));
|
||||
}
|
||||
|
||||
private static ChannelDefinition parcel(String code, String name, String sheet) {
|
||||
return new ChannelDefinition(code, name, PriceModel.REGION_WEIGHT_TIER, sheet, Set.of(name));
|
||||
}
|
||||
|
||||
public record ChannelDefinition(
|
||||
String channelCode,
|
||||
String displayName,
|
||||
PriceModel priceModel,
|
||||
String sourceSheetName,
|
||||
Set<String> aliases
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.baiyue.firstleg.domain;
|
||||
|
||||
/** 平台公共船期价格表版本状态;上传后待确认映射,确认即生效。 */
|
||||
public enum PriceBookStatus {
|
||||
PENDING_CONFIRMATION,
|
||||
ACTIVE,
|
||||
SUPERSEDED,
|
||||
FAILED
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.baiyue.firstleg.domain;
|
||||
|
||||
/** 当前首版支持的两种明确价格模型,不扩展为通用规则引擎。 */
|
||||
public enum PriceModel {
|
||||
FBA_WAREHOUSE_RATE,
|
||||
REGION_WEIGHT_TIER
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.baiyue.firstleg.domain;
|
||||
|
||||
/** 海派首版固定美国区域;优先由目的仓邮编或版本内区划数据解析。 */
|
||||
public enum PriceRegion {
|
||||
WEST,
|
||||
CENTRAL,
|
||||
EAST
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.baiyue.firstleg.domain;
|
||||
|
||||
/** 价格明细支持的目的地类型;PRIVATE 本期不参与自动估价。 */
|
||||
public enum PricingDestinationType {
|
||||
FBA,
|
||||
OVERSEAS
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.baiyue.firstleg.domain;
|
||||
|
||||
/** 报价包税维度;与订单的买单/退税 taxMode 是两个独立概念。 */
|
||||
public enum PricingTaxMode {
|
||||
TAX_INCLUDED,
|
||||
TAX_EXCLUDED
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.baiyue.firstleg.dto;
|
||||
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** 已完成外部协议转换后的单箱设备测量命令。 */
|
||||
public record CartonMeasurementCommand(
|
||||
String boxCode,
|
||||
BigDecimal lengthCm,
|
||||
BigDecimal widthCm,
|
||||
BigDecimal heightCm,
|
||||
BigDecimal unitWeightKg,
|
||||
String machineNo,
|
||||
LocalDateTime measuredAt,
|
||||
MultipartFile image
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.baiyue.firstleg.dto;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** 单箱当前测量结果;confirmedPackageCount 为平台人工确认的代表箱数。 */
|
||||
public record CartonMeasurementResult(
|
||||
Long measurementId,
|
||||
Integer measurementVersion,
|
||||
Long itemId,
|
||||
String boxCode,
|
||||
Integer confirmedPackageCount,
|
||||
BigDecimal lengthCm,
|
||||
BigDecimal widthCm,
|
||||
BigDecimal heightCm,
|
||||
BigDecimal unitWeightKg,
|
||||
Long imageFileId,
|
||||
String machineNo,
|
||||
LocalDateTime measuredAt,
|
||||
boolean created
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.baiyue.firstleg.dto;
|
||||
|
||||
import jakarta.validation.constraints.DecimalMax;
|
||||
import jakarta.validation.constraints.DecimalMin;
|
||||
import jakarta.validation.constraints.Digits;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
public final class CustomerDimensionConfigDtos {
|
||||
private CustomerDimensionConfigDtos() { }
|
||||
|
||||
public record UpdateRequest(
|
||||
@NotNull @DecimalMin("0.001") @DecimalMax("100.000") @Digits(integer = 3, fraction = 3)
|
||||
BigDecimal edgeAdditionCm,
|
||||
@Size(max = 500) String remark,
|
||||
@NotNull @Min(0) Integer version
|
||||
) { }
|
||||
|
||||
public record Response(
|
||||
Long id,
|
||||
Long tenantId,
|
||||
BigDecimal edgeAdditionCm,
|
||||
String remark,
|
||||
Integer version,
|
||||
LocalDateTime createdAt,
|
||||
LocalDateTime updatedAt
|
||||
) { }
|
||||
|
||||
public record InitializeRequest(
|
||||
@NotNull @Min(1) Long tenantId,
|
||||
@NotNull @Min(0) Long operatorId,
|
||||
@NotBlank @Size(max = 128) String initializationKey
|
||||
) { }
|
||||
|
||||
public record InitializeResponse(Long configId, boolean createdByThisInitialization) { }
|
||||
}
|
||||
@@ -59,12 +59,13 @@ public final class FbaPricingDtos {
|
||||
|
||||
public record FeeSnapshotResponse(
|
||||
Long feeId, String feeNo, Integer feeVersion, String feeStatus,
|
||||
Long inboundId, String inboundCode,
|
||||
Long chargeRuleId, String ruleCode, Integer chargeRuleVersion,
|
||||
Long carrierRateId, String rateCode, Integer carrierRateVersion,
|
||||
Long customerId, String customerName, Long originWarehouseId, String originWarehouseName,
|
||||
String businessType, String channelCode, Long carrierId, String carrierName,
|
||||
String destinationCode, String taxMode, String chargeMode,
|
||||
Long inboundId, String inboundCode,
|
||||
Long chargeRuleId, String ruleCode, Integer chargeRuleVersion,
|
||||
Long carrierRateId, String rateCode, Integer carrierRateVersion, Long priceBookVersionId,
|
||||
Long customerId, String customerName, Long originWarehouseId, String originWarehouseName,
|
||||
String businessType, String channelCode, Long carrierId, String carrierName,
|
||||
String destinationType, Long destinationWarehouseId, String destinationName, String destinationCode,
|
||||
String taxMode, String pricingTaxMode, String chargeMode,
|
||||
BigDecimal conversionFactor, Integer roundingScale, String roundingMode,
|
||||
BigDecimal actualWeightKg, BigDecimal actualVolumeCbm,
|
||||
BigDecimal convertedQuantity, BigDecimal chargeableQuantity, String chargeUnit,
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.baiyue.firstleg.dto;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
|
||||
/** 稳定箱号分配、补打和测量解析契约。 */
|
||||
public final class InboundCartonLabels {
|
||||
private InboundCartonLabels() {
|
||||
}
|
||||
|
||||
public record Batch(Long inboundId, Long orderId, String inboundCode, Integer totalCount,
|
||||
List<Label> labels) {
|
||||
}
|
||||
|
||||
public record Label(Long id, Long inboundDetailId, Integer cargoItemNo, String cargoName,
|
||||
Integer detailPackageCount, String boxCode, Integer totalCount,
|
||||
Integer cartonIndex, String boxSpecification, BigDecimal lengthCm,
|
||||
BigDecimal widthCm, BigDecimal heightCm, String topCode,
|
||||
String warehouseText, String cartonText, String routeText) {
|
||||
}
|
||||
|
||||
public record Resolution(Long inboundId, Long orderId, Long inboundDetailId, String boxCode,
|
||||
Integer totalCount, Integer cartonIndex, Integer cargoItemNo,
|
||||
String cargoName, String boxSpecification) {
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import jakarta.validation.constraints.PositiveOrZero;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
@@ -57,14 +58,31 @@ public final class InboundMeasurementRequests {
|
||||
) {
|
||||
}
|
||||
|
||||
public record ConfirmedPackageCountRequest(
|
||||
@NotNull @PositiveOrZero Integer measurementVersion,
|
||||
@NotNull @Positive @Max(1000000) Integer confirmedPackageCount
|
||||
) {
|
||||
}
|
||||
|
||||
/** 平台联调使用;最终设备协议在外部适配层转换为相同领域命令。 */
|
||||
public record CartonScanRequest(
|
||||
@NotBlank @Size(max = 128) String boxCode,
|
||||
@NotNull @DecimalMin("0.001") @Digits(integer = 9, fraction = 3) BigDecimal length,
|
||||
@NotNull @DecimalMin("0.001") @Digits(integer = 9, fraction = 3) BigDecimal width,
|
||||
@NotNull @DecimalMin("0.001") @Digits(integer = 9, fraction = 3) BigDecimal height,
|
||||
@DecimalMin("0.001") @Digits(integer = 15, fraction = 3) BigDecimal weight,
|
||||
@NotBlank @Size(max = 64) String machineNo,
|
||||
@NotNull LocalDateTime measuredAt
|
||||
) {
|
||||
}
|
||||
|
||||
public record CreateWeightResultRequest(
|
||||
@NotNull @Positive Long fileId,
|
||||
@Size(max = 64) String ocrProvider,
|
||||
@Size(max = 1000) String ocrRawText,
|
||||
@DecimalMin("0.000") @Digits(integer = 15, fraction = 3) BigDecimal ocrWeightValue,
|
||||
@Size(max = 8) String ocrWeightUnit,
|
||||
@DecimalMin("0.00000") @DecimalMax("1.00000")
|
||||
@Digits(integer = 1, fraction = 5) BigDecimal confidence
|
||||
@DecimalMin("0.00000") @DecimalMax("1.00000") BigDecimal confidence
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -77,7 +95,13 @@ public final class InboundMeasurementRequests {
|
||||
}
|
||||
|
||||
public record ConfirmWeightResultRequest(
|
||||
@NotNull @PositiveOrZero Integer version
|
||||
@NotNull @PositiveOrZero Integer version,
|
||||
@DecimalMin("0.001") @Digits(integer = 15, fraction = 3) BigDecimal adjustedWeightValue,
|
||||
@Size(max = 8) String adjustedWeightUnit,
|
||||
@Size(max = 500) String adjustmentReason
|
||||
) {
|
||||
public boolean hasAdjustment() {
|
||||
return adjustedWeightValue != null || adjustedWeightUnit != null || adjustmentReason != null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ public final class InboundMeasurementResponses {
|
||||
String originalDocumentNo,
|
||||
BigDecimal originalGrossWeightKg,
|
||||
BigDecimal originalVolumeCbm,
|
||||
Integer originalPackageCount,
|
||||
Integer actualPackageCount,
|
||||
BigDecimal actualGrossWeightKg,
|
||||
BigDecimal actualVolumeCbm,
|
||||
@@ -64,7 +65,12 @@ public final class InboundMeasurementResponses {
|
||||
BigDecimal outerLengthCm,
|
||||
BigDecimal outerWidthCm,
|
||||
BigDecimal outerHeightCm,
|
||||
String remark
|
||||
String remark,
|
||||
String boxCode,
|
||||
Integer confirmedPackageCount,
|
||||
String machineNo,
|
||||
LocalDateTime deviceMeasuredAt,
|
||||
LocalDateTime updatedAt
|
||||
) {
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ public record InboundOrderSummary(
|
||||
Long warehouseId, String warehouseName,
|
||||
Long deliveryWarehouseId, String deliveryWarehouseName,
|
||||
String businessType, String channelCode, String channelName,
|
||||
String taxMode, String containerMode, Boolean customsInspection,
|
||||
String taxMode, String pricingTaxMode, String containerMode, Boolean customsInspection,
|
||||
String destinationType, Long destinationWarehouseId, String destinationCode, String destinationName,
|
||||
Boolean holdFlag, String currentHoldReason,
|
||||
Integer totalPackageCount, BigDecimal actualGrossWeightKg, BigDecimal actualNetWeightKg,
|
||||
@@ -40,7 +40,8 @@ public record InboundOrderSummary(
|
||||
order.getExpectedArrivalAt(), order.getCustomerId(), order.getCustomerName(),
|
||||
order.getCompanyId(), order.getCompanyName(), order.getWarehouseId(), order.getWarehouseName(),
|
||||
order.getDeliveryWarehouseId(), order.getDeliveryWarehouseName(), order.getBusinessType(),
|
||||
order.getChannelCode(), order.getChannelName(), order.getTaxMode(), order.getContainerMode(),
|
||||
order.getChannelCode(), order.getChannelName(), order.getTaxMode(), order.getPricingTaxMode(),
|
||||
order.getContainerMode(),
|
||||
order.getCustomsInspection(), order.getDestinationType(), order.getDestinationWarehouseId(),
|
||||
order.getDestinationCode(), order.getDestinationName(), order.getHoldFlag(),
|
||||
order.getCurrentHoldReason(), order.getTotalPackageCount(), order.getActualGrossWeightKg(),
|
||||
|
||||
@@ -21,6 +21,7 @@ public record OrderApprovedInboundCommand(
|
||||
Long carrierId,
|
||||
String carrierName,
|
||||
String taxMode,
|
||||
String pricingTaxMode,
|
||||
String containerMode,
|
||||
Boolean customsInspection,
|
||||
String destinationType,
|
||||
@@ -31,4 +32,19 @@ public record OrderApprovedInboundCommand(
|
||||
String expressCompanyName,
|
||||
String expressTrackingNo
|
||||
) {
|
||||
/** 兼容未携带报价包税维度的历史调用方。 */
|
||||
public OrderApprovedInboundCommand(
|
||||
long tenantId, long orderId, String orderNo, String inboundCode, Long customerId, String customerName,
|
||||
Long warehouseId, String warehouseName, Long deliveryWarehouseId, String deliveryWarehouseName,
|
||||
LocalDateTime expectedArrivalAt, String businessType, String channelCode, String channelName,
|
||||
Long carrierId, String carrierName, String taxMode, String containerMode, Boolean customsInspection,
|
||||
String destinationType, Long destinationWarehouseId, String destinationCode, String destinationName,
|
||||
String publicRemark, String expressCompanyName, String expressTrackingNo
|
||||
) {
|
||||
this(tenantId, orderId, orderNo, inboundCode, customerId, customerName, warehouseId, warehouseName,
|
||||
deliveryWarehouseId, deliveryWarehouseName, expectedArrivalAt, businessType, channelCode, channelName,
|
||||
carrierId, carrierName, taxMode, null, containerMode, customsInspection, destinationType,
|
||||
destinationWarehouseId, destinationCode, destinationName, publicRemark, expressCompanyName,
|
||||
expressTrackingNo);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.baiyue.firstleg.dto;
|
||||
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
public final class PriceBookChannelValidation {
|
||||
private PriceBookChannelValidation() {}
|
||||
|
||||
public record Request(
|
||||
@Min(1) long tenantId,
|
||||
@Min(1) long originWarehouseId,
|
||||
@NotBlank @Size(max = 64) String channelCode
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.baiyue.firstleg.dto;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Confirmation only binds source warehouse dimensions to platform public warehouses.
|
||||
* Price rows and channel metadata are immutable after workbook parsing.
|
||||
*/
|
||||
public record PriceBookConfirmationRequest(
|
||||
@NotEmpty @Size(max = 2) List<@Valid OriginMapping> originMappings,
|
||||
@Size(max = 2) List<@Valid OverseasMapping> overseasMappings
|
||||
) {
|
||||
public PriceBookConfirmationRequest {
|
||||
overseasMappings = overseasMappings == null ? List.of() : List.copyOf(overseasMappings);
|
||||
}
|
||||
|
||||
public record OriginMapping(
|
||||
@NotBlank @Size(max = 64) String originWarehouseCode,
|
||||
@NotNull @Min(1) Long warehouseId
|
||||
) { }
|
||||
|
||||
public record OverseasMapping(
|
||||
@NotBlank @Size(max = 128) String sourceDestinationName,
|
||||
@NotNull @Min(1) Long warehouseId
|
||||
) { }
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package com.baiyue.firstleg.dto;
|
||||
|
||||
import com.baiyue.firstleg.domain.PriceBookStatus;
|
||||
import com.baiyue.firstleg.domain.PriceModel;
|
||||
import com.baiyue.firstleg.domain.PriceRegion;
|
||||
import com.baiyue.firstleg.domain.PricingTaxMode;
|
||||
import com.baiyue.firstleg.domain.PricingDestinationType;
|
||||
import jakarta.validation.constraints.DecimalMin;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/** 船期价格表上传、人工确认、当前渠道和参考估价的公共契约。 */
|
||||
public final class PriceBookDtos {
|
||||
private PriceBookDtos() {}
|
||||
|
||||
public record PriceBookVersionSummary(
|
||||
Long id,
|
||||
Integer versionNo,
|
||||
String originalFileName,
|
||||
Long sourceFileId,
|
||||
String sourceSha256,
|
||||
PriceBookStatus status,
|
||||
Integer channelCount,
|
||||
Integer rateCount,
|
||||
Integer blockingErrorCount,
|
||||
Integer warningCount,
|
||||
String parseIssuesJson,
|
||||
Long createdBy,
|
||||
LocalDateTime createdAt,
|
||||
Long publishedBy,
|
||||
LocalDateTime publishedAt,
|
||||
LocalDateTime supersededAt
|
||||
) {}
|
||||
|
||||
public record PriceBookVersionDetail(
|
||||
PriceBookVersionSummary version,
|
||||
List<ParsedChannel> channels,
|
||||
List<ParsedRate> rates,
|
||||
List<ParseIssue> issues
|
||||
) {}
|
||||
|
||||
/** Read-only channel data parsed from an uploaded workbook. */
|
||||
public record ParsedChannel(
|
||||
@NotBlank @Size(max = 64) String channelCode,
|
||||
@NotBlank @Size(max = 128) String channelName,
|
||||
@NotNull PriceModel priceModel,
|
||||
@NotBlank @Size(max = 128) String sourceSheetName,
|
||||
LocalDate estimatedDepartureDate,
|
||||
@Size(max = 128) String estimatedDepartureText,
|
||||
@Size(max = 128) String latestWarehouseEntry,
|
||||
@Size(max = 128) String vesselName,
|
||||
@Size(max = 64) String voyageNo,
|
||||
@Size(max = 128) String referenceTransit,
|
||||
@Size(max = 2000) String surchargeDescription,
|
||||
@Size(max = 2000) String remark
|
||||
) {}
|
||||
|
||||
/** Read-only rate row parsed from an uploaded workbook. */
|
||||
public record ParsedRate(
|
||||
Long id,
|
||||
@NotBlank @Size(max = 64) String channelCode,
|
||||
@NotNull PriceModel priceModel,
|
||||
Long originWarehouseId,
|
||||
@NotBlank @Size(max = 64) String originWarehouseCode,
|
||||
@NotBlank @Size(max = 128) String originWarehouseName,
|
||||
@Size(max = 32) String destinationCode,
|
||||
PricingDestinationType destinationType,
|
||||
Long destinationWarehouseId,
|
||||
@Size(max = 128) String destinationName,
|
||||
PriceRegion regionCode,
|
||||
PricingTaxMode pricingTaxMode,
|
||||
@NotBlank @Pattern(regexp = "KG|CBM") String chargeUnit,
|
||||
@DecimalMin(value = "0", inclusive = true) BigDecimal weightFromKg,
|
||||
@NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal unitPrice,
|
||||
@NotBlank @Pattern(regexp = "[A-Z]{3}") String currency,
|
||||
@NotBlank @Size(max = 128) String sourceSheetName,
|
||||
@NotNull @Min(1) Integer sourceRowNumber,
|
||||
@Size(max = 1000) String sourceDestinationText,
|
||||
@Size(max = 500) String remark
|
||||
) {}
|
||||
|
||||
public record ParseIssue(
|
||||
String severity,
|
||||
String issueCode,
|
||||
String message,
|
||||
String sourceSheetName,
|
||||
Integer sourceRowNumber,
|
||||
String sourceCell
|
||||
) {}
|
||||
|
||||
public record CurrentChannel(
|
||||
Long priceBookVersionId,
|
||||
String channelCode,
|
||||
String channelName,
|
||||
PriceModel priceModel,
|
||||
List<PricingTaxMode> pricingTaxModes,
|
||||
LocalDate estimatedDepartureDate,
|
||||
String estimatedDepartureText,
|
||||
String latestWarehouseEntry,
|
||||
String referenceTransit,
|
||||
String surchargeDescription,
|
||||
String remark
|
||||
) {}
|
||||
|
||||
public record EstimateRequest(
|
||||
@NotNull @Min(1) Long originWarehouseId,
|
||||
@NotBlank @Size(max = 64) String channelCode,
|
||||
@Size(max = 32) String destinationCode,
|
||||
@NotNull PricingDestinationType destinationType,
|
||||
Long destinationWarehouseId,
|
||||
@Size(max = 128) String destinationName,
|
||||
PriceRegion destinationRegion,
|
||||
PricingTaxMode pricingTaxMode,
|
||||
@DecimalMin(value = "0", inclusive = true) BigDecimal actualWeightKg,
|
||||
@DecimalMin(value = "0", inclusive = true) BigDecimal actualVolumeCbm
|
||||
) {}
|
||||
|
||||
public record EstimateResponse(
|
||||
Long priceBookVersionId,
|
||||
String channelCode,
|
||||
PriceModel priceModel,
|
||||
PricingTaxMode pricingTaxMode,
|
||||
BigDecimal chargeableQuantity,
|
||||
String chargeUnit,
|
||||
BigDecimal unitPrice,
|
||||
BigDecimal estimatedAmount,
|
||||
String currency,
|
||||
String disclaimer
|
||||
) {}
|
||||
}
|
||||
@@ -21,19 +21,31 @@ public class CarrierFbaRateEntity extends BaseEntity {
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
private Long tenantId;
|
||||
private Long priceBookVersionId;
|
||||
private String rateCode;
|
||||
private Integer rateVersion;
|
||||
private Long originWarehouseId;
|
||||
private String originWarehouseCode;
|
||||
private String originWarehouseName;
|
||||
private String businessType;
|
||||
private String channelCode;
|
||||
private String priceModel;
|
||||
private Long carrierId;
|
||||
private String carrierName;
|
||||
private String destinationType;
|
||||
private String destinationCode;
|
||||
private Long destinationWarehouseId;
|
||||
private String destinationName;
|
||||
private String regionCode;
|
||||
private String pricingTaxMode;
|
||||
private BigDecimal weightFromKg;
|
||||
private String chargeUnit;
|
||||
private BigDecimal unitPrice;
|
||||
private String currency;
|
||||
private BigDecimal additionalFee;
|
||||
private String sourceSheetName;
|
||||
private Integer sourceRowNumber;
|
||||
private String sourceDestinationText;
|
||||
private LocalDateTime effectiveFrom;
|
||||
private LocalDateTime effectiveTo;
|
||||
private String status;
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.baiyue.firstleg.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.baiyue.common.core.model.BaseEntity;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/** 客户填写真实尺寸后使用的每侧加宽配置。 */
|
||||
@Getter
|
||||
@Setter
|
||||
@TableName("fl_customer_dimension_config")
|
||||
public class CustomerDimensionConfigEntity extends BaseEntity {
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
private Long tenantId;
|
||||
private BigDecimal edgeAdditionCm;
|
||||
private String remark;
|
||||
private String initializationKey;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.baiyue.firstleg.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** 首次打印前冻结的实际箱号与订单货物明细映射。 */
|
||||
@Getter
|
||||
@Setter
|
||||
@TableName("fl_inbound_carton_label")
|
||||
public class InboundCartonLabelEntity {
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
private Long tenantId;
|
||||
private Long inboundId;
|
||||
private Long orderId;
|
||||
private Long inboundDetailId;
|
||||
private Integer cargoItemNo;
|
||||
private String cargoName;
|
||||
private Integer detailPackageCount;
|
||||
private String inboundCode;
|
||||
private String boxCode;
|
||||
private Integer totalCartonCount;
|
||||
private Integer cartonIndex;
|
||||
private String boxSpecification;
|
||||
private BigDecimal lengthCm;
|
||||
private BigDecimal widthCm;
|
||||
private BigDecimal heightCm;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
@@ -24,11 +24,13 @@ public class InboundFeeEntity {
|
||||
private Integer chargeRuleVersion;
|
||||
private Long carrierRateId;
|
||||
private Integer carrierRateVersion;
|
||||
private Long priceBookVersionId;
|
||||
private String ruleCode;
|
||||
private Long customerId;
|
||||
private String customerName;
|
||||
private String businessType;
|
||||
private String taxMode;
|
||||
private String pricingTaxMode;
|
||||
private Integer roundingScale;
|
||||
private String roundingMode;
|
||||
private LocalDateTime ruleEffectiveFrom;
|
||||
@@ -39,6 +41,9 @@ public class InboundFeeEntity {
|
||||
private String channelCode;
|
||||
private Long carrierId;
|
||||
private String carrierName;
|
||||
private String destinationType;
|
||||
private Long destinationWarehouseId;
|
||||
private String destinationName;
|
||||
private String destinationCode;
|
||||
private LocalDateTime rateEffectiveFrom;
|
||||
private LocalDateTime rateEffectiveTo;
|
||||
|
||||
@@ -20,6 +20,7 @@ public class InboundMeasurementItemEntity {
|
||||
private Long measurementId;
|
||||
private Long inboundDetailId;
|
||||
private String externalDetailNo;
|
||||
private String boxCode;
|
||||
private String itemNo;
|
||||
private String boxSpecification;
|
||||
private Integer packageCount;
|
||||
@@ -36,10 +37,13 @@ public class InboundMeasurementItemEntity {
|
||||
private BigDecimal volumeWeightKg;
|
||||
private String calculationRuleVersion;
|
||||
private Long sourceImageFileId;
|
||||
private String machineNo;
|
||||
private LocalDateTime deviceMeasuredAt;
|
||||
private String overLimitWarning;
|
||||
private BigDecimal outerLengthCm;
|
||||
private BigDecimal outerWidthCm;
|
||||
private BigDecimal outerHeightCm;
|
||||
private String remark;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ public class InboundOrderEntity extends BaseEntity {
|
||||
private Long carrierId;
|
||||
private String carrierName;
|
||||
private String taxMode;
|
||||
private String pricingTaxMode;
|
||||
private String containerMode;
|
||||
private Boolean customsInspection;
|
||||
private String destinationType;
|
||||
|
||||
@@ -30,8 +30,11 @@ public class InboundPricingEntity extends BaseEntity {
|
||||
private Long carrierId;
|
||||
private String carrierName;
|
||||
private String taxMode;
|
||||
private String pricingTaxMode;
|
||||
private String destinationType;
|
||||
private String destinationCode;
|
||||
private Long destinationWarehouseId;
|
||||
private String destinationName;
|
||||
private BigDecimal actualGrossWeightKg;
|
||||
private BigDecimal actualVolumeCbm;
|
||||
private BigDecimal chargeableWeightKg;
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.baiyue.firstleg.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.baiyue.common.core.model.BaseEntity;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import java.time.LocalDate;
|
||||
|
||||
@Getter @Setter
|
||||
@TableName("fl_price_book_channel")
|
||||
public class PriceBookChannelEntity extends BaseEntity {
|
||||
@TableId(type = IdType.ASSIGN_ID) private Long id;
|
||||
private Long priceBookVersionId;
|
||||
private String channelCode;
|
||||
private String channelName;
|
||||
private String priceModel;
|
||||
private String sourceSheetName;
|
||||
private LocalDate estimatedDepartureDate;
|
||||
private String estimatedDepartureText;
|
||||
private String latestWarehouseEntry;
|
||||
private String vesselName;
|
||||
private String voyageNo;
|
||||
private String referenceTransit;
|
||||
private String surchargeDescription;
|
||||
private String remark;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.baiyue.firstleg.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.baiyue.common.core.model.BaseEntity;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Getter @Setter
|
||||
@TableName("fl_price_book_version")
|
||||
public class PriceBookVersionEntity extends BaseEntity {
|
||||
public static final long PLATFORM_SCOPE = 0L;
|
||||
@TableId(type = IdType.ASSIGN_ID) private Long id;
|
||||
private Long tenantId;
|
||||
private Integer versionNo;
|
||||
private String originalFileName;
|
||||
private Long sourceFileId;
|
||||
private String sourceSha256;
|
||||
private String status;
|
||||
private Integer blockingErrorCount;
|
||||
private Integer warningCount;
|
||||
private String parseIssuesJson;
|
||||
private Integer channelCount;
|
||||
private Integer rateCount;
|
||||
private Long publishedBy;
|
||||
private LocalDateTime publishedAt;
|
||||
private LocalDateTime supersededAt;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.baiyue.firstleg.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Getter @Setter
|
||||
@TableName("fl_price_channel_catalog")
|
||||
public class PriceChannelCatalogEntity {
|
||||
@TableId(type = IdType.ASSIGN_ID) private Long id;
|
||||
private String channelCode;
|
||||
private String displayName;
|
||||
private String priceModel;
|
||||
private String sourceSheetName;
|
||||
private String aliasesJson;
|
||||
private String status;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.baiyue.firstleg.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Getter @Setter
|
||||
@TableName("fl_price_origin_alias")
|
||||
public class PriceOriginAliasEntity {
|
||||
@TableId(type = IdType.ASSIGN_ID) private Long id;
|
||||
private String warehouseCode;
|
||||
private String aliasName;
|
||||
private String status;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.baiyue.firstleg.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@TableName("fl_price_warehouse_mapping")
|
||||
public class PriceWarehouseMappingEntity {
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
private String sourceType;
|
||||
private String sourceValue;
|
||||
private Long warehouseId;
|
||||
private String warehouseCode;
|
||||
private String warehouseType;
|
||||
private String status;
|
||||
}
|
||||
@@ -1,4 +1,10 @@
|
||||
package com.baiyue.firstleg.mapper;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.firstleg.entity.CarrierFbaRateEntity;
|
||||
public interface CarrierFbaRateMapper extends BaseMapper<CarrierFbaRateEntity> {}
|
||||
import org.apache.ibatis.annotations.Delete;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
public interface CarrierFbaRateMapper extends BaseMapper<CarrierFbaRateEntity> {
|
||||
@Delete("DELETE FROM fl_carrier_fba_rate WHERE price_book_version_id = #{versionId} AND status = 'PENDING_CONFIRMATION'")
|
||||
int physicallyDeletePendingByPriceBookVersionId(@Param("versionId") long versionId);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.baiyue.firstleg.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.firstleg.entity.CustomerDimensionConfigEntity;
|
||||
import org.apache.ibatis.annotations.Delete;
|
||||
import org.apache.ibatis.annotations.Insert;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
public interface CustomerDimensionConfigMapper extends BaseMapper<CustomerDimensionConfigEntity> {
|
||||
@Insert("""
|
||||
INSERT IGNORE INTO fl_customer_dimension_config
|
||||
(id, tenant_id, edge_addition_cm, remark, initialization_key, created_by, created_at,
|
||||
updated_by, updated_at, deleted, version)
|
||||
VALUES
|
||||
(#{id}, #{tenantId}, #{edgeAdditionCm}, #{remark}, #{initializationKey}, #{operatorId}, CURRENT_TIMESTAMP(3),
|
||||
#{operatorId}, CURRENT_TIMESTAMP(3), 0, 0)
|
||||
""")
|
||||
int insertDefault(@Param("id") long id, @Param("tenantId") long tenantId,
|
||||
@Param("edgeAdditionCm") BigDecimal edgeAdditionCm,
|
||||
@Param("remark") String remark, @Param("initializationKey") String initializationKey,
|
||||
@Param("operatorId") long operatorId);
|
||||
|
||||
@Delete("""
|
||||
DELETE FROM fl_customer_dimension_config
|
||||
WHERE id = #{id}
|
||||
AND tenant_id = #{tenantId}
|
||||
AND initialization_key = #{initializationKey}
|
||||
AND edge_addition_cm = #{edgeAdditionCm}
|
||||
AND version = 0
|
||||
AND deleted = 0
|
||||
""")
|
||||
int deleteUnchangedDefault(@Param("tenantId") long tenantId, @Param("id") long id,
|
||||
@Param("initializationKey") String initializationKey,
|
||||
@Param("edgeAdditionCm") BigDecimal edgeAdditionCm);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.baiyue.firstleg.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.firstleg.entity.InboundCartonLabelEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface InboundCartonLabelMapper extends BaseMapper<InboundCartonLabelEntity> {
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.baiyue.firstleg.mapper;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.firstleg.entity.PriceBookChannelEntity;
|
||||
import org.apache.ibatis.annotations.Delete;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
public interface PriceBookChannelMapper extends BaseMapper<PriceBookChannelEntity> {
|
||||
@Delete("DELETE FROM fl_price_book_channel WHERE price_book_version_id = #{versionId}")
|
||||
int physicallyDeleteByPriceBookVersionId(@Param("versionId") long versionId);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package com.baiyue.firstleg.mapper;
|
||||
|
||||
import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelRow;
|
||||
import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelTaxModeRow;
|
||||
import com.baiyue.firstleg.repository.PriceBookQueryRows.RateRow;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** Dedicated read model for merchant channel selection and reference estimates. */
|
||||
@Mapper
|
||||
public interface PriceBookQueryMapper {
|
||||
@Select("""
|
||||
SELECT id
|
||||
FROM fl_price_book_version
|
||||
WHERE tenant_id = 0 AND status = 'ACTIVE' AND deleted = 0
|
||||
ORDER BY published_at DESC, version_no DESC
|
||||
LIMIT 1
|
||||
""")
|
||||
Long findActiveVersionId();
|
||||
|
||||
@Select("""
|
||||
SELECT c.price_book_version_id AS priceBookVersionId,
|
||||
c.channel_code AS channelCode, c.channel_name AS channelName,
|
||||
c.price_model AS priceModel,
|
||||
c.estimated_departure_date AS estimatedDepartureDate,
|
||||
c.estimated_departure_text AS estimatedDepartureText,
|
||||
c.latest_warehouse_entry AS latestWarehouseEntry,
|
||||
c.reference_transit AS referenceTransit,
|
||||
c.surcharge_description AS surchargeDescription,
|
||||
c.remark AS remark
|
||||
FROM fl_price_book_channel c
|
||||
WHERE c.price_book_version_id = #{versionId}
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM fl_carrier_fba_rate r
|
||||
WHERE r.price_book_version_id = c.price_book_version_id
|
||||
AND r.channel_code = c.channel_code
|
||||
AND r.tenant_id = 0 AND r.origin_warehouse_id = #{originWarehouseId}
|
||||
AND r.status = 'ACTIVE' AND r.deleted = 0
|
||||
)
|
||||
ORDER BY c.id
|
||||
""")
|
||||
List<ChannelRow> findChannels(@Param("versionId") long versionId,
|
||||
@Param("originWarehouseId") long originWarehouseId);
|
||||
|
||||
@Select("""
|
||||
SELECT r.channel_code AS channelCode, r.pricing_tax_mode AS pricingTaxMode
|
||||
FROM fl_carrier_fba_rate r
|
||||
WHERE r.price_book_version_id = #{versionId}
|
||||
AND r.tenant_id = 0 AND r.origin_warehouse_id = #{originWarehouseId}
|
||||
AND r.status = 'ACTIVE' AND r.deleted = 0
|
||||
AND r.pricing_tax_mode IS NOT NULL
|
||||
GROUP BY r.channel_code, r.pricing_tax_mode
|
||||
ORDER BY r.channel_code, r.pricing_tax_mode
|
||||
""")
|
||||
List<ChannelTaxModeRow> findChannelTaxModes(@Param("versionId") long versionId,
|
||||
@Param("originWarehouseId") long originWarehouseId);
|
||||
|
||||
@Select("""
|
||||
SELECT r.id, r.price_book_version_id AS priceBookVersionId,
|
||||
r.rate_code AS rateCode, r.rate_version AS rateVersion,
|
||||
r.origin_warehouse_id AS originWarehouseId, r.origin_warehouse_name AS originWarehouseName,
|
||||
r.channel_code AS channelCode, r.price_model AS priceModel,
|
||||
r.destination_type AS destinationType, r.destination_code AS destinationCode,
|
||||
r.destination_warehouse_id AS destinationWarehouseId, r.destination_name AS destinationName,
|
||||
r.region_code AS regionCode,
|
||||
r.pricing_tax_mode AS pricingTaxMode, r.charge_unit AS chargeUnit,
|
||||
r.weight_from_kg AS weightFromKg, r.unit_price AS unitPrice,
|
||||
r.currency AS currency, r.additional_fee AS additionalFee,
|
||||
r.effective_from AS effectiveFrom, r.effective_to AS effectiveTo
|
||||
FROM fl_carrier_fba_rate r
|
||||
WHERE r.price_book_version_id = #{versionId}
|
||||
AND r.tenant_id = 0 AND r.origin_warehouse_id = #{originWarehouseId}
|
||||
AND r.channel_code = #{channelCode} AND r.destination_type = 'FBA'
|
||||
AND UPPER(r.destination_code) = #{destinationCode}
|
||||
AND r.pricing_tax_mode = #{pricingTaxMode}
|
||||
AND r.status = 'ACTIVE' AND r.deleted = 0
|
||||
""")
|
||||
List<RateRow> findFbaRates(@Param("versionId") long versionId,
|
||||
@Param("originWarehouseId") long originWarehouseId,
|
||||
@Param("channelCode") String channelCode,
|
||||
@Param("destinationCode") String destinationCode,
|
||||
@Param("pricingTaxMode") String pricingTaxMode);
|
||||
|
||||
@Select("""
|
||||
SELECT r.id, r.price_book_version_id AS priceBookVersionId,
|
||||
r.rate_code AS rateCode, r.rate_version AS rateVersion,
|
||||
r.origin_warehouse_id AS originWarehouseId, r.origin_warehouse_name AS originWarehouseName,
|
||||
r.channel_code AS channelCode, r.price_model AS priceModel,
|
||||
r.destination_type AS destinationType, r.destination_code AS destinationCode,
|
||||
r.destination_warehouse_id AS destinationWarehouseId, r.destination_name AS destinationName,
|
||||
r.region_code AS regionCode,
|
||||
r.pricing_tax_mode AS pricingTaxMode, r.charge_unit AS chargeUnit,
|
||||
r.weight_from_kg AS weightFromKg, r.unit_price AS unitPrice,
|
||||
r.currency AS currency, r.additional_fee AS additionalFee,
|
||||
r.effective_from AS effectiveFrom, r.effective_to AS effectiveTo
|
||||
FROM fl_carrier_fba_rate r
|
||||
WHERE r.price_book_version_id = #{versionId}
|
||||
AND r.tenant_id = 0 AND r.origin_warehouse_id = #{originWarehouseId}
|
||||
AND r.channel_code = #{channelCode} AND r.destination_type = 'OVERSEAS'
|
||||
AND r.destination_warehouse_id = #{destinationWarehouseId}
|
||||
AND r.pricing_tax_mode = #{pricingTaxMode}
|
||||
AND r.status = 'ACTIVE' AND r.deleted = 0
|
||||
""")
|
||||
List<RateRow> findOverseasRates(@Param("versionId") long versionId,
|
||||
@Param("originWarehouseId") long originWarehouseId,
|
||||
@Param("channelCode") String channelCode,
|
||||
@Param("destinationWarehouseId") long destinationWarehouseId,
|
||||
@Param("pricingTaxMode") String pricingTaxMode);
|
||||
|
||||
@Select("""
|
||||
SELECT r.id, r.price_book_version_id AS priceBookVersionId,
|
||||
r.rate_code AS rateCode, r.rate_version AS rateVersion,
|
||||
r.origin_warehouse_id AS originWarehouseId, r.origin_warehouse_name AS originWarehouseName,
|
||||
r.channel_code AS channelCode, r.price_model AS priceModel,
|
||||
r.destination_type AS destinationType, r.destination_code AS destinationCode,
|
||||
r.destination_warehouse_id AS destinationWarehouseId, r.destination_name AS destinationName,
|
||||
r.region_code AS regionCode,
|
||||
r.pricing_tax_mode AS pricingTaxMode, r.charge_unit AS chargeUnit,
|
||||
r.weight_from_kg AS weightFromKg, r.unit_price AS unitPrice,
|
||||
r.currency AS currency, r.additional_fee AS additionalFee,
|
||||
r.effective_from AS effectiveFrom, r.effective_to AS effectiveTo
|
||||
FROM fl_carrier_fba_rate r
|
||||
WHERE r.price_book_version_id = #{versionId}
|
||||
AND r.tenant_id = 0 AND r.origin_warehouse_id = #{originWarehouseId}
|
||||
AND r.channel_code = #{channelCode}
|
||||
AND r.price_model = 'REGION_WEIGHT_TIER'
|
||||
AND r.region_code = #{regionCode}
|
||||
AND ((#{pricingTaxMode} IS NULL AND r.pricing_tax_mode IS NULL)
|
||||
OR r.pricing_tax_mode = #{pricingTaxMode})
|
||||
AND r.status = 'ACTIVE' AND r.deleted = 0
|
||||
ORDER BY r.weight_from_kg
|
||||
""")
|
||||
List<RateRow> findRegionTiers(@Param("versionId") long versionId,
|
||||
@Param("originWarehouseId") long originWarehouseId,
|
||||
@Param("channelCode") String channelCode,
|
||||
@Param("regionCode") String regionCode,
|
||||
@Param("pricingTaxMode") String pricingTaxMode);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package com.baiyue.firstleg.mapper;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.firstleg.entity.PriceBookVersionEntity;
|
||||
public interface PriceBookVersionMapper extends BaseMapper<PriceBookVersionEntity> {}
|
||||
@@ -0,0 +1,4 @@
|
||||
package com.baiyue.firstleg.mapper;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.firstleg.entity.PriceChannelCatalogEntity;
|
||||
public interface PriceChannelCatalogMapper extends BaseMapper<PriceChannelCatalogEntity> {}
|
||||
@@ -0,0 +1,4 @@
|
||||
package com.baiyue.firstleg.mapper;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.firstleg.entity.PriceOriginAliasEntity;
|
||||
public interface PriceOriginAliasMapper extends BaseMapper<PriceOriginAliasEntity> {}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.baiyue.firstleg.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.firstleg.entity.PriceWarehouseMappingEntity;
|
||||
|
||||
public interface PriceWarehouseMappingMapper extends BaseMapper<PriceWarehouseMappingEntity> {}
|
||||
@@ -0,0 +1,150 @@
|
||||
package com.baiyue.firstleg.pricing;
|
||||
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.domain.PriceModel;
|
||||
import com.baiyue.firstleg.domain.PriceRegion;
|
||||
import com.baiyue.firstleg.domain.PricingDestinationType;
|
||||
import com.baiyue.firstleg.domain.PricingTaxMode;
|
||||
import com.baiyue.firstleg.dto.PriceBookDtos.EstimateRequest;
|
||||
import com.baiyue.firstleg.repository.PriceBookQueryRepository;
|
||||
import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelRow;
|
||||
import com.baiyue.firstleg.repository.PriceBookQueryRows.RateRow;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
/** Single active-price-book lookup used by merchant estimates and inbound automatic billing. */
|
||||
@Component
|
||||
public class ActivePriceBookEstimator {
|
||||
@Resource private PriceBookQueryRepository repository;
|
||||
@Resource private FbaWarehouseRateEstimator fbaEstimator;
|
||||
@Resource private RegionWeightTierEstimator regionEstimator;
|
||||
@Resource private PriceBookWarehouseRegionResolver warehouseRegionResolver;
|
||||
|
||||
public Estimate estimate(EstimateRequest request) {
|
||||
if (request == null || request.originWarehouseId() == null || request.originWarehouseId() <= 0) {
|
||||
throw validation("请选择有效起运仓");
|
||||
}
|
||||
Long versionId = repository.activeVersionId();
|
||||
if (versionId == null) {
|
||||
throw new BizException(ResultCode.NOT_FOUND, "当前没有生效的船期价格版本");
|
||||
}
|
||||
String channelCode = normalizeChannel(request.channelCode());
|
||||
ChannelRow channel = repository.channels(versionId, request.originWarehouseId()).stream()
|
||||
.filter(value -> channelCode.equals(value.getChannelCode()))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new BizException(ResultCode.CONFLICT, "所选渠道已下线,请重新选择"));
|
||||
return switch (priceModel(channel.getPriceModel())) {
|
||||
case FBA_WAREHOUSE_RATE -> estimateFba(versionId, channelCode, request);
|
||||
case REGION_WEIGHT_TIER -> estimateRegion(versionId, channelCode, request);
|
||||
};
|
||||
}
|
||||
|
||||
private Estimate estimateFba(long versionId, String channelCode, EstimateRequest request) {
|
||||
if (request.pricingTaxMode() == null) {
|
||||
throw validation("请选择包税或不包税报价");
|
||||
}
|
||||
List<RateRow> rates;
|
||||
if (request.destinationType() == PricingDestinationType.FBA) {
|
||||
if (!StringUtils.hasText(request.destinationCode())) {
|
||||
throw validation("请选择有效FBA目的仓");
|
||||
}
|
||||
rates = repository.fbaRates(versionId, request.originWarehouseId(), channelCode,
|
||||
request.destinationCode().trim().toUpperCase(Locale.ROOT), request.pricingTaxMode().name());
|
||||
} else if (request.destinationType() == PricingDestinationType.OVERSEAS) {
|
||||
if (request.destinationWarehouseId() == null || request.destinationWarehouseId() <= 0) {
|
||||
throw validation("请选择有效海外仓");
|
||||
}
|
||||
rates = repository.overseasRates(versionId, request.originWarehouseId(), channelCode,
|
||||
request.destinationWarehouseId(), request.pricingTaxMode().name());
|
||||
} else {
|
||||
throw validation("私人地址暂不支持参考估价");
|
||||
}
|
||||
RateRow rate = uniqueRate(rates);
|
||||
requireModel(rate, PriceModel.FBA_WAREHOUSE_RATE);
|
||||
var estimate = fbaEstimator.estimate(rate, request.actualWeightKg(), request.actualVolumeCbm());
|
||||
return new Estimate(versionId, channelCode, PriceModel.FBA_WAREHOUSE_RATE, request.pricingTaxMode(),
|
||||
rate, estimate.quantity(), estimate.unit(), estimate.unitPrice(), estimate.amount(), estimate.currency());
|
||||
}
|
||||
|
||||
private Estimate estimateRegion(long versionId, String channelCode, EstimateRequest request) {
|
||||
if (request.destinationType() != PricingDestinationType.FBA) {
|
||||
throw validation("海派渠道仅支持FBA目的仓参考估价");
|
||||
}
|
||||
if (request.pricingTaxMode() == null) {
|
||||
throw validation("请选择包税或不包税报价");
|
||||
}
|
||||
PriceRegion region = request.destinationRegion() == null
|
||||
? resolveWarehouseRegion(request.destinationWarehouseId()) : request.destinationRegion();
|
||||
List<RateRow> tiers = repository.regionTiers(versionId, request.originWarehouseId(), channelCode,
|
||||
region.name(), request.pricingTaxMode().name());
|
||||
if (tiers == null || tiers.isEmpty()) {
|
||||
throw new BizException(ResultCode.NOT_FOUND, "当前渠道没有匹配的区域价格");
|
||||
}
|
||||
tiers.forEach(rate -> requireModel(rate, PriceModel.REGION_WEIGHT_TIER));
|
||||
var estimate = regionEstimator.estimate(tiers, request.actualWeightKg(), request.actualVolumeCbm());
|
||||
RateRow selected = estimate.rate();
|
||||
return new Estimate(versionId, channelCode, PriceModel.REGION_WEIGHT_TIER, request.pricingTaxMode(),
|
||||
selected, estimate.quantity(), "KG", estimate.unitPrice(), estimate.amount(), estimate.currency());
|
||||
}
|
||||
|
||||
private PriceRegion resolveWarehouseRegion(Long warehouseId) {
|
||||
if (warehouseId == null || warehouseId <= 0) {
|
||||
throw validation("请提供目的仓区域或平台FBA仓,以确定海派区域");
|
||||
}
|
||||
return warehouseRegionResolver.resolve(warehouseId);
|
||||
}
|
||||
|
||||
private static RateRow uniqueRate(List<RateRow> rates) {
|
||||
if (rates == null || rates.isEmpty()) {
|
||||
throw new BizException(ResultCode.NOT_FOUND, "当前条件没有匹配价格");
|
||||
}
|
||||
if (rates.size() != 1) {
|
||||
throw new BizException(ResultCode.CONFLICT, "当前条件匹配到多条价格,请联系平台处理");
|
||||
}
|
||||
return rates.get(0);
|
||||
}
|
||||
|
||||
private static void requireModel(RateRow rate, PriceModel expected) {
|
||||
if (rate == null || !expected.name().equals(rate.getPriceModel())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "价格模型与渠道配置不一致");
|
||||
}
|
||||
}
|
||||
|
||||
private static PriceModel priceModel(String value) {
|
||||
try {
|
||||
return PriceModel.valueOf(value);
|
||||
} catch (RuntimeException exception) {
|
||||
throw new BizException(ResultCode.CONFLICT, "渠道价格模型配置无效");
|
||||
}
|
||||
}
|
||||
|
||||
private static String normalizeChannel(String value) {
|
||||
if (!StringUtils.hasText(value)) {
|
||||
throw validation("请选择有效渠道");
|
||||
}
|
||||
return value.trim().toUpperCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private static BizException validation(String message) {
|
||||
return new BizException(ResultCode.VALIDATION_FAILED, message);
|
||||
}
|
||||
|
||||
public record Estimate(
|
||||
Long priceBookVersionId,
|
||||
String channelCode,
|
||||
PriceModel priceModel,
|
||||
PricingTaxMode pricingTaxMode,
|
||||
RateRow rate,
|
||||
BigDecimal chargeableQuantity,
|
||||
String chargeUnit,
|
||||
BigDecimal unitPrice,
|
||||
BigDecimal amount,
|
||||
String currency
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.baiyue.firstleg.pricing;
|
||||
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.repository.PriceBookQueryRows.RateRow;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
|
||||
@Component
|
||||
public class FbaWarehouseRateEstimator {
|
||||
private static final BigDecimal KG_PER_CBM = BigDecimal.valueOf(167);
|
||||
private static final BigDecimal KG_PER_BILLABLE_CBM = BigDecimal.valueOf(363);
|
||||
|
||||
public Estimate estimate(RateRow rate, BigDecimal actualWeightKg, BigDecimal actualVolumeCbm) {
|
||||
BigDecimal weight = nonNegative(actualWeightKg);
|
||||
BigDecimal volume = nonNegative(actualVolumeCbm);
|
||||
if (weight.signum() == 0 && volume.signum() == 0) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED, "重量和体积至少填写一项且必须大于0");
|
||||
}
|
||||
BigDecimal quantity = switch (rate.getChargeUnit()) {
|
||||
case "KG" -> weight.max(volume.multiply(KG_PER_CBM));
|
||||
case "CBM" -> volume.max(weight.divide(KG_PER_BILLABLE_CBM, 12, RoundingMode.HALF_UP));
|
||||
default -> throw new BizException(ResultCode.CONFLICT, "当前价格计费单位不受支持");
|
||||
};
|
||||
quantity = quantity.setScale(6, RoundingMode.HALF_UP);
|
||||
return new Estimate(quantity, rate.getChargeUnit(), rate.getUnitPrice(),
|
||||
quantity.multiply(rate.getUnitPrice()).setScale(2, RoundingMode.HALF_UP), rate.getCurrency());
|
||||
}
|
||||
|
||||
private static BigDecimal nonNegative(BigDecimal value) {
|
||||
if (value == null) return BigDecimal.ZERO;
|
||||
if (value.signum() < 0) throw new BizException(ResultCode.VALIDATION_FAILED, "重量和体积不能小于0");
|
||||
return value;
|
||||
}
|
||||
|
||||
public record Estimate(BigDecimal quantity, String unit, BigDecimal unitPrice,
|
||||
BigDecimal amount, String currency) {}
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
package com.baiyue.firstleg.pricing;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.client.PriceBookWarehouseClient;
|
||||
import com.baiyue.firstleg.domain.PriceRegion;
|
||||
import feign.FeignException;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
/** Resolves a platform FBA warehouse to the fixed sea-parcel price region. */
|
||||
@Component
|
||||
public class PriceBookWarehouseRegionResolver {
|
||||
@Resource private PriceBookWarehouseClient warehouses;
|
||||
@Resource private PriceRegionResolver regionResolver;
|
||||
@Value("${baiyue.security.internal-service-token}") private String internalToken;
|
||||
|
||||
public PriceRegion resolve(long warehouseId) {
|
||||
if (warehouseId <= 0) {
|
||||
throw validation("请提供目的仓区域或平台FBA仓,以确定海派区域");
|
||||
}
|
||||
try {
|
||||
ApiResponse<PriceBookWarehouseClient.Detail> response = warehouses.active(internalToken, 0L, warehouseId);
|
||||
if (response == null) {
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "目的仓服务暂时不可用");
|
||||
}
|
||||
if (!response.successful() || response.data() == null || response.data().warehouse() == null) {
|
||||
throw validation("目的仓不是可用的平台FBA仓");
|
||||
}
|
||||
var warehouse = response.data().warehouse();
|
||||
if (!Long.valueOf(warehouseId).equals(warehouse.id()) || !Long.valueOf(0L).equals(warehouse.tenantId())
|
||||
|| !"FBA".equals(warehouse.warehouseType()) || !"ACTIVE".equals(warehouse.status())) {
|
||||
throw validation("目的仓不是可用的平台FBA仓");
|
||||
}
|
||||
String postalCode = response.data().addresses() == null ? null : response.data().addresses().stream()
|
||||
.filter(address -> address != null && StringUtils.hasText(address.postalCode()))
|
||||
.map(PriceBookWarehouseClient.Address::postalCode)
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
return regionResolver.fromPostalCode(postalCode);
|
||||
} catch (BizException exception) {
|
||||
throw exception;
|
||||
} catch (FeignException exception) {
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "目的仓服务暂时不可用");
|
||||
}
|
||||
}
|
||||
|
||||
private static BizException validation(String message) {
|
||||
return new BizException(ResultCode.VALIDATION_FAILED, message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.baiyue.firstleg.pricing;
|
||||
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.domain.PriceRegion;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class PriceRegionResolver {
|
||||
public PriceRegion fromPostalCode(String rawPostalCode) {
|
||||
String digits = rawPostalCode == null ? "" : rawPostalCode.replaceAll("[^0-9]", "");
|
||||
if (digits.isEmpty()) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED, "目的仓未配置可识别的美国邮编,无法确定海派区域");
|
||||
}
|
||||
if (digits.startsWith("46")) return PriceRegion.EAST;
|
||||
return switch (digits.charAt(0)) {
|
||||
case '0', '1', '2', '3' -> PriceRegion.EAST;
|
||||
case '4', '5', '6', '7' -> PriceRegion.CENTRAL;
|
||||
case '8', '9' -> PriceRegion.WEST;
|
||||
default -> throw new BizException(ResultCode.VALIDATION_FAILED, "目的仓邮编无法确定海派区域");
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.baiyue.firstleg.pricing;
|
||||
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.repository.PriceBookQueryRows.RateRow;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
|
||||
@Component
|
||||
public class RegionWeightTierEstimator {
|
||||
private static final BigDecimal KG_PER_CBM = BigDecimal.valueOf(167);
|
||||
|
||||
public Estimate estimate(List<RateRow> tiers, BigDecimal actualWeightKg, BigDecimal actualVolumeCbm) {
|
||||
BigDecimal weight = nonNegative(actualWeightKg);
|
||||
BigDecimal volume = nonNegative(actualVolumeCbm);
|
||||
if (weight.signum() == 0 && volume.signum() == 0) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED, "重量和体积至少填写一项且必须大于0");
|
||||
}
|
||||
BigDecimal quantity = weight.max(volume.multiply(KG_PER_CBM)).setScale(6, RoundingMode.HALF_UP);
|
||||
RateRow selected = tiers.stream()
|
||||
.filter(rate -> "KG".equals(rate.getChargeUnit()) && rate.getWeightFromKg() != null)
|
||||
.filter(rate -> rate.getWeightFromKg().compareTo(quantity) <= 0)
|
||||
.max(Comparator.comparing(RateRow::getWeightFromKg))
|
||||
.orElseThrow(() -> new BizException(ResultCode.VALIDATION_FAILED,
|
||||
"当前计费重量未达到该海派渠道的最低重量阶梯"));
|
||||
return new Estimate(selected, quantity, selected.getUnitPrice(),
|
||||
quantity.multiply(selected.getUnitPrice()).setScale(2, RoundingMode.HALF_UP), selected.getCurrency());
|
||||
}
|
||||
|
||||
private static BigDecimal nonNegative(BigDecimal value) {
|
||||
if (value == null) return BigDecimal.ZERO;
|
||||
if (value.signum() < 0) throw new BizException(ResultCode.VALIDATION_FAILED, "重量和体积不能小于0");
|
||||
return value;
|
||||
}
|
||||
|
||||
public record Estimate(RateRow rate, BigDecimal quantity, BigDecimal unitPrice, BigDecimal amount,
|
||||
String currency) {}
|
||||
}
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
package com.baiyue.firstleg.pricing.importer;
|
||||
|
||||
import com.baiyue.firstleg.domain.PriceModel;
|
||||
import com.baiyue.firstleg.domain.PricingTaxMode;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
/** Pure, persistence-free result of parsing the platform price workbook. */
|
||||
public record PriceWorkbookParseResult(
|
||||
List<ParsedChannel> channels,
|
||||
List<ParseIssue> issues,
|
||||
List<String> ignoredSheets) {
|
||||
|
||||
public PriceWorkbookParseResult {
|
||||
channels = List.copyOf(channels == null ? List.of() : channels);
|
||||
issues = List.copyOf(issues == null ? List.of() : issues);
|
||||
ignoredSheets = List.copyOf(ignoredSheets == null ? List.of() : ignoredSheets);
|
||||
}
|
||||
|
||||
public boolean hasBlockingErrors() {
|
||||
return issues.stream().anyMatch(issue -> issue.severity() == Severity.ERROR);
|
||||
}
|
||||
|
||||
public long rateCount() {
|
||||
return channels.stream().mapToLong(channel -> channel.rates().size()).sum();
|
||||
}
|
||||
|
||||
public enum Severity { ERROR, WARNING }
|
||||
|
||||
public record ParseIssue(
|
||||
Severity severity,
|
||||
String code,
|
||||
String message,
|
||||
String sheetName,
|
||||
Integer rowNumber,
|
||||
String rawValue) {
|
||||
public ParseIssue {
|
||||
severity = severity == null ? Severity.WARNING : severity;
|
||||
code = code == null ? "UNKNOWN" : code;
|
||||
}
|
||||
}
|
||||
|
||||
public record ParsedSailing(
|
||||
String rawText,
|
||||
String vesselName,
|
||||
String voyageNumber,
|
||||
LocalDate expectedDepartureDate) { }
|
||||
|
||||
public record ParsedChannel(
|
||||
String channelCode,
|
||||
String channelName,
|
||||
String sourceSheet,
|
||||
String sourceLabel,
|
||||
PriceModel priceModel,
|
||||
List<ParsedSailing> sailings,
|
||||
String scheduleText,
|
||||
String cutoffText,
|
||||
String transitTimeText,
|
||||
String surchargeText,
|
||||
String remarkText,
|
||||
List<ParsedRate> rates) {
|
||||
public ParsedChannel {
|
||||
sailings = List.copyOf(sailings == null ? List.of() : sailings);
|
||||
rates = List.copyOf(rates == null ? List.of() : rates);
|
||||
}
|
||||
}
|
||||
|
||||
public record ParsedRate(
|
||||
String sourceSheet,
|
||||
int sourceRow,
|
||||
String originWarehouseKey,
|
||||
String originWarehouseLabel,
|
||||
String rawDestinationGroup,
|
||||
String destinationCode,
|
||||
String regionCode,
|
||||
BigDecimal minimumWeightKg,
|
||||
PricingTaxMode taxMode,
|
||||
String chargeUnit,
|
||||
BigDecimal unitPrice,
|
||||
String transitTimeText,
|
||||
String cutoffText,
|
||||
String scheduleText,
|
||||
String surchargeText,
|
||||
String remarkText,
|
||||
/** Source-level destination classification; mapped to the public contract at the boundary. */
|
||||
String destinationType,
|
||||
String destinationName) { }
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
package com.baiyue.firstleg.pricing.importer;
|
||||
|
||||
import com.baiyue.firstleg.domain.PriceModel;
|
||||
import com.baiyue.firstleg.domain.PricingTaxMode;
|
||||
|
||||
import org.apache.poi.openxml4j.opc.OPCPackage;
|
||||
import org.apache.poi.openxml4j.opc.PackageAccess;
|
||||
import org.apache.poi.xssf.eventusermodel.ReadOnlySharedStringsTable;
|
||||
import org.apache.poi.xssf.eventusermodel.XSSFReader;
|
||||
import org.apache.poi.xssf.eventusermodel.XSSFSheetXMLHandler;
|
||||
import org.apache.poi.xssf.model.StylesTable;
|
||||
import org.apache.poi.xssf.usermodel.XSSFComment;
|
||||
import org.apache.poi.util.XMLHelper;
|
||||
import org.xml.sax.InputSource;
|
||||
import org.xml.sax.XMLReader;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.file.Path;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import static com.baiyue.firstleg.pricing.importer.PriceWorkbookParseResult.ParseIssue;
|
||||
import static com.baiyue.firstleg.pricing.importer.PriceWorkbookParseResult.ParsedChannel;
|
||||
import static com.baiyue.firstleg.pricing.importer.PriceWorkbookParseResult.ParsedRate;
|
||||
import static com.baiyue.firstleg.pricing.importer.PriceWorkbookParseResult.ParsedSailing;
|
||||
import static com.baiyue.firstleg.pricing.importer.PriceWorkbookParseResult.Severity;
|
||||
|
||||
/**
|
||||
* Parses the supported price tabs without consulting Excel Table metadata or UsedRange.
|
||||
* The SAX reader only visits real cells in supported sheets, so malformed Table refs and
|
||||
* inflated dimensions in the workbook do not prevent the price tabs from being read.
|
||||
*/
|
||||
public final class PriceWorkbookParser {
|
||||
private static final int MAX_ROWS = 200;
|
||||
private static final int MAX_COLUMNS = 32;
|
||||
private static final Pattern CODE_PATTERN = Pattern.compile("(?<![A-Z0-9])[A-Z][A-Z0-9]{2,9}(?![A-Z0-9])");
|
||||
private static final Pattern DATE_PATTERN = Pattern.compile("预计开船日期\\s*[::]?\\s*(\\d{4})年(\\d{1,2})月(\\d{1,2})日");
|
||||
private static final Pattern SAILING_PATTERN = Pattern.compile("船名航次\\s*[::]\\s*(.+?)\\s+V\\.([A-Za-z0-9-]+)");
|
||||
private static final Pattern NUMBER_PATTERN = Pattern.compile("[-+]?\\d+(?:[,.]\\d+)?");
|
||||
|
||||
private static final List<StandardTab> STANDARD_TABS = List.of(
|
||||
new StandardTab("美森正班海卡", "MATSON_CLX_TRUCK"),
|
||||
new StandardTab("美森加班海卡", "MATSON_MAX_TRUCK"),
|
||||
new StandardTab("EXX海卡", "EXX_TRUCK"),
|
||||
new StandardTab("合德海卡", "HEDE_TRUCK"),
|
||||
new StandardTab("纽约EXX海铁", "EXX_RAIL_NY"),
|
||||
new StandardTab("洛杉矶普船海卡", "OA_LA_TRUCK"),
|
||||
new StandardTab("洛杉矶普船全卡", "OA_LA_FULL_TRUCK"),
|
||||
new StandardTab("芝加哥普船海铁", "OA_CHICAGO_RAIL"),
|
||||
new StandardTab("休斯顿普船海卡", "OA_HOUSTON_TRUCK"),
|
||||
new StandardTab("纽约普船海卡", "OA_NEW_YORK_TRUCK"),
|
||||
new StandardTab("萨凡纳普船海卡", "OA_SAVANNAH_TRUCK"));
|
||||
|
||||
private static final List<ParcelChannel> PARCEL_CHANNELS = List.of(
|
||||
new ParcelChannel("MATSON_CLX_PARCEL", "美森正班海派"),
|
||||
new ParcelChannel("MATSON_MAX_PARCEL", "美森加班海派"),
|
||||
new ParcelChannel("HEDE_PARCEL", "合德海派"),
|
||||
new ParcelChannel("OA_PARCEL", "普船海派"));
|
||||
|
||||
private static final Set<String> IGNORED_SHEETS = Set.of(
|
||||
"出货注意事项", "UPS派送售后服务指南", "快速查询", "UPS&FedEx偏远地区表",
|
||||
"一件代发收费", "FDA申报清单", "FBA分区表", "亚马逊仓库代码表");
|
||||
|
||||
public PriceWorkbookParseResult parse(Path workbook) throws IOException {
|
||||
if (workbook == null) throw new IllegalArgumentException("workbook must not be null");
|
||||
try (OPCPackage packageRef = OPCPackage.open(workbook.toFile(), PackageAccess.READ)) {
|
||||
return parse(packageRef);
|
||||
} catch (Exception exception) {
|
||||
if (exception instanceof IOException io) throw io;
|
||||
throw new IOException("无法读取 Excel 工作簿", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private PriceWorkbookParseResult parse(OPCPackage packageRef) throws Exception {
|
||||
XSSFReader reader = new XSSFReader(packageRef);
|
||||
StylesTable styles = reader.getStylesTable();
|
||||
ReadOnlySharedStringsTable sharedStrings = new ReadOnlySharedStringsTable(packageRef);
|
||||
Map<String, SheetData> sheets = new LinkedHashMap<>();
|
||||
Set<String> allSheetNames = new LinkedHashSet<>();
|
||||
|
||||
XSSFReader.SheetIterator iterator = (XSSFReader.SheetIterator) reader.getSheetsData();
|
||||
while (iterator.hasNext()) {
|
||||
try (InputStream stream = iterator.next()) {
|
||||
String sheetName = iterator.getSheetName();
|
||||
allSheetNames.add(sheetName);
|
||||
if (!isSupportedSheet(sheetName)) continue;
|
||||
sheets.put(sheetName, readSheet(stream, styles, sharedStrings));
|
||||
}
|
||||
}
|
||||
|
||||
List<ParsedChannel> channels = new ArrayList<>();
|
||||
List<ParseIssue> issues = new ArrayList<>();
|
||||
List<String> ignored = new ArrayList<>();
|
||||
for (String sheetName : allSheetNames) {
|
||||
if (IGNORED_SHEETS.contains(sheetName)) ignored.add(sheetName);
|
||||
}
|
||||
|
||||
for (StandardTab tab : STANDARD_TABS) {
|
||||
SheetData data = sheets.get(tab.sheetName());
|
||||
if (data == null) {
|
||||
issues.add(issue(Severity.WARNING, "CHANNEL_SHEET_MISSING",
|
||||
"标准渠道 Sheet 未找到,本版本不会提供该渠道", tab.sheetName(), null, null));
|
||||
continue;
|
||||
}
|
||||
channels.add(parseStandard(tab, data, issues));
|
||||
}
|
||||
SheetData parcel = sheets.get("海派");
|
||||
if (parcel == null) {
|
||||
issues.add(issue(Severity.WARNING, "CHANNEL_SHEET_MISSING",
|
||||
"海派 Sheet 未找到,本版本不会提供海派渠道", "海派", null, null));
|
||||
} else {
|
||||
channels.addAll(parseParcel(parcel, issues));
|
||||
}
|
||||
|
||||
return new PriceWorkbookParseResult(channels, issues, ignored);
|
||||
}
|
||||
|
||||
private static boolean isSupportedSheet(String name) {
|
||||
return "海派".equals(name) || STANDARD_TABS.stream().anyMatch(tab -> tab.sheetName().equals(name));
|
||||
}
|
||||
|
||||
private static SheetData readSheet(InputStream stream, StylesTable styles,
|
||||
ReadOnlySharedStringsTable sharedStrings) throws Exception {
|
||||
SheetData data = new SheetData();
|
||||
XMLReader parser = XMLHelper.newXMLReader();
|
||||
parser.setContentHandler(new XSSFSheetXMLHandler(styles, null, sharedStrings,
|
||||
new CellHandler(data), new org.apache.poi.ss.usermodel.DataFormatter(Locale.CHINA), false));
|
||||
parser.parse(new InputSource(stream));
|
||||
return data;
|
||||
}
|
||||
|
||||
private static ParsedChannel parseStandard(StandardTab tab, SheetData data, List<ParseIssue> issues) {
|
||||
int headerRow = findRowContaining(data, "FBA仓库");
|
||||
if (headerRow < 0) {
|
||||
issues.add(issue(Severity.ERROR, "STANDARD_HEADER_NOT_FOUND",
|
||||
"未识别标准海卡/海铁表头", tab.sheetName(), null, null));
|
||||
return new ParsedChannel(tab.channelCode(), tab.sheetName(), tab.sheetName(), tab.sheetName(),
|
||||
PriceModel.FBA_WAREHOUSE_RATE, sailings(data.value(2, 0)), null, null, null, null, null, List.of());
|
||||
}
|
||||
int base = findCellColumn(data, headerRow, "FBA仓库");
|
||||
List<ParsedRate> rates = new ArrayList<>();
|
||||
String schedule = null, cutoff = null, transit = null, surcharge = null, remark = null;
|
||||
for (int row = headerRow + 2; row <= MAX_ROWS; row++) {
|
||||
String destination = clean(data.value(row, base));
|
||||
if (destination == null) continue;
|
||||
String nonTax = clean(data.value(row, base + 1));
|
||||
String eastTax = clean(data.value(row, base + 2));
|
||||
String southTax = clean(data.value(row, base + 3));
|
||||
if (!hasPrice(nonTax) && !hasPrice(eastTax) && !hasPrice(southTax)) continue;
|
||||
String rowTransit = clean(data.value(row, base + 4));
|
||||
String rowCutoff = clean(data.value(row, base + 5));
|
||||
String rowSchedule = clean(data.value(row, base + 6));
|
||||
String rowSurcharge = clean(data.value(row, base + 7));
|
||||
String rowRemark = clean(data.value(row, base + 8));
|
||||
transit = firstNonBlank(transit, rowTransit);
|
||||
cutoff = firstNonBlank(cutoff, rowCutoff);
|
||||
schedule = firstNonBlank(schedule, rowSchedule);
|
||||
surcharge = firstNonBlank(surcharge, rowSurcharge);
|
||||
remark = firstNonBlank(remark, rowRemark);
|
||||
|
||||
List<String> codes = extractCodes(destination);
|
||||
boolean overseas = destination.contains("海外仓");
|
||||
if (codes.isEmpty() && !overseas) {
|
||||
issues.add(issue(Severity.ERROR, "DESTINATION_MAPPING_REQUIRED",
|
||||
"目的仓文本未识别为 FBA 仓库代码,需要确认映射或删除该行", tab.sheetName(), row, destination));
|
||||
codes = Collections.singletonList(null);
|
||||
}
|
||||
if (codes.isEmpty()) codes = Collections.singletonList(null);
|
||||
for (String code : codes) {
|
||||
addStandardRate(rates, issues, tab.sheetName(), row, code, destination,
|
||||
"YIWU", "华东仓(义乌)", nonTax, PricingTaxMode.TAX_EXCLUDED, "CBM", rowTransit,
|
||||
rowCutoff, rowSchedule, rowSurcharge, rowRemark);
|
||||
addStandardRate(rates, issues, tab.sheetName(), row, code, destination,
|
||||
"YIWU", "华东仓(义乌)", eastTax, PricingTaxMode.TAX_INCLUDED, "KG", rowTransit,
|
||||
rowCutoff, rowSchedule, rowSurcharge, rowRemark);
|
||||
addStandardRate(rates, issues, tab.sheetName(), row, code, destination,
|
||||
"DONGGUAN", "华南仓(深圳/东莞)", southTax, PricingTaxMode.TAX_INCLUDED, "KG", rowTransit,
|
||||
rowCutoff, rowSchedule, rowSurcharge, rowRemark);
|
||||
}
|
||||
}
|
||||
if (rates.isEmpty()) {
|
||||
issues.add(issue(Severity.ERROR, "NO_STANDARD_PRICES", "标准渠道未解析出价格行", tab.sheetName(), null, null));
|
||||
}
|
||||
List<ParsedSailing> sailings = sailings(data.value(2, 0));
|
||||
if (sailings.isEmpty() && clean(data.value(2, 0)) != null) {
|
||||
issues.add(issue(Severity.WARNING, "SAILING_NOT_RECOGNIZED",
|
||||
"船名航次文本未能结构化识别,保留原始文本", tab.sheetName(), 2, data.value(2, 0)));
|
||||
}
|
||||
return new ParsedChannel(tab.channelCode(), tab.sheetName(), tab.sheetName(), tab.sheetName(),
|
||||
PriceModel.FBA_WAREHOUSE_RATE, sailings, schedule, cutoff, transit, surcharge, remark, rates);
|
||||
}
|
||||
|
||||
private static void addStandardRate(List<ParsedRate> rates, List<ParseIssue> issues,
|
||||
String sheet, int row, String code, String rawDestination,
|
||||
String originKey, String originLabel, String rawPrice,
|
||||
PricingTaxMode taxMode, String unit, String transit, String cutoff,
|
||||
String schedule, String surcharge, String remark) {
|
||||
BigDecimal price = parsePrice(rawPrice);
|
||||
if (price == null) return;
|
||||
String destinationType = rawDestination != null && rawDestination.contains("海外仓")
|
||||
? "OVERSEAS" : code != null ? "FBA" : null;
|
||||
String destinationName = "OVERSEAS".equals(destinationType)
|
||||
? normalizeOverseasName(rawDestination) : code;
|
||||
rates.add(new ParsedRate(sheet, row, originKey, originLabel, rawDestination, code, null, null,
|
||||
taxMode, unit, price, transit, cutoff, schedule, surcharge, remark,
|
||||
destinationType, destinationName));
|
||||
}
|
||||
|
||||
private static String normalizeOverseasName(String raw) {
|
||||
if (raw == null) return null;
|
||||
if (raw.contains("洛杉矶")) return "洛杉矶仓";
|
||||
if (raw.contains("纽约")) return "纽约仓";
|
||||
int open = raw.indexOf('(');
|
||||
int close = raw.indexOf(')', open + 1);
|
||||
if (open >= 0 && close > open) return raw.substring(open + 1, close).trim();
|
||||
return raw.replace("海外仓", "").trim();
|
||||
}
|
||||
|
||||
private static List<ParsedChannel> parseParcel(SheetData data, List<ParseIssue> issues) {
|
||||
Map<String, ChannelBuilder> builders = new LinkedHashMap<>();
|
||||
PARCEL_CHANNELS.forEach(channel -> builders.put(channel.code(),
|
||||
new ChannelBuilder(channel.code(), channel.name())));
|
||||
parseParcelOrigin(data, 5, 8, 9, "YIWU", "华东仓(义乌)", builders, issues);
|
||||
parseParcelOrigin(data, 14, 17, 18, "DONGGUAN", "华南仓(深圳/东莞)", builders, issues);
|
||||
List<ParsedChannel> result = new ArrayList<>();
|
||||
for (ChannelBuilder builder : builders.values()) {
|
||||
if (builder.rates.isEmpty()) {
|
||||
issues.add(issue(Severity.ERROR, "NO_PARCEL_PRICES", "海派渠道未解析出价格阶梯", "海派", null, builder.name));
|
||||
}
|
||||
result.add(builder.toChannel());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void parseParcelOrigin(SheetData data, int scheduleRow, int tierRow,
|
||||
int firstRegionRow, String originKey, String originLabel,
|
||||
Map<String, ChannelBuilder> builders, List<ParseIssue> issues) {
|
||||
int[] starts = {2, 5, 8, 11};
|
||||
for (int channel = 0; channel < starts.length; channel++) {
|
||||
int start = starts[channel];
|
||||
ChannelBuilder builder = builders.get(PARCEL_CHANNELS.get(channel).code());
|
||||
builder.scheduleText = firstNonBlank(builder.scheduleText, data.value(scheduleRow, start));
|
||||
builder.transitText = firstNonBlank(builder.transitText, data.value(scheduleRow + 1, start));
|
||||
builder.surchargeText = firstNonBlank(builder.surchargeText, data.value(scheduleRow, 14));
|
||||
builder.remarkText = firstNonBlank(builder.remarkText, data.value(scheduleRow, 15));
|
||||
int[] thresholds = {12, 51, 101};
|
||||
for (int row = firstRegionRow; row < firstRegionRow + 3; row++) {
|
||||
String rawRegion = clean(data.value(row, 0));
|
||||
String region = regionCode(rawRegion);
|
||||
if (region == null) {
|
||||
issues.add(issue(Severity.ERROR, "REGION_NOT_RECOGNIZED",
|
||||
"海派区域未识别", "海派", row, rawRegion));
|
||||
continue;
|
||||
}
|
||||
for (int tier = 0; tier < thresholds.length; tier++) {
|
||||
BigDecimal price = parsePrice(data.value(row, start + tier));
|
||||
if (price == null) {
|
||||
issues.add(issue(Severity.ERROR, "PARCEL_PRICE_MISSING",
|
||||
"海派重量阶梯价格缺失", "海派", row, data.value(row, start + tier)));
|
||||
continue;
|
||||
}
|
||||
builder.rates.add(new ParsedRate("海派", row, originKey, originLabel, rawRegion, null,
|
||||
region, BigDecimal.valueOf(thresholds[tier]), PricingTaxMode.TAX_INCLUDED, "KG", price,
|
||||
builder.transitText, null, builder.scheduleText, builder.surchargeText, builder.remarkText,
|
||||
"REGION_WEIGHT_TIER", rawRegion));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static List<String> extractCodes(String value) {
|
||||
if (value == null) return List.of();
|
||||
Matcher matcher = CODE_PATTERN.matcher(value.toUpperCase(Locale.ROOT));
|
||||
Set<String> codes = new LinkedHashSet<>();
|
||||
while (matcher.find()) codes.add(matcher.group());
|
||||
return List.copyOf(codes);
|
||||
}
|
||||
|
||||
private static String regionCode(String raw) {
|
||||
if (raw == null) return null;
|
||||
if (raw.contains("美西")) return "WEST";
|
||||
if (raw.contains("美中")) return "CENTRAL";
|
||||
if (raw.contains("美东")) return "EAST";
|
||||
return null;
|
||||
}
|
||||
|
||||
private static List<ParsedSailing> sailings(String raw) {
|
||||
if (raw == null || raw.isBlank()) return List.of();
|
||||
LocalDate date = expectedDate(raw);
|
||||
List<ParsedSailing> result = new ArrayList<>();
|
||||
Matcher matcher = SAILING_PATTERN.matcher(raw);
|
||||
while (matcher.find()) result.add(new ParsedSailing(matcher.group(), matcher.group(1).trim(), matcher.group(2).trim(), date));
|
||||
return List.copyOf(result);
|
||||
}
|
||||
|
||||
private static LocalDate expectedDate(String raw) {
|
||||
Matcher matcher = DATE_PATTERN.matcher(raw == null ? "" : raw);
|
||||
if (!matcher.find()) return null;
|
||||
try {
|
||||
return LocalDate.of(Integer.parseInt(matcher.group(1)), Integer.parseInt(matcher.group(2)), Integer.parseInt(matcher.group(3)));
|
||||
} catch (RuntimeException ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static BigDecimal parsePrice(String raw) {
|
||||
if (raw == null || raw.isBlank() || "/".equals(raw.trim()) || "-".equals(raw.trim())) return null;
|
||||
Matcher matcher = NUMBER_PATTERN.matcher(raw.replace(",", ","));
|
||||
if (!matcher.find()) return null;
|
||||
try {
|
||||
return new BigDecimal(matcher.group().replace(",", ""));
|
||||
} catch (NumberFormatException ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean hasPrice(String value) { return parsePrice(value) != null; }
|
||||
|
||||
private static String clean(String value) {
|
||||
if (value == null) return null;
|
||||
String result = value.replace('\u00a0', ' ').trim();
|
||||
return result.isBlank() ? null : result;
|
||||
}
|
||||
|
||||
private static String firstNonBlank(String first, String second) { return clean(first) == null ? clean(second) : first; }
|
||||
|
||||
private static int findRowContaining(SheetData data, String text) {
|
||||
for (int row = 1; row <= MAX_ROWS; row++) {
|
||||
for (int col = 0; col < MAX_COLUMNS; col++) {
|
||||
String value = clean(data.value(row, col));
|
||||
if (value != null && value.contains(text)) return row;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static int findCellColumn(SheetData data, int row, String text) {
|
||||
for (int col = 0; col < MAX_COLUMNS; col++) {
|
||||
String value = clean(data.value(row, col));
|
||||
if (value != null && value.contains(text)) return col;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static ParseIssue issue(Severity severity, String code, String message,
|
||||
String sheet, Integer row, String raw) {
|
||||
return new ParseIssue(severity, code, message, sheet, row, raw);
|
||||
}
|
||||
|
||||
private record StandardTab(String sheetName, String channelCode) { }
|
||||
|
||||
private record ParcelChannel(String code, String name) { }
|
||||
|
||||
private static final class ChannelBuilder {
|
||||
private final String code;
|
||||
private final String name;
|
||||
private final List<ParsedRate> rates = new ArrayList<>();
|
||||
private String scheduleText;
|
||||
private String transitText;
|
||||
private String surchargeText;
|
||||
private String remarkText;
|
||||
|
||||
private ChannelBuilder(String code, String name) { this.code = code; this.name = name; }
|
||||
|
||||
private ParsedChannel toChannel() {
|
||||
return new ParsedChannel(code, name, "海派", name, PriceModel.REGION_WEIGHT_TIER,
|
||||
List.of(), scheduleText, null, transitText, surchargeText, remarkText, rates);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class SheetData {
|
||||
private final Map<Integer, Map<Integer, String>> cells = new LinkedHashMap<>();
|
||||
|
||||
private String value(int row, int column) {
|
||||
Map<Integer, String> values = cells.get(row);
|
||||
return values == null ? null : values.get(column);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class CellHandler implements XSSFSheetXMLHandler.SheetContentsHandler {
|
||||
private final SheetData data;
|
||||
private int row;
|
||||
|
||||
private CellHandler(SheetData data) { this.data = data; }
|
||||
|
||||
@Override public void startRow(int rowNum) { row = rowNum + 1; }
|
||||
|
||||
@Override public void endRow(int rowNum) { }
|
||||
|
||||
@Override public void cell(String cellReference, String formattedValue, XSSFComment comment) {
|
||||
int[] coordinates = coordinates(cellReference);
|
||||
if (coordinates[0] <= MAX_ROWS && coordinates[1] < MAX_COLUMNS) {
|
||||
data.cells.computeIfAbsent(coordinates[0], ignored -> new LinkedHashMap<>())
|
||||
.put(coordinates[1], formattedValue);
|
||||
}
|
||||
}
|
||||
|
||||
private static int[] coordinates(String reference) {
|
||||
int split = 0;
|
||||
while (split < reference.length() && Character.isLetter(reference.charAt(split))) split++;
|
||||
int column = 0;
|
||||
for (int i = 0; i < split; i++) column = column * 26 + (Character.toUpperCase(reference.charAt(i)) - 'A' + 1);
|
||||
return new int[] { Integer.parseInt(reference.substring(split)), column - 1 };
|
||||
}
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package com.baiyue.firstleg.repository;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baiyue.firstleg.entity.CustomerDimensionConfigEntity;
|
||||
import com.baiyue.firstleg.mapper.CustomerDimensionConfigMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
@Repository
|
||||
public class CustomerDimensionConfigRepository {
|
||||
@Resource
|
||||
private CustomerDimensionConfigMapper mapper;
|
||||
|
||||
public CustomerDimensionConfigEntity find(long tenantId) {
|
||||
return mapper.selectOne(new LambdaQueryWrapper<CustomerDimensionConfigEntity>()
|
||||
.eq(CustomerDimensionConfigEntity::getTenantId, tenantId));
|
||||
}
|
||||
|
||||
public boolean insertDefault(long id, long tenantId, BigDecimal edgeAdditionCm,
|
||||
String remark, String initializationKey, long operatorId) {
|
||||
return mapper.insertDefault(id, tenantId, edgeAdditionCm, remark, initializationKey, operatorId) == 1;
|
||||
}
|
||||
|
||||
public int update(CustomerDimensionConfigEntity value) {
|
||||
return mapper.updateById(value);
|
||||
}
|
||||
|
||||
public int deleteUnchangedDefault(long tenantId, long id, String initializationKey,
|
||||
BigDecimal edgeAdditionCm) {
|
||||
return mapper.deleteUnchangedDefault(tenantId, id, initializationKey, edgeAdditionCm);
|
||||
}
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
package com.baiyue.firstleg.repository;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import com.baiyue.firstleg.entity.InboundCartonLabelEntity;
|
||||
import com.baiyue.firstleg.entity.InboundOrderEntity;
|
||||
import com.baiyue.firstleg.mapper.InboundCartonLabelMapper;
|
||||
import com.baiyue.firstleg.mapper.InboundOrderMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Repository
|
||||
public class InboundCartonLabelRepository {
|
||||
@Resource private InboundOrderMapper inbounds;
|
||||
@Resource private InboundCartonLabelMapper labels;
|
||||
|
||||
public InboundOrderEntity lockInbound(long tenantId, long inboundId) {
|
||||
return inbounds.selectOne(new LambdaQueryWrapper<InboundOrderEntity>()
|
||||
.eq(InboundOrderEntity::getTenantId, tenantId)
|
||||
.eq(InboundOrderEntity::getId, inboundId)
|
||||
.eq(InboundOrderEntity::getDeleted, false)
|
||||
.last("FOR UPDATE"));
|
||||
}
|
||||
|
||||
public List<InboundCartonLabelEntity> findByInbound(long tenantId, long inboundId) {
|
||||
return labels.selectList(new LambdaQueryWrapper<InboundCartonLabelEntity>()
|
||||
.eq(InboundCartonLabelEntity::getTenantId, tenantId)
|
||||
.eq(InboundCartonLabelEntity::getInboundId, inboundId)
|
||||
.orderByAsc(InboundCartonLabelEntity::getCartonIndex));
|
||||
}
|
||||
|
||||
public InboundCartonLabelEntity findByBoxCode(long tenantId, String boxCode) {
|
||||
return labels.selectOne(new LambdaQueryWrapper<InboundCartonLabelEntity>()
|
||||
.eq(InboundCartonLabelEntity::getTenantId, tenantId)
|
||||
.eq(InboundCartonLabelEntity::getBoxCode, boxCode));
|
||||
}
|
||||
|
||||
public boolean initializeOriginalPackageCount(long tenantId, long inboundId, int packageCount) {
|
||||
return inbounds.update(null, new LambdaUpdateWrapper<InboundOrderEntity>()
|
||||
.eq(InboundOrderEntity::getTenantId, tenantId)
|
||||
.eq(InboundOrderEntity::getId, inboundId)
|
||||
.isNull(InboundOrderEntity::getTotalPackageCount)
|
||||
.set(InboundOrderEntity::getTotalPackageCount, packageCount)
|
||||
.setSql("version = version + 1")) == 1;
|
||||
}
|
||||
|
||||
public void insert(InboundCartonLabelEntity label) {
|
||||
labels.insert(label);
|
||||
}
|
||||
}
|
||||
+38
@@ -42,6 +42,13 @@ public class InboundMeasurementRepository {
|
||||
.eq(InboundOrderEntity::getId, inboundId));
|
||||
}
|
||||
|
||||
public InboundOrderEntity lockInbound(long tenantId, long inboundId) {
|
||||
return inboundOrders.selectOne(new LambdaQueryWrapper<InboundOrderEntity>()
|
||||
.eq(InboundOrderEntity::getTenantId, tenantId)
|
||||
.eq(InboundOrderEntity::getId, inboundId)
|
||||
.last("FOR UPDATE"));
|
||||
}
|
||||
|
||||
public boolean updateInbound(InboundOrderEntity inbound) {
|
||||
return inboundOrders.update(inbound, new LambdaUpdateWrapper<InboundOrderEntity>()
|
||||
.eq(InboundOrderEntity::getTenantId, inbound.getTenantId())
|
||||
@@ -70,6 +77,37 @@ public class InboundMeasurementRepository {
|
||||
.orderByAsc(InboundMeasurementItemEntity::getItemNo));
|
||||
}
|
||||
|
||||
public InboundMeasurementItemEntity findItem(long tenantId, long itemId) {
|
||||
return items.selectOne(new LambdaQueryWrapper<InboundMeasurementItemEntity>()
|
||||
.eq(InboundMeasurementItemEntity::getTenantId, tenantId)
|
||||
.eq(InboundMeasurementItemEntity::getId, itemId));
|
||||
}
|
||||
|
||||
public InboundMeasurementItemEntity findItemByBoxCode(long tenantId, String boxCode) {
|
||||
return items.selectOne(new LambdaQueryWrapper<InboundMeasurementItemEntity>()
|
||||
.eq(InboundMeasurementItemEntity::getTenantId, tenantId)
|
||||
.eq(InboundMeasurementItemEntity::getBoxCode, boxCode));
|
||||
}
|
||||
|
||||
public void insertItem(InboundMeasurementItemEntity item) {
|
||||
items.insert(item);
|
||||
}
|
||||
|
||||
public boolean updateItem(InboundMeasurementItemEntity item) {
|
||||
return items.update(item, new LambdaUpdateWrapper<InboundMeasurementItemEntity>()
|
||||
.eq(InboundMeasurementItemEntity::getTenantId, item.getTenantId())
|
||||
.eq(InboundMeasurementItemEntity::getId, item.getId())) == 1;
|
||||
}
|
||||
|
||||
public InboundMeasurementEntity findLatestEditableMeasurement(long tenantId, long inboundId) {
|
||||
return measurements.selectOne(new LambdaQueryWrapper<InboundMeasurementEntity>()
|
||||
.eq(InboundMeasurementEntity::getTenantId, tenantId)
|
||||
.eq(InboundMeasurementEntity::getInboundId, inboundId)
|
||||
.in(InboundMeasurementEntity::getStatus, "DRAFT", "RECEIVED", "REJECTED")
|
||||
.orderByDesc(InboundMeasurementEntity::getCreatedAt)
|
||||
.last("LIMIT 1"));
|
||||
}
|
||||
|
||||
public void insertMeasurement(InboundMeasurementEntity measurement) {
|
||||
measurements.insert(measurement);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.firstleg.entity.InboundFeeEntity;
|
||||
import com.baiyue.firstleg.entity.InboundIdempotencyEntity;
|
||||
import com.baiyue.firstleg.entity.InboundMeasurementEntity;
|
||||
import com.baiyue.firstleg.entity.InboundMeasurementItemEntity;
|
||||
import com.baiyue.firstleg.entity.InboundOrderEntity;
|
||||
import com.baiyue.firstleg.entity.InboundOutboxEntity;
|
||||
import com.baiyue.firstleg.entity.InboundStatusHistoryEntity;
|
||||
@@ -16,6 +17,7 @@ import com.baiyue.firstleg.mapper.InboundAllocationQueryMapper;
|
||||
import com.baiyue.firstleg.mapper.InboundFeeMapper;
|
||||
import com.baiyue.firstleg.mapper.InboundIdempotencyMapper;
|
||||
import com.baiyue.firstleg.mapper.InboundMeasurementMapper;
|
||||
import com.baiyue.firstleg.mapper.InboundMeasurementItemMapper;
|
||||
import com.baiyue.firstleg.mapper.InboundOrderMapper;
|
||||
import com.baiyue.firstleg.mapper.InboundOutboxMapper;
|
||||
import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper;
|
||||
@@ -30,6 +32,8 @@ public class InboundWorkflowRepository {
|
||||
@Resource
|
||||
private InboundMeasurementMapper measurements;
|
||||
@Resource
|
||||
private InboundMeasurementItemMapper measurementItems;
|
||||
@Resource
|
||||
private MeasureReviewRecordMapper reviews;
|
||||
@Resource
|
||||
private InboundStatusHistoryMapper histories;
|
||||
@@ -55,6 +59,13 @@ public class InboundWorkflowRepository {
|
||||
.eq(InboundMeasurementEntity::getId, measurementId));
|
||||
}
|
||||
|
||||
public java.util.List<InboundMeasurementItemEntity> listMeasurementItems(
|
||||
long tenantId, long measurementId) {
|
||||
return measurementItems.selectList(new LambdaQueryWrapper<InboundMeasurementItemEntity>()
|
||||
.eq(InboundMeasurementItemEntity::getTenantId, tenantId)
|
||||
.eq(InboundMeasurementItemEntity::getMeasurementId, measurementId));
|
||||
}
|
||||
|
||||
public int transitionReview(long tenantId, long inboundId, long operatorId, int expectedVersion,
|
||||
boolean approved) {
|
||||
return approved
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.baiyue.firstleg.repository;
|
||||
|
||||
import com.baiyue.firstleg.mapper.PriceBookQueryMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Repository
|
||||
public class PriceBookQueryRepository {
|
||||
@Resource
|
||||
private PriceBookQueryMapper mapper;
|
||||
|
||||
public Long activeVersionId() {
|
||||
return mapper.findActiveVersionId();
|
||||
}
|
||||
|
||||
public List<PriceBookQueryRows.ChannelRow> channels(long versionId, long originWarehouseId) {
|
||||
return mapper.findChannels(versionId, originWarehouseId);
|
||||
}
|
||||
|
||||
public List<PriceBookQueryRows.ChannelTaxModeRow> channelTaxModes(long versionId, long originWarehouseId) {
|
||||
return mapper.findChannelTaxModes(versionId, originWarehouseId);
|
||||
}
|
||||
|
||||
public List<PriceBookQueryRows.RateRow> fbaRates(long versionId, long originWarehouseId,
|
||||
String channelCode, String destinationCode,
|
||||
String pricingTaxMode) {
|
||||
return mapper.findFbaRates(versionId, originWarehouseId, channelCode, destinationCode, pricingTaxMode);
|
||||
}
|
||||
|
||||
public List<PriceBookQueryRows.RateRow> overseasRates(long versionId, long originWarehouseId,
|
||||
String channelCode, long destinationWarehouseId,
|
||||
String pricingTaxMode) {
|
||||
return mapper.findOverseasRates(versionId, originWarehouseId, channelCode, destinationWarehouseId,
|
||||
pricingTaxMode);
|
||||
}
|
||||
|
||||
public List<PriceBookQueryRows.RateRow> regionTiers(long versionId, long originWarehouseId,
|
||||
String channelCode, String regionCode,
|
||||
String pricingTaxMode) {
|
||||
return mapper.findRegionTiers(versionId, originWarehouseId, channelCode, regionCode, pricingTaxMode);
|
||||
}
|
||||
|
||||
public boolean channelAvailable(long versionId, long originWarehouseId, String channelCode) {
|
||||
return channels(versionId, originWarehouseId).stream()
|
||||
.anyMatch(channel -> channelCode.equals(channel.getChannelCode()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.baiyue.firstleg.repository;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
|
||||
/** Read-only projections for the active price book. */
|
||||
public final class PriceBookQueryRows {
|
||||
private PriceBookQueryRows() {}
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
public static class ChannelRow {
|
||||
private Long priceBookVersionId;
|
||||
private String channelCode;
|
||||
private String channelName;
|
||||
private String priceModel;
|
||||
private LocalDate estimatedDepartureDate;
|
||||
private String estimatedDepartureText;
|
||||
private String latestWarehouseEntry;
|
||||
private String referenceTransit;
|
||||
private String surchargeDescription;
|
||||
private String remark;
|
||||
}
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
public static class ChannelTaxModeRow {
|
||||
private String channelCode;
|
||||
private String pricingTaxMode;
|
||||
}
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
public static class RateRow {
|
||||
private Long id;
|
||||
private Long priceBookVersionId;
|
||||
private String rateCode;
|
||||
private Integer rateVersion;
|
||||
private Long originWarehouseId;
|
||||
private String originWarehouseName;
|
||||
private String channelCode;
|
||||
private String priceModel;
|
||||
private String destinationType;
|
||||
private String destinationCode;
|
||||
private Long destinationWarehouseId;
|
||||
private String destinationName;
|
||||
private String regionCode;
|
||||
private String pricingTaxMode;
|
||||
private String chargeUnit;
|
||||
private BigDecimal weightFromKg;
|
||||
private BigDecimal unitPrice;
|
||||
private String currency;
|
||||
private BigDecimal additionalFee;
|
||||
private java.time.LocalDateTime effectiveFrom;
|
||||
private java.time.LocalDateTime effectiveTo;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package com.baiyue.firstleg.repository;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import com.baiyue.firstleg.entity.CarrierFbaRateEntity;
|
||||
import com.baiyue.firstleg.entity.PriceBookChannelEntity;
|
||||
import com.baiyue.firstleg.entity.PriceBookVersionEntity;
|
||||
import com.baiyue.firstleg.entity.PriceChannelCatalogEntity;
|
||||
import com.baiyue.firstleg.entity.PriceOriginAliasEntity;
|
||||
import com.baiyue.firstleg.entity.PriceWarehouseMappingEntity;
|
||||
import com.baiyue.firstleg.mapper.CarrierFbaRateMapper;
|
||||
import com.baiyue.firstleg.mapper.PriceBookChannelMapper;
|
||||
import com.baiyue.firstleg.mapper.PriceBookVersionMapper;
|
||||
import com.baiyue.firstleg.mapper.PriceChannelCatalogMapper;
|
||||
import com.baiyue.firstleg.mapper.PriceOriginAliasMapper;
|
||||
import com.baiyue.firstleg.mapper.PriceWarehouseMappingMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
@Repository
|
||||
public class PriceBookRepository {
|
||||
@Resource private PriceBookVersionMapper versions;
|
||||
@Resource private PriceBookChannelMapper channels;
|
||||
@Resource private CarrierFbaRateMapper rates;
|
||||
@Resource private PriceChannelCatalogMapper catalog;
|
||||
@Resource private PriceOriginAliasMapper aliases;
|
||||
@Resource private PriceWarehouseMappingMapper warehouseMappings;
|
||||
|
||||
public int insertVersion(PriceBookVersionEntity value) { return versions.insert(value); }
|
||||
public int updateVersion(PriceBookVersionEntity value) {
|
||||
return versions.update(value, new LambdaUpdateWrapper<PriceBookVersionEntity>()
|
||||
.eq(PriceBookVersionEntity::getTenantId, PriceBookVersionEntity.PLATFORM_SCOPE)
|
||||
.eq(PriceBookVersionEntity::getId, value.getId()));
|
||||
}
|
||||
public PriceBookVersionEntity findVersion(long id) {
|
||||
return versions.selectOne(new LambdaQueryWrapper<PriceBookVersionEntity>()
|
||||
.eq(PriceBookVersionEntity::getTenantId, PriceBookVersionEntity.PLATFORM_SCOPE)
|
||||
.eq(PriceBookVersionEntity::getId, id)
|
||||
.eq(PriceBookVersionEntity::getDeleted, false));
|
||||
}
|
||||
public List<PriceBookVersionEntity> listVersions(String status) {
|
||||
return versions.selectList(new LambdaQueryWrapper<PriceBookVersionEntity>()
|
||||
.eq(PriceBookVersionEntity::getTenantId, PriceBookVersionEntity.PLATFORM_SCOPE)
|
||||
.eq(status != null && !status.isBlank(), PriceBookVersionEntity::getStatus, status)
|
||||
.eq(PriceBookVersionEntity::getDeleted, false)
|
||||
.orderByDesc(PriceBookVersionEntity::getCreatedAt));
|
||||
}
|
||||
public PriceBookVersionEntity findActiveForUpdate() {
|
||||
return versions.selectOne(new LambdaQueryWrapper<PriceBookVersionEntity>()
|
||||
.eq(PriceBookVersionEntity::getTenantId, PriceBookVersionEntity.PLATFORM_SCOPE)
|
||||
.eq(PriceBookVersionEntity::getStatus, "ACTIVE")
|
||||
.eq(PriceBookVersionEntity::getDeleted, false)
|
||||
.last("FOR UPDATE"));
|
||||
}
|
||||
public List<PriceBookVersionEntity> listActiveForUpdate() {
|
||||
return versions.selectList(new LambdaQueryWrapper<PriceBookVersionEntity>()
|
||||
.eq(PriceBookVersionEntity::getTenantId, PriceBookVersionEntity.PLATFORM_SCOPE)
|
||||
.eq(PriceBookVersionEntity::getStatus, "ACTIVE")
|
||||
.eq(PriceBookVersionEntity::getDeleted, false)
|
||||
.last("FOR UPDATE"));
|
||||
}
|
||||
public PriceBookVersionEntity findActive() {
|
||||
return versions.selectOne(new LambdaQueryWrapper<PriceBookVersionEntity>()
|
||||
.eq(PriceBookVersionEntity::getTenantId, PriceBookVersionEntity.PLATFORM_SCOPE)
|
||||
.eq(PriceBookVersionEntity::getStatus, "ACTIVE")
|
||||
.eq(PriceBookVersionEntity::getDeleted, false));
|
||||
}
|
||||
/**
|
||||
* Serializes publish operations for the platform scope. Locking one pending
|
||||
* upload alone is insufficient when two different uploads are confirmed while no
|
||||
* ACTIVE version exists (both transactions could otherwise pass the check).
|
||||
*/
|
||||
public void lockPlatformVersions() {
|
||||
versions.selectList(new LambdaQueryWrapper<PriceBookVersionEntity>()
|
||||
.eq(PriceBookVersionEntity::getTenantId, PriceBookVersionEntity.PLATFORM_SCOPE)
|
||||
.eq(PriceBookVersionEntity::getDeleted, false)
|
||||
.last("FOR UPDATE"));
|
||||
}
|
||||
public int nextVersionNo() {
|
||||
PriceBookVersionEntity latest = versions.selectOne(new LambdaQueryWrapper<PriceBookVersionEntity>()
|
||||
.eq(PriceBookVersionEntity::getTenantId, PriceBookVersionEntity.PLATFORM_SCOPE)
|
||||
.orderByDesc(PriceBookVersionEntity::getVersionNo).last("LIMIT 1 FOR UPDATE"));
|
||||
return latest == null ? 1 : latest.getVersionNo() + 1;
|
||||
}
|
||||
public void insertChannel(PriceBookChannelEntity value) { channels.insert(value); }
|
||||
public void insertRate(CarrierFbaRateEntity value) { rates.insert(value); }
|
||||
public List<PriceBookChannelEntity> listChannels(long versionId) {
|
||||
return channels.selectList(new LambdaQueryWrapper<PriceBookChannelEntity>()
|
||||
.eq(PriceBookChannelEntity::getPriceBookVersionId, versionId)
|
||||
.orderByAsc(PriceBookChannelEntity::getChannelCode));
|
||||
}
|
||||
public List<CarrierFbaRateEntity> listRates(long versionId) {
|
||||
return rates.selectList(new LambdaQueryWrapper<CarrierFbaRateEntity>()
|
||||
.eq(CarrierFbaRateEntity::getPriceBookVersionId, versionId)
|
||||
.orderByAsc(CarrierFbaRateEntity::getChannelCode)
|
||||
.orderByAsc(CarrierFbaRateEntity::getOriginWarehouseCode)
|
||||
.orderByAsc(CarrierFbaRateEntity::getDestinationCode)
|
||||
.orderByAsc(CarrierFbaRateEntity::getWeightFromKg));
|
||||
}
|
||||
public void deletePendingChildren(long versionId) {
|
||||
rates.physicallyDeletePendingByPriceBookVersionId(versionId);
|
||||
channels.physicallyDeleteByPriceBookVersionId(versionId);
|
||||
}
|
||||
public List<PriceChannelCatalogEntity> activeCatalog() {
|
||||
return catalog.selectList(new LambdaQueryWrapper<PriceChannelCatalogEntity>()
|
||||
.eq(PriceChannelCatalogEntity::getStatus, "ACTIVE").orderByAsc(PriceChannelCatalogEntity::getId));
|
||||
}
|
||||
public List<PriceOriginAliasEntity> activeAliases() {
|
||||
return aliases.selectList(new LambdaQueryWrapper<PriceOriginAliasEntity>()
|
||||
.eq(PriceOriginAliasEntity::getStatus, "ACTIVE"));
|
||||
}
|
||||
public List<PriceWarehouseMappingEntity> activeWarehouseMappings() {
|
||||
return warehouseMappings.selectList(new LambdaQueryWrapper<PriceWarehouseMappingEntity>()
|
||||
.eq(PriceWarehouseMappingEntity::getStatus, "ACTIVE")
|
||||
.orderByAsc(PriceWarehouseMappingEntity::getId));
|
||||
}
|
||||
public void supersede(PriceBookVersionEntity old, LocalDateTime at, long operatorId) {
|
||||
old.setStatus("SUPERSEDED"); old.setSupersededAt(at); old.setUpdatedBy(operatorId); old.setUpdatedAt(at);
|
||||
updateVersion(old);
|
||||
rates.update(null, new LambdaUpdateWrapper<CarrierFbaRateEntity>()
|
||||
.eq(CarrierFbaRateEntity::getPriceBookVersionId, old.getId())
|
||||
.eq(CarrierFbaRateEntity::getStatus, "ACTIVE")
|
||||
.set(CarrierFbaRateEntity::getStatus, "SUPERSEDED")
|
||||
.set(CarrierFbaRateEntity::getEffectiveTo, at)
|
||||
.set(CarrierFbaRateEntity::getUpdatedBy, operatorId)
|
||||
.set(CarrierFbaRateEntity::getUpdatedAt, at));
|
||||
}
|
||||
public void activate(PriceBookVersionEntity value, LocalDateTime at, long operatorId) {
|
||||
value.setStatus("ACTIVE"); value.setPublishedAt(at); value.setPublishedBy(operatorId);
|
||||
value.setUpdatedBy(operatorId); value.setUpdatedAt(at); updateVersion(value);
|
||||
rates.update(null, new LambdaUpdateWrapper<CarrierFbaRateEntity>()
|
||||
.eq(CarrierFbaRateEntity::getPriceBookVersionId, value.getId())
|
||||
.set(CarrierFbaRateEntity::getStatus, "ACTIVE")
|
||||
.set(CarrierFbaRateEntity::getEffectiveFrom, at)
|
||||
.set(CarrierFbaRateEntity::getEffectiveTo, null)
|
||||
.set(CarrierFbaRateEntity::getUpdatedBy, operatorId)
|
||||
.set(CarrierFbaRateEntity::getUpdatedAt, at));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.baiyue.firstleg.service;
|
||||
|
||||
/**
|
||||
* 箱号映射查询端口。标签模块负责提供实现,测量模块不解析或推导箱号业务归属。
|
||||
*/
|
||||
public interface CartonLabelBindingResolver {
|
||||
Binding resolveByBoxCode(long tenantId, String boxCode);
|
||||
|
||||
record Binding(long inboundId, long inboundDetailId, String inboundCode,
|
||||
int cartonIndex, int totalCartons, String boxSpecification) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
package com.baiyue.firstleg.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.client.MeasurementImageClient;
|
||||
import com.baiyue.firstleg.dto.CartonMeasurementCommand;
|
||||
import com.baiyue.firstleg.dto.CartonMeasurementResult;
|
||||
import com.baiyue.firstleg.entity.InboundMeasurementEntity;
|
||||
import com.baiyue.firstleg.entity.InboundMeasurementItemEntity;
|
||||
import com.baiyue.firstleg.entity.InboundOrderEntity;
|
||||
import com.baiyue.firstleg.repository.InboundMeasurementRepository;
|
||||
import feign.FeignException;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/** 单箱当前测量新增/覆盖与人工确认箱数编排。 */
|
||||
@Service
|
||||
public class CartonMeasurementService {
|
||||
private static final Logger log = LoggerFactory.getLogger(CartonMeasurementService.class);
|
||||
private static final Set<String> DEVICE_WRITABLE_STATUSES = Set.of("WAIT_MEASURE", "MEASURE_REJECTED");
|
||||
private static final Set<String> MANUAL_COUNT_EDITABLE_STATUSES = Set.of(
|
||||
"WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED");
|
||||
private static final BigDecimal CUBIC_CENTIMETERS_PER_CBM = new BigDecimal("1000000");
|
||||
private static final BigDecimal VOLUME_WEIGHT_FACTOR = new BigDecimal("167");
|
||||
private static final String CALCULATION_RULE_VERSION = "V1_CBM_CEILING_4_GIRTH_L2WH";
|
||||
private static final long MAX_IMAGE_BYTES = 5L * 1024 * 1024;
|
||||
|
||||
private final InboundMeasurementRepository repository;
|
||||
private final ObjectProvider<CartonLabelBindingResolver> resolverProvider;
|
||||
private final MeasurementImageClient imageClient;
|
||||
private final TransactionTemplate transactionTemplate;
|
||||
|
||||
@Value("${baiyue.security.internal-service-token}")
|
||||
private String internalToken;
|
||||
|
||||
public CartonMeasurementService(InboundMeasurementRepository repository,
|
||||
ObjectProvider<CartonLabelBindingResolver> resolverProvider,
|
||||
MeasurementImageClient imageClient,
|
||||
TransactionTemplate transactionTemplate) {
|
||||
this.repository = repository;
|
||||
this.resolverProvider = resolverProvider;
|
||||
this.imageClient = imageClient;
|
||||
this.transactionTemplate = transactionTemplate;
|
||||
}
|
||||
|
||||
public CartonMeasurementResult upsert(long tenantId, long operatorId, CartonMeasurementCommand command) {
|
||||
return upsert(tenantId, operatorId, null, command);
|
||||
}
|
||||
|
||||
public CartonMeasurementResult upsertForInbound(long tenantId, long operatorId, long inboundId,
|
||||
CartonMeasurementCommand command) {
|
||||
if (inboundId <= 0) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "入库单ID必须大于0");
|
||||
}
|
||||
return upsert(tenantId, operatorId, inboundId, command);
|
||||
}
|
||||
|
||||
private CartonMeasurementResult upsert(long tenantId, long operatorId, Long expectedInboundId,
|
||||
CartonMeasurementCommand command) {
|
||||
validateCommand(command);
|
||||
String boxCode = command.boxCode().trim();
|
||||
CartonLabelBindingResolver.Binding binding = requireBinding(tenantId, boxCode);
|
||||
if (expectedInboundId != null && binding.inboundId() != expectedInboundId) {
|
||||
throw new BizException(ResultCode.NOT_FOUND, "箱号不属于当前入库单");
|
||||
}
|
||||
InboundOrderEntity inbound = requireDeviceWritableInbound(tenantId, binding.inboundId());
|
||||
MeasurementImageClient.UploadResult uploaded = uploadImage(
|
||||
tenantId, operatorId, inbound.getId(), command.image());
|
||||
UpsertOutcome outcome;
|
||||
try {
|
||||
outcome = transactionTemplate.execute(status -> persist(
|
||||
tenantId, operatorId, binding, boxCode, command, uploaded.fileId()));
|
||||
} catch (RuntimeException exception) {
|
||||
cleanupImage(tenantId, operatorId, inbound.getId(), uploaded.fileId(), "回传落库失败补偿");
|
||||
throw exception;
|
||||
}
|
||||
if (outcome == null) {
|
||||
cleanupImage(tenantId, operatorId, inbound.getId(), uploaded.fileId(), "事务未返回结果补偿");
|
||||
throw new BizException(ResultCode.INTERNAL_ERROR, "单箱测量保存失败");
|
||||
}
|
||||
if (outcome.oldImageFileId() != null && !outcome.oldImageFileId().equals(uploaded.fileId())) {
|
||||
cleanupImage(tenantId, operatorId, inbound.getId(), outcome.oldImageFileId(), "同箱复测旧图清理");
|
||||
}
|
||||
return outcome.result();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public CartonMeasurementResult updateConfirmedPackageCount(
|
||||
long tenantId, long inboundId, long itemId, int expectedMeasurementVersion,
|
||||
int confirmedPackageCount) {
|
||||
if (confirmedPackageCount < 1 || confirmedPackageCount > 1_000_000) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED, "确认箱数必须是1至1000000的整数");
|
||||
}
|
||||
InboundOrderEntity inbound = requireManualCountEditableInbound(tenantId, inboundId);
|
||||
InboundMeasurementItemEntity item = repository.findItem(tenantId, itemId);
|
||||
if (item == null) {
|
||||
throw new BizException(ResultCode.NOT_FOUND, "单箱测量记录不存在");
|
||||
}
|
||||
InboundMeasurementEntity measurement = repository.findMeasurement(
|
||||
tenantId, inboundId, item.getMeasurementId());
|
||||
if (measurement == null) {
|
||||
throw new BizException(ResultCode.NOT_FOUND, "单箱测量记录不属于当前入库单");
|
||||
}
|
||||
requireVersion(expectedMeasurementVersion, measurement.getVersion());
|
||||
item.setPackageCount(confirmedPackageCount);
|
||||
applyCalculatedValues(item);
|
||||
item.setUpdatedAt(LocalDateTime.now());
|
||||
if (!repository.updateItem(item)) {
|
||||
throw concurrentModification();
|
||||
}
|
||||
recalculateMeasurement(measurement, repository.listItems(tenantId, measurement.getId()));
|
||||
if (!repository.updateMeasurement(measurement)) {
|
||||
throw concurrentModification();
|
||||
}
|
||||
// 提交审核后仍允许平台人工修正代表箱数,但订单原始总箱数不可被测量汇总覆盖。
|
||||
if ("MEASURE_PENDING".equals(inbound.getProcessStatus())) {
|
||||
inbound.setActualVolumeCbm(measurement.getTotalVolumeCbm());
|
||||
inbound.setVolumeWeightKg(measurement.getVolumeWeightKg());
|
||||
if (!repository.updateInbound(inbound)) {
|
||||
throw concurrentModification();
|
||||
}
|
||||
}
|
||||
return result(measurement, item, false);
|
||||
}
|
||||
|
||||
private UpsertOutcome persist(long tenantId, long operatorId,
|
||||
CartonLabelBindingResolver.Binding binding,
|
||||
String boxCode,
|
||||
CartonMeasurementCommand command, long imageFileId) {
|
||||
InboundOrderEntity inbound = repository.lockInbound(tenantId, binding.inboundId());
|
||||
if (inbound == null) {
|
||||
throw new BizException(ResultCode.NOT_FOUND, "入库单不存在");
|
||||
}
|
||||
if (!DEVICE_WRITABLE_STATUSES.contains(inbound.getProcessStatus())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "测量已提交,设备不能新增或覆盖数据");
|
||||
}
|
||||
InboundMeasurementItemEntity item = repository.findItemByBoxCode(tenantId, boxCode);
|
||||
boolean created = item == null;
|
||||
InboundMeasurementEntity measurement;
|
||||
Long oldImageFileId = null;
|
||||
if (created) {
|
||||
measurement = repository.findLatestEditableMeasurement(tenantId, inbound.getId());
|
||||
if (measurement == null) {
|
||||
measurement = newMeasurement(tenantId, operatorId, inbound.getId());
|
||||
repository.insertMeasurement(measurement);
|
||||
}
|
||||
item = new InboundMeasurementItemEntity();
|
||||
item.setId(IdWorker.getId());
|
||||
item.setTenantId(tenantId);
|
||||
item.setMeasurementId(measurement.getId());
|
||||
item.setPackageCount(1);
|
||||
item.setCreatedAt(LocalDateTime.now());
|
||||
} else {
|
||||
measurement = repository.findMeasurement(tenantId, inbound.getId(), item.getMeasurementId());
|
||||
if (measurement == null) {
|
||||
throw new BizException(ResultCode.CONFLICT, "箱号已绑定到其他入库单");
|
||||
}
|
||||
oldImageFileId = item.getSourceImageFileId();
|
||||
}
|
||||
|
||||
item.setInboundDetailId(binding.inboundDetailId());
|
||||
item.setExternalDetailNo(boxCode);
|
||||
item.setBoxCode(boxCode);
|
||||
item.setItemNo("B" + binding.cartonIndex());
|
||||
item.setBoxSpecification(StringUtils.hasText(binding.boxSpecification())
|
||||
? binding.boxSpecification().trim() : boxCode);
|
||||
item.setLengthCm(command.lengthCm());
|
||||
item.setWidthCm(command.widthCm());
|
||||
item.setHeightCm(command.heightCm());
|
||||
item.setUnitWeightKg(command.unitWeightKg());
|
||||
item.setSourceImageFileId(imageFileId);
|
||||
item.setMachineNo(command.machineNo().trim());
|
||||
item.setDeviceMeasuredAt(command.measuredAt());
|
||||
item.setUpdatedAt(LocalDateTime.now());
|
||||
applyCalculatedValues(item);
|
||||
|
||||
if (created) {
|
||||
repository.insertItem(item);
|
||||
} else if (!repository.updateItem(item)) {
|
||||
throw concurrentModification();
|
||||
}
|
||||
|
||||
measurement.setStatus(MeasurementBatchService.STATUS_RECEIVED);
|
||||
measurement.setSourceType(MeasurementBatchService.SOURCE_THIRD_PARTY);
|
||||
measurement.setSourceCode("DEVICE");
|
||||
measurement.setDeviceCode(command.machineNo().trim());
|
||||
measurement.setMeasuredAt(command.measuredAt());
|
||||
measurement.setUpdatedAt(LocalDateTime.now());
|
||||
recalculateMeasurement(measurement, repository.listItems(tenantId, measurement.getId()));
|
||||
if (!repository.updateMeasurement(measurement)) {
|
||||
throw concurrentModification();
|
||||
}
|
||||
return new UpsertOutcome(result(measurement, item, created), oldImageFileId);
|
||||
}
|
||||
|
||||
private InboundMeasurementEntity newMeasurement(long tenantId, long operatorId, long inboundId) {
|
||||
InboundMeasurementEntity measurement = new InboundMeasurementEntity();
|
||||
measurement.setId(IdWorker.getId());
|
||||
measurement.setTenantId(tenantId);
|
||||
measurement.setInboundId(inboundId);
|
||||
measurement.setSourceType(MeasurementBatchService.SOURCE_THIRD_PARTY);
|
||||
measurement.setSourceCode("DEVICE");
|
||||
measurement.setStatus(MeasurementBatchService.STATUS_RECEIVED);
|
||||
measurement.setCreatedBy(operatorId);
|
||||
measurement.setCreatedAt(LocalDateTime.now());
|
||||
measurement.setUpdatedAt(LocalDateTime.now());
|
||||
measurement.setVersion(0);
|
||||
return measurement;
|
||||
}
|
||||
|
||||
private void applyCalculatedValues(InboundMeasurementItemEntity item) {
|
||||
BigDecimal unitRaw = item.getLengthCm().multiply(item.getWidthCm()).multiply(item.getHeightCm())
|
||||
.divide(CUBIC_CENTIMETERS_PER_CBM, 6, RoundingMode.HALF_UP);
|
||||
BigDecimal unitVolume = unitRaw.setScale(4, RoundingMode.CEILING);
|
||||
BigDecimal count = BigDecimal.valueOf(item.getPackageCount());
|
||||
BigDecimal totalRaw = unitRaw.multiply(count).setScale(6, RoundingMode.HALF_UP);
|
||||
BigDecimal totalVolume = unitVolume.multiply(count).setScale(4, RoundingMode.CEILING);
|
||||
item.setGirthCm(item.getLengthCm().add(item.getWidthCm().add(item.getHeightCm())
|
||||
.multiply(BigDecimal.valueOf(2))).setScale(3, RoundingMode.CEILING));
|
||||
item.setUnitWeightKg(item.getUnitWeightKg() == null ? null
|
||||
: item.getUnitWeightKg().setScale(3, RoundingMode.CEILING));
|
||||
item.setUnitVolumeCbm(unitVolume);
|
||||
item.setVolumeRawCbm(totalRaw);
|
||||
item.setVolumeCbm(totalVolume);
|
||||
item.setTotalWeightKg(item.getUnitWeightKg() == null ? null
|
||||
: item.getUnitWeightKg().multiply(count).setScale(3, RoundingMode.CEILING));
|
||||
item.setTotalVolumeCbm(totalVolume);
|
||||
item.setVolumeWeightKg(totalVolume.multiply(VOLUME_WEIGHT_FACTOR).setScale(3, RoundingMode.CEILING));
|
||||
item.setCalculationRuleVersion(CALCULATION_RULE_VERSION);
|
||||
}
|
||||
|
||||
private void recalculateMeasurement(InboundMeasurementEntity measurement,
|
||||
List<InboundMeasurementItemEntity> items) {
|
||||
long packages = items.stream().mapToLong(InboundMeasurementItemEntity::getPackageCount).sum();
|
||||
if (packages > Integer.MAX_VALUE) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED, "测量确认总箱数超过系统上限");
|
||||
}
|
||||
BigDecimal rawVolume = items.stream().map(InboundMeasurementItemEntity::getVolumeRawCbm)
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add).setScale(6, RoundingMode.HALF_UP);
|
||||
BigDecimal totalVolume = items.stream().map(InboundMeasurementItemEntity::getTotalVolumeCbm)
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add).setScale(4, RoundingMode.CEILING);
|
||||
measurement.setTotalPackageCount((int) packages);
|
||||
measurement.setTotalVolumeRawCbm(rawVolume);
|
||||
measurement.setTotalVolumeCbm(totalVolume);
|
||||
measurement.setVolumeWeightKg(totalVolume.multiply(VOLUME_WEIGHT_FACTOR)
|
||||
.setScale(3, RoundingMode.CEILING));
|
||||
}
|
||||
|
||||
private CartonLabelBindingResolver.Binding requireBinding(long tenantId, String boxCode) {
|
||||
CartonLabelBindingResolver resolver = resolverProvider.getIfAvailable();
|
||||
if (resolver == null) {
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "箱号映射服务暂不可用");
|
||||
}
|
||||
CartonLabelBindingResolver.Binding binding = resolver.resolveByBoxCode(tenantId, boxCode);
|
||||
if (binding == null) {
|
||||
throw new BizException(ResultCode.NOT_FOUND, "箱号不存在或不属于当前客户");
|
||||
}
|
||||
return binding;
|
||||
}
|
||||
|
||||
private InboundOrderEntity requireDeviceWritableInbound(long tenantId, long inboundId) {
|
||||
InboundOrderEntity inbound = repository.findInbound(tenantId, inboundId);
|
||||
if (inbound == null) {
|
||||
throw new BizException(ResultCode.NOT_FOUND, "入库单不存在");
|
||||
}
|
||||
if (!DEVICE_WRITABLE_STATUSES.contains(inbound.getProcessStatus())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "测量已提交,设备不能新增或覆盖数据");
|
||||
}
|
||||
return inbound;
|
||||
}
|
||||
|
||||
private InboundOrderEntity requireManualCountEditableInbound(long tenantId, long inboundId) {
|
||||
InboundOrderEntity inbound = repository.findInbound(tenantId, inboundId);
|
||||
if (inbound == null) {
|
||||
throw new BizException(ResultCode.NOT_FOUND, "入库单不存在");
|
||||
}
|
||||
if (!MANUAL_COUNT_EDITABLE_STATUSES.contains(inbound.getProcessStatus())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "当前入库单状态不允许修改确认箱数");
|
||||
}
|
||||
return inbound;
|
||||
}
|
||||
|
||||
private MeasurementImageClient.UploadResult uploadImage(long tenantId, long operatorId,
|
||||
long inboundId, MultipartFile image) {
|
||||
try {
|
||||
ApiResponse<MeasurementImageClient.UploadResult> response = imageClient.upload(
|
||||
internalToken, operatorId, tenantId, inboundId, image);
|
||||
if (response == null || !response.successful() || response.data() == null
|
||||
|| response.data().fileId() == null) {
|
||||
throw downstream(response, "测量图片保存失败");
|
||||
}
|
||||
return response.data();
|
||||
} catch (FeignException exception) {
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "文件服务暂不可用,测量数据未保存");
|
||||
}
|
||||
}
|
||||
|
||||
private void cleanupImage(long tenantId, long operatorId, long inboundId, long fileId, String action) {
|
||||
try {
|
||||
imageClient.delete(internalToken, operatorId, tenantId, inboundId, fileId);
|
||||
} catch (RuntimeException exception) {
|
||||
log.warn("{}失败 tenantId={}, inboundId={}, fileId={}", action, tenantId, inboundId, fileId, exception);
|
||||
}
|
||||
}
|
||||
|
||||
private BizException downstream(ApiResponse<?> response, String fallback) {
|
||||
if (response == null) {
|
||||
return new BizException(ResultCode.SERVICE_UNAVAILABLE, fallback);
|
||||
}
|
||||
String message = StringUtils.hasText(response.message()) ? response.message() : fallback;
|
||||
return new BizException(ResultCode.of(response.code()), message);
|
||||
}
|
||||
|
||||
private void validateCommand(CartonMeasurementCommand command) {
|
||||
if (command == null || !StringUtils.hasText(command.boxCode())
|
||||
|| command.boxCode().trim().length() > 128) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED, "箱号不能为空且不能超过128个字符");
|
||||
}
|
||||
if (!positive(command.lengthCm()) || !positive(command.widthCm()) || !positive(command.heightCm())) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED, "长、宽、高必须大于0");
|
||||
}
|
||||
if (command.unitWeightKg() != null && !positive(command.unitWeightKg())) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED, "单箱重量必须大于0");
|
||||
}
|
||||
if (!StringUtils.hasText(command.machineNo()) || command.machineNo().trim().length() > 64) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED, "设备编号不能为空且不能超过64个字符");
|
||||
}
|
||||
if (command.measuredAt() == null) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED, "设备测量时间不能为空");
|
||||
}
|
||||
if (command.image() == null || command.image().isEmpty()) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED, "测量图片不能为空");
|
||||
}
|
||||
if (command.image().getSize() > MAX_IMAGE_BYTES) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED, "测量图片不能超过5MB");
|
||||
}
|
||||
}
|
||||
|
||||
private boolean positive(BigDecimal value) {
|
||||
return value != null && value.compareTo(BigDecimal.ZERO) > 0;
|
||||
}
|
||||
|
||||
private void requireVersion(int expected, Integer actual) {
|
||||
if (actual == null || expected != actual) {
|
||||
throw concurrentModification();
|
||||
}
|
||||
}
|
||||
|
||||
private CartonMeasurementResult result(InboundMeasurementEntity measurement,
|
||||
InboundMeasurementItemEntity item, boolean created) {
|
||||
return new CartonMeasurementResult(measurement.getId(), measurement.getVersion(), item.getId(),
|
||||
item.getBoxCode(), item.getPackageCount(), item.getLengthCm(), item.getWidthCm(),
|
||||
item.getHeightCm(), item.getUnitWeightKg(), item.getSourceImageFileId(), item.getMachineNo(),
|
||||
item.getDeviceMeasuredAt(), created);
|
||||
}
|
||||
|
||||
private BizException concurrentModification() {
|
||||
return new BizException(ResultCode.CONFLICT, "测量数据已被其他人修改,请刷新后重试");
|
||||
}
|
||||
|
||||
private record UpsertOutcome(CartonMeasurementResult result, Long oldImageFileId) {
|
||||
}
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
package com.baiyue.firstleg.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.dto.CustomerDimensionConfigDtos.InitializeResponse;
|
||||
import com.baiyue.firstleg.dto.CustomerDimensionConfigDtos.Response;
|
||||
import com.baiyue.firstleg.dto.CustomerDimensionConfigDtos.UpdateRequest;
|
||||
import com.baiyue.firstleg.entity.CustomerDimensionConfigEntity;
|
||||
import com.baiyue.firstleg.repository.CustomerDimensionConfigRepository;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/** 管理每个客户唯一的尺寸加边配置,不参与当前订单或计费计算。 */
|
||||
@Service
|
||||
public class CustomerDimensionConfigService {
|
||||
public static final BigDecimal DEFAULT_EDGE_ADDITION_CM = new BigDecimal("2.000");
|
||||
private static final String DEFAULT_REMARK = "默认每侧加宽 2cm";
|
||||
|
||||
@Resource
|
||||
private CustomerDimensionConfigRepository repository;
|
||||
|
||||
public Response get(long tenantId) {
|
||||
CustomerDimensionConfigEntity value = repository.find(tenantId);
|
||||
if (value == null) {
|
||||
throw new BizException(ResultCode.NOT_FOUND, "客户尺寸计算配置不存在");
|
||||
}
|
||||
return response(value);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Response update(long tenantId, long operatorId, UpdateRequest request) {
|
||||
CustomerDimensionConfigEntity value = repository.find(tenantId);
|
||||
if (value == null) {
|
||||
throw new BizException(ResultCode.NOT_FOUND, "客户尺寸计算配置不存在");
|
||||
}
|
||||
if (!value.getVersion().equals(request.version())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "尺寸计算配置已被修改,请刷新后重试");
|
||||
}
|
||||
value.setEdgeAdditionCm(request.edgeAdditionCm());
|
||||
value.setRemark(normalizeRemark(request.remark()));
|
||||
value.setUpdatedBy(operatorId);
|
||||
if (repository.update(value) != 1) {
|
||||
throw new BizException(ResultCode.CONFLICT, "尺寸计算配置已被修改,请刷新后重试");
|
||||
}
|
||||
return response(value);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public InitializeResponse initializeDefault(long tenantId, long operatorId, String initializationKey) {
|
||||
requireTenant(tenantId);
|
||||
String normalizedKey = requireInitializationKey(initializationKey);
|
||||
CustomerDimensionConfigEntity existing = repository.find(tenantId);
|
||||
if (existing != null) {
|
||||
return new InitializeResponse(existing.getId(),
|
||||
normalizedKey.equals(existing.getInitializationKey()));
|
||||
}
|
||||
|
||||
long id = IdWorker.getId();
|
||||
boolean created = repository.insertDefault(id, tenantId, DEFAULT_EDGE_ADDITION_CM,
|
||||
DEFAULT_REMARK, normalizedKey, operatorId);
|
||||
CustomerDimensionConfigEntity persisted = repository.find(tenantId);
|
||||
if (persisted == null) {
|
||||
throw new BizException(ResultCode.INTERNAL_ERROR, "客户尺寸计算配置初始化失败");
|
||||
}
|
||||
boolean ownedByKey = normalizedKey.equals(persisted.getInitializationKey());
|
||||
return new InitializeResponse(persisted.getId(), created || ownedByKey);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void compensateDefault(long tenantId, String initializationKey) {
|
||||
requireTenant(tenantId);
|
||||
String normalizedKey = requireInitializationKey(initializationKey);
|
||||
CustomerDimensionConfigEntity existing = repository.find(tenantId);
|
||||
if (existing == null || !normalizedKey.equals(existing.getInitializationKey())) return;
|
||||
repository.deleteUnchangedDefault(tenantId, existing.getId(), normalizedKey, DEFAULT_EDGE_ADDITION_CM);
|
||||
}
|
||||
|
||||
private static Response response(CustomerDimensionConfigEntity value) {
|
||||
return new Response(value.getId(), value.getTenantId(), value.getEdgeAdditionCm(), value.getRemark(),
|
||||
value.getVersion(), value.getCreatedAt(), value.getUpdatedAt());
|
||||
}
|
||||
|
||||
private static String normalizeRemark(String value) {
|
||||
if (value == null || value.isBlank()) return null;
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
private static void requireTenant(long tenantId) {
|
||||
if (tenantId <= 0) throw new BizException(ResultCode.BAD_REQUEST, "客户ID无效");
|
||||
}
|
||||
|
||||
private static String requireInitializationKey(String value) {
|
||||
if (value == null || value.isBlank() || value.trim().length() > 128) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "初始化幂等键无效");
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
@@ -6,13 +6,16 @@ import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse;
|
||||
import com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest;
|
||||
import com.baiyue.firstleg.entity.CarrierFbaRateEntity;
|
||||
import com.baiyue.firstleg.entity.CustomerChargeRuleEntity;
|
||||
import com.baiyue.firstleg.domain.PricingDestinationType;
|
||||
import com.baiyue.firstleg.domain.PricingTaxMode;
|
||||
import com.baiyue.firstleg.dto.PriceBookDtos.EstimateRequest;
|
||||
import com.baiyue.firstleg.entity.InboundFeeEntity;
|
||||
import com.baiyue.firstleg.entity.InboundFeeItemEntity;
|
||||
import com.baiyue.firstleg.entity.InboundOutboxEntity;
|
||||
import com.baiyue.firstleg.entity.InboundPricingEntity;
|
||||
import com.baiyue.firstleg.entity.InboundPricingIdempotencyEntity;
|
||||
import com.baiyue.firstleg.pricing.ActivePriceBookEstimator;
|
||||
import com.baiyue.firstleg.repository.PriceBookQueryRows.RateRow;
|
||||
import com.baiyue.firstleg.repository.FbaPricingRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
@@ -36,6 +39,8 @@ public class FbaPricingService {
|
||||
@Resource
|
||||
private FbaPricingRepository repository;
|
||||
@Resource
|
||||
private ActivePriceBookEstimator priceBookEstimator;
|
||||
@Resource
|
||||
private Clock clock;
|
||||
@Resource
|
||||
private InboundFeeDetailService feeDetails;
|
||||
@@ -43,53 +48,36 @@ public class FbaPricingService {
|
||||
@Transactional
|
||||
public FeeSnapshotResponse calculate(long tenantId, long operatorId, long inboundId,
|
||||
LocalDateTime requestedAt, String idempotencyKey) {
|
||||
LocalDateTime pricingAt = requestedAt == null ? LocalDateTime.now(clock) : requestedAt;
|
||||
String hash = hash("CALCULATE", inboundId, requestedAt == null ? "CURRENT" : requestedAt.toString());
|
||||
InboundPricingIdempotencyEntity token = beginIdempotency(
|
||||
tenantId, operatorId, inboundId, "FEE_CALCULATE", idempotencyKey, hash);
|
||||
if ("COMPLETED".equals(token.getStatus())) return response(tenantId, token.getResultResourceId(), null);
|
||||
|
||||
InboundPricingEntity inbound = requireEligibleInbound(tenantId, inboundId);
|
||||
List<CustomerChargeRuleEntity> matchedRules = repository.matchRules(inbound, pricingAt);
|
||||
CustomerChargeRuleEntity rule = unique(matchedRules, "客户计费规则");
|
||||
List<CarrierFbaRateEntity> matchedRates = repository.matchRates(inbound, rule.getChargeMode(), pricingAt);
|
||||
CarrierFbaRateEntity rate = unique(matchedRates, "船司 FBA 价格");
|
||||
if (!rule.getCurrency().equals(rate.getCurrency())) {
|
||||
throw conflict("客户规则币种与船司价格币种不一致");
|
||||
}
|
||||
|
||||
RoundingMode roundingMode;
|
||||
try {
|
||||
roundingMode = RoundingMode.valueOf(rule.getRoundingMode());
|
||||
} catch (IllegalArgumentException exception) {
|
||||
throw conflict("客户规则舍入方式无效: " + rule.getRoundingMode());
|
||||
}
|
||||
int scale = rule.getRoundingScale();
|
||||
if (scale < 0 || scale > 4 || rule.getConversionFactor().signum() <= 0) {
|
||||
throw conflict("客户规则换算系数或精度无效");
|
||||
}
|
||||
|
||||
ActivePriceBookEstimator.Estimate estimate = priceBookEstimator.estimate(new EstimateRequest(
|
||||
inbound.getWarehouseId(), inbound.getChannelCode(), inbound.getDestinationCode(),
|
||||
destinationType(inbound), inbound.getDestinationWarehouseId(), inbound.getDestinationName(), null,
|
||||
pricingTaxMode(inbound), inbound.getActualGrossWeightKg(), inbound.getActualVolumeCbm()));
|
||||
RateRow rate = estimate.rate();
|
||||
BigDecimal weight = inbound.getActualGrossWeightKg().setScale(3, RoundingMode.HALF_UP);
|
||||
BigDecimal volume = inbound.getActualVolumeCbm().setScale(4, RoundingMode.HALF_UP);
|
||||
BigDecimal converted = "KG".equals(rule.getChargeMode())
|
||||
? volume.multiply(rule.getConversionFactor()).setScale(scale, roundingMode)
|
||||
: weight.divide(rule.getConversionFactor(), scale, roundingMode);
|
||||
BigDecimal actual = "KG".equals(rule.getChargeMode()) ? weight : volume;
|
||||
BigDecimal chargeable = actual.max(converted).setScale(scale, roundingMode);
|
||||
BigDecimal subtotal = chargeable.multiply(rate.getUnitPrice()).setScale(MONEY_SCALE, RoundingMode.HALF_UP);
|
||||
BigDecimal additional = rate.getAdditionalFee().setScale(MONEY_SCALE, RoundingMode.HALF_UP);
|
||||
BigDecimal chargeable = estimate.chargeableQuantity();
|
||||
BigDecimal converted = chargeable;
|
||||
BigDecimal subtotal = estimate.amount().setScale(MONEY_SCALE, RoundingMode.HALF_UP);
|
||||
BigDecimal additional = rate.getAdditionalFee() == null ? BigDecimal.ZERO
|
||||
: rate.getAdditionalFee().setScale(MONEY_SCALE, RoundingMode.HALF_UP);
|
||||
BigDecimal total = subtotal.add(additional).setScale(MONEY_SCALE, RoundingMode.HALF_UP);
|
||||
LocalDateTime now = LocalDateTime.now(clock);
|
||||
int version = repository.nextFeeVersion(tenantId, inboundId);
|
||||
|
||||
InboundFeeEntity fee = snapshot(tenantId, operatorId, inbound, rule, rate, version,
|
||||
InboundFeeEntity fee = snapshot(tenantId, operatorId, inbound, estimate, rate, version,
|
||||
weight, volume, converted, chargeable, subtotal, additional, total, now);
|
||||
repository.supersedeCalculated(tenantId, inboundId);
|
||||
repository.insertFee(fee);
|
||||
repository.insertItem(baseItem(tenantId, fee, rate, chargeable, subtotal, now));
|
||||
if (additional.signum() > 0) repository.insertItem(additionalItem(tenantId, fee, additional, now));
|
||||
if (repository.markInboundCalculated(tenantId, inboundId, inbound.getProcessStatus(),
|
||||
rule.getChargeMode(), chargeable) != 1) {
|
||||
estimate.chargeUnit(), chargeable) != 1) {
|
||||
throw conflict("入库单状态已变化,费用试算未保存");
|
||||
}
|
||||
complete(token, fee.getId(), now);
|
||||
@@ -245,24 +233,36 @@ public class FbaPricingService {
|
||||
private InboundPricingEntity requireApprovedInbound(long tenantId, long inboundId) {
|
||||
InboundPricingEntity inbound = repository.findInbound(tenantId, inboundId);
|
||||
if (inbound == null) throw new BizException(ResultCode.NOT_FOUND, "入库单不存在");
|
||||
if (!"FBA".equals(inbound.getDestinationType())) throw conflict("二期仅支持 FBA 目的仓计费");
|
||||
if (!"FBA".equals(inbound.getDestinationType()) && !"OVERSEAS".equals(inbound.getDestinationType())) {
|
||||
throw conflict("当前目的地类型不支持自动计费");
|
||||
}
|
||||
if (!"APPROVED".equals(inbound.getMeasureReviewStatus())) throw conflict("只能消费审核通过的测量快照");
|
||||
if (inbound.getActualGrossWeightKg() == null || inbound.getActualGrossWeightKg().signum() <= 0
|
||||
|| inbound.getActualVolumeCbm() == null || inbound.getActualVolumeCbm().signum() <= 0) {
|
||||
throw conflict("审核通过的测量快照必须包含大于零的实重和体积");
|
||||
}
|
||||
if (inbound.getCustomerId() == null || inbound.getWarehouseId() == null
|
||||
|| inbound.getCarrierId() == null || !StringUtils.hasText(inbound.getChannelCode())
|
||||
|| !StringUtils.hasText(inbound.getDestinationCode())) {
|
||||
throw conflict("入库单缺少客户、起运仓、渠道、船司或 FBA 仓匹配维度");
|
||||
|| !StringUtils.hasText(inbound.getChannelCode())) {
|
||||
throw conflict("入库单缺少客户、起运仓或渠道匹配维度");
|
||||
}
|
||||
return inbound;
|
||||
}
|
||||
|
||||
private <T> T unique(List<T> values, String name) {
|
||||
if (values.isEmpty()) throw conflict("未匹配到有效" + name);
|
||||
if (values.size() > 1) throw conflict("匹配到多条有效" + name + ",请修正有效期配置");
|
||||
return values.get(0);
|
||||
private static PricingDestinationType destinationType(InboundPricingEntity inbound) {
|
||||
try {
|
||||
return PricingDestinationType.valueOf(inbound.getDestinationType());
|
||||
} catch (RuntimeException exception) {
|
||||
throw conflict("入库单目的地类型配置无效");
|
||||
}
|
||||
}
|
||||
|
||||
private static PricingTaxMode pricingTaxMode(InboundPricingEntity inbound) {
|
||||
if (!StringUtils.hasText(inbound.getPricingTaxMode())) return null;
|
||||
try {
|
||||
return PricingTaxMode.valueOf(inbound.getPricingTaxMode());
|
||||
} catch (RuntimeException exception) {
|
||||
throw conflict("入库单报价包税维度配置无效");
|
||||
}
|
||||
}
|
||||
|
||||
private InboundPricingIdempotencyEntity beginIdempotency(long tenantId, long operatorId,
|
||||
@@ -296,34 +296,37 @@ public class FbaPricingService {
|
||||
}
|
||||
|
||||
private InboundFeeEntity snapshot(long tenantId, long operatorId, InboundPricingEntity inbound,
|
||||
CustomerChargeRuleEntity rule, CarrierFbaRateEntity rate, int version,
|
||||
ActivePriceBookEstimator.Estimate estimate, RateRow rate, int version,
|
||||
BigDecimal weight, BigDecimal volume, BigDecimal converted,
|
||||
BigDecimal chargeable, BigDecimal subtotal, BigDecimal additional,
|
||||
BigDecimal total, LocalDateTime now) {
|
||||
InboundFeeEntity fee = new InboundFeeEntity();
|
||||
fee.setTenantId(tenantId); fee.setInboundId(inbound.getId());
|
||||
fee.setFeeNo("FBAF-" + inbound.getId() + "-" + version); fee.setFeeVersion(version);
|
||||
fee.setChargeRuleId(rule.getId()); fee.setChargeRuleVersion(rule.getRuleVersion());
|
||||
fee.setChargeRuleId(null); fee.setChargeRuleVersion(null);
|
||||
fee.setCarrierRateId(rate.getId()); fee.setCarrierRateVersion(rate.getRateVersion());
|
||||
fee.setRuleCode(rule.getRuleCode()); fee.setCustomerId(inbound.getCustomerId());
|
||||
fee.setPriceBookVersionId(estimate.priceBookVersionId());
|
||||
fee.setRuleCode(null); fee.setCustomerId(inbound.getCustomerId());
|
||||
fee.setCustomerName(inbound.getCustomerName()); fee.setBusinessType(inbound.getBusinessType());
|
||||
fee.setTaxMode(inbound.getTaxMode()); fee.setRoundingScale(rule.getRoundingScale());
|
||||
fee.setRoundingMode(rule.getRoundingMode()); fee.setRuleEffectiveFrom(rule.getEffectiveFrom());
|
||||
fee.setRuleEffectiveTo(rule.getEffectiveTo()); fee.setRateCode(rate.getRateCode());
|
||||
fee.setTaxMode(inbound.getTaxMode()); fee.setPricingTaxMode(inbound.getPricingTaxMode());
|
||||
fee.setRoundingScale(null); fee.setRoundingMode(null); fee.setRuleEffectiveFrom(null);
|
||||
fee.setRuleEffectiveTo(null); fee.setRateCode(rate.getRateCode());
|
||||
fee.setOriginWarehouseId(rate.getOriginWarehouseId()); fee.setOriginWarehouseName(rate.getOriginWarehouseName());
|
||||
fee.setChannelCode(rate.getChannelCode()); fee.setCarrierId(rate.getCarrierId());
|
||||
fee.setCarrierName(rate.getCarrierName()); fee.setDestinationCode(rate.getDestinationCode());
|
||||
fee.setChannelCode(rate.getChannelCode()); fee.setCarrierId(null);
|
||||
fee.setCarrierName(null); fee.setDestinationType(inbound.getDestinationType());
|
||||
fee.setDestinationWarehouseId(inbound.getDestinationWarehouseId()); fee.setDestinationName(inbound.getDestinationName());
|
||||
fee.setDestinationCode(inbound.getDestinationCode());
|
||||
fee.setRateEffectiveFrom(rate.getEffectiveFrom()); fee.setRateEffectiveTo(rate.getEffectiveTo());
|
||||
fee.setChargeMode(rule.getChargeMode()); fee.setConversionFactor(rule.getConversionFactor());
|
||||
fee.setChargeMode(estimate.chargeUnit()); fee.setConversionFactor(null);
|
||||
fee.setActualWeightKg(weight); fee.setActualVolumeCbm(volume); fee.setConvertedQuantity(converted);
|
||||
fee.setChargeableQuantity(chargeable); fee.setChargeUnit(rule.getChargeMode()); fee.setCurrency(rate.getCurrency());
|
||||
fee.setChargeableQuantity(chargeable); fee.setChargeUnit(estimate.chargeUnit()); fee.setCurrency(rate.getCurrency());
|
||||
fee.setSubtotalAmount(subtotal); fee.setAdditionalAmount(additional); fee.setTotalAmount(total);
|
||||
fee.setFeeStatus("CALCULATED"); fee.setReviewStatus("NOT_SUBMITTED"); fee.setExceptionRelease(false);
|
||||
fee.setCalculatedBy(operatorId); fee.setCalculatedAt(now); fee.setCreatedAt(now); fee.setUpdatedAt(now);
|
||||
return fee;
|
||||
}
|
||||
|
||||
private InboundFeeItemEntity baseItem(long tenantId, InboundFeeEntity fee, CarrierFbaRateEntity rate,
|
||||
private InboundFeeItemEntity baseItem(long tenantId, InboundFeeEntity fee, RateRow rate,
|
||||
BigDecimal quantity, BigDecimal amount, LocalDateTime now) {
|
||||
InboundFeeItemEntity item = item(tenantId, fee, 1, "FBA_BASE", "FBA基础运费", amount, now);
|
||||
item.setQuantity(quantity); item.setUnit(fee.getChargeUnit()); item.setUnitPrice(rate.getUnitPrice());
|
||||
@@ -360,10 +363,12 @@ public class FbaPricingService {
|
||||
return new FeeSnapshotResponse(fee.getId(), fee.getFeeNo(), fee.getFeeVersion(), fee.getFeeStatus(),
|
||||
fee.getInboundId(), inbound == null ? null : inbound.getInboundCode(),
|
||||
fee.getChargeRuleId(), fee.getRuleCode(), fee.getChargeRuleVersion(),
|
||||
fee.getCarrierRateId(), fee.getRateCode(), fee.getCarrierRateVersion(),
|
||||
fee.getCarrierRateId(), fee.getRateCode(), fee.getCarrierRateVersion(), fee.getPriceBookVersionId(),
|
||||
fee.getCustomerId(), fee.getCustomerName(), fee.getOriginWarehouseId(), fee.getOriginWarehouseName(),
|
||||
fee.getBusinessType(), fee.getChannelCode(), fee.getCarrierId(), fee.getCarrierName(),
|
||||
fee.getDestinationCode(), fee.getTaxMode(), fee.getChargeMode(), fee.getConversionFactor(),
|
||||
fee.getDestinationType(), fee.getDestinationWarehouseId(), fee.getDestinationName(), fee.getDestinationCode(),
|
||||
fee.getTaxMode(), fee.getPricingTaxMode(), fee.getChargeMode(),
|
||||
fee.getConversionFactor(),
|
||||
fee.getRoundingScale(), fee.getRoundingMode(), fee.getActualWeightKg(), fee.getActualVolumeCbm(),
|
||||
fee.getConvertedQuantity(), fee.getChargeableQuantity(), fee.getChargeUnit(), unitPrice,
|
||||
fee.getSubtotalAmount(), fee.getAdditionalAmount(), fee.getTotalAmount(), fee.getCurrency(),
|
||||
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
package com.baiyue.firstleg.service;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.client.PrintPdfClient;
|
||||
import com.baiyue.firstleg.dto.InboundCartonLabels;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import feign.FeignException;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
|
||||
/** Allocates stable carton codes and forwards immutable label facts to Print. */
|
||||
@Service
|
||||
public class InboundCartonLabelPrintService {
|
||||
private static final BigDecimal PAGE_MM = new BigDecimal("100");
|
||||
private static final List<String> SAFE_RESPONSE_HEADERS = List.of(
|
||||
HttpHeaders.CONTENT_DISPOSITION, HttpHeaders.CACHE_CONTROL, HttpHeaders.PRAGMA,
|
||||
"X-Print-Page-Count", "X-Request-Id", "X-Trace-Id");
|
||||
|
||||
private final InboundCartonLabelService labelService;
|
||||
private final PrintPdfClient printClient;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@Value("${baiyue.security.internal-service-token}")
|
||||
private String internalToken;
|
||||
|
||||
public InboundCartonLabelPrintService(InboundCartonLabelService labelService,
|
||||
PrintPdfClient printClient,
|
||||
ObjectMapper objectMapper) {
|
||||
this.labelService = labelService;
|
||||
this.printClient = printClient;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
public ResponseEntity<byte[]> generate(long tenantId, long inboundId,
|
||||
List<Integer> cartonIndexes, String requestId) {
|
||||
InboundCartonLabels.Batch batch = labelService.list(tenantId, inboundId, cartonIndexes);
|
||||
PrintPdfClient.InboundMarkPdfRequest request = new PrintPdfClient.InboundMarkPdfRequest(
|
||||
"INBOUND_MARK_V1", new PrintPdfClient.PageSpec(PAGE_MM, PAGE_MM),
|
||||
batch.inboundCode() + "-" + batch.totalCount() + "件-箱唛.pdf",
|
||||
batch.labels().stream().map(this::toPrintLabel).toList());
|
||||
try {
|
||||
return requirePdf(printClient.generateInboundMarks(internalToken, requestId, request));
|
||||
} catch (FeignException exception) {
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "打印服务暂不可用,请稍后重试");
|
||||
}
|
||||
}
|
||||
|
||||
private PrintPdfClient.Label toPrintLabel(InboundCartonLabels.Label label) {
|
||||
return new PrintPdfClient.Label(label.topCode(), label.warehouseText(), label.cartonText(),
|
||||
label.routeText(), label.boxCode());
|
||||
}
|
||||
|
||||
private ResponseEntity<byte[]> requirePdf(ResponseEntity<byte[]> response) {
|
||||
byte[] body = response == null || response.getBody() == null ? new byte[0] : response.getBody();
|
||||
MediaType contentType = response == null ? null : response.getHeaders().getContentType();
|
||||
if (contentType == null || !MediaType.APPLICATION_PDF.isCompatibleWith(contentType) || body.length == 0) {
|
||||
throw downstreamError(body);
|
||||
}
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_PDF);
|
||||
SAFE_RESPONSE_HEADERS.forEach(name -> {
|
||||
String value = response.getHeaders().getFirst(name);
|
||||
if (StringUtils.hasText(value)) headers.set(name, value);
|
||||
});
|
||||
headers.setContentLength(body.length);
|
||||
return ResponseEntity.ok().headers(headers).body(body);
|
||||
}
|
||||
|
||||
private BizException downstreamError(byte[] body) {
|
||||
try {
|
||||
ApiResponse<Object> error = objectMapper.readValue(body, new TypeReference<>() { });
|
||||
if (error != null && !error.successful()) {
|
||||
return new BizException(ResultCode.of(error.code()),
|
||||
StringUtils.hasText(error.message()) ? error.message() : "打印服务生成失败");
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// Non-contract responses are treated as unavailable instead of exposing downstream content.
|
||||
}
|
||||
return new BizException(ResultCode.SERVICE_UNAVAILABLE, "打印服务返回了无效响应");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
package com.baiyue.firstleg.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.client.OrderCargoClient;
|
||||
import com.baiyue.firstleg.dto.InboundCartonLabels;
|
||||
import com.baiyue.firstleg.entity.InboundCartonLabelEntity;
|
||||
import com.baiyue.firstleg.entity.InboundOrderEntity;
|
||||
import com.baiyue.firstleg.repository.InboundCartonLabelRepository;
|
||||
import feign.FeignException;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/** 首次生成后不可变的箱号分配;重打和测量解析只读取该映射。 */
|
||||
@Service
|
||||
public class InboundCartonLabelService implements CartonLabelBindingResolver {
|
||||
private static final int MAX_LABELS = 2000;
|
||||
private static final int MAX_QR_CODE_POINTS = 34;
|
||||
private static final Set<String> ALLOCATABLE_STATUSES = Set.of("WAIT_MEASURE", "MEASURING");
|
||||
|
||||
@Resource private InboundCartonLabelRepository repository;
|
||||
@Resource private OrderCargoClient orderCargoClient;
|
||||
@Value("${baiyue.security.internal-service-token}") private String internalToken;
|
||||
|
||||
@Transactional
|
||||
public InboundCartonLabels.Batch getOrCreate(long tenantId, long inboundId) {
|
||||
requireIds(tenantId, inboundId);
|
||||
InboundOrderEntity inbound = repository.lockInbound(tenantId, inboundId);
|
||||
if (inbound == null) throw new BizException(ResultCode.NOT_FOUND, "入库单不存在");
|
||||
|
||||
List<InboundCartonLabelEntity> existing = new ArrayList<>(repository.findByInbound(tenantId, inboundId));
|
||||
if (!existing.isEmpty()) {
|
||||
List<InboundCartonLabelEntity> consistent = requireConsistent(existing);
|
||||
freezeOriginalPackageCount(inbound, consistent.get(0).getTotalCartonCount());
|
||||
return batch(inbound, consistent);
|
||||
}
|
||||
if (!ALLOCATABLE_STATUSES.contains(inbound.getProcessStatus())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "当前入库单状态不允许首次生成箱号");
|
||||
}
|
||||
|
||||
List<OrderCargoClient.CargoLabelBinding> cargoes = loadCargoes(inbound);
|
||||
int total = cargoes.stream().mapToInt(this::requirePackageCount).sum();
|
||||
if (total < 1 || total > MAX_LABELS) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED, "唛头箱数必须为1至2000箱");
|
||||
}
|
||||
freezeOriginalPackageCount(inbound, total);
|
||||
requirePrintableInboundCode(inbound.getInboundCode(), total);
|
||||
|
||||
int cartonIndex = 0;
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
for (OrderCargoClient.CargoLabelBinding cargo : cargoes) {
|
||||
int detailCount = requirePackageCount(cargo);
|
||||
for (int offset = 0; offset < detailCount; offset++) {
|
||||
cartonIndex++;
|
||||
InboundCartonLabelEntity label = label(inbound, cargo, total, cartonIndex, now);
|
||||
repository.insert(label);
|
||||
existing.add(label);
|
||||
}
|
||||
}
|
||||
return batch(inbound, List.copyOf(existing));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public InboundCartonLabels.Batch list(long tenantId, long inboundId, List<Integer> cartonIndexes) {
|
||||
InboundCartonLabels.Batch batch = getOrCreate(tenantId, inboundId);
|
||||
if (cartonIndexes == null || cartonIndexes.isEmpty()) return batch;
|
||||
Set<Integer> selected = new HashSet<>(cartonIndexes);
|
||||
if (selected.stream().anyMatch(index -> index == null || index < 1 || index > batch.totalCount())) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "补打箱下标超出有效范围");
|
||||
}
|
||||
List<InboundCartonLabels.Label> labels = batch.labels().stream()
|
||||
.filter(label -> selected.contains(label.cartonIndex()))
|
||||
.toList();
|
||||
return new InboundCartonLabels.Batch(batch.inboundId(), batch.orderId(), batch.inboundCode(),
|
||||
batch.totalCount(), labels);
|
||||
}
|
||||
|
||||
@Override
|
||||
public CartonLabelBindingResolver.Binding resolveByBoxCode(long tenantId, String boxCode) {
|
||||
InboundCartonLabelEntity label = requireByBoxCode(tenantId, boxCode);
|
||||
return new CartonLabelBindingResolver.Binding(label.getInboundId(), label.getInboundDetailId(),
|
||||
label.getInboundCode(), label.getCartonIndex(), label.getTotalCartonCount(),
|
||||
label.getBoxSpecification());
|
||||
}
|
||||
|
||||
public InboundCartonLabels.Resolution resolveDetailsByBoxCode(long tenantId, String boxCode) {
|
||||
InboundCartonLabelEntity label = requireByBoxCode(tenantId, boxCode);
|
||||
return new InboundCartonLabels.Resolution(label.getInboundId(), label.getOrderId(),
|
||||
label.getInboundDetailId(), label.getBoxCode(), label.getTotalCartonCount(),
|
||||
label.getCartonIndex(), label.getCargoItemNo(), label.getCargoName(),
|
||||
label.getBoxSpecification());
|
||||
}
|
||||
|
||||
private InboundCartonLabelEntity requireByBoxCode(long tenantId, String boxCode) {
|
||||
if (tenantId <= 0 || !StringUtils.hasText(boxCode)) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "客户ID和箱号不能为空");
|
||||
}
|
||||
InboundCartonLabelEntity label = repository.findByBoxCode(tenantId, boxCode.trim());
|
||||
if (label == null) throw new BizException(ResultCode.NOT_FOUND, "箱号不存在");
|
||||
return label;
|
||||
}
|
||||
|
||||
private List<OrderCargoClient.CargoLabelBinding> loadCargoes(InboundOrderEntity inbound) {
|
||||
try {
|
||||
ApiResponse<List<OrderCargoClient.CargoLabelBinding>> response = orderCargoClient.listForCartonLabels(
|
||||
internalToken, inbound.getTenantId(), inbound.getOrderId());
|
||||
if (response == null || !response.successful() || response.data() == null) {
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "订单货物明细查询失败");
|
||||
}
|
||||
if (response.data().isEmpty()) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED, "订单没有可生成唛头的货物明细");
|
||||
}
|
||||
return response.data().stream()
|
||||
.sorted(java.util.Comparator.comparing(OrderCargoClient.CargoLabelBinding::itemNo,
|
||||
java.util.Comparator.nullsLast(Integer::compareTo))
|
||||
.thenComparing(OrderCargoClient.CargoLabelBinding::cargoId,
|
||||
java.util.Comparator.nullsLast(Long::compareTo)))
|
||||
.toList();
|
||||
} catch (FeignException exception) {
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "订单服务暂不可用,无法生成唛头");
|
||||
}
|
||||
}
|
||||
|
||||
private int requirePackageCount(OrderCargoClient.CargoLabelBinding cargo) {
|
||||
if (cargo.cargoId() == null || cargo.cargoId() <= 0 || cargo.itemNo() == null
|
||||
|| cargo.itemNo() < 1 || cargo.packageCount() == null || cargo.packageCount() < 1) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED, "订单货物明细缺少有效箱数或序号");
|
||||
}
|
||||
return cargo.packageCount();
|
||||
}
|
||||
|
||||
private void requirePrintableInboundCode(String inboundCode, int total) {
|
||||
if (!StringUtils.hasText(inboundCode) || !inboundCode.matches("[A-Za-z0-9._:-]+")) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED, "入仓码不能生成可打印二维码");
|
||||
}
|
||||
String longest = inboundCode + "#" + total + "-" + total;
|
||||
if (longest.codePointCount(0, longest.length()) > MAX_QR_CODE_POINTS) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED, "入仓码和箱号组合超过二维码长度限制");
|
||||
}
|
||||
}
|
||||
|
||||
private void freezeOriginalPackageCount(InboundOrderEntity inbound, int total) {
|
||||
Integer original = inbound.getTotalPackageCount();
|
||||
if (original != null && original != total) {
|
||||
throw new BizException(ResultCode.CONFLICT, "订单总箱数与已冻结箱号数量不一致");
|
||||
}
|
||||
if (original == null) {
|
||||
if (!repository.initializeOriginalPackageCount(inbound.getTenantId(), inbound.getId(), total)) {
|
||||
throw new BizException(ResultCode.CONFLICT, "订单总箱数已变化,请刷新后重试");
|
||||
}
|
||||
inbound.setTotalPackageCount(total);
|
||||
inbound.setVersion(inbound.getVersion() == null ? 1 : inbound.getVersion() + 1);
|
||||
}
|
||||
}
|
||||
|
||||
private InboundCartonLabelEntity label(InboundOrderEntity inbound,
|
||||
OrderCargoClient.CargoLabelBinding cargo,
|
||||
int total, int index, LocalDateTime now) {
|
||||
InboundCartonLabelEntity label = new InboundCartonLabelEntity();
|
||||
label.setId(IdWorker.getId());
|
||||
label.setTenantId(inbound.getTenantId());
|
||||
label.setInboundId(inbound.getId());
|
||||
label.setOrderId(inbound.getOrderId());
|
||||
label.setInboundDetailId(cargo.cargoId());
|
||||
label.setCargoItemNo(cargo.itemNo());
|
||||
label.setCargoName(cargo.cargoName());
|
||||
label.setDetailPackageCount(cargo.packageCount());
|
||||
label.setInboundCode(inbound.getInboundCode());
|
||||
label.setBoxCode(inbound.getInboundCode() + "#" + total + "-" + index);
|
||||
label.setTotalCartonCount(total);
|
||||
label.setCartonIndex(index);
|
||||
label.setLengthCm(cargo.lengthCm());
|
||||
label.setWidthCm(cargo.widthCm());
|
||||
label.setHeightCm(cargo.heightCm());
|
||||
label.setBoxSpecification(specification(cargo));
|
||||
label.setCreatedAt(now);
|
||||
return label;
|
||||
}
|
||||
|
||||
private String specification(OrderCargoClient.CargoLabelBinding cargo) {
|
||||
if (cargo.lengthCm() == null || cargo.widthCm() == null || cargo.heightCm() == null) {
|
||||
return StringUtils.hasText(cargo.cargoName()) ? cargo.cargoName().trim() : "货物明细" + cargo.itemNo();
|
||||
}
|
||||
return decimal(cargo.lengthCm()) + "x" + decimal(cargo.widthCm()) + "x"
|
||||
+ decimal(cargo.heightCm()) + "cm";
|
||||
}
|
||||
|
||||
private String decimal(BigDecimal value) {
|
||||
return value.stripTrailingZeros().toPlainString();
|
||||
}
|
||||
|
||||
private List<InboundCartonLabelEntity> requireConsistent(List<InboundCartonLabelEntity> labels) {
|
||||
int total = labels.get(0).getTotalCartonCount();
|
||||
if (total != labels.size()) throw corrupt();
|
||||
for (int index = 0; index < labels.size(); index++) {
|
||||
InboundCartonLabelEntity label = labels.get(index);
|
||||
if (label.getTotalCartonCount() != total || label.getCartonIndex() != index + 1) throw corrupt();
|
||||
}
|
||||
return labels;
|
||||
}
|
||||
|
||||
private BizException corrupt() {
|
||||
return new BizException(ResultCode.INTERNAL_ERROR, "入库单箱号映射不完整,请联系管理员");
|
||||
}
|
||||
|
||||
private InboundCartonLabels.Batch batch(InboundOrderEntity inbound,
|
||||
List<InboundCartonLabelEntity> entities) {
|
||||
List<InboundCartonLabels.Label> labels = entities.stream().map(entity ->
|
||||
new InboundCartonLabels.Label(entity.getId(), entity.getInboundDetailId(), entity.getCargoItemNo(),
|
||||
entity.getCargoName(), entity.getDetailPackageCount(), entity.getBoxCode(),
|
||||
entity.getTotalCartonCount(), entity.getCartonIndex(), entity.getBoxSpecification(),
|
||||
entity.getLengthCm(), entity.getWidthCm(), entity.getHeightCm(), inbound.getInboundCode(),
|
||||
fallback(inbound.getWarehouseName(), "WAREHOUSE"),
|
||||
"CTNS:" + entity.getTotalCartonCount() + "-" + entity.getCartonIndex(),
|
||||
fallback(inbound.getChannelName(), fallback(inbound.getChannelCode(), "ROUTE"))))
|
||||
.toList();
|
||||
int total = entities.isEmpty() ? 0 : entities.get(0).getTotalCartonCount();
|
||||
return new InboundCartonLabels.Batch(inbound.getId(), inbound.getOrderId(), inbound.getInboundCode(),
|
||||
total, labels);
|
||||
}
|
||||
|
||||
private String fallback(String value, String fallback) {
|
||||
return StringUtils.hasText(value) ? value.trim() : fallback;
|
||||
}
|
||||
|
||||
private void requireIds(long tenantId, long inboundId) {
|
||||
if (tenantId <= 0 || inboundId <= 0) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "客户ID和入库单ID必须大于0");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -101,6 +101,7 @@ public class InboundMeasurementService {
|
||||
BigDecimal confirmedWeight = confirmedWeightForSubmission(weights);
|
||||
|
||||
if (!alreadyAdvanced) {
|
||||
requireConfirmedPackageTotal(inbound, items);
|
||||
measurement.setStatus(STATUS_SUBMITTED);
|
||||
if (!repository.updateMeasurement(measurement)) {
|
||||
throw concurrentModification();
|
||||
@@ -150,7 +151,7 @@ public class InboundMeasurementService {
|
||||
weight.setOcrRawText(request.ocrRawText());
|
||||
weight.setOcrWeightValue(request.ocrWeightValue());
|
||||
weight.setOcrWeightUnit(unit);
|
||||
weight.setConfidence(request.confidence());
|
||||
weight.setConfidence(normalizeConfidence(request.confidence()));
|
||||
weight.setRecognitionStatus(isPositive(request.ocrWeightValue()) && isSupportedUnit(unit)
|
||||
? WEIGHT_PENDING_CONFIRM : WEIGHT_NEEDS_MANUAL);
|
||||
weight.setCreatedBy(operatorId);
|
||||
@@ -168,13 +169,22 @@ public class InboundMeasurementService {
|
||||
InboundOrderEntity inbound = requireInbound(tenantId, inboundId);
|
||||
InboundWeightResultEntity weight = requireWeight(tenantId, inboundId, resultId);
|
||||
requireVersion(request.version(), weight.getVersion());
|
||||
String adjustedUnit = normalizeUnit(request.adjustedWeightUnit());
|
||||
applyWeightCorrection(weight, request.version(), request.adjustedWeightValue(),
|
||||
request.adjustedWeightUnit(), request.adjustmentReason(), operatorId, operatorName);
|
||||
return toWeight(weight);
|
||||
}
|
||||
|
||||
private int applyWeightCorrection(
|
||||
InboundWeightResultEntity weight, int expectedVersion, BigDecimal adjustedWeightValue,
|
||||
String adjustedWeightUnit, String adjustmentReason, long operatorId, String operatorName) {
|
||||
requireCompleteAdjustment(adjustedWeightValue, adjustedWeightUnit, adjustmentReason);
|
||||
String adjustedUnit = normalizeUnit(adjustedWeightUnit);
|
||||
BigDecimal beforeValue = effectiveValue(weight);
|
||||
String beforeUnit = effectiveUnit(weight);
|
||||
|
||||
weight.setAdjustedWeightValue(request.adjustedWeightValue());
|
||||
weight.setAdjustedWeightValue(adjustedWeightValue);
|
||||
weight.setAdjustedWeightUnit(adjustedUnit);
|
||||
weight.setAdjustmentReason(request.adjustmentReason());
|
||||
weight.setAdjustmentReason(adjustmentReason.trim());
|
||||
weight.setAdjustedBy(operatorId);
|
||||
weight.setAdjustedAt(LocalDateTime.now());
|
||||
weight.setFinalWeightKg(null);
|
||||
@@ -185,10 +195,12 @@ public class InboundMeasurementService {
|
||||
if (!repository.updateWeight(weight)) {
|
||||
throw concurrentModification();
|
||||
}
|
||||
insertRevision(weight, request.version() + 1, "CORRECT", beforeValue, beforeUnit,
|
||||
request.adjustedWeightValue(), adjustedUnit, null, request.adjustmentReason(),
|
||||
int correctedVersion = expectedVersion + 1;
|
||||
weight.setVersion(correctedVersion);
|
||||
insertRevision(weight, correctedVersion, "CORRECT", beforeValue, beforeUnit,
|
||||
adjustedWeightValue, adjustedUnit, null, adjustmentReason.trim(),
|
||||
operatorId, operatorName);
|
||||
return toWeight(weight);
|
||||
return correctedVersion;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -198,7 +210,15 @@ public class InboundMeasurementService {
|
||||
InboundOrderEntity inbound = requireInbound(tenantId, inboundId);
|
||||
InboundWeightResultEntity weight = requireWeight(tenantId, inboundId, resultId);
|
||||
requireVersion(request.version(), weight.getVersion());
|
||||
if (WEIGHT_CONFIRMED.equals(weight.getRecognitionStatus())) {
|
||||
int currentVersion = request.version();
|
||||
if (request.hasAdjustment()) {
|
||||
requireCompleteAdjustment(request.adjustedWeightValue(), request.adjustedWeightUnit(),
|
||||
request.adjustmentReason());
|
||||
requireConfirmableKg(request.adjustedWeightValue(), request.adjustedWeightUnit());
|
||||
currentVersion = applyWeightCorrection(weight, currentVersion,
|
||||
request.adjustedWeightValue(), request.adjustedWeightUnit(),
|
||||
request.adjustmentReason(), operatorId, operatorName);
|
||||
} else if (WEIGHT_CONFIRMED.equals(weight.getRecognitionStatus())) {
|
||||
return toWeight(weight);
|
||||
}
|
||||
BigDecimal value = effectiveValue(weight);
|
||||
@@ -207,7 +227,7 @@ public class InboundMeasurementService {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED, "OCR 重量无法确认,请先人工修正数值和单位");
|
||||
}
|
||||
String unit = normalizeUnit(rawUnit);
|
||||
BigDecimal finalKg = toKg(value, unit);
|
||||
BigDecimal finalKg = requireConfirmableKg(value, unit);
|
||||
weight.setFinalWeightKg(finalKg);
|
||||
weight.setRecognitionStatus(WEIGHT_CONFIRMED);
|
||||
weight.setConfirmedBy(operatorId);
|
||||
@@ -216,7 +236,9 @@ public class InboundMeasurementService {
|
||||
if (!repository.updateWeight(weight)) {
|
||||
throw concurrentModification();
|
||||
}
|
||||
insertRevision(weight, request.version() + 1, "CONFIRM", value, unit, value, unit,
|
||||
int confirmedVersion = currentVersion + 1;
|
||||
weight.setVersion(confirmedVersion);
|
||||
insertRevision(weight, confirmedVersion, "CONFIRM", value, unit, value, unit,
|
||||
finalKg, null, operatorId, operatorName);
|
||||
recalculateConfirmedWeight(inbound, operatorId);
|
||||
return toWeight(weight);
|
||||
@@ -247,6 +269,13 @@ public class InboundMeasurementService {
|
||||
return isPositive(total) ? total : null;
|
||||
}
|
||||
|
||||
private void requireCompleteAdjustment(BigDecimal value, String unit, String reason) {
|
||||
if (!isPositive(value) || !StringUtils.hasText(unit) || !StringUtils.hasText(reason)) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED,
|
||||
"人工修改重量时必须同时填写有效重量、单位和修改原因");
|
||||
}
|
||||
}
|
||||
|
||||
private void insertRevision(InboundWeightResultEntity weight, int revisionNo, String action,
|
||||
BigDecimal beforeValue, String beforeUnit,
|
||||
BigDecimal afterValue, String afterUnit, BigDecimal finalWeightKg,
|
||||
@@ -285,6 +314,7 @@ public class InboundMeasurementService {
|
||||
measurement == null ? null : measurement.getOriginalDocumentNo(),
|
||||
measurement == null ? null : measurement.getOriginalGrossWeightKg(),
|
||||
measurement == null ? null : measurement.getOriginalVolumeCbm(),
|
||||
inbound.getTotalPackageCount(),
|
||||
measurement == null ? inbound.getTotalPackageCount() : measurement.getTotalPackageCount(),
|
||||
inbound.getActualGrossWeightKg(),
|
||||
measurement == null ? inbound.getActualVolumeCbm() : measurement.getTotalVolumeCbm(),
|
||||
@@ -300,7 +330,25 @@ public class InboundMeasurementService {
|
||||
item.getUnitWeightKg(), item.getUnitVolumeCbm(), item.getTotalWeightKg(),
|
||||
item.getTotalVolumeCbm(), item.getVolumeWeightKg(), item.getSourceImageFileId(),
|
||||
item.getOverLimitWarning(), item.getOuterLengthCm(), item.getOuterWidthCm(),
|
||||
item.getOuterHeightCm(), item.getRemark());
|
||||
item.getOuterHeightCm(), item.getRemark(), item.getBoxCode(), item.getPackageCount(),
|
||||
item.getMachineNo(), item.getDeviceMeasuredAt(), item.getUpdatedAt());
|
||||
}
|
||||
|
||||
private void requireConfirmedPackageTotal(InboundOrderEntity inbound,
|
||||
List<InboundMeasurementItemEntity> items) {
|
||||
if (inbound.getTotalPackageCount() == null || inbound.getTotalPackageCount() < 1) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED, "入库单原始总箱数无效,不能提交测量");
|
||||
}
|
||||
long confirmed = items.stream()
|
||||
.map(InboundMeasurementItemEntity::getPackageCount)
|
||||
.filter(java.util.Objects::nonNull)
|
||||
.mapToLong(Integer::longValue)
|
||||
.sum();
|
||||
if (confirmed != inbound.getTotalPackageCount()) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED,
|
||||
"人工确认箱数合计" + confirmed + "箱,与入库单总箱数"
|
||||
+ inbound.getTotalPackageCount() + "箱不一致");
|
||||
}
|
||||
}
|
||||
|
||||
private InboundMeasurementResponses.WeightResult toWeight(InboundWeightResultEntity weight) {
|
||||
@@ -370,6 +418,17 @@ public class InboundMeasurementService {
|
||||
return unit.trim().toUpperCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private BigDecimal normalizeConfidence(BigDecimal confidence) {
|
||||
if (confidence == null) {
|
||||
return null;
|
||||
}
|
||||
if (confidence.compareTo(BigDecimal.ZERO) < 0
|
||||
|| confidence.compareTo(BigDecimal.ONE) > 0) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED, "OCR 置信度必须在 0 到 1 之间");
|
||||
}
|
||||
return confidence.setScale(5, RoundingMode.HALF_UP);
|
||||
}
|
||||
|
||||
private boolean isSupportedUnit(String unit) {
|
||||
if (!StringUtils.hasText(unit)) {
|
||||
return false;
|
||||
@@ -396,6 +455,15 @@ public class InboundMeasurementService {
|
||||
return kg.setScale(3, RoundingMode.HALF_UP);
|
||||
}
|
||||
|
||||
private BigDecimal requireConfirmableKg(BigDecimal value, String unit) {
|
||||
BigDecimal finalKg = toKg(value, normalizeUnit(unit));
|
||||
if (!isPositive(finalKg)) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED,
|
||||
"换算后重量不能小于 0.001 KG");
|
||||
}
|
||||
return finalKg;
|
||||
}
|
||||
|
||||
private BigDecimal effectiveValue(InboundWeightResultEntity weight) {
|
||||
return weight.getAdjustedWeightValue() == null
|
||||
? weight.getOcrWeightValue() : weight.getAdjustedWeightValue();
|
||||
|
||||
+32
-5
@@ -3,12 +3,15 @@ package com.baiyue.firstleg.service;
|
||||
import jakarta.annotation.Resource;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.dto.OrderApprovedInboundCommand;
|
||||
import com.baiyue.firstleg.dto.InboundOrderReference;
|
||||
import com.baiyue.firstleg.entity.InboundOrderEntity;
|
||||
import com.baiyue.firstleg.entity.InboundStatusHistoryEntity;
|
||||
import com.baiyue.firstleg.mapper.InboundOrderMapper;
|
||||
import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@@ -25,11 +28,9 @@ public class InboundOrderCreationService {
|
||||
|
||||
@Transactional
|
||||
public long createFromApprovedOrder(OrderApprovedInboundCommand command) {
|
||||
InboundOrderEntity existing = orders.selectOne(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<InboundOrderEntity>()
|
||||
.eq(InboundOrderEntity::getTenantId, command.tenantId())
|
||||
.eq(InboundOrderEntity::getOrderId, command.orderId()));
|
||||
InboundOrderEntity existing = findByOrder(command.tenantId(), command.orderId(), false);
|
||||
if (existing != null) {
|
||||
return existing.getId();
|
||||
return requireMatchingInboundCode(existing, command.inboundCode());
|
||||
}
|
||||
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
@@ -58,6 +59,7 @@ public class InboundOrderCreationService {
|
||||
inbound.setCarrierId(command.carrierId());
|
||||
inbound.setCarrierName(command.carrierName());
|
||||
inbound.setTaxMode(command.taxMode());
|
||||
inbound.setPricingTaxMode(command.pricingTaxMode());
|
||||
inbound.setContainerMode(command.containerMode());
|
||||
inbound.setCustomsInspection(command.customsInspection());
|
||||
inbound.setDestinationType(command.destinationType());
|
||||
@@ -73,7 +75,15 @@ public class InboundOrderCreationService {
|
||||
inbound.setUpdatedAt(now);
|
||||
inbound.setDeleted(false);
|
||||
inbound.setVersion(0);
|
||||
orders.insert(inbound);
|
||||
try {
|
||||
orders.insert(inbound);
|
||||
} catch (DuplicateKeyException duplicate) {
|
||||
InboundOrderEntity winner = findByOrder(command.tenantId(), command.orderId(), true);
|
||||
if (winner == null) {
|
||||
throw new BizException(ResultCode.CONFLICT, "入仓码已被其他订单使用");
|
||||
}
|
||||
return requireMatchingInboundCode(winner, command.inboundCode());
|
||||
}
|
||||
|
||||
InboundStatusHistoryEntity history = new InboundStatusHistoryEntity();
|
||||
history.setId(IdWorker.getId());
|
||||
@@ -88,6 +98,23 @@ public class InboundOrderCreationService {
|
||||
return inbound.getId();
|
||||
}
|
||||
|
||||
private InboundOrderEntity findByOrder(long tenantId, long orderId, boolean currentRead) {
|
||||
var query = new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<InboundOrderEntity>()
|
||||
.eq(InboundOrderEntity::getTenantId, tenantId)
|
||||
.eq(InboundOrderEntity::getOrderId, orderId);
|
||||
if (currentRead) {
|
||||
query.last("FOR UPDATE");
|
||||
}
|
||||
return orders.selectOne(query);
|
||||
}
|
||||
|
||||
private long requireMatchingInboundCode(InboundOrderEntity existing, String inboundCode) {
|
||||
if (!java.util.Objects.equals(existing.getInboundCode(), inboundCode)) {
|
||||
throw new BizException(ResultCode.CONFLICT, "订单入仓码与已有入库单不一致");
|
||||
}
|
||||
return existing.getId();
|
||||
}
|
||||
|
||||
public List<InboundOrderReference.Item> findReferences(long tenantId, List<Long> orderIds) {
|
||||
if (orderIds.isEmpty()) return List.of();
|
||||
return orders.selectList(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<InboundOrderEntity>()
|
||||
|
||||
@@ -68,6 +68,17 @@ public class MeasurementReviewService {
|
||||
if (!"SUBMITTED".equals(measurement.getStatus())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "测量批次不是待审核状态");
|
||||
}
|
||||
if (approved) {
|
||||
long confirmed = repository.listMeasurementItems(tenantId, measurement.getId()).stream()
|
||||
.map(com.baiyue.firstleg.entity.InboundMeasurementItemEntity::getPackageCount)
|
||||
.filter(java.util.Objects::nonNull)
|
||||
.mapToLong(Integer::longValue)
|
||||
.sum();
|
||||
if (order.getTotalPackageCount() == null || confirmed != order.getTotalPackageCount()) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED,
|
||||
"人工确认箱数合计与入库单总箱数不一致,不能审核通过");
|
||||
}
|
||||
}
|
||||
|
||||
if (repository.transitionReview(tenantId, inboundId, operatorId,
|
||||
request.expectedVersion(), approved) != 1) {
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
package com.baiyue.firstleg.service;
|
||||
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.domain.PriceModel;
|
||||
import com.baiyue.firstleg.domain.PricingTaxMode;
|
||||
import com.baiyue.firstleg.dto.PriceBookDtos.CurrentChannel;
|
||||
import com.baiyue.firstleg.dto.PriceBookDtos.EstimateRequest;
|
||||
import com.baiyue.firstleg.dto.PriceBookDtos.EstimateResponse;
|
||||
import com.baiyue.firstleg.pricing.ActivePriceBookEstimator;
|
||||
import com.baiyue.firstleg.repository.PriceBookQueryRepository;
|
||||
import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelRow;
|
||||
import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelTaxModeRow;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.util.EnumSet;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
@Service
|
||||
public class MerchantPriceBookService {
|
||||
public static final String DISCLAIMER = "仅供参考,不含附加费,最终以实际结算为准";
|
||||
|
||||
@Resource private PriceBookQueryRepository repository;
|
||||
@Resource private ActivePriceBookEstimator estimator;
|
||||
|
||||
public List<CurrentChannel> channels(long merchantTenantId, long originWarehouseId) {
|
||||
requireMerchant(merchantTenantId);
|
||||
if (originWarehouseId <= 0) throw validation("请选择有效起运仓");
|
||||
Long versionId = repository.activeVersionId();
|
||||
if (versionId == null) return List.of();
|
||||
List<ChannelRow> channels = repository.channels(versionId, originWarehouseId);
|
||||
if (channels.isEmpty()) return List.of();
|
||||
Map<String, List<PricingTaxMode>> taxModes = pricingTaxModes(repository.channelTaxModes(versionId, originWarehouseId));
|
||||
return channels.stream().map(channel -> toChannel(channel, taxModes)).toList();
|
||||
}
|
||||
|
||||
public EstimateResponse estimate(long merchantTenantId, EstimateRequest request) {
|
||||
requireMerchant(merchantTenantId);
|
||||
ActivePriceBookEstimator.Estimate estimate = estimator.estimate(request);
|
||||
return new EstimateResponse(estimate.priceBookVersionId(), estimate.channelCode(), estimate.priceModel(),
|
||||
estimate.pricingTaxMode(), estimate.chargeableQuantity(), estimate.chargeUnit(), estimate.unitPrice(),
|
||||
estimate.amount(), estimate.currency(), DISCLAIMER);
|
||||
}
|
||||
|
||||
public void requireAvailableChannel(long merchantTenantId, long originWarehouseId, String channelCode) {
|
||||
requireMerchant(merchantTenantId);
|
||||
if (originWarehouseId <= 0) throw validation("请选择有效起运仓");
|
||||
Long versionId = repository.activeVersionId();
|
||||
if (versionId == null || !repository.channelAvailable(versionId, originWarehouseId,
|
||||
normalizeChannel(channelCode))) {
|
||||
throw new BizException(ResultCode.CONFLICT, "所选渠道已下线,请重新选择");
|
||||
}
|
||||
}
|
||||
|
||||
private CurrentChannel toChannel(ChannelRow row, Map<String, List<PricingTaxMode>> taxModes) {
|
||||
String channelCode = normalizeChannel(row.getChannelCode());
|
||||
return new CurrentChannel(row.getPriceBookVersionId(), row.getChannelCode(), row.getChannelName(),
|
||||
priceModel(row.getPriceModel()), taxModes.getOrDefault(channelCode, List.of()),
|
||||
row.getEstimatedDepartureDate(), row.getEstimatedDepartureText(),
|
||||
row.getLatestWarehouseEntry(), row.getReferenceTransit(), row.getSurchargeDescription(), row.getRemark());
|
||||
}
|
||||
|
||||
private static Map<String, List<PricingTaxMode>> pricingTaxModes(List<ChannelTaxModeRow> rows) {
|
||||
Map<String, EnumSet<PricingTaxMode>> values = new HashMap<>();
|
||||
for (ChannelTaxModeRow row : rows) {
|
||||
if (row == null) continue;
|
||||
String channelCode = normalizeChannel(row.getChannelCode());
|
||||
try {
|
||||
values.computeIfAbsent(channelCode, ignored -> EnumSet.noneOf(PricingTaxMode.class))
|
||||
.add(PricingTaxMode.valueOf(row.getPricingTaxMode()));
|
||||
} catch (RuntimeException exception) {
|
||||
throw new BizException(ResultCode.CONFLICT, "渠道报价包税方式配置无效");
|
||||
}
|
||||
}
|
||||
Map<String, List<PricingTaxMode>> result = new HashMap<>();
|
||||
values.forEach((channelCode, modes) -> result.put(channelCode, List.copyOf(modes)));
|
||||
return result;
|
||||
}
|
||||
|
||||
private static PriceModel priceModel(String value) {
|
||||
try {
|
||||
return PriceModel.valueOf(value);
|
||||
} catch (RuntimeException exception) {
|
||||
throw new BizException(ResultCode.CONFLICT, "渠道价格模型配置无效");
|
||||
}
|
||||
}
|
||||
|
||||
private static String normalizeChannel(String value) {
|
||||
if (!StringUtils.hasText(value)) throw validation("请选择有效渠道");
|
||||
return value.trim().toUpperCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private static void requireMerchant(long merchantTenantId) {
|
||||
if (merchantTenantId <= 0) throw new BizException(ResultCode.FORBIDDEN, "仅允许客户端访问");
|
||||
}
|
||||
|
||||
private static BizException validation(String message) {
|
||||
return new BizException(ResultCode.VALIDATION_FAILED, message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,603 @@
|
||||
package com.baiyue.firstleg.service;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.client.PriceBookFileClient;
|
||||
import com.baiyue.firstleg.client.PriceBookWarehouseClient;
|
||||
import com.baiyue.firstleg.domain.PriceBookStatus;
|
||||
import com.baiyue.firstleg.domain.PriceModel;
|
||||
import com.baiyue.firstleg.domain.PriceRegion;
|
||||
import com.baiyue.firstleg.domain.PricingDestinationType;
|
||||
import com.baiyue.firstleg.domain.PricingTaxMode;
|
||||
import com.baiyue.firstleg.dto.PriceBookConfirmationRequest;
|
||||
import com.baiyue.firstleg.dto.PriceBookDtos;
|
||||
import com.baiyue.firstleg.entity.CarrierFbaRateEntity;
|
||||
import com.baiyue.firstleg.entity.PriceBookChannelEntity;
|
||||
import com.baiyue.firstleg.entity.PriceBookVersionEntity;
|
||||
import com.baiyue.firstleg.entity.PriceChannelCatalogEntity;
|
||||
import com.baiyue.firstleg.entity.PriceWarehouseMappingEntity;
|
||||
import com.baiyue.firstleg.pricing.importer.PriceWorkbookParseResult;
|
||||
import com.baiyue.firstleg.pricing.importer.PriceWorkbookParser;
|
||||
import com.baiyue.firstleg.repository.PriceBookRepository;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import feign.FeignException;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
@Service
|
||||
public class PlatformPriceBookService {
|
||||
private static final String PENDING_RATE_STATUS = PriceBookStatus.PENDING_CONFIRMATION.name();
|
||||
private static final BigDecimal ZERO = BigDecimal.ZERO;
|
||||
private static final TypeReference<List<PriceBookDtos.ParseIssue>> ISSUE_LIST = new TypeReference<>() {};
|
||||
private static final Set<String> REVALIDATED_ISSUE_CODES = Set.of(
|
||||
"NO_CHANNELS", "NO_RATES", "DUPLICATE_CHANNEL", "CHANNEL_NOT_MAPPED", "CHANNEL_MODEL_MISMATCH",
|
||||
"RATE_CHANNEL_MISSING", "RATE_MODEL_MISMATCH", "PRICE_NOT_POSITIVE", "SOURCE_LOCATION_MISSING",
|
||||
"ORIGIN_MAPPING_REQUIRED", "ORIGIN_WAREHOUSE_INVALID", "OVERSEAS_MAPPING_REQUIRED",
|
||||
"OVERSEAS_WAREHOUSE_INVALID", "WAREHOUSE_VALIDATION_FAILED", "REGION_TIER_INVALID",
|
||||
"PRICE_MODEL_REQUIRED", "DESTINATION_TYPE_REQUIRED", "PRICING_TAX_MODE_REQUIRED", "FBA_CODE_REQUIRED",
|
||||
"DUPLICATE_RATE", "DUPLICATE_ORIGIN_MAPPING", "DUPLICATE_OVERSEAS_MAPPING",
|
||||
"UNUSED_ORIGIN_MAPPING", "UNUSED_OVERSEAS_MAPPING", "DUPLICATE_WAREHOUSE_MAPPING"
|
||||
);
|
||||
|
||||
@Resource private PriceBookRepository repository;
|
||||
@Resource private PriceBookFileClient files;
|
||||
@Resource private PriceBookWarehouseClient warehouses;
|
||||
@Resource private ObjectMapper objectMapper;
|
||||
@Resource private TransactionTemplate transactions;
|
||||
@Value("${baiyue.security.internal-service-token}") private String internalToken;
|
||||
|
||||
public List<PriceBookDtos.PriceBookVersionSummary> list(PriceBookStatus status) {
|
||||
String persistedStatus = status == null ? null : status.name();
|
||||
return repository.listVersions(persistedStatus).stream().map(this::summary).toList();
|
||||
}
|
||||
|
||||
public PriceBookDtos.PriceBookVersionDetail upload(long operatorId, MultipartFile file) {
|
||||
requireFile(file);
|
||||
PriceBookVersionEntity version = transactions.execute(status -> createVersion(operatorId, file.getOriginalFilename()));
|
||||
if (version == null) throw internal("价格版本创建失败");
|
||||
Path temp = null;
|
||||
try {
|
||||
ApiResponse<PriceBookFileClient.FileMetadata> response = files.upload(internalToken, operatorId, version.getId(), file);
|
||||
if (response == null || !response.successful() || response.data() == null) {
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "价格表原文件保存失败");
|
||||
}
|
||||
var metadata = response.data();
|
||||
if (!Objects.equals(metadata.businessId(), version.getId())
|
||||
|| !Objects.equals(metadata.businessType(), "FBA_PRICE_BOOK_SOURCE")) {
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "价格表原文件归属校验失败");
|
||||
}
|
||||
version.setSourceFileId(metadata.fileId());
|
||||
version.setSourceSha256(metadata.sha256());
|
||||
if (metadata.fileId() == null || metadata.fileId() <= 0) {
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "价格表原文件标识无效");
|
||||
}
|
||||
ResponseEntity<byte[]> stored = files.download(internalToken, operatorId, version.getId(), metadata.fileId());
|
||||
if (stored == null || stored.getBody() == null || stored.getBody().length == 0) {
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "价格表原文件读取失败");
|
||||
}
|
||||
temp = Files.createTempFile("price-book-" + version.getId() + "-", ".xlsx");
|
||||
Files.write(temp, stored.getBody());
|
||||
PriceWorkbookParseResult parsed = new PriceWorkbookParser().parse(temp);
|
||||
ParsedPriceBook parsedPriceBook = parsedPreview(parsed);
|
||||
transactions.executeWithoutResult(status -> persistParsedPreview(version, parsedPriceBook.channels(), parsedPriceBook.rates(),
|
||||
mergeIssues(parsedPriceBook.issues(), validate(parsedPriceBook.channels(), parsedPriceBook.rates())), operatorId));
|
||||
return detail(version.getId());
|
||||
} catch (Exception exception) {
|
||||
List<PriceBookDtos.ParseIssue> issues = List.of(issue("BLOCKING", "UPLOAD_OR_PARSE_FAILED",
|
||||
safeMessage(exception, "价格表上传或解析失败"), null, null, null));
|
||||
markFailed(version, issues, operatorId);
|
||||
if (exception instanceof BizException biz) throw biz;
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED, safeMessage(exception, "价格表解析失败"));
|
||||
} finally {
|
||||
if (temp != null) try { Files.deleteIfExists(temp); } catch (IOException ignored) { }
|
||||
}
|
||||
}
|
||||
|
||||
public PriceBookDtos.PriceBookVersionDetail detail(long versionId) {
|
||||
PriceBookVersionEntity version = requireVersion(versionId);
|
||||
List<PriceBookChannelEntity> channelRows = repository.listChannels(versionId);
|
||||
List<PriceBookDtos.ParseIssue> issues = new ArrayList<>(readIssues(version.getParseIssuesJson()));
|
||||
if (PriceBookStatus.PENDING_CONFIRMATION.name().equals(version.getStatus())) {
|
||||
PriceBookVersionEntity active = repository.findActive();
|
||||
if (active != null && !Objects.equals(active.getId(), versionId)) {
|
||||
Set<String> current = channelRows.stream().map(value -> normalizeChannel(value.getChannelCode())).collect(java.util.stream.Collectors.toSet());
|
||||
repository.listChannels(active.getId()).stream()
|
||||
.filter(value -> !current.contains(normalizeChannel(value.getChannelCode())))
|
||||
.forEach(value -> issues.add(issue("WARNING", "CHANNEL_MISSING_FROM_PENDING_UPLOAD",
|
||||
"待确认上传记录未包含上一生效版本渠道,确认生效后该渠道将下线", value.getSourceSheetName(), null, null)));
|
||||
}
|
||||
}
|
||||
return new PriceBookDtos.PriceBookVersionDetail(summary(version),
|
||||
channelRows.stream().map(this::channelDto).toList(),
|
||||
repository.listRates(versionId).stream().map(this::rateDto).toList(), issues);
|
||||
}
|
||||
|
||||
@Transactional(noRollbackFor = BizException.class)
|
||||
public PriceBookDtos.PriceBookVersionDetail publish(long versionId, long operatorId,
|
||||
PriceBookConfirmationRequest confirmation) {
|
||||
repository.lockPlatformVersions();
|
||||
PriceBookVersionEntity version = requirePendingConfirmation(versionId);
|
||||
List<PriceBookDtos.ParsedChannel> channels = repository.listChannels(versionId).stream().map(this::channelDto).toList();
|
||||
List<PriceBookDtos.ParsedRate> rates = repository.listRates(versionId).stream().map(this::rateDto).toList();
|
||||
MappingResult mapped = applyMappings(rates, confirmation);
|
||||
List<PriceBookDtos.ParseIssue> issues = mergeIssues(
|
||||
mergeIssues(preservedParseIssues(version), mapped.issues()),
|
||||
validate(channels, mapped.rates()));
|
||||
applyIssueCounts(version, issues);
|
||||
if (version.getBlockingErrorCount() > 0) {
|
||||
repository.updateVersion(version);
|
||||
throw new BizException(ResultCode.CONFLICT, "价格表仍有阻断错误,不能生效");
|
||||
}
|
||||
repository.deletePendingChildren(versionId);
|
||||
persistChildren(version, channels, mapped.rates(), operatorId);
|
||||
version.setChannelCount(channels.size());
|
||||
version.setRateCount(mapped.rates().size());
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
repository.listActiveForUpdate().stream()
|
||||
.filter(active -> !Objects.equals(active.getId(), versionId))
|
||||
.forEach(active -> repository.supersede(active, now, operatorId));
|
||||
repository.activate(version, now, operatorId);
|
||||
return detail(versionId);
|
||||
}
|
||||
|
||||
public ResponseEntity<byte[]> source(long versionId, long operatorId) {
|
||||
PriceBookVersionEntity version = requireVersion(versionId);
|
||||
if (version.getSourceFileId() == null) throw new BizException(ResultCode.NOT_FOUND, "价格表原文件不存在");
|
||||
try {
|
||||
ResponseEntity<byte[]> response = files.download(internalToken, operatorId, versionId, version.getSourceFileId());
|
||||
if (response == null || response.getBody() == null) throw new BizException(ResultCode.NOT_FOUND, "价格表原文件不存在");
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.putAll(response.getHeaders());
|
||||
headers.set(HttpHeaders.CONTENT_DISPOSITION, org.springframework.http.ContentDisposition.attachment()
|
||||
.filename(version.getOriginalFileName(), java.nio.charset.StandardCharsets.UTF_8).build().toString());
|
||||
headers.set("X-Content-Type-Options", "nosniff");
|
||||
return new ResponseEntity<>(response.getBody(), headers, response.getStatusCode());
|
||||
} catch (FeignException exception) {
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "价格表原文件服务暂时不可用");
|
||||
}
|
||||
}
|
||||
|
||||
private PriceBookVersionEntity createVersion(long operatorId, String originalFileName) {
|
||||
PriceBookVersionEntity value = new PriceBookVersionEntity();
|
||||
value.setTenantId(PriceBookVersionEntity.PLATFORM_SCOPE);
|
||||
value.setVersionNo(repository.nextVersionNo());
|
||||
value.setOriginalFileName(safeFileName(originalFileName));
|
||||
value.setStatus(PriceBookStatus.PENDING_CONFIRMATION.name());
|
||||
value.setBlockingErrorCount(0); value.setWarningCount(0); value.setChannelCount(0); value.setRateCount(0);
|
||||
value.setCreatedBy(operatorId); value.setUpdatedBy(operatorId); value.setDeleted(false); value.setVersion(0);
|
||||
if (repository.insertVersion(value) != 1) throw internal("价格版本创建失败");
|
||||
return value;
|
||||
}
|
||||
|
||||
private void persistParsedPreview(PriceBookVersionEntity version, List<PriceBookDtos.ParsedChannel> channels,
|
||||
List<PriceBookDtos.ParsedRate> rates, List<PriceBookDtos.ParseIssue> issues,
|
||||
long operatorId) {
|
||||
repository.deletePendingChildren(version.getId());
|
||||
persistChildren(version, channels, rates, operatorId);
|
||||
version.setStatus(PriceBookStatus.PENDING_CONFIRMATION.name());
|
||||
version.setChannelCount(channels.size()); version.setRateCount(rates.size());
|
||||
applyIssueCounts(version, issues);
|
||||
version.setUpdatedBy(operatorId);
|
||||
repository.updateVersion(version);
|
||||
}
|
||||
|
||||
private void persistChildren(PriceBookVersionEntity version, List<PriceBookDtos.ParsedChannel> channels,
|
||||
List<PriceBookDtos.ParsedRate> rates, long operatorId) {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
for (PriceBookDtos.ParsedChannel parsedChannel : channels) {
|
||||
PriceBookChannelEntity value = new PriceBookChannelEntity();
|
||||
value.setPriceBookVersionId(version.getId()); value.setChannelCode(normalizeChannel(parsedChannel.channelCode()));
|
||||
value.setChannelName(parsedChannel.channelName().trim()); value.setPriceModel(parsedChannel.priceModel().name());
|
||||
value.setSourceSheetName(parsedChannel.sourceSheetName().trim()); value.setEstimatedDepartureDate(parsedChannel.estimatedDepartureDate());
|
||||
value.setEstimatedDepartureText(trim(parsedChannel.estimatedDepartureText())); value.setLatestWarehouseEntry(trim(parsedChannel.latestWarehouseEntry()));
|
||||
value.setVesselName(trim(parsedChannel.vesselName())); value.setVoyageNo(trim(parsedChannel.voyageNo()));
|
||||
value.setReferenceTransit(trim(parsedChannel.referenceTransit())); value.setSurchargeDescription(trim(parsedChannel.surchargeDescription()));
|
||||
value.setRemark(trim(parsedChannel.remark())); value.setCreatedBy(operatorId); value.setUpdatedBy(operatorId);
|
||||
value.setCreatedAt(now); value.setUpdatedAt(now); value.setDeleted(false); value.setVersion(0);
|
||||
repository.insertChannel(value);
|
||||
}
|
||||
int number = 1;
|
||||
for (PriceBookDtos.ParsedRate parsedRate : rates) repository.insertRate(rateEntity(version, parsedRate, operatorId, number++, now));
|
||||
}
|
||||
|
||||
private CarrierFbaRateEntity rateEntity(PriceBookVersionEntity version, PriceBookDtos.ParsedRate parsedRate,
|
||||
long operatorId, int number, LocalDateTime now) {
|
||||
CarrierFbaRateEntity value = new CarrierFbaRateEntity();
|
||||
value.setTenantId(CarrierFbaRateEntity.PLATFORM_SCOPE); value.setPriceBookVersionId(version.getId());
|
||||
value.setRateCode("PB-" + version.getVersionNo() + "-" + number); value.setRateVersion(1);
|
||||
value.setOriginWarehouseId(parsedRate.originWarehouseId()); value.setOriginWarehouseCode(parsedRate.originWarehouseCode().trim());
|
||||
value.setOriginWarehouseName(parsedRate.originWarehouseName().trim()); value.setBusinessType("FBA_SEA");
|
||||
value.setChannelCode(normalizeChannel(parsedRate.channelCode())); value.setPriceModel(parsedRate.priceModel().name());
|
||||
value.setCarrierName(parsedRate.channelCode());
|
||||
value.setDestinationType(parsedRate.destinationType() == null ? null : parsedRate.destinationType().name());
|
||||
value.setDestinationCode(upper(parsedRate.destinationCode())); value.setDestinationWarehouseId(parsedRate.destinationWarehouseId());
|
||||
value.setDestinationName(trim(parsedRate.destinationName())); value.setRegionCode(parsedRate.regionCode() == null ? null : parsedRate.regionCode().name());
|
||||
value.setPricingTaxMode(parsedRate.pricingTaxMode() == null ? null : parsedRate.pricingTaxMode().name());
|
||||
value.setWeightFromKg(parsedRate.weightFromKg()); value.setChargeUnit(parsedRate.chargeUnit()); value.setUnitPrice(parsedRate.unitPrice());
|
||||
value.setCurrency(parsedRate.currency()); value.setAdditionalFee(ZERO); value.setSourceSheetName(parsedRate.sourceSheetName());
|
||||
value.setSourceRowNumber(parsedRate.sourceRowNumber()); value.setSourceDestinationText(trim(parsedRate.sourceDestinationText()));
|
||||
value.setEffectiveFrom(now); value.setStatus(PENDING_RATE_STATUS); value.setRemark(trim(parsedRate.remark()));
|
||||
value.setCreatedBy(operatorId); value.setUpdatedBy(operatorId); value.setCreatedAt(now); value.setUpdatedAt(now);
|
||||
value.setDeleted(false); value.setVersion(0);
|
||||
return value;
|
||||
}
|
||||
|
||||
private ParsedPriceBook parsedPreview(PriceWorkbookParseResult parsed) {
|
||||
List<PriceBookDtos.ParsedChannel> channels = new ArrayList<>();
|
||||
List<PriceBookDtos.ParsedRate> rates = new ArrayList<>();
|
||||
for (var channel : parsed.channels()) {
|
||||
String code = normalizeChannel(channel.channelCode());
|
||||
var sailing = channel.sailings().isEmpty() ? null : channel.sailings().get(0);
|
||||
channels.add(new PriceBookDtos.ParsedChannel(code, channel.channelName(), channel.priceModel(),
|
||||
channel.sourceSheet(), sailing == null ? null : sailing.expectedDepartureDate(),
|
||||
sailing == null ? channel.scheduleText() : sailing.rawText(), channel.cutoffText(),
|
||||
sailing == null ? null : sailing.vesselName(), sailing == null ? null : sailing.voyageNumber(),
|
||||
channel.transitTimeText(), channel.surchargeText(), channel.remarkText()));
|
||||
for (var rate : channel.rates()) rates.add(parsedRate(code, channel.priceModel(), rate));
|
||||
}
|
||||
List<PriceBookDtos.ParseIssue> issues = parsed.issues().stream().map(value -> issue(
|
||||
value.severity() == PriceWorkbookParseResult.Severity.ERROR ? "BLOCKING" : "WARNING",
|
||||
value.code(), value.message(), value.sheetName(), value.rowNumber(), null)).toList();
|
||||
return new ParsedPriceBook(channels, applyInitializedWarehouseMappings(rates), issues);
|
||||
}
|
||||
|
||||
private List<PriceBookDtos.ParsedRate> applyInitializedWarehouseMappings(List<PriceBookDtos.ParsedRate> rates) {
|
||||
Map<String, PriceWarehouseMappingEntity> origins = new HashMap<>();
|
||||
Map<String, PriceWarehouseMappingEntity> overseas = new HashMap<>();
|
||||
for (PriceWarehouseMappingEntity mapping : repository.activeWarehouseMappings()) {
|
||||
if (mapping == null || mapping.getWarehouseId() == null || mapping.getWarehouseId() <= 0) continue;
|
||||
if ("ORIGIN_CODE".equals(mapping.getSourceType())
|
||||
&& "DOMESTIC".equals(mapping.getWarehouseType())
|
||||
&& mappingKey(mapping.getSourceValue()).equals(mappingKey(mapping.getWarehouseCode()))) {
|
||||
origins.putIfAbsent(mappingKey(mapping.getSourceValue()), mapping);
|
||||
} else if ("OVERSEAS_NAME".equals(mapping.getSourceType())
|
||||
&& "OVERSEAS".equals(mapping.getWarehouseType())
|
||||
&& StringUtils.hasText(mapping.getWarehouseCode())) {
|
||||
overseas.putIfAbsent(mappingKey(mapping.getSourceValue()), mapping);
|
||||
}
|
||||
}
|
||||
return rates.stream().map(rate -> {
|
||||
PriceWarehouseMappingEntity origin = origins.get(mappingKey(rate.originWarehouseCode()));
|
||||
PriceWarehouseMappingEntity destination = rate.destinationType() == PricingDestinationType.OVERSEAS
|
||||
? overseas.get(mappingKey(rate.destinationName())) : null;
|
||||
return new PriceBookDtos.ParsedRate(rate.id(), rate.channelCode(), rate.priceModel(),
|
||||
origin == null ? rate.originWarehouseId() : origin.getWarehouseId(),
|
||||
rate.originWarehouseCode(), rate.originWarehouseName(),
|
||||
destination == null ? rate.destinationCode() : mappingKey(destination.getWarehouseCode()), rate.destinationType(),
|
||||
destination == null ? rate.destinationWarehouseId() : destination.getWarehouseId(),
|
||||
rate.destinationName(), rate.regionCode(), rate.pricingTaxMode(), rate.chargeUnit(), rate.weightFromKg(),
|
||||
rate.unitPrice(), rate.currency(), rate.sourceSheetName(), rate.sourceRowNumber(),
|
||||
rate.sourceDestinationText(), rate.remark());
|
||||
}).toList();
|
||||
}
|
||||
|
||||
private PriceBookDtos.ParsedRate parsedRate(String channelCode, PriceModel model,
|
||||
PriceWorkbookParseResult.ParsedRate rate) {
|
||||
PricingDestinationType destinationType = "OVERSEAS".equals(rate.destinationType())
|
||||
? PricingDestinationType.OVERSEAS
|
||||
: model == PriceModel.REGION_WEIGHT_TIER ? null : PricingDestinationType.FBA;
|
||||
PriceRegion region = rate.regionCode() == null ? null : PriceRegion.valueOf(rate.regionCode());
|
||||
String originCode = "YIWU".equals(rate.originWarehouseKey()) ? "CN-YIWU-01"
|
||||
: "DONGGUAN".equals(rate.originWarehouseKey()) ? "CN-DONGGUAN-01" : rate.originWarehouseKey();
|
||||
return new PriceBookDtos.ParsedRate(null, channelCode, model, null, originCode,
|
||||
rate.originWarehouseLabel(), rate.destinationCode(), destinationType, null, rate.destinationName(),
|
||||
region, rate.taxMode(), rate.chargeUnit(), rate.minimumWeightKg(), rate.unitPrice(), "CNY",
|
||||
rate.sourceSheet(), rate.sourceRow(), rate.rawDestinationGroup(), rate.remarkText());
|
||||
}
|
||||
|
||||
private MappingResult applyMappings(List<PriceBookDtos.ParsedRate> rates,
|
||||
PriceBookConfirmationRequest confirmation) {
|
||||
List<PriceBookDtos.ParseIssue> issues = new ArrayList<>();
|
||||
if (confirmation == null) {
|
||||
issues.add(issue("BLOCKING", "ORIGIN_MAPPING_REQUIRED", "请确认起运仓映射", null, null, null));
|
||||
return new MappingResult(rates, issues);
|
||||
}
|
||||
Map<String, Long> origins = mappingIndex(confirmation.originMappings(),
|
||||
PriceBookConfirmationRequest.OriginMapping::originWarehouseCode,
|
||||
PriceBookConfirmationRequest.OriginMapping::warehouseId,
|
||||
"DUPLICATE_ORIGIN_MAPPING", "起运仓映射重复", issues);
|
||||
Map<String, Long> overseas = mappingIndex(confirmation.overseasMappings(),
|
||||
PriceBookConfirmationRequest.OverseasMapping::sourceDestinationName,
|
||||
PriceBookConfirmationRequest.OverseasMapping::warehouseId,
|
||||
"DUPLICATE_OVERSEAS_MAPPING", "海外仓映射重复", issues);
|
||||
Set<String> sourceOrigins = rates.stream().map(PriceBookDtos.ParsedRate::originWarehouseCode)
|
||||
.filter(StringUtils::hasText).map(PlatformPriceBookService::mappingKey).collect(java.util.stream.Collectors.toSet());
|
||||
Set<String> sourceOverseas = rates.stream()
|
||||
.filter(rate -> rate.destinationType() == PricingDestinationType.OVERSEAS)
|
||||
.map(PriceBookDtos.ParsedRate::destinationName).filter(StringUtils::hasText)
|
||||
.map(PlatformPriceBookService::mappingKey).collect(java.util.stream.Collectors.toSet());
|
||||
origins.keySet().stream().filter(key -> !sourceOrigins.contains(key)).forEach(key ->
|
||||
issues.add(issue("BLOCKING", "UNUSED_ORIGIN_MAPPING", "起运仓映射不属于当前价格表", null, null, null)));
|
||||
overseas.keySet().stream().filter(key -> !sourceOverseas.contains(key)).forEach(key ->
|
||||
issues.add(issue("BLOCKING", "UNUSED_OVERSEAS_MAPPING", "海外仓映射不属于当前价格表", null, null, null)));
|
||||
|
||||
List<PriceBookDtos.ParsedRate> mapped = new ArrayList<>(rates.size());
|
||||
for (PriceBookDtos.ParsedRate rate : rates) {
|
||||
Long originId = origins.get(mappingKey(rate.originWarehouseCode()));
|
||||
PricingDestinationType destinationType = rate.priceModel() == PriceModel.REGION_WEIGHT_TIER
|
||||
? null : rate.destinationType();
|
||||
Long destinationId = destinationType == PricingDestinationType.OVERSEAS
|
||||
? overseas.get(mappingKey(rate.destinationName())) : null;
|
||||
mapped.add(new PriceBookDtos.ParsedRate(rate.id(), rate.channelCode(), rate.priceModel(), originId,
|
||||
rate.originWarehouseCode(), rate.originWarehouseName(), rate.destinationCode(), destinationType,
|
||||
destinationId, rate.destinationName(), rate.regionCode(), rate.pricingTaxMode(), rate.chargeUnit(),
|
||||
rate.weightFromKg(), rate.unitPrice(), rate.currency(), rate.sourceSheetName(), rate.sourceRowNumber(),
|
||||
rate.sourceDestinationText(), rate.remark()));
|
||||
}
|
||||
return new MappingResult(List.copyOf(mapped), List.copyOf(issues));
|
||||
}
|
||||
|
||||
private <T> Map<String, Long> mappingIndex(List<T> mappings,
|
||||
java.util.function.Function<T, String> key,
|
||||
java.util.function.Function<T, Long> warehouseId,
|
||||
String duplicateCode, String duplicateMessage,
|
||||
List<PriceBookDtos.ParseIssue> issues) {
|
||||
Map<String, Long> values = new HashMap<>();
|
||||
Map<Long, String> warehouseKeys = new HashMap<>();
|
||||
if (mappings == null) return values;
|
||||
for (T mapping : mappings) {
|
||||
if (mapping == null) continue;
|
||||
String normalized = mappingKey(key.apply(mapping));
|
||||
if (!StringUtils.hasText(normalized) || warehouseId.apply(mapping) == null || warehouseId.apply(mapping) <= 0) {
|
||||
continue;
|
||||
}
|
||||
Long id = warehouseId.apply(mapping);
|
||||
if (values.putIfAbsent(normalized, id) != null) {
|
||||
issues.add(issue("BLOCKING", duplicateCode, duplicateMessage, null, null, null));
|
||||
}
|
||||
String previousKey = warehouseKeys.putIfAbsent(id, normalized);
|
||||
if (previousKey != null && !previousKey.equals(normalized)) {
|
||||
issues.add(issue("BLOCKING", "DUPLICATE_WAREHOUSE_MAPPING",
|
||||
"不同来源仓不能映射到同一平台仓", null, null, null));
|
||||
}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
private List<PriceBookDtos.ParseIssue> preservedParseIssues(PriceBookVersionEntity version) {
|
||||
return readIssues(version.getParseIssuesJson()).stream()
|
||||
.filter(issue -> !REVALIDATED_ISSUE_CODES.contains(issue.issueCode())).toList();
|
||||
}
|
||||
|
||||
private List<PriceBookDtos.ParseIssue> validate(List<PriceBookDtos.ParsedChannel> channels,
|
||||
List<PriceBookDtos.ParsedRate> rates) {
|
||||
List<PriceBookDtos.ParseIssue> issues = new ArrayList<>();
|
||||
if (channels == null || channels.isEmpty()) {
|
||||
issues.add(issue("BLOCKING", "NO_CHANNELS", "价格表未解析出可用渠道", null, null, null));
|
||||
return issues;
|
||||
}
|
||||
if (rates == null || rates.isEmpty()) {
|
||||
issues.add(issue("BLOCKING", "NO_RATES", "价格表未解析出价格行", null, null, null));
|
||||
return issues;
|
||||
}
|
||||
Map<String, PriceChannelCatalogEntity> catalog = new HashMap<>();
|
||||
repository.activeCatalog().forEach(value -> catalog.put(value.getChannelCode(), value));
|
||||
Set<String> channelCodes = new HashSet<>();
|
||||
for (var channel : channels) {
|
||||
String code = normalizeChannel(channel.channelCode());
|
||||
if (!channelCodes.add(code)) issues.add(issue("BLOCKING", "DUPLICATE_CHANNEL", "渠道重复", channel.sourceSheetName(), null, null));
|
||||
PriceChannelCatalogEntity definition = catalog.get(code);
|
||||
if (definition == null) issues.add(issue("BLOCKING", "CHANNEL_NOT_MAPPED", "渠道未映射到标准目录", channel.sourceSheetName(), null, null));
|
||||
else if (channel.priceModel() == null || !definition.getPriceModel().equals(channel.priceModel().name()))
|
||||
issues.add(issue("BLOCKING", "CHANNEL_MODEL_MISMATCH", "渠道价格模型与目录不一致", channel.sourceSheetName(), null, null));
|
||||
}
|
||||
Set<String> unique = new HashSet<>();
|
||||
Map<Long, PriceBookWarehouseClient.Warehouse> warehouseCache = new HashMap<>();
|
||||
for (var rate : rates) {
|
||||
String code = normalizeChannel(rate.channelCode());
|
||||
if (!channelCodes.contains(code)) issues.add(issue("BLOCKING", "RATE_CHANNEL_MISSING", "价格行没有对应渠道", rate.sourceSheetName(), rate.sourceRowNumber(), null));
|
||||
PriceChannelCatalogEntity persistedDefinition = catalog.get(code);
|
||||
if (persistedDefinition != null && rate.priceModel() != null
|
||||
&& !persistedDefinition.getPriceModel().equals(rate.priceModel().name())) {
|
||||
issues.add(issue("BLOCKING", "RATE_MODEL_MISMATCH", "价格行模型与渠道目录不一致",
|
||||
rate.sourceSheetName(), rate.sourceRowNumber(), null));
|
||||
}
|
||||
if (rate.priceModel() == null) {
|
||||
issues.add(issue("BLOCKING", "PRICE_MODEL_REQUIRED", "价格行必须选择价格模型",
|
||||
rate.sourceSheetName(), rate.sourceRowNumber(), null));
|
||||
}
|
||||
if (rate.unitPrice() == null || rate.unitPrice().signum() <= 0) issues.add(issue("BLOCKING", "PRICE_NOT_POSITIVE", "单价必须大于0", rate.sourceSheetName(), rate.sourceRowNumber(), null));
|
||||
if (rate.sourceRowNumber() == null || rate.sourceRowNumber() <= 0 || !StringUtils.hasText(rate.sourceSheetName()))
|
||||
issues.add(issue("BLOCKING", "SOURCE_LOCATION_MISSING", "价格行缺少Excel来源位置", rate.sourceSheetName(), rate.sourceRowNumber(), null));
|
||||
validateOrigin(rate, warehouseCache, issues);
|
||||
validateDestination(rate, warehouseCache, issues);
|
||||
String originIdentity = rate.originWarehouseId() == null
|
||||
? "CODE:" + mappingKey(rate.originWarehouseCode()) : "ID:" + rate.originWarehouseId();
|
||||
String destinationIdentity = rate.destinationWarehouseId() == null
|
||||
? "NAME:" + mappingKey(rate.destinationName()) : "ID:" + rate.destinationWarehouseId();
|
||||
String key = String.join("|", code, originIdentity, enumText(rate.destinationType()),
|
||||
destinationIdentity, nullToEmpty(upper(rate.destinationCode())),
|
||||
rate.regionCode() == null ? "" : rate.regionCode().name(), rate.pricingTaxMode() == null ? "" : rate.pricingTaxMode().name(),
|
||||
rate.chargeUnit(), text(rate.weightFromKg()));
|
||||
if (!unique.add(key)) issues.add(issue("BLOCKING", "DUPLICATE_RATE", "存在重复价格组合", rate.sourceSheetName(), rate.sourceRowNumber(), null));
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
private void validateOrigin(PriceBookDtos.ParsedRate rate, Map<Long, PriceBookWarehouseClient.Warehouse> cache,
|
||||
List<PriceBookDtos.ParseIssue> issues) {
|
||||
if (rate.originWarehouseId() == null || rate.originWarehouseId() <= 0) {
|
||||
issues.add(issue("BLOCKING", "ORIGIN_MAPPING_REQUIRED", "起运仓需要映射到平台国内仓", rate.sourceSheetName(), rate.sourceRowNumber(), null));
|
||||
return;
|
||||
}
|
||||
PriceBookWarehouseClient.Warehouse warehouse = warehouse(rate.originWarehouseId(), cache, issues, rate, "起运仓");
|
||||
if (warehouse != null && (!"DOMESTIC".equals(warehouse.warehouseType())
|
||||
|| !Objects.equals(warehouse.warehouseCode(), rate.originWarehouseCode()))) {
|
||||
issues.add(issue("BLOCKING", "ORIGIN_WAREHOUSE_INVALID", "起运仓必须是匹配编码的平台公共国内仓", rate.sourceSheetName(), rate.sourceRowNumber(), null));
|
||||
}
|
||||
}
|
||||
|
||||
private void validateDestination(PriceBookDtos.ParsedRate rate, Map<Long, PriceBookWarehouseClient.Warehouse> cache,
|
||||
List<PriceBookDtos.ParseIssue> issues) {
|
||||
if (rate.priceModel() == PriceModel.REGION_WEIGHT_TIER) {
|
||||
if (rate.regionCode() == null || rate.weightFromKg() == null || rate.pricingTaxMode() == null
|
||||
|| rate.chargeUnit() == null || !"KG".equals(rate.chargeUnit()))
|
||||
issues.add(issue("BLOCKING", "REGION_TIER_INVALID", "海派价格必须包含区域、重量阶梯和KG单位", rate.sourceSheetName(), rate.sourceRowNumber(), null));
|
||||
return;
|
||||
}
|
||||
if (rate.priceModel() == null) {
|
||||
issues.add(issue("BLOCKING", "PRICE_MODEL_REQUIRED", "价格行必须选择价格模型", rate.sourceSheetName(), rate.sourceRowNumber(), null));
|
||||
return;
|
||||
}
|
||||
if (rate.destinationType() == null) {
|
||||
issues.add(issue("BLOCKING", "DESTINATION_TYPE_REQUIRED", "FBA仓库价格必须选择目的地类型", rate.sourceSheetName(), rate.sourceRowNumber(), null));
|
||||
return;
|
||||
}
|
||||
if (rate.pricingTaxMode() == null) {
|
||||
issues.add(issue("BLOCKING", "PRICING_TAX_MODE_REQUIRED", "FBA仓库价格必须选择包税或不包税", rate.sourceSheetName(), rate.sourceRowNumber(), null));
|
||||
}
|
||||
if (rate.destinationType() == PricingDestinationType.FBA) {
|
||||
if (!StringUtils.hasText(rate.destinationCode())) issues.add(issue("BLOCKING", "FBA_CODE_REQUIRED", "FBA价格必须填写目的仓编码", rate.sourceSheetName(), rate.sourceRowNumber(), null));
|
||||
} else {
|
||||
if (rate.destinationWarehouseId() == null || rate.destinationWarehouseId() <= 0) {
|
||||
issues.add(issue("BLOCKING", "OVERSEAS_MAPPING_REQUIRED", "海外仓价格必须映射平台海外仓", rate.sourceSheetName(), rate.sourceRowNumber(), null));
|
||||
} else {
|
||||
PriceBookWarehouseClient.Warehouse warehouse = warehouse(rate.destinationWarehouseId(), cache, issues, rate, "海外仓");
|
||||
if (warehouse != null && (!"OVERSEAS".equals(warehouse.warehouseType())
|
||||
|| (StringUtils.hasText(rate.destinationCode())
|
||||
&& !Objects.equals(warehouse.warehouseCode(), upper(rate.destinationCode())))))
|
||||
issues.add(issue("BLOCKING", "OVERSEAS_WAREHOUSE_INVALID", "目的仓必须是匹配编码的平台公共海外仓", rate.sourceSheetName(), rate.sourceRowNumber(), null));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private PriceBookWarehouseClient.Warehouse warehouse(long id, Map<Long, PriceBookWarehouseClient.Warehouse> cache,
|
||||
List<PriceBookDtos.ParseIssue> issues,
|
||||
PriceBookDtos.ParsedRate rate, String label) {
|
||||
if (cache.containsKey(id)) return cache.get(id);
|
||||
try {
|
||||
ApiResponse<PriceBookWarehouseClient.Detail> response = warehouses.active(internalToken, 0L, id);
|
||||
if (response == null || !response.successful() || response.data() == null || response.data().warehouse() == null)
|
||||
throw new IllegalStateException("仓库不存在");
|
||||
var warehouse = response.data().warehouse();
|
||||
if (!Long.valueOf(id).equals(warehouse.id()) || !Long.valueOf(0L).equals(warehouse.tenantId())
|
||||
|| !"ACTIVE".equals(warehouse.status())) throw new IllegalStateException("仓库不可用");
|
||||
cache.put(id, warehouse); return warehouse;
|
||||
} catch (RuntimeException exception) {
|
||||
issues.add(issue("BLOCKING", "WAREHOUSE_VALIDATION_FAILED", label + "无法通过平台公共仓库校验",
|
||||
rate.sourceSheetName(), rate.sourceRowNumber(), null));
|
||||
cache.put(id, null); return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void markFailed(PriceBookVersionEntity version, List<PriceBookDtos.ParseIssue> issues, long operatorId) {
|
||||
if (version == null || version.getId() == null) return;
|
||||
try {
|
||||
version.setStatus(PriceBookStatus.FAILED.name()); version.setUpdatedBy(operatorId);
|
||||
applyIssueCounts(version, issues); repository.updateVersion(version);
|
||||
} catch (RuntimeException ignored) { }
|
||||
}
|
||||
|
||||
private void applyIssueCounts(PriceBookVersionEntity version, List<PriceBookDtos.ParseIssue> issues) {
|
||||
version.setBlockingErrorCount((int) issues.stream().filter(i -> "BLOCKING".equals(i.severity())).count());
|
||||
version.setWarningCount((int) issues.stream().filter(i -> "WARNING".equals(i.severity())).count());
|
||||
try { version.setParseIssuesJson(objectMapper.writeValueAsString(issues)); }
|
||||
catch (JsonProcessingException exception) { throw internal("价格表校验结果序列化失败"); }
|
||||
}
|
||||
|
||||
private List<PriceBookDtos.ParseIssue> readIssues(String json) {
|
||||
if (!StringUtils.hasText(json)) return List.of();
|
||||
try { return objectMapper.readValue(json, ISSUE_LIST); }
|
||||
catch (JsonProcessingException exception) { throw internal("价格表校验结果读取失败"); }
|
||||
}
|
||||
|
||||
private PriceBookVersionEntity requireVersion(long id) {
|
||||
if (id <= 0) throw validation("价格版本ID必须大于0");
|
||||
PriceBookVersionEntity value = repository.findVersion(id);
|
||||
if (value == null) throw new BizException(ResultCode.NOT_FOUND, "价格表版本不存在");
|
||||
return value;
|
||||
}
|
||||
private PriceBookVersionEntity requirePendingConfirmation(long id) {
|
||||
PriceBookVersionEntity value = requireVersion(id);
|
||||
if (!PriceBookStatus.PENDING_CONFIRMATION.name().equals(value.getStatus())) throw new BizException(ResultCode.CONFLICT, "只有待确认上传记录可以确认并生效");
|
||||
return value;
|
||||
}
|
||||
|
||||
private PriceBookDtos.PriceBookVersionSummary summary(PriceBookVersionEntity value) {
|
||||
PriceBookStatus status = PriceBookStatus.valueOf(value.getStatus());
|
||||
return new PriceBookDtos.PriceBookVersionSummary(value.getId(), value.getVersionNo(), value.getOriginalFileName(),
|
||||
value.getSourceFileId(), value.getSourceSha256(), status,
|
||||
value.getChannelCount(), value.getRateCount(), value.getBlockingErrorCount(), value.getWarningCount(),
|
||||
value.getParseIssuesJson(), value.getCreatedBy(), value.getCreatedAt(), value.getPublishedBy(),
|
||||
value.getPublishedAt(), value.getSupersededAt());
|
||||
}
|
||||
private PriceBookDtos.ParsedChannel channelDto(PriceBookChannelEntity value) {
|
||||
return new PriceBookDtos.ParsedChannel(value.getChannelCode(), value.getChannelName(), PriceModel.valueOf(value.getPriceModel()),
|
||||
value.getSourceSheetName(), value.getEstimatedDepartureDate(), value.getEstimatedDepartureText(),
|
||||
value.getLatestWarehouseEntry(), value.getVesselName(), value.getVoyageNo(), value.getReferenceTransit(),
|
||||
value.getSurchargeDescription(), value.getRemark());
|
||||
}
|
||||
private PriceBookDtos.ParsedRate rateDto(CarrierFbaRateEntity value) {
|
||||
return new PriceBookDtos.ParsedRate(value.getId(), value.getChannelCode(), PriceModel.valueOf(value.getPriceModel()),
|
||||
value.getOriginWarehouseId(), value.getOriginWarehouseCode(), value.getOriginWarehouseName(),
|
||||
value.getDestinationCode(), value.getDestinationType() == null ? null : PricingDestinationType.valueOf(value.getDestinationType()),
|
||||
value.getDestinationWarehouseId(), value.getDestinationName(),
|
||||
value.getRegionCode() == null ? null : PriceRegion.valueOf(value.getRegionCode()),
|
||||
value.getPricingTaxMode() == null ? null : PricingTaxMode.valueOf(value.getPricingTaxMode()),
|
||||
value.getChargeUnit(), value.getWeightFromKg(), value.getUnitPrice(), value.getCurrency(),
|
||||
value.getSourceSheetName(), value.getSourceRowNumber(), value.getSourceDestinationText(), value.getRemark());
|
||||
}
|
||||
|
||||
private static void requireFile(MultipartFile file) {
|
||||
if (file == null || file.isEmpty()) throw validation("请选择价格表Excel文件");
|
||||
String name = file.getOriginalFilename();
|
||||
if (!StringUtils.hasText(name) || !name.toLowerCase(Locale.ROOT).endsWith(".xlsx")) throw validation("仅支持XLSX价格表");
|
||||
}
|
||||
private static String safeFileName(String value) {
|
||||
String name = value == null ? "price-book.xlsx" : value.replace('\\', '/');
|
||||
name = name.substring(name.lastIndexOf('/') + 1).trim();
|
||||
return name.isEmpty() ? "price-book.xlsx" : name.substring(0, Math.min(name.length(), 255));
|
||||
}
|
||||
private static String normalizeChannel(String value) { return value == null ? "" : value.trim().toUpperCase(Locale.ROOT); }
|
||||
private static String upper(String value) { return value == null ? null : value.trim().toUpperCase(Locale.ROOT); }
|
||||
private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; }
|
||||
private static String text(Object value) { return value == null ? "" : value.toString(); }
|
||||
private static String nullToEmpty(String value) { return value == null ? "" : value; }
|
||||
private static String enumText(Enum<?> value) { return value == null ? "" : value.name(); }
|
||||
private static String mappingKey(String value) { return value == null ? "" : value.trim().toUpperCase(Locale.ROOT); }
|
||||
private static String safeMessage(Exception exception, String fallback) {
|
||||
return StringUtils.hasText(exception.getMessage()) ? exception.getMessage() : fallback;
|
||||
}
|
||||
private static List<PriceBookDtos.ParseIssue> mergeIssues(List<PriceBookDtos.ParseIssue> first,
|
||||
List<PriceBookDtos.ParseIssue> second) {
|
||||
Map<String, PriceBookDtos.ParseIssue> result = new LinkedHashMap<>();
|
||||
java.util.stream.Stream.concat(first.stream(), second.stream()).forEach(value -> result.put(
|
||||
String.join("|", value.severity(), value.issueCode(), nullToEmpty(value.sourceSheetName()),
|
||||
text(value.sourceRowNumber()), nullToEmpty(value.sourceCell())), value));
|
||||
return List.copyOf(result.values());
|
||||
}
|
||||
private static PriceBookDtos.ParseIssue issue(String severity, String code, String message,
|
||||
String sheet, Integer row, String cell) {
|
||||
return new PriceBookDtos.ParseIssue(severity, code, message, sheet, row, cell);
|
||||
}
|
||||
private static BizException validation(String message) { return new BizException(ResultCode.VALIDATION_FAILED, message); }
|
||||
private static BizException internal(String message) { return new BizException(ResultCode.INTERNAL_ERROR, message); }
|
||||
private record ParsedPriceBook(List<PriceBookDtos.ParsedChannel> channels, List<PriceBookDtos.ParsedRate> rates,
|
||||
List<PriceBookDtos.ParseIssue> issues) {}
|
||||
private record MappingResult(List<PriceBookDtos.ParsedRate> rates, List<PriceBookDtos.ParseIssue> issues) {}
|
||||
}
|
||||
Reference in New Issue
Block a user