二期内容提交

This commit is contained in:
2026-08-04 10:07:45 +08:00
parent b2b4ff429d
commit 57b54a5236
184 changed files with 13195 additions and 5 deletions
@@ -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);
}
@@ -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<>();
}
}
@@ -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));
}
}
@@ -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)));
}
}
@@ -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);
}
}
@@ -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));
}
}
@@ -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, "必须指定有效目标租户和操作人");
}
}
}
@@ -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, "必须指定有效目标租户");
}
}
}
@@ -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;
}
@@ -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); }
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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) {
}
@@ -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);
}
}
@@ -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;
}
}
@@ -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) {
}
}
@@ -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, "测量数据已被其他人修改,请刷新后重试");
}
}
@@ -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);
}
}