diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalInboundOrderController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalInboundOrderController.java index 0059b8b86..b49d52d86 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalInboundOrderController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalInboundOrderController.java @@ -37,6 +37,16 @@ import java.security.MessageDigest; @RestController @RequestMapping("/internal/firstleg/v1/inbounds") public class InternalInboundOrderController { + /** 海外废弃溯源只读取已生成的有效箱唛,不触发打印或补建。 */ + @org.springframework.web.bind.annotation.GetMapping("/{inboundId}/existing-carton-marks") + public ApiResponse> existingCartonMarks( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @org.springframework.web.bind.annotation.PathVariable long inboundId, + @org.springframework.web.bind.annotation.RequestParam long customerId, + @org.springframework.web.bind.annotation.RequestParam String inboundCode) { + requireToken(token); + return ApiResponse.success(cartonLabels.existingCartonMarks(customerId, inboundId, inboundCode)); + } @Resource private InboundOrderCreationService service; @Resource diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundCartonLabelService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundCartonLabelService.java index 28185e5a2..2f120ac09 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundCartonLabelService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundCartonLabelService.java @@ -45,6 +45,16 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver { @Resource private InboundOrderCreationService inboundOrderCreationService; @Value("${baiyue.security.internal-service-token}") private String internalToken; + /** 海外按数量收货后的只读溯源:仅读已有有效批次,不生成或修改箱号。 */ + public List existingCartonMarks(long customerId, long inboundId, String inboundCode) { + requireIds(customerId, inboundId); + var labels = repository.findActiveByInbound(customerId, inboundId); + if (labels.isEmpty()) return List.of(); + return requireConsistent(labels).stream() + .filter(label -> inboundCode.equals(label.getInboundCode())) + .map(InboundCartonLabelEntity::getBoxCode).toList(); + } + @Transactional public InboundCartonLabels.Batch getOrCreate(long tenantId, long inboundId) { requireIds(tenantId, inboundId); diff --git a/firstleg/src/main/resources/openapi/existing-carton-marks-internal-v1.yaml b/firstleg/src/main/resources/openapi/existing-carton-marks-internal-v1.yaml new file mode 100644 index 000000000..379c5171b --- /dev/null +++ b/firstleg/src/main/resources/openapi/existing-carton-marks-internal-v1.yaml @@ -0,0 +1,18 @@ +openapi: 3.0.3 +info: { title: 原入仓有效箱唛内部查询, version: 1.0.0 } +security: [{ internalToken: [] }] +paths: + /internal/firstleg/v1/inbounds/{inboundId}/existing-carton-marks: + get: + summary: 只读查询指定客户及原入仓已有有效箱唛批次,不补建箱号 + parameters: + - { in: path, name: inboundId, required: true, schema: { type: integer, format: int64, minimum: 1 } } + - { in: query, name: customerId, required: true, schema: { type: integer, format: int64, minimum: 1 } } + - { in: query, name: inboundCode, required: true, schema: { type: string } } + responses: + '200': + description: 统一响应;data为有效箱唛字符串数组,归属不匹配或无已有批次时为空;批次不完整返回业务错误 + '403': { description: 内部服务凭证缺失或无效 } +components: + securitySchemes: + internalToken: { type: apiKey, in: header, name: X-Internal-Service-Token } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCartonLabelServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCartonLabelServiceTest.java index 8cec18a1d..199ab7c31 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCartonLabelServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCartonLabelServiceTest.java @@ -30,6 +30,18 @@ import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class InboundCartonLabelServiceTest { + /** 海外溯源必须使用已有批次,不得因空记录补建箱号。 */ + @Test void existingMarksAreReadOnlyAndCheckInboundCode() { + when(repository.findActiveByInbound(1L, 2L)).thenReturn(List.of()); + assertThat(service.existingCartonMarks(1L, 2L, "IN")).isEmpty(); + var label = new InboundCartonLabelEntity(); + label.setTotalCartonCount(1); label.setCartonIndex(1); label.setLabelVersion(1); + label.setBatchStatus("ACTIVE"); label.setInboundCode("IN"); label.setBoxCode("IN#1-1"); + when(repository.findActiveByInbound(1L, 2L)).thenReturn(List.of(label)); + assertThat(service.existingCartonMarks(1L, 2L, "IN")).containsExactly("IN#1-1"); + assertThat(service.existingCartonMarks(1L, 2L, "OTHER")).isEmpty(); + org.mockito.Mockito.verifyNoInteractions(orderCargoClient, inboundOrderCreationService, labelSkus); + } @Mock private InboundCartonLabelRepository repository; @Mock private OrderCargoClient orderCargoClient; @Mock private PrintableOrderInboundClient printableOrderInboundClient; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/client/FirstlegCartonMarksClient.java b/lastleg-service/src/main/java/com/baiyue/lastleg/client/FirstlegCartonMarksClient.java new file mode 100644 index 000000000..2224d98da --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/client/FirstlegCartonMarksClient.java @@ -0,0 +1,16 @@ +package com.baiyue.lastleg.client; + +import com.baiyue.common.core.api.ApiResponse; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.*; +import java.util.List; + +/** FBA按数量实收库存的原箱唛只读来源。 */ +@FeignClient(name="firstleg-service", contextId="firstlegCartonMarksClient", url="${FIRSTLEG_SERVICE_URL:${baiyue.clients.firstleg.url:}}") +public interface FirstlegCartonMarksClient { + /** 以可信库存来源及客户身份读取有效批次。 */ + @GetMapping("/internal/firstleg/v1/inbounds/{inboundId}/existing-carton-marks") + ApiResponse> marks(@RequestHeader("X-Internal-Service-Token") String token, + @PathVariable("inboundId") long inboundId, @RequestParam("customerId") long customerId, + @RequestParam("inboundCode") String inboundCode); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardInventoryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardInventoryMapper.java index de68f3178..63eb0967b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardInventoryMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardInventoryMapper.java @@ -12,6 +12,29 @@ import java.util.List; @Mapper public interface CargoDiscardInventoryMapper { + record BulkSource(String sourceInboundId, String inboundCode, int packageCount) { } + + /** 仅完整单SKU的FBA数量实收可回查原箱;多SKU/分批或逐箱库存仍走精确关联。 */ + @Select(""" + SELECT o.source_inbound_id,o.inbound_code,o.package_count FROM ll_inventory_position p + JOIN ll_inventory_balance b ON b.id=p.balance_id AND b.customer_id=p.customer_id + JOIN ll_order o ON o.id=b.inbound_order_id AND o.customer_id=b.customer_id + WHERE p.id=#{positionId} AND p.customer_id=#{customerId} + AND o.destination_type='FBA' AND o.order_type='FIRST_LEG' + AND o.source_inbound_id=o.root_inbound_id + AND p.batch_no=o.inbound_order_no AND p.overseas_warehouse_id=o.overseas_warehouse_id + AND (SELECT COUNT(*) FROM ll_order_detail d WHERE d.order_id=o.id AND d.customer_id=o.customer_id)=1 + AND EXISTS (SELECT 1 FROM ll_order_detail d WHERE d.order_id=o.id AND d.customer_id=o.customer_id + AND d.sku_code=b.sku_code AND d.package_count=o.package_count) + AND NOT EXISTS (SELECT 1 FROM ll_inbound_plan_carton c WHERE c.order_id=o.id AND c.customer_id=o.customer_id) + AND EXISTS (SELECT 1 FROM ll_inventory_ledger l WHERE l.position_id=p.id AND l.customer_id=p.customer_id + AND l.business_type='STOCK_IN' AND l.carton_mark IS NULL AND l.delta_packages>0) + """) + BulkSource bulkSource(@Param("customerId") String customerId, @Param("positionId") Long positionId); + + /** 已实际废弃的原箱不得因仍属于头程有效批次而再次扫描。 */ + @Select("SELECT actual_carton_marks_json FROM ll_cargo_discard_item WHERE customer_id=#{customerId} AND inbound_code=#{inboundCode} AND actual_discard_packages>0 AND actual_carton_marks_json IS NOT NULL") + List discardedMarkSnapshots(@Param("customerId") String customerId, @Param("inboundCode") String inboundCode); // 箱唛状态可能保留入库快照;废弃必须同时存在当前客户、同托盘的真实可用库存。 String AVAILABLE_STOCK = " AND EXISTS (SELECT 1 FROM ll_dropship_order_profile dp " + diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/repository/CargoDiscardStockRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/repository/CargoDiscardStockRepository.java index 1e6603801..45c69a641 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/repository/CargoDiscardStockRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/repository/CargoDiscardStockRepository.java @@ -12,6 +12,10 @@ import java.util.List; /** 封装普通库存废弃的客户隔离读取、锁箱和申请快照保存。 */ @Repository public class CargoDiscardStockRepository { + /** 读取允许按原有效批次溯源的数量入库来源。 */ + public CargoDiscardInventoryMapper.BulkSource bulkSource(String customer, Long position) { return stock.bulkSource(customer, position); } + /** 返回已实际废弃的箱唛快照,防止后续数量申请复扫。 */ + public List discardedMarkSnapshots(String customer, String inboundCode) { return stock.discardedMarkSnapshots(customer, inboundCode); } @Resource private CargoDiscardInventoryMapper stock; @Resource private InventoryPositionMapper positions; @Resource private InventoryBalanceMapper balances; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardStockService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardStockService.java index d2e6ddab6..9628d623e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardStockService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardStockService.java @@ -21,6 +21,8 @@ public class CargoDiscardStockService { @Resource private InventoryCommandService commands; @Resource private ObjectMapper objectMapper; @Resource private CargoDiscardRelabelStockService relabelStock; + @Resource private com.baiyue.lastleg.client.FirstlegCartonMarksClient firstlegCartonMarks; + @org.springframework.beans.factory.annotation.Value("${baiyue.security.internal-service-token:}") private String internalToken; /** 展示可用库存及相关托盘,箱唛完整时才提供逐箱选择。 */ public List candidates(String customer, String keyword) { @@ -273,12 +275,25 @@ public class CargoDiscardStockService { /** 可扫描范围只来自该申请明细仓位的原入仓及当前净实收箱唛。 */ public List allowedCartonMarks(String customer, CargoDiscardItem line) { position(customer, line.getInventoryPositionId()); - return repository.traceableMarks(customer, line.getInventoryPositionId()); + var marks = repository.traceableMarks(customer, line.getInventoryPositionId()); + if (!marks.isEmpty()) return marks; + var source = repository.bulkSource(customer, line.getInventoryPositionId()); + if (source == null) return marks; + if (internalToken == null || internalToken.isBlank()) throw conflict("原入仓箱唛服务未就绪,请稍后重试"); + var response = firstlegCartonMarks.marks(internalToken, Long.parseLong(source.sourceInboundId()), Long.parseLong(customer), source.inboundCode()); + if (response == null || !"200".equals(response.code()) || response.data() == null) throw conflict("原入仓箱唛读取失败,请稍后重试"); + if (response.data().size() != source.packageCount()) throw conflict("原入仓箱唛与整票实收数量不一致,请核对库存来源"); + var allowed = new LinkedHashSet<>(response.data()); + for (String snapshot : repository.discardedMarkSnapshots(customer, source.inboundCode())) { + try { allowed.removeAll(objectMapper.readValue(snapshot, new com.fasterxml.jackson.core.type.TypeReference>() {})); } + catch (Exception e) { throw new IllegalStateException("已废弃箱唛读取失败", e); } + } + return List.copyOf(allowed); } /** 无逐箱快照时仍需验证原入库箱唛归属,无法溯源的箱子拒绝出库。 */ public void validateActualCartonMarks(String customer, CargoDiscardItem line, List marks) { position(customer, line.getInventoryPositionId()); - var allowed = new HashSet<>(repository.traceableMarks(customer, line.getInventoryPositionId())); + var allowed = new HashSet<>(allowedCartonMarks(customer, line)); if (!allowed.containsAll(marks)) throw conflict("箱唛不属于本废弃单的原入仓库存,请核对后重新扫描"); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/CargoDiscardTraceableCartonMapperTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/CargoDiscardTraceableCartonMapperTest.java index 4ef5018d6..f06e7b2fc 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/CargoDiscardTraceableCartonMapperTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/CargoDiscardTraceableCartonMapperTest.java @@ -13,6 +13,42 @@ import static org.junit.jupiter.api.Assertions.*; /** 用真实 Mapper 查询验证废弃扫码的来源隔离和已移出箱拦截。 */ class CargoDiscardTraceableCartonMapperTest { + /** 数量回查限定原始FBA单SKU实收;逐箱、跨客户和拆分来源不得扩大范围。 */ + @Test void bulkSourceRequiresUnsplitSingleSkuFbaReceipt() { + var source = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""); + var db = new JdbcTemplate(source); + db.execute("CREATE TABLE ll_inventory_position(id BIGINT,customer_id VARCHAR(20),balance_id BIGINT,batch_no VARCHAR(30),overseas_warehouse_id VARCHAR(20))"); + db.execute("CREATE TABLE ll_inventory_balance(id BIGINT,customer_id VARCHAR(20),inbound_order_id BIGINT,sku_code VARCHAR(20))"); + db.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR(20),source_inbound_id VARCHAR(20),root_inbound_id VARCHAR(20),inbound_code VARCHAR(20),destination_type VARCHAR(20),order_type VARCHAR(20),inbound_order_no VARCHAR(30),overseas_warehouse_id VARCHAR(20),package_count INT)"); + db.execute("CREATE TABLE ll_order_detail(order_id BIGINT,customer_id VARCHAR(20),sku_code VARCHAR(20),package_count INT)"); + db.execute("CREATE TABLE ll_inventory_ledger(position_id BIGINT,customer_id VARCHAR(20),business_type VARCHAR(30),carton_mark VARCHAR(80),delta_packages BIGINT)"); + db.execute("CREATE TABLE ll_inbound_plan_carton(order_id BIGINT,customer_id VARCHAR(20))"); + db.execute("CREATE TABLE ll_cargo_discard_item(customer_id VARCHAR(20),inbound_code VARCHAR(20),actual_discard_packages INT,actual_carton_marks_json VARCHAR(100))"); + db.update("INSERT INTO ll_cargo_discard_item VALUES('C','IN',1,'[\"IN#22-1\"]'),('X','IN',1,'[\"FOREIGN\"]'),('C','OTHER',1,'[\"OTHER\"]'),('C','IN',0,'[\"UNUSED\"]')"); + db.update("INSERT INTO ll_inventory_position VALUES(41,'C',1,'IP-1','WH')"); + db.update("INSERT INTO ll_inventory_balance VALUES(1,'C',10,'SKU')"); + db.update("INSERT INTO ll_order VALUES(10,'C','12','12','IN','FBA','FIRST_LEG','IP-1','WH',22)"); + db.update("INSERT INTO ll_order_detail VALUES(10,'C','SKU',22)"); + db.update("INSERT INTO ll_inventory_ledger VALUES(41,'C','STOCK_IN',NULL,12)"); + var config = new Configuration(new Environment("test", new JdbcTransactionFactory(), source)); + config.addMapper(CargoDiscardInventoryMapper.class); + try (var session = new SqlSessionFactoryBuilder().build(config).openSession(true)) { + var mapper = session.getMapper(CargoDiscardInventoryMapper.class); + assertEquals(java.util.List.of("[\"IN#22-1\"]"), mapper.discardedMarkSnapshots("C", "IN")); + assertEquals(new CargoDiscardInventoryMapper.BulkSource("12", "IN", 22), mapper.bulkSource("C", 41L)); + assertNull(mapper.bulkSource("OTHER", 41L)); + db.update("UPDATE ll_order SET source_inbound_id='13'"); session.clearCache(); + assertNull(mapper.bulkSource("C", 41L)); + db.update("UPDATE ll_order SET source_inbound_id='12',destination_type='OVERSEAS'"); session.clearCache(); + assertNull(mapper.bulkSource("C", 41L)); + db.update("UPDATE ll_order SET destination_type='FBA'"); + db.update("INSERT INTO ll_inbound_plan_carton VALUES(10,'C')"); session.clearCache(); + assertNull(mapper.bulkSource("C", 41L)); + db.update("DELETE FROM ll_inbound_plan_carton"); + db.update("INSERT INTO ll_order_detail VALUES(10,'C','OTHER-SKU',1)"); session.clearCache(); + assertNull(mapper.bulkSource("C", 41L)); + } + } /** 原入仓箱和净实收流水可溯源,其他订单、客户、托盘及已移出箱均不得混入。 */ @Test void onlyCurrentSourceCartonsAreTraceable() { var source = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardStockServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardStockServiceTest.java index 4f787f2cb..69e10de43 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardStockServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardStockServiceTest.java @@ -22,6 +22,33 @@ import static org.mockito.Mockito.*; import static org.mockito.ArgumentMatchers.*; class CargoDiscardStockServiceTest { + /** 已有关联箱唛时只认可关联范围,不合并头程箱唛扩大可扫集合。 */ + @Test void associatedMarksTakePriorityOverFirstlegLookup() { + var line = new CargoDiscardItem(); line.setInventoryPositionId(42L); + when(stock.traceableCartonMarks("C1", 42L)).thenReturn(List.of("LINKED")); + assertEquals(List.of("LINKED"), service.allowedCartonMarks("C1", line)); + verify(stock, never()).bulkSource(anyString(), anyLong()); + service.validateActualCartonMarks("C1", line, List.of("LINKED")); + assertThrows(ResponseStatusException.class, () -> service.validateActualCartonMarks("C1", line, List.of("UNLINKED"))); + } + /** FBA数量入库从原有效箱唛批次恢复范围,并排除历史实际废弃箱。 */ + @Test void bulkFbaReadsAuthoritativeMarksAndRejectsForeignOrDiscardedBox() { + var client = mock(com.baiyue.lastleg.client.FirstlegCartonMarksClient.class); + ReflectionTestUtils.setField(service, "firstlegCartonMarks", client); + ReflectionTestUtils.setField(service, "internalToken", "token"); + var line = new CargoDiscardItem(); line.setInventoryPositionId(42L); + // 使用真实数字客户ID作为跨服务身份。 + when(positions.findTenantPosition("1", 42L)).thenReturn(position); + when(stock.bulkSource("1", 42L)).thenReturn(new CargoDiscardInventoryMapper.BulkSource("12", "IN", 2)); + when(client.marks("token", 12L, 1L, "IN")).thenReturn(com.baiyue.common.core.api.ApiResponse.success(List.of("IN#2-1", "IN#2-2"))); + when(stock.discardedMarkSnapshots("1", "IN")).thenReturn(List.of("[\"IN#2-2\"]")); + assertEquals(List.of("IN#2-1"), service.allowedCartonMarks("1", line)); + service.validateActualCartonMarks("1", line, List.of("IN#2-1")); + assertThrows(ResponseStatusException.class, () -> service.validateActualCartonMarks("1", line, List.of("OTHER#2-1"))); + assertThrows(ResponseStatusException.class, () -> service.validateActualCartonMarks("1", line, List.of("IN#2-2"))); + when(client.marks("token", 12L, 1L, "IN")).thenReturn(null); + assertThrows(ResponseStatusException.class, () -> service.allowedCartonMarks("1", line)); + } final CargoDiscardInventoryMapper stock = mock(CargoDiscardInventoryMapper.class); final InventoryPositionMapper positions = mock(InventoryPositionMapper.class); final InventoryBalanceMapper balances = mock(InventoryBalanceMapper.class);