This commit is contained in:
2026-08-07 14:46:55 +08:00
parent 830575cc92
commit b0ea1a0c5c
109 changed files with 5774 additions and 339 deletions
@@ -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,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, "内部服务凭证无效");
}
}
}
@@ -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));
}
}
@@ -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
}
@@ -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,
@@ -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;
@@ -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;
@@ -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;
}
@@ -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,4 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.PriceBookChannelEntity;
public interface PriceBookChannelMapper extends BaseMapper<PriceBookChannelEntity> {}
@@ -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,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) {}
}
@@ -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) {}
}
@@ -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 };
}
}
}
@@ -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,134 @@
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.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 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;
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.delete(new LambdaQueryWrapper<PriceBookChannelEntity>().eq(PriceBookChannelEntity::getPriceBookVersionId, 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 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));
}
}
@@ -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,59 +48,42 @@ 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);
String nextStatus = "WAIT_SPLIT".equals(inbound.getProcessStatus())
? "WAIT_SPLIT" : "FEE_CONFIRMING";
return response(inbound, fee, rate.getUnitPrice(), nextStatus);
return response(inbound, fee, estimate.unitPrice(), nextStatus);
}
@Transactional
@@ -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(),
@@ -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>()
@@ -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,568 @@
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.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, rates, issues);
}
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 key = String.join("|", code, text(rate.originWarehouseId()), enumText(rate.destinationType()),
text(rate.destinationWarehouseId()), 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) {}
}