优化
This commit is contained in:
@@ -1416,11 +1416,11 @@ ON DUPLICATE KEY UPDATE
|
||||
built_in = 1, deleted = 0;
|
||||
|
||||
-- 新增平台操作权限默认只授予系统管理员。
|
||||
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id)
|
||||
SELECT 4000001, id, 0
|
||||
FROM iam_permission
|
||||
WHERE tenant_id = 0 AND app_code = 'PLATFORM'
|
||||
AND permission_code IN (
|
||||
INSERT IGNORE INTO iam_role_permission (role_id, permission_id, tenant_id)
|
||||
SELECT role.id, permission.id, role.tenant_id
|
||||
FROM iam_role role
|
||||
JOIN iam_permission permission ON permission.tenant_id = 0 AND permission.app_code = 'PLATFORM'
|
||||
AND permission.permission_code IN (
|
||||
'platform:customer:edit', 'platform:customer:status', 'platform:customer:reset-password',
|
||||
'platform:customer:delete', 'platform:customer:impersonate', 'platform:user:grant-role',
|
||||
'platform:permission:edit', 'platform:permission:status', 'platform:permission:delete',
|
||||
@@ -1428,7 +1428,7 @@ WHERE tenant_id = 0 AND app_code = 'PLATFORM'
|
||||
'platform:counterparty:list', 'platform:counterparty:create', 'platform:counterparty:edit', 'platform:counterparty:status',
|
||||
'platform:customer:export', 'platform:download:list', 'platform:download:all'
|
||||
)
|
||||
ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id);
|
||||
WHERE role.tenant_id = 0 AND role.role_code IN ('PLATFORM_ADMIN', 'ORDER_OPERATOR');
|
||||
|
||||
-- 已有客户运营角色继续具备客户导出与本人下载任务查询能力,但不授予查看全部任务。
|
||||
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id)
|
||||
@@ -7397,6 +7397,24 @@ BEGIN
|
||||
SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT = '统一迁移失败:三期入库装柜权限 ID 未完成迁移';
|
||||
END IF;
|
||||
IF (SELECT COUNT(DISTINCT CONCAT(role.role_code, '|', permission.permission_code))
|
||||
FROM baiyue_iam.iam_role_permission role_permission
|
||||
JOIN baiyue_iam.iam_role role
|
||||
ON role.id = role_permission.role_id AND role.tenant_id = role_permission.tenant_id
|
||||
JOIN baiyue_iam.iam_permission permission
|
||||
ON permission.id = role_permission.permission_id
|
||||
AND permission.tenant_id = role_permission.tenant_id
|
||||
WHERE role.tenant_id = 0
|
||||
AND role.role_code IN ('PLATFORM_ADMIN', 'ORDER_OPERATOR')
|
||||
AND permission.permission_code IN (
|
||||
'firstleg:inbound:split', 'firstleg:inbound:preload',
|
||||
'firstleg:inbound:preload-view', 'platform:firstleg:inbound:loading:edit',
|
||||
'platform:firstleg:inbound:loading:view', 'platform:firstleg:inbound:loading:complete',
|
||||
'platform:firstleg:inbound:delivery:complete', 'platform:firstleg:inbound:picking:edit',
|
||||
'platform:firstleg:inbound:picking:view')) <> 18 THEN
|
||||
SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT = '统一迁移失败:三期入库装柜角色授权未完整写入';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission
|
||||
WHERE tenant_id = 0 AND app_code = 'PLATFORM'
|
||||
AND ((id = 5102091 AND permission_code = 'platform:firstleg:container:view' AND parent_id = 5102090)
|
||||
@@ -7778,18 +7796,18 @@ BEGIN
|
||||
AND permission_code LIKE 'platform:firstleg:truck-appointment:%') <> 5 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:订舱或拖车预约权限未完整写入';
|
||||
END IF;
|
||||
IF (SELECT COUNT(DISTINCT p.permission_code)
|
||||
IF (SELECT COUNT(DISTINCT CONCAT(r.role_code, '|', p.permission_code))
|
||||
FROM baiyue_iam.iam_role_permission rp
|
||||
JOIN baiyue_iam.iam_role r ON r.id=rp.role_id AND r.tenant_id=rp.tenant_id
|
||||
JOIN baiyue_iam.iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id
|
||||
WHERE r.tenant_id=0 AND r.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR')
|
||||
AND p.permission_code LIKE 'platform:firstleg:ocean-booking:%') <> 10
|
||||
OR (SELECT COUNT(DISTINCT p.permission_code)
|
||||
AND p.permission_code LIKE 'platform:firstleg:ocean-booking:%') <> 20
|
||||
OR (SELECT COUNT(DISTINCT CONCAT(r.role_code, '|', p.permission_code))
|
||||
FROM baiyue_iam.iam_role_permission rp
|
||||
JOIN baiyue_iam.iam_role r ON r.id=rp.role_id AND r.tenant_id=rp.tenant_id
|
||||
JOIN baiyue_iam.iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id
|
||||
WHERE r.tenant_id=0 AND r.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR')
|
||||
AND p.permission_code LIKE 'platform:firstleg:truck-appointment:%') <> 5 THEN
|
||||
AND p.permission_code LIKE 'platform:firstleg:truck-appointment:%') <> 10 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:订舱或拖车预约角色授权未完整写入';
|
||||
END IF;
|
||||
END$$
|
||||
|
||||
@@ -113,9 +113,7 @@ public class LoadingPickService {
|
||||
history.setOperatorId(operatorId);
|
||||
history.setOperatorName(operatorName);
|
||||
repository.insert(history);
|
||||
if (repository.allPlanItemsPicked(task.getLoadingPlanId())) {
|
||||
loadingListService.finishFromPda(task.getLoadingPlanId(), taskId, operatorId, operatorName);
|
||||
}
|
||||
finishIfResolved(task, operatorId, operatorName);
|
||||
return view(requiredTask(taskId), request.warehouseId());
|
||||
}
|
||||
|
||||
@@ -137,6 +135,7 @@ public class LoadingPickService {
|
||||
}
|
||||
insertHistory(taskId, itemId, operationId, requestHash, "PARTIAL",
|
||||
request.finalPickedPackageCount(), null, operatorId, operatorName);
|
||||
finishIfResolved(task, operatorId, operatorName);
|
||||
return view(requiredTask(taskId), request.warehouseId());
|
||||
}
|
||||
|
||||
@@ -151,16 +150,23 @@ public class LoadingPickService {
|
||||
assertReplay(replay, taskId, itemId, requestHash);
|
||||
return view(requiredTask(taskId), request.warehouseId());
|
||||
}
|
||||
requiredLoadingTask(taskId);
|
||||
LoadingPickTaskEntity task = requiredLoadingTask(taskId);
|
||||
requireWarehouseItem(taskId, itemId, request.warehouseId());
|
||||
if (repository.skip(itemId, request.version(), request.warehouseId()) != 1) {
|
||||
throw conflict("跳拣仅允许未开始拣货的待拣明细,或明细版本已变化");
|
||||
}
|
||||
insertHistory(taskId, itemId, operationId, requestHash, "SKIP", 0, reason,
|
||||
operatorId, operatorName);
|
||||
finishIfResolved(task, operatorId, operatorName);
|
||||
return view(requiredTask(taskId), request.warehouseId());
|
||||
}
|
||||
|
||||
private void finishIfResolved(LoadingPickTaskEntity task, long operatorId, String operatorName) {
|
||||
if (repository.allPlanItemsPicked(task.getLoadingPlanId())) {
|
||||
loadingListService.finishFromPda(task.getLoadingPlanId(), task.getId(), operatorId, operatorName);
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void remove(long planItemId) {
|
||||
LoadingPickItemEntity item = repository.itemByPlanItem(planItemId);
|
||||
|
||||
@@ -10,6 +10,7 @@ import com.baiyue.firstleg.entity.*;
|
||||
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
|
||||
import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository;
|
||||
import com.baiyue.firstleg.repository.TruckAppointmentRepository;
|
||||
import com.baiyue.firstleg.repository.SmartLoadingRepository;
|
||||
import com.baiyue.firstleg.client.TruckAppointmentFileClient;
|
||||
import jakarta.annotation.Resource;
|
||||
import feign.FeignException;
|
||||
@@ -24,6 +25,7 @@ import org.springframework.web.multipart.MultipartFile;
|
||||
public class TruckAppointmentService {
|
||||
@Resource TruckAppointmentRepository repo;
|
||||
@Resource OceanBookingRepository oceanBookings;
|
||||
@Resource SmartLoadingRepository loadingPlans;
|
||||
@Resource TruckAppointmentFileClient files;
|
||||
@Value("${baiyue.security.internal-service-token}") String internalToken;
|
||||
public PageResult<TruckAppointmentEntity> page(Query q){return repo.page(q);} public TruckAppointmentEntity detail(long id){return required(id);}
|
||||
@@ -35,7 +37,8 @@ public class TruckAppointmentService {
|
||||
@Transactional public TruckAppointmentEntity edit(long id,long uid,String name,SaveRequest r){TruckAppointmentEntity e=required(id); checkVersion(e,r.expectedVersion()); if(!(e.getStatus()==TruckAppointmentStatus.DRAFT||e.getStatus()==TruckAppointmentStatus.PENDING_DISPATCH||e.getStatus()==TruckAppointmentStatus.RESCHEDULED))throw conflict("当前状态不可编辑"); validateBookingLink(r); copy(e,r); if(repo.updateEdit(e,r.expectedVersion())!=1)throw conflict("版本已变化,请刷新后重试"); record(e,e.getStatus().name(),e.getStatus().name(),"EDIT",null,uid,name); return required(id);}
|
||||
@Transactional public TruckAppointmentEntity action(long id,String action,ActionRequest r,String idem,String hash,long uid,String name){TruckAppointmentEntity e=required(id); checkVersion(e,r.expectedVersion()); if("RESCHEDULE".equals(action))throw conflict("改约请使用专用接口"); TruckAppointmentStatus from=e.getStatus(),to=next(from,action); if(repo.transition(id,from.name(),to.name(),e.getVersion())!=1)throw conflict("状态或版本已变化,请刷新后重试"); e.setStatus(to);e.setVersion(e.getVersion()+1);e.setRevision(e.getRevision()+1); record(e,from.name(),to.name(),action,r.reason(),uid,name); operation(e,action,idem,hash); return e;}
|
||||
@Transactional public TruckAppointmentEntity reschedule(long id,LocalDateTime at,ActionRequest r,long uid,String name){TruckAppointmentEntity e=required(id);checkVersion(e,r.expectedVersion()); if(e.getStatus()==TruckAppointmentStatus.COMPLETED||e.getStatus()==TruckAppointmentStatus.CANCELLED)throw conflict("当前状态不可改约"); String from=e.getStatus().name(); if(repo.reschedule(id,at,r.reason(),e.getVersion())!=1)throw conflict("状态或版本已变化,请刷新后重试");e.setStatus(TruckAppointmentStatus.RESCHEDULED);e.setPlannedLoadingAt(at);e.setVersion(e.getVersion()+1);e.setRevision(e.getRevision()+1);record(e,from,"RESCHEDULED","RESCHEDULE",r.reason(),uid,name);return e;}
|
||||
private void validateBookingLink(SaveRequest r){if(r.oceanBookingId()==null||r.oceanBookingId()<=0)throw bad("oceanBookingId不能为空"); OceanBookingEntity b=oceanBookings.find(r.oceanBookingId()); if(b==null)throw new BizException(ResultCode.NOT_FOUND,"关联订舱不存在"); if(!"RELEASED".equals(b.getStatus()))throw conflict("仅已放舱订舱可创建拖车预约"); if(r.customerId()!=null&&r.customerId()>0&&!Objects.equals(r.customerId(),b.getCustomerId()))throw bad("customerId与订舱客户不一致"); if(r.containerBookingId()!=null&&r.containerBookingId()>0){ContainerBookingEntity c=oceanBookings.containerBooking(r.containerBookingId()); if(c==null)throw new BizException(ResultCode.NOT_FOUND,"关联柜体不存在"); boolean allocated=oceanBookings.allocations(r.oceanBookingId()).stream().anyMatch(a->Objects.equals(a.getContainerBookingId(),r.containerBookingId())); if(!allocated)throw bad("containerBookingId未分配至该订舱"); if(r.warehouseId()!=null&&r.warehouseId()>0&&c.getWarehouseId()!=null&&!Objects.equals(r.warehouseId(),c.getWarehouseId()))throw bad("warehouseId与柜体不一致");}}
|
||||
private void validateBookingLink(SaveRequest r){if(r.oceanBookingId()==null||r.oceanBookingId()<=0)throw bad("oceanBookingId不能为空"); OceanBookingEntity b=oceanBookings.find(r.oceanBookingId()); if(b==null)throw new BizException(ResultCode.NOT_FOUND,"关联订舱不存在"); if(!"RELEASED".equals(b.getStatus()))throw conflict("仅已放舱订舱可创建拖车预约"); if(r.customerId()!=null&&r.customerId()>0&&!Objects.equals(r.customerId(),b.getCustomerId()))throw bad("customerId与订舱客户不一致"); if(r.containerBookingId()!=null&&r.containerBookingId()>0){ContainerBookingEntity c=oceanBookings.containerBooking(r.containerBookingId()); if(c==null)throw new BizException(ResultCode.NOT_FOUND,"关联柜体不存在"); boolean allocated=oceanBookings.allocations(r.oceanBookingId()).stream().anyMatch(a->Objects.equals(a.getContainerBookingId(),r.containerBookingId())); if(!allocated)throw bad("containerBookingId未分配至该订舱"); if(r.warehouseId()!=null&&r.warehouseId()>0&&c.getWarehouseId()!=null&&!Objects.equals(r.warehouseId(),c.getWarehouseId()))throw bad("warehouseId与柜体不一致");} validateLoadingPlanLink(r);}
|
||||
private void validateLoadingPlanLink(SaveRequest r){if(r.loadingPlanId()==null)return;if(r.loadingPlanId()<=0)throw bad("loadingPlanId必须大于0");if(r.containerBookingId()==null||r.containerBookingId()<=0)throw bad("关联装柜计划时containerBookingId不能为空");LoadingPlanEntity plan=loadingPlans.plan(r.loadingPlanId());if(plan==null)throw new BizException(ResultCode.NOT_FOUND,"关联装柜计划不存在");if(!Objects.equals(plan.getContainerBookingId(),r.containerBookingId()))throw bad("loadingPlanId与柜体不一致");}
|
||||
private TruckAppointmentStatus next(TruckAppointmentStatus s,String a){return switch(a){case"SUBMIT"->s==TruckAppointmentStatus.DRAFT?TruckAppointmentStatus.PENDING_DISPATCH:badStatus();case"DISPATCH"->s==TruckAppointmentStatus.PENDING_DISPATCH||s==TruckAppointmentStatus.RESCHEDULED?TruckAppointmentStatus.DISPATCHED:badStatus();case"PICKUP"->s==TruckAppointmentStatus.DISPATCHED?TruckAppointmentStatus.PICKUP_IN_PROGRESS:badStatus();case"ARRIVE"->s==TruckAppointmentStatus.PICKUP_IN_PROGRESS?TruckAppointmentStatus.ARRIVED_WAREHOUSE:badStatus();case"LOAD"->s==TruckAppointmentStatus.ARRIVED_WAREHOUSE?TruckAppointmentStatus.LOADING:badStatus();case"DEPART"->s==TruckAppointmentStatus.LOADING?TruckAppointmentStatus.DEPARTED:badStatus();case"COMPLETE"->s==TruckAppointmentStatus.DEPARTED?TruckAppointmentStatus.COMPLETED:badStatus();case"EXCEPTION"->s!=TruckAppointmentStatus.COMPLETED&&s!=TruckAppointmentStatus.CANCELLED?TruckAppointmentStatus.EXCEPTION:badStatus();case"CANCEL"->s!=TruckAppointmentStatus.COMPLETED?TruckAppointmentStatus.CANCELLED:badStatus();default->badStatus();};}
|
||||
private TruckAppointmentStatus badStatus(){throw conflict("不允许的状态动作");} private TruckAppointmentEntity required(long id){TruckAppointmentEntity e=repo.find(id);if(e==null)throw new BizException(ResultCode.NOT_FOUND,"拖车预约不存在");return e;} private void checkVersion(TruckAppointmentEntity e,int v){if(e.getVersion()!=v)throw conflict("版本已变化,请刷新后重试");} private static BizException conflict(String m){return new BizException(ResultCode.CONFLICT,m);} private static BizException bad(String m){return new BizException(ResultCode.BAD_REQUEST,m);}
|
||||
private void copy(TruckAppointmentEntity e,SaveRequest r){e.setOutboundOrderId(r.outboundOrderId());e.setOceanBookingId(r.oceanBookingId());e.setContainerBookingId(r.containerBookingId());e.setLoadingPlanId(r.loadingPlanId());e.setWarehouseId(r.warehouseId());e.setCustomerId(r.customerId());e.setCustomerName(r.customerName());e.setCarrierId(r.carrierId());e.setCarrierName(r.carrierName());e.setFleetContact(r.fleetContact());e.setFleetPhone(r.fleetPhone());e.setDriverName(r.driverName());e.setDriverPhone(r.driverPhone());e.setDriverIdCard(r.driverIdCard());e.setVehicleNo(r.vehicleNo());e.setStoreName(r.storeName());e.setStoreContact(r.storeContact());e.setStorePhone(r.storePhone());e.setStoreAddress(r.storeAddress());e.setStoreCity(r.storeCity());e.setStoreZipcode(r.storeZipcode());e.setPackageCount(r.packageCount());e.setGrossWeightKg(r.grossWeightKg());e.setVolumeCbm(r.volumeCbm());e.setPalletCount(r.palletCount());e.setPoNo(r.poNo());e.setPlannedLoadingAt(r.plannedLoadingAt());e.setTransitMinutes(r.transitMinutes());e.setPayableAmount(r.payableAmount());e.setReceivableAmount(r.receivableAmount());e.setCurrency(r.currency()==null?"RMB":r.currency());e.setRemark(r.remark());}
|
||||
|
||||
+3
@@ -35,6 +35,9 @@ class ServiceCenterUnifiedSqlContractTest {
|
||||
.contains("5102154")
|
||||
.contains("PLATFORM_ADMIN")
|
||||
.contains("ORDER_OPERATOR")
|
||||
.contains("COUNT(DISTINCT CONCAT(r.role_code, '|', p.permission_code))")
|
||||
.contains("p.permission_code LIKE 'platform:firstleg:ocean-booking:%') <> 20")
|
||||
.contains("p.permission_code LIKE 'platform:firstleg:truck-appointment:%') <> 10")
|
||||
.contains("assert_phase3_service_center_schema()")
|
||||
.contains("CALL assert_phase3_service_center_schema();");
|
||||
}
|
||||
|
||||
+6
-1
@@ -95,7 +95,12 @@ class SmartLoadingSqlContractTest {
|
||||
.contains("CALL migrate_legacy_loading_pick_task()")
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_inbound_loading_pick_task")
|
||||
.contains("5102121, 0, 'PLATFORM'")
|
||||
.contains("5102129, 0, 'PLATFORM'");
|
||||
.contains("5102129, 0, 'PLATFORM'")
|
||||
.contains("三期入库装柜角色授权未完整写入")
|
||||
.contains("permission.permission_code IN (")
|
||||
.contains("role.role_code IN ('PLATFORM_ADMIN', 'ORDER_OPERATOR')")
|
||||
.contains("permission.permission_code IN (\n 'firstleg:inbound:split'")
|
||||
.contains(")) <> 18 THEN");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.baiyue.firstleg.service;
|
||||
|
||||
import com.baiyue.firstleg.dto.LoadingPickDtos.PickRequest;
|
||||
import com.baiyue.firstleg.dto.LoadingPickDtos.PartialPickRequest;
|
||||
import com.baiyue.firstleg.dto.LoadingPickDtos.SkipRequest;
|
||||
import com.baiyue.firstleg.entity.LoadingPickHistoryEntity;
|
||||
import com.baiyue.firstleg.entity.LoadingPickItemEntity;
|
||||
import com.baiyue.firstleg.entity.LoadingPickTaskEntity;
|
||||
@@ -59,6 +61,38 @@ class LoadingPickServiceTest {
|
||||
verify(repository).insert(any(LoadingPickHistoryEntity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void completesPlanWhenLastItemIsPartiallyPicked() {
|
||||
LoadingPickTaskEntity task = task();
|
||||
LoadingPickItemEntity item = item();
|
||||
when(repository.history("partial-1")).thenReturn(null);
|
||||
when(repository.task(20L)).thenReturn(task);
|
||||
when(repository.item(20L, 30L, 101L)).thenReturn(item);
|
||||
when(repository.partial(30L, 1, 0, 101L)).thenReturn(1);
|
||||
when(repository.allPlanItemsPicked(10L)).thenReturn(true);
|
||||
when(repository.items(20L, 101L)).thenReturn(List.of(item));
|
||||
|
||||
service.partial(20L, 30L, new PartialPickRequest(101L, 1, List.of(), 0, "partial-1"), 9L, "operator");
|
||||
|
||||
verify(loadingListService).finishFromPda(10L, 20L, 9L, "operator");
|
||||
}
|
||||
|
||||
@Test
|
||||
void completesPlanWhenLastItemIsSkipped() {
|
||||
LoadingPickTaskEntity task = task();
|
||||
LoadingPickItemEntity item = item();
|
||||
when(repository.history("skip-1")).thenReturn(null);
|
||||
when(repository.task(20L)).thenReturn(task);
|
||||
when(repository.item(20L, 30L, 101L)).thenReturn(item);
|
||||
when(repository.skip(30L, 0, 101L)).thenReturn(1);
|
||||
when(repository.allPlanItemsPicked(10L)).thenReturn(true);
|
||||
when(repository.items(20L, 101L)).thenReturn(List.of(item));
|
||||
|
||||
service.skip(20L, 30L, new SkipRequest(101L, 0, "skip-1", "fixture unavailable"), 9L, "operator");
|
||||
|
||||
verify(loadingListService).finishFromPda(10L, 20L, 9L, "operator");
|
||||
}
|
||||
|
||||
private static LoadingPickTaskEntity task() {
|
||||
LoadingPickTaskEntity task = new LoadingPickTaskEntity();
|
||||
task.setId(20L);
|
||||
|
||||
@@ -7,9 +7,13 @@ import com.baiyue.firstleg.dto.TruckAppointmentDtos.SaveRequest;
|
||||
import com.baiyue.firstleg.entity.TruckAppointmentEntity;
|
||||
import com.baiyue.firstleg.entity.TruckAppointmentHistoryEntity;
|
||||
import com.baiyue.firstleg.entity.TruckAppointmentOperationEntity;
|
||||
import com.baiyue.firstleg.entity.LoadingPlanEntity;
|
||||
import com.baiyue.firstleg.entity.ContainerBookingEntity;
|
||||
import com.baiyue.firstleg.repository.TruckAppointmentRepository;
|
||||
import com.baiyue.firstleg.repository.SmartLoadingRepository;
|
||||
import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository;
|
||||
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
|
||||
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
@@ -28,6 +32,7 @@ import static org.mockito.Mockito.*;
|
||||
class TruckAppointmentServiceTest {
|
||||
@Mock TruckAppointmentRepository repository;
|
||||
@Mock OceanBookingRepository oceanBookings;
|
||||
@Mock SmartLoadingRepository loadingPlans;
|
||||
@InjectMocks TruckAppointmentService service;
|
||||
|
||||
@BeforeEach void setUp() { MockitoAnnotations.openMocks(this); }
|
||||
@@ -53,6 +58,7 @@ class TruckAppointmentServiceTest {
|
||||
when(request.expectedVersion()).thenReturn(0);
|
||||
when(request.oceanBookingId()).thenReturn(99L);
|
||||
when(request.customerId()).thenReturn(7L);
|
||||
when(request.loadingPlanId()).thenReturn(null);
|
||||
OceanBookingEntity booking = new OceanBookingEntity(); booking.setId(99L); booking.setStatus("RELEASED"); booking.setCustomerId(7L);
|
||||
when(oceanBookings.find(99L)).thenReturn(booking);
|
||||
service.create(7L, "admin", request);
|
||||
@@ -60,6 +66,47 @@ class TruckAppointmentServiceTest {
|
||||
verify(repository).history(any(TruckAppointmentHistoryEntity.class));
|
||||
}
|
||||
|
||||
@Test void createRejectsLoadingPlanFromAnotherContainer() {
|
||||
SaveRequest request = mock(SaveRequest.class);
|
||||
when(request.expectedVersion()).thenReturn(0);
|
||||
when(request.oceanBookingId()).thenReturn(99L);
|
||||
when(request.containerBookingId()).thenReturn(88L);
|
||||
when(request.loadingPlanId()).thenReturn(77L);
|
||||
OceanBookingEntity booking = new OceanBookingEntity(); booking.setId(99L); booking.setStatus("RELEASED");
|
||||
when(oceanBookings.find(99L)).thenReturn(booking);
|
||||
ContainerBookingEntity container = new ContainerBookingEntity(); container.setId(88L);
|
||||
when(oceanBookings.containerBooking(88L)).thenReturn(container);
|
||||
var allocation = new OceanBookingContainerAllocationEntity();
|
||||
allocation.setContainerBookingId(88L);
|
||||
when(oceanBookings.allocations(99L)).thenReturn(java.util.List.of(allocation));
|
||||
LoadingPlanEntity plan = new LoadingPlanEntity(); plan.setId(77L); plan.setContainerBookingId(66L);
|
||||
when(loadingPlans.plan(77L)).thenReturn(plan);
|
||||
|
||||
assertThatThrownBy(() -> service.create(7L, "admin", request))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("loadingPlanId").hasMessageContaining("柜体");
|
||||
verify(repository, never()).insert(any());
|
||||
}
|
||||
|
||||
@Test void createRejectsUnknownLoadingPlan() {
|
||||
SaveRequest request = mock(SaveRequest.class);
|
||||
when(request.expectedVersion()).thenReturn(0);
|
||||
when(request.oceanBookingId()).thenReturn(99L);
|
||||
when(request.containerBookingId()).thenReturn(88L);
|
||||
when(request.loadingPlanId()).thenReturn(77L);
|
||||
OceanBookingEntity booking = new OceanBookingEntity(); booking.setId(99L); booking.setStatus("RELEASED");
|
||||
when(oceanBookings.find(99L)).thenReturn(booking);
|
||||
ContainerBookingEntity container = new ContainerBookingEntity(); container.setId(88L);
|
||||
when(oceanBookings.containerBooking(88L)).thenReturn(container);
|
||||
var allocation = new OceanBookingContainerAllocationEntity();
|
||||
allocation.setContainerBookingId(88L);
|
||||
when(oceanBookings.allocations(99L)).thenReturn(java.util.List.of(allocation));
|
||||
when(loadingPlans.plan(77L)).thenReturn(null);
|
||||
|
||||
assertThatThrownBy(() -> service.create(7L, "admin", request))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("装柜计划不存在");
|
||||
verify(repository, never()).insert(any());
|
||||
}
|
||||
|
||||
@Test void actionAdvancesMainLifecycleAndWritesIdempotentOperation() {
|
||||
TruckAppointmentEntity e = entity(TruckAppointmentStatus.DRAFT, 0);
|
||||
when(repository.find(1L)).thenReturn(e);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -11,7 +11,7 @@ import process from 'node:process'
|
||||
const DEFAULT_GATEWAY = 'http://8.163.30.128:9000'
|
||||
const DEFAULT_USERNAME = 'platform_admin'
|
||||
const DEFAULT_PASSWORD = 'ChangeMe123!'
|
||||
const DEFAULT_MODULES = ['customers', 'overseas-storage-fees', 'system', 'master-data', 'orders', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow']
|
||||
const DEFAULT_MODULES = ['customers', 'overseas-storage-fees', 'system', 'master-data', 'orders', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'phase3-loading', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow']
|
||||
const WEIGHT_IMAGE_PATH = 'C:/Users/BY-OY/Desktop/上传图片包/测量图片/测量图片/orig_1cf27695-b2c3-44b3-98e3-c0be8b80f79a.JPG'
|
||||
const CUSTOMER_FIELDS = [
|
||||
'id', 'tenantCode', 'tenantName', 'status', 'legalName', 'legalRepresentative',
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
const MODULE = 'phase3-loading'
|
||||
const OPTIONS = { scenario: 'phase3-split-preload-loading', deploymentBlocker: true }
|
||||
|
||||
function id(value) { return value == null ? '' : String(value) }
|
||||
function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) }
|
||||
function failure(response) {
|
||||
return [400, 401, 403, 404, 409, 503].includes(response?.http)
|
||||
|| (response?.http === 200 && codeOf(response) !== '200')
|
||||
}
|
||||
|
||||
async function request(context, method, path, body, headers = {}) {
|
||||
try {
|
||||
const response = await fetch(`${context.options.gateway}${path}`, {
|
||||
method,
|
||||
headers: { Accept: 'application/json', Authorization: `Bearer ${context.token}`, ...(body === undefined ? {} : { 'Content-Type': 'application/json; charset=utf-8' }), ...headers },
|
||||
body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(60_000),
|
||||
})
|
||||
const raw = await response.text(); let payload = null
|
||||
try { payload = raw ? JSON.parse(raw) : null } catch { /* bounded raw evidence */ }
|
||||
return { method, path, http: response.status, body: payload, raw: payload === null ? raw.slice(0, 500) : null,
|
||||
requestId: payload?.requestId || response.headers.get('x-request-id'), traceId: payload?.traceId || response.headers.get('x-trace-id') }
|
||||
} catch (error) { return { method, path, http: 0, body: null, raw: String(error), requestId: null, traceId: null } }
|
||||
}
|
||||
|
||||
async function uploadEvidence(context, tenantId, inboundId) {
|
||||
const path = `/api/platform/v1/files/loading-evidence/${tenantId}/${inboundId}?evidenceType=SEAL`
|
||||
const form = new FormData()
|
||||
const png = Uint8Array.from([137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,6,0,0,0,31,21,196,137,0,0,0,13,73,68,65,84,8,215,99,248,207,192,240,31,0,5,0,1,255,137,153,61,29,0,0,0,0,73,69,78,68,174,66,96,130])
|
||||
form.append('file', new Blob([png], { type: 'image/png' }), `${context.marker}-seal.png`)
|
||||
try {
|
||||
const response = await fetch(`${context.options.gateway}${path}`, {
|
||||
method: 'POST', headers: { Accept: 'application/json', Authorization: `Bearer ${context.token}` },
|
||||
body: form, signal: AbortSignal.timeout(60_000),
|
||||
})
|
||||
const raw = await response.text(); let payload = null
|
||||
try { payload = raw ? JSON.parse(raw) : null } catch { /* bounded raw evidence */ }
|
||||
return { method: 'POST', path, http: response.status, body: payload,
|
||||
raw: payload === null ? raw.slice(0, 500) : null,
|
||||
requestId: payload?.requestId || response.headers.get('x-request-id'),
|
||||
traceId: payload?.traceId || response.headers.get('x-trace-id') }
|
||||
} catch (error) {
|
||||
return { method: 'POST', path, http: 0, body: null, raw: String(error), requestId: null, traceId: null }
|
||||
}
|
||||
}
|
||||
|
||||
function fixture(context) {
|
||||
return context.createdResources?.orchestrationFixtures?.inboundMeasurement
|
||||
|| context.createdResources?.inboundMeasurement
|
||||
|| context.createdResources?.orders
|
||||
}
|
||||
|
||||
export async function run(context) {
|
||||
const source = fixture(context)
|
||||
const tenantId = id(source?.tenantId)
|
||||
const inboundId = id(source?.inboundId)
|
||||
if (!tenantId || !inboundId) {
|
||||
context.addStep(MODULE, '取得本轮 WAIT_SPLIT 隔离入库单', null, false,
|
||||
'未找到 orders/inbound-measurement 本轮隔离 fixture,未使用历史入库单', OPTIONS)
|
||||
return
|
||||
}
|
||||
|
||||
const detailPath = `/api/platform/inbounds/${inboundId}?tenantId=${encodeURIComponent(tenantId)}`
|
||||
const initialResponse = await context.request('GET', detailPath)
|
||||
const initial = context.apiSuccess(MODULE, '读取三期隔离入库单状态与版本', initialResponse, `tenantId=${tenantId}, inboundId=${inboundId}`, OPTIONS)
|
||||
const initialStatus = initial?.summary?.processStatus
|
||||
const initialVersion = Number(initial?.summary?.version)
|
||||
context.assert(MODULE, '隔离入库单处于智能分柜门禁状态', initialResponse,
|
||||
['INBOUNDED', 'WAIT_SPLIT'].includes(initialStatus) && Number.isInteger(initialVersion),
|
||||
`status=${initialStatus || 'missing'}, version=${initial?.summary?.version ?? 'missing'}`, OPTIONS)
|
||||
if (!['INBOUNDED', 'WAIT_SPLIT'].includes(initialStatus) || !Number.isInteger(initialVersion)) return
|
||||
|
||||
const base = `/api/platform/v1/inbounds/${inboundId}`
|
||||
const suffix = context.marker.replace(/\D/g, '').slice(-10)
|
||||
const smart = await request(context, 'POST', `${base}/split/smart?tenantId=${encodeURIComponent(tenantId)}`, {
|
||||
expectedVersion: 0,
|
||||
items: [{ containerCode: `P3-${suffix}`, cargoReference: `REF-${suffix}`, packageCount: 1, volumeCbm: 0.01, remark: context.marker }],
|
||||
remark: context.marker,
|
||||
}, { 'Idempotency-Key': `${context.marker}-p3-smart`.slice(0, 128) })
|
||||
const smartData = context.apiSuccess(MODULE, '智能分柜并生成预装清单', smart, `inboundId=${inboundId}`, OPTIONS)
|
||||
const planId = id(smartData?.preloadPlan?.id)
|
||||
context.assert(MODULE, '智能分柜预装计划 PENDING、版本和明细正确', smart,
|
||||
smartData?.mainStatus === 'INBOUNDED' && Boolean(planId) && smartData?.preloadPlan?.status === 'PENDING'
|
||||
&& Number(smartData?.preloadPlan?.version) === 0 && smartData?.preloadPlan?.items?.length === 1,
|
||||
`mainStatus=${smartData?.mainStatus || 'missing'}, planId=${planId || 'missing'}, status=${smartData?.preloadPlan?.status || 'missing'}`, OPTIONS)
|
||||
if (!planId) return
|
||||
|
||||
const replay = await request(context, 'POST', `${base}/split/smart?tenantId=${encodeURIComponent(tenantId)}`, {
|
||||
expectedVersion: 0, items: [], remark: 'replay must not replace',
|
||||
}, { 'Idempotency-Key': `${context.marker}-p3-smart`.slice(0, 128) })
|
||||
context.assert(MODULE, '智能分柜幂等重放返回同一预装计划', replay,
|
||||
codeOf(replay) === '200' && id(replay.body?.data?.preloadPlan?.id) === planId
|
||||
&& replay.body?.data?.preloadPlan?.items?.length === 1,
|
||||
`HTTP=${replay.http}, code=${codeOf(replay)}, planId=${id(replay.body?.data?.preloadPlan?.id) || 'missing'}`, OPTIONS)
|
||||
|
||||
const confirming = await request(context, 'POST', `${base}/preload/confirming?tenantId=${encodeURIComponent(tenantId)}`,
|
||||
{ expectedVersion: Number(smartData.preloadPlan.version) }, { 'Idempotency-Key': `${context.marker}-p3-confirming`.slice(0, 128) })
|
||||
const confirmingData = context.apiSuccess(MODULE, '提交预装清单确认', confirming, '', OPTIONS)
|
||||
context.assert(MODULE, '预装清单进入 CONFIRMING 且版本递增', confirming,
|
||||
confirmingData?.preloadPlan?.status === 'CONFIRMING' && Number(confirmingData?.preloadPlan?.version) === 1,
|
||||
`status=${confirmingData?.preloadPlan?.status || 'missing'}, version=${confirmingData?.preloadPlan?.version ?? 'missing'}`, OPTIONS)
|
||||
if (confirmingData?.preloadPlan?.status !== 'CONFIRMING') return
|
||||
|
||||
const confirm = await request(context, 'POST', `${base}/preload/confirm?tenantId=${encodeURIComponent(tenantId)}`,
|
||||
{ expectedVersion: Number(confirmingData.preloadPlan.version) }, { 'Idempotency-Key': `${context.marker}-p3-confirm`.slice(0, 128) })
|
||||
const confirmed = context.apiSuccess(MODULE, '确认预装并进入装柜中', confirm, '', OPTIONS)
|
||||
context.assert(MODULE, '预装确认推进主状态 LOADING 和计划 CONFIRMED', confirm,
|
||||
confirmed?.mainStatus === 'LOADING' && confirmed?.preloadPlan?.status === 'CONFIRMED'
|
||||
&& Number(confirmed?.preloadPlan?.version) === 2,
|
||||
`mainStatus=${confirmed?.mainStatus || 'missing'}, planStatus=${confirmed?.preloadPlan?.status || 'missing'}, version=${confirmed?.preloadPlan?.version ?? 'missing'}`, OPTIONS)
|
||||
if (confirmed?.mainStatus !== 'LOADING') return
|
||||
|
||||
const preload = await context.request('GET', `${base}/preload?tenantId=${encodeURIComponent(tenantId)}`)
|
||||
const preloadData = context.apiSuccess(MODULE, '回查已确认预装清单', preload, '', OPTIONS)
|
||||
context.assert(MODULE, '预装清单归属、明细和 marker 持久化', preload,
|
||||
id(preloadData?.id) === planId && id(preloadData?.inboundId) === inboundId && id(preloadData?.tenantId) === tenantId
|
||||
&& preloadData?.items?.[0]?.containerCode === `P3-${suffix}` && preloadData?.remark === context.marker,
|
||||
`planId=${id(preloadData?.id) || 'missing'}, items=${preloadData?.items?.length ?? 'n/a'}`, OPTIONS)
|
||||
|
||||
const loadingResponse = await context.request('GET', detailPath)
|
||||
const loading = context.apiSuccess(MODULE, '读取装柜中入库单版本', loadingResponse, '', OPTIONS)
|
||||
const loadingVersion = Number(loading?.summary?.version)
|
||||
context.assert(MODULE, '入库单已进入 LOADING 且版本有效', loadingResponse,
|
||||
loading?.summary?.processStatus === 'LOADING' && Number.isInteger(loadingVersion),
|
||||
`status=${loading?.summary?.processStatus || 'missing'}, version=${loading?.summary?.version ?? 'missing'}`, OPTIONS)
|
||||
if (!Number.isInteger(loadingVersion)) return
|
||||
|
||||
const upload = await uploadEvidence(context, tenantId, inboundId)
|
||||
const uploaded = context.apiSuccess(MODULE, '上传本轮装柜铅封证据文件', upload, `fileName=${context.marker}-seal.png`, OPTIONS)
|
||||
const fileId = id(uploaded?.fileId)
|
||||
context.assert(MODULE, '装柜证据文件归属类型和媒体元数据正确', upload,
|
||||
Boolean(fileId) && uploaded?.businessType === 'LOADING_SEAL' && uploaded?.contentType === 'image/png'
|
||||
&& Number(uploaded?.sizeBytes) > 0,
|
||||
`fileId=${fileId || 'missing'}, businessType=${uploaded?.businessType || 'missing'}`, OPTIONS)
|
||||
if (!fileId) return
|
||||
|
||||
const evidence = await request(context, 'POST', `${base}/loading/evidence?tenantId=${encodeURIComponent(tenantId)}`, {
|
||||
expectedVersion: 0, evidenceType: 'SEAL', fileId, fileName: 'client-must-not-override.png', sealNo: `SEAL-${suffix}`, remark: context.marker,
|
||||
}, { 'Idempotency-Key': `${context.marker}-p3-evidence`.slice(0, 128) })
|
||||
const evidenceData = context.apiSuccess(MODULE, '写入本轮装柜铅封证据', evidence, '', OPTIONS)
|
||||
const evidenceItem = evidenceData?.evidences?.evidences?.find?.((item) => item.evidenceType === 'SEAL')
|
||||
context.assert(MODULE, '装柜证据类型、铅封号、marker 和版本正确', evidence,
|
||||
evidenceData?.mainStatus === 'LOADING' && evidenceItem?.sealNo === `SEAL-${suffix}`
|
||||
&& id(evidenceItem?.fileId) === fileId && evidenceItem?.fileName === `${context.marker}-seal.png`
|
||||
&& evidenceItem?.remark === context.marker && Number(evidenceItem?.version) === 0,
|
||||
`mainStatus=${evidenceData?.mainStatus || 'missing'}, evidenceId=${id(evidenceItem?.id) || 'missing'}`, OPTIONS)
|
||||
if (!evidenceItem?.id) return
|
||||
|
||||
const download = await context.requestBinary('GET', `/api/platform/v1/files/loading-evidence/${tenantId}/${inboundId}/${fileId}`)
|
||||
context.assert(MODULE, '下载本轮装柜证据文件', download,
|
||||
download.http === 200 && download.contentType.toLowerCase().includes('image/png') && download.bytes.length > 0,
|
||||
`bytes=${download.bytes.length}, contentType=${download.contentType || 'missing'}`, OPTIONS)
|
||||
|
||||
const forged = await request(context, 'POST', `${base}/loading/evidence?tenantId=${encodeURIComponent(tenantId)}`, {
|
||||
expectedVersion: Number(evidenceItem.version), evidenceType: 'SEAL', fileId: '999999999999999999',
|
||||
fileName: `${context.marker}-forged.png`, sealNo: `FORGED-${suffix}`, remark: context.marker,
|
||||
}, { 'Idempotency-Key': `${context.marker}-p3-forged-evidence`.slice(0, 128) })
|
||||
context.addStep(MODULE, '伪造装柜证据 fileId 归属校验拒绝', forged, failure(forged),
|
||||
`HTTP=${forged.http}, code=${codeOf(forged)}`, OPTIONS)
|
||||
|
||||
const evidenceList = await context.request('GET', `${base}/loading/evidence?tenantId=${encodeURIComponent(tenantId)}`)
|
||||
const evidenceListData = context.apiSuccess(MODULE, '回查装柜证据列表', evidenceList, '', OPTIONS)
|
||||
context.assert(MODULE, '装柜证据列表只读回显本轮铅封证据', evidenceList,
|
||||
evidenceListData?.evidences?.some?.((item) => id(item.id) === id(evidenceItem.id) && item.sealNo === `SEAL-${suffix}`),
|
||||
`evidences=${evidenceListData?.evidences?.length ?? 'n/a'}`, OPTIONS)
|
||||
|
||||
const complete = await request(context, 'POST', `${base}/loading/complete?tenantId=${encodeURIComponent(tenantId)}`,
|
||||
{ expectedVersion: loadingVersion, remark: context.marker }, { 'Idempotency-Key': `${context.marker}-p3-complete`.slice(0, 128) })
|
||||
const completed = context.apiSuccess(MODULE, '确认本轮入库单装柜完成', complete, '', OPTIONS)
|
||||
context.assert(MODULE, '装柜完成推进主状态 LOADED 且保留证据', complete,
|
||||
completed?.mainStatus === 'LOADED' && completed?.evidences?.evidences?.some?.((item) => id(item.id) === id(evidenceItem.id)),
|
||||
`mainStatus=${completed?.mainStatus || 'missing'}, evidences=${completed?.evidences?.evidences?.length ?? 'n/a'}`, OPTIONS)
|
||||
|
||||
const finalResponse = await context.request('GET', detailPath)
|
||||
const finalData = context.apiSuccess(MODULE, '回查三期装柜完成终态', finalResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '入库单终态 LOADED、版本递增且审计历史存在', finalResponse,
|
||||
finalData?.summary?.processStatus === 'LOADED' && Number(finalData?.summary?.version) === loadingVersion + 1
|
||||
&& finalData?.statusHistory?.some?.((item) => item.action === 'COMPLETE_LOADING' && item.operatorName),
|
||||
`status=${finalData?.summary?.processStatus || 'missing'}, version=${finalData?.summary?.version ?? 'missing'}, histories=${finalData?.statusHistory?.length ?? 'n/a'}`, OPTIONS)
|
||||
context.createdResources.phase3Loading = { tenantId, inboundId, preloadPlanId: planId, evidenceId: id(evidenceItem.id), marker: context.marker, finalStatus: 'LOADED' }
|
||||
|
||||
context.addCoverageGap(MODULE, 'outbound-aggregate', '出仓单/柜次主聚合与整体时间线',
|
||||
'当前公开 API 只有入库单级分柜/预装/装柜状态和独立货柜、订舱、拖车对象;没有出仓单主资源、子流程关联或整体时间线端点', 'contract-gap')
|
||||
context.addCoverageGap(MODULE, 'customs-collaboration', '报关资料、补件、代理提交与回执闭环',
|
||||
'当前 Firstleg 无报关清单版本、客户文件版本、审核退回、代理提交批次、回执上传/下载的公开 Controller 契约', 'not-implemented')
|
||||
context.addCoverageGap(MODULE, 'port-schedule-query', '港务/船期独立查询与时效性',
|
||||
'订舱和货柜仅保存 ETD/ETA、船名航次等快照字段;无港务或船期数据源、更新时间、过期标识和独立查询端点', 'contract-gap')
|
||||
context.addCoverageGap(MODULE, 'domestic-dispatch-gate', '国内出仓统一门禁与 WAITING_FOR_LOADING 终态',
|
||||
'当前公开状态链仅能到 LOADED/DELIVERED 或装柜计划 COMPLETED;无聚合订舱、拖车、拣货复核、柜封证据和报关条件的 check-gate/confirm-dispatch/reverse-dispatch API,也无 WAITING_FOR_LOADING 状态', 'not-implemented')
|
||||
context.addCoverageGap(MODULE, 'parallel-loading-models', '入库单级预装与柜次级智能装柜未串联',
|
||||
'fl_preload_plan 按入库单推进 LOADING/LOADED,fl_loading_plan 按柜次推进 EDITING/LOADING/COMPLETED;两套公开 API 不会互相创建、绑定或校验终态', 'contract-gap')
|
||||
context.addCoverageGap(MODULE, 'pda-picking-convergence', 'PDA 柜次拣货与入库单汇总拣货未收敛',
|
||||
'柜次级 /first-leg/pda-loading 和入库单级 /inbounds/{id}/loading/picking 是两套独立任务;当前无领取、作业员分配、复核和统一完成门禁', 'contract-gap')
|
||||
context.addCoverageGap(MODULE, 'loading-evidence-gate', '四步照片与铅封证据完整性门禁',
|
||||
'本轮真实 HTTP 仅上传并绑定 SEAL 文件即可成功进入 LOADED;服务端未要求 PHOTO_STEP_1..4 齐全,也未将柜次级 PDA 复核结果作为前置', 'contract-gap')
|
||||
}
|
||||
Reference in New Issue
Block a user