优化
This commit is contained in:
@@ -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) {}
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
package com.baiyue.firstleg.controller;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.dto.PriceBookChannelValidation;
|
||||
import com.baiyue.firstleg.service.MerchantPriceBookService;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/internal/firstleg/v1/pricing")
|
||||
public class InternalPriceBookController {
|
||||
@Resource private MerchantPriceBookService service;
|
||||
@Value("${baiyue.security.internal-service-token}") private String internalToken;
|
||||
private byte[] expectedToken;
|
||||
|
||||
@PostConstruct
|
||||
void initializeToken() {
|
||||
expectedToken = internalToken.getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
@PostMapping("/channels/validate")
|
||||
public ApiResponse<Void> validateChannel(
|
||||
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||
@Valid @RequestBody PriceBookChannelValidation.Request request) {
|
||||
requireToken(token);
|
||||
service.requireAvailableChannel(request.tenantId(), request.originWarehouseId(), request.channelCode());
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
|
||||
private void requireToken(String token) {
|
||||
if (token == null || !MessageDigest.isEqual(expectedToken, token.getBytes(StandardCharsets.UTF_8))) {
|
||||
throw new BizException(ResultCode.FORBIDDEN, "内部服务凭证无效");
|
||||
}
|
||||
}
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
package com.baiyue.firstleg.controller;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.firstleg.dto.PriceBookDtos.CurrentChannel;
|
||||
import com.baiyue.firstleg.dto.PriceBookDtos.EstimateRequest;
|
||||
import com.baiyue.firstleg.dto.PriceBookDtos.EstimateResponse;
|
||||
import com.baiyue.firstleg.service.FirstlegScopeGuard;
|
||||
import com.baiyue.firstleg.service.MerchantPriceBookService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Tag(name = "客户端船期渠道与参考估价")
|
||||
@RestController
|
||||
@RequestMapping("/api/merchant/v1/first-leg/pricing")
|
||||
public class MerchantPriceBookController {
|
||||
@Resource private MerchantPriceBookService service;
|
||||
|
||||
@GetMapping("/channels")
|
||||
@PreAuthorize("hasAnyAuthority('merchant:order:create','merchant:order:edit','merchant:order:view')")
|
||||
@Operation(summary = "查询当前起运仓可选渠道")
|
||||
public ApiResponse<List<CurrentChannel>> channels(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam long originWarehouseId) {
|
||||
return ApiResponse.success(service.channels(FirstlegScopeGuard.merchant(user), originWarehouseId));
|
||||
}
|
||||
|
||||
@PostMapping("/estimate")
|
||||
@PreAuthorize("hasAnyAuthority('merchant:order:create','merchant:order:edit','merchant:order:view')")
|
||||
@Operation(summary = "计算头程基础参考费用")
|
||||
public ApiResponse<EstimateResponse> estimate(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid @RequestBody EstimateRequest request) {
|
||||
return ApiResponse.success(service.estimate(FirstlegScopeGuard.merchant(user), request));
|
||||
}
|
||||
}
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
package com.baiyue.firstleg.controller;
|
||||
|
||||
import com.baiyue.common.audit.BusinessAudit;
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.firstleg.domain.PriceBookStatus;
|
||||
import com.baiyue.firstleg.dto.PriceBookConfirmationRequest;
|
||||
import com.baiyue.firstleg.dto.PriceBookDtos;
|
||||
import com.baiyue.firstleg.service.FirstlegScopeGuard;
|
||||
import com.baiyue.firstleg.service.PlatformPriceBookService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RequestPart;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
@Tag(name = "平台船期价格表")
|
||||
@Validated
|
||||
@RestController
|
||||
@RequestMapping("/api/platform/v1/fba-pricing/carrier-rates/price-books")
|
||||
public class PlatformPriceBookController {
|
||||
@Resource private PlatformPriceBookService service;
|
||||
|
||||
@GetMapping
|
||||
@PreAuthorize("hasAuthority('firstleg:fba-pricing:rate:view')")
|
||||
@Operation(summary = "查询船期价格表版本")
|
||||
public ApiResponse<java.util.List<PriceBookDtos.PriceBookVersionSummary>> list(
|
||||
@AuthenticationPrincipal com.baiyue.common.security.AuthenticatedUser user,
|
||||
@RequestParam(required = false) PriceBookStatus status) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return ApiResponse.success(service.list(status));
|
||||
}
|
||||
|
||||
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@PreAuthorize("hasAuthority('firstleg:fba-pricing:rate:edit')")
|
||||
@BusinessAudit(event = "FBA_PRICE_BOOK_UPLOAD", resourceType = "PRICE_BOOK_VERSION", description = "上传船期价格表并等待确认")
|
||||
public ApiResponse<PriceBookDtos.PriceBookVersionDetail> upload(
|
||||
@AuthenticationPrincipal com.baiyue.common.security.AuthenticatedUser user,
|
||||
@RequestPart("file") MultipartFile file) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return ApiResponse.success(service.upload(user.userId(), file));
|
||||
}
|
||||
|
||||
@GetMapping("/{versionId}")
|
||||
@PreAuthorize("hasAuthority('firstleg:fba-pricing:rate:view')")
|
||||
public ApiResponse<PriceBookDtos.PriceBookVersionDetail> detail(
|
||||
@AuthenticationPrincipal com.baiyue.common.security.AuthenticatedUser user,
|
||||
@PathVariable long versionId) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return ApiResponse.success(service.detail(versionId));
|
||||
}
|
||||
|
||||
@PostMapping("/{versionId}/publish")
|
||||
@PreAuthorize("hasAuthority('firstleg:fba-pricing:rate:edit')")
|
||||
@BusinessAudit(event = "FBA_PRICE_BOOK_CONFIRM", resourceType = "PRICE_BOOK_VERSION", resourceId = "#versionId", description = "确认仓库映射并生效船期价格表")
|
||||
public ApiResponse<PriceBookDtos.PriceBookVersionDetail> publish(
|
||||
@AuthenticationPrincipal com.baiyue.common.security.AuthenticatedUser user,
|
||||
@PathVariable long versionId,
|
||||
@Valid @RequestBody PriceBookConfirmationRequest request) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return ApiResponse.success(service.publish(versionId, user.userId(), request));
|
||||
}
|
||||
|
||||
@GetMapping("/{versionId}/source")
|
||||
@PreAuthorize("hasAuthority('firstleg:fba-pricing:rate:view')")
|
||||
public ResponseEntity<byte[]> source(
|
||||
@AuthenticationPrincipal com.baiyue.common.security.AuthenticatedUser user,
|
||||
@PathVariable long versionId) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return service.source(versionId, user.userId());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.baiyue.firstleg.domain;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/** 首版固定渠道目录;上传名称只能映射到这些稳定编码。 */
|
||||
public final class PriceBookChannelCatalog {
|
||||
private PriceBookChannelCatalog() {}
|
||||
|
||||
public static final List<ChannelDefinition> CHANNELS = List.of(
|
||||
fba("MATSON_CLX_TRUCK", "美森正班海卡", "美森正班海卡"),
|
||||
fba("MATSON_MAX_TRUCK", "美森加班海卡", "美森加班海卡"),
|
||||
fba("EXX_TRUCK", "EXX海卡", "EXX海卡"),
|
||||
fba("HEDE_TRUCK", "合德海卡", "合德海卡"),
|
||||
fba("EXX_RAIL_NY", "纽约EXX海铁", "纽约EXX海铁"),
|
||||
fba("OA_LA_TRUCK", "洛杉矶普船海卡", "洛杉矶普船海卡"),
|
||||
fba("OA_LA_FULL_TRUCK", "洛杉矶普船全卡", "洛杉矶普船全卡"),
|
||||
fba("OA_CHICAGO_RAIL", "芝加哥普船海铁", "芝加哥普船海铁"),
|
||||
fba("OA_HOUSTON_TRUCK", "休斯顿普船海卡", "休斯顿普船海卡"),
|
||||
fba("OA_NEW_YORK_TRUCK", "纽约普船海卡", "纽约普船海卡"),
|
||||
fba("OA_SAVANNAH_TRUCK", "萨凡纳普船海卡", "萨凡纳普船海卡"),
|
||||
parcel("MATSON_CLX_PARCEL", "美森正班海派", "海派"),
|
||||
parcel("MATSON_MAX_PARCEL", "美森加班海派", "海派"),
|
||||
parcel("HEDE_PARCEL", "合德海派", "海派"),
|
||||
parcel("OA_PARCEL", "普船海派", "海派")
|
||||
);
|
||||
|
||||
private static ChannelDefinition fba(String code, String name, String sheet) {
|
||||
return new ChannelDefinition(code, name, PriceModel.FBA_WAREHOUSE_RATE, sheet, Set.of(name));
|
||||
}
|
||||
|
||||
private static ChannelDefinition parcel(String code, String name, String sheet) {
|
||||
return new ChannelDefinition(code, name, PriceModel.REGION_WEIGHT_TIER, sheet, Set.of(name));
|
||||
}
|
||||
|
||||
public record ChannelDefinition(
|
||||
String channelCode,
|
||||
String displayName,
|
||||
PriceModel priceModel,
|
||||
String sourceSheetName,
|
||||
Set<String> aliases
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.baiyue.firstleg.domain;
|
||||
|
||||
/** 平台公共船期价格表版本状态;上传后待确认映射,确认即生效。 */
|
||||
public enum PriceBookStatus {
|
||||
PENDING_CONFIRMATION,
|
||||
ACTIVE,
|
||||
SUPERSEDED,
|
||||
FAILED
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.baiyue.firstleg.domain;
|
||||
|
||||
/** 当前首版支持的两种明确价格模型,不扩展为通用规则引擎。 */
|
||||
public enum PriceModel {
|
||||
FBA_WAREHOUSE_RATE,
|
||||
REGION_WEIGHT_TIER
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.baiyue.firstleg.domain;
|
||||
|
||||
/** 海派首版固定美国区域;优先由目的仓邮编或版本内区划数据解析。 */
|
||||
public enum PriceRegion {
|
||||
WEST,
|
||||
CENTRAL,
|
||||
EAST
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.baiyue.firstleg.domain;
|
||||
|
||||
/** 价格明细支持的目的地类型;PRIVATE 本期不参与自动估价。 */
|
||||
public enum PricingDestinationType {
|
||||
FBA,
|
||||
OVERSEAS
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.baiyue.firstleg.domain;
|
||||
|
||||
/** 报价包税维度;与订单的买单/退税 taxMode 是两个独立概念。 */
|
||||
public enum PricingTaxMode {
|
||||
TAX_INCLUDED,
|
||||
TAX_EXCLUDED
|
||||
}
|
||||
@@ -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) {}
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
package com.baiyue.firstleg.pricing;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.client.PriceBookWarehouseClient;
|
||||
import com.baiyue.firstleg.domain.PriceRegion;
|
||||
import feign.FeignException;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
/** Resolves a platform FBA warehouse to the fixed sea-parcel price region. */
|
||||
@Component
|
||||
public class PriceBookWarehouseRegionResolver {
|
||||
@Resource private PriceBookWarehouseClient warehouses;
|
||||
@Resource private PriceRegionResolver regionResolver;
|
||||
@Value("${baiyue.security.internal-service-token}") private String internalToken;
|
||||
|
||||
public PriceRegion resolve(long warehouseId) {
|
||||
if (warehouseId <= 0) {
|
||||
throw validation("请提供目的仓区域或平台FBA仓,以确定海派区域");
|
||||
}
|
||||
try {
|
||||
ApiResponse<PriceBookWarehouseClient.Detail> response = warehouses.active(internalToken, 0L, warehouseId);
|
||||
if (response == null) {
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "目的仓服务暂时不可用");
|
||||
}
|
||||
if (!response.successful() || response.data() == null || response.data().warehouse() == null) {
|
||||
throw validation("目的仓不是可用的平台FBA仓");
|
||||
}
|
||||
var warehouse = response.data().warehouse();
|
||||
if (!Long.valueOf(warehouseId).equals(warehouse.id()) || !Long.valueOf(0L).equals(warehouse.tenantId())
|
||||
|| !"FBA".equals(warehouse.warehouseType()) || !"ACTIVE".equals(warehouse.status())) {
|
||||
throw validation("目的仓不是可用的平台FBA仓");
|
||||
}
|
||||
String postalCode = response.data().addresses() == null ? null : response.data().addresses().stream()
|
||||
.filter(address -> address != null && StringUtils.hasText(address.postalCode()))
|
||||
.map(PriceBookWarehouseClient.Address::postalCode)
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
return regionResolver.fromPostalCode(postalCode);
|
||||
} catch (BizException exception) {
|
||||
throw exception;
|
||||
} catch (FeignException exception) {
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "目的仓服务暂时不可用");
|
||||
}
|
||||
}
|
||||
|
||||
private static BizException validation(String message) {
|
||||
return new BizException(ResultCode.VALIDATION_FAILED, message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.baiyue.firstleg.pricing;
|
||||
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.domain.PriceRegion;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class PriceRegionResolver {
|
||||
public PriceRegion fromPostalCode(String rawPostalCode) {
|
||||
String digits = rawPostalCode == null ? "" : rawPostalCode.replaceAll("[^0-9]", "");
|
||||
if (digits.isEmpty()) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED, "目的仓未配置可识别的美国邮编,无法确定海派区域");
|
||||
}
|
||||
if (digits.startsWith("46")) return PriceRegion.EAST;
|
||||
return switch (digits.charAt(0)) {
|
||||
case '0', '1', '2', '3' -> PriceRegion.EAST;
|
||||
case '4', '5', '6', '7' -> PriceRegion.CENTRAL;
|
||||
case '8', '9' -> PriceRegion.WEST;
|
||||
default -> throw new BizException(ResultCode.VALIDATION_FAILED, "目的仓邮编无法确定海派区域");
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.baiyue.firstleg.pricing;
|
||||
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.repository.PriceBookQueryRows.RateRow;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
|
||||
@Component
|
||||
public class RegionWeightTierEstimator {
|
||||
private static final BigDecimal KG_PER_CBM = BigDecimal.valueOf(167);
|
||||
|
||||
public Estimate estimate(List<RateRow> tiers, BigDecimal actualWeightKg, BigDecimal actualVolumeCbm) {
|
||||
BigDecimal weight = nonNegative(actualWeightKg);
|
||||
BigDecimal volume = nonNegative(actualVolumeCbm);
|
||||
if (weight.signum() == 0 && volume.signum() == 0) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED, "重量和体积至少填写一项且必须大于0");
|
||||
}
|
||||
BigDecimal quantity = weight.max(volume.multiply(KG_PER_CBM)).setScale(6, RoundingMode.HALF_UP);
|
||||
RateRow selected = tiers.stream()
|
||||
.filter(rate -> "KG".equals(rate.getChargeUnit()) && rate.getWeightFromKg() != null)
|
||||
.filter(rate -> rate.getWeightFromKg().compareTo(quantity) <= 0)
|
||||
.max(Comparator.comparing(RateRow::getWeightFromKg))
|
||||
.orElseThrow(() -> new BizException(ResultCode.VALIDATION_FAILED,
|
||||
"当前计费重量未达到该海派渠道的最低重量阶梯"));
|
||||
return new Estimate(selected, quantity, selected.getUnitPrice(),
|
||||
quantity.multiply(selected.getUnitPrice()).setScale(2, RoundingMode.HALF_UP), selected.getCurrency());
|
||||
}
|
||||
|
||||
private static BigDecimal nonNegative(BigDecimal value) {
|
||||
if (value == null) return BigDecimal.ZERO;
|
||||
if (value.signum() < 0) throw new BizException(ResultCode.VALIDATION_FAILED, "重量和体积不能小于0");
|
||||
return value;
|
||||
}
|
||||
|
||||
public record Estimate(RateRow rate, BigDecimal quantity, BigDecimal unitPrice, BigDecimal amount,
|
||||
String currency) {}
|
||||
}
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
package com.baiyue.firstleg.pricing.importer;
|
||||
|
||||
import com.baiyue.firstleg.domain.PriceModel;
|
||||
import com.baiyue.firstleg.domain.PricingTaxMode;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
/** Pure, persistence-free result of parsing the platform price workbook. */
|
||||
public record PriceWorkbookParseResult(
|
||||
List<ParsedChannel> channels,
|
||||
List<ParseIssue> issues,
|
||||
List<String> ignoredSheets) {
|
||||
|
||||
public PriceWorkbookParseResult {
|
||||
channels = List.copyOf(channels == null ? List.of() : channels);
|
||||
issues = List.copyOf(issues == null ? List.of() : issues);
|
||||
ignoredSheets = List.copyOf(ignoredSheets == null ? List.of() : ignoredSheets);
|
||||
}
|
||||
|
||||
public boolean hasBlockingErrors() {
|
||||
return issues.stream().anyMatch(issue -> issue.severity() == Severity.ERROR);
|
||||
}
|
||||
|
||||
public long rateCount() {
|
||||
return channels.stream().mapToLong(channel -> channel.rates().size()).sum();
|
||||
}
|
||||
|
||||
public enum Severity { ERROR, WARNING }
|
||||
|
||||
public record ParseIssue(
|
||||
Severity severity,
|
||||
String code,
|
||||
String message,
|
||||
String sheetName,
|
||||
Integer rowNumber,
|
||||
String rawValue) {
|
||||
public ParseIssue {
|
||||
severity = severity == null ? Severity.WARNING : severity;
|
||||
code = code == null ? "UNKNOWN" : code;
|
||||
}
|
||||
}
|
||||
|
||||
public record ParsedSailing(
|
||||
String rawText,
|
||||
String vesselName,
|
||||
String voyageNumber,
|
||||
LocalDate expectedDepartureDate) { }
|
||||
|
||||
public record ParsedChannel(
|
||||
String channelCode,
|
||||
String channelName,
|
||||
String sourceSheet,
|
||||
String sourceLabel,
|
||||
PriceModel priceModel,
|
||||
List<ParsedSailing> sailings,
|
||||
String scheduleText,
|
||||
String cutoffText,
|
||||
String transitTimeText,
|
||||
String surchargeText,
|
||||
String remarkText,
|
||||
List<ParsedRate> rates) {
|
||||
public ParsedChannel {
|
||||
sailings = List.copyOf(sailings == null ? List.of() : sailings);
|
||||
rates = List.copyOf(rates == null ? List.of() : rates);
|
||||
}
|
||||
}
|
||||
|
||||
public record ParsedRate(
|
||||
String sourceSheet,
|
||||
int sourceRow,
|
||||
String originWarehouseKey,
|
||||
String originWarehouseLabel,
|
||||
String rawDestinationGroup,
|
||||
String destinationCode,
|
||||
String regionCode,
|
||||
BigDecimal minimumWeightKg,
|
||||
PricingTaxMode taxMode,
|
||||
String chargeUnit,
|
||||
BigDecimal unitPrice,
|
||||
String transitTimeText,
|
||||
String cutoffText,
|
||||
String scheduleText,
|
||||
String surchargeText,
|
||||
String remarkText,
|
||||
/** Source-level destination classification; mapped to the public contract at the boundary. */
|
||||
String destinationType,
|
||||
String destinationName) { }
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
package com.baiyue.firstleg.pricing.importer;
|
||||
|
||||
import com.baiyue.firstleg.domain.PriceModel;
|
||||
import com.baiyue.firstleg.domain.PricingTaxMode;
|
||||
|
||||
import org.apache.poi.openxml4j.opc.OPCPackage;
|
||||
import org.apache.poi.openxml4j.opc.PackageAccess;
|
||||
import org.apache.poi.xssf.eventusermodel.ReadOnlySharedStringsTable;
|
||||
import org.apache.poi.xssf.eventusermodel.XSSFReader;
|
||||
import org.apache.poi.xssf.eventusermodel.XSSFSheetXMLHandler;
|
||||
import org.apache.poi.xssf.model.StylesTable;
|
||||
import org.apache.poi.xssf.usermodel.XSSFComment;
|
||||
import org.apache.poi.util.XMLHelper;
|
||||
import org.xml.sax.InputSource;
|
||||
import org.xml.sax.XMLReader;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.file.Path;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import static com.baiyue.firstleg.pricing.importer.PriceWorkbookParseResult.ParseIssue;
|
||||
import static com.baiyue.firstleg.pricing.importer.PriceWorkbookParseResult.ParsedChannel;
|
||||
import static com.baiyue.firstleg.pricing.importer.PriceWorkbookParseResult.ParsedRate;
|
||||
import static com.baiyue.firstleg.pricing.importer.PriceWorkbookParseResult.ParsedSailing;
|
||||
import static com.baiyue.firstleg.pricing.importer.PriceWorkbookParseResult.Severity;
|
||||
|
||||
/**
|
||||
* Parses the supported price tabs without consulting Excel Table metadata or UsedRange.
|
||||
* The SAX reader only visits real cells in supported sheets, so malformed Table refs and
|
||||
* inflated dimensions in the workbook do not prevent the price tabs from being read.
|
||||
*/
|
||||
public final class PriceWorkbookParser {
|
||||
private static final int MAX_ROWS = 200;
|
||||
private static final int MAX_COLUMNS = 32;
|
||||
private static final Pattern CODE_PATTERN = Pattern.compile("(?<![A-Z0-9])[A-Z][A-Z0-9]{2,9}(?![A-Z0-9])");
|
||||
private static final Pattern DATE_PATTERN = Pattern.compile("预计开船日期\\s*[::]?\\s*(\\d{4})年(\\d{1,2})月(\\d{1,2})日");
|
||||
private static final Pattern SAILING_PATTERN = Pattern.compile("船名航次\\s*[::]\\s*(.+?)\\s+V\\.([A-Za-z0-9-]+)");
|
||||
private static final Pattern NUMBER_PATTERN = Pattern.compile("[-+]?\\d+(?:[,.]\\d+)?");
|
||||
|
||||
private static final List<StandardTab> STANDARD_TABS = List.of(
|
||||
new StandardTab("美森正班海卡", "MATSON_CLX_TRUCK"),
|
||||
new StandardTab("美森加班海卡", "MATSON_MAX_TRUCK"),
|
||||
new StandardTab("EXX海卡", "EXX_TRUCK"),
|
||||
new StandardTab("合德海卡", "HEDE_TRUCK"),
|
||||
new StandardTab("纽约EXX海铁", "EXX_RAIL_NY"),
|
||||
new StandardTab("洛杉矶普船海卡", "OA_LA_TRUCK"),
|
||||
new StandardTab("洛杉矶普船全卡", "OA_LA_FULL_TRUCK"),
|
||||
new StandardTab("芝加哥普船海铁", "OA_CHICAGO_RAIL"),
|
||||
new StandardTab("休斯顿普船海卡", "OA_HOUSTON_TRUCK"),
|
||||
new StandardTab("纽约普船海卡", "OA_NEW_YORK_TRUCK"),
|
||||
new StandardTab("萨凡纳普船海卡", "OA_SAVANNAH_TRUCK"));
|
||||
|
||||
private static final List<ParcelChannel> PARCEL_CHANNELS = List.of(
|
||||
new ParcelChannel("MATSON_CLX_PARCEL", "美森正班海派"),
|
||||
new ParcelChannel("MATSON_MAX_PARCEL", "美森加班海派"),
|
||||
new ParcelChannel("HEDE_PARCEL", "合德海派"),
|
||||
new ParcelChannel("OA_PARCEL", "普船海派"));
|
||||
|
||||
private static final Set<String> IGNORED_SHEETS = Set.of(
|
||||
"出货注意事项", "UPS派送售后服务指南", "快速查询", "UPS&FedEx偏远地区表",
|
||||
"一件代发收费", "FDA申报清单", "FBA分区表", "亚马逊仓库代码表");
|
||||
|
||||
public PriceWorkbookParseResult parse(Path workbook) throws IOException {
|
||||
if (workbook == null) throw new IllegalArgumentException("workbook must not be null");
|
||||
try (OPCPackage packageRef = OPCPackage.open(workbook.toFile(), PackageAccess.READ)) {
|
||||
return parse(packageRef);
|
||||
} catch (Exception exception) {
|
||||
if (exception instanceof IOException io) throw io;
|
||||
throw new IOException("无法读取 Excel 工作簿", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private PriceWorkbookParseResult parse(OPCPackage packageRef) throws Exception {
|
||||
XSSFReader reader = new XSSFReader(packageRef);
|
||||
StylesTable styles = reader.getStylesTable();
|
||||
ReadOnlySharedStringsTable sharedStrings = new ReadOnlySharedStringsTable(packageRef);
|
||||
Map<String, SheetData> sheets = new LinkedHashMap<>();
|
||||
Set<String> allSheetNames = new LinkedHashSet<>();
|
||||
|
||||
XSSFReader.SheetIterator iterator = (XSSFReader.SheetIterator) reader.getSheetsData();
|
||||
while (iterator.hasNext()) {
|
||||
try (InputStream stream = iterator.next()) {
|
||||
String sheetName = iterator.getSheetName();
|
||||
allSheetNames.add(sheetName);
|
||||
if (!isSupportedSheet(sheetName)) continue;
|
||||
sheets.put(sheetName, readSheet(stream, styles, sharedStrings));
|
||||
}
|
||||
}
|
||||
|
||||
List<ParsedChannel> channels = new ArrayList<>();
|
||||
List<ParseIssue> issues = new ArrayList<>();
|
||||
List<String> ignored = new ArrayList<>();
|
||||
for (String sheetName : allSheetNames) {
|
||||
if (IGNORED_SHEETS.contains(sheetName)) ignored.add(sheetName);
|
||||
}
|
||||
|
||||
for (StandardTab tab : STANDARD_TABS) {
|
||||
SheetData data = sheets.get(tab.sheetName());
|
||||
if (data == null) {
|
||||
issues.add(issue(Severity.WARNING, "CHANNEL_SHEET_MISSING",
|
||||
"标准渠道 Sheet 未找到,本版本不会提供该渠道", tab.sheetName(), null, null));
|
||||
continue;
|
||||
}
|
||||
channels.add(parseStandard(tab, data, issues));
|
||||
}
|
||||
SheetData parcel = sheets.get("海派");
|
||||
if (parcel == null) {
|
||||
issues.add(issue(Severity.WARNING, "CHANNEL_SHEET_MISSING",
|
||||
"海派 Sheet 未找到,本版本不会提供海派渠道", "海派", null, null));
|
||||
} else {
|
||||
channels.addAll(parseParcel(parcel, issues));
|
||||
}
|
||||
|
||||
return new PriceWorkbookParseResult(channels, issues, ignored);
|
||||
}
|
||||
|
||||
private static boolean isSupportedSheet(String name) {
|
||||
return "海派".equals(name) || STANDARD_TABS.stream().anyMatch(tab -> tab.sheetName().equals(name));
|
||||
}
|
||||
|
||||
private static SheetData readSheet(InputStream stream, StylesTable styles,
|
||||
ReadOnlySharedStringsTable sharedStrings) throws Exception {
|
||||
SheetData data = new SheetData();
|
||||
XMLReader parser = XMLHelper.newXMLReader();
|
||||
parser.setContentHandler(new XSSFSheetXMLHandler(styles, null, sharedStrings,
|
||||
new CellHandler(data), new org.apache.poi.ss.usermodel.DataFormatter(Locale.CHINA), false));
|
||||
parser.parse(new InputSource(stream));
|
||||
return data;
|
||||
}
|
||||
|
||||
private static ParsedChannel parseStandard(StandardTab tab, SheetData data, List<ParseIssue> issues) {
|
||||
int headerRow = findRowContaining(data, "FBA仓库");
|
||||
if (headerRow < 0) {
|
||||
issues.add(issue(Severity.ERROR, "STANDARD_HEADER_NOT_FOUND",
|
||||
"未识别标准海卡/海铁表头", tab.sheetName(), null, null));
|
||||
return new ParsedChannel(tab.channelCode(), tab.sheetName(), tab.sheetName(), tab.sheetName(),
|
||||
PriceModel.FBA_WAREHOUSE_RATE, sailings(data.value(2, 0)), null, null, null, null, null, List.of());
|
||||
}
|
||||
int base = findCellColumn(data, headerRow, "FBA仓库");
|
||||
List<ParsedRate> rates = new ArrayList<>();
|
||||
String schedule = null, cutoff = null, transit = null, surcharge = null, remark = null;
|
||||
for (int row = headerRow + 2; row <= MAX_ROWS; row++) {
|
||||
String destination = clean(data.value(row, base));
|
||||
if (destination == null) continue;
|
||||
String nonTax = clean(data.value(row, base + 1));
|
||||
String eastTax = clean(data.value(row, base + 2));
|
||||
String southTax = clean(data.value(row, base + 3));
|
||||
if (!hasPrice(nonTax) && !hasPrice(eastTax) && !hasPrice(southTax)) continue;
|
||||
String rowTransit = clean(data.value(row, base + 4));
|
||||
String rowCutoff = clean(data.value(row, base + 5));
|
||||
String rowSchedule = clean(data.value(row, base + 6));
|
||||
String rowSurcharge = clean(data.value(row, base + 7));
|
||||
String rowRemark = clean(data.value(row, base + 8));
|
||||
transit = firstNonBlank(transit, rowTransit);
|
||||
cutoff = firstNonBlank(cutoff, rowCutoff);
|
||||
schedule = firstNonBlank(schedule, rowSchedule);
|
||||
surcharge = firstNonBlank(surcharge, rowSurcharge);
|
||||
remark = firstNonBlank(remark, rowRemark);
|
||||
|
||||
List<String> codes = extractCodes(destination);
|
||||
boolean overseas = destination.contains("海外仓");
|
||||
if (codes.isEmpty() && !overseas) {
|
||||
issues.add(issue(Severity.ERROR, "DESTINATION_MAPPING_REQUIRED",
|
||||
"目的仓文本未识别为 FBA 仓库代码,需要确认映射或删除该行", tab.sheetName(), row, destination));
|
||||
codes = Collections.singletonList(null);
|
||||
}
|
||||
if (codes.isEmpty()) codes = Collections.singletonList(null);
|
||||
for (String code : codes) {
|
||||
addStandardRate(rates, issues, tab.sheetName(), row, code, destination,
|
||||
"YIWU", "华东仓(义乌)", nonTax, PricingTaxMode.TAX_EXCLUDED, "CBM", rowTransit,
|
||||
rowCutoff, rowSchedule, rowSurcharge, rowRemark);
|
||||
addStandardRate(rates, issues, tab.sheetName(), row, code, destination,
|
||||
"YIWU", "华东仓(义乌)", eastTax, PricingTaxMode.TAX_INCLUDED, "KG", rowTransit,
|
||||
rowCutoff, rowSchedule, rowSurcharge, rowRemark);
|
||||
addStandardRate(rates, issues, tab.sheetName(), row, code, destination,
|
||||
"DONGGUAN", "华南仓(深圳/东莞)", southTax, PricingTaxMode.TAX_INCLUDED, "KG", rowTransit,
|
||||
rowCutoff, rowSchedule, rowSurcharge, rowRemark);
|
||||
}
|
||||
}
|
||||
if (rates.isEmpty()) {
|
||||
issues.add(issue(Severity.ERROR, "NO_STANDARD_PRICES", "标准渠道未解析出价格行", tab.sheetName(), null, null));
|
||||
}
|
||||
List<ParsedSailing> sailings = sailings(data.value(2, 0));
|
||||
if (sailings.isEmpty() && clean(data.value(2, 0)) != null) {
|
||||
issues.add(issue(Severity.WARNING, "SAILING_NOT_RECOGNIZED",
|
||||
"船名航次文本未能结构化识别,保留原始文本", tab.sheetName(), 2, data.value(2, 0)));
|
||||
}
|
||||
return new ParsedChannel(tab.channelCode(), tab.sheetName(), tab.sheetName(), tab.sheetName(),
|
||||
PriceModel.FBA_WAREHOUSE_RATE, sailings, schedule, cutoff, transit, surcharge, remark, rates);
|
||||
}
|
||||
|
||||
private static void addStandardRate(List<ParsedRate> rates, List<ParseIssue> issues,
|
||||
String sheet, int row, String code, String rawDestination,
|
||||
String originKey, String originLabel, String rawPrice,
|
||||
PricingTaxMode taxMode, String unit, String transit, String cutoff,
|
||||
String schedule, String surcharge, String remark) {
|
||||
BigDecimal price = parsePrice(rawPrice);
|
||||
if (price == null) return;
|
||||
String destinationType = rawDestination != null && rawDestination.contains("海外仓")
|
||||
? "OVERSEAS" : code != null ? "FBA" : null;
|
||||
String destinationName = "OVERSEAS".equals(destinationType)
|
||||
? normalizeOverseasName(rawDestination) : code;
|
||||
rates.add(new ParsedRate(sheet, row, originKey, originLabel, rawDestination, code, null, null,
|
||||
taxMode, unit, price, transit, cutoff, schedule, surcharge, remark,
|
||||
destinationType, destinationName));
|
||||
}
|
||||
|
||||
private static String normalizeOverseasName(String raw) {
|
||||
if (raw == null) return null;
|
||||
if (raw.contains("洛杉矶")) return "洛杉矶仓";
|
||||
if (raw.contains("纽约")) return "纽约仓";
|
||||
int open = raw.indexOf('(');
|
||||
int close = raw.indexOf(')', open + 1);
|
||||
if (open >= 0 && close > open) return raw.substring(open + 1, close).trim();
|
||||
return raw.replace("海外仓", "").trim();
|
||||
}
|
||||
|
||||
private static List<ParsedChannel> parseParcel(SheetData data, List<ParseIssue> issues) {
|
||||
Map<String, ChannelBuilder> builders = new LinkedHashMap<>();
|
||||
PARCEL_CHANNELS.forEach(channel -> builders.put(channel.code(),
|
||||
new ChannelBuilder(channel.code(), channel.name())));
|
||||
parseParcelOrigin(data, 5, 8, 9, "YIWU", "华东仓(义乌)", builders, issues);
|
||||
parseParcelOrigin(data, 14, 17, 18, "DONGGUAN", "华南仓(深圳/东莞)", builders, issues);
|
||||
List<ParsedChannel> result = new ArrayList<>();
|
||||
for (ChannelBuilder builder : builders.values()) {
|
||||
if (builder.rates.isEmpty()) {
|
||||
issues.add(issue(Severity.ERROR, "NO_PARCEL_PRICES", "海派渠道未解析出价格阶梯", "海派", null, builder.name));
|
||||
}
|
||||
result.add(builder.toChannel());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void parseParcelOrigin(SheetData data, int scheduleRow, int tierRow,
|
||||
int firstRegionRow, String originKey, String originLabel,
|
||||
Map<String, ChannelBuilder> builders, List<ParseIssue> issues) {
|
||||
int[] starts = {2, 5, 8, 11};
|
||||
for (int channel = 0; channel < starts.length; channel++) {
|
||||
int start = starts[channel];
|
||||
ChannelBuilder builder = builders.get(PARCEL_CHANNELS.get(channel).code());
|
||||
builder.scheduleText = firstNonBlank(builder.scheduleText, data.value(scheduleRow, start));
|
||||
builder.transitText = firstNonBlank(builder.transitText, data.value(scheduleRow + 1, start));
|
||||
builder.surchargeText = firstNonBlank(builder.surchargeText, data.value(scheduleRow, 14));
|
||||
builder.remarkText = firstNonBlank(builder.remarkText, data.value(scheduleRow, 15));
|
||||
int[] thresholds = {12, 51, 101};
|
||||
for (int row = firstRegionRow; row < firstRegionRow + 3; row++) {
|
||||
String rawRegion = clean(data.value(row, 0));
|
||||
String region = regionCode(rawRegion);
|
||||
if (region == null) {
|
||||
issues.add(issue(Severity.ERROR, "REGION_NOT_RECOGNIZED",
|
||||
"海派区域未识别", "海派", row, rawRegion));
|
||||
continue;
|
||||
}
|
||||
for (int tier = 0; tier < thresholds.length; tier++) {
|
||||
BigDecimal price = parsePrice(data.value(row, start + tier));
|
||||
if (price == null) {
|
||||
issues.add(issue(Severity.ERROR, "PARCEL_PRICE_MISSING",
|
||||
"海派重量阶梯价格缺失", "海派", row, data.value(row, start + tier)));
|
||||
continue;
|
||||
}
|
||||
builder.rates.add(new ParsedRate("海派", row, originKey, originLabel, rawRegion, null,
|
||||
region, BigDecimal.valueOf(thresholds[tier]), PricingTaxMode.TAX_INCLUDED, "KG", price,
|
||||
builder.transitText, null, builder.scheduleText, builder.surchargeText, builder.remarkText,
|
||||
"REGION_WEIGHT_TIER", rawRegion));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static List<String> extractCodes(String value) {
|
||||
if (value == null) return List.of();
|
||||
Matcher matcher = CODE_PATTERN.matcher(value.toUpperCase(Locale.ROOT));
|
||||
Set<String> codes = new LinkedHashSet<>();
|
||||
while (matcher.find()) codes.add(matcher.group());
|
||||
return List.copyOf(codes);
|
||||
}
|
||||
|
||||
private static String regionCode(String raw) {
|
||||
if (raw == null) return null;
|
||||
if (raw.contains("美西")) return "WEST";
|
||||
if (raw.contains("美中")) return "CENTRAL";
|
||||
if (raw.contains("美东")) return "EAST";
|
||||
return null;
|
||||
}
|
||||
|
||||
private static List<ParsedSailing> sailings(String raw) {
|
||||
if (raw == null || raw.isBlank()) return List.of();
|
||||
LocalDate date = expectedDate(raw);
|
||||
List<ParsedSailing> result = new ArrayList<>();
|
||||
Matcher matcher = SAILING_PATTERN.matcher(raw);
|
||||
while (matcher.find()) result.add(new ParsedSailing(matcher.group(), matcher.group(1).trim(), matcher.group(2).trim(), date));
|
||||
return List.copyOf(result);
|
||||
}
|
||||
|
||||
private static LocalDate expectedDate(String raw) {
|
||||
Matcher matcher = DATE_PATTERN.matcher(raw == null ? "" : raw);
|
||||
if (!matcher.find()) return null;
|
||||
try {
|
||||
return LocalDate.of(Integer.parseInt(matcher.group(1)), Integer.parseInt(matcher.group(2)), Integer.parseInt(matcher.group(3)));
|
||||
} catch (RuntimeException ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static BigDecimal parsePrice(String raw) {
|
||||
if (raw == null || raw.isBlank() || "/".equals(raw.trim()) || "-".equals(raw.trim())) return null;
|
||||
Matcher matcher = NUMBER_PATTERN.matcher(raw.replace(",", ","));
|
||||
if (!matcher.find()) return null;
|
||||
try {
|
||||
return new BigDecimal(matcher.group().replace(",", ""));
|
||||
} catch (NumberFormatException ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean hasPrice(String value) { return parsePrice(value) != null; }
|
||||
|
||||
private static String clean(String value) {
|
||||
if (value == null) return null;
|
||||
String result = value.replace('\u00a0', ' ').trim();
|
||||
return result.isBlank() ? null : result;
|
||||
}
|
||||
|
||||
private static String firstNonBlank(String first, String second) { return clean(first) == null ? clean(second) : first; }
|
||||
|
||||
private static int findRowContaining(SheetData data, String text) {
|
||||
for (int row = 1; row <= MAX_ROWS; row++) {
|
||||
for (int col = 0; col < MAX_COLUMNS; col++) {
|
||||
String value = clean(data.value(row, col));
|
||||
if (value != null && value.contains(text)) return row;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static int findCellColumn(SheetData data, int row, String text) {
|
||||
for (int col = 0; col < MAX_COLUMNS; col++) {
|
||||
String value = clean(data.value(row, col));
|
||||
if (value != null && value.contains(text)) return col;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static ParseIssue issue(Severity severity, String code, String message,
|
||||
String sheet, Integer row, String raw) {
|
||||
return new ParseIssue(severity, code, message, sheet, row, raw);
|
||||
}
|
||||
|
||||
private record StandardTab(String sheetName, String channelCode) { }
|
||||
|
||||
private record ParcelChannel(String code, String name) { }
|
||||
|
||||
private static final class ChannelBuilder {
|
||||
private final String code;
|
||||
private final String name;
|
||||
private final List<ParsedRate> rates = new ArrayList<>();
|
||||
private String scheduleText;
|
||||
private String transitText;
|
||||
private String surchargeText;
|
||||
private String remarkText;
|
||||
|
||||
private ChannelBuilder(String code, String name) { this.code = code; this.name = name; }
|
||||
|
||||
private ParsedChannel toChannel() {
|
||||
return new ParsedChannel(code, name, "海派", name, PriceModel.REGION_WEIGHT_TIER,
|
||||
List.of(), scheduleText, null, transitText, surchargeText, remarkText, rates);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class SheetData {
|
||||
private final Map<Integer, Map<Integer, String>> cells = new LinkedHashMap<>();
|
||||
|
||||
private String value(int row, int column) {
|
||||
Map<Integer, String> values = cells.get(row);
|
||||
return values == null ? null : values.get(column);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class CellHandler implements XSSFSheetXMLHandler.SheetContentsHandler {
|
||||
private final SheetData data;
|
||||
private int row;
|
||||
|
||||
private CellHandler(SheetData data) { this.data = data; }
|
||||
|
||||
@Override public void startRow(int rowNum) { row = rowNum + 1; }
|
||||
|
||||
@Override public void endRow(int rowNum) { }
|
||||
|
||||
@Override public void cell(String cellReference, String formattedValue, XSSFComment comment) {
|
||||
int[] coordinates = coordinates(cellReference);
|
||||
if (coordinates[0] <= MAX_ROWS && coordinates[1] < MAX_COLUMNS) {
|
||||
data.cells.computeIfAbsent(coordinates[0], ignored -> new LinkedHashMap<>())
|
||||
.put(coordinates[1], formattedValue);
|
||||
}
|
||||
}
|
||||
|
||||
private static int[] coordinates(String reference) {
|
||||
int split = 0;
|
||||
while (split < reference.length() && Character.isLetter(reference.charAt(split))) split++;
|
||||
int column = 0;
|
||||
for (int i = 0; i < split; i++) column = column * 26 + (Character.toUpperCase(reference.charAt(i)) - 'A' + 1);
|
||||
return new int[] { Integer.parseInt(reference.substring(split)), column - 1 };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
+32
-5
@@ -3,12 +3,15 @@ package com.baiyue.firstleg.service;
|
||||
import jakarta.annotation.Resource;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.dto.OrderApprovedInboundCommand;
|
||||
import com.baiyue.firstleg.dto.InboundOrderReference;
|
||||
import com.baiyue.firstleg.entity.InboundOrderEntity;
|
||||
import com.baiyue.firstleg.entity.InboundStatusHistoryEntity;
|
||||
import com.baiyue.firstleg.mapper.InboundOrderMapper;
|
||||
import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@@ -25,11 +28,9 @@ public class InboundOrderCreationService {
|
||||
|
||||
@Transactional
|
||||
public long createFromApprovedOrder(OrderApprovedInboundCommand command) {
|
||||
InboundOrderEntity existing = orders.selectOne(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<InboundOrderEntity>()
|
||||
.eq(InboundOrderEntity::getTenantId, command.tenantId())
|
||||
.eq(InboundOrderEntity::getOrderId, command.orderId()));
|
||||
InboundOrderEntity existing = findByOrder(command.tenantId(), command.orderId(), false);
|
||||
if (existing != null) {
|
||||
return existing.getId();
|
||||
return requireMatchingInboundCode(existing, command.inboundCode());
|
||||
}
|
||||
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
@@ -58,6 +59,7 @@ public class InboundOrderCreationService {
|
||||
inbound.setCarrierId(command.carrierId());
|
||||
inbound.setCarrierName(command.carrierName());
|
||||
inbound.setTaxMode(command.taxMode());
|
||||
inbound.setPricingTaxMode(command.pricingTaxMode());
|
||||
inbound.setContainerMode(command.containerMode());
|
||||
inbound.setCustomsInspection(command.customsInspection());
|
||||
inbound.setDestinationType(command.destinationType());
|
||||
@@ -73,7 +75,15 @@ public class InboundOrderCreationService {
|
||||
inbound.setUpdatedAt(now);
|
||||
inbound.setDeleted(false);
|
||||
inbound.setVersion(0);
|
||||
orders.insert(inbound);
|
||||
try {
|
||||
orders.insert(inbound);
|
||||
} catch (DuplicateKeyException duplicate) {
|
||||
InboundOrderEntity winner = findByOrder(command.tenantId(), command.orderId(), true);
|
||||
if (winner == null) {
|
||||
throw new BizException(ResultCode.CONFLICT, "入仓码已被其他订单使用");
|
||||
}
|
||||
return requireMatchingInboundCode(winner, command.inboundCode());
|
||||
}
|
||||
|
||||
InboundStatusHistoryEntity history = new InboundStatusHistoryEntity();
|
||||
history.setId(IdWorker.getId());
|
||||
@@ -88,6 +98,23 @@ public class InboundOrderCreationService {
|
||||
return inbound.getId();
|
||||
}
|
||||
|
||||
private InboundOrderEntity findByOrder(long tenantId, long orderId, boolean currentRead) {
|
||||
var query = new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<InboundOrderEntity>()
|
||||
.eq(InboundOrderEntity::getTenantId, tenantId)
|
||||
.eq(InboundOrderEntity::getOrderId, orderId);
|
||||
if (currentRead) {
|
||||
query.last("FOR UPDATE");
|
||||
}
|
||||
return orders.selectOne(query);
|
||||
}
|
||||
|
||||
private long requireMatchingInboundCode(InboundOrderEntity existing, String inboundCode) {
|
||||
if (!java.util.Objects.equals(existing.getInboundCode(), inboundCode)) {
|
||||
throw new BizException(ResultCode.CONFLICT, "订单入仓码与已有入库单不一致");
|
||||
}
|
||||
return existing.getId();
|
||||
}
|
||||
|
||||
public List<InboundOrderReference.Item> findReferences(long tenantId, List<Long> orderIds) {
|
||||
if (orderIds.isEmpty()) return List.of();
|
||||
return orders.selectList(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<InboundOrderEntity>()
|
||||
|
||||
@@ -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) {}
|
||||
}
|
||||
Reference in New Issue
Block a user