Merge remote-tracking branch 'origin/test' into dev
# Conflicts: # scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs # scripts/admin-test-env-modules/phase4-overseas-warehouse-contract.test.mjs
This commit is contained in:
+2
-2
@@ -19,7 +19,7 @@
|
||||
| 箱唛箱码生成、打印与测量绑定 | 审核建单生成钩子 `controller/InternalInboundOrderController.java`;生成/解析 `service/InboundCartonLabelService.java`、`repository/InboundCartonLabelRepository.java`;平台与客户打印 `controller/PlatformInboundOrderController.java`、`MerchantInboundCartonLabelController.java`、`service/InboundCartonLabelPrintService.java`。`fl_inbound_carton_label.sku_code` 是箱唛的 SKU 冻结快照,生成时经 `client/OrderCargoClient.java` 获取;既有记录按 `inbound_detail_id` 回填。`ONE_CLICK_DROPSHIP` 缺 SKU 时禁止生成箱唛,打印面显示 SKU;`box_code` 与二维码内容仍只携带稳定箱码。基线/升级 SQL 为 `src/main/resources/db/phase2-inbound-carton-label.sql`、`phase2-carton-label-versioning-migration.sql`、`../deploy/sql/头程数据库统一增量-MySQL8.sql` |
|
||||
| 一件代发测量后 PDA 合托 | `controller/PlatformDropshipPalletizationController.java`、`service/DropshipPalletizationService.java`、`mapper/DropshipPalletizationMapper.java`;测量通过进入 `WAIT_PALLETIZE` 并下发独立任务,不创建国内库存,封托生成 `DP` 托盘号,全部箱数完成后进入 `WAIT_SPLIT`。契约 `src/main/resources/openapi/dropship-palletization-v1.yaml` |
|
||||
| 墨家设备测量上传 | `controller/OpenApiInboundMeasurementController.java`、`dto/MojiaMeasurementDtos.java`、`service/MojiaMeasurementOpenApiService.java`、`security/MeasurementRequestAuthenticator.java`;接口契约见 `src/main/resources/openapi/firstleg-v1.yaml` 与 `../../docs/墨家设备测量上传接口说明-V3.md` |
|
||||
| 称重确认与整票库位块 | `service/MeasurementLocationOccupationService.java`、`InboundMeasurementService.java`、`MeasurementSubmissionService.java`、`client/WarehousePalletLocationClient.java`;Warehouse 块服务见 `../warehouse/src/main/java/com/baiyue/warehouse/service/LocationBlockService.java`,接口与上架引导见 `src/main/resources/openapi/firstleg-v1.yaml` |
|
||||
| 称重确认与整票库位块 | `service/MeasurementLocationOccupationService.java` 的 `ensureForConfirmedWeight/ensureForSubmission`、`InboundMeasurementService.java`、`MeasurementSubmissionService.java`、`client/WarehousePalletLocationClient.java`;Warehouse 块服务见 `../warehouse/src/main/java/com/baiyue/warehouse/service/LocationBlockService.java`,接口与上架引导见 `src/main/resources/openapi/firstleg-v1.yaml` |
|
||||
| 审核与初始计价 | `controller/PlatformInboundMeasurementReviewController.java`、`service/MeasurementReviewService.java`、`service/InitialInboundPricingService.java`、`dto/MeasurementApprovalResponse.java` |
|
||||
| 客户四档箱规与默认算法 | `controller/PlatformCustomerDimensionConfigController.java`、`service/CustomerDimensionConfigService.java`、`repository/CustomerDimensionConfigRepository.java`;固定 `SMALL/MEDIUM/LARGE/EXTRA_LARGE`,默认算法只允许 `ADD/MULTIPLY` |
|
||||
| FBA 主数据/计费与客户规则 | `controller/PlatformFbaMasterDataController.java`、`PlatformFbaPricingController.java`、`service/FbaPricingService.java`、`repository/FbaPricingRepository.java`;客户规则可保存多个版本,但每个客户仅一条默认规则,计算请求可提交已确认的重量/体积选择值 |
|
||||
@@ -84,7 +84,7 @@
|
||||
- 客户入库费用只在登录客户范围、测量审核通过、`feeStatus=SENT` 且存在已发布明细时可见;确认必须具备 `merchant:order:edit`,携带当前 `expectedFeeVersion` 与幂等键。成功后记录确认成员和时间并将该费用版本永久锁定,平台不得再调整;旧版本、未发送或重复异参确认均返回冲突,不做兼容覆盖。
|
||||
- PDA 拣货以 `fl_inbound_carton_label.box_code` 对应的稳定箱唛箱码为扫描身份;审核建单、重复打印、手工测量和墨家测量复用同一映射,版本字段只用于并发控制,不进入扫码文本。一个有效且未重复的箱码扫描表示拣出一箱,不得以 SKU、商品件数或页面展示字段替代箱码。任务下发与提交契约见 `src/main/resources/openapi/phase3-pda-convergence-v1.yaml`。
|
||||
- PDA 部分拣货生成二级子入库单时,数量必须守恒:`已拣数量 + 子单数量 = 拣货前主单可用数量`。子单只允许一层,不能继续拆分。
|
||||
- 首张称重图片确认时按 `max(已确认称重图片数, ceil(有效体积 / 默认托盘容量))` 预留并确认整票库位块;有效体积严格依次取墨家/人工测量实测体积、测量批次冻结申报体积、Order 最新客户申报体积,全部无效才拒绝,不得把后两级回退冒充实测。后续确认绑定最小空槽,所需槽数增长时通过 Warehouse `/blocks/expand` 原子追加,既有 `blockId/slotNo/reservationId/locationId` 不得变化。测量提交只校验并复用已存在权威块,不得首次分配。Firstleg 必须显式保存 `weightResultId -> blockId/slotNo/reservationId/locationId/locationCode`,按 `confirmedAt + weightResultId` 稳定顺序绑定,禁止按列表下标推断;普通连续位不足的新增槽由 Warehouse 放入共享 A0,不阻断确认。
|
||||
- 首张称重图片确认时按 `max(已确认称重图片数, ceil(有效体积 / 默认托盘容量))` 尽力预留并确认整票库位块;有效体积先取入库单已落地的正数实测体积,否则对最新测量批次的正数 `totalVolumeCbm/originalVolumeCbm` 取较大值,再无有效值才查询 Order 最新客户申报体积,全部无效则拒绝。后续确认绑定最小空槽,所需槽数增长时通过 Warehouse `/blocks/expand` 原子追加,既有 `blockId/slotNo/reservationId/locationId/sharedOverflow` 不得变化。库位指引失败只记录警告,不阻断称重确认;测量提交后以最新已确认图片调用 `ensureForSubmission` 再次补齐和校验,失败同样不回滚已提交的测量主流程。Firstleg 必须显式保存 `weightResultId -> blockId/slotNo/reservationId/locationId/locationCode`,按 `confirmedAt + weightResultId` 稳定顺序绑定,禁止按列表下标推断;普通连续位不足的新增槽由 Warehouse 放入共享 A0,不阻断确认。
|
||||
- 删除称重图片只解除图片和托盘快照绑定,将槽位恢复为 `RESERVED_EMPTY`;槽位和整票块继续归当前入仓码。解绑失败时不得调用文件删除,文件删除失败时本地解绑和重量结果删除必须回滚。
|
||||
- 头程快照必须保存 Warehouse `blockId + slotNo + reservationId`;A0 可被多个整票块共享 `locationId`,业务资源身份始终使用 `reservationId`,不得把 `locationId` 当作唯一标识。
|
||||
- PDA 部分拣货不得释放块内单槽;存在余量子单时通过 `/internal/warehouse/v1/pallet-locations/blocks/transfer` 将整段转移给子单,源单与余量子单可各自保留同一块槽及称重快照,因此 `fl_inbound_pallet_allocation` 的槽位与称重唯一键必须包含 `inbound_id`。整票全部拣完时只通过 `/internal/warehouse/v1/pallet-locations/blocks/release` 一次性释放全部普通位和 A0 预约,禁止恢复逐槽 `settlements/release-selected` 协议。
|
||||
|
||||
@@ -25,6 +25,8 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import feign.FeignException;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
@@ -37,6 +39,7 @@ import java.util.Set;
|
||||
/** 平台端入库测量、OCR 修正、重量确认和提交审核应用服务。 */
|
||||
@Service
|
||||
public class InboundMeasurementService {
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(InboundMeasurementService.class);
|
||||
/** 测量批次已提交,等待测量审核。 */
|
||||
private static final String STATUS_SUBMITTED = "SUBMITTED";
|
||||
/** OCR 或人工修正的重量已确认,可参与计费。 */
|
||||
@@ -298,8 +301,7 @@ public class InboundMeasurementService {
|
||||
request.adjustedWeightValue(), request.adjustedWeightUnit(),
|
||||
request.adjustmentReason(), operatorId, operatorName);
|
||||
} else if (WEIGHT_CONFIRMED.equals(weight.getRecognitionStatus())) {
|
||||
locationOccupation.ensureForConfirmedWeight(
|
||||
tenantId, inboundId, operatorId, weight.getId(), weight.getVersion());
|
||||
ensureLocationWithoutBlocking(tenantId, inboundId, operatorId, weight.getId(), weight.getVersion());
|
||||
return toWeight(weight);
|
||||
}
|
||||
BigDecimal value = effectiveValue(weight);
|
||||
@@ -329,11 +331,21 @@ public class InboundMeasurementService {
|
||||
insertRevision(weight, confirmedVersion, "CONFIRM", value, unit, value, unit,
|
||||
grossWeightKg, netWeightKg, null, operatorId, operatorName);
|
||||
recalculateConfirmedWeight(inbound, operatorId);
|
||||
locationOccupation.ensureForConfirmedWeight(
|
||||
tenantId, inboundId, operatorId, weight.getId(), confirmedVersion);
|
||||
ensureLocationWithoutBlocking(tenantId, inboundId, operatorId, weight.getId(), confirmedVersion);
|
||||
return toWeight(weight);
|
||||
}
|
||||
|
||||
private void ensureLocationWithoutBlocking(long tenantId, long inboundId, long operatorId,
|
||||
long weightResultId, int weightVersion) {
|
||||
try {
|
||||
locationOccupation.ensureForConfirmedWeight(
|
||||
tenantId, inboundId, operatorId, weightResultId, weightVersion);
|
||||
} catch (RuntimeException exception) {
|
||||
LOGGER.warn("称重确认已完成,但库位指引暂未生成,不阻断测量主流程: tenantId={}, inboundId={}, weightResultId={}",
|
||||
tenantId, inboundId, weightResultId, exception);
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deleteWeightResult(long tenantId, long inboundId, long resultId,
|
||||
int version, long operatorId) {
|
||||
|
||||
+10
-1
@@ -94,7 +94,16 @@ public class MeasurementLocationOccupationService {
|
||||
}
|
||||
}
|
||||
|
||||
public void requireOccupied(long tenantId, long inboundId) {
|
||||
public void ensureForSubmission(long tenantId, long inboundId, long operatorId) {
|
||||
var weights = confirmedWeights(tenantId, inboundId);
|
||||
InboundWeightResultEntity latest = weights.stream()
|
||||
.max(java.util.Comparator.comparing(InboundWeightResultEntity::getConfirmedAt)
|
||||
.thenComparing(InboundWeightResultEntity::getId))
|
||||
.orElseThrow(() -> new BizException(ResultCode.CONFLICT,
|
||||
"至少需要一张已确认的称重图片才能提交测量"));
|
||||
ensureForConfirmedWeight(tenantId, inboundId, operatorId, latest.getId(),
|
||||
latest.getVersion() == null ? 0 : latest.getVersion());
|
||||
|
||||
InboundOrderEntity order = requireOrder(tenantId, inboundId);
|
||||
long warehouseId = requireWarehouse(order);
|
||||
var confirmedWeights = confirmedWeights(tenantId, inboundId);
|
||||
|
||||
+9
-1
@@ -6,9 +6,12 @@ import com.baiyue.firstleg.dto.InboundMeasurementRequests;
|
||||
import com.baiyue.firstleg.dto.InboundMeasurementResponses;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
@Service
|
||||
public class MeasurementSubmissionService {
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(MeasurementSubmissionService.class);
|
||||
@Resource
|
||||
private InboundMeasurementService measurements;
|
||||
@Resource
|
||||
@@ -19,7 +22,12 @@ public class MeasurementSubmissionService {
|
||||
long tenantId, long inboundId, long operatorId,
|
||||
InboundMeasurementRequests.SubmitRequest request) {
|
||||
var result = measurements.submit(tenantId, inboundId, operatorId, request);
|
||||
locations.requireOccupied(tenantId, inboundId);
|
||||
try {
|
||||
locations.ensureForSubmission(tenantId, inboundId, operatorId);
|
||||
} catch (RuntimeException exception) {
|
||||
LOGGER.warn("测量已提交,但库位指引暂未补齐,不阻断提交审核主流程: tenantId={}, inboundId={}",
|
||||
tenantId, inboundId, exception);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.clearInvocations;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.spy;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
@@ -579,6 +580,28 @@ class InboundMeasurementServiceTest {
|
||||
assertThat(result.slotStatus()).isEqualTo("BOUND");
|
||||
}
|
||||
|
||||
@Test
|
||||
void locationAllocationFailureDoesNotBlockWeightConfirmation() {
|
||||
InboundOrderEntity inbound = inbound("WAIT_MEASURE");
|
||||
InboundWeightResultEntity weight = weight(20L, "30.000", "KG", "PENDING_CONFIRM", 0);
|
||||
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
|
||||
when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight);
|
||||
when(repository.updateWeight(weight)).thenReturn(true);
|
||||
when(repository.listWeights(1L, 10L)).thenReturn(List.of(weight));
|
||||
doThrow(new IllegalStateException("warehouse unavailable"))
|
||||
.when(locationOccupation).ensureForConfirmedWeight(1L, 10L, 9L, 20L, 1);
|
||||
|
||||
InboundMeasurementResponses.WeightResult result = service.confirmWeightResult(
|
||||
1L, 10L, 20L, 9L, "操作员",
|
||||
new InboundMeasurementRequests.ConfirmWeightResultRequest(0, null, null, null));
|
||||
|
||||
assertThat(result.recognitionStatus()).isEqualTo("CONFIRMED");
|
||||
assertThat(result.grossWeightKg()).isEqualByComparingTo("30.000");
|
||||
assertThat(result.locationCode()).isNull();
|
||||
verify(repository).insertRevision(any(InboundWeightRevisionEntity.class));
|
||||
verify(repository).updateInboundWeightSummary(inbound);
|
||||
}
|
||||
|
||||
@Test
|
||||
void confirmingFifthPalletSeparatelyAggregatesGrossAndCargoNetWeight() {
|
||||
InboundOrderEntity inbound = inbound("WAIT_MEASURE");
|
||||
|
||||
+58
-1
@@ -320,7 +320,7 @@ class MeasurementLocationOccupationServiceTest {
|
||||
when(measurements.listWeights(BUSINESS_TENANT, INBOUND)).thenReturn(weights);
|
||||
when(allocations.list(BUSINESS_TENANT, INBOUND)).thenReturn(List.of(first, second));
|
||||
|
||||
service.requireOccupied(BUSINESS_TENANT, INBOUND);
|
||||
service.ensureForSubmission(BUSINESS_TENANT, INBOUND, OPERATOR);
|
||||
|
||||
verify(warehouse, never()).reserveBlock(org.mockito.ArgumentMatchers.anyString(),
|
||||
org.mockito.ArgumentMatchers.any());
|
||||
@@ -330,6 +330,51 @@ class MeasurementLocationOccupationServiceTest {
|
||||
org.mockito.ArgumentMatchers.any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void submissionReconcilesMissingBlockFromLatestConfirmedWeight() {
|
||||
InboundOrderEntity inbound = inbound();
|
||||
inbound.setActualVolumeCbm(new BigDecimal("2.5000"));
|
||||
List<InboundWeightResultEntity> weights = confirmedWeights().subList(0, 1);
|
||||
when(workflow.findOwned(BUSINESS_TENANT, INBOUND)).thenReturn(inbound);
|
||||
when(measurements.listWeights(BUSINESS_TENANT, INBOUND)).thenReturn(weights);
|
||||
when(warehouse.reserveBlock(org.mockito.ArgumentMatchers.eq("internal-token"),
|
||||
org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(block("RESERVED", 3)));
|
||||
when(warehouse.confirmBlock(org.mockito.ArgumentMatchers.eq("internal-token"),
|
||||
org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(block("OCCUPIED", 3)));
|
||||
when(allocations.list(BUSINESS_TENANT, INBOUND))
|
||||
.thenReturn(List.of(), blockSlotsBoundTo(weights));
|
||||
|
||||
service.ensureForSubmission(BUSINESS_TENANT, INBOUND, OPERATOR);
|
||||
|
||||
verify(warehouse).reserveBlock(org.mockito.ArgumentMatchers.eq("internal-token"),
|
||||
org.mockito.ArgumentMatchers.argThat(request -> request.requiredSlotCount() == 3));
|
||||
verify(allocations).bindConfirmedWeights(BUSINESS_TENANT, INBOUND, weights);
|
||||
}
|
||||
|
||||
@Test
|
||||
void submissionExpandsUndersizedBlockWhenMeasuredVolumeGrows() {
|
||||
InboundOrderEntity inbound = inbound();
|
||||
inbound.setActualVolumeCbm(new BigDecimal("2.5000"));
|
||||
List<InboundWeightResultEntity> weights = confirmedWeights().subList(0, 1);
|
||||
var current = slot(1);
|
||||
current.setWeightResultId(weights.get(0).getId());
|
||||
current.setSlotStatus("BOUND");
|
||||
when(workflow.findOwned(BUSINESS_TENANT, INBOUND)).thenReturn(inbound);
|
||||
when(measurements.listWeights(BUSINESS_TENANT, INBOUND)).thenReturn(weights);
|
||||
when(allocations.list(BUSINESS_TENANT, INBOUND))
|
||||
.thenReturn(List.of(current), blockSlotsBoundTo(weights));
|
||||
when(warehouse.expandBlock(org.mockito.ArgumentMatchers.eq("internal-token"),
|
||||
org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(block("OCCUPIED", 3)));
|
||||
|
||||
service.ensureForSubmission(BUSINESS_TENANT, INBOUND, OPERATOR);
|
||||
|
||||
verify(warehouse).expandBlock(org.mockito.ArgumentMatchers.eq("internal-token"),
|
||||
org.mockito.ArgumentMatchers.argThat(request -> request.blockId() == 500L
|
||||
&& request.requiredSlotCount() == 3));
|
||||
verify(allocations).replaceBlock(BUSINESS_TENANT, INBOUND, WAREHOUSE,
|
||||
20, new BigDecimal("2.5000"), block("OCCUPIED", 3));
|
||||
}
|
||||
|
||||
private static InboundProperties properties() {
|
||||
InboundProperties value = new InboundProperties();
|
||||
value.setDefaultPalletCapacityCbm(new BigDecimal("1.200000"));
|
||||
@@ -377,6 +422,18 @@ class MeasurementLocationOccupationServiceTest {
|
||||
return value;
|
||||
}
|
||||
|
||||
private static List<com.baiyue.firstleg.entity.InboundPalletAllocationEntity> blockSlotsBoundTo(
|
||||
List<InboundWeightResultEntity> weights) {
|
||||
return java.util.stream.IntStream.rangeClosed(1, 3).mapToObj(slotNo -> {
|
||||
var slot = slot(slotNo);
|
||||
if (slotNo <= weights.size()) {
|
||||
slot.setWeightResultId(weights.get(slotNo - 1).getId());
|
||||
slot.setSlotStatus("BOUND");
|
||||
}
|
||||
return slot;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
private static List<InboundWeightResultEntity> confirmedWeights() {
|
||||
return java.util.stream.LongStream.rangeClosed(1, 3).mapToObj(index -> {
|
||||
InboundWeightResultEntity weight = new InboundWeightResultEntity();
|
||||
|
||||
+6
-2
@@ -9,6 +9,7 @@ import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@@ -35,7 +36,7 @@ class MeasurementSubmissionServiceTest {
|
||||
|
||||
assertThat(service.submit(10001L, 88L, 9L, request)).isSameAs(submitted);
|
||||
|
||||
verify(locations).requireOccupied(10001L, 88L);
|
||||
verify(locations).ensureForSubmission(10001L, 88L, 9L);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -44,7 +45,10 @@ class MeasurementSubmissionServiceTest {
|
||||
var submitted = new InboundMeasurementResponses.MeasurementSubmitted(
|
||||
30L, 3, "MEASURE_PENDING", "PENDING", "STANDARD", null, null, null);
|
||||
when(measurements.submit(10001L, 88L, 9L, request)).thenReturn(submitted);
|
||||
doThrow(new IllegalStateException("warehouse unavailable"))
|
||||
.when(locations).ensureForSubmission(10001L, 88L, 9L);
|
||||
|
||||
assertThat(service.submit(10001L, 88L, 9L, request)).isSameAs(submitted);
|
||||
verify(locations).requireOccupied(10001L, 88L);
|
||||
verify(locations).ensureForSubmission(10001L, 88L, 9L);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user