This commit is contained in:
2026-11-04 11:13:29 +08:00
parent 413e1e83e8
commit ba49035942
10 changed files with 173 additions and 2 deletions
@@ -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<List<String>> marks(@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable("inboundId") long inboundId, @RequestParam("customerId") long customerId,
@RequestParam("inboundCode") String inboundCode);
}
@@ -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<String> discardedMarkSnapshots(@Param("customerId") String customerId, @Param("inboundCode") String inboundCode);
// 箱唛状态可能保留入库快照;废弃必须同时存在当前客户、同托盘的真实可用库存。
String AVAILABLE_STOCK = " AND EXISTS (SELECT 1 FROM ll_dropship_order_profile dp " +
@@ -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<String> discardedMarkSnapshots(String customer, String inboundCode) { return stock.discardedMarkSnapshots(customer, inboundCode); }
@Resource private CargoDiscardInventoryMapper stock;
@Resource private InventoryPositionMapper positions;
@Resource private InventoryBalanceMapper balances;
@@ -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<CargoDiscardResponses.Candidate> candidates(String customer, String keyword) {
@@ -273,12 +275,25 @@ public class CargoDiscardStockService {
/** 可扫描范围只来自该申请明细仓位的原入仓及当前净实收箱唛。 */
public List<String> 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<List<String>>() {})); }
catch (Exception e) { throw new IllegalStateException("已废弃箱唛读取失败", e); }
}
return List.copyOf(allowed);
}
/** 无逐箱快照时仍需验证原入库箱唛归属,无法溯源的箱子拒绝出库。 */
public void validateActualCartonMarks(String customer, CargoDiscardItem line, List<String> 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("箱唛不属于本废弃单的原入仓库存,请核对后重新扫描");
}
@@ -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", "");
@@ -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);