二期内容提交
This commit is contained in:
@@ -3,11 +3,13 @@ package com.baiyue.firstleg;
|
||||
import org.mybatis.spring.annotation.MapperScan;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.cloud.openfeign.EnableFeignClients;
|
||||
|
||||
/**
|
||||
* 头程履约微服务启动入口,负责装配履约任务组件并扫描 MyBatis-Plus Mapper。
|
||||
*/
|
||||
@MapperScan("com.baiyue.firstleg.mapper")
|
||||
@EnableFeignClients(basePackages = "com.baiyue.firstleg.client")
|
||||
@SpringBootApplication(scanBasePackages = "com.baiyue")
|
||||
public class BaiyueFirstlegApplication {
|
||||
public static void main(String[] args) { SpringApplication.run(BaiyueFirstlegApplication.class, args); }
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.baiyue.firstleg.client;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import org.springframework.cloud.openfeign.FeignClient;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
@FeignClient(name = "warehouse-service", contextId = "warehousePalletLocationClient",
|
||||
url = "${baiyue.clients.warehouse.url:}", path = "/internal/warehouse/v1/pallet-locations")
|
||||
public interface WarehousePalletLocationClient {
|
||||
@PostMapping("/reservations")
|
||||
ApiResponse<BatchResult> reserve(@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@RequestBody ReserveRequest request);
|
||||
|
||||
@PostMapping("/confirm")
|
||||
ApiResponse<BatchResult> confirm(@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@RequestBody ConfirmRequest request);
|
||||
|
||||
@PostMapping("/release")
|
||||
ApiResponse<List<ActiveResource>> release(@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@RequestBody ReleaseRequest request);
|
||||
|
||||
record ReserveRequest(long tenantId, long warehouseId, String sourceService, long businessId,
|
||||
String inboundCode, String idempotencyKey, long operatorId,
|
||||
BigDecimal totalVolumeCbm, BigDecimal palletCapacityCbm, String palletType,
|
||||
LocalDateTime expiresAt, boolean measurementCompleted) {}
|
||||
record ConfirmRequest(long tenantId, long warehouseId, String sourceService, long businessId,
|
||||
long operatorId, BigDecimal totalVolumeCbm, BigDecimal palletCapacityCbm) {}
|
||||
record ReleaseRequest(long tenantId, String sourceService, long businessId, long operatorId,
|
||||
String reason) {}
|
||||
record AllocationResult(long reservationId, String reservationNo, int allocationNo,
|
||||
long palletId, String palletCode, long locationId, String locationCode,
|
||||
BigDecimal allocatedVolumeCbm, String state) {}
|
||||
record BatchResult(int palletCount, List<AllocationResult> allocations) {}
|
||||
record ActiveResource(long reservationId, long warehouseId, long palletId, String palletCode,
|
||||
long locationId, String locationCode, String state) {}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.baiyue.firstleg.config;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Duration;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 头程入库动态配置。代码默认值必须保持关闭,避免 Nacos 缺失时异常放行。
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "firstleg.inbound")
|
||||
public class InboundProperties {
|
||||
private boolean allowRejectedMeasurement = false;
|
||||
private BigDecimal defaultPalletCapacityCbm = new BigDecimal("1.200000");
|
||||
private Duration reservationTtl = Duration.ofMinutes(30);
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package com.baiyue.firstleg.config;
|
||||
|
||||
import io.swagger.v3.oas.annotations.enums.SecuritySchemeIn;
|
||||
import io.swagger.v3.oas.annotations.enums.SecuritySchemeType;
|
||||
import io.swagger.v3.oas.annotations.security.SecurityScheme;
|
||||
import org.springdoc.core.models.GroupedOpenApi;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/** 第三方测量接口独立 Swagger 分组和 HMAC 请求头定义。 */
|
||||
@Configuration
|
||||
@SecurityScheme(name = "measurementAppKey", type = SecuritySchemeType.APIKEY,
|
||||
in = SecuritySchemeIn.HEADER, paramName = "X-App-Key", description = "分配给测量服务商的应用标识")
|
||||
@SecurityScheme(name = "measurementTimestamp", type = SecuritySchemeType.APIKEY,
|
||||
in = SecuritySchemeIn.HEADER, paramName = "X-Timestamp", description = "Unix 毫秒时间戳")
|
||||
@SecurityScheme(name = "measurementNonce", type = SecuritySchemeType.APIKEY,
|
||||
in = SecuritySchemeIn.HEADER, paramName = "X-Nonce", description = "每次请求唯一的随机串")
|
||||
@SecurityScheme(name = "measurementSignature", type = SecuritySchemeType.APIKEY,
|
||||
in = SecuritySchemeIn.HEADER, paramName = "X-Signature", description = "签名串的 HMAC-SHA256 小写十六进制值")
|
||||
public class MeasurementOpenApiDocumentationConfiguration {
|
||||
@Bean
|
||||
GroupedOpenApi measurementExternalApi() {
|
||||
return GroupedOpenApi.builder()
|
||||
.group("measurement-openapi")
|
||||
.displayName("Third-party Measurement OpenAPI / 第三方测量开放接口")
|
||||
.pathsToMatch("/openapi/v1/inbound-measurements/**")
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.baiyue.firstleg.config;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 第三方测量开放接口配置。密钥只能由环境变量或配置中心密文注入。
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "firstleg.measurement-openapi")
|
||||
public class MeasurementOpenApiProperties {
|
||||
private boolean enabled = false;
|
||||
private Duration maxClockSkew = Duration.ofMinutes(5);
|
||||
private Duration nonceTtl = Duration.ofMinutes(10);
|
||||
private Map<String, AppCredential> apps = new LinkedHashMap<>();
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
public static class AppCredential {
|
||||
private boolean enabled = true;
|
||||
private String secret;
|
||||
private Long tenantId;
|
||||
private Long subjectId;
|
||||
private Map<String, Long> warehouseBindings = new LinkedHashMap<>();
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
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.common.security.AuthenticatedUser;
|
||||
import com.baiyue.firstleg.dto.MerchantInboundBillingResponse;
|
||||
import com.baiyue.firstleg.service.MerchantInboundBillingService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
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.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@Tag(name = "客户端入库结果与费用")
|
||||
@RestController
|
||||
@RequestMapping("/api/merchant/v1/first-leg/orders")
|
||||
public class MerchantInboundBillingController {
|
||||
private final MerchantInboundBillingService service;
|
||||
|
||||
public MerchantInboundBillingController(MerchantInboundBillingService service) {
|
||||
this.service = service;
|
||||
}
|
||||
|
||||
@GetMapping("/{orderId}/inbound-billing")
|
||||
@PreAuthorize("hasAuthority('merchant:order:view')")
|
||||
@Operation(summary = "查询订单入库结果和最新费用",
|
||||
description = "客户只读查看客服审核通过后发布的费用,不需要确认费用。")
|
||||
public ApiResponse<MerchantInboundBillingResponse> find(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long orderId) {
|
||||
if (user == null || !"MERCHANT".equals(user.appCode()) || user.tenantId() == null) {
|
||||
throw new BizException(ResultCode.FORBIDDEN, "仅允许客户端访问");
|
||||
}
|
||||
return ApiResponse.success(service.find(user.tenantId(), orderId));
|
||||
}
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
package com.baiyue.firstleg.controller;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.firstleg.dto.OpenApiMeasurementDtos.EventResponse;
|
||||
import com.baiyue.firstleg.security.MeasurementRequestHeaders;
|
||||
import com.baiyue.firstleg.service.InboundMeasurementOpenApiService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.enums.ParameterIn;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
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.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/** 第三方设备长宽高测量开放接口。 */
|
||||
@Validated
|
||||
@RestController
|
||||
@RequestMapping("/openapi/v1/inbound-measurements")
|
||||
@Tag(name = "第三方入库测量 OpenAPI", description = "按入仓码接收长宽高结果,只落测量数据,不审核、计费或入库")
|
||||
@SecurityRequirement(name = "measurementAppKey")
|
||||
@SecurityRequirement(name = "measurementTimestamp")
|
||||
@SecurityRequirement(name = "measurementNonce")
|
||||
@SecurityRequirement(name = "measurementSignature")
|
||||
public class OpenApiInboundMeasurementController {
|
||||
private final InboundMeasurementOpenApiService service;
|
||||
|
||||
public OpenApiInboundMeasurementController(InboundMeasurementOpenApiService service) {
|
||||
this.service = service;
|
||||
}
|
||||
|
||||
@Operation(summary = "提交第三方长宽高测量结果",
|
||||
description = "签名正文为 METHOD、PATH、时间戳、Nonce、Idempotency-Key、原始 body SHA-256 六行;只保存调用日志、测量批次和明细")
|
||||
@ApiResponses({
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "成功或业务失败,按响应体 code 判断"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "413", description = "请求体超过网关限制"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "415", description = "Content-Type 不支持")
|
||||
})
|
||||
@PostMapping
|
||||
public ApiResponse<EventResponse> receive(
|
||||
@RequestHeader("X-App-Key") String appKey,
|
||||
@RequestHeader("X-Timestamp") String timestamp,
|
||||
@RequestHeader("X-Nonce") String nonce,
|
||||
@RequestHeader("X-Signature") String signature,
|
||||
@RequestHeader("Idempotency-Key") String idempotencyKey,
|
||||
@io.swagger.v3.oas.annotations.parameters.RequestBody(required = true,
|
||||
description = "原始 UTF-8 JSON;任何空白差异都会改变签名")
|
||||
@RequestBody String rawBody) {
|
||||
return ApiResponse.success(service.receive(
|
||||
new MeasurementRequestHeaders(appKey, timestamp, nonce, signature, idempotencyKey), rawBody));
|
||||
}
|
||||
|
||||
@Operation(summary = "查询第三方测量事件状态",
|
||||
description = "GET 签名的 Idempotency-Key 行和 body SHA-256 分别为空字符串、空字节 SHA-256")
|
||||
@ApiResponses(@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
||||
responseCode = "200", description = "查询成功或业务失败,按响应体 code 判断"))
|
||||
@GetMapping("/events/{eventNo}")
|
||||
public ApiResponse<EventResponse> query(
|
||||
@Parameter(description = "第三方业务事件号", required = true)
|
||||
@PathVariable @Pattern(regexp = "[A-Za-z0-9._:-]{1,128}") String eventNo,
|
||||
@RequestHeader("X-App-Key") String appKey,
|
||||
@RequestHeader("X-Timestamp") String timestamp,
|
||||
@RequestHeader("X-Nonce") String nonce,
|
||||
@RequestHeader("X-Signature") String signature) {
|
||||
return ApiResponse.success(service.query(eventNo,
|
||||
new MeasurementRequestHeaders(appKey, timestamp, nonce, signature, null)));
|
||||
}
|
||||
}
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
package com.baiyue.firstleg.controller;
|
||||
|
||||
import com.baiyue.common.audit.BusinessAudit;
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.firstleg.dto.FbaPricingDtos.CarrierRateVersionRequest;
|
||||
import com.baiyue.firstleg.dto.FbaPricingDtos.CustomerRuleVersionRequest;
|
||||
import com.baiyue.firstleg.entity.CarrierFbaRateEntity;
|
||||
import com.baiyue.firstleg.entity.CustomerChargeRuleEntity;
|
||||
import com.baiyue.firstleg.service.FbaMasterDataService;
|
||||
import com.baiyue.firstleg.service.FirstlegScopeGuard;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
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.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** 平台端客户计费规则和船司 FBA 价格版本管理。 */
|
||||
@Tag(name = "FBA计费基础资料")
|
||||
@RestController
|
||||
@RequestMapping("/api/platform/v1/fba-pricing")
|
||||
public class PlatformFbaMasterDataController {
|
||||
private final FbaMasterDataService service;
|
||||
|
||||
public PlatformFbaMasterDataController(FbaMasterDataService service) { this.service = service; }
|
||||
|
||||
@GetMapping("/customer-rules")
|
||||
@PreAuthorize("hasAuthority('firstleg:fba-pricing:rule:view')")
|
||||
public ApiResponse<List<CustomerChargeRuleEntity>> rules(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId,
|
||||
@RequestParam(required = false) String ruleCode) {
|
||||
return ApiResponse.success(service.listRules(FirstlegScopeGuard.platform(user, tenantId), ruleCode));
|
||||
}
|
||||
|
||||
@GetMapping("/customer-rules/{id}")
|
||||
@PreAuthorize("hasAuthority('firstleg:fba-pricing:rule:view')")
|
||||
public ApiResponse<CustomerChargeRuleEntity> rule(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId, @PathVariable long id) {
|
||||
return ApiResponse.success(service.rule(FirstlegScopeGuard.platform(user, tenantId), id));
|
||||
}
|
||||
|
||||
@PostMapping("/customer-rules/versions")
|
||||
@PreAuthorize("hasAuthority('firstleg:fba-pricing:rule:edit')")
|
||||
@BusinessAudit(event = "FBA_RULE_VERSION_CREATE", resourceType = "CUSTOMER_CHARGE_RULE",
|
||||
description = "创建客户FBA计费规则版本")
|
||||
public ApiResponse<CustomerChargeRuleEntity> createRule(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId,
|
||||
@Valid @RequestBody CustomerRuleVersionRequest request) {
|
||||
long scope = FirstlegScopeGuard.platform(user, tenantId);
|
||||
return ApiResponse.success(service.createRuleVersion(scope, user.userId(), request));
|
||||
}
|
||||
|
||||
@DeleteMapping("/customer-rules/{id}")
|
||||
@PreAuthorize("hasAuthority('firstleg:fba-pricing:rule:edit')")
|
||||
@BusinessAudit(event = "FBA_RULE_RETIRE", resourceType = "CUSTOMER_CHARGE_RULE", resourceId = "#id",
|
||||
description = "停用客户FBA计费规则版本")
|
||||
public ApiResponse<Void> retireRule(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam long tenantId, @PathVariable long id) {
|
||||
service.retireRule(FirstlegScopeGuard.platform(user, tenantId), id, user.userId());
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
|
||||
@GetMapping("/carrier-rates")
|
||||
@PreAuthorize("hasAuthority('firstleg:fba-pricing:rate:view')")
|
||||
public ApiResponse<List<CarrierFbaRateEntity>> rates(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId,
|
||||
@RequestParam(required = false) String rateCode) {
|
||||
return ApiResponse.success(service.listRates(FirstlegScopeGuard.platform(user, tenantId), rateCode));
|
||||
}
|
||||
|
||||
@GetMapping("/carrier-rates/{id}")
|
||||
@PreAuthorize("hasAuthority('firstleg:fba-pricing:rate:view')")
|
||||
public ApiResponse<CarrierFbaRateEntity> rate(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId, @PathVariable long id) {
|
||||
return ApiResponse.success(service.rate(FirstlegScopeGuard.platform(user, tenantId), id));
|
||||
}
|
||||
|
||||
@PostMapping("/carrier-rates/versions")
|
||||
@PreAuthorize("hasAuthority('firstleg:fba-pricing:rate:edit')")
|
||||
@BusinessAudit(event = "FBA_RATE_VERSION_CREATE", resourceType = "CARRIER_FBA_RATE",
|
||||
description = "创建船司FBA价格版本")
|
||||
public ApiResponse<CarrierFbaRateEntity> createRate(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId,
|
||||
@Valid @RequestBody CarrierRateVersionRequest request) {
|
||||
long scope = FirstlegScopeGuard.platform(user, tenantId);
|
||||
return ApiResponse.success(service.createRateVersion(scope, user.userId(), request));
|
||||
}
|
||||
|
||||
@DeleteMapping("/carrier-rates/{id}")
|
||||
@PreAuthorize("hasAuthority('firstleg:fba-pricing:rate:edit')")
|
||||
@BusinessAudit(event = "FBA_RATE_RETIRE", resourceType = "CARRIER_FBA_RATE", resourceId = "#id",
|
||||
description = "停用船司FBA价格版本")
|
||||
public ApiResponse<Void> retireRate(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam long tenantId, @PathVariable long id) {
|
||||
service.retireRate(FirstlegScopeGuard.platform(user, tenantId), id, user.userId());
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
}
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
package com.baiyue.firstleg.controller;
|
||||
|
||||
import com.baiyue.common.audit.BusinessAudit;
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.firstleg.dto.FbaPricingDtos.CalculateRequest;
|
||||
import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse;
|
||||
import com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest;
|
||||
import com.baiyue.firstleg.service.FbaPricingService;
|
||||
import com.baiyue.firstleg.service.FirstlegScopeGuard;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
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.PathVariable;
|
||||
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.RequestParam;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/** 平台端入库单 FBA 费用试算、确认和发送接口。 */
|
||||
@Tag(name = "入库FBA计费")
|
||||
@RestController
|
||||
@RequestMapping("/api/platform/v1/inbounds/{inboundId}/fees")
|
||||
public class PlatformFbaPricingController {
|
||||
private final FbaPricingService service;
|
||||
|
||||
public PlatformFbaPricingController(FbaPricingService service) { this.service = service; }
|
||||
|
||||
@PostMapping("/calculate")
|
||||
@PreAuthorize("hasAuthority('firstleg:fba-pricing:calculate')")
|
||||
@BusinessAudit(event = "FBA_FEE_CALCULATE", resourceType = "INBOUND", resourceId = "#inboundId",
|
||||
description = "试算FBA费用")
|
||||
public ApiResponse<FeeSnapshotResponse> calculate(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId,
|
||||
@PathVariable long inboundId, @RequestHeader("Idempotency-Key") String idempotencyKey,
|
||||
@Valid @RequestBody(required = false) CalculateRequest request) {
|
||||
long scope = FirstlegScopeGuard.platform(user, tenantId);
|
||||
return ApiResponse.success(service.calculate(scope, user.userId(), inboundId,
|
||||
request == null ? null : request.pricingAt(), idempotencyKey));
|
||||
}
|
||||
|
||||
@PostMapping("/confirm")
|
||||
@PreAuthorize("hasAuthority('firstleg:fba-pricing:confirm')")
|
||||
@BusinessAudit(event = "FBA_FEE_CONFIRM", resourceType = "INBOUND", resourceId = "#inboundId",
|
||||
description = "确认FBA费用快照")
|
||||
public ApiResponse<FeeSnapshotResponse> confirm(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId,
|
||||
@PathVariable long inboundId, @RequestHeader("Idempotency-Key") String idempotencyKey) {
|
||||
long scope = FirstlegScopeGuard.platform(user, tenantId);
|
||||
return ApiResponse.success(service.confirm(scope, user.userId(), inboundId, idempotencyKey));
|
||||
}
|
||||
|
||||
@PostMapping("/send")
|
||||
@PreAuthorize("hasAuthority('firstleg:inbound:send-fee')")
|
||||
@BusinessAudit(event = "FBA_FEE_SEND", resourceType = "INBOUND", resourceId = "#inboundId",
|
||||
description = "发送FBA费用快照并进入待托盘状态")
|
||||
public ApiResponse<FeeSnapshotResponse> send(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId,
|
||||
@PathVariable long inboundId, @RequestHeader("Idempotency-Key") String idempotencyKey) {
|
||||
long scope = FirstlegScopeGuard.platform(user, tenantId);
|
||||
return ApiResponse.success(service.send(scope, user.userId(), inboundId, idempotencyKey));
|
||||
}
|
||||
|
||||
@GetMapping("/latest")
|
||||
@PreAuthorize("hasAuthority('firstleg:fba-pricing:view')")
|
||||
public ApiResponse<FeeSnapshotResponse> latest(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId,
|
||||
@PathVariable long inboundId) {
|
||||
return ApiResponse.success(service.latest(FirstlegScopeGuard.platform(user, tenantId), inboundId));
|
||||
}
|
||||
|
||||
@PutMapping("/published")
|
||||
@PreAuthorize("hasAuthority('firstleg:fba-pricing:adjust')")
|
||||
@BusinessAudit(event = "FBA_FEE_ADJUST", resourceType = "INBOUND", resourceId = "#inboundId",
|
||||
description = "调整已发布入库费用,不回退入库状态")
|
||||
public ApiResponse<FeeSnapshotResponse> adjustPublished(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId,
|
||||
@PathVariable long inboundId, @RequestHeader("Idempotency-Key") String idempotencyKey,
|
||||
@Valid @RequestBody AdjustFeeRequest request) {
|
||||
long scope = FirstlegScopeGuard.platform(user, tenantId);
|
||||
return ApiResponse.success(service.adjustPublished(
|
||||
scope, user.userId(), inboundId, request, idempotencyKey));
|
||||
}
|
||||
}
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
package com.baiyue.firstleg.controller;
|
||||
|
||||
import com.baiyue.common.audit.BusinessAudit;
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.firstleg.dto.InboundMeasurementRequests;
|
||||
import com.baiyue.firstleg.dto.InboundMeasurementResponses;
|
||||
import com.baiyue.firstleg.service.InboundMeasurementService;
|
||||
import com.baiyue.firstleg.service.MeasurementSubmissionService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
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.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/** 平台后台入库测量录入、提交和 OCR 重量结果维护接口。 */
|
||||
@Tag(name = "平台入库测量", description = "维护测量草稿、箱规明细和可追溯 OCR 重量结果。")
|
||||
@RestController
|
||||
@RequestMapping("/api/platform/v1/first-leg/inbounds/{inboundId}")
|
||||
public class PlatformInboundMeasurementController {
|
||||
private final InboundMeasurementService service;
|
||||
private final MeasurementSubmissionService submission;
|
||||
|
||||
public PlatformInboundMeasurementController(InboundMeasurementService service,
|
||||
MeasurementSubmissionService submission) {
|
||||
this.service = service;
|
||||
this.submission = submission;
|
||||
}
|
||||
|
||||
@Operation(summary = "查询入库测量详情", description = "返回原单、实测箱规、审核快照和 OCR 重量结果。")
|
||||
@GetMapping("/measurement")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:inbound:measure:view')")
|
||||
public ApiResponse<InboundMeasurementResponses.Detail> detail(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam long tenantId,
|
||||
@PathVariable long inboundId) {
|
||||
requirePlatformTenant(user, tenantId);
|
||||
return ApiResponse.success(service.detail(tenantId, inboundId));
|
||||
}
|
||||
|
||||
@BusinessAudit(event = "INBOUND_MEASUREMENT_DRAFT_SAVE", resourceType = "INBOUND",
|
||||
resourceId = "#inboundId", description = "保存入库测量草稿")
|
||||
@Operation(summary = "保存测量草稿", description = "保存人工测量批次和服务端计算明细,不推进入库状态。")
|
||||
@PutMapping("/measurement/draft")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:inbound:measure:edit')")
|
||||
public ApiResponse<InboundMeasurementResponses.DraftSaved> saveDraft(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam long tenantId,
|
||||
@PathVariable long inboundId,
|
||||
@Valid @RequestBody InboundMeasurementRequests.SaveDraftRequest request) {
|
||||
requirePlatformTenant(user, tenantId);
|
||||
return ApiResponse.success(service.saveDraft(tenantId, inboundId, user.userId(), request));
|
||||
}
|
||||
|
||||
@BusinessAudit(event = "INBOUND_MEASUREMENT_SUBMIT", resourceType = "INBOUND",
|
||||
resourceId = "#inboundId", description = "确认测量并提交审核")
|
||||
@Operation(summary = "确认测量并提交审核",
|
||||
description = "校验测量图片和全部 OCR 重量后,最多推进到 MEASURE_PENDING。")
|
||||
@PostMapping("/measurement/submit")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:inbound:measure:submit')")
|
||||
public ApiResponse<InboundMeasurementResponses.MeasurementSubmitted> submit(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam long tenantId,
|
||||
@PathVariable long inboundId,
|
||||
@Valid @RequestBody InboundMeasurementRequests.SubmitRequest request) {
|
||||
requirePlatformTenant(user, tenantId);
|
||||
return ApiResponse.success(submission.submit(tenantId, inboundId, user.userId(), request));
|
||||
}
|
||||
|
||||
@BusinessAudit(event = "INBOUND_WEIGHT_OCR_RECORD", resourceType = "INBOUND",
|
||||
resourceId = "#inboundId", description = "登记称重图片 OCR 原值")
|
||||
@Operation(summary = "登记 OCR 重量结果", description = "按图片文件 ID 保存不可覆盖的 OCR 原值。")
|
||||
@PostMapping("/weight-results")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:inbound:weight:edit')")
|
||||
public ApiResponse<InboundMeasurementResponses.WeightResult> createWeightResult(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam long tenantId,
|
||||
@PathVariable long inboundId,
|
||||
@Valid @RequestBody InboundMeasurementRequests.CreateWeightResultRequest request) {
|
||||
requirePlatformTenant(user, tenantId);
|
||||
return ApiResponse.success(service.createWeightResult(tenantId, inboundId, user.userId(), request));
|
||||
}
|
||||
|
||||
@BusinessAudit(event = "INBOUND_WEIGHT_OCR_CORRECT", resourceType = "INBOUND_WEIGHT_RESULT",
|
||||
resourceId = "#resultId", description = "人工修正 OCR 重量")
|
||||
@Operation(summary = "人工修正 OCR 重量", description = "保留 OCR 原值并新增不可变修正版本。")
|
||||
@PutMapping("/weight-results/{resultId}")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:inbound:weight:edit')")
|
||||
public ApiResponse<InboundMeasurementResponses.WeightResult> correctWeightResult(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam long tenantId,
|
||||
@PathVariable long inboundId,
|
||||
@PathVariable long resultId,
|
||||
@Valid @RequestBody InboundMeasurementRequests.CorrectWeightResultRequest request) {
|
||||
requirePlatformTenant(user, tenantId);
|
||||
return ApiResponse.success(service.correctWeightResult(
|
||||
tenantId, inboundId, resultId, user.userId(), user.username(), request));
|
||||
}
|
||||
|
||||
@BusinessAudit(event = "INBOUND_WEIGHT_OCR_CONFIRM", resourceType = "INBOUND_WEIGHT_RESULT",
|
||||
resourceId = "#resultId", description = "确认 OCR 重量并累加实际总毛重")
|
||||
@Operation(summary = "确认 OCR 重量", description = "统一换算为 KG 并重算所有已确认图片的总重量。")
|
||||
@PostMapping("/weight-results/{resultId}/confirm")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:inbound:weight:confirm')")
|
||||
public ApiResponse<InboundMeasurementResponses.WeightResult> confirmWeightResult(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam long tenantId,
|
||||
@PathVariable long inboundId,
|
||||
@PathVariable long resultId,
|
||||
@Valid @RequestBody InboundMeasurementRequests.ConfirmWeightResultRequest request) {
|
||||
requirePlatformTenant(user, tenantId);
|
||||
return ApiResponse.success(service.confirmWeightResult(
|
||||
tenantId, inboundId, resultId, user.userId(), user.username(), request));
|
||||
}
|
||||
|
||||
private static void requirePlatformTenant(AuthenticatedUser user, long tenantId) {
|
||||
if (user == null || !"PLATFORM".equals(user.appCode())) {
|
||||
throw new BizException(ResultCode.FORBIDDEN, "仅允许平台端访问");
|
||||
}
|
||||
if (tenantId <= 0 || user.userId() == null || user.userId() <= 0) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "必须指定有效目标租户和操作人");
|
||||
}
|
||||
}
|
||||
}
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
package com.baiyue.firstleg.controller;
|
||||
|
||||
import com.baiyue.common.audit.BusinessAudit;
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.firstleg.dto.ExceptionReleaseRequest;
|
||||
import com.baiyue.firstleg.dto.MeasurementReviewRequest;
|
||||
import com.baiyue.firstleg.entity.MeasureReviewRecordEntity;
|
||||
import com.baiyue.firstleg.service.MeasurementReviewService;
|
||||
import com.baiyue.firstleg.service.AutomaticInboundCompletionService;
|
||||
import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
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.PathVariable;
|
||||
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.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/** 平台端入库测量审核、异常放行和审核历史接口。 */
|
||||
@Tag(name = "平台入库测量审核")
|
||||
@RestController
|
||||
@RequestMapping("/api/platform/v1/inbounds")
|
||||
public class PlatformInboundMeasurementReviewController {
|
||||
private final MeasurementReviewService service;
|
||||
private final AutomaticInboundCompletionService automaticCompletion;
|
||||
|
||||
public PlatformInboundMeasurementReviewController(MeasurementReviewService service,
|
||||
AutomaticInboundCompletionService automaticCompletion) {
|
||||
this.service = service;
|
||||
this.automaticCompletion = automaticCompletion;
|
||||
}
|
||||
|
||||
@BusinessAudit(event = "INBOUND_MEASURE_APPROVE", resourceType = "INBOUND",
|
||||
resourceId = "#inboundId", description = "测量审核通过")
|
||||
@Operation(summary = "测量审核通过",
|
||||
description = "客服审核通过后自动计费、发布费用给客户并完成入库;客户无需确认费用。")
|
||||
@PostMapping("/{inboundId}/measurements/approve")
|
||||
@PreAuthorize("hasAuthority('firstleg:inbound:measurement-review')")
|
||||
public ApiResponse<FeeSnapshotResponse> approve(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam long tenantId,
|
||||
@PathVariable long inboundId,
|
||||
@Parameter(required = true, description = "同键同请求重试返回首次成功结果")
|
||||
@RequestHeader("Idempotency-Key") String idempotencyKey,
|
||||
@Valid @RequestBody MeasurementReviewRequest request) {
|
||||
validateScope(user, tenantId);
|
||||
service.approve(tenantId, user.userId(), user.username(), inboundId, idempotencyKey, request);
|
||||
return ApiResponse.success(automaticCompletion.completeApproved(
|
||||
tenantId, inboundId, user.userId(), user.username(), request.measurementId(),
|
||||
request.expectedVersion() + 1));
|
||||
}
|
||||
|
||||
@BusinessAudit(event = "INBOUND_MEASURE_REJECT", resourceType = "INBOUND",
|
||||
resourceId = "#inboundId", description = "测量审核驳回")
|
||||
@Operation(summary = "测量审核驳回",
|
||||
description = "驳回原因必填,只退回测量数据修改;已确认的托盘和库位占用保持不动。")
|
||||
@PostMapping("/{inboundId}/measurements/reject")
|
||||
@PreAuthorize("hasAuthority('firstleg:inbound:measurement-review')")
|
||||
public ApiResponse<Void> reject(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam long tenantId,
|
||||
@PathVariable long inboundId,
|
||||
@RequestHeader("Idempotency-Key") String idempotencyKey,
|
||||
@Valid @RequestBody MeasurementReviewRequest request) {
|
||||
validateScope(user, tenantId);
|
||||
service.reject(tenantId, user.userId(), user.username(), inboundId, idempotencyKey, request);
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
|
||||
@BusinessAudit(event = "INBOUND_MEASURE_EXCEPTION_RELEASE", resourceType = "INBOUND",
|
||||
resourceId = "#inboundId", description = "测量驳回异常放行")
|
||||
@Operation(summary = "测量驳回异常放行",
|
||||
description = "Nacos 开关开启、有异常放行权限且原因非空时,才可从 MEASURE_REJECTED 推进至 WAIT_PRICING。")
|
||||
@PostMapping("/{inboundId}/measurements/exception-release")
|
||||
@PreAuthorize("hasAuthority('firstleg:inbound:exception-release')")
|
||||
public ApiResponse<Void> exceptionRelease(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam long tenantId,
|
||||
@PathVariable long inboundId,
|
||||
@RequestHeader("Idempotency-Key") String idempotencyKey,
|
||||
@Valid @RequestBody ExceptionReleaseRequest request) {
|
||||
validateScope(user, tenantId);
|
||||
service.exceptionRelease(
|
||||
tenantId, user.userId(), user.username(), inboundId, idempotencyKey, request);
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
|
||||
@Operation(summary = "查询测量审核历史")
|
||||
@GetMapping("/{inboundId}/measurement-review-history")
|
||||
@PreAuthorize("hasAuthority('firstleg:inbound:measurement-review-history')")
|
||||
public ApiResponse<PageResult<MeasureReviewRecordEntity>> history(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam long tenantId,
|
||||
@PathVariable long inboundId,
|
||||
@RequestParam(defaultValue = "1") @Min(1) long page,
|
||||
@RequestParam(defaultValue = "20") @Min(1) @Max(200) long size) {
|
||||
validateScope(user, tenantId);
|
||||
return ApiResponse.success(service.history(tenantId, inboundId, page, size));
|
||||
}
|
||||
|
||||
private static void validateScope(AuthenticatedUser user, long tenantId) {
|
||||
if (user == null || !"PLATFORM".equals(user.appCode())) {
|
||||
throw new BizException(ResultCode.FORBIDDEN, "仅允许平台端访问");
|
||||
}
|
||||
if (tenantId <= 0) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "必须指定有效目标租户");
|
||||
}
|
||||
}
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
package com.baiyue.firstleg.controller;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.firstleg.dto.InboundOrderDetail;
|
||||
import com.baiyue.firstleg.dto.InboundOrderQuery;
|
||||
import com.baiyue.firstleg.dto.InboundOrderSummary;
|
||||
import com.baiyue.firstleg.service.InboundOrderQueryService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
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.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/** 平台端入库单列表和详情只读接口。 */
|
||||
@Tag(name = "平台入库单", description = "二期入库单列表、页签、筛选和只读聚合详情")
|
||||
@RestController
|
||||
@RequestMapping("/api/platform/inbounds")
|
||||
public class PlatformInboundOrderController {
|
||||
private final InboundOrderQueryService service;
|
||||
|
||||
public PlatformInboundOrderController(InboundOrderQueryService service) {
|
||||
this.service = service;
|
||||
}
|
||||
|
||||
@Operation(summary = "分页查询入库单", description = "按目标租户和二期状态页签分页查询入库单快照。")
|
||||
@GetMapping
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:inbound:list')")
|
||||
public ApiResponse<PageResult<InboundOrderSummary>> page(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam long tenantId,
|
||||
@Valid InboundOrderQuery query) {
|
||||
requirePlatformTenant(user, tenantId);
|
||||
return ApiResponse.success(service.page(tenantId, query));
|
||||
}
|
||||
|
||||
@Operation(summary = "查询入库单详情", description = "查询入库单、运输、托盘库位、文件、备注和状态历史快照。")
|
||||
@GetMapping("/{inboundId}")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:inbound:view')")
|
||||
public ApiResponse<InboundOrderDetail> detail(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam long tenantId,
|
||||
@PathVariable long inboundId) {
|
||||
requirePlatformTenant(user, tenantId);
|
||||
if (inboundId <= 0) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "入库单ID必须大于零");
|
||||
}
|
||||
return ApiResponse.success(service.detail(tenantId, inboundId));
|
||||
}
|
||||
|
||||
private static void requirePlatformTenant(AuthenticatedUser user, long tenantId) {
|
||||
if (user == null || !"PLATFORM".equals(user.appCode())) {
|
||||
throw new BizException(ResultCode.FORBIDDEN, "仅允许平台端访问");
|
||||
}
|
||||
if (tenantId <= 0) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "必须指定有效目标租户");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.baiyue.firstleg.domain;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** 入库列表页签及其流程状态映射。 */
|
||||
public enum InboundListTab {
|
||||
WAIT_MEASURE(List.of("WAIT_MEASURE", "MEASURING", "MEASURE_REJECTED")),
|
||||
WAIT_REVIEW(List.of("MEASURE_PENDING")),
|
||||
WAIT_FEE_SEND(List.of("WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING")),
|
||||
INBOUNDED(List.of("INBOUNDED"));
|
||||
|
||||
private final List<String> processStatuses;
|
||||
|
||||
InboundListTab(List<String> processStatuses) {
|
||||
this.processStatuses = processStatuses;
|
||||
}
|
||||
|
||||
public List<String> processStatuses() {
|
||||
return processStatuses;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.baiyue.firstleg.domain;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
/** 二期入库主流程状态,不包含原型中的“待客户确认”。 */
|
||||
public final class InboundProcessStatus {
|
||||
public static final Set<String> VALUES = Set.of(
|
||||
"WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED",
|
||||
"WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING", "INBOUNDED", "VOIDED"
|
||||
);
|
||||
|
||||
private InboundProcessStatus() {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.baiyue.firstleg.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
import jakarta.validation.constraints.PositiveOrZero;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
/** 测量驳回后的异常放行请求。 */
|
||||
public record ExceptionReleaseRequest(
|
||||
@NotNull @Positive Long measurementId,
|
||||
@NotNull @PositiveOrZero Integer expectedVersion,
|
||||
@NotBlank @Size(max = 1000) String reason
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.baiyue.firstleg.dto;
|
||||
|
||||
import jakarta.validation.constraints.DecimalMin;
|
||||
import jakarta.validation.constraints.Max;
|
||||
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.LocalDateTime;
|
||||
|
||||
/** 二期 FBA 计费请求与只读快照响应。 */
|
||||
public final class FbaPricingDtos {
|
||||
private FbaPricingDtos() {}
|
||||
|
||||
public record CustomerRuleVersionRequest(
|
||||
@NotBlank @Size(max = 50) String ruleCode,
|
||||
@NotNull @Min(1) Long customerId,
|
||||
@NotBlank @Size(max = 128) String customerName,
|
||||
@NotBlank @Size(max = 32) String businessType,
|
||||
@NotBlank @Pattern(regexp = "KG|CBM") String chargeMode,
|
||||
@NotBlank @Pattern(regexp = "TAX_INCLUDED|TAX_EXCLUDED") String taxMode,
|
||||
@NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal conversionFactor,
|
||||
@NotNull @Min(0) @Max(4) Integer roundingScale,
|
||||
@NotBlank @Pattern(regexp = "UP|DOWN|CEILING|FLOOR|HALF_UP|HALF_DOWN|HALF_EVEN") String roundingMode,
|
||||
@NotBlank @Pattern(regexp = "[A-Z]{3}") String currency,
|
||||
@NotNull LocalDateTime effectiveFrom,
|
||||
LocalDateTime effectiveTo,
|
||||
@Size(max = 500) String remark
|
||||
) {}
|
||||
|
||||
public record CarrierRateVersionRequest(
|
||||
@NotBlank @Size(max = 50) String rateCode,
|
||||
@NotNull @Min(1) Long originWarehouseId,
|
||||
@NotBlank @Size(max = 128) String originWarehouseName,
|
||||
@NotBlank @Size(max = 32) String businessType,
|
||||
@NotBlank @Size(max = 64) String channelCode,
|
||||
@NotNull @Min(1) Long carrierId,
|
||||
@NotBlank @Size(max = 128) String carrierName,
|
||||
@NotBlank @Size(max = 32) String destinationCode,
|
||||
@NotBlank @Pattern(regexp = "KG|CBM") String chargeUnit,
|
||||
@NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal unitPrice,
|
||||
@NotBlank @Pattern(regexp = "[A-Z]{3}") String currency,
|
||||
@NotNull @DecimalMin("0") BigDecimal additionalFee,
|
||||
@NotNull LocalDateTime effectiveFrom,
|
||||
LocalDateTime effectiveTo,
|
||||
@Size(max = 500) String remark
|
||||
) {}
|
||||
|
||||
public record CalculateRequest(LocalDateTime pricingAt) {}
|
||||
|
||||
public record AdjustFeeRequest(
|
||||
@NotNull @DecimalMin("0.00") BigDecimal subtotalAmount,
|
||||
@NotNull @DecimalMin("0.00") BigDecimal additionalAmount,
|
||||
@NotBlank @Size(max = 500) String reason
|
||||
) {}
|
||||
|
||||
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,
|
||||
BigDecimal conversionFactor, Integer roundingScale, String roundingMode,
|
||||
BigDecimal actualWeightKg, BigDecimal actualVolumeCbm,
|
||||
BigDecimal convertedQuantity, BigDecimal chargeableQuantity, String chargeUnit,
|
||||
BigDecimal unitPrice, BigDecimal subtotalAmount, BigDecimal additionalAmount,
|
||||
BigDecimal totalAmount, String currency,
|
||||
LocalDateTime rateEffectiveFrom, LocalDateTime rateEffectiveTo,
|
||||
LocalDateTime calculatedAt, LocalDateTime confirmedAt, LocalDateTime sentAt,
|
||||
String nextProcessStatus
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package com.baiyue.firstleg.dto;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.DecimalMin;
|
||||
import jakarta.validation.constraints.DecimalMax;
|
||||
import jakarta.validation.constraints.Digits;
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
import jakarta.validation.constraints.PositiveOrZero;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 平台端入库测量写请求。计算字段只由服务端生成,避免前端篡改汇总结果。
|
||||
*/
|
||||
public final class InboundMeasurementRequests {
|
||||
private InboundMeasurementRequests() {
|
||||
}
|
||||
|
||||
public record SaveDraftRequest(
|
||||
Long measurementId,
|
||||
@NotNull @PositiveOrZero Integer version,
|
||||
@Size(max = 64) String originalDocumentNo,
|
||||
@DecimalMin("0.000") @Digits(integer = 15, fraction = 3) BigDecimal originalGrossWeightKg,
|
||||
@DecimalMin("0.0000") @Digits(integer = 14, fraction = 4) BigDecimal originalVolumeCbm,
|
||||
@Size(max = 255) String actualGoodsName,
|
||||
@NotEmpty @Size(max = 500) List<@Valid ItemRequest> items
|
||||
) {
|
||||
}
|
||||
|
||||
public record ItemRequest(
|
||||
@NotBlank @Size(max = 32) String itemNo,
|
||||
@NotBlank @Size(max = 64) String boxSpecification,
|
||||
@NotNull @Positive @Max(1000000) Integer packageCount,
|
||||
@NotNull @DecimalMin("0.001") @Digits(integer = 9, fraction = 3) BigDecimal lengthCm,
|
||||
@NotNull @DecimalMin("0.001") @Digits(integer = 9, fraction = 3) BigDecimal widthCm,
|
||||
@NotNull @DecimalMin("0.001") @Digits(integer = 9, fraction = 3) BigDecimal heightCm,
|
||||
@NotNull @DecimalMin("0.001") @Digits(integer = 15, fraction = 3) BigDecimal unitWeightKg,
|
||||
@Positive Long intelligentImageFileId,
|
||||
@Size(max = 500) String overLimitWarning,
|
||||
@DecimalMin("0.000") @Digits(integer = 9, fraction = 3) BigDecimal outerLengthCm,
|
||||
@DecimalMin("0.000") @Digits(integer = 9, fraction = 3) BigDecimal outerWidthCm,
|
||||
@DecimalMin("0.000") @Digits(integer = 9, fraction = 3) BigDecimal outerHeightCm,
|
||||
@Size(max = 500) String remark
|
||||
) {
|
||||
}
|
||||
|
||||
public record SubmitRequest(
|
||||
@NotNull @Positive Long measurementId,
|
||||
@NotNull @PositiveOrZero Integer version
|
||||
) {
|
||||
}
|
||||
|
||||
public record CreateWeightResultRequest(
|
||||
@NotNull @Positive Long fileId,
|
||||
@Size(max = 64) String ocrProvider,
|
||||
@Size(max = 1000) String ocrRawText,
|
||||
@DecimalMin("0.000") @Digits(integer = 15, fraction = 3) BigDecimal ocrWeightValue,
|
||||
@Size(max = 8) String ocrWeightUnit,
|
||||
@DecimalMin("0.00000") @DecimalMax("1.00000")
|
||||
@Digits(integer = 1, fraction = 5) BigDecimal confidence
|
||||
) {
|
||||
}
|
||||
|
||||
public record CorrectWeightResultRequest(
|
||||
@NotNull @PositiveOrZero Integer version,
|
||||
@NotNull @DecimalMin("0.001") @Digits(integer = 15, fraction = 3) BigDecimal adjustedWeightValue,
|
||||
@NotBlank @Size(max = 8) String adjustedWeightUnit,
|
||||
@NotBlank @Size(max = 500) String adjustmentReason
|
||||
) {
|
||||
}
|
||||
|
||||
public record ConfirmWeightResultRequest(
|
||||
@NotNull @PositiveOrZero Integer version
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package com.baiyue.firstleg.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 入库测量详情响应,覆盖原单、实测明细、审核快照和 OCR 重量维护字段。
|
||||
*/
|
||||
public final class InboundMeasurementResponses {
|
||||
private InboundMeasurementResponses() {
|
||||
}
|
||||
|
||||
public record Detail(
|
||||
Long inboundId,
|
||||
String inboundCode,
|
||||
String companyName,
|
||||
String channelCode,
|
||||
String channelName,
|
||||
String processStatus,
|
||||
String measureReviewStatus,
|
||||
@Schema(description = "测量完成后的推荐托盘类型,仅为建议,不代表已分配托盘",
|
||||
allowableValues = "STANDARD", example = "STANDARD")
|
||||
String recommendedPalletType,
|
||||
String reviewReason,
|
||||
Long reviewerId,
|
||||
String reviewerName,
|
||||
Long measurementId,
|
||||
String sourceType,
|
||||
String measurementStatus,
|
||||
Integer version,
|
||||
String originalDocumentNo,
|
||||
BigDecimal originalGrossWeightKg,
|
||||
BigDecimal originalVolumeCbm,
|
||||
Integer actualPackageCount,
|
||||
BigDecimal actualGrossWeightKg,
|
||||
BigDecimal actualVolumeCbm,
|
||||
BigDecimal volumeWeightKg,
|
||||
String actualGoodsName,
|
||||
List<Item> items,
|
||||
List<WeightResult> weightResults
|
||||
) {
|
||||
}
|
||||
|
||||
public record Item(
|
||||
Long id,
|
||||
String itemNo,
|
||||
String boxSpecification,
|
||||
Integer packageCount,
|
||||
BigDecimal lengthCm,
|
||||
BigDecimal widthCm,
|
||||
BigDecimal heightCm,
|
||||
BigDecimal girthCm,
|
||||
BigDecimal unitWeightKg,
|
||||
BigDecimal unitVolumeCbm,
|
||||
BigDecimal totalWeightKg,
|
||||
BigDecimal totalVolumeCbm,
|
||||
BigDecimal volumeWeightKg,
|
||||
Long intelligentImageFileId,
|
||||
String overLimitWarning,
|
||||
BigDecimal outerLengthCm,
|
||||
BigDecimal outerWidthCm,
|
||||
BigDecimal outerHeightCm,
|
||||
String remark
|
||||
) {
|
||||
}
|
||||
|
||||
public record WeightResult(
|
||||
Long id,
|
||||
Long fileId,
|
||||
String ocrProvider,
|
||||
String ocrRawText,
|
||||
BigDecimal ocrWeightValue,
|
||||
String ocrWeightUnit,
|
||||
BigDecimal confidence,
|
||||
BigDecimal adjustedWeightValue,
|
||||
String adjustedWeightUnit,
|
||||
BigDecimal finalWeightKg,
|
||||
String recognitionStatus,
|
||||
String adjustmentReason,
|
||||
Long adjustedBy,
|
||||
LocalDateTime adjustedAt,
|
||||
Long confirmedBy,
|
||||
LocalDateTime confirmedAt,
|
||||
Integer version
|
||||
) {
|
||||
}
|
||||
|
||||
public record DraftSaved(Long measurementId, Integer version, String status) {
|
||||
}
|
||||
|
||||
public record MeasurementSubmitted(Long measurementId, Integer version, String processStatus,
|
||||
String measureReviewStatus,
|
||||
@Schema(description = "测量完成后的推荐托盘类型,仅为建议,不代表已分配托盘",
|
||||
allowableValues = "STANDARD", example = "STANDARD")
|
||||
String recommendedPalletType,
|
||||
Long feeId, String feeNo, String feeStatus) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.baiyue.firstleg.dto;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/** 入库单只读聚合详情。 */
|
||||
public record InboundOrderDetail(
|
||||
InboundOrderSummary summary,
|
||||
String publicRemark,
|
||||
List<PalletLocationSummary> palletLocations,
|
||||
List<FileSummary> files,
|
||||
List<InternalNoteSummary> internalNotes,
|
||||
List<StatusHistorySummary> statusHistory
|
||||
) {
|
||||
public record PalletLocationSummary(
|
||||
Integer allocationNo, Long warehouseId, Long palletId, String palletCode,
|
||||
BigDecimal palletCapacityCbm, BigDecimal allocatedVolumeCbm, Integer allocatedPackageCount,
|
||||
Long locationId, String locationCode, String reservationNo, String allocationStatus) {
|
||||
}
|
||||
|
||||
public record FileSummary(
|
||||
Long fileId, String fileType, String fileNameSnapshot, String visibility,
|
||||
Integer fileVersion, Long uploadedBy, LocalDateTime uploadedAt) {
|
||||
}
|
||||
|
||||
public record InternalNoteSummary(
|
||||
Long id, String content, Long createdBy, String createdByName, LocalDateTime createdAt) {
|
||||
}
|
||||
|
||||
public record StatusHistorySummary(
|
||||
String fromStatus, String toStatus, String action, String reason,
|
||||
Long operatorId, String operatorName, LocalDateTime occurredAt) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.baiyue.firstleg.dto;
|
||||
|
||||
import com.baiyue.firstleg.domain.InboundListTab;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** 入库单分页筛选条件。 */
|
||||
@Schema(name = "InboundOrderQuery", description = "入库单列表筛选和页签条件")
|
||||
public record InboundOrderQuery(
|
||||
@Size(max = 40) String inboundCode,
|
||||
@Size(max = 32) String orderNo,
|
||||
@Size(max = 24) String processStatus,
|
||||
@Size(max = 24) String orderStatus,
|
||||
@Size(max = 24) String measureReviewStatus,
|
||||
@Size(max = 24) String feeStatus,
|
||||
@Size(max = 24) String feeReviewStatus,
|
||||
InboundListTab tab,
|
||||
LocalDateTime expectedArrivalFrom,
|
||||
LocalDateTime expectedArrivalTo,
|
||||
Long customerId,
|
||||
@Size(max = 128) String customerName,
|
||||
Long companyId,
|
||||
Long warehouseId,
|
||||
Long deliveryWarehouseId,
|
||||
@Size(max = 32) String destinationCode,
|
||||
Boolean holdFlag,
|
||||
@Min(1) long page,
|
||||
@Min(1) @Max(200) long size
|
||||
) {
|
||||
public InboundOrderQuery {
|
||||
if (page == 0) {
|
||||
page = 1;
|
||||
}
|
||||
if (size == 0) {
|
||||
size = 20;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.baiyue.firstleg.dto;
|
||||
|
||||
import com.baiyue.firstleg.entity.InboundOrderEntity;
|
||||
import com.baiyue.firstleg.entity.InboundTransportSnapshotEntity;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** 入库列表行,所有名称和运输信息均为业务快照。 */
|
||||
public record InboundOrderSummary(
|
||||
Long id, Long orderId, String orderNo, String inboundCode,
|
||||
String processStatus, String orderStatusSnapshot, String measureReviewStatus,
|
||||
String feeStatus, String feeReviewStatus, String notificationStatus,
|
||||
LocalDateTime expectedArrivalAt,
|
||||
Long customerId, String customerName, Long companyId, String companyName,
|
||||
Long warehouseId, String warehouseName,
|
||||
Long deliveryWarehouseId, String deliveryWarehouseName,
|
||||
String businessType, String channelCode, String channelName,
|
||||
String taxMode, String containerMode, Boolean customsInspection,
|
||||
String destinationType, Long destinationWarehouseId, String destinationCode, String destinationName,
|
||||
Boolean holdFlag, String currentHoldReason,
|
||||
Integer totalPackageCount, BigDecimal actualGrossWeightKg, BigDecimal actualNetWeightKg,
|
||||
BigDecimal actualVolumeCbm, BigDecimal volumeWeightKg,
|
||||
BigDecimal chargeableWeightKg, BigDecimal chargeableVolumeCbm, Integer palletCount,
|
||||
String locationCodes,
|
||||
LocalDateTime measureCompletedAt, LocalDateTime inboundedAt,
|
||||
String expressCompanyCode, String expressCompanyName, String expressTrackingNo,
|
||||
String loadingStatus, String containerNo, String cabinetNo,
|
||||
String vesselName, String voyageNo, LocalDateTime sailingAt, LocalDateTime portArrivalAt,
|
||||
LocalDateTime createdAt, LocalDateTime updatedAt
|
||||
) {
|
||||
public static InboundOrderSummary from(
|
||||
InboundOrderEntity order, InboundTransportSnapshotEntity transport, String locationCodes) {
|
||||
return new InboundOrderSummary(
|
||||
order.getId(), order.getOrderId(), order.getOrderNo(), order.getInboundCode(),
|
||||
order.getProcessStatus(), order.getOrderStatusSnapshot(), order.getMeasureReviewStatus(),
|
||||
order.getFeeStatus(), order.getFeeReviewStatus(), order.getNotificationStatus(),
|
||||
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.getCustomsInspection(), order.getDestinationType(), order.getDestinationWarehouseId(),
|
||||
order.getDestinationCode(), order.getDestinationName(), order.getHoldFlag(),
|
||||
order.getCurrentHoldReason(), order.getTotalPackageCount(), order.getActualGrossWeightKg(),
|
||||
order.getActualNetWeightKg(), order.getActualVolumeCbm(), order.getVolumeWeightKg(),
|
||||
order.getChargeableWeightKg(), order.getChargeableVolumeCbm(), order.getPalletCount(),
|
||||
locationCodes,
|
||||
order.getMeasureCompletedAt(), order.getInboundedAt(), order.getExpressCompanyCode(),
|
||||
order.getExpressCompanyName(), order.getExpressTrackingNo(),
|
||||
transport == null ? null : transport.getLoadingStatus(),
|
||||
transport == null ? null : transport.getContainerNo(),
|
||||
transport == null ? null : transport.getCabinetNo(),
|
||||
transport == null ? null : transport.getVesselName(),
|
||||
transport == null ? null : transport.getVoyageNo(),
|
||||
transport == null ? null : transport.getSailingAt(),
|
||||
transport == null ? null : transport.getPortArrivalAt(),
|
||||
order.getCreatedAt(), order.getUpdatedAt()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.baiyue.firstleg.dto;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 测量领域统一写命令。后台 DTO 与第三方开放 API DTO 都应先适配为该命令。
|
||||
* 该命令的持久化过程不改变入库单流程状态。
|
||||
*/
|
||||
public record MeasurementBatchCommand(
|
||||
long tenantId,
|
||||
long inboundId,
|
||||
Long measurementId,
|
||||
int expectedVersion,
|
||||
String sourceType,
|
||||
String sourceCode,
|
||||
String externalEventNo,
|
||||
String deviceCode,
|
||||
String warehouseCode,
|
||||
LocalDateTime measuredAt,
|
||||
String rawPayloadJson,
|
||||
String originalDocumentNo,
|
||||
BigDecimal originalGrossWeightKg,
|
||||
BigDecimal originalVolumeCbm,
|
||||
String actualGoodsName,
|
||||
long operatorId,
|
||||
List<Item> items
|
||||
) {
|
||||
public record Item(
|
||||
String itemNo,
|
||||
String boxSpecification,
|
||||
int packageCount,
|
||||
BigDecimal lengthCm,
|
||||
BigDecimal widthCm,
|
||||
BigDecimal heightCm,
|
||||
BigDecimal unitWeightKg,
|
||||
Long intelligentImageFileId,
|
||||
String overLimitWarning,
|
||||
BigDecimal outerLengthCm,
|
||||
BigDecimal outerWidthCm,
|
||||
BigDecimal outerHeightCm,
|
||||
String remark
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.baiyue.firstleg.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
import jakarta.validation.constraints.PositiveOrZero;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
/** 测量审核请求,expectedVersion 用于拒绝过期页面提交。 */
|
||||
public record MeasurementReviewRequest(
|
||||
@NotNull @Positive Long measurementId,
|
||||
@NotNull @PositiveOrZero Integer expectedVersion,
|
||||
@Size(max = 1000) String reason
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.baiyue.firstleg.dto;
|
||||
|
||||
import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse;
|
||||
|
||||
public record MerchantInboundBillingResponse(InboundOrderDetail inbound,
|
||||
FeeSnapshotResponse latestFee) {
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.baiyue.firstleg.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.DecimalMax;
|
||||
import jakarta.validation.constraints.DecimalMin;
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/** 第三方长宽高测量开放接口 DTO。 */
|
||||
public final class OpenApiMeasurementDtos {
|
||||
private OpenApiMeasurementDtos() {
|
||||
}
|
||||
|
||||
@Schema(name = "InboundMeasurementCallbackRequest", description = "按入仓码提交的第三方长宽高测量结果")
|
||||
public record CallbackRequest(
|
||||
@NotBlank @Size(max = 128)
|
||||
@Pattern(regexp = "[A-Za-z0-9._:-]+")
|
||||
@Schema(description = "第三方业务事件号,同一 appKey 下唯一", example = "M202608010001")
|
||||
String eventNo,
|
||||
@NotBlank @Size(max = 50)
|
||||
@Schema(description = "已绑定到应用和租户的仓库编码", example = "CN-YW-01")
|
||||
String warehouseCode,
|
||||
@NotBlank @Size(max = 40)
|
||||
@Schema(description = "入仓码,不接受内部数据库主键", example = "RK202608010001")
|
||||
String inboundCode,
|
||||
@NotBlank @Size(max = 64)
|
||||
@Schema(description = "测量设备编码", example = "DEVICE-01")
|
||||
String deviceCode,
|
||||
@NotNull
|
||||
@Schema(description = "设备测量时间,必须包含时区", example = "2026-08-01T10:30:00+08:00")
|
||||
OffsetDateTime measuredAt,
|
||||
@NotEmpty @Size(max = 500) List<@Valid Item> items) {
|
||||
}
|
||||
|
||||
@Schema(name = "InboundMeasurementItem", description = "相同箱规的测量明细")
|
||||
public record Item(
|
||||
@NotBlank @Size(max = 32)
|
||||
@Pattern(regexp = "[A-Za-z0-9._:-]+") String itemNo,
|
||||
@NotNull @DecimalMin(value = "0", inclusive = false) @DecimalMax("999999999.999") BigDecimal lengthCm,
|
||||
@NotNull @DecimalMin(value = "0", inclusive = false) @DecimalMax("999999999.999") BigDecimal widthCm,
|
||||
@NotNull @DecimalMin(value = "0", inclusive = false) @DecimalMax("999999999.999") BigDecimal heightCm,
|
||||
@NotNull @Min(1) @Max(1000000) Integer quantity,
|
||||
@Size(max = 2048) String sourceImageUrl,
|
||||
@NotBlank @Size(max = 64) String boxSpecification) {
|
||||
}
|
||||
|
||||
@Schema(name = "InboundMeasurementEventResponse", description = "第三方测量事件处理结果")
|
||||
public record EventResponse(
|
||||
String eventNo,
|
||||
String inboundCode,
|
||||
boolean accepted,
|
||||
String status,
|
||||
OffsetDateTime receivedAt) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.baiyue.firstleg.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.baiyue.common.core.model.BaseEntity;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** 船司 FBA 目的仓价格的不可变版本。 */
|
||||
@Getter
|
||||
@Setter
|
||||
@TableName("fl_carrier_fba_rate")
|
||||
public class CarrierFbaRateEntity extends BaseEntity {
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
private Long tenantId;
|
||||
private String rateCode;
|
||||
private Integer rateVersion;
|
||||
private Long originWarehouseId;
|
||||
private String originWarehouseName;
|
||||
private String businessType;
|
||||
private String channelCode;
|
||||
private Long carrierId;
|
||||
private String carrierName;
|
||||
private String destinationCode;
|
||||
private String chargeUnit;
|
||||
private BigDecimal unitPrice;
|
||||
private String currency;
|
||||
private BigDecimal additionalFee;
|
||||
private LocalDateTime effectiveFrom;
|
||||
private LocalDateTime effectiveTo;
|
||||
private String status;
|
||||
private String remark;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.baiyue.firstleg.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.baiyue.common.core.model.BaseEntity;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** 客户 FBA 计费规则的不可变版本。 */
|
||||
@Getter
|
||||
@Setter
|
||||
@TableName("fl_customer_charge_rule")
|
||||
public class CustomerChargeRuleEntity extends BaseEntity {
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
private Long tenantId;
|
||||
private String ruleCode;
|
||||
private Integer ruleVersion;
|
||||
private Long customerId;
|
||||
private String customerName;
|
||||
private String businessType;
|
||||
private String chargeMode;
|
||||
private String taxMode;
|
||||
private BigDecimal conversionFactor;
|
||||
private Integer roundingScale;
|
||||
private String roundingMode;
|
||||
private String currency;
|
||||
private LocalDateTime effectiveFrom;
|
||||
private LocalDateTime effectiveTo;
|
||||
private String status;
|
||||
private String remark;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.baiyue.firstleg.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** 已保存全部计算输入的入库费用快照。 */
|
||||
@Getter
|
||||
@Setter
|
||||
@TableName("fl_inbound_fee")
|
||||
public class InboundFeeEntity {
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
private Long tenantId;
|
||||
private Long inboundId;
|
||||
private String feeNo;
|
||||
private Integer feeVersion;
|
||||
private Long chargeRuleId;
|
||||
private Integer chargeRuleVersion;
|
||||
private Long carrierRateId;
|
||||
private Integer carrierRateVersion;
|
||||
private String ruleCode;
|
||||
private Long customerId;
|
||||
private String customerName;
|
||||
private String businessType;
|
||||
private String taxMode;
|
||||
private Integer roundingScale;
|
||||
private String roundingMode;
|
||||
private LocalDateTime ruleEffectiveFrom;
|
||||
private LocalDateTime ruleEffectiveTo;
|
||||
private String rateCode;
|
||||
private Long originWarehouseId;
|
||||
private String originWarehouseName;
|
||||
private String channelCode;
|
||||
private Long carrierId;
|
||||
private String carrierName;
|
||||
private String destinationCode;
|
||||
private LocalDateTime rateEffectiveFrom;
|
||||
private LocalDateTime rateEffectiveTo;
|
||||
private String chargeMode;
|
||||
private BigDecimal conversionFactor;
|
||||
private BigDecimal actualWeightKg;
|
||||
private BigDecimal actualVolumeCbm;
|
||||
private BigDecimal convertedQuantity;
|
||||
private BigDecimal chargeableQuantity;
|
||||
private String chargeUnit;
|
||||
private String currency;
|
||||
private BigDecimal subtotalAmount;
|
||||
private BigDecimal additionalAmount;
|
||||
private BigDecimal totalAmount;
|
||||
private String feeStatus;
|
||||
private String reviewStatus;
|
||||
private Boolean exceptionRelease;
|
||||
private Long calculatedBy;
|
||||
private LocalDateTime calculatedAt;
|
||||
private Long confirmedBy;
|
||||
private LocalDateTime confirmedAt;
|
||||
private Long sentBy;
|
||||
private LocalDateTime sentAt;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.baiyue.firstleg.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** 费用快照明细。 */
|
||||
@Getter
|
||||
@Setter
|
||||
@TableName("fl_inbound_fee_item")
|
||||
public class InboundFeeItemEntity {
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
private Long tenantId;
|
||||
private Long feeId;
|
||||
private Integer itemNo;
|
||||
private String feeCode;
|
||||
private String feeName;
|
||||
private BigDecimal quantity;
|
||||
private String unit;
|
||||
private BigDecimal unitPrice;
|
||||
private BigDecimal amount;
|
||||
private String currency;
|
||||
private Boolean automaticFlag;
|
||||
private String remark;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.baiyue.firstleg.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableLogic;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** 入库单与文件中心对象的分类关系。 */
|
||||
@Getter
|
||||
@Setter
|
||||
@TableName("fl_inbound_file")
|
||||
public class InboundFileEntity {
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
private Long tenantId;
|
||||
private Long inboundId;
|
||||
private Long fileId;
|
||||
private String fileType;
|
||||
private String fileNameSnapshot;
|
||||
private String visibility;
|
||||
private Integer fileVersion;
|
||||
private Long uploadedBy;
|
||||
private LocalDateTime uploadedAt;
|
||||
@TableLogic
|
||||
private Boolean deleted;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.baiyue.firstleg.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** 入库写操作幂等记录。 */
|
||||
@Getter
|
||||
@Setter
|
||||
@TableName("fl_inbound_idempotency")
|
||||
public class InboundIdempotencyEntity {
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
private Long tenantId;
|
||||
private String operation;
|
||||
private Long operatorId;
|
||||
private Long inboundId;
|
||||
private String idempotencyKey;
|
||||
private String requestHash;
|
||||
private String status;
|
||||
private String resultJson;
|
||||
private LocalDateTime completedAt;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.baiyue.firstleg.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** 客服内部备注历史,仅平台详情可见。 */
|
||||
@Getter
|
||||
@Setter
|
||||
@TableName("fl_inbound_internal_note")
|
||||
public class InboundInternalNoteEntity {
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
private Long tenantId;
|
||||
private Long inboundId;
|
||||
private String content;
|
||||
private Long createdBy;
|
||||
private String createdByName;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
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.baomidou.mybatisplus.annotation.Version;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** 后台和第三方共同使用的测量批次。 */
|
||||
@Getter
|
||||
@Setter
|
||||
@TableName("fl_inbound_measurement")
|
||||
public class InboundMeasurementEntity {
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
private Long tenantId;
|
||||
private Long inboundId;
|
||||
private String sourceType;
|
||||
private String sourceCode;
|
||||
private String externalEventNo;
|
||||
private String deviceCode;
|
||||
private String warehouseCode;
|
||||
private LocalDateTime measuredAt;
|
||||
private String status;
|
||||
private String rawPayloadJson;
|
||||
private String originalDocumentNo;
|
||||
private BigDecimal originalGrossWeightKg;
|
||||
private BigDecimal originalVolumeCbm;
|
||||
private String actualGoodsName;
|
||||
private Integer totalPackageCount;
|
||||
private BigDecimal totalVolumeRawCbm;
|
||||
private BigDecimal totalVolumeCbm;
|
||||
private BigDecimal volumeWeightKg;
|
||||
private Long createdBy;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
@Version
|
||||
private Integer version;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.baiyue.firstleg.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** 测量批次箱规明细,计算字段由服务端生成。 */
|
||||
@Getter
|
||||
@Setter
|
||||
@TableName("fl_inbound_measurement_item")
|
||||
public class InboundMeasurementItemEntity {
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
private Long tenantId;
|
||||
private Long measurementId;
|
||||
private String itemNo;
|
||||
private String boxSpecification;
|
||||
private Integer packageCount;
|
||||
private BigDecimal lengthCm;
|
||||
private BigDecimal widthCm;
|
||||
private BigDecimal heightCm;
|
||||
private BigDecimal girthCm;
|
||||
private BigDecimal unitWeightKg;
|
||||
private BigDecimal unitVolumeCbm;
|
||||
private BigDecimal volumeRawCbm;
|
||||
private BigDecimal volumeCbm;
|
||||
private BigDecimal totalWeightKg;
|
||||
private BigDecimal totalVolumeCbm;
|
||||
private BigDecimal volumeWeightKg;
|
||||
private String calculationRuleVersion;
|
||||
private Long sourceImageFileId;
|
||||
private String overLimitWarning;
|
||||
private BigDecimal outerLengthCm;
|
||||
private BigDecimal outerWidthCm;
|
||||
private BigDecimal outerHeightCm;
|
||||
private String remark;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.baiyue.firstleg.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.baiyue.common.core.model.BaseEntity;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** 二期入库单聚合根及列表查询快照。 */
|
||||
@Getter
|
||||
@Setter
|
||||
@TableName("fl_inbound_order")
|
||||
public class InboundOrderEntity extends BaseEntity {
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
private Long tenantId;
|
||||
private Long taskId;
|
||||
private Long orderId;
|
||||
private String orderNo;
|
||||
private String inboundCode;
|
||||
private String processStatus;
|
||||
private String orderStatusSnapshot;
|
||||
private String measureReviewStatus;
|
||||
private String feeStatus;
|
||||
private String feeReviewStatus;
|
||||
private String notificationStatus;
|
||||
private LocalDateTime expectedArrivalAt;
|
||||
private Long customerId;
|
||||
private String customerName;
|
||||
private Long companyId;
|
||||
private String companyName;
|
||||
private Long warehouseId;
|
||||
private String warehouseName;
|
||||
private Long deliveryWarehouseId;
|
||||
private String deliveryWarehouseName;
|
||||
private String businessType;
|
||||
private String channelCode;
|
||||
private String channelName;
|
||||
private String taxMode;
|
||||
private String containerMode;
|
||||
private Boolean customsInspection;
|
||||
private String destinationType;
|
||||
private Long destinationWarehouseId;
|
||||
private String destinationCode;
|
||||
private String destinationName;
|
||||
private Boolean holdFlag;
|
||||
private String currentHoldReason;
|
||||
private Integer totalPackageCount;
|
||||
private BigDecimal actualGrossWeightKg;
|
||||
private BigDecimal chargeableWeightKg;
|
||||
private BigDecimal actualVolumeCbm;
|
||||
private BigDecimal chargeableVolumeCbm;
|
||||
private Integer palletCount;
|
||||
private BigDecimal volumeWeightKg;
|
||||
private BigDecimal actualNetWeightKg;
|
||||
private LocalDateTime measureCompletedAt;
|
||||
private LocalDateTime inboundedAt;
|
||||
private String expressCompanyCode;
|
||||
private String expressCompanyName;
|
||||
private String expressTrackingNo;
|
||||
private String publicRemark;
|
||||
private String createdByName;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.baiyue.firstleg.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
/** 开放接口只读使用的入库单最小投影,禁止通过该实体更新主单。 */
|
||||
@Getter
|
||||
@Setter
|
||||
@TableName("fl_inbound_order")
|
||||
public class InboundOrderLookupEntity {
|
||||
@TableId
|
||||
private Long id;
|
||||
private Long tenantId;
|
||||
private String inboundCode;
|
||||
private Long warehouseId;
|
||||
private String processStatus;
|
||||
private Integer deleted;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.baiyue.firstleg.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** 入库完成事件 Outbox 记录。 */
|
||||
@Getter
|
||||
@Setter
|
||||
@TableName("fl_inbound_outbox")
|
||||
public class InboundOutboxEntity {
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
private Long tenantId;
|
||||
private String aggregateType;
|
||||
private Long aggregateId;
|
||||
private String eventType;
|
||||
private String payloadJson;
|
||||
private String publishStatus;
|
||||
private Integer retryCount;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.baiyue.firstleg.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** 头程侧托盘和库位分配结果快照。 */
|
||||
@Getter
|
||||
@Setter
|
||||
@TableName("fl_inbound_pallet_allocation")
|
||||
public class InboundPalletAllocationEntity {
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
private Long tenantId;
|
||||
private Long inboundId;
|
||||
private Integer allocationNo;
|
||||
private Long warehouseId;
|
||||
private Long palletId;
|
||||
private String palletCode;
|
||||
private BigDecimal palletCapacityCbm;
|
||||
private BigDecimal allocatedVolumeCbm;
|
||||
private Integer allocatedPackageCount;
|
||||
private Long locationId;
|
||||
private String locationCode;
|
||||
private String reservationNo;
|
||||
private String allocationStatus;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.baiyue.firstleg.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.baiyue.common.core.model.BaseEntity;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/** 计费所需的入库单聚合根字段。 */
|
||||
@Getter
|
||||
@Setter
|
||||
@TableName("fl_inbound_order")
|
||||
public class InboundPricingEntity extends BaseEntity {
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
private Long tenantId;
|
||||
private String inboundCode;
|
||||
private String processStatus;
|
||||
private String measureReviewStatus;
|
||||
private String feeStatus;
|
||||
private Long customerId;
|
||||
private String customerName;
|
||||
private Long warehouseId;
|
||||
private String warehouseName;
|
||||
private String businessType;
|
||||
private String channelCode;
|
||||
private Long carrierId;
|
||||
private String carrierName;
|
||||
private String taxMode;
|
||||
private String destinationType;
|
||||
private String destinationCode;
|
||||
private BigDecimal actualGrossWeightKg;
|
||||
private BigDecimal actualVolumeCbm;
|
||||
private BigDecimal chargeableWeightKg;
|
||||
private BigDecimal chargeableVolumeCbm;
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package com.baiyue.firstleg.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** 费用试算、确认和发送操作的持久化幂等记录。 */
|
||||
@Getter
|
||||
@Setter
|
||||
@TableName("fl_inbound_idempotency")
|
||||
public class InboundPricingIdempotencyEntity {
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
private Long tenantId;
|
||||
private String operation;
|
||||
private Long operatorId;
|
||||
private Long inboundId;
|
||||
private String idempotencyKey;
|
||||
private String requestHash;
|
||||
private String status;
|
||||
private String resultJson;
|
||||
private Long resultResourceId;
|
||||
private LocalDateTime completedAt;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.baiyue.firstleg.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** 入库流程状态不可变历史。 */
|
||||
@Getter
|
||||
@Setter
|
||||
@TableName("fl_inbound_status_history")
|
||||
public class InboundStatusHistoryEntity {
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
private Long tenantId;
|
||||
private Long inboundId;
|
||||
private String fromStatus;
|
||||
private String toStatus;
|
||||
private String action;
|
||||
private String reason;
|
||||
private Long operatorId;
|
||||
private String operatorName;
|
||||
private LocalDateTime occurredAt;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.baiyue.firstleg.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** 关联运输信息只读快照。 */
|
||||
@Getter
|
||||
@Setter
|
||||
@TableName("fl_inbound_transport_snapshot")
|
||||
public class InboundTransportSnapshotEntity {
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
private Long tenantId;
|
||||
private Long inboundId;
|
||||
private String loadingStatus;
|
||||
private String containerNo;
|
||||
private String cabinetNo;
|
||||
private String vesselName;
|
||||
private String voyageNo;
|
||||
private LocalDateTime sailingAt;
|
||||
private LocalDateTime portArrivalAt;
|
||||
private LocalDateTime sourceUpdatedAt;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.baiyue.firstleg.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.baiyue.common.core.model.BaseEntity;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** 称重图片 OCR 原值、人工修正值和最终确认重量。 */
|
||||
@Getter
|
||||
@Setter
|
||||
@TableName("fl_inbound_weight_result")
|
||||
public class InboundWeightResultEntity extends BaseEntity {
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
private Long tenantId;
|
||||
private Long inboundId;
|
||||
private Long fileId;
|
||||
private String ocrProvider;
|
||||
private String ocrRawText;
|
||||
private BigDecimal ocrWeightValue;
|
||||
private String ocrWeightUnit;
|
||||
private BigDecimal confidence;
|
||||
private BigDecimal adjustedWeightValue;
|
||||
private String adjustedWeightUnit;
|
||||
private BigDecimal finalWeightKg;
|
||||
private String recognitionStatus;
|
||||
private String adjustmentReason;
|
||||
private Long adjustedBy;
|
||||
private LocalDateTime adjustedAt;
|
||||
private Long confirmedBy;
|
||||
private LocalDateTime confirmedAt;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.baiyue.firstleg.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** OCR 重量结果每次人工修正和确认的不可变版本。 */
|
||||
@Getter
|
||||
@Setter
|
||||
@TableName("fl_inbound_weight_revision")
|
||||
public class InboundWeightRevisionEntity {
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
private Long tenantId;
|
||||
private Long inboundId;
|
||||
private Long weightResultId;
|
||||
private Integer revisionNo;
|
||||
private String action;
|
||||
private BigDecimal beforeWeightValue;
|
||||
private String beforeWeightUnit;
|
||||
private BigDecimal afterWeightValue;
|
||||
private String afterWeightUnit;
|
||||
private BigDecimal finalWeightKg;
|
||||
private String reason;
|
||||
private Long operatorId;
|
||||
private String operatorName;
|
||||
private LocalDateTime operatedAt;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.baiyue.firstleg.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** 测量审核、驳回和异常放行不可变历史。 */
|
||||
@Getter
|
||||
@Setter
|
||||
@TableName("fl_measure_review_record")
|
||||
public class MeasureReviewRecordEntity {
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
private Long tenantId;
|
||||
private Long inboundId;
|
||||
private Long measurementId;
|
||||
private String reviewResult;
|
||||
private String reviewReason;
|
||||
private Boolean exceptionRelease;
|
||||
private String releaseReason;
|
||||
private Boolean allowRejectedConfig;
|
||||
private Long reviewerId;
|
||||
private String reviewerName;
|
||||
private LocalDateTime reviewedAt;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.baiyue.firstleg.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** 第三方测量 API 每次调用的原始报文和处理结果留档。 */
|
||||
@Getter
|
||||
@Setter
|
||||
@TableName("fl_third_party_call_log")
|
||||
public class ThirdPartyCallLogEntity {
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
private Long tenantId;
|
||||
private String appKey;
|
||||
private String signatureSubject;
|
||||
private String requestMethod;
|
||||
private String requestPath;
|
||||
private String requestTimestamp;
|
||||
private String nonce;
|
||||
private String idempotencyKey;
|
||||
private Integer idempotencyAnchor;
|
||||
private String signatureValue;
|
||||
private String requestHash;
|
||||
private String rawPayload;
|
||||
private String processingStatus;
|
||||
private String resultCode;
|
||||
private String resultMessage;
|
||||
private String resultJson;
|
||||
private LocalDateTime requestedAt;
|
||||
private LocalDateTime processedAt;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package com.baiyue.firstleg.mapper;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.firstleg.entity.CarrierFbaRateEntity;
|
||||
public interface CarrierFbaRateMapper extends BaseMapper<CarrierFbaRateEntity> {}
|
||||
@@ -0,0 +1,4 @@
|
||||
package com.baiyue.firstleg.mapper;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.firstleg.entity.CustomerChargeRuleEntity;
|
||||
public interface CustomerChargeRuleMapper extends BaseMapper<CustomerChargeRuleEntity> {}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.baiyue.firstleg.mapper;
|
||||
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
/** 只检查任务5已准备好的托盘/库位引用,不在头程服务实现仓库 CRUD。 */
|
||||
@Mapper
|
||||
public interface InboundAllocationQueryMapper {
|
||||
@Select("""
|
||||
SELECT COUNT(*) FROM fl_inbound_pallet_allocation
|
||||
WHERE tenant_id = #{tenantId} AND inbound_id = #{inboundId}
|
||||
AND allocation_status IN ('RESERVED', 'CONFIRMED')
|
||||
""")
|
||||
long countPrepared(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package com.baiyue.firstleg.mapper;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.firstleg.entity.InboundFeeItemEntity;
|
||||
public interface InboundFeeItemMapper extends BaseMapper<InboundFeeItemEntity> {}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.baiyue.firstleg.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.firstleg.entity.InboundFeeEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
|
||||
@Mapper
|
||||
public interface InboundFeeMapper extends BaseMapper<InboundFeeEntity> {
|
||||
@Update("""
|
||||
UPDATE fl_inbound_fee
|
||||
SET fee_status = 'SENT', sent_by = #{operatorId}, sent_at = CURRENT_TIMESTAMP(3)
|
||||
WHERE tenant_id = #{tenantId} AND inbound_id = #{inboundId} AND id = #{feeId}
|
||||
AND fee_status = 'CONFIRMED'
|
||||
""")
|
||||
int markSent(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
|
||||
@Param("feeId") long feeId, @Param("operatorId") long operatorId);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.baiyue.firstleg.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.firstleg.entity.InboundFileEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface InboundFileMapper extends BaseMapper<InboundFileEntity> {
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.baiyue.firstleg.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.firstleg.entity.InboundIdempotencyEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
|
||||
@Mapper
|
||||
public interface InboundIdempotencyMapper extends BaseMapper<InboundIdempotencyEntity> {
|
||||
@Update("""
|
||||
UPDATE fl_inbound_idempotency
|
||||
SET status = 'SUCCESS', result_json = #{resultJson}, completed_at = CURRENT_TIMESTAMP(3)
|
||||
WHERE id = #{id} AND status = 'PROCESSING'
|
||||
""")
|
||||
int complete(@Param("id") long id, @Param("resultJson") String resultJson);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.baiyue.firstleg.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.firstleg.entity.InboundInternalNoteEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface InboundInternalNoteMapper extends BaseMapper<InboundInternalNoteEntity> {
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.baiyue.firstleg.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.firstleg.entity.InboundMeasurementItemEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface InboundMeasurementItemMapper extends BaseMapper<InboundMeasurementItemEntity> {
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.baiyue.firstleg.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.firstleg.entity.InboundMeasurementEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
|
||||
/** 测量批次 Mapper。 */
|
||||
@Mapper
|
||||
public interface InboundMeasurementMapper extends BaseMapper<InboundMeasurementEntity> {
|
||||
@Update("""
|
||||
UPDATE fl_inbound_measurement SET status = #{toStatus}, updated_at = CURRENT_TIMESTAMP(3)
|
||||
WHERE tenant_id = #{tenantId} AND inbound_id = #{inboundId} AND id = #{measurementId}
|
||||
AND status = 'SUBMITTED'
|
||||
""")
|
||||
int reviewSubmitted(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
|
||||
@Param("measurementId") long measurementId, @Param("toStatus") String toStatus);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.baiyue.firstleg.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.firstleg.entity.InboundOrderLookupEntity;
|
||||
|
||||
/** 入库单只读 Mapper,开放接口不得在此增加 update 方法。 */
|
||||
public interface InboundOrderLookupMapper extends BaseMapper<InboundOrderLookupEntity> {
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package com.baiyue.firstleg.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.firstleg.entity.InboundOrderEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
|
||||
/** 入库聚合根条件更新 Mapper,所有推进均同时校验租户、前置状态和版本。 */
|
||||
@Mapper
|
||||
public interface InboundOrderMapper extends BaseMapper<InboundOrderEntity> {
|
||||
@Update("""
|
||||
UPDATE fl_inbound_order
|
||||
SET process_status = 'WAIT_PRICING', measure_review_status = 'APPROVED',
|
||||
updated_by = #{operatorId}, version = version + 1
|
||||
WHERE tenant_id = #{tenantId} AND id = #{inboundId} AND deleted = 0
|
||||
AND process_status = 'MEASURE_PENDING' AND measure_review_status = 'PENDING'
|
||||
AND version = #{expectedVersion}
|
||||
""")
|
||||
int approveMeasurement(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
|
||||
@Param("operatorId") long operatorId,
|
||||
@Param("expectedVersion") int expectedVersion);
|
||||
|
||||
@Update("""
|
||||
UPDATE fl_inbound_order
|
||||
SET process_status = 'MEASURE_REJECTED', measure_review_status = 'REJECTED',
|
||||
updated_by = #{operatorId}, version = version + 1
|
||||
WHERE tenant_id = #{tenantId} AND id = #{inboundId} AND deleted = 0
|
||||
AND process_status = 'MEASURE_PENDING' AND measure_review_status = 'PENDING'
|
||||
AND version = #{expectedVersion}
|
||||
""")
|
||||
int rejectMeasurement(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
|
||||
@Param("operatorId") long operatorId,
|
||||
@Param("expectedVersion") int expectedVersion);
|
||||
|
||||
@Update("""
|
||||
UPDATE fl_inbound_order
|
||||
SET process_status = 'WAIT_PRICING', updated_by = #{operatorId}, version = version + 1
|
||||
WHERE tenant_id = #{tenantId} AND id = #{inboundId} AND deleted = 0
|
||||
AND process_status = 'MEASURE_REJECTED' AND measure_review_status = 'REJECTED'
|
||||
AND version = #{expectedVersion}
|
||||
""")
|
||||
int releaseRejectedMeasurement(@Param("tenantId") long tenantId,
|
||||
@Param("inboundId") long inboundId,
|
||||
@Param("operatorId") long operatorId,
|
||||
@Param("expectedVersion") int expectedVersion);
|
||||
|
||||
@Update("""
|
||||
UPDATE fl_inbound_order
|
||||
SET process_status = 'INBOUNDING', updated_by = #{operatorId}, version = version + 1
|
||||
WHERE tenant_id = #{tenantId} AND id = #{inboundId} AND deleted = 0
|
||||
AND process_status = 'FEE_CONFIRMING' AND fee_status = 'CONFIRMED'
|
||||
AND version = #{expectedVersion}
|
||||
""")
|
||||
int beginInbound(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
|
||||
@Param("operatorId") long operatorId,
|
||||
@Param("expectedVersion") int expectedVersion);
|
||||
|
||||
@Update("""
|
||||
UPDATE fl_inbound_order
|
||||
SET process_status = 'INBOUNDED', fee_status = 'SENT', notification_status = 'PENDING',
|
||||
inbounded_at = CURRENT_TIMESTAMP(3), updated_by = #{operatorId}, version = version + 1
|
||||
WHERE tenant_id = #{tenantId} AND id = #{inboundId} AND deleted = 0
|
||||
AND process_status = 'INBOUNDING' AND fee_status = 'CONFIRMED'
|
||||
AND version = #{expectedVersion}
|
||||
""")
|
||||
int completeInbound(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
|
||||
@Param("operatorId") long operatorId,
|
||||
@Param("expectedVersion") int expectedVersion);
|
||||
|
||||
@Update("""
|
||||
UPDATE fl_inbound_order
|
||||
SET process_status = 'FEE_CONFIRMING', updated_by = #{operatorId}, version = version + 1
|
||||
WHERE tenant_id = #{tenantId} AND id = #{inboundId} AND deleted = 0
|
||||
AND process_status = 'INBOUNDING' AND version = #{expectedVersion}
|
||||
""")
|
||||
int rollbackInbound(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
|
||||
@Param("operatorId") long operatorId,
|
||||
@Param("expectedVersion") int expectedVersion);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.baiyue.firstleg.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.firstleg.entity.InboundOutboxEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface InboundOutboxMapper extends BaseMapper<InboundOutboxEntity> {
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.baiyue.firstleg.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.firstleg.entity.InboundPalletAllocationEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface InboundPalletAllocationMapper extends BaseMapper<InboundPalletAllocationEntity> {
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package com.baiyue.firstleg.mapper;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.firstleg.entity.InboundPricingIdempotencyEntity;
|
||||
public interface InboundPricingIdempotencyMapper extends BaseMapper<InboundPricingIdempotencyEntity> {}
|
||||
@@ -0,0 +1,4 @@
|
||||
package com.baiyue.firstleg.mapper;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.firstleg.entity.InboundPricingEntity;
|
||||
public interface InboundPricingMapper extends BaseMapper<InboundPricingEntity> {}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.baiyue.firstleg.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.firstleg.entity.InboundStatusHistoryEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface InboundStatusHistoryMapper extends BaseMapper<InboundStatusHistoryEntity> {
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.baiyue.firstleg.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.firstleg.entity.InboundTransportSnapshotEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface InboundTransportSnapshotMapper extends BaseMapper<InboundTransportSnapshotEntity> {
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.baiyue.firstleg.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.firstleg.entity.InboundWeightResultEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface InboundWeightResultMapper extends BaseMapper<InboundWeightResultEntity> {
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.baiyue.firstleg.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.firstleg.entity.InboundWeightRevisionEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface InboundWeightRevisionMapper extends BaseMapper<InboundWeightRevisionEntity> {
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.baiyue.firstleg.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.firstleg.entity.MeasureReviewRecordEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface MeasureReviewRecordMapper extends BaseMapper<MeasureReviewRecordEntity> {
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.baiyue.firstleg.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.firstleg.entity.ThirdPartyCallLogEntity;
|
||||
|
||||
public interface ThirdPartyCallLogMapper extends BaseMapper<ThirdPartyCallLogEntity> {
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
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.CustomerChargeRuleEntity;
|
||||
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.mapper.CarrierFbaRateMapper;
|
||||
import com.baiyue.firstleg.mapper.CustomerChargeRuleMapper;
|
||||
import com.baiyue.firstleg.mapper.InboundFeeItemMapper;
|
||||
import com.baiyue.firstleg.mapper.InboundFeeMapper;
|
||||
import com.baiyue.firstleg.mapper.InboundOutboxMapper;
|
||||
import com.baiyue.firstleg.mapper.InboundPricingIdempotencyMapper;
|
||||
import com.baiyue.firstleg.mapper.InboundPricingMapper;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/** FBA 计费仓储,所有业务查询都强制带租户条件。 */
|
||||
@Repository
|
||||
public class FbaPricingRepository {
|
||||
private final CustomerChargeRuleMapper rules;
|
||||
private final CarrierFbaRateMapper rates;
|
||||
private final InboundPricingMapper inbounds;
|
||||
private final InboundFeeMapper fees;
|
||||
private final InboundFeeItemMapper items;
|
||||
private final InboundPricingIdempotencyMapper idempotencies;
|
||||
private final InboundOutboxMapper outbox;
|
||||
|
||||
public FbaPricingRepository(CustomerChargeRuleMapper rules, CarrierFbaRateMapper rates,
|
||||
InboundPricingMapper inbounds, InboundFeeMapper fees,
|
||||
InboundFeeItemMapper items,
|
||||
InboundPricingIdempotencyMapper idempotencies,
|
||||
InboundOutboxMapper outbox) {
|
||||
this.rules = rules;
|
||||
this.rates = rates;
|
||||
this.inbounds = inbounds;
|
||||
this.fees = fees;
|
||||
this.items = items;
|
||||
this.idempotencies = idempotencies;
|
||||
this.outbox = outbox;
|
||||
}
|
||||
|
||||
public void insertRule(CustomerChargeRuleEntity rule) { rules.insert(rule); }
|
||||
public void insertRate(CarrierFbaRateEntity rate) { rates.insert(rate); }
|
||||
|
||||
public CustomerChargeRuleEntity findRule(long tenantId, long id) {
|
||||
return rules.selectOne(new LambdaQueryWrapper<CustomerChargeRuleEntity>()
|
||||
.eq(CustomerChargeRuleEntity::getTenantId, tenantId)
|
||||
.eq(CustomerChargeRuleEntity::getId, id));
|
||||
}
|
||||
|
||||
public CarrierFbaRateEntity findRate(long tenantId, long id) {
|
||||
return rates.selectOne(new LambdaQueryWrapper<CarrierFbaRateEntity>()
|
||||
.eq(CarrierFbaRateEntity::getTenantId, tenantId)
|
||||
.eq(CarrierFbaRateEntity::getId, id));
|
||||
}
|
||||
|
||||
public List<CustomerChargeRuleEntity> listRules(long tenantId, String ruleCode) {
|
||||
return rules.selectList(new LambdaQueryWrapper<CustomerChargeRuleEntity>()
|
||||
.eq(CustomerChargeRuleEntity::getTenantId, tenantId)
|
||||
.eq(ruleCode != null && !ruleCode.isBlank(), CustomerChargeRuleEntity::getRuleCode, ruleCode)
|
||||
.orderByAsc(CustomerChargeRuleEntity::getRuleCode)
|
||||
.orderByDesc(CustomerChargeRuleEntity::getRuleVersion));
|
||||
}
|
||||
|
||||
public List<CarrierFbaRateEntity> listRates(long tenantId, String rateCode) {
|
||||
return rates.selectList(new LambdaQueryWrapper<CarrierFbaRateEntity>()
|
||||
.eq(CarrierFbaRateEntity::getTenantId, tenantId)
|
||||
.eq(rateCode != null && !rateCode.isBlank(), CarrierFbaRateEntity::getRateCode, rateCode)
|
||||
.orderByAsc(CarrierFbaRateEntity::getRateCode)
|
||||
.orderByDesc(CarrierFbaRateEntity::getRateVersion));
|
||||
}
|
||||
|
||||
public int nextRuleVersion(long tenantId, String ruleCode) {
|
||||
List<CustomerChargeRuleEntity> values = listRules(tenantId, ruleCode);
|
||||
return values.isEmpty() ? 1 : values.get(0).getRuleVersion() + 1;
|
||||
}
|
||||
|
||||
public int nextRateVersion(long tenantId, String rateCode) {
|
||||
List<CarrierFbaRateEntity> values = listRates(tenantId, rateCode);
|
||||
return values.isEmpty() ? 1 : values.get(0).getRateVersion() + 1;
|
||||
}
|
||||
|
||||
public int retireRule(long tenantId, long id, long operatorId) {
|
||||
return rules.update(null, new LambdaUpdateWrapper<CustomerChargeRuleEntity>()
|
||||
.eq(CustomerChargeRuleEntity::getTenantId, tenantId)
|
||||
.eq(CustomerChargeRuleEntity::getId, id)
|
||||
.eq(CustomerChargeRuleEntity::getStatus, "ACTIVE")
|
||||
.set(CustomerChargeRuleEntity::getStatus, "INACTIVE")
|
||||
.set(CustomerChargeRuleEntity::getUpdatedBy, operatorId));
|
||||
}
|
||||
|
||||
public int retireRate(long tenantId, long id, long operatorId) {
|
||||
return rates.update(null, new LambdaUpdateWrapper<CarrierFbaRateEntity>()
|
||||
.eq(CarrierFbaRateEntity::getTenantId, tenantId)
|
||||
.eq(CarrierFbaRateEntity::getId, id)
|
||||
.eq(CarrierFbaRateEntity::getStatus, "ACTIVE")
|
||||
.set(CarrierFbaRateEntity::getStatus, "INACTIVE")
|
||||
.set(CarrierFbaRateEntity::getUpdatedBy, operatorId));
|
||||
}
|
||||
|
||||
public InboundPricingEntity findInbound(long tenantId, long inboundId) {
|
||||
return inbounds.selectOne(new LambdaQueryWrapper<InboundPricingEntity>()
|
||||
.eq(InboundPricingEntity::getTenantId, tenantId)
|
||||
.eq(InboundPricingEntity::getId, inboundId));
|
||||
}
|
||||
|
||||
public List<CustomerChargeRuleEntity> matchRules(InboundPricingEntity inbound, LocalDateTime at) {
|
||||
return rules.selectList(new LambdaQueryWrapper<CustomerChargeRuleEntity>()
|
||||
.eq(CustomerChargeRuleEntity::getTenantId, inbound.getTenantId())
|
||||
.eq(CustomerChargeRuleEntity::getCustomerId, inbound.getCustomerId())
|
||||
.eq(CustomerChargeRuleEntity::getBusinessType, inbound.getBusinessType())
|
||||
.eq(CustomerChargeRuleEntity::getTaxMode, inbound.getTaxMode())
|
||||
.eq(CustomerChargeRuleEntity::getStatus, "ACTIVE")
|
||||
.le(CustomerChargeRuleEntity::getEffectiveFrom, at)
|
||||
.and(w -> w.isNull(CustomerChargeRuleEntity::getEffectiveTo)
|
||||
.or().gt(CustomerChargeRuleEntity::getEffectiveTo, at)));
|
||||
}
|
||||
|
||||
public List<CarrierFbaRateEntity> matchRates(InboundPricingEntity inbound, String chargeUnit,
|
||||
LocalDateTime at) {
|
||||
return rates.selectList(new LambdaQueryWrapper<CarrierFbaRateEntity>()
|
||||
.eq(CarrierFbaRateEntity::getTenantId, inbound.getTenantId())
|
||||
.eq(CarrierFbaRateEntity::getOriginWarehouseId, inbound.getWarehouseId())
|
||||
.eq(CarrierFbaRateEntity::getBusinessType, inbound.getBusinessType())
|
||||
.eq(CarrierFbaRateEntity::getChannelCode, inbound.getChannelCode())
|
||||
.eq(CarrierFbaRateEntity::getCarrierId, inbound.getCarrierId())
|
||||
.eq(CarrierFbaRateEntity::getDestinationCode, inbound.getDestinationCode())
|
||||
.eq(CarrierFbaRateEntity::getChargeUnit, chargeUnit)
|
||||
.eq(CarrierFbaRateEntity::getStatus, "ACTIVE")
|
||||
.le(CarrierFbaRateEntity::getEffectiveFrom, at)
|
||||
.and(w -> w.isNull(CarrierFbaRateEntity::getEffectiveTo)
|
||||
.or().gt(CarrierFbaRateEntity::getEffectiveTo, at)));
|
||||
}
|
||||
|
||||
public int nextFeeVersion(long tenantId, long inboundId) {
|
||||
InboundFeeEntity latest = fees.selectOne(new LambdaQueryWrapper<InboundFeeEntity>()
|
||||
.eq(InboundFeeEntity::getTenantId, tenantId)
|
||||
.eq(InboundFeeEntity::getInboundId, inboundId)
|
||||
.orderByDesc(InboundFeeEntity::getFeeVersion).last("LIMIT 1"));
|
||||
return latest == null ? 1 : latest.getFeeVersion() + 1;
|
||||
}
|
||||
|
||||
public void supersedeCalculated(long tenantId, long inboundId) {
|
||||
fees.update(null, new LambdaUpdateWrapper<InboundFeeEntity>()
|
||||
.eq(InboundFeeEntity::getTenantId, tenantId)
|
||||
.eq(InboundFeeEntity::getInboundId, inboundId)
|
||||
.eq(InboundFeeEntity::getFeeStatus, "CALCULATED")
|
||||
.set(InboundFeeEntity::getFeeStatus, "SUPERSEDED"));
|
||||
}
|
||||
|
||||
public void insertFee(InboundFeeEntity fee) { fees.insert(fee); }
|
||||
public void insertItem(InboundFeeItemEntity item) { items.insert(item); }
|
||||
|
||||
public InboundFeeEntity findFee(long tenantId, long feeId) {
|
||||
return fees.selectOne(new LambdaQueryWrapper<InboundFeeEntity>()
|
||||
.eq(InboundFeeEntity::getTenantId, tenantId).eq(InboundFeeEntity::getId, feeId));
|
||||
}
|
||||
|
||||
public InboundFeeEntity findLatestFee(long tenantId, long inboundId) {
|
||||
return fees.selectOne(new LambdaQueryWrapper<InboundFeeEntity>()
|
||||
.eq(InboundFeeEntity::getTenantId, tenantId)
|
||||
.eq(InboundFeeEntity::getInboundId, inboundId)
|
||||
.orderByDesc(InboundFeeEntity::getFeeVersion).last("LIMIT 1"));
|
||||
}
|
||||
|
||||
public InboundFeeItemEntity findBaseItem(long tenantId, long feeId) {
|
||||
return items.selectOne(new LambdaQueryWrapper<InboundFeeItemEntity>()
|
||||
.eq(InboundFeeItemEntity::getTenantId, tenantId)
|
||||
.eq(InboundFeeItemEntity::getFeeId, feeId)
|
||||
.eq(InboundFeeItemEntity::getFeeCode, "FBA_BASE"));
|
||||
}
|
||||
|
||||
public List<InboundFeeItemEntity> listItems(long tenantId, long feeId) {
|
||||
return items.selectList(new LambdaQueryWrapper<InboundFeeItemEntity>()
|
||||
.eq(InboundFeeItemEntity::getTenantId, tenantId)
|
||||
.eq(InboundFeeItemEntity::getFeeId, feeId)
|
||||
.orderByAsc(InboundFeeItemEntity::getItemNo));
|
||||
}
|
||||
|
||||
public int supersedeSent(long tenantId, long feeId) {
|
||||
return fees.update(null, new LambdaUpdateWrapper<InboundFeeEntity>()
|
||||
.eq(InboundFeeEntity::getTenantId, tenantId)
|
||||
.eq(InboundFeeEntity::getId, feeId)
|
||||
.eq(InboundFeeEntity::getFeeStatus, "SENT")
|
||||
.set(InboundFeeEntity::getFeeStatus, "SUPERSEDED")
|
||||
.set(InboundFeeEntity::getUpdatedAt, LocalDateTime.now()));
|
||||
}
|
||||
|
||||
public int markInboundCalculated(long tenantId, long inboundId, String chargeMode,
|
||||
java.math.BigDecimal chargeable) {
|
||||
LambdaUpdateWrapper<InboundPricingEntity> update = new LambdaUpdateWrapper<InboundPricingEntity>()
|
||||
.eq(InboundPricingEntity::getTenantId, tenantId)
|
||||
.eq(InboundPricingEntity::getId, inboundId)
|
||||
.in(InboundPricingEntity::getProcessStatus, List.of("WAIT_PRICING", "FEE_CONFIRMING"))
|
||||
.in(InboundPricingEntity::getFeeStatus, List.of("UNPRICED", "CALCULATED"))
|
||||
.set(InboundPricingEntity::getProcessStatus, "FEE_CONFIRMING")
|
||||
.set(InboundPricingEntity::getFeeStatus, "CALCULATED");
|
||||
if ("KG".equals(chargeMode)) update.set(InboundPricingEntity::getChargeableWeightKg, chargeable);
|
||||
else update.set(InboundPricingEntity::getChargeableVolumeCbm, chargeable);
|
||||
return inbounds.update(null, update);
|
||||
}
|
||||
|
||||
public int confirmFee(long tenantId, long feeId, long operatorId, LocalDateTime now) {
|
||||
return fees.update(null, new LambdaUpdateWrapper<InboundFeeEntity>()
|
||||
.eq(InboundFeeEntity::getTenantId, tenantId).eq(InboundFeeEntity::getId, feeId)
|
||||
.eq(InboundFeeEntity::getFeeStatus, "CALCULATED")
|
||||
.set(InboundFeeEntity::getFeeStatus, "CONFIRMED")
|
||||
.set(InboundFeeEntity::getConfirmedBy, operatorId)
|
||||
.set(InboundFeeEntity::getConfirmedAt, now));
|
||||
}
|
||||
|
||||
public int markInboundConfirmed(long tenantId, long inboundId) {
|
||||
return inbounds.update(null, new LambdaUpdateWrapper<InboundPricingEntity>()
|
||||
.eq(InboundPricingEntity::getTenantId, tenantId).eq(InboundPricingEntity::getId, inboundId)
|
||||
.eq(InboundPricingEntity::getProcessStatus, "FEE_CONFIRMING")
|
||||
.eq(InboundPricingEntity::getFeeStatus, "CALCULATED")
|
||||
.set(InboundPricingEntity::getFeeStatus, "CONFIRMED"));
|
||||
}
|
||||
|
||||
public int sendFee(long tenantId, long feeId, long operatorId, LocalDateTime now) {
|
||||
return fees.update(null, new LambdaUpdateWrapper<InboundFeeEntity>()
|
||||
.eq(InboundFeeEntity::getTenantId, tenantId).eq(InboundFeeEntity::getId, feeId)
|
||||
.eq(InboundFeeEntity::getFeeStatus, "CONFIRMED")
|
||||
.set(InboundFeeEntity::getFeeStatus, "SENT")
|
||||
.set(InboundFeeEntity::getSentBy, operatorId)
|
||||
.set(InboundFeeEntity::getSentAt, now));
|
||||
}
|
||||
|
||||
public int markInboundWaitingPallet(long tenantId, long inboundId) {
|
||||
return inbounds.update(null, new LambdaUpdateWrapper<InboundPricingEntity>()
|
||||
.eq(InboundPricingEntity::getTenantId, tenantId).eq(InboundPricingEntity::getId, inboundId)
|
||||
.eq(InboundPricingEntity::getProcessStatus, "FEE_CONFIRMING")
|
||||
.eq(InboundPricingEntity::getFeeStatus, "CONFIRMED")
|
||||
.set(InboundPricingEntity::getProcessStatus, "WAIT_PALLET")
|
||||
.set(InboundPricingEntity::getFeeStatus, "SENT"));
|
||||
}
|
||||
|
||||
public InboundPricingIdempotencyEntity findIdempotency(long tenantId, long operatorId,
|
||||
String operation, String key) {
|
||||
return idempotencies.selectOne(new LambdaQueryWrapper<InboundPricingIdempotencyEntity>()
|
||||
.eq(InboundPricingIdempotencyEntity::getTenantId, tenantId)
|
||||
.eq(InboundPricingIdempotencyEntity::getOperatorId, operatorId)
|
||||
.eq(InboundPricingIdempotencyEntity::getOperation, operation)
|
||||
.eq(InboundPricingIdempotencyEntity::getIdempotencyKey, key));
|
||||
}
|
||||
|
||||
public void insertIdempotency(InboundPricingIdempotencyEntity value) { idempotencies.insert(value); }
|
||||
|
||||
public int completeIdempotency(InboundPricingIdempotencyEntity value, long resourceId, LocalDateTime now) {
|
||||
return idempotencies.update(null, new LambdaUpdateWrapper<InboundPricingIdempotencyEntity>()
|
||||
.eq(InboundPricingIdempotencyEntity::getId, value.getId())
|
||||
.eq(InboundPricingIdempotencyEntity::getStatus, "PROCESSING")
|
||||
.set(InboundPricingIdempotencyEntity::getStatus, "COMPLETED")
|
||||
.set(InboundPricingIdempotencyEntity::getResultResourceId, resourceId)
|
||||
.set(InboundPricingIdempotencyEntity::getCompletedAt, now));
|
||||
}
|
||||
|
||||
public void insertOutbox(InboundOutboxEntity event) { outbox.insert(event); }
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
package com.baiyue.firstleg.repository;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baiyue.firstleg.entity.InboundMeasurementEntity;
|
||||
import com.baiyue.firstleg.entity.InboundMeasurementItemEntity;
|
||||
import com.baiyue.firstleg.entity.InboundOrderLookupEntity;
|
||||
import com.baiyue.firstleg.mapper.InboundMeasurementItemMapper;
|
||||
import com.baiyue.firstleg.mapper.InboundMeasurementMapper;
|
||||
import com.baiyue.firstleg.mapper.InboundOrderLookupMapper;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
/** 第三方测量开放接口仓储,仅允许读取主单并写测量、明细和幂等记录。 */
|
||||
@Repository
|
||||
public class InboundMeasurementOpenApiRepository {
|
||||
private final InboundOrderLookupMapper inboundOrders;
|
||||
private final InboundMeasurementMapper measurements;
|
||||
private final InboundMeasurementItemMapper items;
|
||||
|
||||
public InboundMeasurementOpenApiRepository(InboundOrderLookupMapper inboundOrders,
|
||||
InboundMeasurementMapper measurements,
|
||||
InboundMeasurementItemMapper items) {
|
||||
this.inboundOrders = inboundOrders;
|
||||
this.measurements = measurements;
|
||||
this.items = items;
|
||||
}
|
||||
|
||||
public InboundOrderLookupEntity findInbound(long tenantId, String inboundCode) {
|
||||
return inboundOrders.selectOne(new LambdaQueryWrapper<InboundOrderLookupEntity>()
|
||||
.eq(InboundOrderLookupEntity::getTenantId, tenantId)
|
||||
.eq(InboundOrderLookupEntity::getInboundCode, inboundCode)
|
||||
.eq(InboundOrderLookupEntity::getDeleted, 0));
|
||||
}
|
||||
|
||||
public InboundOrderLookupEntity findInbound(long tenantId, long inboundId) {
|
||||
return inboundOrders.selectOne(new LambdaQueryWrapper<InboundOrderLookupEntity>()
|
||||
.eq(InboundOrderLookupEntity::getTenantId, tenantId)
|
||||
.eq(InboundOrderLookupEntity::getId, inboundId)
|
||||
.eq(InboundOrderLookupEntity::getDeleted, 0));
|
||||
}
|
||||
|
||||
public InboundMeasurementEntity findEvent(long tenantId, String appKey, String eventNo) {
|
||||
return measurements.selectOne(new LambdaQueryWrapper<InboundMeasurementEntity>()
|
||||
.eq(InboundMeasurementEntity::getTenantId, tenantId)
|
||||
.eq(InboundMeasurementEntity::getSourceCode, appKey)
|
||||
.eq(InboundMeasurementEntity::getExternalEventNo, eventNo));
|
||||
}
|
||||
|
||||
public void insertMeasurement(InboundMeasurementEntity measurement) {
|
||||
measurements.insert(measurement);
|
||||
}
|
||||
|
||||
public void insertItem(InboundMeasurementItemEntity item) {
|
||||
items.insert(item);
|
||||
}
|
||||
|
||||
}
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
package com.baiyue.firstleg.repository;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baiyue.firstleg.entity.InboundMeasurementEntity;
|
||||
import com.baiyue.firstleg.entity.InboundMeasurementItemEntity;
|
||||
import com.baiyue.firstleg.entity.InboundOrderEntity;
|
||||
import com.baiyue.firstleg.entity.InboundWeightResultEntity;
|
||||
import com.baiyue.firstleg.entity.InboundWeightRevisionEntity;
|
||||
import com.baiyue.firstleg.entity.MeasureReviewRecordEntity;
|
||||
import com.baiyue.firstleg.mapper.InboundMeasurementItemMapper;
|
||||
import com.baiyue.firstleg.mapper.InboundMeasurementMapper;
|
||||
import com.baiyue.firstleg.mapper.InboundOrderMapper;
|
||||
import com.baiyue.firstleg.mapper.InboundWeightResultMapper;
|
||||
import com.baiyue.firstleg.mapper.InboundWeightRevisionMapper;
|
||||
import com.baiyue.firstleg.mapper.MeasureReviewRecordMapper;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** 入库测量仓储,所有读取都同时带租户和入库单边界。 */
|
||||
@Repository
|
||||
public class InboundMeasurementRepository {
|
||||
private final InboundOrderMapper inboundOrders;
|
||||
private final InboundMeasurementMapper measurements;
|
||||
private final InboundMeasurementItemMapper items;
|
||||
private final InboundWeightResultMapper weights;
|
||||
private final InboundWeightRevisionMapper revisions;
|
||||
private final MeasureReviewRecordMapper reviews;
|
||||
|
||||
public InboundMeasurementRepository(InboundOrderMapper inboundOrders,
|
||||
InboundMeasurementMapper measurements,
|
||||
InboundMeasurementItemMapper items,
|
||||
InboundWeightResultMapper weights,
|
||||
InboundWeightRevisionMapper revisions,
|
||||
MeasureReviewRecordMapper reviews) {
|
||||
this.inboundOrders = inboundOrders;
|
||||
this.measurements = measurements;
|
||||
this.items = items;
|
||||
this.weights = weights;
|
||||
this.revisions = revisions;
|
||||
this.reviews = reviews;
|
||||
}
|
||||
|
||||
public InboundOrderEntity findInbound(long tenantId, long inboundId) {
|
||||
return inboundOrders.selectOne(new LambdaQueryWrapper<InboundOrderEntity>()
|
||||
.eq(InboundOrderEntity::getTenantId, tenantId)
|
||||
.eq(InboundOrderEntity::getId, inboundId));
|
||||
}
|
||||
|
||||
public boolean updateInbound(InboundOrderEntity inbound) {
|
||||
return inboundOrders.updateById(inbound) == 1;
|
||||
}
|
||||
|
||||
public InboundMeasurementEntity findMeasurement(long tenantId, long inboundId, long measurementId) {
|
||||
return measurements.selectOne(new LambdaQueryWrapper<InboundMeasurementEntity>()
|
||||
.eq(InboundMeasurementEntity::getTenantId, tenantId)
|
||||
.eq(InboundMeasurementEntity::getInboundId, inboundId)
|
||||
.eq(InboundMeasurementEntity::getId, measurementId));
|
||||
}
|
||||
|
||||
public InboundMeasurementEntity findLatestMeasurement(long tenantId, long inboundId) {
|
||||
return measurements.selectOne(new LambdaQueryWrapper<InboundMeasurementEntity>()
|
||||
.eq(InboundMeasurementEntity::getTenantId, tenantId)
|
||||
.eq(InboundMeasurementEntity::getInboundId, inboundId)
|
||||
.orderByDesc(InboundMeasurementEntity::getCreatedAt)
|
||||
.last("LIMIT 1"));
|
||||
}
|
||||
|
||||
public List<InboundMeasurementItemEntity> listItems(long tenantId, long measurementId) {
|
||||
return items.selectList(new LambdaQueryWrapper<InboundMeasurementItemEntity>()
|
||||
.eq(InboundMeasurementItemEntity::getTenantId, tenantId)
|
||||
.eq(InboundMeasurementItemEntity::getMeasurementId, measurementId)
|
||||
.orderByAsc(InboundMeasurementItemEntity::getItemNo));
|
||||
}
|
||||
|
||||
public void insertMeasurement(InboundMeasurementEntity measurement) {
|
||||
measurements.insert(measurement);
|
||||
}
|
||||
|
||||
public boolean updateMeasurement(InboundMeasurementEntity measurement) {
|
||||
return measurements.updateById(measurement) == 1;
|
||||
}
|
||||
|
||||
public void replaceItems(long tenantId, long measurementId,
|
||||
List<InboundMeasurementItemEntity> replacement) {
|
||||
items.delete(new LambdaQueryWrapper<InboundMeasurementItemEntity>()
|
||||
.eq(InboundMeasurementItemEntity::getTenantId, tenantId)
|
||||
.eq(InboundMeasurementItemEntity::getMeasurementId, measurementId));
|
||||
replacement.forEach(items::insert);
|
||||
}
|
||||
|
||||
public List<InboundWeightResultEntity> listWeights(long tenantId, long inboundId) {
|
||||
return weights.selectList(new LambdaQueryWrapper<InboundWeightResultEntity>()
|
||||
.eq(InboundWeightResultEntity::getTenantId, tenantId)
|
||||
.eq(InboundWeightResultEntity::getInboundId, inboundId)
|
||||
.orderByAsc(InboundWeightResultEntity::getCreatedAt));
|
||||
}
|
||||
|
||||
public InboundWeightResultEntity findWeight(long tenantId, long inboundId, long resultId) {
|
||||
return weights.selectOne(new LambdaQueryWrapper<InboundWeightResultEntity>()
|
||||
.eq(InboundWeightResultEntity::getTenantId, tenantId)
|
||||
.eq(InboundWeightResultEntity::getInboundId, inboundId)
|
||||
.eq(InboundWeightResultEntity::getId, resultId));
|
||||
}
|
||||
|
||||
public InboundWeightResultEntity findWeightByFile(long tenantId, long inboundId, long fileId) {
|
||||
return weights.selectOne(new LambdaQueryWrapper<InboundWeightResultEntity>()
|
||||
.eq(InboundWeightResultEntity::getTenantId, tenantId)
|
||||
.eq(InboundWeightResultEntity::getInboundId, inboundId)
|
||||
.eq(InboundWeightResultEntity::getFileId, fileId));
|
||||
}
|
||||
|
||||
public void insertWeight(InboundWeightResultEntity weight) {
|
||||
weights.insert(weight);
|
||||
}
|
||||
|
||||
public boolean updateWeight(InboundWeightResultEntity weight) {
|
||||
return weights.updateById(weight) == 1;
|
||||
}
|
||||
|
||||
|
||||
public void insertRevision(InboundWeightRevisionEntity revision) {
|
||||
revisions.insert(revision);
|
||||
}
|
||||
|
||||
public MeasureReviewRecordEntity findLatestReview(long tenantId, long inboundId) {
|
||||
return reviews.selectOne(new LambdaQueryWrapper<MeasureReviewRecordEntity>()
|
||||
.eq(MeasureReviewRecordEntity::getTenantId, tenantId)
|
||||
.eq(MeasureReviewRecordEntity::getInboundId, inboundId)
|
||||
.orderByDesc(MeasureReviewRecordEntity::getReviewedAt)
|
||||
.last("LIMIT 1"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package com.baiyue.firstleg.repository;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.firstleg.dto.InboundOrderDetail;
|
||||
import com.baiyue.firstleg.dto.InboundOrderQuery;
|
||||
import com.baiyue.firstleg.dto.InboundOrderSummary;
|
||||
import com.baiyue.firstleg.entity.InboundFileEntity;
|
||||
import com.baiyue.firstleg.entity.InboundInternalNoteEntity;
|
||||
import com.baiyue.firstleg.entity.InboundOrderEntity;
|
||||
import com.baiyue.firstleg.entity.InboundPalletAllocationEntity;
|
||||
import com.baiyue.firstleg.entity.InboundStatusHistoryEntity;
|
||||
import com.baiyue.firstleg.entity.InboundTransportSnapshotEntity;
|
||||
import com.baiyue.firstleg.mapper.InboundFileMapper;
|
||||
import com.baiyue.firstleg.mapper.InboundInternalNoteMapper;
|
||||
import com.baiyue.firstleg.mapper.InboundOrderMapper;
|
||||
import com.baiyue.firstleg.mapper.InboundPalletAllocationMapper;
|
||||
import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper;
|
||||
import com.baiyue.firstleg.mapper.InboundTransportSnapshotMapper;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/** 入库单只读仓储,所有查询均以租户作为首要隔离条件。 */
|
||||
@Repository
|
||||
public class InboundOrderRepository {
|
||||
private final InboundOrderMapper orders;
|
||||
private final InboundTransportSnapshotMapper transports;
|
||||
private final InboundPalletAllocationMapper pallets;
|
||||
private final InboundFileMapper files;
|
||||
private final InboundInternalNoteMapper notes;
|
||||
private final InboundStatusHistoryMapper histories;
|
||||
|
||||
public InboundOrderRepository(
|
||||
InboundOrderMapper orders,
|
||||
InboundTransportSnapshotMapper transports,
|
||||
InboundPalletAllocationMapper pallets,
|
||||
InboundFileMapper files,
|
||||
InboundInternalNoteMapper notes,
|
||||
InboundStatusHistoryMapper histories) {
|
||||
this.orders = orders;
|
||||
this.transports = transports;
|
||||
this.pallets = pallets;
|
||||
this.files = files;
|
||||
this.notes = notes;
|
||||
this.histories = histories;
|
||||
}
|
||||
|
||||
public PageResult<InboundOrderSummary> page(long tenantId, InboundOrderQuery query) {
|
||||
LambdaQueryWrapper<InboundOrderEntity> wrapper = new LambdaQueryWrapper<InboundOrderEntity>()
|
||||
.eq(InboundOrderEntity::getTenantId, tenantId)
|
||||
.likeRight(StringUtils.hasText(query.inboundCode()), InboundOrderEntity::getInboundCode,
|
||||
query.inboundCode())
|
||||
.likeRight(StringUtils.hasText(query.orderNo()), InboundOrderEntity::getOrderNo, query.orderNo())
|
||||
.eq(StringUtils.hasText(query.processStatus()), InboundOrderEntity::getProcessStatus,
|
||||
query.processStatus())
|
||||
.eq(StringUtils.hasText(query.orderStatus()), InboundOrderEntity::getOrderStatusSnapshot,
|
||||
query.orderStatus())
|
||||
.eq(StringUtils.hasText(query.measureReviewStatus()), InboundOrderEntity::getMeasureReviewStatus,
|
||||
query.measureReviewStatus())
|
||||
.eq(StringUtils.hasText(query.feeStatus()), InboundOrderEntity::getFeeStatus, query.feeStatus())
|
||||
.eq(StringUtils.hasText(query.feeReviewStatus()), InboundOrderEntity::getFeeReviewStatus,
|
||||
query.feeReviewStatus())
|
||||
.in(query.tab() != null, InboundOrderEntity::getProcessStatus,
|
||||
query.tab() == null ? List.of() : query.tab().processStatuses())
|
||||
.ge(query.expectedArrivalFrom() != null, InboundOrderEntity::getExpectedArrivalAt,
|
||||
query.expectedArrivalFrom())
|
||||
.le(query.expectedArrivalTo() != null, InboundOrderEntity::getExpectedArrivalAt,
|
||||
query.expectedArrivalTo())
|
||||
.eq(query.customerId() != null, InboundOrderEntity::getCustomerId, query.customerId())
|
||||
.like(StringUtils.hasText(query.customerName()), InboundOrderEntity::getCustomerName,
|
||||
query.customerName())
|
||||
.eq(query.companyId() != null, InboundOrderEntity::getCompanyId, query.companyId())
|
||||
.eq(query.warehouseId() != null, InboundOrderEntity::getWarehouseId, query.warehouseId())
|
||||
.eq(query.deliveryWarehouseId() != null, InboundOrderEntity::getDeliveryWarehouseId,
|
||||
query.deliveryWarehouseId())
|
||||
.eq(StringUtils.hasText(query.destinationCode()), InboundOrderEntity::getDestinationCode,
|
||||
query.destinationCode())
|
||||
.eq(query.holdFlag() != null, InboundOrderEntity::getHoldFlag, query.holdFlag())
|
||||
.orderByDesc(InboundOrderEntity::getCreatedAt)
|
||||
.orderByDesc(InboundOrderEntity::getId);
|
||||
Page<InboundOrderEntity> result = orders.selectPage(new Page<>(query.page(), query.size()), wrapper);
|
||||
List<Long> ids = result.getRecords().stream().map(InboundOrderEntity::getId).toList();
|
||||
Map<Long, InboundTransportSnapshotEntity> transportByInbound = transportMap(tenantId, ids);
|
||||
Map<Long, String> locationCodesByInbound = locationCodesMap(tenantId, ids);
|
||||
List<InboundOrderSummary> records = result.getRecords().stream()
|
||||
.map(order -> InboundOrderSummary.from(order, transportByInbound.get(order.getId()),
|
||||
locationCodesByInbound.get(order.getId())))
|
||||
.toList();
|
||||
return new PageResult<>(records, result.getCurrent(), result.getSize(), result.getTotal(), result.getPages());
|
||||
}
|
||||
|
||||
public InboundOrderEntity findOwned(long tenantId, long inboundId) {
|
||||
return orders.selectOne(new LambdaQueryWrapper<InboundOrderEntity>()
|
||||
.eq(InboundOrderEntity::getTenantId, tenantId)
|
||||
.eq(InboundOrderEntity::getId, inboundId));
|
||||
}
|
||||
|
||||
public InboundOrderEntity findByOrderId(long tenantId, long orderId) {
|
||||
return orders.selectOne(new LambdaQueryWrapper<InboundOrderEntity>()
|
||||
.eq(InboundOrderEntity::getTenantId, tenantId)
|
||||
.eq(InboundOrderEntity::getOrderId, orderId)
|
||||
.orderByDesc(InboundOrderEntity::getCreatedAt)
|
||||
.last("LIMIT 1"));
|
||||
}
|
||||
|
||||
public InboundOrderDetail detail(long tenantId, InboundOrderEntity order) {
|
||||
long inboundId = order.getId();
|
||||
InboundTransportSnapshotEntity transport = transports.selectOne(
|
||||
owned(InboundTransportSnapshotEntity::getTenantId,
|
||||
InboundTransportSnapshotEntity::getInboundId, tenantId, inboundId));
|
||||
List<InboundPalletAllocationEntity> palletAllocations = pallets.selectList(
|
||||
owned(InboundPalletAllocationEntity::getTenantId,
|
||||
InboundPalletAllocationEntity::getInboundId, tenantId, inboundId)
|
||||
.orderByAsc(InboundPalletAllocationEntity::getAllocationNo));
|
||||
List<InboundOrderDetail.PalletLocationSummary> palletSummaries = palletAllocations.stream()
|
||||
.map(item -> new InboundOrderDetail.PalletLocationSummary(
|
||||
item.getAllocationNo(), item.getWarehouseId(), item.getPalletId(), item.getPalletCode(),
|
||||
item.getPalletCapacityCbm(), item.getAllocatedVolumeCbm(), item.getAllocatedPackageCount(),
|
||||
item.getLocationId(), item.getLocationCode(), item.getReservationNo(), item.getAllocationStatus()))
|
||||
.toList();
|
||||
List<InboundOrderDetail.FileSummary> fileSummaries = files.selectList(
|
||||
owned(InboundFileEntity::getTenantId, InboundFileEntity::getInboundId, tenantId, inboundId)
|
||||
.orderByAsc(InboundFileEntity::getFileType)
|
||||
.orderByDesc(InboundFileEntity::getUploadedAt))
|
||||
.stream().map(item -> new InboundOrderDetail.FileSummary(
|
||||
item.getFileId(), item.getFileType(), item.getFileNameSnapshot(), item.getVisibility(),
|
||||
item.getFileVersion(), item.getUploadedBy(), item.getUploadedAt()))
|
||||
.toList();
|
||||
List<InboundOrderDetail.InternalNoteSummary> noteSummaries = notes.selectList(
|
||||
owned(InboundInternalNoteEntity::getTenantId,
|
||||
InboundInternalNoteEntity::getInboundId, tenantId, inboundId)
|
||||
.orderByDesc(InboundInternalNoteEntity::getCreatedAt))
|
||||
.stream().map(item -> new InboundOrderDetail.InternalNoteSummary(
|
||||
item.getId(), item.getContent(), item.getCreatedBy(), item.getCreatedByName(), item.getCreatedAt()))
|
||||
.toList();
|
||||
List<InboundOrderDetail.StatusHistorySummary> historySummaries = histories.selectList(
|
||||
owned(InboundStatusHistoryEntity::getTenantId,
|
||||
InboundStatusHistoryEntity::getInboundId, tenantId, inboundId)
|
||||
.orderByDesc(InboundStatusHistoryEntity::getOccurredAt))
|
||||
.stream().map(item -> new InboundOrderDetail.StatusHistorySummary(
|
||||
item.getFromStatus(), item.getToStatus(), item.getAction(), item.getReason(),
|
||||
item.getOperatorId(), item.getOperatorName(), item.getOccurredAt()))
|
||||
.toList();
|
||||
String locationCodes = palletAllocations.stream()
|
||||
.map(InboundPalletAllocationEntity::getLocationCode)
|
||||
.filter(StringUtils::hasText)
|
||||
.collect(Collectors.joining("、"));
|
||||
return new InboundOrderDetail(InboundOrderSummary.from(order, transport, locationCodes),
|
||||
order.getPublicRemark(),
|
||||
palletSummaries, fileSummaries, noteSummaries, historySummaries);
|
||||
}
|
||||
|
||||
private Map<Long, InboundTransportSnapshotEntity> transportMap(long tenantId, List<Long> inboundIds) {
|
||||
if (inboundIds.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
return transports.selectList(new LambdaQueryWrapper<InboundTransportSnapshotEntity>()
|
||||
.eq(InboundTransportSnapshotEntity::getTenantId, tenantId)
|
||||
.in(InboundTransportSnapshotEntity::getInboundId, inboundIds))
|
||||
.stream().collect(Collectors.toMap(InboundTransportSnapshotEntity::getInboundId,
|
||||
Function.identity(), (left, right) -> left));
|
||||
}
|
||||
|
||||
private Map<Long, String> locationCodesMap(long tenantId, List<Long> inboundIds) {
|
||||
if (inboundIds.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
return pallets.selectList(new LambdaQueryWrapper<InboundPalletAllocationEntity>()
|
||||
.eq(InboundPalletAllocationEntity::getTenantId, tenantId)
|
||||
.in(InboundPalletAllocationEntity::getInboundId, inboundIds)
|
||||
.orderByAsc(InboundPalletAllocationEntity::getInboundId)
|
||||
.orderByAsc(InboundPalletAllocationEntity::getAllocationNo))
|
||||
.stream()
|
||||
.filter(item -> StringUtils.hasText(item.getLocationCode()))
|
||||
.collect(Collectors.groupingBy(InboundPalletAllocationEntity::getInboundId,
|
||||
java.util.LinkedHashMap::new,
|
||||
Collectors.mapping(InboundPalletAllocationEntity::getLocationCode,
|
||||
Collectors.joining("、"))));
|
||||
}
|
||||
|
||||
private static <T> LambdaQueryWrapper<T> owned(
|
||||
com.baomidou.mybatisplus.core.toolkit.support.SFunction<T, ?> tenantColumn,
|
||||
com.baomidou.mybatisplus.core.toolkit.support.SFunction<T, ?> inboundColumn,
|
||||
long tenantId,
|
||||
long inboundId) {
|
||||
return new LambdaQueryWrapper<T>().eq(tenantColumn, tenantId).eq(inboundColumn, inboundId);
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
package com.baiyue.firstleg.repository;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baiyue.firstleg.client.WarehousePalletLocationClient.BatchResult;
|
||||
import com.baiyue.firstleg.entity.InboundPalletAllocationEntity;
|
||||
import com.baiyue.firstleg.mapper.InboundPalletAllocationMapper;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Repository
|
||||
public class InboundPalletAllocationRepository {
|
||||
private final InboundPalletAllocationMapper mapper;
|
||||
|
||||
public InboundPalletAllocationRepository(InboundPalletAllocationMapper mapper) {
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void replace(long tenantId, long inboundId, long warehouseId, BigDecimal capacity,
|
||||
BatchResult result) {
|
||||
mapper.delete(new LambdaQueryWrapper<InboundPalletAllocationEntity>()
|
||||
.eq(InboundPalletAllocationEntity::getTenantId, tenantId)
|
||||
.eq(InboundPalletAllocationEntity::getInboundId, inboundId));
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
result.allocations().forEach(source -> {
|
||||
InboundPalletAllocationEntity value = new InboundPalletAllocationEntity();
|
||||
value.setTenantId(tenantId);
|
||||
value.setInboundId(inboundId);
|
||||
value.setAllocationNo(source.allocationNo());
|
||||
value.setWarehouseId(warehouseId);
|
||||
value.setPalletId(source.palletId());
|
||||
value.setPalletCode(source.palletCode());
|
||||
value.setPalletCapacityCbm(capacity);
|
||||
value.setAllocatedVolumeCbm(source.allocatedVolumeCbm());
|
||||
value.setLocationId(source.locationId());
|
||||
value.setLocationCode(source.locationCode());
|
||||
value.setReservationNo(source.reservationNo());
|
||||
value.setAllocationStatus(normalize(source.state()));
|
||||
value.setCreatedAt(now);
|
||||
value.setUpdatedAt(now);
|
||||
mapper.insert(value);
|
||||
});
|
||||
}
|
||||
|
||||
private static String normalize(String state) {
|
||||
return "OCCUPIED".equals(state) ? "CONFIRMED" : state;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package com.baiyue.firstleg.repository;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.firstleg.entity.InboundFeeEntity;
|
||||
import com.baiyue.firstleg.entity.InboundIdempotencyEntity;
|
||||
import com.baiyue.firstleg.entity.InboundMeasurementEntity;
|
||||
import com.baiyue.firstleg.entity.InboundOrderEntity;
|
||||
import com.baiyue.firstleg.entity.InboundOutboxEntity;
|
||||
import com.baiyue.firstleg.entity.InboundStatusHistoryEntity;
|
||||
import com.baiyue.firstleg.entity.MeasureReviewRecordEntity;
|
||||
import com.baiyue.firstleg.mapper.InboundAllocationQueryMapper;
|
||||
import com.baiyue.firstleg.mapper.InboundFeeMapper;
|
||||
import com.baiyue.firstleg.mapper.InboundIdempotencyMapper;
|
||||
import com.baiyue.firstleg.mapper.InboundMeasurementMapper;
|
||||
import com.baiyue.firstleg.mapper.InboundOrderMapper;
|
||||
import com.baiyue.firstleg.mapper.InboundOutboxMapper;
|
||||
import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper;
|
||||
import com.baiyue.firstleg.mapper.MeasureReviewRecordMapper;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
/** 入库审核和完成编排仓储;所有业务查询强制携带租户。 */
|
||||
@Repository
|
||||
public class InboundWorkflowRepository {
|
||||
private final InboundOrderMapper orders;
|
||||
private final InboundMeasurementMapper measurements;
|
||||
private final MeasureReviewRecordMapper reviews;
|
||||
private final InboundStatusHistoryMapper histories;
|
||||
private final InboundIdempotencyMapper idempotencies;
|
||||
private final InboundFeeMapper fees;
|
||||
private final InboundOutboxMapper outboxes;
|
||||
private final InboundAllocationQueryMapper allocations;
|
||||
|
||||
public InboundWorkflowRepository(InboundOrderMapper orders,
|
||||
InboundMeasurementMapper measurements,
|
||||
MeasureReviewRecordMapper reviews,
|
||||
InboundStatusHistoryMapper histories,
|
||||
InboundIdempotencyMapper idempotencies,
|
||||
InboundFeeMapper fees,
|
||||
InboundOutboxMapper outboxes,
|
||||
InboundAllocationQueryMapper allocations) {
|
||||
this.orders = orders;
|
||||
this.measurements = measurements;
|
||||
this.reviews = reviews;
|
||||
this.histories = histories;
|
||||
this.idempotencies = idempotencies;
|
||||
this.fees = fees;
|
||||
this.outboxes = outboxes;
|
||||
this.allocations = allocations;
|
||||
}
|
||||
|
||||
public InboundOrderEntity findOwned(long tenantId, long inboundId) {
|
||||
return orders.selectOne(new LambdaQueryWrapper<InboundOrderEntity>()
|
||||
.eq(InboundOrderEntity::getTenantId, tenantId)
|
||||
.eq(InboundOrderEntity::getId, inboundId));
|
||||
}
|
||||
|
||||
public InboundMeasurementEntity findMeasurement(long tenantId, long inboundId, long measurementId) {
|
||||
return measurements.selectOne(new LambdaQueryWrapper<InboundMeasurementEntity>()
|
||||
.eq(InboundMeasurementEntity::getTenantId, tenantId)
|
||||
.eq(InboundMeasurementEntity::getInboundId, inboundId)
|
||||
.eq(InboundMeasurementEntity::getId, measurementId));
|
||||
}
|
||||
|
||||
public int transitionReview(long tenantId, long inboundId, long operatorId, int expectedVersion,
|
||||
boolean approved) {
|
||||
return approved
|
||||
? orders.approveMeasurement(tenantId, inboundId, operatorId, expectedVersion)
|
||||
: orders.rejectMeasurement(tenantId, inboundId, operatorId, expectedVersion);
|
||||
}
|
||||
|
||||
public int transitionMeasurement(long tenantId, long inboundId, long measurementId, String result) {
|
||||
return measurements.reviewSubmitted(tenantId, inboundId, measurementId, result);
|
||||
}
|
||||
|
||||
public int releaseRejected(long tenantId, long inboundId, long operatorId, int expectedVersion) {
|
||||
return orders.releaseRejectedMeasurement(tenantId, inboundId, operatorId, expectedVersion);
|
||||
}
|
||||
|
||||
public void insertReview(MeasureReviewRecordEntity record) {
|
||||
reviews.insert(record);
|
||||
}
|
||||
|
||||
public PageResult<MeasureReviewRecordEntity> reviewHistory(
|
||||
long tenantId, long inboundId, long page, long size) {
|
||||
return PageResult.from(reviews.selectPage(new Page<>(page, size),
|
||||
new LambdaQueryWrapper<MeasureReviewRecordEntity>()
|
||||
.eq(MeasureReviewRecordEntity::getTenantId, tenantId)
|
||||
.eq(MeasureReviewRecordEntity::getInboundId, inboundId)
|
||||
.orderByDesc(MeasureReviewRecordEntity::getReviewedAt)
|
||||
.orderByDesc(MeasureReviewRecordEntity::getId)));
|
||||
}
|
||||
|
||||
public boolean hasExceptionRelease(long tenantId, long inboundId) {
|
||||
return reviews.selectCount(new LambdaQueryWrapper<MeasureReviewRecordEntity>()
|
||||
.eq(MeasureReviewRecordEntity::getTenantId, tenantId)
|
||||
.eq(MeasureReviewRecordEntity::getInboundId, inboundId)
|
||||
.eq(MeasureReviewRecordEntity::getExceptionRelease, true)) > 0;
|
||||
}
|
||||
|
||||
public void insertHistory(InboundStatusHistoryEntity history) {
|
||||
histories.insert(history);
|
||||
}
|
||||
|
||||
public void insertIdempotency(InboundIdempotencyEntity record) {
|
||||
idempotencies.insert(record);
|
||||
}
|
||||
|
||||
public InboundIdempotencyEntity findIdempotency(
|
||||
long tenantId, String operation, long operatorId, String key) {
|
||||
return idempotencies.selectOne(new LambdaQueryWrapper<InboundIdempotencyEntity>()
|
||||
.eq(InboundIdempotencyEntity::getTenantId, tenantId)
|
||||
.eq(InboundIdempotencyEntity::getOperation, operation)
|
||||
.eq(InboundIdempotencyEntity::getOperatorId, operatorId)
|
||||
.eq(InboundIdempotencyEntity::getIdempotencyKey, key));
|
||||
}
|
||||
|
||||
public int completeIdempotency(long id, String resultJson) {
|
||||
return idempotencies.complete(id, resultJson);
|
||||
}
|
||||
|
||||
public InboundFeeEntity findConfirmedFee(long tenantId, long inboundId, long feeId) {
|
||||
return fees.selectOne(new LambdaQueryWrapper<InboundFeeEntity>()
|
||||
.eq(InboundFeeEntity::getTenantId, tenantId)
|
||||
.eq(InboundFeeEntity::getInboundId, inboundId)
|
||||
.eq(InboundFeeEntity::getId, feeId)
|
||||
.eq(InboundFeeEntity::getFeeStatus, "CONFIRMED"));
|
||||
}
|
||||
|
||||
public long countPreparedAllocations(long tenantId, long inboundId) {
|
||||
return allocations.countPrepared(tenantId, inboundId);
|
||||
}
|
||||
|
||||
public int beginInbound(long tenantId, long inboundId, long operatorId, int expectedVersion) {
|
||||
return orders.beginInbound(tenantId, inboundId, operatorId, expectedVersion);
|
||||
}
|
||||
|
||||
public int completeInbound(long tenantId, long inboundId, long operatorId, int expectedVersion) {
|
||||
return orders.completeInbound(tenantId, inboundId, operatorId, expectedVersion);
|
||||
}
|
||||
|
||||
public int rollbackInbound(long tenantId, long inboundId, long operatorId, int expectedVersion) {
|
||||
return orders.rollbackInbound(tenantId, inboundId, operatorId, expectedVersion);
|
||||
}
|
||||
|
||||
public int markFeeSent(long tenantId, long inboundId, long feeId, long operatorId) {
|
||||
return fees.markSent(tenantId, inboundId, feeId, operatorId);
|
||||
}
|
||||
|
||||
public void insertOutbox(InboundOutboxEntity event) {
|
||||
outboxes.insert(event);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.baiyue.firstleg.security;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/** 签名校验通过后的第三方应用主体。 */
|
||||
public record AuthenticatedMeasurementApp(
|
||||
String appKey,
|
||||
long tenantId,
|
||||
long subjectId,
|
||||
Map<String, Long> warehouseBindings) {
|
||||
}
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
package com.baiyue.firstleg.security;
|
||||
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.config.MeasurementOpenApiProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.HexFormat;
|
||||
import java.util.Map;
|
||||
|
||||
/** HMAC、时间戳和 Nonce 三项请求认证。 */
|
||||
@Component
|
||||
public class MeasurementRequestAuthenticator {
|
||||
private static final String HMAC_ALGORITHM = "HmacSHA256";
|
||||
private final MeasurementOpenApiProperties properties;
|
||||
private final NonceReplayGuard nonceReplayGuard;
|
||||
private final Clock clock;
|
||||
|
||||
public MeasurementRequestAuthenticator(MeasurementOpenApiProperties properties,
|
||||
NonceReplayGuard nonceReplayGuard) {
|
||||
this(properties, nonceReplayGuard, Clock.systemUTC());
|
||||
}
|
||||
|
||||
MeasurementRequestAuthenticator(MeasurementOpenApiProperties properties,
|
||||
NonceReplayGuard nonceReplayGuard,
|
||||
Clock clock) {
|
||||
this.properties = properties;
|
||||
this.nonceReplayGuard = nonceReplayGuard;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
public AuthenticatedMeasurementApp authenticate(String method, String path,
|
||||
MeasurementRequestHeaders headers,
|
||||
String rawBody, boolean requireIdempotencyKey) {
|
||||
if (!properties.isEnabled()) {
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "第三方测量开放接口未启用");
|
||||
}
|
||||
requireHeader(headers.appKey(), "X-App-Key");
|
||||
requireHeader(headers.timestamp(), "X-Timestamp");
|
||||
requireHeader(headers.nonce(), "X-Nonce");
|
||||
requireHeader(headers.signature(), "X-Signature");
|
||||
if (requireIdempotencyKey) {
|
||||
requireHeader(headers.idempotencyKey(), "Idempotency-Key");
|
||||
}
|
||||
validateTokenLength(headers.nonce(), 128, "X-Nonce");
|
||||
if (requireIdempotencyKey) {
|
||||
validateTokenLength(headers.idempotencyKey(), 128, "Idempotency-Key");
|
||||
}
|
||||
|
||||
MeasurementOpenApiProperties.AppCredential credential = properties.getApps().get(headers.appKey());
|
||||
if (credential == null || !credential.isEnabled() || !StringUtils.hasText(credential.getSecret())
|
||||
|| credential.getTenantId() == null || credential.getTenantId() <= 0
|
||||
|| credential.getSubjectId() == null || credential.getSubjectId() <= 0) {
|
||||
throw unauthorized("应用凭据无效");
|
||||
}
|
||||
long timestamp = parseTimestamp(headers.timestamp());
|
||||
Duration drift = Duration.between(Instant.ofEpochMilli(timestamp), clock.instant()).abs();
|
||||
if (drift.compareTo(properties.getMaxClockSkew()) > 0) {
|
||||
throw unauthorized("请求时间戳已过期");
|
||||
}
|
||||
|
||||
String canonical = canonicalString(method, path, headers.timestamp(), headers.nonce(),
|
||||
requireIdempotencyKey ? headers.idempotencyKey() : "", sha256Hex(rawBody));
|
||||
String expected = hmacHex(credential.getSecret(), canonical);
|
||||
if (!constantTimeEquals(expected, headers.signature())) {
|
||||
throw unauthorized("请求签名无效");
|
||||
}
|
||||
try {
|
||||
if (!nonceReplayGuard.claim(headers.appKey(), headers.nonce(), properties.getNonceTtl())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "Nonce 已使用,拒绝重放请求");
|
||||
}
|
||||
} catch (BizException exception) {
|
||||
throw exception;
|
||||
} catch (RuntimeException exception) {
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "Nonce 防重放服务暂不可用");
|
||||
}
|
||||
return new AuthenticatedMeasurementApp(headers.appKey(), credential.getTenantId(),
|
||||
credential.getSubjectId(), Map.copyOf(credential.getWarehouseBindings()));
|
||||
}
|
||||
|
||||
public static String canonicalString(String method, String path, String timestamp, String nonce,
|
||||
String idempotencyKey, String bodySha256) {
|
||||
return String.join("\n", method.toUpperCase(java.util.Locale.ROOT), path, timestamp, nonce,
|
||||
idempotencyKey == null ? "" : idempotencyKey, bodySha256);
|
||||
}
|
||||
|
||||
public static String sha256Hex(String value) {
|
||||
try {
|
||||
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
|
||||
.digest((value == null ? "" : value).getBytes(StandardCharsets.UTF_8)));
|
||||
} catch (Exception exception) {
|
||||
throw new IllegalStateException("SHA-256 不可用", exception);
|
||||
}
|
||||
}
|
||||
|
||||
static String hmacHex(String secret, String canonical) {
|
||||
try {
|
||||
Mac mac = Mac.getInstance(HMAC_ALGORITHM);
|
||||
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), HMAC_ALGORITHM));
|
||||
return HexFormat.of().formatHex(mac.doFinal(canonical.getBytes(StandardCharsets.UTF_8)));
|
||||
} catch (Exception exception) {
|
||||
throw new IllegalStateException("HMAC-SHA256 不可用", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private long parseTimestamp(String timestamp) {
|
||||
try {
|
||||
return Long.parseLong(timestamp);
|
||||
} catch (NumberFormatException exception) {
|
||||
throw unauthorized("X-Timestamp 必须为 Unix 毫秒时间戳");
|
||||
}
|
||||
}
|
||||
|
||||
private boolean constantTimeEquals(String expected, String actual) {
|
||||
return MessageDigest.isEqual(expected.getBytes(StandardCharsets.US_ASCII),
|
||||
actual.toLowerCase(java.util.Locale.ROOT).getBytes(StandardCharsets.US_ASCII));
|
||||
}
|
||||
|
||||
private void requireHeader(String value, String name) {
|
||||
if (!StringUtils.hasText(value)) {
|
||||
throw unauthorized("缺少请求头 " + name);
|
||||
}
|
||||
}
|
||||
|
||||
private void validateTokenLength(String value, int max, String name) {
|
||||
if (value.length() > max) {
|
||||
throw unauthorized(name + " 长度超过限制");
|
||||
}
|
||||
}
|
||||
|
||||
private BizException unauthorized(String message) {
|
||||
return new BizException(ResultCode.UNAUTHORIZED, message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.baiyue.firstleg.security;
|
||||
|
||||
/** 第三方测量请求的认证和幂等请求头。 */
|
||||
public record MeasurementRequestHeaders(
|
||||
String appKey,
|
||||
String timestamp,
|
||||
String nonce,
|
||||
String signature,
|
||||
String idempotencyKey) {
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.baiyue.firstleg.security;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
/** 原子占用 Nonce,返回 false 表示同一应用已使用过该 Nonce。 */
|
||||
public interface NonceReplayGuard {
|
||||
boolean claim(String appKey, String nonce, Duration ttl);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.baiyue.firstleg.security;
|
||||
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
/** 使用 Redis SET NX EX 实现跨实例 Nonce 防重放。 */
|
||||
@Component
|
||||
public class RedisNonceReplayGuard implements NonceReplayGuard {
|
||||
private static final String KEY_PREFIX = "baiyue:firstleg:measurement-openapi:nonce:";
|
||||
private final StringRedisTemplate redisTemplate;
|
||||
|
||||
public RedisNonceReplayGuard(StringRedisTemplate redisTemplate) {
|
||||
this.redisTemplate = redisTemplate;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean claim(String appKey, String nonce, Duration ttl) {
|
||||
Boolean claimed = redisTemplate.opsForValue()
|
||||
.setIfAbsent(KEY_PREFIX + appKey + ":" + nonce, "1", ttl);
|
||||
return Boolean.TRUE.equals(claimed);
|
||||
}
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
package com.baiyue.firstleg.service;
|
||||
|
||||
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.entity.InboundOrderEntity;
|
||||
import com.baiyue.firstleg.repository.InboundWorkflowRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class AutomaticInboundCompletionService {
|
||||
private final FbaPricingService pricing;
|
||||
private final InboundWorkflowRepository workflow;
|
||||
private final InboundCompletionOrchestrator completion;
|
||||
|
||||
public AutomaticInboundCompletionService(FbaPricingService pricing,
|
||||
InboundWorkflowRepository workflow,
|
||||
InboundCompletionOrchestrator completion) {
|
||||
this.pricing = pricing;
|
||||
this.workflow = workflow;
|
||||
this.completion = completion;
|
||||
}
|
||||
|
||||
public FeeSnapshotResponse completeApproved(long tenantId, long inboundId, long operatorId,
|
||||
String operatorName, long measurementId,
|
||||
int reviewedOrderVersion) {
|
||||
String key = "auto-" + inboundId + "-" + measurementId + "-" + reviewedOrderVersion;
|
||||
InboundOrderEntity order = requireOrder(tenantId, inboundId);
|
||||
if (completed(order)) return pricing.latest(tenantId, inboundId);
|
||||
|
||||
if ("WAIT_PRICING".equals(order.getProcessStatus()) && "UNPRICED".equals(order.getFeeStatus())) {
|
||||
pricing.calculate(tenantId, operatorId, inboundId, null, key + "-calculate");
|
||||
order = requireOrder(tenantId, inboundId);
|
||||
}
|
||||
if ("FEE_CONFIRMING".equals(order.getProcessStatus())
|
||||
&& "CALCULATED".equals(order.getFeeStatus())) {
|
||||
pricing.confirm(tenantId, operatorId, inboundId, key + "-confirm");
|
||||
order = requireOrder(tenantId, inboundId);
|
||||
}
|
||||
|
||||
FeeSnapshotResponse fee = pricing.latest(tenantId, inboundId);
|
||||
if ("FEE_CONFIRMING".equals(order.getProcessStatus())
|
||||
&& "CONFIRMED".equals(order.getFeeStatus())) {
|
||||
completion.beginSendFeeAndInbound(tenantId, operatorId, operatorName, inboundId,
|
||||
fee.feeId(), order.getVersion(), key + "-begin");
|
||||
order = requireOrder(tenantId, inboundId);
|
||||
}
|
||||
if ("INBOUNDING".equals(order.getProcessStatus())
|
||||
&& "CONFIRMED".equals(order.getFeeStatus())) {
|
||||
completion.completeSendFeeAndInbound(tenantId, operatorId, operatorName, inboundId,
|
||||
fee.feeId(), order.getVersion(), key + "-complete");
|
||||
order = requireOrder(tenantId, inboundId);
|
||||
}
|
||||
if (!completed(order)) {
|
||||
throw new BizException(ResultCode.CONFLICT, "客服审核后的自动计费入库流程状态异常");
|
||||
}
|
||||
return pricing.latest(tenantId, inboundId);
|
||||
}
|
||||
|
||||
private static boolean completed(InboundOrderEntity order) {
|
||||
return "INBOUNDED".equals(order.getProcessStatus()) && "SENT".equals(order.getFeeStatus());
|
||||
}
|
||||
|
||||
private InboundOrderEntity requireOrder(long tenantId, long inboundId) {
|
||||
InboundOrderEntity order = workflow.findOwned(tenantId, inboundId);
|
||||
if (order == null) throw new BizException(ResultCode.NOT_FOUND, "入库单不存在");
|
||||
return order;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package com.baiyue.firstleg.service;
|
||||
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.dto.FbaPricingDtos.CarrierRateVersionRequest;
|
||||
import com.baiyue.firstleg.dto.FbaPricingDtos.CustomerRuleVersionRequest;
|
||||
import com.baiyue.firstleg.entity.CarrierFbaRateEntity;
|
||||
import com.baiyue.firstleg.entity.CustomerChargeRuleEntity;
|
||||
import com.baiyue.firstleg.repository.FbaPricingRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/** 客户规则和船司价格版本管理。版本内容创建后不可原地修改。 */
|
||||
@Service
|
||||
public class FbaMasterDataService {
|
||||
private final FbaPricingRepository repository;
|
||||
|
||||
public FbaMasterDataService(FbaPricingRepository repository) { this.repository = repository; }
|
||||
|
||||
@Transactional
|
||||
public CustomerChargeRuleEntity createRuleVersion(long tenantId, long operatorId,
|
||||
CustomerRuleVersionRequest request) {
|
||||
validatePeriod(request.effectiveFrom(), request.effectiveTo());
|
||||
boolean overlaps = repository.listRules(tenantId, null).stream()
|
||||
.filter(v -> "ACTIVE".equals(v.getStatus()))
|
||||
.filter(v -> Objects.equals(v.getCustomerId(), request.customerId()))
|
||||
.filter(v -> Objects.equals(v.getBusinessType(), request.businessType()))
|
||||
.filter(v -> Objects.equals(v.getTaxMode(), request.taxMode()))
|
||||
.anyMatch(v -> overlaps(v.getEffectiveFrom(), v.getEffectiveTo(),
|
||||
request.effectiveFrom(), request.effectiveTo()));
|
||||
if (overlaps) throw conflict("客户计费规则有效期重叠,无法保证唯一匹配");
|
||||
|
||||
CustomerChargeRuleEntity value = new CustomerChargeRuleEntity();
|
||||
value.setTenantId(tenantId);
|
||||
value.setRuleCode(request.ruleCode());
|
||||
value.setRuleVersion(repository.nextRuleVersion(tenantId, request.ruleCode()));
|
||||
value.setCustomerId(request.customerId());
|
||||
value.setCustomerName(request.customerName());
|
||||
value.setBusinessType(request.businessType());
|
||||
value.setChargeMode(request.chargeMode());
|
||||
value.setTaxMode(request.taxMode());
|
||||
value.setConversionFactor(request.conversionFactor());
|
||||
value.setRoundingScale(request.roundingScale());
|
||||
value.setRoundingMode(request.roundingMode());
|
||||
value.setCurrency(request.currency());
|
||||
value.setEffectiveFrom(request.effectiveFrom());
|
||||
value.setEffectiveTo(request.effectiveTo());
|
||||
value.setStatus("ACTIVE");
|
||||
value.setRemark(request.remark());
|
||||
value.setCreatedBy(operatorId);
|
||||
value.setUpdatedBy(operatorId);
|
||||
repository.insertRule(value);
|
||||
return value;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public CarrierFbaRateEntity createRateVersion(long tenantId, long operatorId,
|
||||
CarrierRateVersionRequest request) {
|
||||
validatePeriod(request.effectiveFrom(), request.effectiveTo());
|
||||
boolean overlaps = repository.listRates(tenantId, null).stream()
|
||||
.filter(v -> "ACTIVE".equals(v.getStatus()))
|
||||
.filter(v -> Objects.equals(v.getOriginWarehouseId(), request.originWarehouseId()))
|
||||
.filter(v -> Objects.equals(v.getBusinessType(), request.businessType()))
|
||||
.filter(v -> Objects.equals(v.getChannelCode(), request.channelCode()))
|
||||
.filter(v -> Objects.equals(v.getCarrierId(), request.carrierId()))
|
||||
.filter(v -> Objects.equals(v.getDestinationCode(), request.destinationCode()))
|
||||
.filter(v -> Objects.equals(v.getChargeUnit(), request.chargeUnit()))
|
||||
.anyMatch(v -> overlaps(v.getEffectiveFrom(), v.getEffectiveTo(),
|
||||
request.effectiveFrom(), request.effectiveTo()));
|
||||
if (overlaps) throw conflict("船司 FBA 价格有效期重叠,无法保证唯一匹配");
|
||||
|
||||
CarrierFbaRateEntity value = new CarrierFbaRateEntity();
|
||||
value.setTenantId(tenantId);
|
||||
value.setRateCode(request.rateCode());
|
||||
value.setRateVersion(repository.nextRateVersion(tenantId, request.rateCode()));
|
||||
value.setOriginWarehouseId(request.originWarehouseId());
|
||||
value.setOriginWarehouseName(request.originWarehouseName());
|
||||
value.setBusinessType(request.businessType());
|
||||
value.setChannelCode(request.channelCode());
|
||||
value.setCarrierId(request.carrierId());
|
||||
value.setCarrierName(request.carrierName());
|
||||
value.setDestinationCode(request.destinationCode());
|
||||
value.setChargeUnit(request.chargeUnit());
|
||||
value.setUnitPrice(request.unitPrice());
|
||||
value.setCurrency(request.currency());
|
||||
value.setAdditionalFee(request.additionalFee());
|
||||
value.setEffectiveFrom(request.effectiveFrom());
|
||||
value.setEffectiveTo(request.effectiveTo());
|
||||
value.setStatus("ACTIVE");
|
||||
value.setRemark(request.remark());
|
||||
value.setCreatedBy(operatorId);
|
||||
value.setUpdatedBy(operatorId);
|
||||
repository.insertRate(value);
|
||||
return value;
|
||||
}
|
||||
|
||||
public List<CustomerChargeRuleEntity> listRules(long tenantId, String code) {
|
||||
return repository.listRules(tenantId, code);
|
||||
}
|
||||
|
||||
public List<CarrierFbaRateEntity> listRates(long tenantId, String code) {
|
||||
return repository.listRates(tenantId, code);
|
||||
}
|
||||
|
||||
public CustomerChargeRuleEntity rule(long tenantId, long id) {
|
||||
CustomerChargeRuleEntity value = repository.findRule(tenantId, id);
|
||||
if (value == null) throw new BizException(ResultCode.NOT_FOUND, "客户计费规则版本不存在");
|
||||
return value;
|
||||
}
|
||||
|
||||
public CarrierFbaRateEntity rate(long tenantId, long id) {
|
||||
CarrierFbaRateEntity value = repository.findRate(tenantId, id);
|
||||
if (value == null) throw new BizException(ResultCode.NOT_FOUND, "船司 FBA 价格版本不存在");
|
||||
return value;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void retireRule(long tenantId, long id, long operatorId) {
|
||||
if (repository.retireRule(tenantId, id, operatorId) != 1) throw conflict("规则已停用或不存在");
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void retireRate(long tenantId, long id, long operatorId) {
|
||||
if (repository.retireRate(tenantId, id, operatorId) != 1) throw conflict("价格已停用或不存在");
|
||||
}
|
||||
|
||||
private static void validatePeriod(LocalDateTime from, LocalDateTime to) {
|
||||
if (to != null && !to.isAfter(from)) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "有效期结束时间必须晚于开始时间");
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean overlaps(LocalDateTime aFrom, LocalDateTime aTo,
|
||||
LocalDateTime bFrom, LocalDateTime bTo) {
|
||||
return (aTo == null || bFrom.isBefore(aTo)) && (bTo == null || aFrom.isBefore(bTo));
|
||||
}
|
||||
|
||||
private static BizException conflict(String message) {
|
||||
return new BizException(ResultCode.CONFLICT, message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
package com.baiyue.firstleg.service;
|
||||
|
||||
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.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.repository.FbaPricingRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.beans.BeanUtils;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.Clock;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
|
||||
/** FBA 费用试算、确认和发送状态机。 */
|
||||
@Service
|
||||
public class FbaPricingService {
|
||||
private static final int MONEY_SCALE = 2;
|
||||
private final FbaPricingRepository repository;
|
||||
private final Clock clock;
|
||||
|
||||
public FbaPricingService(FbaPricingRepository repository) {
|
||||
this(repository, Clock.systemDefaultZone());
|
||||
}
|
||||
|
||||
FbaPricingService(FbaPricingRepository repository, Clock clock) {
|
||||
this.repository = repository;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
@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(), "FEE_CONFIRMING");
|
||||
|
||||
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("客户规则换算系数或精度无效");
|
||||
}
|
||||
|
||||
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 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,
|
||||
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, rule.getChargeMode(), chargeable) != 1) {
|
||||
throw conflict("入库单状态已变化,费用试算未保存");
|
||||
}
|
||||
complete(token, fee.getId(), now);
|
||||
return response(inbound, fee, rate.getUnitPrice(), "FEE_CONFIRMING");
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public FeeSnapshotResponse confirm(long tenantId, long operatorId, long inboundId,
|
||||
String idempotencyKey) {
|
||||
InboundFeeEntity current = repository.findLatestFee(tenantId, inboundId);
|
||||
if (current == null) throw conflict("入库单尚无可确认的费用试算");
|
||||
String hash = hash("CONFIRM", inboundId, current.getId().toString());
|
||||
InboundPricingIdempotencyEntity token = beginIdempotency(
|
||||
tenantId, operatorId, inboundId, "FEE_CONFIRM", idempotencyKey, hash);
|
||||
if ("COMPLETED".equals(token.getStatus())) return response(tenantId, token.getResultResourceId(), "FEE_CONFIRMING");
|
||||
LocalDateTime now = LocalDateTime.now(clock);
|
||||
if (repository.confirmFee(tenantId, current.getId(), operatorId, now) != 1
|
||||
|| repository.markInboundConfirmed(tenantId, inboundId) != 1) {
|
||||
throw conflict("费用或入库单状态已变化,不能重复确认");
|
||||
}
|
||||
complete(token, current.getId(), now);
|
||||
return response(tenantId, current.getId(), "FEE_CONFIRMING");
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public FeeSnapshotResponse send(long tenantId, long operatorId, long inboundId,
|
||||
String idempotencyKey) {
|
||||
InboundPricingEntity inbound = requireApprovedInbound(tenantId, inboundId);
|
||||
InboundFeeEntity current = repository.findLatestFee(tenantId, inboundId);
|
||||
if (current == null) throw conflict("入库单尚无费用快照");
|
||||
String hash = hash("SEND", inboundId, current.getId().toString());
|
||||
InboundPricingIdempotencyEntity token = beginIdempotency(
|
||||
tenantId, operatorId, inboundId, "FEE_SEND", idempotencyKey, hash);
|
||||
if ("COMPLETED".equals(token.getStatus())) return response(tenantId, token.getResultResourceId(), "WAIT_PALLET");
|
||||
LocalDateTime now = LocalDateTime.now(clock);
|
||||
if (repository.sendFee(tenantId, current.getId(), operatorId, now) != 1
|
||||
|| repository.markInboundWaitingPallet(tenantId, inboundId) != 1) {
|
||||
throw conflict("费用未确认或入库单状态已变化,不能发送");
|
||||
}
|
||||
InboundOutboxEntity event = new InboundOutboxEntity();
|
||||
event.setTenantId(tenantId);
|
||||
event.setAggregateType("INBOUND");
|
||||
event.setAggregateId(inboundId);
|
||||
event.setEventType("FIRSTLEG_FEE_SENT");
|
||||
event.setPayloadJson("{\"inboundId\":\"" + inboundId + "\",\"feeId\":\"" + current.getId() + "\"}");
|
||||
event.setPublishStatus("PENDING");
|
||||
event.setRetryCount(0);
|
||||
event.setCreatedAt(now);
|
||||
repository.insertOutbox(event);
|
||||
complete(token, current.getId(), now);
|
||||
return response(tenantId, current.getId(), "WAIT_PALLET");
|
||||
}
|
||||
|
||||
public FeeSnapshotResponse latest(long tenantId, long inboundId) {
|
||||
InboundFeeEntity fee = repository.findLatestFee(tenantId, inboundId);
|
||||
if (fee == null) throw new BizException(ResultCode.NOT_FOUND, "费用快照不存在");
|
||||
return response(tenantId, fee.getId(), null);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public FeeSnapshotResponse adjustPublished(long tenantId, long operatorId, long inboundId,
|
||||
AdjustFeeRequest request, String idempotencyKey) {
|
||||
InboundPricingEntity inbound = repository.findInbound(tenantId, inboundId);
|
||||
if (inbound == null) throw new BizException(ResultCode.NOT_FOUND, "入库单不存在");
|
||||
if (!"INBOUNDED".equals(inbound.getProcessStatus()) || !"SENT".equals(inbound.getFeeStatus())) {
|
||||
throw conflict("只能修改已完成入库且已发布的费用");
|
||||
}
|
||||
InboundFeeEntity current = repository.findLatestFee(tenantId, inboundId);
|
||||
if (current == null || !"SENT".equals(current.getFeeStatus())) {
|
||||
throw conflict("入库单没有可修改的已发布费用");
|
||||
}
|
||||
String discriminator = current.getId() + ":" + request.subtotalAmount() + ":"
|
||||
+ request.additionalAmount() + ":" + request.reason().trim();
|
||||
InboundPricingIdempotencyEntity token = beginIdempotency(tenantId, operatorId, inboundId,
|
||||
"FEE_ADJUST", idempotencyKey, hash("ADJUST", inboundId, discriminator));
|
||||
if ("COMPLETED".equals(token.getStatus())) {
|
||||
return response(tenantId, token.getResultResourceId(), "INBOUNDED");
|
||||
}
|
||||
|
||||
LocalDateTime now = LocalDateTime.now(clock);
|
||||
int nextVersion = repository.nextFeeVersion(tenantId, inboundId);
|
||||
InboundFeeEntity adjusted = new InboundFeeEntity();
|
||||
BeanUtils.copyProperties(current, adjusted);
|
||||
adjusted.setId(null);
|
||||
adjusted.setFeeNo("FBAF-" + inboundId + "-" + nextVersion);
|
||||
adjusted.setFeeVersion(nextVersion);
|
||||
adjusted.setSubtotalAmount(request.subtotalAmount().setScale(MONEY_SCALE, RoundingMode.HALF_UP));
|
||||
adjusted.setAdditionalAmount(request.additionalAmount().setScale(MONEY_SCALE, RoundingMode.HALF_UP));
|
||||
adjusted.setTotalAmount(adjusted.getSubtotalAmount().add(adjusted.getAdditionalAmount()));
|
||||
adjusted.setFeeStatus("SENT");
|
||||
adjusted.setReviewStatus("NOT_REQUIRED");
|
||||
adjusted.setCalculatedBy(operatorId);
|
||||
adjusted.setCalculatedAt(now);
|
||||
adjusted.setConfirmedBy(operatorId);
|
||||
adjusted.setConfirmedAt(now);
|
||||
adjusted.setSentBy(operatorId);
|
||||
adjusted.setSentAt(now);
|
||||
adjusted.setCreatedAt(now);
|
||||
adjusted.setUpdatedAt(now);
|
||||
if (repository.supersedeSent(tenantId, current.getId()) != 1) {
|
||||
throw conflict("费用已被其他操作员修改,请刷新后重试");
|
||||
}
|
||||
repository.insertFee(adjusted);
|
||||
BigDecimal quantity = current.getChargeableQuantity();
|
||||
InboundFeeItemEntity base = item(tenantId, adjusted, 1, "FBA_BASE", "FBA基础运费",
|
||||
adjusted.getSubtotalAmount(), now);
|
||||
base.setQuantity(quantity);
|
||||
base.setUnit(current.getChargeUnit());
|
||||
base.setUnitPrice(quantity.signum() == 0 ? BigDecimal.ZERO
|
||||
: adjusted.getSubtotalAmount().divide(quantity, 6, RoundingMode.HALF_UP));
|
||||
base.setAutomaticFlag(false);
|
||||
base.setRemark(request.reason().trim());
|
||||
repository.insertItem(base);
|
||||
if (adjusted.getAdditionalAmount().signum() > 0) {
|
||||
InboundFeeItemEntity extra = additionalItem(tenantId, adjusted,
|
||||
adjusted.getAdditionalAmount(), now);
|
||||
extra.setAutomaticFlag(false);
|
||||
extra.setRemark(request.reason().trim());
|
||||
repository.insertItem(extra);
|
||||
}
|
||||
InboundOutboxEntity event = new InboundOutboxEntity();
|
||||
event.setTenantId(tenantId);
|
||||
event.setAggregateType("INBOUND");
|
||||
event.setAggregateId(inboundId);
|
||||
event.setEventType("FIRSTLEG_FEE_ADJUSTED");
|
||||
event.setPayloadJson("{\"inboundId\":\"" + inboundId + "\",\"feeId\":\""
|
||||
+ adjusted.getId() + "\",\"feeVersion\":" + nextVersion + "}");
|
||||
event.setPublishStatus("PENDING");
|
||||
event.setRetryCount(0);
|
||||
event.setCreatedAt(now);
|
||||
repository.insertOutbox(event);
|
||||
complete(token, adjusted.getId(), now);
|
||||
return response(tenantId, adjusted.getId(), "INBOUNDED");
|
||||
}
|
||||
|
||||
private InboundPricingEntity requireEligibleInbound(long tenantId, long inboundId) {
|
||||
InboundPricingEntity inbound = requireApprovedInbound(tenantId, inboundId);
|
||||
if (!List.of("WAIT_PRICING", "FEE_CONFIRMING").contains(inbound.getProcessStatus())
|
||||
|| !List.of("UNPRICED", "CALCULATED").contains(inbound.getFeeStatus())) {
|
||||
throw conflict("当前入库单状态不允许费用试算");
|
||||
}
|
||||
return inbound;
|
||||
}
|
||||
|
||||
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 (!"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 仓匹配维度");
|
||||
}
|
||||
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 InboundPricingIdempotencyEntity beginIdempotency(long tenantId, long operatorId,
|
||||
long inboundId, String operation,
|
||||
String key, String hash) {
|
||||
if (!StringUtils.hasText(key) || key.length() > 128) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "Idempotency-Key 必填且不能超过128字符");
|
||||
}
|
||||
InboundPricingIdempotencyEntity existing = repository.findIdempotency(tenantId, operatorId, operation, key);
|
||||
if (existing != null) {
|
||||
if (!hash.equals(existing.getRequestHash())) throw conflict("幂等键已用于不同请求");
|
||||
if (!"COMPLETED".equals(existing.getStatus())) throw conflict("相同请求正在处理中");
|
||||
return existing;
|
||||
}
|
||||
InboundPricingIdempotencyEntity value = new InboundPricingIdempotencyEntity();
|
||||
value.setTenantId(tenantId);
|
||||
value.setOperation(operation);
|
||||
value.setOperatorId(operatorId);
|
||||
value.setInboundId(inboundId);
|
||||
value.setIdempotencyKey(key);
|
||||
value.setRequestHash(hash);
|
||||
value.setStatus("PROCESSING");
|
||||
value.setCreatedAt(LocalDateTime.now(clock));
|
||||
value.setUpdatedAt(LocalDateTime.now(clock));
|
||||
repository.insertIdempotency(value);
|
||||
return value;
|
||||
}
|
||||
|
||||
private void complete(InboundPricingIdempotencyEntity token, long resourceId, LocalDateTime now) {
|
||||
if (repository.completeIdempotency(token, resourceId, now) != 1) throw conflict("幂等记录状态已变化");
|
||||
}
|
||||
|
||||
private InboundFeeEntity snapshot(long tenantId, long operatorId, InboundPricingEntity inbound,
|
||||
CustomerChargeRuleEntity rule, CarrierFbaRateEntity 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.setCarrierRateId(rate.getId()); fee.setCarrierRateVersion(rate.getRateVersion());
|
||||
fee.setRuleCode(rule.getRuleCode()); 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.setOriginWarehouseId(rate.getOriginWarehouseId()); fee.setOriginWarehouseName(rate.getOriginWarehouseName());
|
||||
fee.setChannelCode(rate.getChannelCode()); fee.setCarrierId(rate.getCarrierId());
|
||||
fee.setCarrierName(rate.getCarrierName()); fee.setDestinationCode(rate.getDestinationCode());
|
||||
fee.setRateEffectiveFrom(rate.getEffectiveFrom()); fee.setRateEffectiveTo(rate.getEffectiveTo());
|
||||
fee.setChargeMode(rule.getChargeMode()); fee.setConversionFactor(rule.getConversionFactor());
|
||||
fee.setActualWeightKg(weight); fee.setActualVolumeCbm(volume); fee.setConvertedQuantity(converted);
|
||||
fee.setChargeableQuantity(chargeable); fee.setChargeUnit(rule.getChargeMode()); 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,
|
||||
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());
|
||||
return item;
|
||||
}
|
||||
|
||||
private InboundFeeItemEntity additionalItem(long tenantId, InboundFeeEntity fee,
|
||||
BigDecimal amount, LocalDateTime now) {
|
||||
InboundFeeItemEntity item = item(tenantId, fee, 2, "FBA_ADDITIONAL", "FBA固定附加费", amount, now);
|
||||
item.setQuantity(BigDecimal.ONE.setScale(4)); item.setUnit("ORDER"); item.setUnitPrice(amount);
|
||||
return item;
|
||||
}
|
||||
|
||||
private InboundFeeItemEntity item(long tenantId, InboundFeeEntity fee, int no, String code,
|
||||
String name, BigDecimal amount, LocalDateTime now) {
|
||||
InboundFeeItemEntity item = new InboundFeeItemEntity();
|
||||
item.setTenantId(tenantId); item.setFeeId(fee.getId()); item.setItemNo(no);
|
||||
item.setFeeCode(code); item.setFeeName(name); item.setAmount(amount); item.setCurrency(fee.getCurrency());
|
||||
item.setAutomaticFlag(true); item.setCreatedAt(now); return item;
|
||||
}
|
||||
|
||||
private FeeSnapshotResponse response(long tenantId, long feeId, String nextStatus) {
|
||||
InboundFeeEntity fee = repository.findFee(tenantId, feeId);
|
||||
if (fee == null) throw new BizException(ResultCode.NOT_FOUND, "费用快照不存在");
|
||||
InboundPricingEntity inbound = repository.findInbound(tenantId, fee.getInboundId());
|
||||
InboundFeeItemEntity base = repository.findBaseItem(tenantId, feeId);
|
||||
String actualStatus = nextStatus == null && inbound != null
|
||||
? inbound.getProcessStatus() : nextStatus;
|
||||
return response(inbound, fee, base == null ? null : base.getUnitPrice(), actualStatus);
|
||||
}
|
||||
|
||||
private FeeSnapshotResponse response(InboundPricingEntity inbound, InboundFeeEntity fee,
|
||||
BigDecimal unitPrice, String nextStatus) {
|
||||
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.getCustomerId(), fee.getCustomerName(), fee.getOriginWarehouseId(), fee.getOriginWarehouseName(),
|
||||
fee.getBusinessType(), fee.getChannelCode(), fee.getCarrierId(), fee.getCarrierName(),
|
||||
fee.getDestinationCode(), fee.getTaxMode(), 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(),
|
||||
fee.getRateEffectiveFrom(), fee.getRateEffectiveTo(), fee.getCalculatedAt(), fee.getConfirmedAt(),
|
||||
fee.getSentAt(), nextStatus);
|
||||
}
|
||||
|
||||
private static String hash(String operation, long inboundId, String discriminator) {
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
return HexFormat.of().formatHex(digest.digest(
|
||||
(operation + ":" + inboundId + ":" + discriminator).getBytes(StandardCharsets.UTF_8)));
|
||||
} catch (NoSuchAlgorithmException exception) {
|
||||
throw new IllegalStateException("JVM不支持SHA-256", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static BizException conflict(String message) {
|
||||
return new BizException(ResultCode.CONFLICT, message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.baiyue.firstleg.service;
|
||||
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
|
||||
/** 头程平台接口租户边界校验。 */
|
||||
public final class FirstlegScopeGuard {
|
||||
private FirstlegScopeGuard() {}
|
||||
|
||||
public static long platform(AuthenticatedUser user, long targetTenantId) {
|
||||
if (user == null || !"PLATFORM".equals(user.appCode())) {
|
||||
throw new BizException(ResultCode.FORBIDDEN, "仅允许平台端访问");
|
||||
}
|
||||
if (targetTenantId <= 0) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "必须指定有效目标租户");
|
||||
}
|
||||
return targetTenantId;
|
||||
}
|
||||
}
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
package com.baiyue.firstleg.service;
|
||||
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.entity.InboundFeeEntity;
|
||||
import com.baiyue.firstleg.entity.InboundOrderEntity;
|
||||
import com.baiyue.firstleg.entity.InboundOutboxEntity;
|
||||
import com.baiyue.firstleg.repository.InboundWorkflowRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 发送费用并完成入库的状态编排骨架。
|
||||
*
|
||||
* <p>本服务不计算费用、不创建托盘/库位,也不调用仓库 CRUD。测量完成阶段已确认托盘和库位占用;
|
||||
* 客服审核通过后的自动编排调用 {@link #beginSendFeeAndInbound} 进入 INBOUNDING,再调用
|
||||
* {@link #completeSendFeeAndInbound} 发布费用并完成入库。</p>
|
||||
*/
|
||||
@Service
|
||||
public class InboundCompletionOrchestrator {
|
||||
private final InboundWorkflowRepository repository;
|
||||
private final InboundIdempotencyService idempotency;
|
||||
|
||||
public InboundCompletionOrchestrator(InboundWorkflowRepository repository,
|
||||
InboundIdempotencyService idempotency) {
|
||||
this.repository = repository;
|
||||
this.idempotency = idempotency;
|
||||
}
|
||||
|
||||
/**
|
||||
* 任务5对接方法一:锁定发送动作。允许前置状态仅为 FEE_CONFIRMING + CONFIRMED。
|
||||
*/
|
||||
@Transactional
|
||||
public void beginSendFeeAndInbound(long tenantId, long operatorId, String operatorName,
|
||||
long inboundId, long feeId, int expectedVersion,
|
||||
String idempotencyKey) {
|
||||
String canonical = inboundId + "|" + feeId + "|" + expectedVersion;
|
||||
InboundIdempotencyService.Context context = idempotency.begin(
|
||||
tenantId, operatorId, inboundId, "SEND_FEE_BEGIN", idempotencyKey, canonical);
|
||||
if (context.replayed()) {
|
||||
return;
|
||||
}
|
||||
InboundOrderEntity order = requireOrder(tenantId, inboundId);
|
||||
if (!"FEE_CONFIRMING".equals(order.getProcessStatus()) || !"CONFIRMED".equals(order.getFeeStatus())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "费用未确认或入库单不在待发送阶段");
|
||||
}
|
||||
boolean released = measurementGatePassed(order, tenantId, inboundId);
|
||||
InboundFeeEntity fee = repository.findConfirmedFee(tenantId, inboundId, feeId);
|
||||
if (fee == null) {
|
||||
throw new BizException(ResultCode.CONFLICT, "缺少已确认且不可变的费用快照");
|
||||
}
|
||||
if (Boolean.TRUE.equals(fee.getExceptionRelease()) != released) {
|
||||
throw new BizException(ResultCode.CONFLICT, "费用快照的异常放行标记与审核历史不一致");
|
||||
}
|
||||
if (repository.countPreparedAllocations(tenantId, inboundId) <= 0) {
|
||||
throw new BizException(ResultCode.CONFLICT, "尚未准备托盘和库位预占引用");
|
||||
}
|
||||
if (repository.beginInbound(tenantId, inboundId, operatorId, expectedVersion) != 1) {
|
||||
throw concurrentConflict();
|
||||
}
|
||||
repository.insertHistory(MeasurementReviewService.history(tenantId, inboundId,
|
||||
"FEE_CONFIRMING", "INBOUNDING", "SEND_FEE_BEGIN", null, operatorId, operatorName));
|
||||
idempotency.complete(context, inboundId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 任务5对接方法二:仓库确认占用成功后,原子锁定费用、完成入库并写 Outbox。
|
||||
*/
|
||||
@Transactional
|
||||
public void completeSendFeeAndInbound(long tenantId, long operatorId, String operatorName,
|
||||
long inboundId, long feeId, int expectedVersion,
|
||||
String idempotencyKey) {
|
||||
String canonical = inboundId + "|" + feeId + "|" + expectedVersion;
|
||||
InboundIdempotencyService.Context context = idempotency.begin(
|
||||
tenantId, operatorId, inboundId, "SEND_FEE_COMPLETE", idempotencyKey, canonical);
|
||||
if (context.replayed()) {
|
||||
return;
|
||||
}
|
||||
InboundOrderEntity order = requireOrder(tenantId, inboundId);
|
||||
if (!"INBOUNDING".equals(order.getProcessStatus())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "入库单不在入库处理中");
|
||||
}
|
||||
if (repository.completeInbound(tenantId, inboundId, operatorId, expectedVersion) != 1) {
|
||||
throw concurrentConflict();
|
||||
}
|
||||
if (repository.markFeeSent(tenantId, inboundId, feeId, operatorId) != 1) {
|
||||
throw new BizException(ResultCode.CONFLICT, "费用快照已被并发发送或状态不正确");
|
||||
}
|
||||
repository.insertHistory(MeasurementReviewService.history(tenantId, inboundId,
|
||||
"INBOUNDING", "INBOUNDED", "SEND_FEE_COMPLETE", null, operatorId, operatorName));
|
||||
repository.insertOutbox(completedEvent(tenantId, inboundId, feeId));
|
||||
idempotency.complete(context, inboundId);
|
||||
}
|
||||
|
||||
/** 任务5对接方法三:仅在仓库预占已释放后回退,不能回退已完成实物入库。 */
|
||||
@Transactional
|
||||
public void rollbackSendFeeAndInbound(long tenantId, long operatorId, String operatorName,
|
||||
long inboundId, int expectedVersion, String reason,
|
||||
String idempotencyKey) {
|
||||
if (!StringUtils.hasText(reason)) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED, "入库回退必须记录失败原因");
|
||||
}
|
||||
String canonical = inboundId + "|" + expectedVersion + "|" + (reason == null ? "" : reason.trim());
|
||||
InboundIdempotencyService.Context context = idempotency.begin(
|
||||
tenantId, operatorId, inboundId, "SEND_FEE_ROLLBACK", idempotencyKey, canonical);
|
||||
if (context.replayed()) {
|
||||
return;
|
||||
}
|
||||
requireOrder(tenantId, inboundId);
|
||||
if (repository.rollbackInbound(tenantId, inboundId, operatorId, expectedVersion) != 1) {
|
||||
throw concurrentConflict();
|
||||
}
|
||||
repository.insertHistory(MeasurementReviewService.history(tenantId, inboundId,
|
||||
"INBOUNDING", "FEE_CONFIRMING", "SEND_FEE_ROLLBACK", reason, operatorId, operatorName));
|
||||
idempotency.complete(context, inboundId);
|
||||
}
|
||||
|
||||
private boolean measurementGatePassed(InboundOrderEntity order, long tenantId, long inboundId) {
|
||||
if ("APPROVED".equals(order.getMeasureReviewStatus())) {
|
||||
return false;
|
||||
}
|
||||
if ("REJECTED".equals(order.getMeasureReviewStatus())
|
||||
&& repository.hasExceptionRelease(tenantId, inboundId)) {
|
||||
return true;
|
||||
}
|
||||
throw new BizException(ResultCode.CONFLICT, "测量未审核通过且无有效异常放行记录");
|
||||
}
|
||||
|
||||
private InboundOrderEntity requireOrder(long tenantId, long inboundId) {
|
||||
InboundOrderEntity order = repository.findOwned(tenantId, inboundId);
|
||||
if (order == null) {
|
||||
throw new BizException(ResultCode.NOT_FOUND, "入库单不存在");
|
||||
}
|
||||
return order;
|
||||
}
|
||||
|
||||
private static InboundOutboxEntity completedEvent(long tenantId, long inboundId, long feeId) {
|
||||
InboundOutboxEntity event = new InboundOutboxEntity();
|
||||
event.setTenantId(tenantId);
|
||||
event.setAggregateType("INBOUND");
|
||||
event.setAggregateId(inboundId);
|
||||
event.setEventType("FIRSTLEG_INBOUND_FEE_SENT");
|
||||
event.setPayloadJson("{\"inboundId\":\"" + inboundId + "\",\"feeId\":\"" + feeId + "\"}");
|
||||
event.setPublishStatus("PENDING");
|
||||
event.setRetryCount(0);
|
||||
event.setCreatedAt(LocalDateTime.now());
|
||||
return event;
|
||||
}
|
||||
|
||||
private static BizException concurrentConflict() {
|
||||
return new BizException(ResultCode.CONFLICT, "入库单已被并发更新,请刷新后重试");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package com.baiyue.firstleg.service;
|
||||
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.entity.InboundIdempotencyEntity;
|
||||
import com.baiyue.firstleg.repository.InboundWorkflowRepository;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HexFormat;
|
||||
|
||||
/** 入库写操作的统一持久化幂等边界。 */
|
||||
@Service
|
||||
public class InboundIdempotencyService {
|
||||
private final InboundWorkflowRepository repository;
|
||||
|
||||
public InboundIdempotencyService(InboundWorkflowRepository repository) {
|
||||
this.repository = repository;
|
||||
}
|
||||
|
||||
public Context begin(long tenantId, long operatorId, long inboundId,
|
||||
String operation, String idempotencyKey, String canonicalRequest) {
|
||||
String key = idempotencyKey == null ? null : idempotencyKey.trim();
|
||||
if (!StringUtils.hasText(key)) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "必须提供 Idempotency-Key");
|
||||
}
|
||||
if (key.length() > 128) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "Idempotency-Key 长度不能超过128");
|
||||
}
|
||||
String hash = sha256(canonicalRequest);
|
||||
InboundIdempotencyEntity record = new InboundIdempotencyEntity();
|
||||
record.setTenantId(tenantId);
|
||||
record.setOperation(operation);
|
||||
record.setOperatorId(operatorId);
|
||||
record.setInboundId(inboundId);
|
||||
record.setIdempotencyKey(key);
|
||||
record.setRequestHash(hash);
|
||||
record.setStatus("PROCESSING");
|
||||
record.setCreatedAt(LocalDateTime.now());
|
||||
record.setUpdatedAt(record.getCreatedAt());
|
||||
try {
|
||||
repository.insertIdempotency(record);
|
||||
return new Context(record.getId(), false);
|
||||
} catch (DuplicateKeyException duplicate) {
|
||||
InboundIdempotencyEntity existing = repository.findIdempotency(
|
||||
tenantId, operation, operatorId, key);
|
||||
if (existing == null || !hash.equals(existing.getRequestHash())) {
|
||||
throw new BizException(ResultCode.CONFLICT,
|
||||
"Idempotency-Key 已用于不同请求");
|
||||
}
|
||||
if ("SUCCESS".equals(existing.getStatus())) {
|
||||
return new Context(existing.getId(), true);
|
||||
}
|
||||
throw new BizException(ResultCode.CONFLICT, "相同请求正在处理中,请稍后重试");
|
||||
}
|
||||
}
|
||||
|
||||
public void complete(Context context, long inboundId) {
|
||||
if (!context.replayed()
|
||||
&& repository.completeIdempotency(context.id(), "{\"inboundId\":\"" + inboundId + "\"}") != 1) {
|
||||
throw new BizException(ResultCode.CONFLICT, "幂等记录已被并发修改");
|
||||
}
|
||||
}
|
||||
|
||||
private static String sha256(String value) {
|
||||
try {
|
||||
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
|
||||
.digest(value.getBytes(StandardCharsets.UTF_8)));
|
||||
} catch (NoSuchAlgorithmException impossible) {
|
||||
throw new IllegalStateException("JVM 不支持 SHA-256", impossible);
|
||||
}
|
||||
}
|
||||
|
||||
public record Context(long id, boolean replayed) {
|
||||
}
|
||||
}
|
||||
+236
@@ -0,0 +1,236 @@
|
||||
package com.baiyue.firstleg.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.dto.OpenApiMeasurementDtos.CallbackRequest;
|
||||
import com.baiyue.firstleg.dto.OpenApiMeasurementDtos.EventResponse;
|
||||
import com.baiyue.firstleg.dto.OpenApiMeasurementDtos.Item;
|
||||
import com.baiyue.firstleg.entity.InboundMeasurementEntity;
|
||||
import com.baiyue.firstleg.entity.InboundMeasurementItemEntity;
|
||||
import com.baiyue.firstleg.entity.InboundOrderLookupEntity;
|
||||
import com.baiyue.firstleg.repository.InboundMeasurementOpenApiRepository;
|
||||
import com.baiyue.firstleg.security.AuthenticatedMeasurementApp;
|
||||
import com.baiyue.firstleg.security.MeasurementRequestAuthenticator;
|
||||
import com.baiyue.firstleg.security.MeasurementRequestHeaders;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.validation.ConstraintViolation;
|
||||
import jakarta.validation.Validator;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 第三方测量接收编排。只写调用日志、测量批次和明细,不提交审核且不更新入库主单。
|
||||
*/
|
||||
@Service
|
||||
public class InboundMeasurementOpenApiService {
|
||||
static final String CALLBACK_PATH = "/openapi/v1/inbound-measurements";
|
||||
private static final Set<String> ACCEPTABLE_INBOUND_STATUSES = Set.of("WAIT_MEASURE", "MEASURE_REJECTED");
|
||||
private static final BigDecimal CUBIC_CENTIMETERS_PER_CBM = new BigDecimal("1000000");
|
||||
private static final String CALCULATION_RULE_VERSION = "V1_CEILING_4";
|
||||
|
||||
private final InboundMeasurementOpenApiRepository repository;
|
||||
private final MeasurementRequestAuthenticator authenticator;
|
||||
private final ThirdPartyCallLogService callLogService;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final Validator validator;
|
||||
private final TransactionTemplate transactionTemplate;
|
||||
|
||||
public InboundMeasurementOpenApiService(InboundMeasurementOpenApiRepository repository,
|
||||
MeasurementRequestAuthenticator authenticator,
|
||||
ThirdPartyCallLogService callLogService,
|
||||
ObjectMapper objectMapper, Validator validator,
|
||||
TransactionTemplate transactionTemplate) {
|
||||
this.repository = repository;
|
||||
this.authenticator = authenticator;
|
||||
this.callLogService = callLogService;
|
||||
this.objectMapper = objectMapper;
|
||||
this.validator = validator;
|
||||
this.transactionTemplate = transactionTemplate;
|
||||
}
|
||||
|
||||
public EventResponse receive(MeasurementRequestHeaders headers, String rawBody) {
|
||||
long logId = callLogService.start("POST", CALLBACK_PATH, headers, rawBody);
|
||||
try {
|
||||
AuthenticatedMeasurementApp app = authenticator.authenticate(
|
||||
"POST", CALLBACK_PATH, headers, rawBody, true);
|
||||
CallbackRequest request = parseAndValidate(rawBody);
|
||||
String requestHash = MeasurementRequestAuthenticator.sha256Hex(rawBody);
|
||||
if (!callLogService.claimIdempotency(logId)) {
|
||||
EventResponse replay = replayIdempotency(app, headers.idempotencyKey(), requestHash);
|
||||
callLogService.complete(logId, "ACCEPTED", ResultCode.SUCCESS.code(), "duplicate", writeJson(replay));
|
||||
return replay;
|
||||
}
|
||||
EventResponse result = transactionTemplate.execute(status ->
|
||||
persist(app, request, rawBody, requestHash));
|
||||
String resultJson = writeJson(result);
|
||||
callLogService.complete(logId, "ACCEPTED", ResultCode.SUCCESS.code(), "success", resultJson);
|
||||
return result;
|
||||
} catch (RuntimeException exception) {
|
||||
ResultCode code = exception instanceof BizException biz ? biz.getResultCode() : ResultCode.INTERNAL_ERROR;
|
||||
callLogService.complete(logId, "REJECTED", code.code(), exception.getMessage(), null);
|
||||
throw exception;
|
||||
}
|
||||
}
|
||||
|
||||
public EventResponse query(String eventNo, MeasurementRequestHeaders headers) {
|
||||
String path = CALLBACK_PATH + "/events/" + eventNo;
|
||||
long logId = callLogService.start("GET", path, headers, "");
|
||||
try {
|
||||
AuthenticatedMeasurementApp app = authenticator.authenticate("GET", path, headers, "", false);
|
||||
InboundMeasurementEntity measurement = repository.findEvent(app.tenantId(), app.appKey(), eventNo);
|
||||
if (measurement == null) {
|
||||
throw new BizException(ResultCode.NOT_FOUND, "第三方测量事件不存在");
|
||||
}
|
||||
InboundOrderLookupEntity inbound = repository.findInbound(app.tenantId(), measurement.getInboundId());
|
||||
if (inbound == null) {
|
||||
throw new BizException(ResultCode.NOT_FOUND, "入仓码对应的入库单不存在");
|
||||
}
|
||||
EventResponse result = response(measurement, inbound.getInboundCode());
|
||||
callLogService.complete(logId, "ACCEPTED", ResultCode.SUCCESS.code(), "success", writeJson(result));
|
||||
return result;
|
||||
} catch (RuntimeException exception) {
|
||||
ResultCode code = exception instanceof BizException biz ? biz.getResultCode() : ResultCode.INTERNAL_ERROR;
|
||||
callLogService.complete(logId, "REJECTED", code.code(), exception.getMessage(), null);
|
||||
throw exception;
|
||||
}
|
||||
}
|
||||
|
||||
private EventResponse persist(AuthenticatedMeasurementApp app,
|
||||
CallbackRequest request, String rawBody, String requestHash) {
|
||||
InboundMeasurementEntity existingEvent = repository.findEvent(
|
||||
app.tenantId(), app.appKey(), request.eventNo());
|
||||
if (existingEvent != null) {
|
||||
if (!MeasurementRequestAuthenticator.sha256Hex(existingEvent.getRawPayloadJson()).equals(requestHash)) {
|
||||
throw new BizException(ResultCode.CONFLICT, "同一 appKey 和 eventNo 的报文内容不一致");
|
||||
}
|
||||
InboundOrderLookupEntity inbound = repository.findInbound(app.tenantId(), existingEvent.getInboundId());
|
||||
EventResponse result = response(existingEvent, inbound == null ? request.inboundCode() : inbound.getInboundCode());
|
||||
return result;
|
||||
}
|
||||
|
||||
Long boundWarehouseId = app.warehouseBindings().get(request.warehouseCode());
|
||||
if (boundWarehouseId == null) {
|
||||
throw new BizException(ResultCode.FORBIDDEN, "应用未绑定请求中的仓库编码");
|
||||
}
|
||||
InboundOrderLookupEntity inbound = repository.findInbound(app.tenantId(), request.inboundCode());
|
||||
if (inbound == null || !boundWarehouseId.equals(inbound.getWarehouseId())) {
|
||||
throw new BizException(ResultCode.NOT_FOUND, "入仓码不存在或不属于绑定仓库");
|
||||
}
|
||||
if (!ACCEPTABLE_INBOUND_STATUSES.contains(inbound.getProcessStatus())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "当前入库单状态不允许接收测量结果");
|
||||
}
|
||||
|
||||
InboundMeasurementEntity measurement = buildMeasurement(app, request, rawBody, inbound.getId());
|
||||
repository.insertMeasurement(measurement);
|
||||
for (Item item : request.items()) {
|
||||
repository.insertItem(buildItem(app.tenantId(), measurement.getId(), item));
|
||||
}
|
||||
EventResponse result = response(measurement, request.inboundCode());
|
||||
return result;
|
||||
}
|
||||
|
||||
private InboundMeasurementEntity buildMeasurement(AuthenticatedMeasurementApp app,
|
||||
CallbackRequest request, String rawBody, long inboundId) {
|
||||
InboundMeasurementEntity measurement = new InboundMeasurementEntity();
|
||||
measurement.setId(IdWorker.getId());
|
||||
measurement.setTenantId(app.tenantId());
|
||||
measurement.setInboundId(inboundId);
|
||||
measurement.setSourceType("THIRD_PARTY");
|
||||
measurement.setSourceCode(app.appKey());
|
||||
measurement.setExternalEventNo(request.eventNo());
|
||||
measurement.setDeviceCode(request.deviceCode());
|
||||
measurement.setWarehouseCode(request.warehouseCode());
|
||||
measurement.setMeasuredAt(request.measuredAt().withOffsetSameInstant(ZoneOffset.UTC).toLocalDateTime());
|
||||
measurement.setStatus("RECEIVED");
|
||||
measurement.setRawPayloadJson(rawBody);
|
||||
measurement.setTotalPackageCount(request.items().stream().mapToInt(Item::quantity).sum());
|
||||
BigDecimal rawTotal = request.items().stream().map(this::rawVolume).reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
measurement.setTotalVolumeRawCbm(rawTotal.setScale(6, RoundingMode.HALF_UP));
|
||||
measurement.setTotalVolumeCbm(rawTotal.setScale(4, RoundingMode.CEILING));
|
||||
measurement.setCreatedBy(app.subjectId());
|
||||
measurement.setCreatedAt(LocalDateTime.now());
|
||||
measurement.setUpdatedAt(LocalDateTime.now());
|
||||
return measurement;
|
||||
}
|
||||
|
||||
private InboundMeasurementItemEntity buildItem(long tenantId, long measurementId, Item source) {
|
||||
BigDecimal rawVolume = rawVolume(source);
|
||||
InboundMeasurementItemEntity item = new InboundMeasurementItemEntity();
|
||||
item.setId(IdWorker.getId());
|
||||
item.setTenantId(tenantId);
|
||||
item.setMeasurementId(measurementId);
|
||||
item.setItemNo(source.itemNo());
|
||||
item.setBoxSpecification(org.springframework.util.StringUtils.hasText(source.boxSpecification())
|
||||
? source.boxSpecification().trim() : source.itemNo());
|
||||
item.setPackageCount(source.quantity());
|
||||
item.setLengthCm(source.lengthCm());
|
||||
item.setWidthCm(source.widthCm());
|
||||
item.setHeightCm(source.heightCm());
|
||||
item.setVolumeRawCbm(rawVolume.setScale(6, RoundingMode.HALF_UP));
|
||||
item.setVolumeCbm(rawVolume.setScale(4, RoundingMode.CEILING));
|
||||
item.setCalculationRuleVersion(CALCULATION_RULE_VERSION);
|
||||
item.setCreatedAt(LocalDateTime.now());
|
||||
return item;
|
||||
}
|
||||
|
||||
private BigDecimal rawVolume(Item item) {
|
||||
return item.lengthCm().multiply(item.widthCm()).multiply(item.heightCm())
|
||||
.multiply(BigDecimal.valueOf(item.quantity()))
|
||||
.divide(CUBIC_CENTIMETERS_PER_CBM, 12, RoundingMode.HALF_UP);
|
||||
}
|
||||
|
||||
private EventResponse replayIdempotency(AuthenticatedMeasurementApp app, String key, String requestHash) {
|
||||
var existing = callLogService.findIdempotencyOwner(app.tenantId(), app.appKey(), key);
|
||||
if (existing == null || "PROCESSING".equals(existing.getProcessingStatus())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "相同 Idempotency-Key 的请求正在处理");
|
||||
}
|
||||
if (!requestHash.equals(existing.getRequestHash())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "同一 Idempotency-Key 的报文内容不一致");
|
||||
}
|
||||
if (!"ACCEPTED".equals(existing.getProcessingStatus()) || existing.getResultJson() == null) {
|
||||
throw new BizException(ResultCode.CONFLICT, "同一 Idempotency-Key 已处理但未成功");
|
||||
}
|
||||
try {
|
||||
return objectMapper.readValue(existing.getResultJson(), EventResponse.class);
|
||||
} catch (JsonProcessingException exception) {
|
||||
throw new BizException(ResultCode.INTERNAL_ERROR, "幂等结果无法读取");
|
||||
}
|
||||
}
|
||||
|
||||
private CallbackRequest parseAndValidate(String rawBody) {
|
||||
try {
|
||||
CallbackRequest request = objectMapper.readValue(rawBody, CallbackRequest.class);
|
||||
Set<ConstraintViolation<CallbackRequest>> violations = validator.validate(request);
|
||||
if (!violations.isEmpty()) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED,
|
||||
violations.iterator().next().getPropertyPath() + " " + violations.iterator().next().getMessage());
|
||||
}
|
||||
return request;
|
||||
} catch (BizException exception) {
|
||||
throw exception;
|
||||
} catch (JsonProcessingException exception) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "请求 JSON 格式无效");
|
||||
}
|
||||
}
|
||||
|
||||
private EventResponse response(InboundMeasurementEntity measurement, String inboundCode) {
|
||||
return new EventResponse(measurement.getExternalEventNo(), inboundCode, true,
|
||||
measurement.getStatus(), measurement.getCreatedAt().atOffset(ZoneOffset.UTC));
|
||||
}
|
||||
|
||||
private String writeJson(Object value) {
|
||||
try {
|
||||
return objectMapper.writeValueAsString(value);
|
||||
} catch (JsonProcessingException exception) {
|
||||
throw new BizException(ResultCode.INTERNAL_ERROR, "处理结果无法序列化");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
package com.baiyue.firstleg.service;
|
||||
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.dto.InboundMeasurementRequests;
|
||||
import com.baiyue.firstleg.dto.InboundMeasurementResponses;
|
||||
import com.baiyue.firstleg.dto.MeasurementBatchCommand;
|
||||
import com.baiyue.firstleg.entity.InboundMeasurementEntity;
|
||||
import com.baiyue.firstleg.entity.InboundMeasurementItemEntity;
|
||||
import com.baiyue.firstleg.entity.InboundOrderEntity;
|
||||
import com.baiyue.firstleg.entity.InboundWeightResultEntity;
|
||||
import com.baiyue.firstleg.entity.InboundWeightRevisionEntity;
|
||||
import com.baiyue.firstleg.entity.MeasureReviewRecordEntity;
|
||||
import com.baiyue.firstleg.repository.InboundMeasurementRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
|
||||
/** 平台端入库测量、OCR 修正、重量确认和提交审核应用服务。 */
|
||||
@Service
|
||||
public class InboundMeasurementService {
|
||||
private static final String STATUS_SUBMITTED = "SUBMITTED";
|
||||
private static final String WEIGHT_CONFIRMED = "CONFIRMED";
|
||||
private static final String WEIGHT_PENDING_CONFIRM = "PENDING_CONFIRM";
|
||||
private static final String WEIGHT_NEEDS_MANUAL = "NEEDS_MANUAL";
|
||||
/** 测量完成后的默认普通托盘类型;实际拆托和库位占用由提交编排服务执行。 */
|
||||
private static final String RECOMMENDED_PALLET_TYPE = "STANDARD";
|
||||
private static final Set<String> EDITABLE_INBOUND_STATUSES =
|
||||
Set.of("WAIT_MEASURE", "MEASURING", "MEASURE_REJECTED");
|
||||
private static final BigDecimal LB_TO_KG = new BigDecimal("0.45359237");
|
||||
|
||||
private final InboundMeasurementRepository repository;
|
||||
private final MeasurementBatchService batchService;
|
||||
|
||||
public InboundMeasurementService(InboundMeasurementRepository repository,
|
||||
MeasurementBatchService batchService) {
|
||||
this.repository = repository;
|
||||
this.batchService = batchService;
|
||||
}
|
||||
|
||||
public InboundMeasurementResponses.Detail detail(long tenantId, long inboundId) {
|
||||
InboundOrderEntity inbound = requireInbound(tenantId, inboundId);
|
||||
InboundMeasurementEntity measurement = repository.findLatestMeasurement(tenantId, inboundId);
|
||||
List<InboundMeasurementItemEntity> items = measurement == null
|
||||
? List.of() : repository.listItems(tenantId, measurement.getId());
|
||||
List<InboundWeightResultEntity> weights = repository.listWeights(tenantId, inboundId);
|
||||
MeasureReviewRecordEntity review = repository.findLatestReview(tenantId, inboundId);
|
||||
return toDetail(inbound, measurement, items, weights, review);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public InboundMeasurementResponses.DraftSaved saveDraft(
|
||||
long tenantId, long inboundId, long operatorId,
|
||||
InboundMeasurementRequests.SaveDraftRequest request) {
|
||||
InboundOrderEntity inbound = requireInbound(tenantId, inboundId);
|
||||
requireEditable(inbound);
|
||||
MeasurementBatchCommand command = new MeasurementBatchCommand(
|
||||
tenantId, inboundId, request.measurementId(), request.version(),
|
||||
MeasurementBatchService.SOURCE_MANUAL, "PLATFORM", null, null, null,
|
||||
LocalDateTime.now(), null, request.originalDocumentNo(),
|
||||
request.originalGrossWeightKg(), request.originalVolumeCbm(), request.actualGoodsName(),
|
||||
operatorId, request.items().stream().map(item -> new MeasurementBatchCommand.Item(
|
||||
item.itemNo(), item.boxSpecification(), item.packageCount(), item.lengthCm(),
|
||||
item.widthCm(), item.heightCm(), item.unitWeightKg(), item.intelligentImageFileId(),
|
||||
item.overLimitWarning(), item.outerLengthCm(), item.outerWidthCm(), item.outerHeightCm(),
|
||||
item.remark())).toList());
|
||||
InboundMeasurementEntity saved = batchService.saveBatch(command);
|
||||
return new InboundMeasurementResponses.DraftSaved(saved.getId(), saved.getVersion(), saved.getStatus());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public InboundMeasurementResponses.MeasurementSubmitted submit(
|
||||
long tenantId, long inboundId, long operatorId,
|
||||
InboundMeasurementRequests.SubmitRequest request) {
|
||||
InboundOrderEntity inbound = requireInbound(tenantId, inboundId);
|
||||
requireEditable(inbound);
|
||||
InboundMeasurementEntity measurement = requireMeasurement(tenantId, inboundId, request.measurementId());
|
||||
requireVersion(request.version(), measurement.getVersion());
|
||||
if (!Set.of(MeasurementBatchService.STATUS_DRAFT, MeasurementBatchService.STATUS_RECEIVED)
|
||||
.contains(measurement.getStatus())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "当前测量批次不能重复提交审核");
|
||||
}
|
||||
List<InboundMeasurementItemEntity> items = repository.listItems(tenantId, measurement.getId());
|
||||
if (items.isEmpty()) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED, "测量明细不能为空");
|
||||
}
|
||||
if (items.stream().noneMatch(item -> item.getSourceImageFileId() != null)) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED, "至少需要一张测量图片");
|
||||
}
|
||||
List<InboundWeightResultEntity> weights = repository.listWeights(tenantId, inboundId);
|
||||
BigDecimal confirmedWeight = confirmedWeightForSubmission(weights);
|
||||
|
||||
measurement.setStatus(STATUS_SUBMITTED);
|
||||
if (!repository.updateMeasurement(measurement)) {
|
||||
throw concurrentModification();
|
||||
}
|
||||
|
||||
inbound.setProcessStatus("MEASURE_PENDING");
|
||||
inbound.setMeasureReviewStatus("PENDING");
|
||||
inbound.setTotalPackageCount(measurement.getTotalPackageCount());
|
||||
inbound.setActualGrossWeightKg(confirmedWeight);
|
||||
inbound.setActualVolumeCbm(measurement.getTotalVolumeCbm());
|
||||
inbound.setVolumeWeightKg(measurement.getVolumeWeightKg());
|
||||
inbound.setMeasureCompletedAt(LocalDateTime.now());
|
||||
inbound.setUpdatedBy(operatorId);
|
||||
if (!repository.updateInbound(inbound)) {
|
||||
throw concurrentModification();
|
||||
}
|
||||
return new InboundMeasurementResponses.MeasurementSubmitted(
|
||||
measurement.getId(), measurement.getVersion(), inbound.getProcessStatus(),
|
||||
inbound.getMeasureReviewStatus(), RECOMMENDED_PALLET_TYPE, null, null, null);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public InboundMeasurementResponses.WeightResult createWeightResult(
|
||||
long tenantId, long inboundId, long operatorId,
|
||||
InboundMeasurementRequests.CreateWeightResultRequest request) {
|
||||
InboundOrderEntity inbound = requireInbound(tenantId, inboundId);
|
||||
requireEditable(inbound);
|
||||
if (repository.findWeightByFile(tenantId, inboundId, request.fileId()) != null) {
|
||||
throw new BizException(ResultCode.CONFLICT, "同一称重图片不能重复计重");
|
||||
}
|
||||
String unit = rawOcrUnit(request.ocrWeightUnit());
|
||||
InboundWeightResultEntity weight = new InboundWeightResultEntity();
|
||||
weight.setTenantId(tenantId);
|
||||
weight.setInboundId(inboundId);
|
||||
weight.setFileId(request.fileId());
|
||||
weight.setOcrProvider(request.ocrProvider());
|
||||
weight.setOcrRawText(request.ocrRawText());
|
||||
weight.setOcrWeightValue(request.ocrWeightValue());
|
||||
weight.setOcrWeightUnit(unit);
|
||||
weight.setConfidence(request.confidence());
|
||||
weight.setRecognitionStatus(isPositive(request.ocrWeightValue()) && isSupportedUnit(unit)
|
||||
? WEIGHT_PENDING_CONFIRM : WEIGHT_NEEDS_MANUAL);
|
||||
weight.setCreatedBy(operatorId);
|
||||
weight.setUpdatedBy(operatorId);
|
||||
weight.setDeleted(false);
|
||||
weight.setVersion(0);
|
||||
repository.insertWeight(weight);
|
||||
return toWeight(weight);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public InboundMeasurementResponses.WeightResult correctWeightResult(
|
||||
long tenantId, long inboundId, long resultId, long operatorId, String operatorName,
|
||||
InboundMeasurementRequests.CorrectWeightResultRequest request) {
|
||||
InboundOrderEntity inbound = requireInbound(tenantId, inboundId);
|
||||
requireEditable(inbound);
|
||||
InboundWeightResultEntity weight = requireWeight(tenantId, inboundId, resultId);
|
||||
requireVersion(request.version(), weight.getVersion());
|
||||
String adjustedUnit = normalizeUnit(request.adjustedWeightUnit());
|
||||
BigDecimal beforeValue = effectiveValue(weight);
|
||||
String beforeUnit = effectiveUnit(weight);
|
||||
|
||||
weight.setAdjustedWeightValue(request.adjustedWeightValue());
|
||||
weight.setAdjustedWeightUnit(adjustedUnit);
|
||||
weight.setAdjustmentReason(request.adjustmentReason());
|
||||
weight.setAdjustedBy(operatorId);
|
||||
weight.setAdjustedAt(LocalDateTime.now());
|
||||
weight.setFinalWeightKg(null);
|
||||
weight.setConfirmedBy(null);
|
||||
weight.setConfirmedAt(null);
|
||||
weight.setRecognitionStatus(WEIGHT_PENDING_CONFIRM);
|
||||
weight.setUpdatedBy(operatorId);
|
||||
if (!repository.updateWeight(weight)) {
|
||||
throw concurrentModification();
|
||||
}
|
||||
insertRevision(weight, request.version() + 1, "CORRECT", beforeValue, beforeUnit,
|
||||
request.adjustedWeightValue(), adjustedUnit, null, request.adjustmentReason(),
|
||||
operatorId, operatorName);
|
||||
return toWeight(weight);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public InboundMeasurementResponses.WeightResult confirmWeightResult(
|
||||
long tenantId, long inboundId, long resultId, long operatorId, String operatorName,
|
||||
InboundMeasurementRequests.ConfirmWeightResultRequest request) {
|
||||
InboundOrderEntity inbound = requireInbound(tenantId, inboundId);
|
||||
requireEditable(inbound);
|
||||
InboundWeightResultEntity weight = requireWeight(tenantId, inboundId, resultId);
|
||||
requireVersion(request.version(), weight.getVersion());
|
||||
if (WEIGHT_CONFIRMED.equals(weight.getRecognitionStatus())) {
|
||||
return toWeight(weight);
|
||||
}
|
||||
BigDecimal value = effectiveValue(weight);
|
||||
String rawUnit = effectiveUnit(weight);
|
||||
if (!isPositive(value) || !StringUtils.hasText(rawUnit)) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED, "OCR 重量无法确认,请先人工修正数值和单位");
|
||||
}
|
||||
String unit = normalizeUnit(rawUnit);
|
||||
BigDecimal finalKg = toKg(value, unit);
|
||||
weight.setFinalWeightKg(finalKg);
|
||||
weight.setRecognitionStatus(WEIGHT_CONFIRMED);
|
||||
weight.setConfirmedBy(operatorId);
|
||||
weight.setConfirmedAt(LocalDateTime.now());
|
||||
weight.setUpdatedBy(operatorId);
|
||||
if (!repository.updateWeight(weight)) {
|
||||
throw concurrentModification();
|
||||
}
|
||||
insertRevision(weight, request.version() + 1, "CONFIRM", value, unit, value, unit,
|
||||
finalKg, null, operatorId, operatorName);
|
||||
recalculateConfirmedWeight(inbound, operatorId);
|
||||
return toWeight(weight);
|
||||
}
|
||||
|
||||
private void recalculateConfirmedWeight(InboundOrderEntity inbound, long operatorId) {
|
||||
BigDecimal total = repository.listWeights(inbound.getTenantId(), inbound.getId()).stream()
|
||||
.filter(weight -> WEIGHT_CONFIRMED.equals(weight.getRecognitionStatus()))
|
||||
.map(InboundWeightResultEntity::getFinalWeightKg)
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add)
|
||||
.setScale(3, RoundingMode.HALF_UP);
|
||||
inbound.setActualGrossWeightKg(total);
|
||||
if ("WAIT_MEASURE".equals(inbound.getProcessStatus())) {
|
||||
inbound.setProcessStatus("MEASURING");
|
||||
}
|
||||
inbound.setUpdatedBy(operatorId);
|
||||
if (!repository.updateInbound(inbound)) {
|
||||
throw concurrentModification();
|
||||
}
|
||||
}
|
||||
|
||||
private BigDecimal confirmedWeightForSubmission(List<InboundWeightResultEntity> weights) {
|
||||
if (weights.isEmpty()) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED, "至少需要一张已确认的称重图片");
|
||||
}
|
||||
if (weights.stream().anyMatch(weight -> !WEIGHT_CONFIRMED.equals(weight.getRecognitionStatus()))) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED, "所有有效称重图片必须先完成确认");
|
||||
}
|
||||
BigDecimal total = weights.stream().map(InboundWeightResultEntity::getFinalWeightKg)
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add).setScale(3, RoundingMode.HALF_UP);
|
||||
if (!isPositive(total)) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED, "确认后的实际总重量必须大于 0");
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
private void insertRevision(InboundWeightResultEntity weight, int revisionNo, String action,
|
||||
BigDecimal beforeValue, String beforeUnit,
|
||||
BigDecimal afterValue, String afterUnit, BigDecimal finalWeightKg,
|
||||
String reason, long operatorId, String operatorName) {
|
||||
InboundWeightRevisionEntity revision = new InboundWeightRevisionEntity();
|
||||
revision.setTenantId(weight.getTenantId());
|
||||
revision.setInboundId(weight.getInboundId());
|
||||
revision.setWeightResultId(weight.getId());
|
||||
revision.setRevisionNo(revisionNo);
|
||||
revision.setAction(action);
|
||||
revision.setBeforeWeightValue(beforeValue);
|
||||
revision.setBeforeWeightUnit(beforeUnit);
|
||||
revision.setAfterWeightValue(afterValue);
|
||||
revision.setAfterWeightUnit(afterUnit);
|
||||
revision.setFinalWeightKg(finalWeightKg);
|
||||
revision.setReason(reason);
|
||||
revision.setOperatorId(operatorId);
|
||||
revision.setOperatorName(operatorName);
|
||||
revision.setOperatedAt(LocalDateTime.now());
|
||||
repository.insertRevision(revision);
|
||||
}
|
||||
|
||||
private InboundMeasurementResponses.Detail toDetail(
|
||||
InboundOrderEntity inbound, InboundMeasurementEntity measurement,
|
||||
List<InboundMeasurementItemEntity> items, List<InboundWeightResultEntity> weights,
|
||||
MeasureReviewRecordEntity review) {
|
||||
return new InboundMeasurementResponses.Detail(
|
||||
inbound.getId(), inbound.getInboundCode(), inbound.getCompanyName(), inbound.getChannelCode(),
|
||||
inbound.getChannelName(), inbound.getProcessStatus(), inbound.getMeasureReviewStatus(),
|
||||
RECOMMENDED_PALLET_TYPE,
|
||||
review == null ? null : review.getReviewReason(), review == null ? null : review.getReviewerId(),
|
||||
review == null ? null : review.getReviewerName(), measurement == null ? null : measurement.getId(),
|
||||
measurement == null ? null : measurement.getSourceType(),
|
||||
measurement == null ? null : measurement.getStatus(),
|
||||
measurement == null ? null : measurement.getVersion(),
|
||||
measurement == null ? null : measurement.getOriginalDocumentNo(),
|
||||
measurement == null ? null : measurement.getOriginalGrossWeightKg(),
|
||||
measurement == null ? null : measurement.getOriginalVolumeCbm(),
|
||||
measurement == null ? inbound.getTotalPackageCount() : measurement.getTotalPackageCount(),
|
||||
inbound.getActualGrossWeightKg(),
|
||||
measurement == null ? inbound.getActualVolumeCbm() : measurement.getTotalVolumeCbm(),
|
||||
measurement == null ? inbound.getVolumeWeightKg() : measurement.getVolumeWeightKg(),
|
||||
measurement == null ? null : measurement.getActualGoodsName(),
|
||||
items.stream().map(this::toItem).toList(), weights.stream().map(this::toWeight).toList());
|
||||
}
|
||||
|
||||
private InboundMeasurementResponses.Item toItem(InboundMeasurementItemEntity item) {
|
||||
return new InboundMeasurementResponses.Item(
|
||||
item.getId(), item.getItemNo(), item.getBoxSpecification(), item.getPackageCount(),
|
||||
item.getLengthCm(), item.getWidthCm(), item.getHeightCm(), item.getGirthCm(),
|
||||
item.getUnitWeightKg(), item.getUnitVolumeCbm(), item.getTotalWeightKg(),
|
||||
item.getTotalVolumeCbm(), item.getVolumeWeightKg(), item.getSourceImageFileId(),
|
||||
item.getOverLimitWarning(), item.getOuterLengthCm(), item.getOuterWidthCm(),
|
||||
item.getOuterHeightCm(), item.getRemark());
|
||||
}
|
||||
|
||||
private InboundMeasurementResponses.WeightResult toWeight(InboundWeightResultEntity weight) {
|
||||
return new InboundMeasurementResponses.WeightResult(
|
||||
weight.getId(), weight.getFileId(), weight.getOcrProvider(), weight.getOcrRawText(),
|
||||
weight.getOcrWeightValue(), weight.getOcrWeightUnit(), weight.getConfidence(),
|
||||
weight.getAdjustedWeightValue(), weight.getAdjustedWeightUnit(), weight.getFinalWeightKg(),
|
||||
weight.getRecognitionStatus(), weight.getAdjustmentReason(), weight.getAdjustedBy(),
|
||||
weight.getAdjustedAt(), weight.getConfirmedBy(), weight.getConfirmedAt(), weight.getVersion());
|
||||
}
|
||||
|
||||
private InboundOrderEntity requireInbound(long tenantId, long inboundId) {
|
||||
InboundOrderEntity inbound = repository.findInbound(tenantId, inboundId);
|
||||
if (inbound == null) {
|
||||
throw new BizException(ResultCode.NOT_FOUND, "入库单不存在");
|
||||
}
|
||||
return inbound;
|
||||
}
|
||||
|
||||
private InboundMeasurementEntity requireMeasurement(long tenantId, long inboundId, long measurementId) {
|
||||
InboundMeasurementEntity measurement = repository.findMeasurement(tenantId, inboundId, measurementId);
|
||||
if (measurement == null) {
|
||||
throw new BizException(ResultCode.NOT_FOUND, "测量批次不存在");
|
||||
}
|
||||
return measurement;
|
||||
}
|
||||
|
||||
private InboundWeightResultEntity requireWeight(long tenantId, long inboundId, long resultId) {
|
||||
InboundWeightResultEntity weight = repository.findWeight(tenantId, inboundId, resultId);
|
||||
if (weight == null) {
|
||||
throw new BizException(ResultCode.NOT_FOUND, "OCR 重量结果不存在");
|
||||
}
|
||||
return weight;
|
||||
}
|
||||
|
||||
private void requireEditable(InboundOrderEntity inbound) {
|
||||
if (!EDITABLE_INBOUND_STATUSES.contains(inbound.getProcessStatus())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "当前入库状态不允许维护测量数据");
|
||||
}
|
||||
}
|
||||
|
||||
private void requireVersion(Integer expected, Integer actual) {
|
||||
if (expected == null || !expected.equals(actual)) {
|
||||
throw concurrentModification();
|
||||
}
|
||||
}
|
||||
|
||||
private String rawOcrUnit(String unit) {
|
||||
if (!StringUtils.hasText(unit)) {
|
||||
return null;
|
||||
}
|
||||
return unit.trim().toUpperCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private boolean isSupportedUnit(String unit) {
|
||||
if (!StringUtils.hasText(unit)) {
|
||||
return false;
|
||||
}
|
||||
String normalized = unit.trim().toUpperCase(Locale.ROOT);
|
||||
return Set.of("KG", "KGS", "LB", "LBS").contains(normalized);
|
||||
}
|
||||
|
||||
private String normalizeUnit(String unit) {
|
||||
String normalized = unit.trim().toUpperCase(Locale.ROOT);
|
||||
if ("KGS".equals(normalized)) {
|
||||
normalized = "KG";
|
||||
} else if ("LBS".equals(normalized)) {
|
||||
normalized = "LB";
|
||||
}
|
||||
if (!Set.of("KG", "LB").contains(normalized)) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED, "重量单位仅支持 KG 或 LB");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private BigDecimal toKg(BigDecimal value, String unit) {
|
||||
BigDecimal kg = "LB".equals(unit) ? value.multiply(LB_TO_KG) : value;
|
||||
return kg.setScale(3, RoundingMode.HALF_UP);
|
||||
}
|
||||
|
||||
private BigDecimal effectiveValue(InboundWeightResultEntity weight) {
|
||||
return weight.getAdjustedWeightValue() == null
|
||||
? weight.getOcrWeightValue() : weight.getAdjustedWeightValue();
|
||||
}
|
||||
|
||||
private String effectiveUnit(InboundWeightResultEntity weight) {
|
||||
return weight.getAdjustedWeightUnit() == null
|
||||
? weight.getOcrWeightUnit() : weight.getAdjustedWeightUnit();
|
||||
}
|
||||
|
||||
private boolean isPositive(BigDecimal value) {
|
||||
return value != null && value.compareTo(BigDecimal.ZERO) > 0;
|
||||
}
|
||||
|
||||
private BizException concurrentModification() {
|
||||
return new BizException(ResultCode.CONFLICT, "数据版本已变化,请刷新后重试");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.baiyue.firstleg.service;
|
||||
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.domain.InboundProcessStatus;
|
||||
import com.baiyue.firstleg.dto.InboundOrderDetail;
|
||||
import com.baiyue.firstleg.dto.InboundOrderQuery;
|
||||
import com.baiyue.firstleg.dto.InboundOrderSummary;
|
||||
import com.baiyue.firstleg.entity.InboundOrderEntity;
|
||||
import com.baiyue.firstleg.repository.InboundOrderRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
/** 入库单列表和详情只读应用服务,不执行任何状态推进。 */
|
||||
@Service
|
||||
public class InboundOrderQueryService {
|
||||
private final InboundOrderRepository repository;
|
||||
|
||||
public InboundOrderQueryService(InboundOrderRepository repository) {
|
||||
this.repository = repository;
|
||||
}
|
||||
|
||||
public PageResult<InboundOrderSummary> page(long tenantId, InboundOrderQuery query) {
|
||||
validateQuery(query);
|
||||
return repository.page(tenantId, query);
|
||||
}
|
||||
|
||||
public InboundOrderDetail detail(long tenantId, long inboundId) {
|
||||
InboundOrderEntity order = repository.findOwned(tenantId, inboundId);
|
||||
if (order == null) {
|
||||
throw new BizException(ResultCode.NOT_FOUND, "入库单不存在");
|
||||
}
|
||||
return repository.detail(tenantId, order);
|
||||
}
|
||||
|
||||
private void validateQuery(InboundOrderQuery query) {
|
||||
if (StringUtils.hasText(query.processStatus())
|
||||
&& !InboundProcessStatus.VALUES.contains(query.processStatus())) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "入库流程状态不合法");
|
||||
}
|
||||
if (query.tab() != null && StringUtils.hasText(query.processStatus())) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "页签和流程状态不能同时指定");
|
||||
}
|
||||
if (query.expectedArrivalFrom() != null && query.expectedArrivalTo() != null
|
||||
&& query.expectedArrivalFrom().isAfter(query.expectedArrivalTo())) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "预计到达开始时间不能晚于结束时间");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package com.baiyue.firstleg.service;
|
||||
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.dto.MeasurementBatchCommand;
|
||||
import com.baiyue.firstleg.entity.InboundMeasurementEntity;
|
||||
import com.baiyue.firstleg.entity.InboundMeasurementItemEntity;
|
||||
import com.baiyue.firstleg.repository.InboundMeasurementRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 后台录入和第三方回传共用的测量批次持久化服务。
|
||||
*
|
||||
* <p>本服务只保存测量批次和明细,绝不推进入库单状态。第三方适配层应调用
|
||||
* {@link #saveBatch(MeasurementBatchCommand)},由平台确认接口另行提交审核。</p>
|
||||
*/
|
||||
@Service
|
||||
public class MeasurementBatchService {
|
||||
static final String SOURCE_MANUAL = "MANUAL";
|
||||
static final String SOURCE_THIRD_PARTY = "THIRD_PARTY";
|
||||
static final String STATUS_DRAFT = "DRAFT";
|
||||
static final String STATUS_RECEIVED = "RECEIVED";
|
||||
static final BigDecimal VOLUME_WEIGHT_FACTOR = new BigDecimal("167");
|
||||
static final String CALCULATION_RULE_VERSION = "V1_CBM_CEILING_4_GIRTH_L2WH";
|
||||
|
||||
private final InboundMeasurementRepository repository;
|
||||
|
||||
public MeasurementBatchService(InboundMeasurementRepository repository) {
|
||||
this.repository = repository;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public InboundMeasurementEntity saveBatch(MeasurementBatchCommand command) {
|
||||
if (repository.findInbound(command.tenantId(), command.inboundId()) == null) {
|
||||
throw new BizException(ResultCode.NOT_FOUND, "入库单不存在");
|
||||
}
|
||||
validateSource(command);
|
||||
List<InboundMeasurementItemEntity> calculatedItems = calculateItems(command);
|
||||
|
||||
InboundMeasurementEntity measurement;
|
||||
if (command.measurementId() == null) {
|
||||
measurement = newMeasurement(command);
|
||||
applyTotals(measurement, calculatedItems);
|
||||
repository.insertMeasurement(measurement);
|
||||
} else {
|
||||
measurement = requireEditableManualBatch(command);
|
||||
applyHeader(measurement, command);
|
||||
applyTotals(measurement, calculatedItems);
|
||||
if (!repository.updateMeasurement(measurement)) {
|
||||
throw concurrentModification();
|
||||
}
|
||||
}
|
||||
calculatedItems.forEach(item -> {
|
||||
item.setTenantId(command.tenantId());
|
||||
item.setMeasurementId(measurement.getId());
|
||||
});
|
||||
repository.replaceItems(command.tenantId(), measurement.getId(), calculatedItems);
|
||||
return measurement;
|
||||
}
|
||||
|
||||
private void validateSource(MeasurementBatchCommand command) {
|
||||
if (!SOURCE_MANUAL.equals(command.sourceType()) && !SOURCE_THIRD_PARTY.equals(command.sourceType())) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "测量数据来源不支持");
|
||||
}
|
||||
if (SOURCE_THIRD_PARTY.equals(command.sourceType())
|
||||
&& (command.externalEventNo() == null || command.externalEventNo().isBlank())) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "第三方测量必须提供事件号");
|
||||
}
|
||||
if (command.items() == null || command.items().isEmpty()) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED, "测量明细不能为空");
|
||||
}
|
||||
}
|
||||
|
||||
private InboundMeasurementEntity newMeasurement(MeasurementBatchCommand command) {
|
||||
InboundMeasurementEntity measurement = new InboundMeasurementEntity();
|
||||
measurement.setTenantId(command.tenantId());
|
||||
measurement.setInboundId(command.inboundId());
|
||||
measurement.setSourceType(command.sourceType());
|
||||
measurement.setStatus(SOURCE_MANUAL.equals(command.sourceType()) ? STATUS_DRAFT : STATUS_RECEIVED);
|
||||
measurement.setCreatedBy(command.operatorId());
|
||||
measurement.setVersion(0);
|
||||
applyHeader(measurement, command);
|
||||
return measurement;
|
||||
}
|
||||
|
||||
private InboundMeasurementEntity requireEditableManualBatch(MeasurementBatchCommand command) {
|
||||
InboundMeasurementEntity measurement = repository.findMeasurement(
|
||||
command.tenantId(), command.inboundId(), command.measurementId());
|
||||
if (measurement == null) {
|
||||
throw new BizException(ResultCode.NOT_FOUND, "测量批次不存在");
|
||||
}
|
||||
if (!SOURCE_MANUAL.equals(measurement.getSourceType()) || !STATUS_DRAFT.equals(measurement.getStatus())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "仅人工草稿允许直接修改");
|
||||
}
|
||||
if (!Integer.valueOf(command.expectedVersion()).equals(measurement.getVersion())) {
|
||||
throw concurrentModification();
|
||||
}
|
||||
return measurement;
|
||||
}
|
||||
|
||||
private void applyHeader(InboundMeasurementEntity measurement, MeasurementBatchCommand command) {
|
||||
measurement.setSourceCode(command.sourceCode());
|
||||
measurement.setExternalEventNo(command.externalEventNo());
|
||||
measurement.setDeviceCode(command.deviceCode());
|
||||
measurement.setWarehouseCode(command.warehouseCode());
|
||||
measurement.setMeasuredAt(command.measuredAt());
|
||||
measurement.setRawPayloadJson(command.rawPayloadJson());
|
||||
measurement.setOriginalDocumentNo(command.originalDocumentNo());
|
||||
measurement.setOriginalGrossWeightKg(command.originalGrossWeightKg());
|
||||
measurement.setOriginalVolumeCbm(command.originalVolumeCbm());
|
||||
measurement.setActualGoodsName(command.actualGoodsName());
|
||||
}
|
||||
|
||||
private List<InboundMeasurementItemEntity> calculateItems(MeasurementBatchCommand command) {
|
||||
Set<String> itemNumbers = new HashSet<>();
|
||||
return command.items().stream().map(source -> {
|
||||
if (!itemNumbers.add(source.itemNo())) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED, "测量明细号不能重复");
|
||||
}
|
||||
BigDecimal unitVolumeRaw = source.lengthCm().multiply(source.widthCm())
|
||||
.multiply(source.heightCm()).divide(new BigDecimal("1000000"), 6, RoundingMode.HALF_UP);
|
||||
BigDecimal unitVolume = unitVolumeRaw.setScale(4, RoundingMode.CEILING);
|
||||
BigDecimal packageCount = BigDecimal.valueOf(source.packageCount());
|
||||
BigDecimal totalVolumeRaw = unitVolumeRaw.multiply(packageCount).setScale(6, RoundingMode.HALF_UP);
|
||||
BigDecimal totalVolume = unitVolume.multiply(packageCount).setScale(4, RoundingMode.CEILING);
|
||||
|
||||
InboundMeasurementItemEntity item = new InboundMeasurementItemEntity();
|
||||
item.setItemNo(source.itemNo());
|
||||
item.setBoxSpecification(source.boxSpecification());
|
||||
item.setPackageCount(source.packageCount());
|
||||
item.setLengthCm(source.lengthCm());
|
||||
item.setWidthCm(source.widthCm());
|
||||
item.setHeightCm(source.heightCm());
|
||||
item.setGirthCm(source.lengthCm().add(source.widthCm().add(source.heightCm())
|
||||
.multiply(BigDecimal.valueOf(2))).setScale(3, RoundingMode.CEILING));
|
||||
item.setUnitWeightKg(source.unitWeightKg() == null ? null
|
||||
: source.unitWeightKg().setScale(3, RoundingMode.CEILING));
|
||||
item.setUnitVolumeCbm(unitVolume);
|
||||
item.setVolumeRawCbm(totalVolumeRaw);
|
||||
item.setVolumeCbm(totalVolume);
|
||||
item.setTotalWeightKg(source.unitWeightKg() == null ? null
|
||||
: source.unitWeightKg().multiply(packageCount).setScale(3, RoundingMode.CEILING));
|
||||
item.setTotalVolumeCbm(totalVolume);
|
||||
item.setVolumeWeightKg(totalVolume.multiply(VOLUME_WEIGHT_FACTOR).setScale(3, RoundingMode.CEILING));
|
||||
item.setCalculationRuleVersion(CALCULATION_RULE_VERSION);
|
||||
item.setSourceImageFileId(source.intelligentImageFileId());
|
||||
item.setOverLimitWarning(source.overLimitWarning());
|
||||
item.setOuterLengthCm(source.outerLengthCm());
|
||||
item.setOuterWidthCm(source.outerWidthCm());
|
||||
item.setOuterHeightCm(source.outerHeightCm());
|
||||
item.setRemark(source.remark());
|
||||
item.setCreatedAt(LocalDateTime.now());
|
||||
return item;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
private void applyTotals(InboundMeasurementEntity measurement,
|
||||
List<InboundMeasurementItemEntity> items) {
|
||||
long packages = items.stream().mapToLong(InboundMeasurementItemEntity::getPackageCount).sum();
|
||||
if (packages > Integer.MAX_VALUE) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED, "测量总箱数超过系统上限");
|
||||
}
|
||||
BigDecimal rawVolume = items.stream().map(InboundMeasurementItemEntity::getVolumeRawCbm)
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add).setScale(6, RoundingMode.HALF_UP);
|
||||
BigDecimal volume = items.stream().map(InboundMeasurementItemEntity::getTotalVolumeCbm)
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add).setScale(4, RoundingMode.CEILING);
|
||||
measurement.setTotalPackageCount((int) packages);
|
||||
measurement.setTotalVolumeRawCbm(rawVolume);
|
||||
measurement.setTotalVolumeCbm(volume);
|
||||
measurement.setVolumeWeightKg(volume.multiply(VOLUME_WEIGHT_FACTOR).setScale(3, RoundingMode.CEILING));
|
||||
}
|
||||
|
||||
private BizException concurrentModification() {
|
||||
return new BizException(ResultCode.CONFLICT, "测量数据已被其他人修改,请刷新后重试");
|
||||
}
|
||||
}
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
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.WarehousePalletLocationClient;
|
||||
import com.baiyue.firstleg.config.InboundProperties;
|
||||
import com.baiyue.firstleg.entity.InboundOrderEntity;
|
||||
import com.baiyue.firstleg.repository.InboundPalletAllocationRepository;
|
||||
import com.baiyue.firstleg.repository.InboundWorkflowRepository;
|
||||
import feign.FeignException;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Service
|
||||
public class MeasurementLocationOccupationService {
|
||||
private static final String SOURCE_SERVICE = "firstleg-service";
|
||||
private final WarehousePalletLocationClient warehouse;
|
||||
private final InboundPalletAllocationRepository allocations;
|
||||
private final InboundWorkflowRepository workflow;
|
||||
private final InboundProperties properties;
|
||||
private final String internalToken;
|
||||
|
||||
public MeasurementLocationOccupationService(WarehousePalletLocationClient warehouse,
|
||||
InboundPalletAllocationRepository allocations, InboundWorkflowRepository workflow,
|
||||
InboundProperties properties,
|
||||
@Value("${baiyue.security.internal-service-token}") String internalToken) {
|
||||
this.warehouse = warehouse;
|
||||
this.allocations = allocations;
|
||||
this.workflow = workflow;
|
||||
this.properties = properties;
|
||||
this.internalToken = internalToken;
|
||||
}
|
||||
|
||||
public void occupy(long tenantId, long inboundId, long operatorId, long measurementId,
|
||||
int measurementVersion) {
|
||||
InboundOrderEntity order = requireOrder(tenantId, inboundId);
|
||||
long warehouseId = requireWarehouse(order);
|
||||
BigDecimal volume = requireVolume(order);
|
||||
String key = "measure-" + inboundId + "-" + measurementId + "-" + measurementVersion;
|
||||
WarehousePalletLocationClient.BatchResult reserved = requireSuccess(call(() -> warehouse.reserve(
|
||||
internalToken, new WarehousePalletLocationClient.ReserveRequest(tenantId, warehouseId,
|
||||
SOURCE_SERVICE, inboundId, order.getInboundCode(), key, operatorId, volume,
|
||||
properties.getDefaultPalletCapacityCbm(), "STANDARD",
|
||||
LocalDateTime.now().plus(properties.getReservationTtl()), true))),
|
||||
"测量完成后库位预占失败");
|
||||
allocations.replace(tenantId, inboundId, warehouseId,
|
||||
properties.getDefaultPalletCapacityCbm(), reserved);
|
||||
try {
|
||||
WarehousePalletLocationClient.BatchResult occupied = requireSuccess(call(() -> warehouse.confirm(
|
||||
internalToken, new WarehousePalletLocationClient.ConfirmRequest(tenantId, warehouseId,
|
||||
SOURCE_SERVICE, inboundId, operatorId, volume,
|
||||
properties.getDefaultPalletCapacityCbm()))), "测量完成后库位占用失败");
|
||||
allocations.replace(tenantId, inboundId, warehouseId,
|
||||
properties.getDefaultPalletCapacityCbm(), occupied);
|
||||
} catch (RuntimeException failure) {
|
||||
try {
|
||||
warehouse.release(internalToken, new WarehousePalletLocationClient.ReleaseRequest(
|
||||
tenantId, SOURCE_SERVICE, inboundId, operatorId, "测量完成库位占用失败"));
|
||||
} catch (RuntimeException releaseFailure) {
|
||||
failure.addSuppressed(releaseFailure);
|
||||
}
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
private InboundOrderEntity requireOrder(long tenantId, long inboundId) {
|
||||
InboundOrderEntity order = workflow.findOwned(tenantId, inboundId);
|
||||
if (order == null) throw new BizException(ResultCode.NOT_FOUND, "入库单不存在");
|
||||
return order;
|
||||
}
|
||||
|
||||
private static long requireWarehouse(InboundOrderEntity order) {
|
||||
if (order.getWarehouseId() == null || order.getWarehouseId() <= 0) {
|
||||
throw new BizException(ResultCode.CONFLICT, "入库单缺少收货仓库");
|
||||
}
|
||||
return order.getWarehouseId();
|
||||
}
|
||||
|
||||
private static BigDecimal requireVolume(InboundOrderEntity order) {
|
||||
if (order.getActualVolumeCbm() == null || order.getActualVolumeCbm().signum() <= 0) {
|
||||
throw new BizException(ResultCode.CONFLICT, "入库单缺少有效实测体积");
|
||||
}
|
||||
return order.getActualVolumeCbm();
|
||||
}
|
||||
|
||||
private static <T> ApiResponse<T> call(RemoteCall<T> call) {
|
||||
try { return call.invoke(); }
|
||||
catch (FeignException exception) {
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "仓储服务不可用");
|
||||
}
|
||||
}
|
||||
|
||||
private static <T> T requireSuccess(ApiResponse<T> response, String fallback) {
|
||||
if (response == null || !response.successful() || response.data() == null) {
|
||||
ResultCode code = response == null ? ResultCode.SERVICE_UNAVAILABLE : ResultCode.of(response.code());
|
||||
throw new BizException(code, response == null || response.message() == null
|
||||
? fallback : response.message());
|
||||
}
|
||||
return response.data();
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
private interface RemoteCall<T> { ApiResponse<T> invoke(); }
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package com.baiyue.firstleg.service;
|
||||
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.config.InboundProperties;
|
||||
import com.baiyue.firstleg.dto.ExceptionReleaseRequest;
|
||||
import com.baiyue.firstleg.dto.MeasurementReviewRequest;
|
||||
import com.baiyue.firstleg.entity.InboundMeasurementEntity;
|
||||
import com.baiyue.firstleg.entity.InboundOrderEntity;
|
||||
import com.baiyue.firstleg.entity.InboundStatusHistoryEntity;
|
||||
import com.baiyue.firstleg.entity.MeasureReviewRecordEntity;
|
||||
import com.baiyue.firstleg.repository.InboundWorkflowRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 测量审核应用服务。审核是 MEASURE_PENDING 到计费阶段的唯一正常推进关口。
|
||||
*/
|
||||
@Service
|
||||
public class MeasurementReviewService {
|
||||
private final InboundWorkflowRepository repository;
|
||||
private final InboundIdempotencyService idempotency;
|
||||
private final InboundProperties properties;
|
||||
|
||||
public MeasurementReviewService(InboundWorkflowRepository repository,
|
||||
InboundIdempotencyService idempotency,
|
||||
InboundProperties properties) {
|
||||
this.repository = repository;
|
||||
this.idempotency = idempotency;
|
||||
this.properties = properties;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void approve(long tenantId, long operatorId, String operatorName, long inboundId,
|
||||
String idempotencyKey, MeasurementReviewRequest request) {
|
||||
review(tenantId, operatorId, operatorName, inboundId, idempotencyKey, request, true);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void reject(long tenantId, long operatorId, String operatorName, long inboundId,
|
||||
String idempotencyKey, MeasurementReviewRequest request) {
|
||||
if (!StringUtils.hasText(request.reason())) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED, "审核驳回必须填写原因");
|
||||
}
|
||||
review(tenantId, operatorId, operatorName, inboundId, idempotencyKey, request, false);
|
||||
}
|
||||
|
||||
private void review(long tenantId, long operatorId, String operatorName, long inboundId,
|
||||
String idempotencyKey, MeasurementReviewRequest request, boolean approved) {
|
||||
String operation = approved ? "MEASURE_APPROVE" : "MEASURE_REJECT";
|
||||
String canonical = inboundId + "|" + request.measurementId() + "|"
|
||||
+ request.expectedVersion() + "|" + normalize(request.reason());
|
||||
InboundIdempotencyService.Context context = idempotency.begin(
|
||||
tenantId, operatorId, inboundId, operation, idempotencyKey, canonical);
|
||||
if (context.replayed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
InboundOrderEntity order = requireOrder(tenantId, inboundId);
|
||||
requirePendingReview(order);
|
||||
InboundMeasurementEntity measurement = repository.findMeasurement(
|
||||
tenantId, inboundId, request.measurementId());
|
||||
if (measurement == null) {
|
||||
throw new BizException(ResultCode.NOT_FOUND, "待审核测量批次不存在");
|
||||
}
|
||||
if (!"SUBMITTED".equals(measurement.getStatus())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "测量批次不是待审核状态");
|
||||
}
|
||||
|
||||
if (repository.transitionReview(tenantId, inboundId, operatorId,
|
||||
request.expectedVersion(), approved) != 1) {
|
||||
throw concurrentConflict();
|
||||
}
|
||||
String result = approved ? "APPROVED" : "REJECTED";
|
||||
if (repository.transitionMeasurement(tenantId, inboundId, request.measurementId(), result) != 1) {
|
||||
throw concurrentConflict();
|
||||
}
|
||||
repository.insertReview(reviewRecord(tenantId, inboundId, request.measurementId(), result,
|
||||
request.reason(), false, null, false, operatorId, operatorName));
|
||||
repository.insertHistory(history(tenantId, inboundId, "MEASURE_PENDING",
|
||||
approved ? "WAIT_PRICING" : "MEASURE_REJECTED",
|
||||
approved ? "MEASURE_APPROVE" : "MEASURE_REJECT", request.reason(), operatorId, operatorName));
|
||||
idempotency.complete(context, inboundId);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void exceptionRelease(long tenantId, long operatorId, String operatorName, long inboundId,
|
||||
String idempotencyKey, ExceptionReleaseRequest request) {
|
||||
if (!StringUtils.hasText(request.reason())) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED, "异常放行必须填写原因");
|
||||
}
|
||||
String canonical = inboundId + "|" + request.measurementId() + "|"
|
||||
+ request.expectedVersion() + "|" + normalize(request.reason());
|
||||
InboundIdempotencyService.Context context = idempotency.begin(
|
||||
tenantId, operatorId, inboundId, "MEASURE_EXCEPTION_RELEASE", idempotencyKey, canonical);
|
||||
if (context.replayed()) {
|
||||
return;
|
||||
}
|
||||
if (!properties.isAllowRejectedMeasurement()) {
|
||||
throw new BizException(ResultCode.CONFLICT, "测量驳回异常放行开关未开启");
|
||||
}
|
||||
InboundOrderEntity order = requireOrder(tenantId, inboundId);
|
||||
if (!"MEASURE_REJECTED".equals(order.getProcessStatus())
|
||||
|| !"REJECTED".equals(order.getMeasureReviewStatus())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "只有测量审核驳回单可异常放行");
|
||||
}
|
||||
InboundMeasurementEntity measurement = repository.findMeasurement(
|
||||
tenantId, inboundId, request.measurementId());
|
||||
if (measurement == null || !"REJECTED".equals(measurement.getStatus())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "被放行测量批次必须为已驳回状态");
|
||||
}
|
||||
if (repository.releaseRejected(tenantId, inboundId, operatorId, request.expectedVersion()) != 1) {
|
||||
throw concurrentConflict();
|
||||
}
|
||||
repository.insertReview(reviewRecord(tenantId, inboundId, request.measurementId(), "REJECTED",
|
||||
null, true, request.reason(), true, operatorId, operatorName));
|
||||
repository.insertHistory(history(tenantId, inboundId, "MEASURE_REJECTED", "WAIT_PRICING",
|
||||
"MEASURE_EXCEPTION_RELEASE", request.reason(), operatorId, operatorName));
|
||||
idempotency.complete(context, inboundId);
|
||||
}
|
||||
|
||||
public PageResult<MeasureReviewRecordEntity> history(
|
||||
long tenantId, long inboundId, long page, long size) {
|
||||
requireOrder(tenantId, inboundId);
|
||||
return repository.reviewHistory(tenantId, inboundId, page, size);
|
||||
}
|
||||
|
||||
private InboundOrderEntity requireOrder(long tenantId, long inboundId) {
|
||||
InboundOrderEntity order = repository.findOwned(tenantId, inboundId);
|
||||
if (order == null) {
|
||||
throw new BizException(ResultCode.NOT_FOUND, "入库单不存在");
|
||||
}
|
||||
return order;
|
||||
}
|
||||
|
||||
private static void requirePendingReview(InboundOrderEntity order) {
|
||||
if (!"MEASURE_PENDING".equals(order.getProcessStatus())
|
||||
|| !"PENDING".equals(order.getMeasureReviewStatus())) {
|
||||
throw new BizException(ResultCode.CONFLICT,
|
||||
"仅待测量审核单可审核,已审核请求请使用原 Idempotency-Key 重放");
|
||||
}
|
||||
}
|
||||
|
||||
private static BizException concurrentConflict() {
|
||||
return new BizException(ResultCode.CONFLICT, "入库单已被并发更新,请刷新后重试");
|
||||
}
|
||||
|
||||
private static String normalize(String value) {
|
||||
return value == null ? "" : value.trim();
|
||||
}
|
||||
|
||||
private static MeasureReviewRecordEntity reviewRecord(
|
||||
long tenantId, long inboundId, long measurementId, String result, String reviewReason,
|
||||
boolean exceptionRelease, String releaseReason, boolean configValue,
|
||||
long operatorId, String operatorName) {
|
||||
MeasureReviewRecordEntity record = new MeasureReviewRecordEntity();
|
||||
record.setTenantId(tenantId);
|
||||
record.setInboundId(inboundId);
|
||||
record.setMeasurementId(measurementId);
|
||||
record.setReviewResult(result);
|
||||
record.setReviewReason(normalize(reviewReason));
|
||||
record.setExceptionRelease(exceptionRelease);
|
||||
record.setReleaseReason(releaseReason);
|
||||
record.setAllowRejectedConfig(configValue);
|
||||
record.setReviewerId(operatorId);
|
||||
record.setReviewerName(operatorName);
|
||||
record.setReviewedAt(LocalDateTime.now());
|
||||
return record;
|
||||
}
|
||||
|
||||
static InboundStatusHistoryEntity history(long tenantId, long inboundId, String from, String to,
|
||||
String action, String reason,
|
||||
long operatorId, String operatorName) {
|
||||
InboundStatusHistoryEntity history = new InboundStatusHistoryEntity();
|
||||
history.setTenantId(tenantId);
|
||||
history.setInboundId(inboundId);
|
||||
history.setFromStatus(from);
|
||||
history.setToStatus(to);
|
||||
history.setAction(action);
|
||||
history.setReason(reason);
|
||||
history.setOperatorId(operatorId);
|
||||
history.setOperatorName(operatorName);
|
||||
history.setOccurredAt(LocalDateTime.now());
|
||||
return history;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.baiyue.firstleg.service;
|
||||
|
||||
import com.baiyue.firstleg.dto.InboundMeasurementRequests;
|
||||
import com.baiyue.firstleg.dto.InboundMeasurementResponses;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
public class MeasurementSubmissionService {
|
||||
private final InboundMeasurementService measurements;
|
||||
private final MeasurementLocationOccupationService locations;
|
||||
|
||||
public MeasurementSubmissionService(InboundMeasurementService measurements,
|
||||
MeasurementLocationOccupationService locations) {
|
||||
this.measurements = measurements;
|
||||
this.locations = locations;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public InboundMeasurementResponses.MeasurementSubmitted submit(
|
||||
long tenantId, long inboundId, long operatorId,
|
||||
InboundMeasurementRequests.SubmitRequest request) {
|
||||
var result = measurements.submit(tenantId, inboundId, operatorId, request);
|
||||
locations.occupy(tenantId, inboundId, operatorId, result.measurementId(), result.version());
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.baiyue.firstleg.service;
|
||||
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.dto.MerchantInboundBillingResponse;
|
||||
import com.baiyue.firstleg.entity.InboundOrderEntity;
|
||||
import com.baiyue.firstleg.repository.InboundOrderRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class MerchantInboundBillingService {
|
||||
private final InboundOrderRepository orders;
|
||||
private final FbaPricingService pricing;
|
||||
|
||||
public MerchantInboundBillingService(InboundOrderRepository orders, FbaPricingService pricing) {
|
||||
this.orders = orders;
|
||||
this.pricing = pricing;
|
||||
}
|
||||
|
||||
public MerchantInboundBillingResponse find(long tenantId, long orderId) {
|
||||
InboundOrderEntity inbound = orders.findByOrderId(tenantId, orderId);
|
||||
if (inbound == null) {
|
||||
throw new BizException(ResultCode.NOT_FOUND, "订单尚未生成入库单");
|
||||
}
|
||||
if (!"INBOUNDED".equals(inbound.getProcessStatus()) || !"SENT".equals(inbound.getFeeStatus())) {
|
||||
throw new BizException(ResultCode.NOT_FOUND, "入库费用尚未由客服审核并发布");
|
||||
}
|
||||
return new MerchantInboundBillingResponse(orders.detail(tenantId, inbound),
|
||||
pricing.latest(tenantId, inbound.getId()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package com.baiyue.firstleg.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baiyue.firstleg.config.MeasurementOpenApiProperties;
|
||||
import com.baiyue.firstleg.entity.ThirdPartyCallLogEntity;
|
||||
import com.baiyue.firstleg.mapper.ThirdPartyCallLogMapper;
|
||||
import com.baiyue.firstleg.security.MeasurementRequestAuthenticator;
|
||||
import com.baiyue.firstleg.security.MeasurementRequestHeaders;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** 将每次第三方 API 调用独立留档,业务事务回滚也不会丢失失败记录。 */
|
||||
@Service
|
||||
public class ThirdPartyCallLogService {
|
||||
private final ThirdPartyCallLogMapper mapper;
|
||||
private final MeasurementOpenApiProperties properties;
|
||||
|
||||
public ThirdPartyCallLogService(ThirdPartyCallLogMapper mapper,
|
||||
MeasurementOpenApiProperties properties) {
|
||||
this.mapper = mapper;
|
||||
this.properties = properties;
|
||||
}
|
||||
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public long start(String method, String path, MeasurementRequestHeaders headers, String rawBody) {
|
||||
ThirdPartyCallLogEntity log = new ThirdPartyCallLogEntity();
|
||||
log.setId(IdWorker.getId());
|
||||
MeasurementOpenApiProperties.AppCredential app = properties.getApps().get(headers.appKey());
|
||||
log.setTenantId(app == null || app.getTenantId() == null ? 0L : app.getTenantId());
|
||||
log.setAppKey(limit(headers.appKey(), 128));
|
||||
log.setSignatureSubject(limit(headers.appKey(), 128));
|
||||
log.setRequestMethod(method);
|
||||
log.setRequestPath(limit(path, 255));
|
||||
log.setRequestTimestamp(limit(headers.timestamp(), 32));
|
||||
log.setNonce(limit(headers.nonce(), 128));
|
||||
log.setIdempotencyKey(limit(headers.idempotencyKey(), 128));
|
||||
log.setSignatureValue(limit(headers.signature(), 128));
|
||||
log.setRequestHash(MeasurementRequestAuthenticator.sha256Hex(rawBody));
|
||||
log.setRawPayload(rawBody == null ? "" : rawBody);
|
||||
log.setProcessingStatus("PROCESSING");
|
||||
log.setRequestedAt(LocalDateTime.now());
|
||||
mapper.insert(log);
|
||||
return log.getId();
|
||||
}
|
||||
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public void complete(long id, String status, String code, String message, String resultJson) {
|
||||
ThirdPartyCallLogEntity update = new ThirdPartyCallLogEntity();
|
||||
update.setId(id);
|
||||
update.setProcessingStatus(status);
|
||||
update.setResultCode(limit(code, 32));
|
||||
update.setResultMessage(limit(message, 1000));
|
||||
update.setResultJson(resultJson);
|
||||
update.setProcessedAt(LocalDateTime.now());
|
||||
mapper.updateById(update);
|
||||
}
|
||||
|
||||
/** 签名通过后原子认领 Idempotency-Key;非法签名不会污染幂等空间。 */
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public boolean claimIdempotency(long logId) {
|
||||
ThirdPartyCallLogEntity update = new ThirdPartyCallLogEntity();
|
||||
update.setId(logId);
|
||||
update.setIdempotencyAnchor(1);
|
||||
try {
|
||||
mapper.updateById(update);
|
||||
return true;
|
||||
} catch (DuplicateKeyException exception) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public ThirdPartyCallLogEntity findIdempotencyOwner(long tenantId, String appKey, String idempotencyKey) {
|
||||
return mapper.selectOne(new LambdaQueryWrapper<ThirdPartyCallLogEntity>()
|
||||
.eq(ThirdPartyCallLogEntity::getTenantId, tenantId)
|
||||
.eq(ThirdPartyCallLogEntity::getAppKey, appKey)
|
||||
.eq(ThirdPartyCallLogEntity::getIdempotencyKey, idempotencyKey)
|
||||
.eq(ThirdPartyCallLogEntity::getIdempotencyAnchor, 1));
|
||||
}
|
||||
|
||||
private String limit(String value, int max) {
|
||||
if (value == null || value.length() <= max) {
|
||||
return value;
|
||||
}
|
||||
return value.substring(0, max);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user