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) {}
|
||||
}
|
||||
@@ -35,6 +35,8 @@ baiyue:
|
||||
url: ${FILE_SERVICE_URL:}
|
||||
warehouse:
|
||||
url: ${WAREHOUSE_SERVICE_URL:}
|
||||
print:
|
||||
url: ${PRINT_SERVICE_URL:}
|
||||
firstleg:
|
||||
inbound:
|
||||
allow-rejected-measurement: false
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
SET NAMES utf8mb4;
|
||||
USE baiyue_firstleg;
|
||||
|
||||
-- 每个客户唯一的尺寸计算配置;当前只保存每侧加宽值,不接入订单或计费。
|
||||
CREATE TABLE IF NOT EXISTS fl_customer_dimension_config (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '配置ID',
|
||||
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离ID',
|
||||
edge_addition_cm DECIMAL(6,3) NOT NULL DEFAULT 2.000 COMMENT '每个尺寸方向的单侧加宽厘米数',
|
||||
remark VARCHAR(500) NULL COMMENT '备注',
|
||||
initialization_key VARCHAR(128) NOT NULL COMMENT '创建流程幂等键及补偿归属',
|
||||
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID',
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
||||
deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除',
|
||||
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_customer_dimension_config_tenant (tenant_id),
|
||||
CONSTRAINT chk_customer_dimension_edge_addition
|
||||
CHECK (edge_addition_cm >= 0.001 AND edge_addition_cm <= 100.000)
|
||||
) ENGINE=InnoDB COMMENT='客户尺寸计算配置';
|
||||
@@ -0,0 +1,53 @@
|
||||
-- 单箱当前测量记录增量;可重复执行。
|
||||
USE baiyue_firstleg;
|
||||
|
||||
DROP PROCEDURE IF EXISTS phase2_add_carton_measurement_column;
|
||||
DELIMITER $$
|
||||
CREATE PROCEDURE phase2_add_carton_measurement_column(
|
||||
IN p_column VARCHAR(64), IN p_definition TEXT)
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'fl_inbound_measurement_item'
|
||||
AND COLUMN_NAME = p_column
|
||||
) THEN
|
||||
SET @ddl = CONCAT(
|
||||
'ALTER TABLE fl_inbound_measurement_item ADD COLUMN `',
|
||||
p_column, '` ', p_definition);
|
||||
PREPARE statement FROM @ddl;
|
||||
EXECUTE statement;
|
||||
DEALLOCATE PREPARE statement;
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
|
||||
CALL phase2_add_carton_measurement_column(
|
||||
'box_code',
|
||||
'VARCHAR(128) NULL COMMENT ''柏悦唛头箱号;设备单箱测量时同一客户内唯一'' AFTER external_detail_no');
|
||||
CALL phase2_add_carton_measurement_column(
|
||||
'machine_no',
|
||||
'VARCHAR(64) NULL COMMENT ''本次测量设备编号'' AFTER source_image_file_id');
|
||||
CALL phase2_add_carton_measurement_column(
|
||||
'device_measured_at',
|
||||
'DATETIME(3) NULL COMMENT ''本次设备测量时间'' AFTER machine_no');
|
||||
CALL phase2_add_carton_measurement_column(
|
||||
'updated_at',
|
||||
'DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT ''最后更新时间'' AFTER created_at');
|
||||
|
||||
DROP PROCEDURE IF EXISTS phase2_add_carton_measurement_column;
|
||||
|
||||
SET @has_box_code_index = (
|
||||
SELECT COUNT(*) FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'fl_inbound_measurement_item'
|
||||
AND INDEX_NAME = 'uk_measure_box_code'
|
||||
);
|
||||
SET @ddl = IF(
|
||||
@has_box_code_index = 0,
|
||||
'ALTER TABLE fl_inbound_measurement_item ADD UNIQUE KEY uk_measure_box_code (tenant_id, box_code)',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE statement FROM @ddl;
|
||||
EXECUTE statement;
|
||||
DEALLOCATE PREPARE statement;
|
||||
@@ -0,0 +1,28 @@
|
||||
SET NAMES utf8mb4;
|
||||
USE baiyue_firstleg;
|
||||
|
||||
-- 存量环境可重复执行:首次打印前冻结整单箱号;Print 服务继续保持无状态。
|
||||
CREATE TABLE IF NOT EXISTS fl_inbound_carton_label (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '实际箱唛头映射ID(雪花ID)',
|
||||
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '当前客户隔离ID',
|
||||
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
|
||||
order_id BIGINT UNSIGNED NOT NULL COMMENT '订单服务订单ID快照',
|
||||
inbound_detail_id BIGINT UNSIGNED NOT NULL COMMENT '订单货物明细ID,不建立跨库外键',
|
||||
cargo_item_no INT UNSIGNED NOT NULL COMMENT '订单货物明细序号快照',
|
||||
cargo_name VARCHAR(255) NULL COMMENT '货物名称快照',
|
||||
detail_package_count INT UNSIGNED NOT NULL COMMENT '该订单货物明细箱数快照',
|
||||
inbound_code VARCHAR(40) NOT NULL COMMENT '入仓码快照',
|
||||
box_code VARCHAR(80) NOT NULL COMMENT '固定二维码内容:入仓码#总箱数-箱下标',
|
||||
total_carton_count INT UNSIGNED NOT NULL COMMENT '首次分配时整单总箱数',
|
||||
carton_index INT UNSIGNED NOT NULL COMMENT '整单从1开始的稳定箱下标',
|
||||
box_specification VARCHAR(255) NOT NULL COMMENT '订单箱规或货物明细描述快照',
|
||||
length_cm DECIMAL(12,3) NULL COMMENT '下单箱规长度cm快照',
|
||||
width_cm DECIMAL(12,3) NULL COMMENT '下单箱规宽度cm快照',
|
||||
height_cm DECIMAL(12,3) NULL COMMENT '下单箱规高度cm快照',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '首次分配时间',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_carton_label_box_code (tenant_id, box_code),
|
||||
UNIQUE KEY uk_carton_label_index (tenant_id, inbound_id, carton_index),
|
||||
KEY idx_carton_label_detail (tenant_id, inbound_id, inbound_detail_id),
|
||||
CONSTRAINT fk_carton_label_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id)
|
||||
) ENGINE=InnoDB COMMENT='首次打印前冻结的实际箱号与订单货物明细映射';
|
||||
@@ -32,6 +32,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_order (
|
||||
carrier_id BIGINT UNSIGNED NULL COMMENT '计费船司逻辑ID',
|
||||
carrier_name VARCHAR(128) NULL COMMENT '计费船司名称快照',
|
||||
tax_mode VARCHAR(24) NULL COMMENT '买单或退税模式',
|
||||
pricing_tax_mode VARCHAR(16) NULL COMMENT '报价包税维度:TAX_INCLUDED/TAX_EXCLUDED',
|
||||
container_mode VARCHAR(24) NULL COMMENT '整柜或散货模式',
|
||||
customs_inspection TINYINT(1) NULL COMMENT '是否商检',
|
||||
destination_type VARCHAR(24) NULL COMMENT '目的仓类型',
|
||||
@@ -139,6 +140,31 @@ CREATE TABLE IF NOT EXISTS fl_inbound_transport_snapshot (
|
||||
CONSTRAINT fk_transport_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id)
|
||||
) ENGINE=InnoDB COMMENT='入库单关联运输信息只读快照';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fl_inbound_carton_label (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '实际箱唛头映射ID(雪花ID)',
|
||||
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '当前客户隔离ID',
|
||||
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
|
||||
order_id BIGINT UNSIGNED NOT NULL COMMENT '订单服务订单ID快照',
|
||||
inbound_detail_id BIGINT UNSIGNED NOT NULL COMMENT '订单货物明细ID,不建立跨库外键',
|
||||
cargo_item_no INT UNSIGNED NOT NULL COMMENT '订单货物明细序号快照',
|
||||
cargo_name VARCHAR(255) NULL COMMENT '货物名称快照',
|
||||
detail_package_count INT UNSIGNED NOT NULL COMMENT '该订单货物明细箱数快照',
|
||||
inbound_code VARCHAR(40) NOT NULL COMMENT '入仓码快照',
|
||||
box_code VARCHAR(80) NOT NULL COMMENT '固定二维码内容:入仓码#总箱数-箱下标',
|
||||
total_carton_count INT UNSIGNED NOT NULL COMMENT '首次分配时整单总箱数',
|
||||
carton_index INT UNSIGNED NOT NULL COMMENT '整单从1开始的稳定箱下标',
|
||||
box_specification VARCHAR(255) NOT NULL COMMENT '订单箱规或货物明细描述快照',
|
||||
length_cm DECIMAL(12,3) NULL COMMENT '下单箱规长度cm快照',
|
||||
width_cm DECIMAL(12,3) NULL COMMENT '下单箱规宽度cm快照',
|
||||
height_cm DECIMAL(12,3) NULL COMMENT '下单箱规高度cm快照',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '首次分配时间',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_carton_label_box_code (tenant_id, box_code),
|
||||
UNIQUE KEY uk_carton_label_index (tenant_id, inbound_id, carton_index),
|
||||
KEY idx_carton_label_detail (tenant_id, inbound_id, inbound_detail_id),
|
||||
CONSTRAINT fk_carton_label_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id)
|
||||
) ENGINE=InnoDB COMMENT='首次打印前冻结的实际箱号与订单货物明细映射';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fl_inbound_measurement (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '测量批次ID',
|
||||
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
|
||||
@@ -176,6 +202,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_measurement_item (
|
||||
measurement_id BIGINT UNSIGNED NOT NULL COMMENT '测量批次ID',
|
||||
inbound_detail_id BIGINT UNSIGNED NULL COMMENT '绑定的订单货物明细ID,平台手工明细可为空',
|
||||
external_detail_no VARCHAR(128) NULL COMMENT '第三方测量明细唯一号,平台手工明细可为空',
|
||||
box_code VARCHAR(128) NULL COMMENT '柏悦唛头箱号;设备单箱测量时同一客户内唯一',
|
||||
item_no VARCHAR(32) NOT NULL COMMENT '批次内明细号',
|
||||
box_specification VARCHAR(64) NOT NULL COMMENT '箱规标识',
|
||||
package_count INT UNSIGNED NOT NULL COMMENT '相同箱规箱数',
|
||||
@@ -192,15 +219,19 @@ CREATE TABLE IF NOT EXISTS fl_inbound_measurement_item (
|
||||
volume_weight_kg DECIMAL(18,3) NOT NULL COMMENT '本箱规材积重kg',
|
||||
calculation_rule_version VARCHAR(32) NOT NULL COMMENT '体积计算和舍入规则版本',
|
||||
source_image_file_id BIGINT UNSIGNED NULL COMMENT '测量设备图片文件ID',
|
||||
machine_no VARCHAR(64) NULL COMMENT '本次测量设备编号',
|
||||
device_measured_at DATETIME(3) NULL COMMENT '本次设备测量时间',
|
||||
over_limit_warning VARCHAR(500) NULL COMMENT '超线提醒快照',
|
||||
outer_length_cm DECIMAL(12,3) NULL COMMENT '外箱规长度cm',
|
||||
outer_width_cm DECIMAL(12,3) NULL COMMENT '外箱规宽度cm',
|
||||
outer_height_cm DECIMAL(12,3) NULL COMMENT '外箱规高度cm',
|
||||
remark VARCHAR(500) NULL COMMENT '测量明细备注',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_measure_item_no (tenant_id, measurement_id, item_no),
|
||||
UNIQUE KEY uk_measure_external_detail (tenant_id, inbound_detail_id, external_detail_no),
|
||||
UNIQUE KEY uk_measure_box_code (tenant_id, box_code),
|
||||
CONSTRAINT fk_measure_item_batch FOREIGN KEY (measurement_id) REFERENCES fl_inbound_measurement (id)
|
||||
) ENGINE=InnoDB COMMENT='入库长宽高和箱数测量明细';
|
||||
|
||||
@@ -310,22 +341,34 @@ CREATE TABLE IF NOT EXISTS fl_customer_charge_rule (
|
||||
CREATE TABLE IF NOT EXISTS fl_carrier_fba_rate (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '船司FBA价格ID',
|
||||
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '平台价格作用域ID,船司价格固定为0',
|
||||
price_book_version_id BIGINT UNSIGNED NULL COMMENT '整表价格版本ID;历史兼容价格可为空',
|
||||
rate_code VARCHAR(50) NOT NULL COMMENT '平台公共价格编码',
|
||||
rate_version INT UNSIGNED NOT NULL COMMENT '价格版本号',
|
||||
origin_warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '起运仓逻辑ID',
|
||||
origin_warehouse_id BIGINT UNSIGNED NULL COMMENT '确认前可为空;确认后绑定平台公共起运仓逻辑ID',
|
||||
origin_warehouse_code VARCHAR(64) NULL COMMENT '起运仓稳定编码',
|
||||
origin_warehouse_name VARCHAR(128) NOT NULL COMMENT '起运仓名称快照',
|
||||
business_type VARCHAR(32) NOT NULL COMMENT '业务或计费类型',
|
||||
channel_code VARCHAR(64) NULL COMMENT '渠道编码',
|
||||
price_model VARCHAR(32) NOT NULL DEFAULT 'FBA_WAREHOUSE_RATE' COMMENT '价格模型',
|
||||
carrier_id BIGINT UNSIGNED NULL COMMENT '船司逻辑ID',
|
||||
carrier_name VARCHAR(128) NOT NULL COMMENT '船司名称快照',
|
||||
destination_code VARCHAR(32) NOT NULL COMMENT 'FBA目的仓编码',
|
||||
carrier_name VARCHAR(128) NULL COMMENT '历史承运商名称;渠道价格可为空',
|
||||
destination_type VARCHAR(16) NULL COMMENT '目的地类型:FBA或OVERSEAS;REGION_WEIGHT_TIER区域阶梯行为空',
|
||||
destination_code VARCHAR(32) NULL COMMENT 'FBA目的仓编码;区域阶梯模型为空',
|
||||
destination_warehouse_id BIGINT UNSIGNED NULL COMMENT '平台海外仓稳定ID;不存环境专属ID',
|
||||
destination_name VARCHAR(128) NULL COMMENT '目的仓名称快照',
|
||||
region_code VARCHAR(16) NULL COMMENT '区域阶梯模型区域编码',
|
||||
pricing_tax_mode VARCHAR(16) NULL COMMENT '报价包税维度;独立于订单tax_mode',
|
||||
weight_from_kg DECIMAL(18,6) NULL COMMENT '区域阶梯起始计费重量KG',
|
||||
charge_unit VARCHAR(16) NOT NULL COMMENT '计费单位:KG或CBM',
|
||||
unit_price DECIMAL(18,6) NOT NULL COMMENT '基础单价',
|
||||
currency CHAR(3) NOT NULL COMMENT '币种',
|
||||
additional_fee DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '固定附加费',
|
||||
source_sheet_name VARCHAR(128) NULL COMMENT '原始Excel Sheet',
|
||||
source_row_number INT UNSIGNED NULL COMMENT '原始Excel行号',
|
||||
source_destination_text VARCHAR(1000) NULL COMMENT '原始组合仓库或区域文本',
|
||||
effective_from DATETIME(3) NOT NULL COMMENT '生效开始时间',
|
||||
effective_to DATETIME(3) NULL COMMENT '生效结束时间',
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '价格状态',
|
||||
status VARCHAR(24) NOT NULL DEFAULT 'ACTIVE' COMMENT '价格状态:PENDING_CONFIRMATION、ACTIVE或SUPERSEDED',
|
||||
remark VARCHAR(500) NULL COMMENT '价格备注',
|
||||
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
@@ -335,39 +378,187 @@ CREATE TABLE IF NOT EXISTS fl_carrier_fba_rate (
|
||||
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_fba_rate_version (tenant_id, rate_code, rate_version),
|
||||
KEY idx_fba_rate_match (tenant_id, origin_warehouse_id, business_type, channel_code, carrier_id, destination_code, charge_unit, status, effective_from, effective_to)
|
||||
KEY idx_fba_rate_match (tenant_id, origin_warehouse_id, business_type, channel_code, carrier_id, destination_type, destination_code, destination_warehouse_id, charge_unit, status, effective_from, effective_to),
|
||||
KEY idx_fba_rate_book (price_book_version_id, channel_code, origin_warehouse_id, price_model, status),
|
||||
KEY idx_fba_rate_destination (price_book_version_id, channel_code, origin_warehouse_id, destination_type, destination_warehouse_id, destination_code),
|
||||
KEY idx_fba_rate_region (price_book_version_id, channel_code, origin_warehouse_id, region_code, weight_from_kg)
|
||||
) ENGINE=InnoDB COMMENT='船司FBA目的仓价格版本';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fl_price_channel_catalog (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '渠道目录ID',
|
||||
channel_code VARCHAR(64) NOT NULL COMMENT '稳定渠道编码',
|
||||
display_name VARCHAR(128) NOT NULL COMMENT '平台渠道名称',
|
||||
price_model VARCHAR(32) NOT NULL COMMENT '默认价格模型',
|
||||
source_sheet_name VARCHAR(128) NOT NULL COMMENT '来源Sheet名称;海派为同一Sheet',
|
||||
aliases_json JSON NOT NULL COMMENT '上传名称别名数组',
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '目录状态',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_price_channel_code (channel_code),
|
||||
KEY idx_price_channel_status (status, price_model)
|
||||
) ENGINE=InnoDB COMMENT='平台公共船期价格固定渠道目录';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fl_price_origin_alias (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '起运仓别名ID',
|
||||
warehouse_code VARCHAR(64) NOT NULL COMMENT '平台仓库稳定编码,不存环境专属ID',
|
||||
alias_name VARCHAR(128) NOT NULL COMMENT 'Excel起运仓名称别名',
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_price_origin_alias (warehouse_code, alias_name),
|
||||
KEY idx_price_origin_alias_name (alias_name, status)
|
||||
) ENGINE=InnoDB COMMENT='价格表起运仓名称别名映射';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fl_price_warehouse_mapping (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '价格仓映射ID',
|
||||
source_type VARCHAR(24) NOT NULL COMMENT '来源维度:ORIGIN_CODE或OVERSEAS_NAME',
|
||||
source_value VARCHAR(128) NOT NULL COMMENT '解析后的来源仓编码或海外仓名称',
|
||||
warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '固定平台公共仓库ID',
|
||||
warehouse_code VARCHAR(64) NOT NULL COMMENT '平台仓库稳定编码快照',
|
||||
warehouse_type VARCHAR(16) NOT NULL COMMENT 'DOMESTIC或OVERSEAS',
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_price_warehouse_mapping_source (source_type, source_value),
|
||||
KEY idx_price_warehouse_mapping_target (warehouse_id, status)
|
||||
) ENGINE=InnoDB COMMENT='价格表来源仓与固定平台公共仓映射';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fl_price_book_version (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '整表价格版本ID',
|
||||
tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '平台公共价格作用域固定为0',
|
||||
version_no INT UNSIGNED NOT NULL COMMENT '整表版本号',
|
||||
original_file_name VARCHAR(255) NOT NULL COMMENT '原始Excel文件名',
|
||||
source_file_id BIGINT UNSIGNED NULL COMMENT 'File服务原始文件ID',
|
||||
source_sha256 CHAR(64) NULL COMMENT '原始文件SHA-256',
|
||||
status VARCHAR(24) NOT NULL DEFAULT 'PENDING_CONFIRMATION' COMMENT '待确认上传记录;ACTIVE/SUPERSEDED/FAILED',
|
||||
blocking_error_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '阻断错误数',
|
||||
warning_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '警告数',
|
||||
parse_issues_json JSON NULL COMMENT '上传解析问题及待确认重新校验结果',
|
||||
channel_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '解析渠道数',
|
||||
rate_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '解析价格数',
|
||||
created_by BIGINT UNSIGNED NOT NULL,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
published_by BIGINT UNSIGNED NULL,
|
||||
published_at DATETIME(3) NULL COMMENT '确认并生效时间',
|
||||
superseded_at DATETIME(3) NULL COMMENT '被下一版本替换时间',
|
||||
updated_by BIGINT UNSIGNED NOT NULL,
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
deleted TINYINT(1) NOT NULL DEFAULT 0,
|
||||
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_price_book_version (tenant_id, version_no),
|
||||
KEY idx_price_book_status (tenant_id, status, published_at),
|
||||
KEY idx_price_book_source_hash (tenant_id, source_sha256)
|
||||
) ENGINE=InnoDB COMMENT='平台公共船期价格整表版本';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fl_price_book_channel (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '版本渠道快照ID',
|
||||
price_book_version_id BIGINT UNSIGNED NOT NULL COMMENT '整表价格版本ID',
|
||||
channel_code VARCHAR(64) NOT NULL COMMENT '固定渠道编码',
|
||||
channel_name VARCHAR(128) NOT NULL COMMENT '渠道名称快照',
|
||||
price_model VARCHAR(32) NOT NULL COMMENT '价格模型快照',
|
||||
source_sheet_name VARCHAR(128) NOT NULL COMMENT '原始Excel Sheet',
|
||||
estimated_departure_date DATE NULL COMMENT '标准预计开船日期',
|
||||
estimated_departure_text VARCHAR(128) NULL COMMENT '原始预计开船日期文本',
|
||||
latest_warehouse_entry VARCHAR(128) NULL COMMENT '最晚进仓时间',
|
||||
vessel_name VARCHAR(128) NULL COMMENT '船名',
|
||||
voyage_no VARCHAR(64) NULL COMMENT '航次',
|
||||
reference_transit VARCHAR(128) NULL COMMENT '参考时效',
|
||||
surcharge_description VARCHAR(2000) NULL COMMENT '附加费说明,仅展示',
|
||||
remark VARCHAR(2000) NULL COMMENT '渠道备注',
|
||||
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID',
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记',
|
||||
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_price_book_channel (price_book_version_id, channel_code),
|
||||
KEY idx_price_book_channel_code (channel_code, price_book_version_id)
|
||||
) ENGINE=InnoDB COMMENT='价格版本渠道及船期快照';
|
||||
|
||||
INSERT INTO fl_price_channel_catalog
|
||||
(id, channel_code, display_name, price_model, source_sheet_name, aliases_json, status)
|
||||
VALUES
|
||||
(1, 'MATSON_CLX_TRUCK', '美森正班海卡', 'FBA_WAREHOUSE_RATE', '美森正班海卡', JSON_ARRAY('美森正班海卡'), 'ACTIVE'),
|
||||
(2, 'MATSON_MAX_TRUCK', '美森加班海卡', 'FBA_WAREHOUSE_RATE', '美森加班海卡', JSON_ARRAY('美森加班海卡'), 'ACTIVE'),
|
||||
(3, 'EXX_TRUCK', 'EXX海卡', 'FBA_WAREHOUSE_RATE', 'EXX海卡', JSON_ARRAY('EXX海卡'), 'ACTIVE'),
|
||||
(4, 'HEDE_TRUCK', '合德海卡', 'FBA_WAREHOUSE_RATE', '合德海卡', JSON_ARRAY('合德海卡'), 'ACTIVE'),
|
||||
(5, 'EXX_RAIL_NY', '纽约EXX海铁', 'FBA_WAREHOUSE_RATE', '纽约EXX海铁', JSON_ARRAY('纽约EXX海铁'), 'ACTIVE'),
|
||||
(6, 'OA_LA_TRUCK', '洛杉矶普船海卡', 'FBA_WAREHOUSE_RATE', '洛杉矶普船海卡', JSON_ARRAY('洛杉矶普船海卡'), 'ACTIVE'),
|
||||
(7, 'OA_LA_FULL_TRUCK', '洛杉矶普船全卡', 'FBA_WAREHOUSE_RATE', '洛杉矶普船全卡', JSON_ARRAY('洛杉矶普船全卡'), 'ACTIVE'),
|
||||
(8, 'OA_CHICAGO_RAIL', '芝加哥普船海铁', 'FBA_WAREHOUSE_RATE', '芝加哥普船海铁', JSON_ARRAY('芝加哥普船海铁'), 'ACTIVE'),
|
||||
(9, 'OA_HOUSTON_TRUCK', '休斯顿普船海卡', 'FBA_WAREHOUSE_RATE', '休斯顿普船海卡', JSON_ARRAY('休斯顿普船海卡'), 'ACTIVE'),
|
||||
(10, 'OA_NEW_YORK_TRUCK', '纽约普船海卡', 'FBA_WAREHOUSE_RATE', '纽约普船海卡', JSON_ARRAY('纽约普船海卡'), 'ACTIVE'),
|
||||
(11, 'OA_SAVANNAH_TRUCK', '萨凡纳普船海卡', 'FBA_WAREHOUSE_RATE', '萨凡纳普船海卡', JSON_ARRAY('萨凡纳普船海卡'), 'ACTIVE'),
|
||||
(12, 'MATSON_CLX_PARCEL', '美森正班海派', 'REGION_WEIGHT_TIER', '海派', JSON_ARRAY('美森正班海派'), 'ACTIVE'),
|
||||
(13, 'MATSON_MAX_PARCEL', '美森加班海派', 'REGION_WEIGHT_TIER', '海派', JSON_ARRAY('美森加班海派'), 'ACTIVE'),
|
||||
(14, 'HEDE_PARCEL', '合德海派', 'REGION_WEIGHT_TIER', '海派', JSON_ARRAY('合德海派'), 'ACTIVE'),
|
||||
(15, 'OA_PARCEL', '普船海派', 'REGION_WEIGHT_TIER', '海派', JSON_ARRAY('普船海派'), 'ACTIVE')
|
||||
ON DUPLICATE KEY UPDATE
|
||||
display_name = VALUES(display_name), price_model = VALUES(price_model),
|
||||
source_sheet_name = VALUES(source_sheet_name), aliases_json = VALUES(aliases_json), status = VALUES(status);
|
||||
|
||||
INSERT INTO fl_price_origin_alias (id, warehouse_code, alias_name, status)
|
||||
VALUES
|
||||
(1, 'CN-YIWU-01', '华东仓(义乌)', 'ACTIVE'),
|
||||
(2, 'CN-YIWU-01', '义乌仓', 'ACTIVE'),
|
||||
(3, 'CN-YIWU-01', '义乌', 'ACTIVE'),
|
||||
(4, 'CN-DONGGUAN-01', '华南仓(深圳)', 'ACTIVE'),
|
||||
(5, 'CN-DONGGUAN-01', '东莞仓', 'ACTIVE'),
|
||||
(6, 'CN-DONGGUAN-01', '深圳仓', 'ACTIVE'),
|
||||
(7, 'CN-DONGGUAN-01', '华南仓(深圳/东莞)', 'ACTIVE')
|
||||
ON DUPLICATE KEY UPDATE status = VALUES(status);
|
||||
|
||||
INSERT INTO fl_price_warehouse_mapping
|
||||
(id, source_type, source_value, warehouse_id, warehouse_code, warehouse_type, status)
|
||||
VALUES
|
||||
(1, 'ORIGIN_CODE', 'CN-YIWU-01', 193000000000200001, 'CN-YIWU-01', 'DOMESTIC', 'ACTIVE'),
|
||||
(2, 'ORIGIN_CODE', 'CN-DONGGUAN-01', 193000000000200002, 'CN-DONGGUAN-01', 'DOMESTIC', 'ACTIVE'),
|
||||
(3, 'OVERSEAS_NAME', '洛杉矶仓', 193000000000200003, 'US-LAX-01', 'OVERSEAS', 'ACTIVE'),
|
||||
(4, 'OVERSEAS_NAME', '纽约仓', 193000000000200004, 'US-NYC-01', 'OVERSEAS', 'ACTIVE')
|
||||
ON DUPLICATE KEY UPDATE
|
||||
warehouse_id = VALUES(warehouse_id), warehouse_code = VALUES(warehouse_code),
|
||||
warehouse_type = VALUES(warehouse_type), status = VALUES(status);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fl_inbound_fee (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '入库费用ID',
|
||||
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
|
||||
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
|
||||
fee_no VARCHAR(40) NOT NULL COMMENT '租户内唯一费用单号',
|
||||
fee_version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '费用快照版本',
|
||||
charge_rule_id BIGINT UNSIGNED NOT NULL COMMENT '客户计费规则ID',
|
||||
charge_rule_version INT UNSIGNED NOT NULL COMMENT '客户规则版本快照',
|
||||
charge_rule_id BIGINT UNSIGNED NULL COMMENT '历史客户计费规则ID;渠道价格可为空',
|
||||
charge_rule_version INT UNSIGNED NULL COMMENT '历史客户规则版本快照',
|
||||
carrier_rate_id BIGINT UNSIGNED NOT NULL COMMENT '船司FBA价格ID',
|
||||
carrier_rate_version INT UNSIGNED NOT NULL COMMENT '船司价格版本快照',
|
||||
rule_code VARCHAR(50) NOT NULL COMMENT '客户规则编码快照',
|
||||
price_book_version_id BIGINT UNSIGNED NULL COMMENT '整表价格版本ID快照',
|
||||
rule_code VARCHAR(50) NULL COMMENT '历史客户规则编码快照',
|
||||
customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID快照',
|
||||
customer_name VARCHAR(128) NOT NULL COMMENT '客户名称快照',
|
||||
business_type VARCHAR(32) NOT NULL COMMENT '业务类型快照',
|
||||
tax_mode VARCHAR(24) NULL COMMENT '包税模式快照',
|
||||
rounding_scale TINYINT UNSIGNED NOT NULL COMMENT '计费量保留位数快照',
|
||||
rounding_mode VARCHAR(24) NOT NULL COMMENT '舍入方式快照',
|
||||
rule_effective_from DATETIME(3) NOT NULL COMMENT '客户规则生效开始快照',
|
||||
tax_mode VARCHAR(24) NULL COMMENT '订单买单或退税模式快照',
|
||||
pricing_tax_mode VARCHAR(16) NULL COMMENT '报价包税维度快照',
|
||||
rounding_scale TINYINT UNSIGNED NULL COMMENT '历史客户规则计费量保留位数快照',
|
||||
rounding_mode VARCHAR(24) NULL COMMENT '历史客户规则舍入方式快照',
|
||||
rule_effective_from DATETIME(3) NULL COMMENT '历史客户规则生效开始快照',
|
||||
rule_effective_to DATETIME(3) NULL COMMENT '客户规则生效结束快照',
|
||||
rate_code VARCHAR(50) NOT NULL COMMENT '船司价格编码快照',
|
||||
origin_warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '起运仓ID快照',
|
||||
origin_warehouse_name VARCHAR(128) NOT NULL COMMENT '起运仓名称快照',
|
||||
channel_code VARCHAR(64) NOT NULL COMMENT '渠道编码快照',
|
||||
carrier_id BIGINT UNSIGNED NOT NULL COMMENT '船司ID快照',
|
||||
carrier_name VARCHAR(128) NOT NULL COMMENT '船司名称快照',
|
||||
destination_code VARCHAR(32) NOT NULL COMMENT 'FBA仓编码快照',
|
||||
carrier_id BIGINT UNSIGNED NULL COMMENT '历史承运商ID;渠道价格可为空',
|
||||
carrier_name VARCHAR(128) NULL COMMENT '历史承运商名称;渠道价格可为空',
|
||||
destination_type VARCHAR(16) NULL COMMENT '目的地类型快照:FBA或OVERSEAS',
|
||||
destination_warehouse_id BIGINT UNSIGNED NULL COMMENT '海外仓或FBA目的仓ID快照',
|
||||
destination_name VARCHAR(128) NULL COMMENT '目的仓名称快照',
|
||||
destination_code VARCHAR(32) NULL COMMENT 'FBA仓编码快照;海外仓可为空',
|
||||
rate_effective_from DATETIME(3) NOT NULL COMMENT '价格生效开始快照',
|
||||
rate_effective_to DATETIME(3) NULL COMMENT '价格生效结束快照',
|
||||
charge_mode VARCHAR(16) NOT NULL COMMENT '计费口径:KG或CBM',
|
||||
conversion_factor DECIMAL(18,6) NOT NULL COMMENT '使用的换算系数快照',
|
||||
charge_mode VARCHAR(16) NULL COMMENT '计费口径:KG或CBM',
|
||||
conversion_factor DECIMAL(18,6) NULL COMMENT '历史客户规则使用的换算系数快照',
|
||||
actual_weight_kg DECIMAL(18,3) NOT NULL COMMENT '试算时实际重量kg',
|
||||
actual_volume_cbm DECIMAL(18,4) NOT NULL COMMENT '试算时实际体积m3',
|
||||
converted_quantity DECIMAL(18,4) NOT NULL COMMENT '换算后的重量或体积',
|
||||
@@ -538,3 +729,21 @@ CREATE TABLE IF NOT EXISTS fl_inbound_idempotency (
|
||||
UNIQUE KEY uk_inbound_idempotency (tenant_id, operation, operator_id, idempotency_key),
|
||||
KEY idx_inbound_idempotency_created (created_at)
|
||||
) ENGINE=InnoDB COMMENT='第三方测量、审核、费用发送和入库写操作幂等记录';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fl_customer_dimension_config (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '配置ID',
|
||||
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离ID',
|
||||
edge_addition_cm DECIMAL(6,3) NOT NULL DEFAULT 2.000 COMMENT '每个尺寸方向的单侧加宽厘米数',
|
||||
remark VARCHAR(500) NULL COMMENT '备注',
|
||||
initialization_key VARCHAR(128) NOT NULL COMMENT '创建流程幂等键及补偿归属',
|
||||
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID',
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
||||
deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除',
|
||||
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_customer_dimension_config_tenant (tenant_id),
|
||||
CONSTRAINT chk_customer_dimension_edge_addition
|
||||
CHECK (edge_addition_cm >= 0.001 AND edge_addition_cm <= 100.000)
|
||||
) ENGINE=InnoDB COMMENT='客户尺寸计算配置';
|
||||
|
||||
@@ -13,7 +13,13 @@ paths:
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/OptionalTenantId'}
|
||||
- {name: ruleCode, in: query, schema: {type: string, maxLength: 50}}
|
||||
responses: {'200': {description: 成功或业务失败}, '403': {description: 无权限}}
|
||||
responses:
|
||||
'200':
|
||||
description: 成功或业务失败
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/CustomerRuleVersionSummary'}
|
||||
'403': {description: 无权限}
|
||||
/api/platform/v1/fba-pricing/customer-rules/versions:
|
||||
post:
|
||||
summary: 新建客户计费规则版本
|
||||
@@ -51,10 +57,80 @@ paths:
|
||||
summary: 停用船司 FBA 价格版本
|
||||
parameters: [{$ref: '#/components/parameters/Id'}]
|
||||
responses: {'200': {description: 成功或业务失败}, '403': {description: 无权限}}
|
||||
/api/platform/v1/fba-pricing/carrier-rates/price-books:
|
||||
get:
|
||||
summary: 查询船期价格表版本
|
||||
description: 需要 firstleg:fba-pricing:rate:view 权限;返回待确认上传、当前生效和历史替换版本。
|
||||
parameters:
|
||||
- {name: status, in: query, schema: {$ref: '#/components/schemas/PriceBookStatus'}}
|
||||
responses:
|
||||
'200':
|
||||
description: 成功或业务失败
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items: {$ref: '#/components/schemas/PriceBookVersionSummary'}
|
||||
'403': {description: 无权限}
|
||||
post:
|
||||
summary: 上传船期价格表并生成待确认记录
|
||||
description: 仅上传和解析,生成待确认上传记录,不改变当前生效版本;需要 firstleg:fba-pricing:rate:edit 权限。
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
multipart/form-data:
|
||||
schema: {$ref: '#/components/schemas/PriceBookUploadRequest'}
|
||||
responses: {'200': {description: 成功或业务失败}, '403': {description: 无权限}}
|
||||
/api/platform/v1/fba-pricing/carrier-rates/price-books/{versionId}:
|
||||
get:
|
||||
summary: 查询船期价格表版本详情和解析结果
|
||||
parameters: [{$ref: '#/components/parameters/VersionId'}]
|
||||
responses:
|
||||
'200':
|
||||
description: 成功或业务失败
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/PriceBookVersionDetail'}
|
||||
'403': {description: 无权限}
|
||||
/api/platform/v1/fba-pricing/carrier-rates/price-books/{versionId}/publish:
|
||||
post:
|
||||
summary: 确认并立即生效船期价格表版本
|
||||
description: 只确认上传表中的仓库映射,不允许在线改价;阻断错误必须为零。发布事务将上一 ACTIVE 版本标记为 SUPERSEDED,始终只保留一份 ACTIVE。
|
||||
parameters: [{$ref: '#/components/parameters/VersionId'}]
|
||||
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/PriceBookConfirmationRequest'}}}}
|
||||
responses: {'200': {description: 成功或业务失败}, '403': {description: 无权限}}
|
||||
/api/platform/v1/fba-pricing/carrier-rates/price-books/{versionId}/source:
|
||||
get:
|
||||
summary: 下载船期价格表原始文件
|
||||
description: 需要 firstleg:fba-pricing:rate:view 权限;由 Firstleg 代理 File 服务内部下载。
|
||||
parameters: [{$ref: '#/components/parameters/VersionId'}]
|
||||
responses:
|
||||
'200': {description: 原始 XLSX 附件, content: {application/octet-stream: {schema: {type: string, format: binary}}}}
|
||||
'403': {description: 无权限}
|
||||
/api/merchant/v1/first-leg/pricing/channels:
|
||||
get:
|
||||
summary: 查询当前起运仓可选船期渠道
|
||||
description: 客户只看到当前 ACTIVE 版本;不存在于当前版本的渠道不可选择。pricingTaxModes 来自当前起运仓下的生效价格行,前端不得写死包税选项。
|
||||
parameters:
|
||||
- {name: originWarehouseId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
responses:
|
||||
'200':
|
||||
description: 成功或业务失败
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items: {$ref: '#/components/schemas/MerchantPricingChannel'}
|
||||
/api/merchant/v1/first-leg/pricing/estimate:
|
||||
post:
|
||||
summary: 计算头程参考费用
|
||||
description: 只返回基础参考费用,不包含复杂附加费和最终结算调整;pricingTaxMode 不复用订单 taxMode。
|
||||
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/PriceEstimateRequest'}}}}
|
||||
responses: {'200': {description: 成功或业务失败}}
|
||||
/api/platform/v1/inbounds/{inboundId}/fees/calculate:
|
||||
post:
|
||||
summary: 按审核通过测量快照试算 FBA 费用
|
||||
description: 精确匹配唯一客户规则和唯一船司价格;测量审核后即使主流程已到 INBOUNDED,也可独立试算且不回退主流程状态;需要 firstleg:fba-pricing:calculate 权限。
|
||||
description: 仅匹配当前唯一 ACTIVE 船期价格表中的 ACTIVE 价格行,按起运仓、渠道、目的仓、pricingTaxMode 和计费单位估价;不再以 carrierId 或客户计费规则作为前置。海派还需按FBA区域和重量阶梯匹配;测量审核后即使主流程已到 WAIT_SPLIT,也可独立试算且不回退主流程状态;需要 firstleg:fba-pricing:calculate 权限。
|
||||
parameters: [{$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
||||
requestBody: {content: {application/json: {schema: {$ref: '#/components/schemas/CalculateRequest'}}}}
|
||||
responses: {'200': {description: 成功或业务失败}, '403': {description: 无权限}}
|
||||
@@ -94,6 +170,7 @@ components:
|
||||
TenantId: {name: tenantId, in: query, required: true, description: 目标客户 ID;参数名为历史兼容, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
Id: {name: id, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
InboundId: {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
VersionId: {name: versionId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
IdempotencyKey: {name: Idempotency-Key, in: header, required: true, schema: {type: string, maxLength: 128}}
|
||||
schemas:
|
||||
AdjustFeeRequest:
|
||||
@@ -124,7 +201,7 @@ components:
|
||||
effectiveTo: {type: string, format: date-time, nullable: true}
|
||||
CarrierRateVersionRequest:
|
||||
type: object
|
||||
required: [rateCode, originWarehouseId, originWarehouseName, businessType, channelCode, carrierId, carrierName, destinationCode, chargeUnit, unitPrice, currency, additionalFee, effectiveFrom]
|
||||
required: [rateCode, originWarehouseId, originWarehouseName, businessType, channelCode, carrierId, carrierName, destinationType, chargeUnit, unitPrice, currency, additionalFee, effectiveFrom]
|
||||
properties:
|
||||
rateCode: {type: string, maxLength: 50}
|
||||
originWarehouseId: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
@@ -133,10 +210,170 @@ components:
|
||||
channelCode: {type: string}
|
||||
carrierId: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
carrierName: {type: string}
|
||||
destinationCode: {type: string}
|
||||
destinationType: {$ref: '#/components/schemas/PricingDestinationType'}
|
||||
destinationCode: {type: string, nullable: true}
|
||||
destinationWarehouseId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true}
|
||||
destinationName: {type: string, maxLength: 128, nullable: true}
|
||||
chargeUnit: {type: string, enum: [KG, CBM]}
|
||||
unitPrice: {type: number, exclusiveMinimum: 0}
|
||||
currency: {type: string, pattern: '^[A-Z]{3}$'}
|
||||
additionalFee: {type: number, minimum: 0}
|
||||
effectiveFrom: {type: string, format: date-time}
|
||||
effectiveTo: {type: string, format: date-time, nullable: true}
|
||||
PriceBookStatus:
|
||||
type: string
|
||||
enum: [PENDING_CONFIRMATION, ACTIVE, SUPERSEDED, FAILED]
|
||||
PriceModel:
|
||||
type: string
|
||||
enum: [FBA_WAREHOUSE_RATE, REGION_WEIGHT_TIER]
|
||||
PricingTaxMode:
|
||||
type: string
|
||||
enum: [TAX_INCLUDED, TAX_EXCLUDED]
|
||||
PricingDestinationType:
|
||||
type: string
|
||||
description: 价格明细支持的目的地类型;PRIVATE 本期不参与自动估价
|
||||
enum: [FBA, OVERSEAS]
|
||||
PriceBookUploadRequest:
|
||||
type: object
|
||||
required: [file]
|
||||
properties:
|
||||
file: {type: string, format: binary, description: 原始 XLSX 文件}
|
||||
PriceBookVersionSummary:
|
||||
type: object
|
||||
properties:
|
||||
id: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
versionNo: {type: integer}
|
||||
originalFileName: {type: string, maxLength: 255}
|
||||
sourceFileId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true}
|
||||
sourceSha256: {type: string, maxLength: 64, nullable: true}
|
||||
status: {$ref: '#/components/schemas/PriceBookStatus'}
|
||||
channelCount: {type: integer, minimum: 0}
|
||||
rateCount: {type: integer, minimum: 0}
|
||||
blockingErrorCount: {type: integer, minimum: 0}
|
||||
warningCount: {type: integer, minimum: 0}
|
||||
parseIssuesJson: {type: string, nullable: true, description: 上传解析问题及待确认重新校验结果JSON}
|
||||
createdBy: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
createdAt: {type: string, format: date-time}
|
||||
publishedBy: {type: string, pattern: '^[1-9][0-9]*$', nullable: true}
|
||||
publishedAt: {type: string, format: date-time, nullable: true}
|
||||
supersededAt: {type: string, format: date-time, nullable: true}
|
||||
PriceBookVersionDetail:
|
||||
type: object
|
||||
required: [version, channels, rates, issues]
|
||||
properties:
|
||||
version: {$ref: '#/components/schemas/PriceBookVersionSummary'}
|
||||
channels:
|
||||
type: array
|
||||
items: {$ref: '#/components/schemas/PriceBookParsedChannel'}
|
||||
rates:
|
||||
type: array
|
||||
items: {$ref: '#/components/schemas/PriceBookParsedRate'}
|
||||
issues:
|
||||
type: array
|
||||
items: {$ref: '#/components/schemas/PriceBookParseIssue'}
|
||||
PriceBookParseIssue:
|
||||
type: object
|
||||
properties:
|
||||
severity: {type: string}
|
||||
issueCode: {type: string}
|
||||
message: {type: string}
|
||||
sourceSheetName: {type: string, nullable: true}
|
||||
sourceRowNumber: {type: integer, minimum: 1, nullable: true}
|
||||
sourceCell: {type: string, nullable: true}
|
||||
PriceBookConfirmationRequest:
|
||||
type: object
|
||||
required: [originMappings]
|
||||
properties:
|
||||
originMappings:
|
||||
type: array
|
||||
minItems: 1
|
||||
maxItems: 2
|
||||
items: {$ref: '#/components/schemas/PriceBookOriginMapping'}
|
||||
overseasMappings:
|
||||
type: array
|
||||
maxItems: 2
|
||||
items: {$ref: '#/components/schemas/PriceBookOverseasMapping'}
|
||||
PriceBookOriginMapping:
|
||||
type: object
|
||||
required: [originWarehouseCode, warehouseId]
|
||||
properties:
|
||||
originWarehouseCode: {type: string, maxLength: 64, description: 上传表识别出的起运仓稳定编码}
|
||||
warehouseId: {type: string, pattern: '^[1-9][0-9]*$', description: 平台公共国内仓稳定ID}
|
||||
PriceBookOverseasMapping:
|
||||
type: object
|
||||
required: [sourceDestinationName, warehouseId]
|
||||
properties:
|
||||
sourceDestinationName: {type: string, maxLength: 128, description: 上传表中的海外仓名称}
|
||||
warehouseId: {type: string, pattern: '^[1-9][0-9]*$', description: 平台公共海外仓稳定ID}
|
||||
PriceBookParsedChannel:
|
||||
type: object
|
||||
required: [channelCode, channelName, priceModel, sourceSheetName]
|
||||
properties:
|
||||
channelCode: {type: string, maxLength: 64}
|
||||
channelName: {type: string, maxLength: 128}
|
||||
priceModel: {$ref: '#/components/schemas/PriceModel'}
|
||||
sourceSheetName: {type: string, maxLength: 128}
|
||||
estimatedDepartureDate: {type: string, format: date}
|
||||
estimatedDepartureText: {type: string, maxLength: 128}
|
||||
latestWarehouseEntry: {type: string, maxLength: 128}
|
||||
vesselName: {type: string, maxLength: 128}
|
||||
voyageNo: {type: string, maxLength: 64}
|
||||
referenceTransit: {type: string, maxLength: 128}
|
||||
surchargeDescription: {type: string, maxLength: 2000}
|
||||
remark: {type: string, maxLength: 2000}
|
||||
PriceBookParsedRate:
|
||||
type: object
|
||||
required: [channelCode, priceModel, originWarehouseCode, originWarehouseName, chargeUnit, unitPrice, currency, sourceSheetName, sourceRowNumber]
|
||||
properties:
|
||||
id: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
channelCode: {type: string, maxLength: 64}
|
||||
priceModel: {$ref: '#/components/schemas/PriceModel'}
|
||||
originWarehouseId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: 确认时绑定的平台公共国内仓稳定ID}
|
||||
originWarehouseCode: {type: string, maxLength: 64}
|
||||
originWarehouseName: {type: string, maxLength: 128}
|
||||
destinationType: {$ref: '#/components/schemas/PricingDestinationType', nullable: true, description: FBA_WAREHOUSE_RATE 必填;REGION_WEIGHT_TIER 为空}
|
||||
destinationCode: {type: string, maxLength: 32, nullable: true}
|
||||
destinationWarehouseId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: 确认时绑定的平台海外仓稳定ID}
|
||||
destinationName: {type: string, maxLength: 128, nullable: true, description: 目的仓名称快照}
|
||||
regionCode: {type: string, maxLength: 16, nullable: true}
|
||||
pricingTaxMode: {$ref: '#/components/schemas/PricingTaxMode'}
|
||||
chargeUnit: {type: string, enum: [KG, CBM]}
|
||||
weightFromKg: {type: number, minimum: 0, nullable: true}
|
||||
unitPrice: {type: number, exclusiveMinimum: 0}
|
||||
currency: {type: string, pattern: '^[A-Z]{3}$'}
|
||||
sourceSheetName: {type: string, maxLength: 128}
|
||||
sourceRowNumber: {type: integer, minimum: 1}
|
||||
sourceDestinationText: {type: string, maxLength: 1000}
|
||||
remark: {type: string, maxLength: 500}
|
||||
MerchantPricingChannel:
|
||||
type: object
|
||||
required: [priceBookVersionId, channelCode, channelName, priceModel, pricingTaxModes]
|
||||
properties:
|
||||
priceBookVersionId: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
channelCode: {type: string, maxLength: 64}
|
||||
channelName: {type: string, maxLength: 128}
|
||||
priceModel: {$ref: '#/components/schemas/PriceModel'}
|
||||
pricingTaxModes:
|
||||
type: array
|
||||
description: 当前起运仓、当前渠道的生效价格行支持的报价税模式;可能只包含包税或不包税其中一种。
|
||||
items: {$ref: '#/components/schemas/PricingTaxMode'}
|
||||
estimatedDepartureDate: {type: string, format: date, nullable: true}
|
||||
estimatedDepartureText: {type: string, maxLength: 128, nullable: true}
|
||||
latestWarehouseEntry: {type: string, maxLength: 128, nullable: true}
|
||||
referenceTransit: {type: string, maxLength: 128, nullable: true}
|
||||
surchargeDescription: {type: string, maxLength: 2000, nullable: true}
|
||||
remark: {type: string, maxLength: 2000, nullable: true}
|
||||
PriceEstimateRequest:
|
||||
type: object
|
||||
required: [originWarehouseId, channelCode, destinationType]
|
||||
properties:
|
||||
originWarehouseId: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
channelCode: {type: string, maxLength: 64}
|
||||
destinationType: {$ref: '#/components/schemas/PricingDestinationType'}
|
||||
destinationCode: {type: string, maxLength: 32}
|
||||
destinationWarehouseId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true}
|
||||
destinationName: {type: string, maxLength: 128, nullable: true}
|
||||
destinationRegion: {type: string, enum: [WEST, CENTRAL, EAST]}
|
||||
pricingTaxMode: {$ref: '#/components/schemas/PricingTaxMode'}
|
||||
actualWeightKg: {type: number, minimum: 0}
|
||||
actualVolumeCbm: {type: number, minimum: 0}
|
||||
|
||||
@@ -6,10 +6,55 @@ info:
|
||||
security:
|
||||
- bearerAuth: []
|
||||
paths:
|
||||
/internal/firstleg/v1/customer-dimension-configs/defaults:
|
||||
post:
|
||||
summary: 幂等初始化客户尺寸计算默认配置
|
||||
description: 仅供 IAM 在客户创建或注册成功编排中调用;同一客户和 initializationKey 重试稳定返回 createdByThisInitialization=true。
|
||||
security:
|
||||
- internalServiceToken: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/CustomerDimensionConfigInitializeRequest'}
|
||||
responses:
|
||||
'200': {description: 成功或业务失败,data 返回 configId 和 createdByThisInitialization}
|
||||
delete:
|
||||
summary: 补偿删除本次新建且尚未修改的默认配置
|
||||
description: 仅供 IAM 创建客户事务补偿使用;同时匹配 initializationKey、默认2cm和version=0才物理删除。
|
||||
security:
|
||||
- internalServiceToken: []
|
||||
parameters:
|
||||
- {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
- {name: initializationKey, in: query, required: true, schema: {type: string, minLength: 1, maxLength: 128}}
|
||||
responses:
|
||||
'200': {description: 幂等补偿完成}
|
||||
/api/platform/v1/first-leg/customer-dimension-config:
|
||||
get:
|
||||
summary: 查询客户尺寸计算配置
|
||||
description: 需要 firstleg:dimension-config:view 权限;配置只描述真实尺寸的每侧加宽值,当前不参与订单或计费。
|
||||
parameters:
|
||||
- {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
responses:
|
||||
'200': {description: 成功时 data 为 CustomerDimensionConfigResponse}
|
||||
'403': {description: 无查询权限}
|
||||
put:
|
||||
summary: 编辑客户尺寸计算配置
|
||||
description: 需要 firstleg:dimension-config:edit 权限;使用 version 执行乐观锁更新。
|
||||
parameters:
|
||||
- {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/CustomerDimensionConfigUpdateRequest'}
|
||||
responses:
|
||||
'200': {description: 成功时 data 为更新后的 CustomerDimensionConfigResponse}
|
||||
'403': {description: 无编辑权限}
|
||||
/internal/firstleg/v1/inbounds/order-approved:
|
||||
post:
|
||||
summary: 订单审核通过后幂等创建入库单
|
||||
description: 仅供订单服务调用;入仓码由订单提交时生成,Firstleg 必须原样沿用。承运商可在后续履约或计费前补充。
|
||||
description: 仅供订单服务调用;入仓码由订单提交时生成,Firstleg 必须原样沿用。
|
||||
security:
|
||||
- internalServiceToken: []
|
||||
requestBody:
|
||||
@@ -174,7 +219,9 @@ paths:
|
||||
签名body的 SHA-256 小写十六进制,六部分使用 LF(\n)连接且末尾不追加换行。
|
||||
签名body = data原始UTF-8文本 + LF + image文件内容SHA-256小写十六进制。
|
||||
三层语义:Nonce 防短时重放;Idempotency-Key 标识一次 HTTP 写操作;
|
||||
appKey + eventNo 标识第三方业务事件。
|
||||
appKey + eventNo 标识第三方业务事件。多台设备可共用同一 appKey/secret,
|
||||
但 deviceCode、eventNo 和 Idempotency-Key 必须由调用方生成全局唯一值,
|
||||
不得仅在单台设备内从 1 开始递增。
|
||||
security:
|
||||
- measurementAppKey: []
|
||||
measurementTimestamp: []
|
||||
@@ -245,6 +292,86 @@ paths:
|
||||
responses:
|
||||
'200': {description: 成功或业务失败,按响应体 code 判断}
|
||||
'403': {description: 无查看权限}
|
||||
/api/platform/inbounds/{inboundId}/carton-label-pdf:
|
||||
post:
|
||||
summary: 生成或补打入仓箱唛 PDF
|
||||
description: 需要 platform:firstleg:inbound:view 权限;首次打印冻结整单箱号,重复打印和部分补打复用原映射。
|
||||
parameters:
|
||||
- {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
- {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
- name: cartonIndex
|
||||
in: query
|
||||
required: false
|
||||
style: form
|
||||
explode: true
|
||||
schema: {type: array, items: {type: integer, minimum: 1}}
|
||||
description: 不传时打印整单;可重复传入以部分补打指定箱下标。
|
||||
responses:
|
||||
'200':
|
||||
description: 成功返回 PDF;业务失败返回统一 JSON 响应,调用方必须按 Content-Type 区分。
|
||||
content:
|
||||
application/pdf: {schema: {type: string, format: binary}}
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [code, message]
|
||||
properties:
|
||||
code: {type: string}
|
||||
message: {type: string}
|
||||
requestId: {type: string, nullable: true}
|
||||
traceId: {type: string, nullable: true}
|
||||
'403': {description: 无查看权限}
|
||||
/api/platform/v1/first-leg/inbounds/{inboundId}/measurement/carton-scans:
|
||||
post:
|
||||
summary: 平台模拟单箱设备扫描
|
||||
description: >-
|
||||
需要 platform:firstleg:inbound:measure:edit 权限;仅用于平台联调和代设备录入,
|
||||
复用正式单箱覆盖规则,不代表最终第三方设备协议或鉴权方式。
|
||||
parameters:
|
||||
- {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
- {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
multipart/form-data:
|
||||
schema:
|
||||
type: object
|
||||
required: [data, image]
|
||||
properties:
|
||||
data:
|
||||
type: object
|
||||
required: [boxCode, length, width, height, machineNo, measuredAt]
|
||||
properties:
|
||||
boxCode: {type: string, maxLength: 128}
|
||||
length: {type: number, format: double, minimum: 0.001}
|
||||
width: {type: number, format: double, minimum: 0.001}
|
||||
height: {type: number, format: double, minimum: 0.001}
|
||||
weight: {type: number, format: double, minimum: 0.001, nullable: true}
|
||||
machineNo: {type: string, maxLength: 64}
|
||||
measuredAt: {type: string, format: date-time}
|
||||
image: {type: string, format: binary}
|
||||
responses:
|
||||
'200': {description: 成功或业务失败,按响应体 code 判断}
|
||||
'403': {description: 无测量编辑权限}
|
||||
/api/platform/v1/first-leg/inbounds/{inboundId}/weight-results/{resultId}/confirm:
|
||||
post:
|
||||
summary: 确认 OCR 重量
|
||||
description: >-
|
||||
需要 platform:firstleg:inbound:weight:confirm 权限;携带人工修改字段时还需要
|
||||
platform:firstleg:inbound:weight:edit 权限。修改与确认在同一事务内完成,保留 OCR 原值,
|
||||
分别写入 CORRECT 和 CONFIRM 修订记录,并重算所有已确认图片的实际总毛重。
|
||||
parameters:
|
||||
- {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
- {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
- {name: resultId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/ConfirmWeightResultRequest'}
|
||||
responses:
|
||||
'200': {description: 成功或业务失败,按响应体 code 判断}
|
||||
'403': {description: 无确认权限,或修改重量时无编辑权限}
|
||||
/api/platform/v1/inbounds/{inboundId}/measurements/approve:
|
||||
post:
|
||||
summary: 审核通过测量
|
||||
@@ -365,6 +492,52 @@ components:
|
||||
required: true
|
||||
schema: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
schemas:
|
||||
CustomerDimensionConfigUpdateRequest:
|
||||
type: object
|
||||
required: [edgeAdditionCm, version]
|
||||
properties:
|
||||
edgeAdditionCm:
|
||||
type: number
|
||||
minimum: 0.001
|
||||
maximum: 100
|
||||
multipleOf: 0.001
|
||||
description: 真实长、宽、高三个方向分别使用的每侧加宽厘米数
|
||||
example: 2.000
|
||||
remark: {type: string, maxLength: 500, nullable: true}
|
||||
version: {type: integer, minimum: 0, description: 乐观锁版本号}
|
||||
CustomerDimensionConfigResponse:
|
||||
type: object
|
||||
required: [id, tenantId, edgeAdditionCm, version]
|
||||
properties:
|
||||
id: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
tenantId: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
edgeAdditionCm: {type: number, minimum: 0.001, maximum: 100, example: 2.000}
|
||||
remark: {type: string, nullable: true}
|
||||
version: {type: integer, minimum: 0}
|
||||
createdAt: {type: string, format: date-time, nullable: true}
|
||||
updatedAt: {type: string, format: date-time, nullable: true}
|
||||
CustomerDimensionConfigInitializeRequest:
|
||||
type: object
|
||||
required: [tenantId, operatorId, initializationKey]
|
||||
properties:
|
||||
tenantId: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
operatorId: {type: string, pattern: '^[0-9]+$'}
|
||||
initializationKey: {type: string, minLength: 1, maxLength: 128}
|
||||
CustomerDimensionConfigInitializeResponse:
|
||||
type: object
|
||||
required: [configId, createdByThisInitialization]
|
||||
properties:
|
||||
configId: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
createdByThisInitialization: {type: boolean}
|
||||
ConfirmWeightResultRequest:
|
||||
type: object
|
||||
required: [version]
|
||||
description: 人工修改字段必须同时提供;全部省略时直接确认现有 OCR 或已修正重量。
|
||||
properties:
|
||||
version: {type: integer, minimum: 0}
|
||||
adjustedWeightValue: {type: number, format: double, minimum: 0.001, nullable: true}
|
||||
adjustedWeightUnit: {type: string, enum: [KG, LB], nullable: true}
|
||||
adjustmentReason: {type: string, minLength: 1, maxLength: 500, nullable: true}
|
||||
OrderApprovedInboundCommand:
|
||||
type: object
|
||||
required: [tenantId, orderId, inboundCode, customerName, warehouseId]
|
||||
@@ -383,9 +556,10 @@ components:
|
||||
businessType: {type: string, nullable: true}
|
||||
channelCode: {type: string, nullable: true}
|
||||
channelName: {type: string, nullable: true}
|
||||
carrierId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: 可选,可在后续履约或计费前补充}
|
||||
carrierId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: 历史兼容的内部运输供应商快照}
|
||||
carrierName: {type: string, nullable: true}
|
||||
taxMode: {type: string, nullable: true}
|
||||
pricingTaxMode: {type: string, enum: [TAX_INCLUDED, TAX_EXCLUDED], nullable: true, description: 报价包税维度;独立于订单taxMode}
|
||||
containerMode: {type: string, nullable: true}
|
||||
customsInspection: {type: boolean, nullable: true}
|
||||
destinationType: {type: string, nullable: true}
|
||||
@@ -571,6 +745,8 @@ components:
|
||||
processStatus: {type: string}
|
||||
customerId: {type: string, nullable: true}
|
||||
companyId: {type: string, nullable: true}
|
||||
taxMode: {type: string, nullable: true, description: 订单报关方式快照}
|
||||
pricingTaxMode: {type: string, enum: [TAX_INCLUDED, TAX_EXCLUDED], nullable: true, description: 下单时选择的报价包税维度快照}
|
||||
InboundMeasurementCallbackRequest:
|
||||
type: object
|
||||
required: [eventNo, externalDetailNo, warehouseCode, inboundCode, inboundDetailId, deviceCode, measuredAt, boxSpecification, packageCount, lengthCm, widthCm, heightCm]
|
||||
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
package com.baiyue.firstleg.controller;
|
||||
|
||||
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.Resource;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@SpringJUnitConfig(InternalCustomerDimensionConfigController.class)
|
||||
class CustomerDimensionConfigControllerContractTest {
|
||||
@Resource
|
||||
private InternalCustomerDimensionConfigController internalController;
|
||||
@MockBean
|
||||
private CustomerDimensionConfigService service;
|
||||
|
||||
@BeforeEach
|
||||
void initializeToken() {
|
||||
ReflectionTestUtils.setField(internalController, "internalToken", "test-token");
|
||||
internalController.initializeToken();
|
||||
}
|
||||
|
||||
@Test
|
||||
void internalInitializationRequiresTokenAndReturnsCreationIdentity() {
|
||||
InitializeRequest request = new InitializeRequest(8L, 100L, "registration-1");
|
||||
when(service.initializeDefault(8L, 100L, "registration-1"))
|
||||
.thenReturn(new InitializeResponse(91L, true));
|
||||
|
||||
assertThatThrownBy(() -> internalController.initializeDefault("wrong", request))
|
||||
.isInstanceOf(BizException.class);
|
||||
assertThat(internalController.initializeDefault("test-token", request).data())
|
||||
.isEqualTo(new InitializeResponse(91L, true));
|
||||
verify(service).initializeDefault(8L, 100L, "registration-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void platformControllerKeepsScopeAndExistingPermissions() throws Exception {
|
||||
RequestMapping mapping = PlatformCustomerDimensionConfigController.class
|
||||
.getAnnotation(RequestMapping.class);
|
||||
assertThat(mapping.value()).containsExactly("/api/platform/v1/first-leg/customer-dimension-config");
|
||||
assertThat(PlatformCustomerDimensionConfigController.class
|
||||
.getDeclaredMethod("get", com.baiyue.common.security.AuthenticatedUser.class, long.class)
|
||||
.getAnnotation(PreAuthorize.class).value())
|
||||
.isEqualTo("hasAuthority('firstleg:dimension-config:view')");
|
||||
assertThat(PlatformCustomerDimensionConfigController.class
|
||||
.getDeclaredMethod("update", com.baiyue.common.security.AuthenticatedUser.class,
|
||||
long.class, com.baiyue.firstleg.dto.CustomerDimensionConfigDtos.UpdateRequest.class)
|
||||
.getAnnotation(PreAuthorize.class).value())
|
||||
.isEqualTo("hasAuthority('firstleg:dimension-config:edit')");
|
||||
}
|
||||
}
|
||||
+6
@@ -9,6 +9,7 @@ import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@@ -19,6 +20,8 @@ class InboundOrderControllerContractTest {
|
||||
AuthenticatedUser.class, Long.class, InboundOrderQuery.class);
|
||||
assertAuthority("detail", "hasAuthority('platform:firstleg:inbound:view')",
|
||||
AuthenticatedUser.class, long.class, long.class);
|
||||
assertAuthority("printCartonLabels", "hasAuthority('platform:firstleg:inbound:view')",
|
||||
AuthenticatedUser.class, long.class, long.class, List.class, String.class);
|
||||
|
||||
Method page = PlatformInboundOrderController.class.getDeclaredMethod(
|
||||
"page", AuthenticatedUser.class, Long.class, InboundOrderQuery.class);
|
||||
@@ -41,9 +44,12 @@ class InboundOrderControllerContractTest {
|
||||
assertThat(openapi)
|
||||
.contains("/api/platform/inbounds:")
|
||||
.contains("/api/platform/inbounds/{inboundId}:")
|
||||
.contains("/api/platform/inbounds/{inboundId}/carton-label-pdf:")
|
||||
.contains("/api/platform/v1/first-leg/inbounds/{inboundId}/measurement/carton-scans:")
|
||||
.contains("required: false")
|
||||
.contains("InboundOrderSummary:")
|
||||
.contains("required: [id, tenantId]")
|
||||
.contains("pricingTaxMode: {type: string, enum: [TAX_INCLUDED, TAX_EXCLUDED]")
|
||||
.contains("tenantId: {type: string, pattern: '^[1-9][0-9]*$', description:")
|
||||
.contains("[WAIT_MEASURE, WAIT_REVIEW, WAIT_FEE_SEND, INBOUNDED]")
|
||||
.contains("platform:firstleg:inbound:list")
|
||||
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
package com.baiyue.firstleg.controller;
|
||||
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.firstleg.dto.CartonMeasurementCommand;
|
||||
import com.baiyue.firstleg.dto.CartonMeasurementResult;
|
||||
import com.baiyue.firstleg.service.CartonMeasurementService;
|
||||
import com.baiyue.firstleg.service.InboundMeasurementService;
|
||||
import com.baiyue.firstleg.service.MeasurementSubmissionService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.core.MethodParameter;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.mock.web.MockPart;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
import org.springframework.web.context.request.NativeWebRequest;
|
||||
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
|
||||
import org.springframework.web.method.support.ModelAndViewContainer;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
class PlatformCartonScanControllerTest {
|
||||
@Test
|
||||
void bindsJsonDataAndImageToTheFormalCartonMeasurementCommand() throws Exception {
|
||||
CartonMeasurementService cartonMeasurements = mock(CartonMeasurementService.class);
|
||||
PlatformInboundMeasurementController controller = new PlatformInboundMeasurementController();
|
||||
ReflectionTestUtils.setField(controller, "service", mock(InboundMeasurementService.class));
|
||||
ReflectionTestUtils.setField(controller, "submission", mock(MeasurementSubmissionService.class));
|
||||
ReflectionTestUtils.setField(controller, "cartonMeasurements", cartonMeasurements);
|
||||
AuthenticatedUser user = new AuthenticatedUser(90001L, 0L, 0L, null,
|
||||
"platform_admin", "PLATFORM", "WEB", Set.of("platform:firstleg:inbound:measure:edit"));
|
||||
MockMvc mvc = MockMvcBuilders.standaloneSetup(controller)
|
||||
.setCustomArgumentResolvers(authenticationPrincipal(user))
|
||||
.build();
|
||||
when(cartonMeasurements.upsertForInbound(eq(10001L), eq(90001L), eq(70001L), any()))
|
||||
.thenReturn(new CartonMeasurementResult(30001L, 1, 40001L, "RK-001#5-1", 1,
|
||||
new BigDecimal("60.1"), new BigDecimal("40.2"), new BigDecimal("30.3"),
|
||||
new BigDecimal("12.5"), 99001L, "SIM-01",
|
||||
LocalDateTime.parse("2026-08-07T10:30:00"), true));
|
||||
MockPart data = new MockPart("data", "data", ("""
|
||||
{"boxCode":"RK-001#5-1","length":60.1,"width":40.2,"height":30.3,
|
||||
"weight":12.5,"machineNo":"SIM-01","measuredAt":"2026-08-07T10:30:00"}
|
||||
""").getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||
data.getHeaders().setContentType(MediaType.APPLICATION_JSON);
|
||||
MockMultipartFile image = new MockMultipartFile(
|
||||
"image", "carton.jpg", MediaType.IMAGE_JPEG_VALUE, new byte[] {1, 2, 3});
|
||||
|
||||
mvc.perform(multipart("/api/platform/v1/first-leg/inbounds/{inboundId}/measurement/carton-scans", 70001L)
|
||||
.file(image).part(data).param("tenantId", "10001"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value("200"))
|
||||
.andExpect(jsonPath("$.data.boxCode").value("RK-001#5-1"));
|
||||
|
||||
ArgumentCaptor<CartonMeasurementCommand> command = ArgumentCaptor.forClass(CartonMeasurementCommand.class);
|
||||
verify(cartonMeasurements).upsertForInbound(
|
||||
eq(10001L), eq(90001L), eq(70001L), command.capture());
|
||||
assertThat(command.getValue().machineNo()).isEqualTo("SIM-01");
|
||||
assertThat(command.getValue().image().getOriginalFilename()).isEqualTo("carton.jpg");
|
||||
}
|
||||
|
||||
private HandlerMethodArgumentResolver authenticationPrincipal(AuthenticatedUser user) {
|
||||
return new HandlerMethodArgumentResolver() {
|
||||
@Override
|
||||
public boolean supportsParameter(MethodParameter parameter) {
|
||||
return parameter.hasParameterAnnotation(AuthenticationPrincipal.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
|
||||
NativeWebRequest webRequest,
|
||||
org.springframework.web.bind.support.WebDataBinderFactory binderFactory) {
|
||||
return user;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
+9
-1
@@ -5,6 +5,7 @@ import com.baiyue.firstleg.dto.InboundMeasurementRequests;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
|
||||
@@ -22,13 +23,20 @@ class PlatformInboundMeasurementControllerContractTest {
|
||||
assertAuthority("submit", "hasAuthority('platform:firstleg:inbound:measure:submit')",
|
||||
AuthenticatedUser.class, long.class, long.class,
|
||||
InboundMeasurementRequests.SubmitRequest.class);
|
||||
assertAuthority("updateConfirmedPackageCount", "hasAuthority('platform:firstleg:inbound:measure:edit')",
|
||||
AuthenticatedUser.class, long.class, long.class, long.class,
|
||||
InboundMeasurementRequests.ConfirmedPackageCountRequest.class);
|
||||
assertAuthority("simulateCartonScan", "hasAuthority('platform:firstleg:inbound:measure:edit')",
|
||||
AuthenticatedUser.class, long.class, long.class,
|
||||
InboundMeasurementRequests.CartonScanRequest.class, MultipartFile.class);
|
||||
assertAuthority("createWeightResult", "hasAuthority('platform:firstleg:inbound:weight:edit')",
|
||||
AuthenticatedUser.class, long.class, long.class,
|
||||
InboundMeasurementRequests.CreateWeightResultRequest.class);
|
||||
assertAuthority("correctWeightResult", "hasAuthority('platform:firstleg:inbound:weight:edit')",
|
||||
AuthenticatedUser.class, long.class, long.class, long.class,
|
||||
InboundMeasurementRequests.CorrectWeightResultRequest.class);
|
||||
assertAuthority("confirmWeightResult", "hasAuthority('platform:firstleg:inbound:weight:confirm')",
|
||||
assertAuthority("confirmWeightResult", "hasAuthority('platform:firstleg:inbound:weight:confirm')"
|
||||
+ " and (!#request.hasAdjustment() or hasAuthority('platform:firstleg:inbound:weight:edit'))",
|
||||
AuthenticatedUser.class, long.class, long.class, long.class,
|
||||
InboundMeasurementRequests.ConfirmWeightResultRequest.class);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package com.baiyue.firstleg.controller;
|
||||
|
||||
import com.baiyue.firstleg.domain.PriceBookChannelCatalog;
|
||||
import com.baiyue.firstleg.domain.PriceBookStatus;
|
||||
import com.baiyue.firstleg.domain.PriceModel;
|
||||
import com.baiyue.firstleg.domain.PricingTaxMode;
|
||||
import com.baiyue.firstleg.domain.PricingDestinationType;
|
||||
import com.baiyue.firstleg.dto.PriceBookDtos;
|
||||
import com.baiyue.firstleg.mapper.PriceBookQueryMapper;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@DisplayName("船期价格表公共契约")
|
||||
class PriceBookContractTest {
|
||||
@Test
|
||||
@DisplayName("固定十五个渠道且海派使用区域重量模型")
|
||||
void keepsStableChannelCatalog() {
|
||||
assertThat(PriceBookChannelCatalog.CHANNELS).hasSize(15);
|
||||
assertThat(PriceBookChannelCatalog.CHANNELS.stream()
|
||||
.map(PriceBookChannelCatalog.ChannelDefinition::channelCode))
|
||||
.containsExactly(
|
||||
"MATSON_CLX_TRUCK", "MATSON_MAX_TRUCK", "EXX_TRUCK", "HEDE_TRUCK", "EXX_RAIL_NY",
|
||||
"OA_LA_TRUCK", "OA_LA_FULL_TRUCK", "OA_CHICAGO_RAIL", "OA_HOUSTON_TRUCK",
|
||||
"OA_NEW_YORK_TRUCK", "OA_SAVANNAH_TRUCK", "MATSON_CLX_PARCEL", "MATSON_MAX_PARCEL",
|
||||
"HEDE_PARCEL", "OA_PARCEL");
|
||||
assertThat(PriceBookChannelCatalog.CHANNELS.stream()
|
||||
.filter(channel -> channel.priceModel() == PriceModel.REGION_WEIGHT_TIER))
|
||||
.extracting(PriceBookChannelCatalog.ChannelDefinition::channelCode)
|
||||
.containsExactly("MATSON_CLX_PARCEL", "MATSON_MAX_PARCEL", "HEDE_PARCEL", "OA_PARCEL");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("报价包税维度独立于订单税模式")
|
||||
void keepsPricingTaxModeSeparate() {
|
||||
assertThat(PriceBookStatus.values()).containsExactly(
|
||||
PriceBookStatus.PENDING_CONFIRMATION, PriceBookStatus.ACTIVE,
|
||||
PriceBookStatus.SUPERSEDED, PriceBookStatus.FAILED);
|
||||
assertThat(PricingTaxMode.values()).containsExactly(PricingTaxMode.TAX_INCLUDED, PricingTaxMode.TAX_EXCLUDED);
|
||||
assertThat(PricingDestinationType.values()).containsExactly(PricingDestinationType.FBA, PricingDestinationType.OVERSEAS);
|
||||
assertThat(PriceBookDtos.ParsedRate.class.getRecordComponents())
|
||||
.extracting(component -> component.getName())
|
||||
.contains("pricingTaxMode", "originWarehouseId", "destinationType", "destinationCode", "destinationWarehouseId", "destinationName", "regionCode");
|
||||
assertThat(PriceBookDtos.EstimateRequest.class.getRecordComponents())
|
||||
.extracting(component -> component.getName())
|
||||
.contains("destinationType", "destinationWarehouseId", "destinationName");
|
||||
assertThat(PriceBookDtos.CurrentChannel.class.getRecordComponents())
|
||||
.extracting(component -> component.getName())
|
||||
.contains("pricingTaxModes");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("渠道税模式只汇总当前版本和起运仓的生效价格行")
|
||||
void scopesChannelPricingTaxModesToActiveRows() throws Exception {
|
||||
Select select = PriceBookQueryMapper.class
|
||||
.getMethod("findChannelTaxModes", long.class, long.class)
|
||||
.getAnnotation(Select.class);
|
||||
|
||||
assertThat(String.join("\n", select.value()))
|
||||
.contains("r.price_book_version_id = #{versionId}")
|
||||
.contains("r.origin_warehouse_id = #{originWarehouseId}")
|
||||
.contains("r.status = 'ACTIVE'")
|
||||
.contains("r.deleted = 0")
|
||||
.contains("r.pricing_tax_mode IS NOT NULL")
|
||||
.contains("GROUP BY r.channel_code, r.pricing_tax_mode");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("OpenAPI冻结待确认上传、发布和客户参考估价路径")
|
||||
void documentsPriceBookLifecycle() throws Exception {
|
||||
String openapi = new ClassPathResource("openapi/fba-pricing-v1.yaml")
|
||||
.getContentAsString(StandardCharsets.UTF_8);
|
||||
assertThat(openapi)
|
||||
.contains("/api/platform/v1/fba-pricing/carrier-rates/price-books:")
|
||||
.contains("/api/platform/v1/fba-pricing/carrier-rates/price-books/{versionId}:")
|
||||
.contains("/api/platform/v1/fba-pricing/carrier-rates/price-books/{versionId}/publish:")
|
||||
.contains("/api/merchant/v1/first-leg/pricing/channels:")
|
||||
.contains("/api/merchant/v1/first-leg/pricing/estimate:")
|
||||
.contains("PriceBookStatus")
|
||||
.contains("FBA_WAREHOUSE_RATE")
|
||||
.contains("REGION_WEIGHT_TIER")
|
||||
.contains("pricingTaxMode")
|
||||
.contains("PricingDestinationType")
|
||||
.contains("MerchantPricingChannel")
|
||||
.contains("pricingTaxModes")
|
||||
.contains("destinationWarehouseId")
|
||||
.contains("parseIssuesJson")
|
||||
.contains("PriceBookVersionDetail")
|
||||
.contains("PriceBookParseIssue")
|
||||
.contains("/api/platform/v1/fba-pricing/carrier-rates/price-books/{versionId}/source")
|
||||
.contains("确认并立即生效")
|
||||
.contains("SUPERSEDED");
|
||||
}
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
package com.baiyue.firstleg.dto;
|
||||
|
||||
import jakarta.validation.Validation;
|
||||
import jakarta.validation.Validator;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.Arguments;
|
||||
import org.junit.jupiter.params.provider.MethodSource;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class ConfirmWeightResultRequestValidationTest {
|
||||
private final Validator validator = Validation.buildDefaultValidatorFactory().getValidator();
|
||||
|
||||
@Test
|
||||
void acceptsConfirmationWithoutAdjustment() {
|
||||
var request = new InboundMeasurementRequests.ConfirmWeightResultRequest(
|
||||
0, null, null, null);
|
||||
|
||||
assertThat(validator.validate(request)).isEmpty();
|
||||
assertThat(request.hasAdjustment()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptsDocumentedWeightBoundaries() {
|
||||
var minimum = new InboundMeasurementRequests.ConfirmWeightResultRequest(
|
||||
0, new BigDecimal("0.001"), "KG", "最小值");
|
||||
var maximum = new InboundMeasurementRequests.ConfirmWeightResultRequest(
|
||||
Integer.MAX_VALUE, new BigDecimal("999999999999999.999"), "LB", "最大值");
|
||||
|
||||
assertThat(validator.validate(minimum)).isEmpty();
|
||||
assertThat(validator.validate(maximum)).isEmpty();
|
||||
assertThat(minimum.hasAdjustment()).isTrue();
|
||||
assertThat(maximum.hasAdjustment()).isTrue();
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource("invalidRequests")
|
||||
void rejectsInvalidFieldBoundaries(
|
||||
InboundMeasurementRequests.ConfirmWeightResultRequest request,
|
||||
String invalidField) {
|
||||
assertThat(validator.validate(request))
|
||||
.extracting(violation -> violation.getPropertyPath().toString())
|
||||
.contains(invalidField);
|
||||
}
|
||||
|
||||
private static Stream<Arguments> invalidRequests() {
|
||||
return Stream.of(
|
||||
Arguments.of(request(-1, "1.000", "KG", "原因"), "version"),
|
||||
Arguments.of(request(0, "0.000", "KG", "原因"), "adjustedWeightValue"),
|
||||
Arguments.of(request(0, "-1.000", "KG", "原因"), "adjustedWeightValue"),
|
||||
Arguments.of(request(0, "1.0001", "KG", "原因"), "adjustedWeightValue"),
|
||||
Arguments.of(request(0, "1000000000000000.000", "KG", "原因"), "adjustedWeightValue"),
|
||||
Arguments.of(request(0, "1.000", "KILOGRAMS", "原因"), "adjustedWeightUnit"),
|
||||
Arguments.of(request(0, "1.000", "KG", "x".repeat(501)), "adjustmentReason")
|
||||
);
|
||||
}
|
||||
|
||||
private static InboundMeasurementRequests.ConfirmWeightResultRequest request(
|
||||
int version, String value, String unit, String reason) {
|
||||
return new InboundMeasurementRequests.ConfirmWeightResultRequest(
|
||||
version, new BigDecimal(value), unit, reason);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.baiyue.firstleg.mapper;
|
||||
|
||||
import org.apache.ibatis.annotations.Delete;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@DisplayName("价格版本渠道 Mapper 契约")
|
||||
class PriceBookChannelMapperTest {
|
||||
@Test
|
||||
@DisplayName("重新生成版本快照时使用物理删除避免唯一键冲突")
|
||||
void shouldPhysicallyDeleteVersionChannels() throws Exception {
|
||||
Method method = PriceBookChannelMapper.class
|
||||
.getMethod("physicallyDeleteByPriceBookVersionId", long.class);
|
||||
|
||||
assertThat(method.getAnnotation(Delete.class).value())
|
||||
.containsExactly("DELETE FROM fl_price_book_channel WHERE price_book_version_id = #{versionId}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.baiyue.firstleg.pricing;
|
||||
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.repository.PriceBookQueryRows.RateRow;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class FbaWarehouseRateEstimatorTest {
|
||||
private final FbaWarehouseRateEstimator estimator = new FbaWarehouseRateEstimator();
|
||||
|
||||
@Test
|
||||
void shouldUseGreaterOfActualAndVolumeWeightForKgRate() {
|
||||
var estimate = estimator.estimate(rate("KG", "12.50"),
|
||||
new BigDecimal("20"), new BigDecimal("0.20"));
|
||||
|
||||
assertThat(estimate.quantity()).isEqualByComparingTo("33.400000");
|
||||
assertThat(estimate.unit()).isEqualTo("KG");
|
||||
assertThat(estimate.unitPrice()).isEqualByComparingTo("12.50");
|
||||
assertThat(estimate.amount()).isEqualByComparingTo("417.50");
|
||||
assertThat(estimate.currency()).isEqualTo("CNY");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldUseGreaterOfVolumeAndWeightConversionForCbmRate() {
|
||||
var estimate = estimator.estimate(rate("CBM", "880"),
|
||||
new BigDecimal("363"), new BigDecimal("0.50"));
|
||||
|
||||
assertThat(estimate.quantity()).isEqualByComparingTo("1.000000");
|
||||
assertThat(estimate.amount()).isEqualByComparingTo("880.00");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectMissingWeightAndVolume() {
|
||||
assertThatThrownBy(() -> estimator.estimate(rate("KG", "10"), null, BigDecimal.ZERO))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("至少填写一项");
|
||||
}
|
||||
|
||||
private static RateRow rate(String unit, String price) {
|
||||
RateRow row = new RateRow();
|
||||
row.setChargeUnit(unit);
|
||||
row.setUnitPrice(new BigDecimal(price));
|
||||
row.setCurrency("CNY");
|
||||
return row;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.baiyue.firstleg.pricing;
|
||||
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.domain.PriceRegion;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.CsvSource;
|
||||
import org.junit.jupiter.params.provider.NullSource;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class PriceRegionResolverTest {
|
||||
private final PriceRegionResolver resolver = new PriceRegionResolver();
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource({
|
||||
"90001, WEST",
|
||||
"60601, CENTRAL",
|
||||
"10001, EAST",
|
||||
"'US 46201', EAST"
|
||||
})
|
||||
void shouldResolveUnitedStatesPostalCode(String postalCode, PriceRegion expected) {
|
||||
assertThat(resolver.fromPostalCode(postalCode)).isEqualTo(expected);
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@NullSource
|
||||
@ValueSource(strings = {"", "USA"})
|
||||
void shouldRejectPostalCodeWithoutDigits(String postalCode) {
|
||||
assertThatThrownBy(() -> resolver.fromPostalCode(postalCode))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("无法确定海派区域");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.baiyue.firstleg.pricing;
|
||||
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.repository.PriceBookQueryRows.RateRow;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class RegionWeightTierEstimatorTest {
|
||||
private final RegionWeightTierEstimator estimator = new RegionWeightTierEstimator();
|
||||
private final List<RateRow> tiers = List.of(tier("12", "15"), tier("51", "13"), tier("101", "11"));
|
||||
|
||||
@Test
|
||||
void shouldChooseHighestTierNotExceedingChargeableWeightAtBoundaries() {
|
||||
assertThat(estimator.estimate(tiers, new BigDecimal("12"), null).unitPrice())
|
||||
.isEqualByComparingTo("15");
|
||||
assertThat(estimator.estimate(tiers, new BigDecimal("50.999999"), null).unitPrice())
|
||||
.isEqualByComparingTo("15");
|
||||
assertThat(estimator.estimate(tiers, new BigDecimal("51"), null).unitPrice())
|
||||
.isEqualByComparingTo("13");
|
||||
assertThat(estimator.estimate(tiers, new BigDecimal("101"), null).unitPrice())
|
||||
.isEqualByComparingTo("11");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldUseVolumeWeightBeforeSelectingTier() {
|
||||
var estimate = estimator.estimate(tiers, new BigDecimal("20"), new BigDecimal("0.61"));
|
||||
|
||||
assertThat(estimate.quantity()).isEqualByComparingTo("101.870000");
|
||||
assertThat(estimate.unitPrice()).isEqualByComparingTo("11");
|
||||
assertThat(estimate.amount()).isEqualByComparingTo("1120.57");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectWeightBelowLowestTier() {
|
||||
assertThatThrownBy(() -> estimator.estimate(tiers, new BigDecimal("11.999999"), null))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("最低重量阶梯");
|
||||
}
|
||||
|
||||
private static RateRow tier(String from, String price) {
|
||||
RateRow row = new RateRow();
|
||||
row.setChargeUnit("KG");
|
||||
row.setWeightFromKg(new BigDecimal(from));
|
||||
row.setUnitPrice(new BigDecimal(price));
|
||||
row.setCurrency("CNY");
|
||||
return row;
|
||||
}
|
||||
}
|
||||
+206
@@ -0,0 +1,206 @@
|
||||
package com.baiyue.firstleg.pricing.importer;
|
||||
|
||||
import com.baiyue.firstleg.domain.PricingTaxMode;
|
||||
|
||||
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
|
||||
import org.junit.jupiter.api.Assumptions;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipInputStream;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class PriceWorkbookParserTest {
|
||||
private static final List<String> STANDARD_SHEETS = List.of(
|
||||
"美森正班海卡", "美森加班海卡", "EXX海卡", "合德海卡", "纽约EXX海铁",
|
||||
"洛杉矶普船海卡", "洛杉矶普船全卡", "芝加哥普船海铁", "休斯顿普船海卡",
|
||||
"纽约普船海卡", "萨凡纳普船海卡");
|
||||
|
||||
@Test
|
||||
void parsesFifteenChannelsAndIgnoresMalformedQuickQueryTable() throws Exception {
|
||||
Path workbook = malformedWorkbook();
|
||||
PriceWorkbookParseResult result = new PriceWorkbookParser().parse(workbook);
|
||||
|
||||
assertEquals(15, result.channels().size());
|
||||
assertFalse(result.hasBlockingErrors());
|
||||
assertTrue(result.ignoredSheets().contains("快速查询"));
|
||||
|
||||
PriceWorkbookParseResult.ParsedChannel matson = result.channels().stream()
|
||||
.filter(channel -> channel.channelCode().equals("MATSON_CLX_TRUCK")).findFirst().orElseThrow();
|
||||
List<PriceWorkbookParseResult.ParsedRate> ont = matson.rates().stream()
|
||||
.filter(rate -> "ONT8".equals(rate.destinationCode())).toList();
|
||||
assertEquals(3, ont.size());
|
||||
assertTrue(ont.stream().anyMatch(rate -> rate.originWarehouseKey().equals("YIWU")
|
||||
&& rate.taxMode() == PricingTaxMode.TAX_EXCLUDED
|
||||
&& rate.chargeUnit().equals("CBM")
|
||||
&& rate.unitPrice().compareTo(new BigDecimal("1790")) == 0));
|
||||
assertTrue(ont.stream().anyMatch(rate -> rate.originWarehouseKey().equals("DONGGUAN")
|
||||
&& rate.taxMode() == PricingTaxMode.TAX_INCLUDED
|
||||
&& rate.unitPrice().compareTo(new BigDecimal("10.7")) == 0));
|
||||
|
||||
PriceWorkbookParseResult.ParsedChannel parcel = result.channels().stream()
|
||||
.filter(channel -> channel.channelCode().equals("MATSON_CLX_PARCEL")).findFirst().orElseThrow();
|
||||
assertEquals(18, parcel.rates().size());
|
||||
assertTrue(parcel.rates().stream().allMatch(rate -> rate.taxMode() == PricingTaxMode.TAX_INCLUDED));
|
||||
assertTrue(parcel.rates().stream().anyMatch(rate -> rate.originWarehouseKey().equals("DONGGUAN")
|
||||
&& rate.regionCode().equals("EAST")
|
||||
&& rate.minimumWeightKg().compareTo(BigDecimal.valueOf(101)) == 0), parcel.rates().toString());
|
||||
PriceWorkbookParseResult.ParsedChannel hedeParcel = result.channels().stream()
|
||||
.filter(channel -> channel.channelCode().equals("HEDE_PARCEL")).findFirst().orElseThrow();
|
||||
assertTrue(hedeParcel.rates().stream().anyMatch(rate -> rate.originWarehouseKey().equals("YIWU")
|
||||
&& rate.regionCode().equals("WEST")
|
||||
&& rate.minimumWeightKg().compareTo(BigDecimal.valueOf(12)) == 0
|
||||
&& rate.unitPrice().compareTo(new BigDecimal("15.8")) == 0), hedeParcel.rates().toString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsUnknownDestinationAsBlockingMappingIssueInsteadOfDroppingTheRow() throws Exception {
|
||||
Path workbook = minimalStandardWorkbook("未知仓组", "1790", "10.2", "10.7");
|
||||
PriceWorkbookParseResult result = new PriceWorkbookParser().parse(workbook);
|
||||
assertTrue(result.hasBlockingErrors());
|
||||
assertTrue(result.issues().stream().anyMatch(issue -> issue.code().equals("DESTINATION_MAPPING_REQUIRED")));
|
||||
assertTrue(result.channels().stream().flatMap(channel -> channel.rates().stream())
|
||||
.anyMatch(rate -> rate.rawDestinationGroup().equals("未知仓组") && rate.destinationCode() == null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void parsesPlatformOverseasWarehouseRowsWithoutGuessingWarehouseIdOrCode() throws Exception {
|
||||
Path workbook = minimalStandardWorkbook("海外仓(洛杉矶仓)", "1790", "10.2", "10.7");
|
||||
PriceWorkbookParseResult result = new PriceWorkbookParser().parse(workbook);
|
||||
|
||||
assertFalse(result.hasBlockingErrors());
|
||||
List<PriceWorkbookParseResult.ParsedRate> rates = result.channels().stream()
|
||||
.flatMap(channel -> channel.rates().stream()).toList();
|
||||
assertEquals(3, rates.size());
|
||||
assertTrue(rates.stream().allMatch(rate -> "OVERSEAS".equals(rate.destinationType())
|
||||
&& "洛杉矶仓".equals(rate.destinationName())
|
||||
&& rate.destinationCode() == null
|
||||
&& rate.rawDestinationGroup().equals("海外仓(洛杉矶仓)")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void parsesTheRealWorkbookWhenAnExternalPathIsProvided() throws Exception {
|
||||
String configured = System.getProperty("price.workbook");
|
||||
Assumptions.assumeTrue(configured != null && !configured.isBlank());
|
||||
PriceWorkbookParseResult result = new PriceWorkbookParser().parse(Path.of(configured));
|
||||
System.out.printf("REAL_WORKBOOK channels=%d rates=%d issues=%d blocking=%d ignored=%s%n",
|
||||
result.channels().size(), result.rateCount(), result.issues().size(),
|
||||
result.issues().stream().filter(issue -> issue.severity() == PriceWorkbookParseResult.Severity.ERROR).count(),
|
||||
result.ignoredSheets());
|
||||
result.issues().forEach(issue -> System.out.printf("REAL_ISSUE %s %s sheet=%s row=%s raw=%s%n",
|
||||
issue.severity(), issue.code(), issue.sheetName(), issue.rowNumber(), issue.rawValue()));
|
||||
assertEquals(15, result.channels().size());
|
||||
assertTrue(result.channels().stream().flatMap(channel -> channel.rates().stream())
|
||||
.anyMatch(rate -> "ONT8".equals(rate.destinationCode())));
|
||||
assertTrue(result.channels().stream().anyMatch(channel -> channel.channelCode().equals("OA_PARCEL")));
|
||||
assertFalse(result.hasBlockingErrors());
|
||||
assertFalse(result.issues().stream().anyMatch(issue ->
|
||||
issue.code().equals("OVERSEAS_WAREHOUSE_NOT_INCLUDED")));
|
||||
assertTrue(result.channels().stream().flatMap(channel -> channel.rates().stream())
|
||||
.anyMatch(rate -> "OVERSEAS".equals(rate.destinationType())
|
||||
&& "洛杉矶仓".equals(rate.destinationName())
|
||||
&& rate.destinationCode() == null));
|
||||
assertTrue(result.channels().stream().flatMap(channel -> channel.rates().stream())
|
||||
.anyMatch(rate -> "OVERSEAS".equals(rate.destinationType())
|
||||
&& "纽约仓".equals(rate.destinationName())
|
||||
&& rate.destinationCode() == null));
|
||||
}
|
||||
|
||||
private static Path malformedWorkbook() throws IOException {
|
||||
Path valid = minimalWorkbook();
|
||||
Path malformed = Files.createTempFile("price-parser-malformed-", ".xlsx");
|
||||
try (ZipInputStream input = new ZipInputStream(Files.newInputStream(valid));
|
||||
OutputStream output = Files.newOutputStream(malformed);
|
||||
ZipOutputStream zip = new ZipOutputStream(output)) {
|
||||
ZipEntry entry;
|
||||
while ((entry = input.getNextEntry()) != null) {
|
||||
zip.putNextEntry(new ZipEntry(entry.getName()));
|
||||
input.transferTo(zip);
|
||||
zip.closeEntry();
|
||||
}
|
||||
zip.putNextEntry(new ZipEntry("xl/tables/table1.xml"));
|
||||
zip.write("<table xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\" ref=\"5:31\"/>".getBytes());
|
||||
zip.closeEntry();
|
||||
} finally {
|
||||
Files.deleteIfExists(valid);
|
||||
}
|
||||
return malformed;
|
||||
}
|
||||
|
||||
private static Path minimalWorkbook() throws IOException {
|
||||
Path file = Files.createTempFile("price-parser-", ".xlsx");
|
||||
try (XSSFWorkbook workbook = new XSSFWorkbook()) {
|
||||
for (int i = 0; i < STANDARD_SHEETS.size(); i++) {
|
||||
String sheetName = STANDARD_SHEETS.get(i);
|
||||
var sheet = workbook.createSheet(sheetName);
|
||||
sheet.createRow(1).createCell(0).setCellValue("船名航次:DANIEL K. INOUYE V.100E 上海出,预计开船日期:2026年8月12日");
|
||||
boolean shifted = sheetName.equals("纽约EXX海铁");
|
||||
int base = shifted ? 1 : 0;
|
||||
sheet.createRow(3).createCell(base).setCellValue("FBA仓库");
|
||||
sheet.getRow(3).createCell(base + 1).setCellValue("华东仓(义乌)");
|
||||
sheet.getRow(3).createCell(base + 3).setCellValue("华南仓(深圳)");
|
||||
var row = sheet.createRow(5);
|
||||
row.createCell(base).setCellValue("ONT8/LGB8");
|
||||
row.createCell(base + 1).setCellValue(1790);
|
||||
row.createCell(base + 2).setCellValue(10.2);
|
||||
row.createCell(base + 3).setCellValue(10.7);
|
||||
row.createCell(base + 4).setCellValue("20天左右");
|
||||
row.createCell(base + 5).setCellValue("周日全天");
|
||||
row.createCell(base + 6).setCellValue("MATSON 上海周三开");
|
||||
}
|
||||
var parcel = workbook.createSheet("海派");
|
||||
parcel.createRow(4).createCell(2).setCellValue("MASTON 周三开");
|
||||
parcel.getRow(4).createCell(5).setCellValue("MASTON 周四开");
|
||||
parcel.getRow(4).createCell(8).setCellValue("合德 周四开");
|
||||
parcel.getRow(4).createCell(11).setCellValue("普船统配");
|
||||
parcel.createRow(5).createCell(2).setCellValue("船开后15天左右");
|
||||
parcel.createRow(8).createCell(0).setCellValue("美西");
|
||||
parcel.createRow(9).createCell(0).setCellValue("美中");
|
||||
parcel.createRow(10).createCell(0).setCellValue("美东");
|
||||
for (int row = 8; row <= 10; row++) {
|
||||
for (int col = 2; col <= 13; col++) parcel.getRow(row).createCell(col).setCellValue(15 + col / 10.0);
|
||||
}
|
||||
parcel.createRow(13).createCell(2).setCellValue("MASTON 周三开");
|
||||
parcel.createRow(14).createCell(2).setCellValue("船开后15天左右");
|
||||
parcel.createRow(17).createCell(0).setCellValue("美西");
|
||||
parcel.createRow(18).createCell(0).setCellValue("美中");
|
||||
parcel.createRow(19).createCell(0).setCellValue("美东");
|
||||
for (int row = 17; row <= 19; row++) {
|
||||
for (int col = 2; col <= 13; col++) parcel.getRow(row).createCell(col).setCellValue(16 + col / 10.0);
|
||||
}
|
||||
var quick = workbook.createSheet("快速查询");
|
||||
quick.createRow(4).createCell(0).setCellValue("列1");
|
||||
try (OutputStream output = Files.newOutputStream(file)) { workbook.write(output); }
|
||||
}
|
||||
return file;
|
||||
}
|
||||
|
||||
private static Path minimalStandardWorkbook(String destination, String east, String yiwuTax, String dongguanTax) throws IOException {
|
||||
Path file = Files.createTempFile("price-parser-unknown-", ".xlsx");
|
||||
try (XSSFWorkbook workbook = new XSSFWorkbook()) {
|
||||
var sheet = workbook.createSheet("美森正班海卡");
|
||||
sheet.createRow(3).createCell(0).setCellValue("FBA仓库");
|
||||
var row = sheet.createRow(5);
|
||||
row.createCell(0).setCellValue(destination);
|
||||
row.createCell(1).setCellValue(Double.parseDouble(east));
|
||||
row.createCell(2).setCellValue(Double.parseDouble(yiwuTax));
|
||||
row.createCell(3).setCellValue(Double.parseDouble(dongguanTax));
|
||||
try (OutputStream output = Files.newOutputStream(file)) { workbook.write(output); }
|
||||
}
|
||||
return file;
|
||||
}
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
package com.baiyue.firstleg.repository;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.apache.ibatis.annotations.Delete;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class CustomerDimensionConfigSqlContractTest {
|
||||
@Test
|
||||
void moduleMigrationDefinesUniqueCustomerConfigAndDefault() throws IOException {
|
||||
String sql = new ClassPathResource("db/customer-dimension-config.sql")
|
||||
.getContentAsString(StandardCharsets.UTF_8);
|
||||
|
||||
assertThat(sql)
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_customer_dimension_config")
|
||||
.contains("edge_addition_cm DECIMAL(6,3) NOT NULL DEFAULT 2.000")
|
||||
.contains("initialization_key VARCHAR(128) NOT NULL")
|
||||
.contains("UNIQUE KEY uk_customer_dimension_config_tenant (tenant_id)");
|
||||
}
|
||||
|
||||
@Test
|
||||
void unifiedMigrationIncludesConfigAndFinalAssertion() throws IOException {
|
||||
String sql = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"),
|
||||
StandardCharsets.UTF_8);
|
||||
|
||||
assertThat(sql)
|
||||
.contains("CREATE TABLE IF NOT EXISTS baiyue_firstleg.fl_customer_dimension_config")
|
||||
.contains("客户尺寸计算配置表未创建")
|
||||
.contains("uk_customer_dimension_config_tenant")
|
||||
.contains("CREATE PROCEDURE unified_guard_dimension_config_permissions()")
|
||||
.contains("id = 5102111 AND COALESCE(permission_code, '') <> 'firstleg:dimension-config:view'")
|
||||
.contains("permission_code = 'firstleg:dimension-config:view' AND id <> 5102111")
|
||||
.contains("id = 5102112 AND COALESCE(permission_code, '') <> 'firstleg:dimension-config:edit'")
|
||||
.contains("permission_code = 'firstleg:dimension-config:edit' AND id <> 5102112")
|
||||
.contains("(5102111, 0, 'PLATFORM', 'BUTTON', 5100010")
|
||||
.contains("(5102112, 0, 'PLATFORM', 'BUTTON', 5100010")
|
||||
.contains("r.role_code = 'PLATFORM_ADMIN'")
|
||||
.contains("r.role_code = 'ORDER_OPERATOR'")
|
||||
.contains("客户尺寸计算配置权限父节点企业客户不存在")
|
||||
.contains("客户尺寸计算配置权限码、固定ID或父节点不正确")
|
||||
.contains("平台管理员未获得客户尺寸计算配置读写权限")
|
||||
.contains("订单运营角色的客户尺寸计算配置权限不是只读");
|
||||
}
|
||||
|
||||
@Test
|
||||
void compensationDeleteRequiresExactKeyDefaultValueAndUnmodifiedVersion() throws Exception {
|
||||
Delete annotation = com.baiyue.firstleg.mapper.CustomerDimensionConfigMapper.class
|
||||
.getDeclaredMethod("deleteUnchangedDefault", long.class, long.class,
|
||||
String.class, java.math.BigDecimal.class)
|
||||
.getAnnotation(Delete.class);
|
||||
String sql = String.join(" ", annotation.value());
|
||||
|
||||
assertThat(sql)
|
||||
.contains("tenant_id = #{tenantId}")
|
||||
.contains("initialization_key = #{initializationKey}")
|
||||
.contains("edge_addition_cm = #{edgeAdditionCm}")
|
||||
.contains("version = 0");
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package com.baiyue.firstleg.repository;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class InboundCartonLabelSqlContractTest {
|
||||
@Test
|
||||
void baseSchemaAndExistingDatabaseMigrationKeepStableCartonKeys() throws Exception {
|
||||
String schema = resource("db/phase2-inbound-schema.sql");
|
||||
String migration = resource("db/phase2-inbound-carton-label.sql");
|
||||
String unified = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"));
|
||||
|
||||
assertThat(schema).contains("CREATE TABLE IF NOT EXISTS fl_inbound_carton_label")
|
||||
.contains("UNIQUE KEY uk_carton_label_box_code (tenant_id, box_code)")
|
||||
.contains("UNIQUE KEY uk_carton_label_index (tenant_id, inbound_id, carton_index)")
|
||||
.contains("inbound_detail_id BIGINT UNSIGNED NOT NULL");
|
||||
assertThat(migration).contains("CREATE TABLE IF NOT EXISTS fl_inbound_carton_label")
|
||||
.contains("固定二维码内容:入仓码#总箱数-箱下标")
|
||||
.contains("idx_carton_label_detail (tenant_id, inbound_id, inbound_detail_id)");
|
||||
assertThat(unified).contains("CREATE TABLE IF NOT EXISTS fl_inbound_carton_label")
|
||||
.contains("uk_carton_label_box_code")
|
||||
.contains("uk_carton_label_index")
|
||||
.contains("稳定箱号映射表未创建");
|
||||
}
|
||||
|
||||
private String resource(String path) throws Exception {
|
||||
return new ClassPathResource(path).getContentAsString(StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
+25
@@ -5,6 +5,8 @@ import org.junit.jupiter.api.Test;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@@ -38,4 +40,27 @@ class InboundWorkflowSqlContractTest {
|
||||
.contains("SET warehouse_resource_tenant_id = 0")
|
||||
.contains("MODIFY COLUMN warehouse_resource_tenant_id BIGINT UNSIGNED NOT NULL");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("单箱当前测量记录包含箱号、设备元数据和唯一约束")
|
||||
void shouldPersistCurrentCartonMeasurementFields() throws Exception {
|
||||
String schema = new ClassPathResource("db/phase2-inbound-schema.sql")
|
||||
.getContentAsString(StandardCharsets.UTF_8);
|
||||
String migration = new ClassPathResource("db/phase2-carton-measurement-current-record.sql")
|
||||
.getContentAsString(StandardCharsets.UTF_8);
|
||||
String unified = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"));
|
||||
assertThat(schema)
|
||||
.contains("box_code VARCHAR(128)")
|
||||
.contains("machine_no VARCHAR(64)")
|
||||
.contains("device_measured_at DATETIME(3)")
|
||||
.contains("uk_measure_box_code");
|
||||
assertThat(migration)
|
||||
.contains("phase2_add_carton_measurement_column")
|
||||
.contains("uk_measure_box_code")
|
||||
.contains("information_schema.STATISTICS");
|
||||
assertThat(unified)
|
||||
.contains("CALL phase2_measure_add_column('fl_inbound_measurement_item', 'box_code'")
|
||||
.contains("uk_measure_box_code")
|
||||
.contains("单箱当前测量字段未完整创建");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.baiyue.firstleg.repository;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@DisplayName("船期价格表SQL契约")
|
||||
class PriceBookSqlContractTest {
|
||||
@Test
|
||||
@DisplayName("基线包含整表版本、固定渠道、起运仓别名和两类价格明细")
|
||||
void shouldProvidePriceBookTables() throws Exception {
|
||||
String schema = new ClassPathResource("db/phase2-inbound-schema.sql")
|
||||
.getContentAsString(StandardCharsets.UTF_8);
|
||||
String migration = new ClassPathResource("db/phase2-fba-pricing-migration.sql")
|
||||
.getContentAsString(StandardCharsets.UTF_8);
|
||||
String channelTable = schema.substring(
|
||||
schema.indexOf("CREATE TABLE IF NOT EXISTS fl_price_book_channel"),
|
||||
schema.indexOf("ENGINE=InnoDB COMMENT='价格版本渠道及船期快照'")
|
||||
);
|
||||
String rateTable = schema.substring(
|
||||
schema.indexOf("CREATE TABLE IF NOT EXISTS fl_carrier_fba_rate"),
|
||||
schema.indexOf("ENGINE=InnoDB COMMENT='船司FBA目的仓价格版本'")
|
||||
);
|
||||
assertThat(schema)
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_price_channel_catalog")
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_price_origin_alias")
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_price_warehouse_mapping")
|
||||
.contains("华南仓(深圳/东莞)")
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_price_book_version")
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_price_book_channel")
|
||||
.contains("price_book_version_id")
|
||||
.contains("price_model VARCHAR(32)")
|
||||
.contains("pricing_tax_mode VARCHAR(16)")
|
||||
.contains("source_sheet_name VARCHAR(128)")
|
||||
.contains("source_row_number INT UNSIGNED")
|
||||
.contains("destination_type VARCHAR(16)")
|
||||
.contains("destination_warehouse_id BIGINT UNSIGNED")
|
||||
.contains("destination_name VARCHAR(128)")
|
||||
.contains("parse_issues_json JSON NULL")
|
||||
.contains("DEFAULT 'PENDING_CONFIRMATION'");
|
||||
assertThat(schema)
|
||||
.contains("'ORIGIN_CODE', 'CN-YIWU-01', 193000000000200001")
|
||||
.contains("'ORIGIN_CODE', 'CN-DONGGUAN-01', 193000000000200002")
|
||||
.contains("'OVERSEAS_NAME', '洛杉矶仓', 193000000000200003")
|
||||
.contains("'OVERSEAS_NAME', '纽约仓', 193000000000200004");
|
||||
assertThat(channelTable)
|
||||
.contains("created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID'")
|
||||
.contains("updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID'")
|
||||
.contains("deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记'")
|
||||
.contains("version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号'");
|
||||
assertThat(rateTable)
|
||||
.contains("status VARCHAR(24) NOT NULL DEFAULT 'ACTIVE'")
|
||||
.contains("PENDING_CONFIRMATION、ACTIVE或SUPERSEDED");
|
||||
assertThat(migration)
|
||||
.doesNotContain("fl_price_book_version")
|
||||
.doesNotContain("'pricing_tax_mode'")
|
||||
.doesNotContain("'price_book_version_id'");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package com.baiyue.firstleg.service;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
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 org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.transaction.support.TransactionCallback;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
class CartonMeasurementServiceTest {
|
||||
private final InboundMeasurementRepository repository = mock(InboundMeasurementRepository.class);
|
||||
@SuppressWarnings("unchecked")
|
||||
private final ObjectProvider<CartonLabelBindingResolver> resolverProvider = mock(ObjectProvider.class);
|
||||
private final CartonLabelBindingResolver resolver = mock(CartonLabelBindingResolver.class);
|
||||
private final MeasurementImageClient images = mock(MeasurementImageClient.class);
|
||||
private final TransactionTemplate transactions = mock(TransactionTemplate.class);
|
||||
private CartonMeasurementService service;
|
||||
private final AtomicReference<InboundMeasurementItemEntity> insertedItem = new AtomicReference<>();
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new CartonMeasurementService(repository, resolverProvider, images, transactions);
|
||||
ReflectionTestUtils.setField(service, "internalToken", "internal-token");
|
||||
when(resolverProvider.getIfAvailable()).thenReturn(resolver);
|
||||
when(resolver.resolveByBoxCode(10001L, "RK-001#5-1"))
|
||||
.thenReturn(new CartonLabelBindingResolver.Binding(70001L, 80001L, "RK-001", 1, 5, "50x40x30cm"));
|
||||
when(repository.findInbound(10001L, 70001L)).thenReturn(inbound("WAIT_MEASURE"));
|
||||
when(repository.lockInbound(10001L, 70001L)).thenReturn(inbound("WAIT_MEASURE"));
|
||||
when(images.upload(any(), any(Long.TYPE), any(Long.TYPE), any(Long.TYPE), any()))
|
||||
.thenReturn(ApiResponse.success(new MeasurementImageClient.UploadResult(
|
||||
99001L, "measure.jpg", "image/jpeg", 10L, "NOT_REQUIRED", null, null, null, null)));
|
||||
when(transactions.execute(any())).thenAnswer(invocation -> {
|
||||
@SuppressWarnings("unchecked")
|
||||
TransactionCallback<CartonMeasurementResult> callback = invocation.getArgument(0);
|
||||
return callback.doInTransaction(mock(org.springframework.transaction.TransactionStatus.class));
|
||||
});
|
||||
doAnswer(invocation -> {
|
||||
insertedItem.set(invocation.getArgument(0));
|
||||
return null;
|
||||
}).when(repository).insertItem(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void firstScanCreatesOneRecordWithConfirmedCountOne() {
|
||||
InboundMeasurementEntity measurement = measurement(30001L, "RECEIVED", 0);
|
||||
when(repository.findItemByBoxCode(10001L, "RK-001#5-1")).thenReturn(null);
|
||||
when(repository.findLatestEditableMeasurement(10001L, 70001L)).thenReturn(measurement);
|
||||
when(repository.listItems(10001L, 30001L)).thenAnswer(invocation -> List.of(insertedItem.get()));
|
||||
when(repository.updateMeasurement(measurement)).thenReturn(true);
|
||||
|
||||
CartonMeasurementResult result = service.upsert(10001L, 90001L, command());
|
||||
|
||||
assertThat(result.created()).isTrue();
|
||||
assertThat(result.confirmedPackageCount()).isEqualTo(1);
|
||||
assertThat(result.boxCode()).isEqualTo("RK-001#5-1");
|
||||
assertThat(insertedItem.get().getMeasurementId()).isEqualTo(30001L);
|
||||
assertThat(insertedItem.get().getTotalVolumeCbm()).isEqualByComparingTo("0.0733");
|
||||
}
|
||||
|
||||
@Test
|
||||
void repeatedScanReplacesDeviceValuesButPreservesManualCountAndOldImageIsDeleted() {
|
||||
InboundMeasurementEntity measurement = measurement(30001L, "RECEIVED", 4);
|
||||
InboundMeasurementItemEntity existing = item(40001L, 30001L, 7, 88001L);
|
||||
when(repository.findItemByBoxCode(10001L, "RK-001#5-1")).thenReturn(existing);
|
||||
when(repository.findMeasurement(10001L, 70001L, 30001L)).thenReturn(measurement);
|
||||
when(repository.updateItem(existing)).thenReturn(true);
|
||||
when(repository.listItems(10001L, 30001L)).thenReturn(List.of(existing));
|
||||
when(repository.updateMeasurement(measurement)).thenReturn(true);
|
||||
|
||||
CartonMeasurementResult result = service.upsert(10001L, 90001L, command());
|
||||
|
||||
assertThat(result.created()).isFalse();
|
||||
assertThat(result.confirmedPackageCount()).isEqualTo(7);
|
||||
assertThat(existing.getLengthCm()).isEqualByComparingTo("60.100");
|
||||
assertThat(existing.getSourceImageFileId()).isEqualTo(99001L);
|
||||
verify(images).delete("internal-token", 90001L, 10001L, 70001L, 88001L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void submittedInboundRejectsDeviceBeforeUploadingImage() {
|
||||
when(repository.findInbound(10001L, 70001L)).thenReturn(inbound("MEASURE_PENDING"));
|
||||
|
||||
assertThatThrownBy(() -> service.upsert(10001L, 90001L, command()))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("不能新增或覆盖");
|
||||
|
||||
verify(images, never()).upload(any(), any(Long.TYPE), any(Long.TYPE), any(Long.TYPE), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void platformSimulationRejectsBoxCodeFromAnotherInboundBeforeUploadingImage() {
|
||||
assertThatThrownBy(() -> service.upsertForInbound(10001L, 90001L, 70002L, command()))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("箱号不属于当前入库单");
|
||||
|
||||
verify(images, never()).upload(any(), any(Long.TYPE), any(Long.TYPE), any(Long.TYPE), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void confirmedPackageCountUsesMeasurementOptimisticVersion() {
|
||||
InboundOrderEntity inbound = inbound("WAIT_MEASURE");
|
||||
InboundMeasurementEntity measurement = measurement(30001L, "RECEIVED", 2);
|
||||
InboundMeasurementItemEntity item = item(40001L, 30001L, 1, 99001L);
|
||||
when(repository.findItem(10001L, 40001L)).thenReturn(item);
|
||||
when(repository.findMeasurement(10001L, 70001L, 30001L)).thenReturn(measurement);
|
||||
when(repository.updateItem(item)).thenReturn(true);
|
||||
when(repository.listItems(10001L, 30001L)).thenReturn(List.of(item));
|
||||
when(repository.updateMeasurement(measurement)).thenReturn(true);
|
||||
|
||||
CartonMeasurementResult result = service.updateConfirmedPackageCount(
|
||||
10001L, 70001L, 40001L, 2, 5);
|
||||
|
||||
assertThat(result.confirmedPackageCount()).isEqualTo(5);
|
||||
assertThat(item.getPackageCount()).isEqualTo(5);
|
||||
verify(repository).updateMeasurement(measurement);
|
||||
}
|
||||
|
||||
@Test
|
||||
void confirmedPackageCountCanBeCorrectedAfterSubmissionWithoutChangingOriginalPackageCount() {
|
||||
InboundOrderEntity inbound = inbound("MEASURE_PENDING");
|
||||
InboundMeasurementEntity measurement = measurement(30001L, "SUBMITTED", 2);
|
||||
measurement.setTotalPackageCount(1);
|
||||
measurement.setTotalVolumeCbm(new BigDecimal("0.0600"));
|
||||
measurement.setVolumeWeightKg(new BigDecimal("10.020"));
|
||||
InboundMeasurementItemEntity item = item(40001L, 30001L, 1, 99001L);
|
||||
when(repository.findInbound(10001L, 70001L)).thenReturn(inbound);
|
||||
when(repository.findItem(10001L, 40001L)).thenReturn(item);
|
||||
when(repository.findMeasurement(10001L, 70001L, 30001L)).thenReturn(measurement);
|
||||
when(repository.updateItem(item)).thenReturn(true);
|
||||
when(repository.listItems(10001L, 30001L)).thenReturn(List.of(item));
|
||||
when(repository.updateMeasurement(measurement)).thenReturn(true);
|
||||
when(repository.updateInbound(inbound)).thenReturn(true);
|
||||
|
||||
service.updateConfirmedPackageCount(10001L, 70001L, 40001L, 2, 5);
|
||||
|
||||
assertThat(inbound.getTotalPackageCount()).isEqualTo(5);
|
||||
assertThat(inbound.getActualVolumeCbm()).isEqualByComparingTo("0.3000");
|
||||
assertThat(inbound.getVolumeWeightKg()).isEqualByComparingTo("50.100");
|
||||
verify(repository).updateInbound(inbound);
|
||||
}
|
||||
|
||||
private CartonMeasurementCommand command() {
|
||||
return new CartonMeasurementCommand("RK-001#5-1", new BigDecimal("60.100"),
|
||||
new BigDecimal("40.200"), new BigDecimal("30.300"), new BigDecimal("12.500"),
|
||||
"MJ-01", LocalDateTime.parse("2026-08-07T10:30:00"),
|
||||
new MockMultipartFile("image", "measure.jpg", "image/jpeg", "image".getBytes()));
|
||||
}
|
||||
|
||||
private InboundOrderEntity inbound(String status) {
|
||||
InboundOrderEntity inbound = new InboundOrderEntity();
|
||||
inbound.setId(70001L); inbound.setTenantId(10001L); inbound.setProcessStatus(status);
|
||||
inbound.setTotalPackageCount(5); inbound.setVersion(0);
|
||||
return inbound;
|
||||
}
|
||||
|
||||
private InboundMeasurementEntity measurement(long id, String status, int version) {
|
||||
InboundMeasurementEntity measurement = new InboundMeasurementEntity();
|
||||
measurement.setId(id); measurement.setTenantId(10001L); measurement.setInboundId(70001L);
|
||||
measurement.setStatus(status); measurement.setVersion(version);
|
||||
return measurement;
|
||||
}
|
||||
|
||||
private InboundMeasurementItemEntity item(long id, long measurementId, int packageCount, long imageId) {
|
||||
InboundMeasurementItemEntity item = new InboundMeasurementItemEntity();
|
||||
item.setId(id); item.setTenantId(10001L); item.setMeasurementId(measurementId);
|
||||
item.setBoxCode("RK-001#5-1"); item.setPackageCount(packageCount);
|
||||
item.setLengthCm(new BigDecimal("50")); item.setWidthCm(new BigDecimal("40"));
|
||||
item.setHeightCm(new BigDecimal("30")); item.setUnitWeightKg(new BigDecimal("10"));
|
||||
item.setSourceImageFileId(imageId); item.setItemNo("B1"); item.setBoxSpecification("50x40x30cm");
|
||||
return item;
|
||||
}
|
||||
}
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
package com.baiyue.firstleg.service;
|
||||
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.dto.CustomerDimensionConfigDtos.InitializeResponse;
|
||||
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.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@SpringJUnitConfig(CustomerDimensionConfigService.class)
|
||||
class CustomerDimensionConfigServiceTest {
|
||||
@MockBean
|
||||
private CustomerDimensionConfigRepository repository;
|
||||
@Resource
|
||||
private CustomerDimensionConfigService service;
|
||||
|
||||
@Test
|
||||
void initializesTwoCentimeterDefaultExactlyOnce() {
|
||||
CustomerDimensionConfigEntity persisted = config(91L, 8L, "2.000", 0, "registration-1");
|
||||
when(repository.find(8L)).thenReturn(null, persisted);
|
||||
when(repository.insertDefault(anyLong(), eq(8L), eq(new BigDecimal("2.000")),
|
||||
eq("默认每侧加宽 2cm"), eq("registration-1"), eq(100L))).thenReturn(true);
|
||||
|
||||
InitializeResponse result = service.initializeDefault(8L, 100L, "registration-1");
|
||||
|
||||
assertThat(result.createdByThisInitialization()).isTrue();
|
||||
assertThat(result.configId()).isEqualTo(91L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void retryAfterLostResponseKeepsCreationOwnershipForSameKey() {
|
||||
when(repository.find(8L)).thenReturn(config(91L, 8L, "2.000", 0, "registration-1"));
|
||||
|
||||
InitializeResponse result = service.initializeDefault(8L, 100L, "registration-1");
|
||||
|
||||
assertThat(result).isEqualTo(new InitializeResponse(91L, true));
|
||||
verify(repository, never()).insertDefault(anyLong(), anyLong(),
|
||||
eq(new BigDecimal("2.000")), eq("默认每侧加宽 2cm"),
|
||||
eq("registration-1"), anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
void differentInitializationKeyDoesNotTakeOwnershipOrOverwrite() {
|
||||
when(repository.find(8L)).thenReturn(config(91L, 8L, "2.000", 0, "registration-1"));
|
||||
|
||||
InitializeResponse result = service.initializeDefault(8L, 100L, "registration-2");
|
||||
|
||||
assertThat(result).isEqualTo(new InitializeResponse(91L, false));
|
||||
verify(repository, never()).insertDefault(anyLong(), anyLong(),
|
||||
eq(new BigDecimal("2.000")), eq("默认每侧加宽 2cm"),
|
||||
eq("registration-2"), anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsStaleUpdateVersion() {
|
||||
when(repository.find(8L)).thenReturn(config(91L, 8L, "2.000", 3, "registration-1"));
|
||||
|
||||
assertThatThrownBy(() -> service.update(8L, 100L,
|
||||
new UpdateRequest(new BigDecimal("2.500"), "新配置", 2)))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("刷新后重试");
|
||||
verify(repository, never()).update(org.mockito.ArgumentMatchers.any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void compensationUsesTenantKeyDefaultValueAndUnmodifiedVersion() {
|
||||
when(repository.find(8L)).thenReturn(config(91L, 8L, "2.000", 0, "registration-1"));
|
||||
|
||||
service.compensateDefault(8L, "registration-1");
|
||||
|
||||
verify(repository).deleteUnchangedDefault(
|
||||
8L, 91L, "registration-1", new BigDecimal("2.000"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void editedConfigIsNotCompensatedByDelayedRollback() {
|
||||
when(repository.find(8L)).thenReturn(config(91L, 8L, "2.500", 1, "registration-1"));
|
||||
|
||||
service.compensateDefault(8L, "registration-1");
|
||||
|
||||
verify(repository).deleteUnchangedDefault(
|
||||
8L, 91L, "registration-1", new BigDecimal("2.000"));
|
||||
}
|
||||
|
||||
private static CustomerDimensionConfigEntity config(long id, long tenantId,
|
||||
String edgeAddition, int version,
|
||||
String initializationKey) {
|
||||
CustomerDimensionConfigEntity value = new CustomerDimensionConfigEntity();
|
||||
value.setId(id);
|
||||
value.setTenantId(tenantId);
|
||||
value.setEdgeAdditionCm(new BigDecimal(edgeAddition));
|
||||
value.setVersion(version);
|
||||
value.setInitializationKey(initializationKey);
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -2,14 +2,16 @@ 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.FbaPricingDtos.FeeSnapshotResponse;
|
||||
import com.baiyue.firstleg.entity.CarrierFbaRateEntity;
|
||||
import com.baiyue.firstleg.entity.CustomerChargeRuleEntity;
|
||||
import com.baiyue.firstleg.entity.InboundFeeEntity;
|
||||
import com.baiyue.firstleg.entity.InboundFeeItemEntity;
|
||||
import com.baiyue.firstleg.entity.InboundPricingEntity;
|
||||
import com.baiyue.firstleg.entity.InboundPricingIdempotencyEntity;
|
||||
import com.baiyue.firstleg.pricing.ActivePriceBookEstimator;
|
||||
import com.baiyue.firstleg.repository.FbaPricingRepository;
|
||||
import com.baiyue.firstleg.repository.PriceBookQueryRows.RateRow;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -24,7 +26,6 @@ import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
@@ -33,7 +34,6 @@ import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
@@ -48,6 +48,7 @@ class FbaPricingServiceTest {
|
||||
private static final LocalDateTime NOW = LocalDateTime.of(2026, 8, 3, 10, 0);
|
||||
@MockBean private FbaPricingRepository repository;
|
||||
@MockBean private InboundFeeDetailService feeDetails;
|
||||
@MockBean private ActivePriceBookEstimator priceBookEstimator;
|
||||
@Resource private FbaPricingService service;
|
||||
|
||||
@Test
|
||||
@@ -63,15 +64,15 @@ class FbaPricingServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("包税KG按配置系数取体积重较大值并按规则向上舍入")
|
||||
void calculatesTaxIncludedKgWithConfiguredFactorAndPrecision() {
|
||||
@DisplayName("包税KG按生效价格表计费且不依赖客户规则或承运商")
|
||||
void calculatesTaxIncludedKgWithoutCustomerRuleOrCarrier() {
|
||||
readyRepository();
|
||||
InboundPricingEntity inbound = inbound("100.000", "1.0001");
|
||||
CustomerChargeRuleEntity rule = rule("KG", "167.000000", 3, "CEILING");
|
||||
CarrierFbaRateEntity rate = rate("KG", "2.345678", "10.00");
|
||||
inbound.setCarrierId(null);
|
||||
inbound.setCarrierName(null);
|
||||
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
|
||||
when(repository.matchRules(inbound, NOW)).thenReturn(List.of(rule));
|
||||
when(repository.matchRates(inbound, "KG", NOW)).thenReturn(List.of(rate));
|
||||
when(priceBookEstimator.estimate(any())).thenReturn(
|
||||
estimate("KG", "167.017", "2.345678", "391.77", "10.00"));
|
||||
|
||||
FeeSnapshotResponse response = service.calculate(TENANT, OPERATOR, INBOUND, NOW, "calc-1");
|
||||
|
||||
@@ -79,19 +80,26 @@ class FbaPricingServiceTest {
|
||||
assertThat(response.chargeableQuantity()).isEqualByComparingTo("167.017");
|
||||
assertThat(response.subtotalAmount()).isEqualByComparingTo("391.77");
|
||||
assertThat(response.totalAmount()).isEqualByComparingTo("401.77");
|
||||
assertThat(response.priceBookVersionId()).isEqualTo(701L);
|
||||
assertThat(response.pricingTaxMode()).isEqualTo("TAX_INCLUDED");
|
||||
assertThat(response.nextProcessStatus()).isEqualTo("FEE_CONFIRMING");
|
||||
ArgumentCaptor<InboundFeeEntity> fee = ArgumentCaptor.forClass(InboundFeeEntity.class);
|
||||
verify(repository).insertFee(fee.capture());
|
||||
assertThat(fee.getValue().getTaxMode()).isEqualTo("BUY_DECLARATION");
|
||||
assertThat(fee.getValue().getPricingTaxMode()).isEqualTo("TAX_INCLUDED");
|
||||
assertThat(fee.getValue().getChargeRuleId()).isNull();
|
||||
assertThat(fee.getValue().getCarrierId()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("不包税CBM按配置系数折算且精度边界向上取整")
|
||||
void calculatesTaxExcludedCbmAtRoundingBoundary() {
|
||||
@DisplayName("不包税CBM使用生效价格表返回的计费量")
|
||||
void calculatesTaxExcludedCbmFromActivePriceBook() {
|
||||
readyRepository();
|
||||
InboundPricingEntity inbound = inbound("363.001", "0.5000");
|
||||
CustomerChargeRuleEntity rule = rule("CBM", "363.000000", 4, "CEILING");
|
||||
CarrierFbaRateEntity rate = rate("CBM", "100.000000", "0.00");
|
||||
inbound.setPricingTaxMode("TAX_EXCLUDED");
|
||||
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
|
||||
when(repository.matchRules(inbound, NOW)).thenReturn(List.of(rule));
|
||||
when(repository.matchRates(inbound, "CBM", NOW)).thenReturn(List.of(rate));
|
||||
when(priceBookEstimator.estimate(any())).thenReturn(
|
||||
estimate("CBM", "1.0001", "100.000000", "100.01", "0.00"));
|
||||
|
||||
FeeSnapshotResponse response = service.calculate(TENANT, OPERATOR, INBOUND, NOW, "calc-2");
|
||||
|
||||
@@ -109,8 +117,8 @@ class FbaPricingServiceTest {
|
||||
CustomerChargeRuleEntity rule = rule("KG", "167.000000", 3, "CEILING");
|
||||
CarrierFbaRateEntity rate = rate("KG", "2.000000", "0.00");
|
||||
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
|
||||
when(repository.matchRules(inbound, NOW)).thenReturn(List.of(rule));
|
||||
when(repository.matchRates(inbound, "KG", NOW)).thenReturn(List.of(rate));
|
||||
when(priceBookEstimator.estimate(any())).thenReturn(
|
||||
estimate("KG", "167.000", "2.000000", "334.00", "0.00"));
|
||||
|
||||
FeeSnapshotResponse response = service.calculate(TENANT, OPERATOR, INBOUND, NOW, "wait-split");
|
||||
|
||||
@@ -156,26 +164,21 @@ class FbaPricingServiceTest {
|
||||
assertThatThrownBy(() -> service.calculate(TENANT, OPERATOR, INBOUND, NOW, "calc-3"))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("审核通过");
|
||||
verify(repository, never()).matchRules(any(), any());
|
||||
verify(priceBookEstimator, never()).estimate(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("无客户规则和多客户规则都明确拒绝")
|
||||
void rejectsMissingAndAmbiguousRules() {
|
||||
@DisplayName("当前生效价格表无匹配价格时明确拒绝")
|
||||
void rejectsMissingActivePriceBookRate() {
|
||||
readyRepository();
|
||||
InboundPricingEntity inbound = inbound("10", "1");
|
||||
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
|
||||
when(repository.matchRules(inbound, NOW)).thenReturn(List.of());
|
||||
assertThatThrownBy(() -> service.calculate(TENANT, OPERATOR, INBOUND, NOW, "none"))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("未匹配到有效客户计费规则");
|
||||
when(priceBookEstimator.estimate(any())).thenThrow(
|
||||
new BizException(ResultCode.NOT_FOUND, "当前条件没有匹配价格"));
|
||||
|
||||
org.mockito.Mockito.reset(repository);
|
||||
readyRepository();
|
||||
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
|
||||
when(repository.matchRules(inbound, NOW)).thenReturn(List.of(
|
||||
rule("KG", "167", 3, "CEILING"), rule("KG", "168", 3, "CEILING")));
|
||||
assertThatThrownBy(() -> service.calculate(TENANT, OPERATOR, INBOUND, NOW, "many"))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("多条有效客户计费规则");
|
||||
assertThatThrownBy(() -> service.calculate(TENANT, OPERATOR, INBOUND, NOW, "none"))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("没有匹配价格");
|
||||
verify(repository, never()).insertFee(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -299,29 +302,25 @@ class FbaPricingServiceTest {
|
||||
value.setProcessStatus("WAIT_PRICING"); value.setMeasureReviewStatus("APPROVED");
|
||||
value.setFeeStatus("UNPRICED"); value.setCustomerId(11L); value.setCustomerName("客户A");
|
||||
value.setWarehouseId(21L); value.setWarehouseName("义乌仓"); value.setBusinessType("FBA_SEA");
|
||||
value.setChannelCode("SEA-TAX"); value.setCarrierId(31L); value.setCarrierName("船司A");
|
||||
value.setTaxMode("TAX_INCLUDED"); value.setDestinationType("FBA"); value.setDestinationCode("ONT8");
|
||||
value.setChannelCode("MATSON_TRUCK"); value.setCarrierId(31L); value.setCarrierName("船司A");
|
||||
value.setTaxMode("BUY_DECLARATION"); value.setPricingTaxMode("TAX_INCLUDED");
|
||||
value.setDestinationType("FBA"); value.setDestinationCode("ONT8");
|
||||
value.setActualGrossWeightKg(new BigDecimal(weight)); value.setActualVolumeCbm(new BigDecimal(volume));
|
||||
return value;
|
||||
}
|
||||
|
||||
private CustomerChargeRuleEntity rule(String mode, String factor, int scale, String rounding) {
|
||||
CustomerChargeRuleEntity value = new CustomerChargeRuleEntity();
|
||||
value.setId(101L); value.setRuleCode("CR-001"); value.setRuleVersion(1);
|
||||
value.setChargeMode(mode); value.setConversionFactor(new BigDecimal(factor));
|
||||
value.setRoundingScale(scale); value.setRoundingMode(rounding); value.setCurrency("USD");
|
||||
return value;
|
||||
}
|
||||
|
||||
private CarrierFbaRateEntity rate(String unit, String price, String additional) {
|
||||
CarrierFbaRateEntity value = new CarrierFbaRateEntity();
|
||||
value.setId(201L); value.setRateCode("RT-001"); value.setRateVersion(1);
|
||||
value.setOriginWarehouseId(21L); value.setOriginWarehouseName("义乌仓");
|
||||
value.setChannelCode("SEA-TAX"); value.setCarrierId(31L); value.setCarrierName("船司A");
|
||||
value.setDestinationCode("ONT8"); value.setChargeUnit(unit); value.setUnitPrice(new BigDecimal(price));
|
||||
value.setCurrency("USD"); value.setAdditionalFee(new BigDecimal(additional));
|
||||
value.setEffectiveFrom(NOW.minusDays(1)); value.setEffectiveTo(NOW.plusDays(1));
|
||||
return value;
|
||||
private ActivePriceBookEstimator.Estimate estimate(String unit, String quantity, String price,
|
||||
String amount, String additional) {
|
||||
RateRow rate = new RateRow();
|
||||
rate.setId(201L); rate.setPriceBookVersionId(701L); rate.setRateCode("PB-7-1"); rate.setRateVersion(1);
|
||||
rate.setOriginWarehouseId(21L); rate.setOriginWarehouseName("义乌仓"); rate.setChannelCode("MATSON_TRUCK");
|
||||
rate.setPriceModel(PriceModel.FBA_WAREHOUSE_RATE.name()); rate.setDestinationType("FBA");
|
||||
rate.setDestinationCode("ONT8"); rate.setPricingTaxMode("TAX_INCLUDED"); rate.setChargeUnit(unit);
|
||||
rate.setUnitPrice(new BigDecimal(price)); rate.setCurrency("USD");
|
||||
rate.setAdditionalFee(new BigDecimal(additional)); rate.setEffectiveFrom(NOW.minusDays(1));
|
||||
return new ActivePriceBookEstimator.Estimate(701L, "MATSON_TRUCK", PriceModel.FBA_WAREHOUSE_RATE,
|
||||
PricingTaxMode.TAX_INCLUDED, rate, new BigDecimal(quantity), unit,
|
||||
new BigDecimal(price), new BigDecimal(amount), "USD");
|
||||
}
|
||||
|
||||
private InboundPricingIdempotencyEntity completed(String operation, long feeId) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user