diff --git a/deploy/sql/README.md b/deploy/sql/README.md index 5674b6160..a5fbf3fd6 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -1,5 +1,7 @@ # 单测试环境数据库空库初始化 +车辆与司机档案照片复用:先执行 `lastleg-fleet-resource-photos.sql` 新增照片版本表,再配套发布 File、Lastleg 和 Admin。车辆详情/司机详情维护照片,排车保存时复制缺失照片为当趟证据;已有照片不被后续档案替换覆盖。驾驶证统一接受 PDF/JPG/PNG。源基线与海外空库基线已同步;本次未执行环境 SQL 或部署。 + 放舱后预计到港窄维护需要配套更新 `trg_ocean_booking_before_update`:仅从冻结比较中移除 `estimated_arrival_at`,其余运输、预报、关系方及柜型冻结不变。定义已同步 `firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql` 与国内统一基线;已有 TEST 需另行审阅并授权替换该触发器,不重放空库文件,不回填数据。本轮只准备定义,未执行 SQL 或部署。 货柜关联已放舱订舱时不再限制 ISO 柜号格式,Firstleg 模块基线与国内统一空库基线已移除 `chk_container_booking_iso6346`,保留同订舱柜号唯一键。既有 TEST 发布前需核对并单独授权移除该约束:`ALTER TABLE baiyue_firstleg.fl_container_booking DROP CHECK chk_container_booking_iso6346;`。配套发布 Firstleg 与 Admin;不重放空库初始化、不修改业务数据。本次仅准备源码与 SQL 定义,未执行环境 SQL 或部署。 diff --git a/deploy/sql/lastleg-fleet-resource-photos.sql b/deploy/sql/lastleg-fleet-resource-photos.sql new file mode 100644 index 000000000..128e4f9b9 --- /dev/null +++ b/deploy/sql/lastleg-fleet-resource-photos.sql @@ -0,0 +1,16 @@ +-- 仅新增车辆及司机档案照片版本表,可重复执行,不回填业务数据。 +USE baiyue_lastleg; + +CREATE TABLE IF NOT EXISTS ll_fleet_resource_photo ( + resource_kind VARCHAR(16) NOT NULL COMMENT '档案类型:车辆或司机', + resource_id BIGINT NOT NULL COMMENT '车辆或司机档案标识', + record_version BIGINT NOT NULL COMMENT '照片版本,从一递增', + file_id BIGINT NOT NULL COMMENT '文件服务照片标识', + file_name VARCHAR(512) NOT NULL COMMENT '照片原始文件名', + content_type VARCHAR(64) NOT NULL COMMENT '照片媒体类型', + size_bytes BIGINT NOT NULL COMMENT '照片字节数', + created_by VARCHAR(64) NOT NULL COMMENT '上传操作人标识', + created_at DATETIME(3) NOT NULL COMMENT '上传时间(UTC)', + PRIMARY KEY (resource_kind, resource_id, record_version), + UNIQUE KEY uk_fleet_resource_photo_file (resource_kind, resource_id, file_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车辆及司机档案照片版本记录'; diff --git a/deploy/sql/migrations/overseas/baiyue_lastleg/V34__fleet_resource_photos.sql b/deploy/sql/migrations/overseas/baiyue_lastleg/V34__fleet_resource_photos.sql new file mode 100644 index 000000000..af97b30b5 --- /dev/null +++ b/deploy/sql/migrations/overseas/baiyue_lastleg/V34__fleet_resource_photos.sql @@ -0,0 +1,14 @@ + +CREATE TABLE IF NOT EXISTS ll_fleet_resource_photo ( + resource_kind VARCHAR(16) NOT NULL COMMENT '档案类型:车辆或司机', + resource_id BIGINT NOT NULL COMMENT '车辆或司机档案标识', + record_version BIGINT NOT NULL COMMENT '照片版本,从一递增', + file_id BIGINT NOT NULL COMMENT '文件服务照片标识', + file_name VARCHAR(512) NOT NULL COMMENT '照片原始文件名', + content_type VARCHAR(64) NOT NULL COMMENT '照片媒体类型', + size_bytes BIGINT NOT NULL COMMENT '照片字节数', + created_by VARCHAR(64) NOT NULL COMMENT '上传操作人标识', + created_at DATETIME(3) NOT NULL COMMENT '上传时间(UTC)', + PRIMARY KEY (resource_kind, resource_id, record_version), + UNIQUE KEY uk_fleet_resource_photo_file (resource_kind, resource_id, file_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车辆及司机档案照片版本记录'; diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index c422ca478..35d33d62d 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -5106,4 +5106,18 @@ CREATE TABLE ll_storage_rent_preparation ( KEY idx_storage_rent_release (release_status,updated_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='仓租扫码资金预占与结算快照'; -SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 239 AS initialized_tables; +CREATE TABLE ll_fleet_resource_photo ( + resource_kind VARCHAR(16) NOT NULL COMMENT '档案类型:车辆或司机', + resource_id BIGINT NOT NULL COMMENT '车辆或司机档案标识', + record_version BIGINT NOT NULL COMMENT '照片版本,从一递增', + file_id BIGINT NOT NULL COMMENT '文件服务照片标识', + file_name VARCHAR(512) NOT NULL COMMENT '照片原始文件名', + content_type VARCHAR(64) NOT NULL COMMENT '照片媒体类型', + size_bytes BIGINT NOT NULL COMMENT '照片字节数', + created_by VARCHAR(64) NOT NULL COMMENT '上传操作人标识', + created_at DATETIME(3) NOT NULL COMMENT '上传时间(UTC)', + PRIMARY KEY (resource_kind, resource_id, record_version), + UNIQUE KEY uk_fleet_resource_photo_file (resource_kind, resource_id, file_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车辆及司机档案照片版本记录'; + +SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 240 AS initialized_tables; diff --git a/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java b/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java index 94bdeb1af..5de589c09 100644 --- a/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java +++ b/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java @@ -7,6 +7,8 @@ import java.util.Arrays; /** File-owned storage policy. Business visibility and lifecycle remain in the calling service. */ enum BusinessFileCategory { + FLEET_VEHICLE_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), + FLEET_DRIVER_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), FLEET_DRIVER_LICENSE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), FLEET_TRIP_OPERATION_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_DISPATCH_VEHICLE_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), @@ -90,7 +92,7 @@ enum BusinessFileCategory { if (this == FINANCE_VEHICLE_VOUCHER && tenantId != 0) { throw new BizException(ResultCode.BAD_REQUEST, "公司车辆付款凭证必须归属平台"); } - if (this == FLEET_VEHICLE_RECORD_PHOTO && tenantId != 0) { + if ((this == FLEET_VEHICLE_RECORD_PHOTO || this == FLEET_VEHICLE_PHOTO || this == FLEET_DRIVER_PHOTO) && tenantId != 0) { throw new BizException(ResultCode.BAD_REQUEST, "车辆运维照片必须归属平台"); } if (this == FLEET_DRIVER_LICENSE && tenantId != 0) { diff --git a/file/src/main/resources/openapi/fleet-resource-photo-v1.yaml b/file/src/main/resources/openapi/fleet-resource-photo-v1.yaml new file mode 100644 index 000000000..db6873fb6 --- /dev/null +++ b/file/src/main/resources/openapi/fleet-resource-photo-v1.yaml @@ -0,0 +1,88 @@ +openapi: 3.0.3 +info: + title: 车辆与司机档案照片内部文件契约 + version: 1.0.0 + description: >- + 复用InternalBusinessFileController,固定平台tenantId=0、businessType=FLEET_VEHICLE_PHOTO或FLEET_DRIVER_PHOTO、 + businessId=对应车辆或司机内部ID。Lastleg负责档案存在性、列表/维护权限、版本关联和历史可见性; + File校验内部服务令牌、平台复合归属、真实非空文件、MIME与签名。最大10MiB,仅JPG/PNG。 + 上传只生成不可变对象,不改变驾驶证审核结论,不生成公开URL,排车保存时复制为本趟证据,后续档案替换不改历史排车。 +security: + - InternalServiceToken: [] +paths: + /internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}: + parameters: + - {$ref: '#/components/parameters/TenantId'} + - {$ref: '#/components/parameters/BusinessType'} + - {$ref: '#/components/parameters/BusinessId'} + post: + summary: 上传车辆或司机档案照片 + parameters: + - {name: X-Operator-Id, in: header, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [file] + properties: + file: {type: string, format: binary} + responses: + '200': {$ref: '#/components/responses/Metadata'} + '403': {description: 内部服务令牌无效} + /internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}/{fileId}: + parameters: + - {$ref: '#/components/parameters/TenantId'} + - {$ref: '#/components/parameters/BusinessType'} + - {$ref: '#/components/parameters/BusinessId'} + - {$ref: '#/components/parameters/FileId'} + get: + summary: 校验归属并读取元数据 + responses: + '200': {$ref: '#/components/responses/Metadata'} + '403': {description: 内部服务令牌无效} + /internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}/{fileId}/content: + parameters: + - {$ref: '#/components/parameters/TenantId'} + - {$ref: '#/components/parameters/BusinessType'} + - {$ref: '#/components/parameters/BusinessId'} + - {$ref: '#/components/parameters/FileId'} + get: + summary: 校验归属并下载真实文件 + responses: + '200': + description: 文件内容,业务错误按JSON code识别 + content: + image/jpeg: {schema: {type: string, format: binary}} + image/png: {schema: {type: string, format: binary}} + '403': {description: 内部服务令牌无效} +components: + securitySchemes: + InternalServiceToken: {type: apiKey, in: header, name: X-Internal-Service-Token} + parameters: + TenantId: {name: tenantId, in: path, required: true, schema: {type: string, enum: ['0']}} + BusinessType: {name: businessType, in: path, required: true, schema: {type: string, enum: [FLEET_VEHICLE_PHOTO, FLEET_DRIVER_PHOTO]}} + BusinessId: {name: businessId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + FileId: {name: fileId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + responses: + Metadata: + description: 统一响应;业务成功code为200,复合归属不匹配按业务错误返回 + content: + application/json: + schema: + type: object + properties: + code: {type: string} + message: {type: string, nullable: true} + data: + type: object + properties: + fileId: {type: string} + tenantId: {type: string, enum: ['0']} + businessType: {type: string, enum: [FLEET_VEHICLE_PHOTO, FLEET_DRIVER_PHOTO]} + businessId: {type: string} + fileName: {type: string} + sha256: {type: string, pattern: '^[0-9a-f]{64}$'} + contentType: {type: string, enum: [image/jpeg, image/png]} + sizeBytes: {type: integer, format: int64, minimum: 1, maximum: 10485760} diff --git a/file/src/test/java/com/baiyue/file/service/FleetResourcePhotoCategoryTest.java b/file/src/test/java/com/baiyue/file/service/FleetResourcePhotoCategoryTest.java new file mode 100644 index 000000000..b39e55735 --- /dev/null +++ b/file/src/test/java/com/baiyue/file/service/FleetResourcePhotoCategoryTest.java @@ -0,0 +1,16 @@ +package com.baiyue.file.service; + +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.*; + +class FleetResourcePhotoCategoryTest { + @Test void profilePhotosOnlyAllowPlatformImagesAndPositiveResourceIdentities() { + for(var category : new BusinessFileCategory[]{BusinessFileCategory.FLEET_VEHICLE_PHOTO,BusinessFileCategory.FLEET_DRIVER_PHOTO}) { + category.validateOwner(0); + assertThatThrownBy(() -> category.validateOwner(1)).hasMessageContaining("平台"); + assertThat(category.formatPolicy()).isEqualTo(BusinessFileCategory.FormatPolicy.IMAGE); + assertThat(category.maxSizeBytes()).isEqualTo(10L*1024*1024); + assertThatThrownBy(() -> category.storageBusinessId("not-a-resource")).isInstanceOf(RuntimeException.class); + } + } +} diff --git a/lastleg-service/docs/business-index.md b/lastleg-service/docs/business-index.md index 6840e75c4..a2fcc3006 100644 --- a/lastleg-service/docs/business-index.md +++ b/lastleg-service/docs/business-index.md @@ -36,6 +36,7 @@ | 托盘分配算法 | `inboundplan/service/PalletAllocator.java` | | FBA 预约创建、详情与编辑 | `InboundPlanController.java` 的 `/fba-appointments/**`、`inboundplan/service/InboundFbaAppointmentService.java`、`BatchFbaAppointmentRequest.java`、`UpdateFbaAppointmentRequest.java` | | PDA 入仓、实收、上架与完成 | `inboundplan/pda/controller/ReceivingContainerController.java`、`inboundplan/pda/service/ReceivingContainerService.java`、内部编排 `InboundPdaWorkflowService.java`;公网入口固定为 `/api/pda/v1/last-leg/receiving-containers/**`,`/warehouse-profile` 只按 Token 绑定仓解析共享仓库中文名称与编码,旧 planNo/allocationId Controller 已移除。多计划同柜返回计划级 `assigned/mergeAllowed/urgent/mergeHint`;普通 FBA/OVERSEAS 分别使用 bulk/carton 收货,一件代发只允许 `/current/receipts/pallet` 按国内冻结来源托盘整托收货。合托只作现场可选提示;全部计划完整绑定真实 pallet/location/lock 前不得查看、准备或签收该柜,首扫只确认已预留绑定,不得重新分配计划资源 | +| FBA收货短少确认 | `inboundplan/controller/InboundPlanController.java` 的 `/{planNo}/confirm-shortage` → `InboundPdaWorkflowService.confirmShortage`;仅平台入库编辑权限指定客户操作,要求待差异核对、原版本及应收/实收快照、原因和幂等键。仅同柜次同目的仓收货组单票且冻结申报单SKU可按总数确认,多SKU及跨票自动分摊拒绝;逐SKU实收不得超原申报,确认后保留应收与现场结束时间,按实收生成库存和原计租批次,计划/PDA任务进入 `PARTIAL_INBOUND`,尾程订单完成入库;同事务交接原预排,明确零实收的托盘同步移除预排明细、释放本车占用及预约分配并重算整车货量;缺少零实收证据或已有拣货拒绝。不重复发布卸柜事件。无实收、超收或逐SKU事实不一致拒绝;空预留托盘不能再次作为计划货源排车。 | | PDA 卸柜照片上传、归属校验与预览 | `ReceivingContainerController.java` 的 `/current/unload-photos/files`、`/current/unload-photos/files/{fileId}/download`,网关 `inboundplan/pda/gateway/UnloadPhotoFileGateway.java`、`HttpUnloadPhotoFileGateway.java`;File 类型固定 `OVERSEAS_UNLOAD_PHOTO`,下载只允许当前会话已签收的 fileId 并返回 `no-store` | | PDA 国内装柜照片对照 | `ReceivingContainerController.java` 的 `/current/domestic-loading-photos/{evidenceType}/content`、`ContainerLoadingEvidenceMapper.java`、`DomesticLoadingEvidenceGateway.java`;元数据按物理柜投影,不保存客户,下载必须经当前 PDA 会话鉴权并复用 `FILE_SERVICE_URL` 只读访问 File 服务,单图不可用不改变拆柜状态 | | 卸柜照片与海外卸柜完成 Outbox | `ReceivingContainerController.java`、`ReceivingContainerService.java`、`inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`、`entity/LastlegEventOutbox.java`、`mapper/LastlegEventOutboxMapper.java`;多计划同柜只能在全柜完成后调用 `publishContainerCompleted`,按来源柜预订 ID 幂等发布一次,完成时刻与拍照时刻分离;事件模型见 `../common-event-contract/AGENTS.md` | @@ -87,7 +88,7 @@ - 车次成本:`fleet/trip/FleetTripCostService.java`、`fleet-trip-costs-v1.yaml`;按DEPART实测及外接实收分摊,不产生客户账单/付款。 - 报价/统计:`fleet/quote/`、`fleet/reporting/`,合同`fleet-quotes-v1.yaml`、`fleet-operations-v1.yaml`;实测凭证按车次归属,仅当前/历史版本引用可下载。 - 自有业务运营:`fleet/ownedbusiness/`、`fleet-owned-business-v1.yaml`,共同规则见`../../../docs/海外仓业务/自有业务首版开发契约.md`;原排车装车后三阶段按无实际拼车货物展示,车辆归属独立,仅补金额、逐目的地到达、里程、油耗与照片。原详情只经`dispatch.port.DispatchDetailReader`读取,原装车事务经`OwnedBusinessOwnershipCapture`捕获归属,不另建履约流程。 -- 外配业务运营:`fleet/outsourcedbusiness/`、`fleet-outsourced-business-v1.yaml`,共同规则见`../../../docs/海外仓业务/外配业务首版开发契约.md`;原排车装车后三阶段按实际接入需求池拼车货物展示,车辆归属独立,包含MASTER/EXTERNAL两种资源模式。只登记整车承运金额、逐目的仓到达和五装车照片,无里程/油耗或成本拆分。内部可信作用域复用四表,入口权限与自有严格分开,不引入第二履约模型。 +- 外配业务运营:`fleet/outsourcedbusiness/`、`fleet-outsourced-business-v1.yaml`,共同规则见`../../../docs/海外仓业务/外配业务首版开发契约.md`;原排车装车后三阶段按实际接入需求池拼车货物展示,车辆归属独立,包含MASTER/EXTERNAL两种资源模式。只登记整车承运金额、逐目的仓到达和五类装车照片(每类最多5张),无里程/油耗或成本拆分。内部可信作用域复用四表,入口权限与自有严格分开,不引入第二履约模型。 - 外接选价合同:`fleet-quote-candidates-v1.yaml`、`fleet-trips-v1.yaml`;来源快照与成交价分开,确认承运复验。 - 在途交接:`fleet/exception/`、`fleet-handovers-v1.yaml`;后台确认交接,旧资源分别结束任务,DEPART不重写。 @@ -163,6 +164,8 @@ - 定向验证:从后端根执行 `mvn -pl lastleg-service -am test`。 - 事件链路变更至少覆盖重复事件、相同来源入仓单、事务回滚、并发单号、内部令牌和租户越权测试。 +- 2026-10-26 装车资料:五类依次为司机驾驶证、车牌照片、装车完成、封车照片、BOL司机签字;既有技术槽位与文件归属保持。每类1至5张,未传 `replaceFileId` 追加,指定当前同类文件ID则精确替换;油耗仍单张替换。五类至少各一张且装车确认后才自动派送,同类多张不能充当其他类别,附加行版本/幂等/历史及当前文件下载授权共同保留。无需新增数据库字段,PDA、Admin、Lastleg需配套发布。 + ### 海外人工收费首版切换 - 除仓租外,换标报价、外接约定运费、拆柜打托成本仅保留业务依据,不再自动生成客户应收。旧派送应收参数须改由海外费用登记审核,供应商应付不变。订单详情新增费用统一委托人工费用服务。 diff --git a/lastleg-service/docs/lastleg-internal-modularization-baseline.md b/lastleg-service/docs/lastleg-internal-modularization-baseline.md index 6a0a01299..77e3cffa7 100644 --- a/lastleg-service/docs/lastleg-internal-modularization-baseline.md +++ b/lastleg-service/docs/lastleg-internal-modularization-baseline.md @@ -264,3 +264,7 @@ node lastleg-service/scripts/analyze-lastleg-dependencies.mjs --source-set all - - 两条边均属于同一 `lastleg-service` 部署单元内的海外仓出库与派送正式链路;本次只冻结已存在的生产依赖,不扩大海外主数据或安全投影的允许方向。 2026-10-03 外接来货使用`FleetBookingIncomingGateway`,仅`transfer.fleet.FleetIncomingAdapter`实现该端口并复用`FleetDispatchGateway`的目的键算法;这两个精确类引用进入reviewedClassDependencies,不放开整个转运切片读取fleet实现。fleet不导入转运DTO/Service/Mapper,转运仍维护实收与库存;车次只保存实收证据和确认的承运载荷。包无环及禁止其他消费者扩大依赖的反向测试继续生效。 + +### 档案照片只读复用边界(2026-10-26) + +`DispatchMasterPhotoService`仅通过`fleet.photo.port.FleetResourcePhotoPort`及其照片DTO读取档案照片,再经排车文件网关生成当趟副本。`TransportTaskService`仅新增照片类别DTO引用,不读取照片Mapper或Repository。`fleet.photo`复用车辆`VehicleRepository`应用端口与司机应用服务校验身份,不反向依赖排车;精确边界纳入依赖合同。 diff --git a/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs b/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs index b1b7ab34f..fe32447f9 100644 --- a/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs +++ b/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs @@ -64,6 +64,8 @@ test('fleet stays a business slice and its policies have no infrastructure depen && edge.target.startsWith('com.baiyue.lastleg.fleet.')); assert.ok(dispatchEdges.length > 0); assert.ok(dispatchEdges.every(edge => /\.(FleetDispatchGateway|FleetDispatchBindingGuard|FleetTripExecutionService|FleetExecutionModels|FleetCompletionModels)$/.test(edge.target) + || edge.source === 'com.baiyue.lastleg.dispatch.service.DispatchMasterPhotoService' + && ['com.baiyue.lastleg.fleet.photo.port.FleetResourcePhotoPort', 'com.baiyue.lastleg.fleet.photo.FleetResourcePhotoModels'].includes(edge.target) || edge.source === 'com.baiyue.lastleg.dispatch.fleet.FleetDispatchPlanningAdapter' && /\.(FleetBookingIncomingGateway|FleetDispatchPlanningGateway)$/.test(edge.target) || edge.source === 'com.baiyue.lastleg.dispatch.fleet.FleetDispatchPlanningMapper' @@ -82,6 +84,7 @@ test('fleet stays a business slice and its policies have no infrastructure depen 'TransportTaskProjection->trip.FleetTripEntity', 'TransportTaskProjection->trip.TransportTripChanged', 'TransportTaskService->business.FleetPoolOrderQueries', 'TransportTaskService->driver.DriverService', 'TransportTaskService->reporting.TripOperationsEvidenceGateway', + 'TransportTaskService->photo.FleetResourcePhotoModels', // 取货照片沿既有运输边界读取车次身份/时间,并复用司机资格与证照授权;不开放 Mapper。 'TransportReviewPhotos->driver.DriverService', 'TransportReviewPhotos->trip.FleetTripEntity', ].some(reference => `${edge.source}->${edge.target}` === `com.baiyue.lastleg.dispatch.transport.${reference.replace('->', '->com.baiyue.lastleg.fleet.')}`)), diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchAggregateMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchAggregateMapper.java index 60088ca91..1405a5e66 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchAggregateMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchAggregateMapper.java @@ -122,7 +122,7 @@ public interface DispatchAggregateMapper { estimated_arrival_at=#{r.estimatedArrivalAt},remark=#{r.remark},loading_completed_at=#{r.loadingCompletedAt}, loading_completion_hash=#{r.loadingCompletionHash},loading_confirmation_json=#{r.loadingConfirmationJson},arrangement_key=#{r.arrangementKey}, arrangement_business_hash=#{r.arrangementBusinessHash},departed_at=#{r.departedAt}, - delivery_completed_at=#{r.deliveryCompletedAt},package_count=#{r.packageCount},pallet_count=#{r.palletCount},picked_package_count=#{r.pickedPackageCount}, + delivery_completed_at=#{r.deliveryCompletedAt},item_count=#{r.itemCount},route=#{r.route},package_count=#{r.packageCount},pallet_count=#{r.palletCount},picked_package_count=#{r.pickedPackageCount}, shipped_package_count=#{r.shippedPackageCount},updated_at=#{r.updatedAt} WHERE id=#{r.id} AND record_version=#{version} """) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java index 23968c094..9a356e834 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java @@ -11,6 +11,8 @@ import java.util.List; public interface DispatchCandidateMapper { /** 自动预留托盘不是实收;尚未开始收货时仍以计划来源预排,不生成库存或实托装载事实。 */ String PLAN_ONLY = "(p.warehouse_pallet_id IS NULL OR (COALESCE(p.assignment_status,'')='RESERVED' AND COALESCE(p.actual_package_count,0)=0))"; + /** 完成入库或已确认短少后,不再把未实收的预留托盘当作在途货;真实库存仍可排车。 */ + String OPEN_PLAN = "COALESCE(ip.status,'') NOT IN ('COMPLETED','PARTIAL_INBOUND')"; /** 在途计划的全部真实来源必须已离港;人工节点覆盖后不再借用原柜节点。 */ String DEPARTED_PLAN = "EXISTS (SELECT 1 FROM ll_inbound_plan_pallet_allocation da JOIN ll_inbound_plan_line dl " + @@ -178,7 +180,7 @@ public interface DispatchCandidateMapper { "JOIN ll_inbound_plan_line hl ON hl.id=ha.line_id " + "JOIN ll_order ho ON ho.id=hl.order_id AND ho.customer_id=ip.customer_id " + "WHERE ha.pallet_id=p.id AND ho.detained=1) " + - "AND (NOT " + PLAN_ONLY + " OR (" + DEPARTED_PLAN + ")) " + + "AND (NOT " + PLAN_ONLY + " OR (" + OPEN_PLAN + " AND " + DEPARTED_PLAN + ")) " + "AND (" + PLAN_ONLY + " OR EXISTS (SELECT 1 FROM ll_inventory_position pos " + "WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0)) " + "AND NOT EXISTS (SELECT 1 FROM ll_dispatch_pallet_lock d " + @@ -283,7 +285,7 @@ public interface DispatchCandidateMapper { "JOIN ll_inbound_plan_line hl ON hl.id=ha.line_id " + "JOIN ll_order ho ON ho.id=hl.order_id AND ho.customer_id=ip.customer_id " + "WHERE ha.pallet_id=p.id AND ho.detained=1) " + - "AND (NOT " + PLAN_ONLY + " OR (" + DEPARTED_PLAN + ")) " + + "AND (NOT " + PLAN_ONLY + " OR (" + OPEN_PLAN + " AND " + DEPARTED_PLAN + ")) " + "AND (" + PLAN_ONLY + " OR EXISTS (SELECT 1 FROM ll_inventory_position pos " + "WHERE pos.customer_id=#{customerId} AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0)) " + "AND NOT EXISTS (SELECT 1 FROM ll_warehouse_transfer_inventory_allocation ta " + diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchSourceMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchSourceMapper.java index e2a2baf4b..6e6863d6f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchSourceMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchSourceMapper.java @@ -33,7 +33,7 @@ public interface DispatchSourceMapper { FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id AND ip.customer_id=p.customer_id JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id AND a.customer_id=p.customer_id JOIN ll_inbound_plan_line l ON l.id=a.line_id AND l.customer_id=a.customer_id - WHERE p.id=#{id} AND p.customer_id=#{customer} AND ip.status='COMPLETED' AND p.warehouse_pallet_id IS NOT NULL + WHERE p.id=#{id} AND p.customer_id=#{customer} AND ip.status IN ('COMPLETED','PARTIAL_INBOUND') AND p.warehouse_pallet_id IS NOT NULL GROUP BY p.id,p.plan_id,p.customer_id,p.warehouse_pallet_id,p.pallet_no,ip.overseas_warehouse_id,p.delivery_address HAVING COUNT(*)=COUNT(a.counted_package_count) AND COUNT(DISTINCT l.destination_type)=1 AND COUNT(DISTINCT COALESCE(l.fba_warehouse_code,''))=1 @@ -41,6 +41,23 @@ public interface DispatchSourceMapper { @Options(useCache=false,flushCache=Options.FlushCachePolicy.TRUE) List confirmedPlan(@Param("customer") String customer,@Param("id") long id); + /** 只有原客户、原计划、原仓已结束且逐项明确为零的来源可移除;缺记录不代表短少。 */ + @Select(""" + SELECT COUNT(*) FROM ll_inbound_plan_pallet p + JOIN ll_inbound_plan ip ON ip.id=p.plan_id AND ip.customer_id=p.customer_id + WHERE p.id=#{id} AND p.customer_id=#{customer} AND p.plan_id=#{plan} + AND CAST(ip.overseas_warehouse_id AS CHAR(128))=#{warehouse} + AND ip.status IN ('COMPLETED','PARTIAL_INBOUND') + AND COALESCE(p.actual_package_count,0)=0 + AND EXISTS (SELECT 1 FROM ll_inbound_plan_pallet_allocation a WHERE a.pallet_id=p.id AND a.customer_id=p.customer_id) + AND NOT EXISTS (SELECT 1 FROM ll_inbound_plan_pallet_allocation a WHERE a.pallet_id=p.id AND a.customer_id=p.customer_id + AND (a.counted_package_count IS NULL OR a.counted_package_count<>0 OR a.picked_package_count IS NULL OR a.picked_package_count<>0)) + AND NOT EXISTS (SELECT 1 FROM ll_inbound_stock_receipt r JOIN ll_inbound_plan_pallet_allocation a ON a.id=r.allocation_id AND a.customer_id=r.customer_id + WHERE a.pallet_id=p.id AND a.customer_id=p.customer_id) + """) + @Options(useCache=false,flushCache=Options.FlushCachePolicy.TRUE) + int confirmedEmptyPlan(@Param("customer") String customer,@Param("id") long id,@Param("plan") long plan,@Param("warehouse") String warehouse); + /** 全车普通来源剩余箱数依据实际扫码,不以当前聚合库存反推选中份额。 */ @Select("SELECT i.id AS itemId,COALESCE(SUM(p.packages),0) AS packages FROM ll_dispatch_item i LEFT JOIN ll_dispatch_pda_pick p " + "ON p.dispatch_item_id=i.id AND p.customer_id=i.customer_id WHERE i.dispatch_order_id=#{dispatch} GROUP BY i.id") @@ -115,7 +132,7 @@ public interface DispatchSourceMapper { /** 原计划完成实收后才能把预排估算替换为实际整托数量,避免收货中提前锁定半托。 */ @Select("SELECT COUNT(*) FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id AND ip.customer_id=p.customer_id " - + "WHERE p.id=#{id} AND p.customer_id=#{customer} AND ip.status IN ('COMPLETED','PENDING_RECONCILIATION')") + + "WHERE p.id=#{id} AND p.customer_id=#{customer} AND ip.status IN ('COMPLETED','PARTIAL_INBOUND','PENDING_RECONCILIATION')") int receivedPlanCompleted(@Param("customer") String customer,@Param("id") Long id); /** 退回来源仍使用本票实际实收,不借普通入仓别名。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchSourceRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchSourceRepository.java index 3e7f1328c..c8a85d1ae 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchSourceRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchSourceRepository.java @@ -68,6 +68,8 @@ public class DispatchSourceRepository { public List selectedPlan(String customer, Long id) { return plans.readSelected(customer, List.of(id)); } /** 确认入库直接读取逐票实收,与可用、暂扣等出货资格分离。 */ public List confirmedPlan(String customer,long id) { return source.confirmedPlan(customer,id); } + /** 空预排来源必须具备已完成的零实收事实,不能把来源缺失当作零。 */ + public boolean confirmedEmptyPlan(String customer,long id,long plan,String warehouse) { return source.confirmedEmptyPlan(customer,id,plan,warehouse)==1; } /** 取得已有真实退回候选。 */ public List selectedReturn(String customer, Long id) { return returns.readSelected(customer, List.of(id)); } /** 同托真实来源订单组成。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java index 8897c9686..3addb7c67 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java @@ -34,6 +34,7 @@ import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*; public class DispatchAggregateService implements DispatchExecutionPort,DispatchAppointmentAmendmentPort { @jakarta.annotation.Resource private com.baiyue.lastleg.storagerent.service.StorageRentService storageRent; private static final org.slf4j.Logger LOG=org.slf4j.LoggerFactory.getLogger(DispatchAggregateService.class); + @jakarta.annotation.Resource private DispatchMasterPhotoService masterPhotos; @jakarta.annotation.Resource private DispatchNumberGenerator numbers; @jakarta.annotation.Resource private DispatchAppointmentAllocations appointments; @jakarta.annotation.Resource private DispatchPhysicalClaimService physicalClaims; @@ -311,7 +312,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA row.setDispatchReviewJson(write(pending(0))); row.setLoadingReviewJson(write(pending(0))); var photos=request.driver()==null || request.driver().evidence()==null?List.of():request.driver().evidence().photoFileIds(); if(!photos.isEmpty())verifyEvidence(id,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER_PHOTO,photos); - Resources resources=resources(shell,photos,null); row.setResourcesJson(write(resources)); + Resources resources=masterPhotos.populate(id,actor.operatorId(),resources(shell,photos,null)); row.setResourcesJson(write(resources)); row.setVehiclePlate(resources.vehicle()==null ? null : resources.vehicle().plate()); row.setPlannedDepartureAt(utc(request.plannedDepartureAt())); row.setEstimatedArrivalAt(utc(request.estimatedArrivalAt())); row.setItemCount(0); row.setPalletCount(0); row.setPackageCount(0); row.setPickedPackageCount(0); row.setShippedPackageCount(0); @@ -445,7 +446,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA if(vehiclePhotos!=null && !vehiclePhotos.isEmpty())verifyEvidence(row.getId(),com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.VEHICLE,vehiclePhotos); var actual=fleet.dispatchResources(number,fleetVehicle(request.vehicle()),updatedFleetDriver(request.driver(),read(row.getResourcesJson(),Resources.class))); DispatchDestinationAppointments.validate(request.maintenance(),reviewAppointments(row)); - var savedResources=resources(actual,photos,request.maintenance()); + var savedResources=masterPhotos.populate(row.getId(),actor.operatorId(),resources(actual,photos,request.maintenance())); // 主档引用只能由车队解析产生;补传照片必须核验本车归属后才允许保存。 var savedDriver=savedResources.driver(); if(savedDriver!=null && savedDriver.evidence()!=null)for(var id:savedDriver.evidence().fileIds()) { @@ -735,6 +736,25 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA /** 自然实收只延续仍有效的原审核依据,失效审核继续保持失效。 */ void saveReceivedCargo(DispatchAggregate row,String operator,LocalDateTime at,boolean preserveReview) { long version=row.getRecordVersion(); + // 空预排明细已移除后按剩余真实货物重算,避免列表、路线和车次继续保留幽灵托盘。 + var all=repository.cargo(row.getId()); + boolean removedItems=!Objects.equals(row.getItemCount(),all.size()); + int previousPackages=row.getPackageCount(); + row.setPackageCount(Math.toIntExact(all.stream().mapToLong(DispatchItem::getPackageCount).sum())); + row.setItemCount(all.size()); + row.setPalletCount(Math.toIntExact(all.stream().map(i->i.getWarehousePalletId()==null?"PLAN:"+i.getPlanPalletId():"PALLET:"+i.getWarehousePalletId()).distinct().count())); + if(removedItems)row.setRoute(all.stream().map(this::destinationLabel).filter(Objects::nonNull).distinct().sorted().collect(Collectors.joining(","))); + var digest=all.stream().sorted(Comparator.comparing(DispatchItem::getSourceKey)).map(i->List.of(i.getSourceKey(),i.getSourceVersion(),i.getCustomerId(),i.getPackageCount())).toList(); + row.setCargoHash(hashes.source(digest));row.setCargoRevision(Math.addExact(row.getCargoRevision(),1)); + if(removedItems || previousPackages!=row.getPackageCount()) { + var stops=all.stream().map(i->new FleetExecutionModels.DispatchStop(destinationLabel(i),i.getDeliveryAddress(),destination(i).key())) + .collect(Collectors.toMap(FleetExecutionModels.DispatchStop::destinationKey,s->s,(a,b)->a,TreeMap::new)).values().stream().toList(); + fleet.updateDispatchCargo(row.getDispatchNo(),row.getPackageCount(),stops); + } + if(all.isEmpty()) { + invalidate(row,"实收核对后本车无货物,请重新安排货物并提交审核"); + row.setStage(Stage.BEFORE_LOADING.name());preserveReview=false; + } if(preserveReview)row.setReviewBasisHash(basis(row)); row.setRecordVersion(Math.addExact(version,1));row.setUpdatedAt(at); if(repository.saveState(row,version)!=1)throw conflict("收货交接期间排车版本已变化"); @@ -777,21 +797,22 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA new FleetExecutionModels.DispatchDriver(d.mode().name(),d.driverId(),d.name(),d.phone(),e.licenseNo(),e.licenseExpiresOn(),e.fileIds(),e.expectedTaskEndAt())); if(!Objects.equals(hashes.source(r),hashes.source(resources(actual,e.photoFileIds(),r.maintenance()))))throw conflict("车辆或司机主档已变化,请重新保存并提交审核"); } - /** 三类均须含至少一张当前有效图片;保存草稿不调用此送审/通过门禁。 */ + /** 车辆和司机须有有效图片,驾驶证接受与档案一致的PDF或图片。 */ private void verifyReviewPhotos(DispatchAggregate row,Resources resources) { var driver=resources.driver();var proof=driver.evidence(); requireReviewPhoto(row,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.VEHICLE,resources.vehicle().evidenceFileIds(),"车辆图片"); requireReviewPhoto(row,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER_PHOTO,proof.photoFileIds(),"司机照片"); requireReviewPhoto(row,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER,proof.fileIds(),"驾驶证照片"); } - /** 文件存在、归属及有效性由原文件提供者复验,PDF不能充当照片。 */ + /** 文件存在、归属及有效性由原提供者复验,PDF仅可作为驾驶证。 */ private void requireReviewPhoto(DispatchAggregate row,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind kind,List ids,String label) { if(ids==null || ids.isEmpty() || ids.size()>20 || ids.stream().anyMatch(id->id==null || id<=0) || ids.stream().distinct().count()!=ids.size())throw bad("请上传至少一张有效"+label); boolean image=false; for(var id:ids) { var master=masterEvidenceDriverId(row,kind,id); var file=master==null?evidence.metadata(row.getId(),kind,id):driverEvidenceMetadata(fleet.dispatchDriverLicense(master,id).metadata(),id); - if(file!=null && ("image/jpeg".equals(file.contentType()) || "image/png".equals(file.contentType())))image=true; + if(file!=null && ("image/jpeg".equals(file.contentType()) || "image/png".equals(file.contentType()) + || kind==com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER && "application/pdf".equals(file.contentType())))image=true; } if(!image)throw bad("请上传至少一张有效"+label); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchInboundReceiptService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchInboundReceiptService.java index 3970a395d..9a0d9e7cf 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchInboundReceiptService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchInboundReceiptService.java @@ -67,7 +67,13 @@ public class DispatchInboundReceiptService implements InboundDispatchReceiptGate if(!Set.of("BEFORE_LOADING","LOADING").contains(row.getStage()))throw conflict("原排车已进入出货后阶段,无法确认实收交接"); boolean preserveReview=workflow.currentReceiptBasis(row); var received=new LinkedHashMap(); - for(var item:pending) { + var empty=new ArrayList(); + for(var item:pending.stream().sorted(Comparator.comparing(DispatchItem::getPlanPalletId)).toList()) { + if(sources.lockPlan(item.getCustomerId(),item.getPlanPalletId())==null)throw conflict("原计划来源已变化"); + if(sources.confirmedEmptyPlan(item.getCustomerId(),item.getPlanPalletId(),item.getPlanId(),row.getOverseasWarehouseId())) { + empty.add(item); + continue; + } var actual=DispatchReceivedPlan.resolve(row.getOverseasWarehouseId(),item,sources) .orElseThrow(()->conflict("实收托盘与原排车来源或占用不一致,请核对后确认入库")); received.put(item,actual); @@ -79,14 +85,25 @@ public class DispatchInboundReceiptService implements InboundDispatchReceiptGate entry.getKey().setGrossWeightKg(entry.getValue().grossWeightKg()); entry.getKey().setVolumeCbm(entry.getValue().volumeCbm()); } - for(var item:pending.stream().sorted(Comparator.comparing(DispatchItem::getPlanPalletId)).toList()) { + for(var item:received.keySet().stream().sorted(Comparator.comparing(DispatchItem::getPlanPalletId)).toList()) { if(sources.lockPlan(item.getCustomerId(),item.getPlanPalletId())==null)throw conflict("原计划来源已变化"); var fresh=sources.confirmedPlan(item.getCustomerId(),item.getPlanPalletId()); if(fresh.size()!=1 || !DispatchReceivedPlan.sameIdentity(item,fresh.get(0)) || !Objects.equals(item.getWarehousePalletId(),fresh.get(0).warehousePalletId()) || !Objects.equals(item.getPackageCount(),fresh.get(0).packageCount()))throw conflict("实收托盘已变化,请重新确认入库"); } - var pallets=pending.stream().map(DispatchItem::getWarehousePalletId).distinct().sorted().toList(); + // 先释放空预排的逻辑占用;共享实托仍有成员时保留其物理排他。 + if(!empty.isEmpty()) { + var owner=new DispatchPhysicalClaimService.Owner(row.getOverseasWarehouseId(),row.getId(),null,null,operator,null); + var original=physicalClaims.lockOwner(owner); + for(var item:empty) { + if(picking.picks(item.getCustomerId(),row.getId()).stream().anyMatch(p->Objects.equals(p.getDispatchItemId(),item.getId()))) + throw conflict("空托盘已有真实拣货,不能按短少移除"); + if(sources.release(row.getId(),item,at)!=1)throw conflict("空托盘原逻辑占用已变化"); + } + physicalClaims.releaseIfUnused(owner,original.stream().map(com.baiyue.lastleg.dispatch.entity.DispatchPhysicalPalletLock::getId).toList(),at); + } + var pallets=received.keySet().stream().map(DispatchItem::getWarehousePalletId).distinct().sorted().toList(); var physical=physicalClaims.reserve(new DispatchPhysicalClaimService.Owner(row.getOverseasWarehouseId(),row.getId(),null,null,operator,null),pallets,at); for(var entry:received.entrySet()) { var item=entry.getKey();var actual=entry.getValue(); @@ -95,16 +112,8 @@ public class DispatchInboundReceiptService implements InboundDispatchReceiptGate item.setPalletNo(actual.palletNo());item.setSourceStage("RECEIVED"); if(picking.materialize(item)!=1)throw conflict("原排车逐托实收数量未保存"); } - appointments.complete(row.getId(),pending.stream().map(i->new InboundDispatchReceiptGateway.Item(i.getId(),i.getCustomerId(),i.getPlanPalletId(),i.getPackageCount())).toList()); - int total=Math.toIntExact(items.stream().mapToLong(DispatchItem::getPackageCount).sum()); - if(total!=row.getPackageCount()) { - var stops=items.stream().map(i->new com.baiyue.lastleg.fleet.trip.FleetExecutionModels.DispatchStop( - i.getDestinationWarehouseName(),i.getDeliveryAddress(),workflow.destination(i).key())) - .collect(java.util.stream.Collectors.toMap(com.baiyue.lastleg.fleet.trip.FleetExecutionModels.DispatchStop::destinationKey,s->s,(a,b)->a,TreeMap::new)).values().stream().toList(); - fleet.updateDispatchCargo(number,total,stops); - } - row.setPackageCount(total); - row.setPalletCount(Math.toIntExact(items.stream().map(i->i.getWarehousePalletId()==null?"PLAN:"+i.getPlanPalletId():"PALLET:"+i.getWarehousePalletId()).distinct().count())); + appointments.complete(row.getId(),pending.stream().map(i->new InboundDispatchReceiptGateway.Item(i.getId(),i.getCustomerId(),i.getPlanPalletId(),empty.contains(i)?0:i.getPackageCount())).toList()); + for(var item:empty)if(repository.removeItem(item.getId())!=1)throw conflict("空托盘原排车明细已变化"); workflow.saveReceivedCargo(row,operator,at,preserveReview); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchMasterPhotoService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchMasterPhotoService.java new file mode 100644 index 000000000..d0bc8d25b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchMasterPhotoService.java @@ -0,0 +1,66 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway; +import com.baiyue.lastleg.fleet.photo.FleetResourcePhotoModels.Kind; +import com.baiyue.lastleg.fleet.photo.port.FleetResourcePhotoPort; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; +import java.io.*; +import java.nio.file.Files; +import java.util.List; +import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*; +import static com.baiyue.lastleg.dispatch.port.DispatchTypes.ResourceMode.MASTER; + +/** 档案照片只在缺少本车照片时带入,复制为本车证据以冻结历史与文件权限。 */ +@Service +public class DispatchMasterPhotoService { + private final FleetResourcePhotoPort photos; + private final DispatchEvidenceGateway evidence; + /** 只读主档并通过本车文件网关创建证据。 */ + public DispatchMasterPhotoService(FleetResourcePhotoPort photos,DispatchEvidenceGateway evidence) { + this.photos=photos; this.evidence=evidence; + } + /** 保存前补入已有档案照片;临时资源和已有当趟照片保持原样。 */ + public Resources populate(long dispatchId,String operator,Resources value) { + var v=value.vehicle(); var d=value.driver(); + if(v!=null && v.mode()==MASTER && v.vehicleId()!=null) { + var ids=populate(dispatchId,operator,Kind.VEHICLE,v.vehicleId(),v.evidenceFileIds()); + v=new VehicleSnapshot(v.mode(),v.vehicleId(),v.plate(),v.carrierName(),v.vehicleStatus(),v.capacity(),ids); + } + if(d!=null && d.mode()==MASTER && d.driverId()!=null && d.evidence()!=null) { + var e=d.evidence(); + var ids=populate(dispatchId,operator,Kind.DRIVER,d.driverId(),e.photoFileIds()); + d=new DriverSnapshot(d.mode(),d.driverId(),d.name(),d.phone(),new DriverEvidence(e.licenseNo(),e.licenseExpiresOn(),e.fileIds(),e.expectedTaskEndAt(),ids,e.masterLicenseFileId())); + } + return new Resources(v,d,value.maintenance()); + } + /** 只复制已经由档案服务确认的图片,上传失败使业务事务整体失败。 */ + public List populate(long dispatchId,String operator,Kind kind,long resourceId,List existing) { + if(existing!=null && !existing.isEmpty())return List.copyOf(existing); + var current=photos.current(kind,resourceId); + if(current.photo()==null)return List.of(); + var source=photos.download(kind,resourceId,Long.parseLong(current.photo().fileId())); + var file=new SnapshotFile(source.metadata().fileName(),source.metadata().contentType(),source.content()); + var copied=evidence.upload(dispatchId,kind==Kind.VEHICLE?DispatchEvidenceGateway.Kind.VEHICLE:DispatchEvidenceGateway.Kind.DRIVER_PHOTO,operator,file); + return List.of(copied.fileId()); + } + /** 只供服务间复制已核验图片,不接受任意磁盘路径。 */ + private record SnapshotFile(String filename,String contentType,byte[] bytes) implements MultipartFile { + /** 上传部件固定使用文件字段。 */ + @Override public String getName() { return "file"; } + /** 保留已核验主档文件名。 */ + @Override public String getOriginalFilename() { return filename; } + /** 沿用已核验图片类型。 */ + @Override public String getContentType() { return contentType; } + /** 拒绝空图片。 */ + @Override public boolean isEmpty() { return bytes.length==0; } + /** 使用实际读取的字节长度。 */ + @Override public long getSize() { return bytes.length; } + /** 返回本次复制的图片内容。 */ + @Override public byte[] getBytes() { return bytes; } + /** 提供上传流,不读取磁盘文件。 */ + @Override public InputStream getInputStream() { return new ByteArrayInputStream(bytes); } + /** 实现文件上传协议要求的输出方法。 */ + @Override public void transferTo(File dest) throws IOException { Files.write(dest.toPath(),bytes); } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskService.java index 7368d9cb3..c77e1eea2 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskService.java @@ -36,6 +36,7 @@ public class TransportTaskService { private final TripOperationsEvidenceGateway files; private final ObjectMapper json; @jakarta.annotation.Resource private TransportReviewPhotos reviewPhotos; + @jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.service.DispatchMasterPhotoService masterPhotos; /** 复用已有资格、占用、原单及凭证能力。 */ public TransportTaskService(DispatchAggregateRepository roots,TransportTaskRepository links,TransportTaskProjection projection, FleetTripRepository tripRepo,FleetTripService trips,FleetPickupService pickups,FleetPickupRepository pickupFacts, @@ -119,6 +120,20 @@ public class TransportTaskService { if(!Objects.equals(trip.getRecordVersion(),request.recordVersion()) || request.dispatchRecordVersion()==null || !Objects.equals(row.getRecordVersion(),request.dispatchRecordVersion()))throw conflict("排车或审核信息已变化,请刷新后重试"); if(!"PLANNED".equals(trip.getStatus()) || pickups.tasks(trip.getTripNo()).stream().anyMatch(task->!"ASSIGNED".equals(task.status())))throw conflict("已取货的任务不能更换资源照片"); + // 主档照片缺省带入本车;显式本趟照片仍由原归属校验复核。 + var supplied=request.reviewPhotos(); + var vehiclePhotos=masterPhotos.populate(row.getId(),operator,com.baiyue.lastleg.fleet.photo.FleetResourcePhotoModels.Kind.VEHICLE, + request.vehicleId(),supplied==null?List.of():supplied.vehicleFileIds()); + var driverPhotos=masterPhotos.populate(row.getId(),operator,com.baiyue.lastleg.fleet.photo.FleetResourcePhotoModels.Kind.DRIVER, + request.driverId(),supplied==null?List.of():supplied.driverPhotoFileIds()); + var license=request.masterLicenseFileId(); + if(license==null && (supplied==null || supplied.licenseFileIds().isEmpty())) { + var driver=drivers.detail(request.driverId(),null); + if(driver.licenseFileId()!=null)license=Long.valueOf(driver.licenseFileId()); + } + request=new TransportTaskModels.Assign(request.recordVersion(),request.vehicleId(),request.driverId(),request.reason(), + request.dispatchRecordVersion(),new com.baiyue.lastleg.dispatch.port.DispatchContracts.ReviewAttachments( + vehiclePhotos,driverPhotos,supplied==null?List.of():supplied.licenseFileIds()),license); photos=reviewPhotos.validate(row,trip,request); } trips.assign(trip.getTripNo(),operator,new FleetTripModels.Assign(request.recordVersion(),request.vehicleId(),request.driverId(),request.reason())); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/outsourcedbusiness/model/OutsourcedBusinessModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/outsourcedbusiness/model/OutsourcedBusinessModels.java index eadd9cb31..25d9c0f4d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/outsourcedbusiness/model/OutsourcedBusinessModels.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/outsourcedbusiness/model/OutsourcedBusinessModels.java @@ -27,7 +27,10 @@ public final class OutsourcedBusinessModels { /** 固定外配入口不接收浏览器作用域或其他协议字段。 */ @JsonAnySetter public void unsupported(String field,Object value){throw unsupportedField(field);} } - public record BindPhoto(@NotBlank String recordVersion,@NotBlank @Size(min=8,max=128) String idempotencyKey,@NotBlank String fileId) { + public record BindPhoto(@NotBlank String recordVersion,@NotBlank @Size(min=8,max=128) String idempotencyKey,@NotBlank String fileId, + @Pattern(regexp="[1-9][0-9]*") String replaceFileId) { + /** 未指定替换目标时追加装车照片。 */ + public BindPhoto(String recordVersion,String idempotencyKey,String fileId) { this(recordVersion,idempotencyKey,fileId,null); } /** 绑定只接受原版本、原键和File编号,不能提交车辆归属。 */ @JsonAnySetter public void unsupported(String field,Object value){throw unsupportedField(field);} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/outsourcedbusiness/service/OutsourcedBusinessService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/outsourcedbusiness/service/OutsourcedBusinessService.java index 620a910f2..6061bc572 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/outsourcedbusiness/service/OutsourcedBusinessService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/outsourcedbusiness/service/OutsourcedBusinessService.java @@ -53,7 +53,7 @@ public class OutsourcedBusinessService { /** 原File编号由共享绑定事务校验,不创建第二份附件或写原主单。 */ public Detail bind(OwnedBusinessModels.Actor actor,String number,PhotoSlot slot,BindPhoto request) { if(request==null)throw bad("照片绑定请求缺失"); - return detail(photos.bind(actor,number,slot(slot),new OwnedBusinessModels.BindPhoto(request.recordVersion(),request.idempotencyKey(),request.fileId()),SCOPE)); + return detail(photos.bind(actor,number,slot(slot),new OwnedBusinessModels.BindPhoto(request.recordVersion(),request.idempotencyKey(),request.fileId(),request.replaceFileId()),SCOPE)); } /** 当前或历史引用读取始终限定原车、照片框和外配权限。 */ public OwnedBusinessPhotoService.Download content(OwnedBusinessModels.Actor actor,String number,PhotoSlot slot,long fileId,Long revision) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/model/OwnedBusinessModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/model/OwnedBusinessModels.java index 25d841394..c42499c11 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/model/OwnedBusinessModels.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/model/OwnedBusinessModels.java @@ -34,7 +34,11 @@ public final class OwnedBusinessModels { @NotBlank @Size(min=8,max=128) String idempotencyKey,@NotNull @Valid Operations operations, @Size(max=1000) String reason) {} public record BindPhoto(@NotBlank String recordVersion, - @NotBlank @Size(min=8,max=128) String idempotencyKey,@NotBlank String fileId) {} + @NotBlank @Size(min=8,max=128) String idempotencyKey,@NotBlank String fileId, + @Pattern(regexp="[1-9][0-9]*") String replaceFileId) { + /** 未指定替换目标时追加装车照片。 */ + public BindPhoto(String recordVersion,String idempotencyKey,String fileId) { this(recordVersion,idempotencyKey,fileId,null); } + } public record Gate(boolean allowed,String blockReason) {} public record Actions(Gate edit,Gate upload) {} public record Origin(String warehouseId,String warehouseCode,String warehouseName) {} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/port/DispatchLoadingPhotoPort.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/port/DispatchLoadingPhotoPort.java index de58e6d38..7b68e0d43 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/port/DispatchLoadingPhotoPort.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/port/DispatchLoadingPhotoPort.java @@ -8,7 +8,11 @@ import org.springframework.web.multipart.MultipartFile; public interface DispatchLoadingPhotoPort { enum Slot { LOADING_SCENE, LOADING_LIST, PRE_DEPARTURE_CHECK, DRIVER_LICENSE, RECEIPT } record Photo(Slot slot,String category,String fileId,String fileName,String contentType,String sizeBytes,String uploadedAt,String savedAt) {} - record BindPhoto(@NotBlank String recordVersion,@NotBlank @Size(min=8,max=128) String idempotencyKey,@NotBlank String fileId) {} + record BindPhoto(@NotBlank String recordVersion,@NotBlank @Size(min=8,max=128) String idempotencyKey,@NotBlank String fileId, + @Pattern(regexp="[1-9][0-9]*") String replaceFileId) { + /** 未指定替换目标时追加照片,每类最多五张。 */ + public BindPhoto(String recordVersion,String idempotencyKey,String fileId) { this(recordVersion,idempotencyKey,fileId,null); } + } record State(String recordVersion,List photos) {} record Documents(String dispatchNo,String recordVersion,String dispatchRecordVersion,String stage,boolean editable,boolean complete,List photos) {} record Download(Photo photo,byte[] content) {} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/DispatchLoadingPhotoAdapter.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/DispatchLoadingPhotoAdapter.java index 9712823d3..ba1ec9b42 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/DispatchLoadingPhotoAdapter.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/DispatchLoadingPhotoAdapter.java @@ -77,9 +77,9 @@ public class DispatchLoadingPhotoAdapter implements DispatchLoadingPhotoPort { seed.setCreatedBy(operator);seed.setUpdatedBy(operator);seed.setCreatedAt(codec.utc(Instant.now()));seed.setUpdatedAt(seed.getCreatedAt()); var row=repository.lock(seed); if(row==null || row.getRecordVersion()==null || row.getRecordVersion()!=version)throw conflict("装车资料已被其他操作更新,请刷新后核对"); - var photos=new EnumMap(PhotoSlot.class);for(var p:projection.photos(row))photos.put(p.slot(),p); - photos.put(originalSlot,new OwnedBusinessModels.Photo(originalSlot,originalSlot.category(),verified.fileId(),verified.fileName(),verified.contentType(),verified.sizeBytes(),verified.uploadedAt(),Instant.now().toString())); - row.setPhotosJson(codec.write(List.copyOf(photos.values())));row.setRecordVersion(Math.addExact(version,1));row.setUpdatedBy(operator);row.setUpdatedAt(codec.utc(Instant.now())); + var saved=new OwnedBusinessModels.Photo(originalSlot,originalSlot.category(),verified.fileId(),verified.fileName(),verified.contentType(),verified.sizeBytes(),verified.uploadedAt(),Instant.now().toString()); + var photos=OwnedBusinessPhotoBindings.bind(projection.photos(row),saved,request.replaceFileId()); + row.setPhotosJson(codec.write(photos));row.setRecordVersion(Math.addExact(version,1));row.setUpdatedBy(operator);row.setUpdatedAt(codec.utc(Instant.now())); if(repository.update(row,version)!=1)throw conflict("装车资料版本竞争失败"); var history=new OwnedBusinessRows.History();history.setId(IdWorker.getId());history.setDispatchId(id);history.setRecordVersion(row.getRecordVersion()); history.setAction("BIND_LOADING_DOCUMENT");history.setOperatorId(operator);history.setReason("保存装车照片 "+(slot.ordinal()+1));history.setOccurredAt(row.getUpdatedAt()); @@ -104,6 +104,7 @@ public class DispatchLoadingPhotoAdapter implements DispatchLoadingPhotoPort { private void validate(Slot slot,BindPhoto request) { slot(slot);if(request==null)throw bad("照片绑定请求缺失");codec.key(request.idempotencyKey());codec.version(request.recordVersion()); if(codec.version(request.fileId())<=0)throw bad("文件编号无效"); + if(request.replaceFileId()!=null && codec.version(request.replaceFileId())<=0)throw bad("待替换文件编号无效"); } /** 账本只消费可信操作者;平台/PDA权限在排车入口复核。 */ private OwnedBusinessModels.Actor operator(String operator){return new OwnedBusinessModels.Actor(operator,operator,Set.of());} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessPhotoBindings.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessPhotoBindings.java new file mode 100644 index 000000000..828d72a4e --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessPhotoBindings.java @@ -0,0 +1,33 @@ +package com.baiyue.lastleg.fleet.ownedbusiness.service; + +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.Photo; +import java.util.ArrayList; +import java.util.List; +import static com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport.*; + +/** 照片按文件追加或精确替换,禁止按类别压缩而丢失其他已保存图片。 */ +final class OwnedBusinessPhotoBindings { + /** 工具类仅提供照片引用合并。 */ + private OwnedBusinessPhotoBindings() {} + /** 装车每类最多五张;油耗仍为单张,替换目标必须在当前同类引用中。 */ + static List bind(List existing,Photo photo,String replaceFileId) { + var result=new ArrayList<>(existing); + // 油耗沿原单张替换协议,不能因装车改多图而改变其他入口的保存行为。 + if(replaceFileId==null && "FUEL".equals(photo.category())) + replaceFileId=existing.stream().filter(p->p.slot()==photo.slot()).map(Photo::fileId).findFirst().orElse(null); + if(replaceFileId!=null) { + int index=-1; + for(int i=0;ip.fileId().equals(photo.fileId())))throw bad("照片已绑定,不能重复保存"); + result.add(index,photo); + } else { + if(result.stream().anyMatch(p->p.fileId().equals(photo.fileId())))throw bad("照片已绑定,不能重复保存"); + int limit="LOADING".equals(photo.category())?5:1; + if(result.stream().filter(p->p.slot()==photo.slot()).count()>=limit)throw conflict("该类照片最多"+limit+"张,请替换已有照片"); + result.add(photo); + } + return List.copyOf(result); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessRecordService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessRecordService.java index 1ec0b5a54..bc59230fe 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessRecordService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessRecordService.java @@ -69,20 +69,20 @@ public class OwnedBusinessRecordService { var row=lock(source,actor);old=commands.existing(source.getId(),request.idempotencyKey(),actor); if(old!=null){commands.same(old,"BIND_PHOTO",version,identity);return result(old);} requireVersion(row,version); - var photos=new EnumMap(PhotoSlot.class);for(var p:projection.photos(row))photos.put(p.slot(),p); - photos.put(slot,new Photo(slot,slot.category(),verified.fileId(),verified.fileName(),verified.contentType(), - verified.sizeBytes(),verified.uploadedAt(),Instant.now().toString())); - row.setPhotosJson(codec.write(List.copyOf(photos.values()))); + var saved=new Photo(slot,slot.category(),verified.fileId(),verified.fileName(),verified.contentType(), + verified.sizeBytes(),verified.uploadedAt(),Instant.now().toString()); + var photos=OwnedBusinessPhotoBindings.bind(projection.photos(row),saved,request.replaceFileId()); + row.setPhotosJson(codec.write(photos)); return commit(source,row,actor,version,"BIND_PHOTO",request.idempotencyKey(),identity,photoReason(slot),scope); } /** 操作记录使用准确中文槽位,不向用户展示内部枚举。 */ private String photoReason(PhotoSlot slot) { return switch(slot) { - case LOADING_SCENE -> "保存装车现场照片"; - case LOADING_LIST -> "保存装车清单照片"; - case PRE_DEPARTURE_CHECK -> "保存出车前检查照片"; - case DRIVER_LICENSE -> "保存驾驶证附件照片"; - case RECEIPT -> "保存签收凭证照片"; + case LOADING_SCENE -> "保存司机驾驶证照片"; + case LOADING_LIST -> "保存车牌照片"; + case PRE_DEPARTURE_CHECK -> "保存装车完成照片"; + case DRIVER_LICENSE -> "保存封车照片"; + case RECEIPT -> "保存BOL司机签字照片"; case FUEL_START -> "保存起始油耗表盘照片"; case FUEL_END -> "保存结束油耗表盘照片"; }; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/photo/FleetResourcePhotoGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/photo/FleetResourcePhotoGateway.java new file mode 100644 index 000000000..179232514 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/photo/FleetResourcePhotoGateway.java @@ -0,0 +1,14 @@ +package com.baiyue.lastleg.fleet.photo; + +import org.springframework.web.multipart.MultipartFile; +import static com.baiyue.lastleg.fleet.photo.FleetResourcePhotoModels.*; + +/** File按平台、档案类型、资源身份及文件身份隔离照片。 */ +public interface FleetResourcePhotoGateway { + /** 上传平台所属的档案照片。 */ + Photo upload(Kind kind, long id, String operator, MultipartFile file); + /** 核验档案类型、资源身份和文件身份。 */ + Photo metadata(Kind kind, long id, long file); + /** 读取已核验归属的图片内容。 */ + byte[] download(Kind kind, long id, long file); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/photo/FleetResourcePhotoMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/photo/FleetResourcePhotoMapper.java new file mode 100644 index 000000000..20eabb153 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/photo/FleetResourcePhotoMapper.java @@ -0,0 +1,17 @@ +package com.baiyue.lastleg.fleet.photo; + +import org.apache.ibatis.annotations.*; + +/** 仅操作档案照片版本表;资源有效性通过所属领域服务核验。 */ +@Mapper +public interface FleetResourcePhotoMapper { + /** 读取同一档案的最大照片版本。 */ + @Select("SELECT * FROM ll_fleet_resource_photo WHERE resource_kind=#{kind} AND resource_id=#{id} ORDER BY record_version DESC LIMIT 1") + FleetResourcePhotoModels.Stored current(@Param("kind") String kind, @Param("id") long id); + /** 按档案与文件复合身份查询历史引用。 */ + @Select("SELECT * FROM ll_fleet_resource_photo WHERE resource_kind=#{kind} AND resource_id=#{id} AND file_id=#{file} LIMIT 1") + FleetResourcePhotoModels.Stored file(@Param("kind") String kind, @Param("id") long id, @Param("file") long file); + /** 追加不可变照片版本,数据库拒绝重复资源版本。 */ + @Insert("INSERT INTO ll_fleet_resource_photo(resource_kind,resource_id,record_version,file_id,file_name,content_type,size_bytes,created_by,created_at) VALUES(#{resourceKind},#{resourceId},#{recordVersion},#{fileId},#{fileName},#{contentType},#{sizeBytes},#{createdBy},#{createdAt})") + int insert(FleetResourcePhotoModels.Stored row); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/photo/FleetResourcePhotoModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/photo/FleetResourcePhotoModels.java new file mode 100644 index 000000000..b8a1f87be --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/photo/FleetResourcePhotoModels.java @@ -0,0 +1,15 @@ +package com.baiyue.lastleg.fleet.photo; + +import java.time.LocalDateTime; + +/** 档案照片独立版本化,历史图片不覆盖且不改变驾驶证审核结论。 */ +public final class FleetResourcePhotoModels { + /** 仅承载照片接口与持久化数据。 */ + private FleetResourcePhotoModels() { } + public enum Kind { VEHICLE, DRIVER } + public record Photo(String fileId, String fileName, String contentType, long sizeBytes) { } + public record Detail(long recordVersion, Photo photo) { } + public record Stored(String resourceKind, long resourceId, long recordVersion, long fileId, + String fileName, String contentType, long sizeBytes, String createdBy, LocalDateTime createdAt) { } + public record Download(Photo metadata, byte[] content) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/photo/FleetResourcePhotoRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/photo/FleetResourcePhotoRepository.java new file mode 100644 index 000000000..8ff81ffe7 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/photo/FleetResourcePhotoRepository.java @@ -0,0 +1,18 @@ +package com.baiyue.lastleg.fleet.photo; + +import org.springframework.stereotype.Repository; +import static com.baiyue.lastleg.fleet.photo.FleetResourcePhotoModels.*; + +/** 同一资源版本唯一,阻止并发替换静默覆盖。 */ +@Repository +public class FleetResourcePhotoRepository { + private final FleetResourcePhotoMapper mapper; + /** 注入唯一照片Mapper。 */ + public FleetResourcePhotoRepository(FleetResourcePhotoMapper mapper) { this.mapper = mapper; } + /** 读取指定档案的最新照片版本。 */ + public Stored current(Kind kind, long id) { return mapper.current(kind.name(), id); } + /** 按档案及文件身份读取不可变历史引用。 */ + public Stored file(Kind kind, long id, long file) { return mapper.file(kind.name(), id, file); } + /** 追加新版本,唯一键负责并发冲突保护。 */ + public void append(Stored row) { if (mapper.insert(row) != 1) throw new IllegalStateException("档案照片未保存"); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/photo/FleetResourcePhotoService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/photo/FleetResourcePhotoService.java new file mode 100644 index 000000000..b58859fea --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/photo/FleetResourcePhotoService.java @@ -0,0 +1,63 @@ +package com.baiyue.lastleg.fleet.photo; + +import com.baiyue.lastleg.fleet.driver.DriverService; +import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.server.ResponseStatusException; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import static com.baiyue.lastleg.fleet.photo.FleetResourcePhotoModels.*; + +/** 管理可重复用于排车的车辆及司机照片,排车另存当趟不可变副本。 */ +@Service +public class FleetResourcePhotoService implements com.baiyue.lastleg.fleet.photo.port.FleetResourcePhotoPort { + private final FleetResourcePhotoRepository repository; + private final FleetResourcePhotoGateway files; + private final VehicleRepository vehicles; + private final DriverService drivers; + /** 通过资源领域入口确认身份,所有照片版本经Repository保存。 */ + public FleetResourcePhotoService(FleetResourcePhotoRepository repository, FleetResourcePhotoGateway files, + VehicleRepository vehicles, DriverService drivers) { + this.repository=repository; this.files=files; this.vehicles=vehicles; this.drivers=drivers; + } + /** 先确认资源存在,空照片是明确的可补齐状态。 */ + public Detail current(Kind kind, long id) { + requireResource(kind,id); + var row=repository.current(kind,id); + return row==null ? new Detail(0,null) : new Detail(row.recordVersion(),photo(row)); + } + /** 版本随照片独立递增;失败保留旧照片及历史。 */ + @Transactional + public Detail upload(Kind kind,long id,long version,String operator,MultipartFile file) { + var current=current(kind,id); + if(version!=current.recordVersion())throw conflict(); + var saved=files.upload(kind,id,operator,file); + try { + repository.append(new Stored(kind.name(),id,version+1,Long.parseLong(saved.fileId()),saved.fileName(), + saved.contentType(),saved.sizeBytes(),operator,LocalDateTime.now(ZoneOffset.UTC))); + } catch(DuplicateKeyException error) { throw conflict(); } + return new Detail(version+1,saved); + } + /** 只允许读取本档案已关联的当前或历史图片。 */ + public Download download(Kind kind,long id,long file) { + requireResource(kind,id); + if(repository.file(kind,id,file)==null)throw new ResponseStatusException(HttpStatus.NOT_FOUND,"档案照片不存在"); + var metadata=files.metadata(kind,id,file); + var bytes=files.download(kind,id,file); + if(bytes.length!=metadata.sizeBytes())throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"档案照片内容不完整"); + return new Download(metadata,bytes); + } + /** 车辆及司机身份必须真实存在,不能按自由文本上传。 */ + private void requireResource(Kind kind,long id) { + if(id<=0)throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"档案身份无效"); + if(kind==Kind.VEHICLE)vehicles.findById(id).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND,"车辆档案不存在")); else drivers.detail(id,null); + } + /** 转换为不暴露持久化信息的文件元数据。 */ + private static Photo photo(Stored row) { return new Photo(Long.toString(row.fileId()),row.fileName(),row.contentType(),row.sizeBytes()); } + /** 冲突要求重读当前照片,不自动覆盖他人的修改。 */ + private static ResponseStatusException conflict() { return new ResponseStatusException(HttpStatus.CONFLICT,"档案照片已变化,请重新读取后上传"); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/photo/HttpFleetResourcePhotoGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/photo/HttpFleetResourcePhotoGateway.java new file mode 100644 index 000000000..62e2f649f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/photo/HttpFleetResourcePhotoGateway.java @@ -0,0 +1,83 @@ +package com.baiyue.lastleg.fleet.photo; + +import static com.baiyue.lastleg.fleet.photo.FleetResourcePhotoModels.*; + +import com.baiyue.lastleg.config.LastlegRestClientFactory; +import com.baiyue.lastleg.config.LastlegRestClientProperties; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.http.*; +import org.springframework.http.client.MultipartBodyBuilder; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.server.ResponseStatusException; +import java.io.IOException; +import java.util.Objects; +import java.util.Set; + +@Component +public class HttpFleetResourcePhotoGateway implements FleetResourcePhotoGateway { + private static final String PATH = "/internal/file/v1/business-files/0/{type}/{record}"; + private static final long MAX_SIZE = 10L * 1024 * 1024; + private final RestClient client; + private final String token; + /** 使用统一内部服务客户端与File通信。 */ + public HttpFleetResourcePhotoGateway(RestClient.Builder builder, LastlegRestClientFactory factory, LastlegRestClientProperties properties, + @Value("${baiyue.clients.file.url:http://127.0.0.1:9205}") String baseUrl, + @Value("${baiyue.security.internal-service-token:}") String token) { + client = factory.build(builder, baseUrl, properties.getCustomerVisitFile()); this.token = token; + } + /** 只接受非空图片,通过内部File上传并校验归属。 */ + @Override public Photo upload(Kind kind, long recordId, String operatorId, MultipartFile file) { + if (file == null || file.isEmpty() || file.getSize() > MAX_SIZE || !image(file.getContentType())) throw bad("档案照片须为非空 JPG 或 PNG,最大 10MiB"); + var body = new MultipartBodyBuilder(); + try { body.part("file", new ByteArrayResource(file.getBytes()) { + @Override public String getFilename() { return file.getOriginalFilename(); } + }).contentType(MediaType.parseMediaType(file.getContentType())); } + catch (IOException error) { throw bad("无法读取档案照片"); } + var result = client.post().uri(PATH, type(kind), recordId).header("X-Internal-Service-Token", token).header("X-Operator-Id", operatorId) + .contentType(MediaType.MULTIPART_FORM_DATA).body(body.build()).retrieve() + .onStatus(HttpStatusCode::isError, (request, response) -> { throw unavailable(response.getStatusCode()); }).body(Envelope.class); + return verified(result, kind, recordId, null); + } + /** 逐文件核验平台及当前档案复合归属。 */ + @Override public Photo metadata(Kind kind, long recordId, long fileId) { + return verified(client.get().uri(PATH + "/{file}", type(kind), recordId, fileId).header("X-Internal-Service-Token", token).retrieve() + .onStatus(HttpStatusCode::isError, (request, response) -> { throw unavailable(response.getStatusCode()); }).body(Envelope.class), kind, recordId, fileId); + } + /** 按原档案身份读取图片,不返回匿名URL。 */ + @Override public byte[] download(Kind kind, long recordId, long fileId) { + var response = client.get().uri(PATH + "/{file}/content", type(kind), recordId, fileId).header("X-Internal-Service-Token", token).retrieve() + .onStatus(HttpStatusCode::isError, (request, result) -> { throw unavailable(result.getStatusCode()); }).toEntity(byte[].class); + return imageContent(response); + } + /** 拒绝错误正文、空文件和超过上限的内容。 */ + static byte[] imageContent(ResponseEntity response) { + byte[] content = response.getBody(); + if (content == null || content.length == 0 || content.length > MAX_SIZE + || !image(Objects.toString(response.getHeaders().getContentType(), ""))) throw unavailable(HttpStatus.SERVICE_UNAVAILABLE); + return content; + } + /** 验证File返回的身份、类型及字节数。 */ + static Photo verified(Envelope result, Kind kind, long recordId, Long fileId) { + if (result == null || !"200".equals(result.code())) throw unavailable(HttpStatus.SERVICE_UNAVAILABLE); + var data = result.data(); + if (data == null || data.fileId() == null || data.fileId() <= 0 || !Objects.equals(data.tenantId(), 0L) + || !type(kind).equals(data.businessType()) || !Long.toString(recordId).equals(data.businessId()) + || (fileId != null && !fileId.equals(data.fileId())) || data.fileName() == null || data.fileName().isBlank() + || !image(data.contentType()) || data.sizeBytes() == null || data.sizeBytes() <= 0 || data.sizeBytes() > MAX_SIZE) + throw bad("文件不属于当前资源档案或不是有效照片"); + return new Photo(data.fileId().toString(), data.fileName(), data.contentType(), data.sizeBytes()); + } + /** 档案类型不可由调用方拼接任意文件业务类别。 */ + private static String type(Kind kind) { return kind == Kind.VEHICLE ? "FLEET_VEHICLE_PHOTO" : "FLEET_DRIVER_PHOTO"; } + /** 车辆和司机本人照片仅允许JPG及PNG。 */ + private static boolean image(String type) { return Set.of("image/jpeg", "image/png").contains(Objects.toString(type, "")); } + /** 返回可修正的附件业务错误。 */ + private static ResponseStatusException bad(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); } + /** 外部失败保留诊断,不伪装为成功。 */ + private static ResponseStatusException unavailable(HttpStatusCode status) { return new ResponseStatusException(status.is4xxClientError() ? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE, "档案照片不可用或归属校验失败"); } + record Envelope(String code, Metadata data) { } + record Metadata(Long fileId, Long tenantId, String businessType, String businessId, String fileName, String contentType, Long sizeBytes) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/photo/PlatformFleetResourcePhotoController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/photo/PlatformFleetResourcePhotoController.java new file mode 100644 index 000000000..19231ef3a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/photo/PlatformFleetResourcePhotoController.java @@ -0,0 +1,46 @@ +package com.baiyue.lastleg.fleet.photo; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import jakarta.validation.constraints.Min; +import org.springframework.http.*; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; +import java.nio.charset.StandardCharsets; +import static com.baiyue.lastleg.fleet.photo.FleetResourcePhotoModels.*; + +/** 复用车辆编辑、司机上传及各自列表权限,不扩大跨档案访问域。 */ +@RestController +@Validated +@RequestMapping("/api/platform/v1/last-leg/fleet/resource-photos/{kind}/{id}") +public class PlatformFleetResourcePhotoController { + private final FleetResourcePhotoService service; + /** 注入档案照片应用服务。 */ + public PlatformFleetResourcePhotoController(FleetResourcePhotoService service) { this.service=service; } + /** 列表权限读取档案照片及版本。 */ + @GetMapping + @PreAuthorize("hasAuthority('platform:overseas-master-data:' + #kind.name().toLowerCase() + ':list')") + public ApiResponse current(@PathVariable Kind kind,@PathVariable @Min(1) long id) { + return ApiResponse.success(service.current(kind,id)); + } + /** 按资源类型校验上传权限并提交当前版本。 */ + @PostMapping(consumes=MediaType.MULTIPART_FORM_DATA_VALUE) + @PreAuthorize("(#kind.name() == 'VEHICLE' and hasAuthority('platform:overseas-master-data:vehicle:edit')) or (#kind.name() == 'DRIVER' and hasAuthority('platform:overseas-master-data:driver:upload'))") + public ApiResponse upload(@PathVariable Kind kind,@PathVariable @Min(1) long id, + @RequestParam @Min(0) long recordVersion,@RequestPart("file") MultipartFile file, + @AuthenticationPrincipal AuthenticatedUser user) { + return ApiResponse.success(service.upload(kind,id,recordVersion,user.userId().toString(),file)); + } + /** 原档案读取权限包含其已关联的历史图片。 */ + @GetMapping("/{fileId}/content") + @PreAuthorize("hasAuthority('platform:overseas-master-data:' + #kind.name().toLowerCase() + ':list')") + public ResponseEntity download(@PathVariable Kind kind,@PathVariable @Min(1) long id,@PathVariable @Min(1) long fileId) { + var file=service.download(kind,id,fileId); + return ResponseEntity.ok().cacheControl(CacheControl.noStore()).header("X-Content-Type-Options","nosniff") + .header(HttpHeaders.CONTENT_DISPOSITION,ContentDisposition.attachment().filename(file.metadata().fileName(),StandardCharsets.UTF_8).build().toString()) + .contentType(MediaType.parseMediaType(file.metadata().contentType())).body(file.content()); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/photo/port/FleetResourcePhotoPort.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/photo/port/FleetResourcePhotoPort.java new file mode 100644 index 000000000..da428ecde --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/photo/port/FleetResourcePhotoPort.java @@ -0,0 +1,11 @@ +package com.baiyue.lastleg.fleet.photo.port; + +import com.baiyue.lastleg.fleet.photo.FleetResourcePhotoModels.*; + +/** 排车仅通过此只读应用端口获取档案照片,不访问照片持久化或改写档案。 */ +public interface FleetResourcePhotoPort { + /** 返回当前档案照片及其独立版本,缺失时photo为空。 */ + Detail current(Kind kind,long id); + /** 读取已经关联本档案的图片并复验平台文件归属。 */ + Download download(Kind kind,long id,long file); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java index ef92711ee..88f56a6ed 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java @@ -38,6 +38,23 @@ public class InboundPlanController { @Resource private InboundPlanService service; @Resource private ContainerInboundPlanConversionCoordinator containerConversion; @Resource private InboundFbaAppointmentService appointmentService; + @Resource private com.baiyue.lastleg.inboundplan.pda.service.InboundPdaWorkflowService inboundWorkflow; + + /** 平台核对现场短少后按实收入库,不修改原应收数量。 */ + @PostMapping("/{planNo}/confirm-shortage") + @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_EDIT + "')") + @Operation(summary = "确认入库短少", description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_EDIT) + public ApiResponse confirmShortage( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String planNo, + @RequestHeader("Idempotency-Key") String idempotencyKey, + @Valid @RequestBody com.baiyue.lastleg.inboundplan.dto.ConfirmInboundShortageRequest request) { + if (user == null || user.tenantId() == null || user.tenantId() != 0L || !"PLATFORM".equals(user.appCode())) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "仅平台账号可确认入库短少"); + } + String customerId = convertScope(user, request.customerId()); + String operatorId = user.userId() == null ? user.username() : String.valueOf(user.userId()); + return ApiResponse.success(inboundWorkflow.confirmShortage(customerId, planNo, operatorId, idempotencyKey, request)); + } /** 按入仓计划明细分页查询可预约的入库订单行。 */ @GetMapping diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/ConfirmInboundShortageRequest.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/ConfirmInboundShortageRequest.java new file mode 100644 index 000000000..50bd4ff5a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/ConfirmInboundShortageRequest.java @@ -0,0 +1,15 @@ +package com.baiyue.lastleg.inboundplan.dto; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +/** 平台确认现场短少,数量仅用于核对当前收货事实,不能补写实收。 */ +public record ConfirmInboundShortageRequest( + @NotBlank String customerId, + @NotNull @Min(0) Integer version, + @NotNull @Min(1) Integer expectedPackageCount, + @NotNull @Min(1) Integer receivedPackageCount, + @NotBlank @Size(max = 200) String reason +) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/InboundDispatchReceiptGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/InboundDispatchReceiptGateway.java index 18257f5bd..79697a56c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/InboundDispatchReceiptGateway.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/InboundDispatchReceiptGateway.java @@ -11,7 +11,7 @@ public interface InboundDispatchReceiptGateway { /** 库存与计划完成后,按实收逐托数量更新原运输安排,不扣出货库存。 */ void complete(List planIds, String operatorId, LocalDateTime at); - /** 只传同一客户原计划的真实托盘与数量,不传推算的逐票分摊。 */ + /** 只传原计划真实数量;零表示已确认无实收,须释放该托的原预约分配。 */ record Item(long itemId, String customerId, long planPalletId, int packages) { } /** 预约域负责核对原批次归属及实收来源,排车域不直接修改预约账本。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingSkuReceiptMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingSkuReceiptMapper.java index fa28ae74e..f35614d60 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingSkuReceiptMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingSkuReceiptMapper.java @@ -10,6 +10,32 @@ import java.util.List; @Mapper public interface ReceivingSkuReceiptMapper extends BaseMapper { + record FrozenSku(String skuCode, long packages) { } + + /** 查询冻结申报的全部 SKU,不能从已自动分摊的实收反推缺失商品。 */ + @Select("SELECT UPPER(TRIM(d.sku_code)) AS skuCode,SUM(d.package_count) AS packages " + + "FROM ll_inbound_plan_line l JOIN ll_order_detail d ON d.order_id=l.order_id AND d.customer_id=l.customer_id " + + "WHERE l.customer_id=#{customerId} AND l.plan_id=#{planId} AND l.id=#{lineId} GROUP BY UPPER(TRIM(d.sku_code))") + List frozenSkus(@Param("customerId") String customerId, @Param("planId") Long planId, @Param("lineId") Long lineId); + + /** 同柜次同 FBA 仓按总箱数收货,跨客户只返回成员数用于拒绝有歧义的短少确认。 */ + @Select(""" + SELECT COUNT(*) FROM ll_inbound_plan_line selected + JOIN ll_inbound_plan sp ON sp.id=selected.plan_id AND sp.customer_id=selected.customer_id + JOIN ll_order so ON so.id=selected.order_id AND so.customer_id=selected.customer_id + JOIN ll_inbound_plan p ON p.container_no=sp.container_no + JOIN ll_inbound_plan_line l ON l.plan_id=p.id AND l.customer_id=p.customer_id + JOIN ll_order o ON o.id=l.order_id AND o.customer_id=l.customer_id + WHERE selected.customer_id=#{customerId} AND selected.plan_id=#{planId} AND selected.id=#{lineId} + AND NULLIF(TRIM(so.source_container_booking_id),'') IS NOT NULL + AND TRIM(o.source_container_booking_id)=TRIM(so.source_container_booking_id) + AND TRIM(o.container_no)=sp.container_no + AND COALESCE(o.order_type,'')<>'ONE_CLICK_DROPSHIP' + AND UPPER(TRIM(l.fba_warehouse_code))=UPPER(TRIM(selected.fba_warehouse_code)) + AND UPPER(COALESCE(NULLIF(TRIM(l.destination_type),''),NULLIF(TRIM(o.destination_type),''),'FBA'))='FBA' + """) + long fbaGroupLineCount(@Param("customerId") String customerId, @Param("planId") Long planId, @Param("lineId") Long lineId); + @Select("SELECT * FROM ll_receiving_sku_receipt WHERE customer_id=#{customerId} " + "AND line_id=#{lineId} AND sku_code=#{skuCode} ORDER BY id FOR UPDATE") List lockLineSku(@Param("customerId") String customerId, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/repository/PdaWorkflowRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/repository/PdaWorkflowRepository.java index dbd9b4615..3e5d3927b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/repository/PdaWorkflowRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/repository/PdaWorkflowRepository.java @@ -36,6 +36,22 @@ public class PdaWorkflowRepository { @Resource private InboundPalletMergeMapper merges; @Resource private InboundPlanCartonMapper cartons; @Resource private LastlegOrderMapper orders; + @Resource private com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingSkuReceiptMapper skuReceipts; + + /** 短少确认只能采用本客户本计划已登记的逐 SKU 实收。 */ + public List skuReceipts(String customerId, Long planId) { + return skuReceipts.listPlan(customerId, planId); + } + + /** 冻结申报用于判断总箱数能否唯一确定实际 SKU。 */ + public List frozenSkus(String customerId, Long planId, Long lineId) { + return skuReceipts.frozenSkus(customerId, planId, lineId); + } + + /** 返回整组来源数,不把其他客户的货物明细暴露给操作端。 */ + public long fbaGroupLineCount(String customerId, Long planId, Long lineId) { + return skuReceipts.fbaGroupLineCount(customerId, planId, lineId); + } /** 收货完成前只读计划身份,运输父锁取得后再加来源行锁。 */ public InboundPlan findPlan(String customerId, String planNo) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java index bcd469f2a..7b6d6bf6e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java @@ -716,7 +716,87 @@ public class InboundPdaWorkflowService { repository.update(plan); } - /** Explicit operator decision to receive only the cartons actually picked. */ + /** 平台确认 FBA 短少后将真实实收入库;原应收、现场结束时间和短少证据保留。 */ + @Transactional + public PdaResponses.Task confirmShortage(String customerId, String planNo, String operatorId, + String idempotencyKey, com.baiyue.lastleg.inboundplan.dto.ConfirmInboundShortageRequest request) { + if (idempotencyKey == null || idempotencyKey.isBlank() || idempotencyKey.length() > 128 + || operatorId == null || operatorId.isBlank() || request.reason() == null || request.reason().isBlank()) { + throw conflict("短少确认必须提供操作人、请求键和确认原因"); + } + InboundPlan original = repository.findPlan(customerId, planNo); + if (original == null) throw notFound("入库单不存在或不属于当前客户"); + dispatchReceipts.lock(List.of(original.getId())); + InboundPlan plan = requirePlan(customerId, planNo); + // 将单号和操作人纳入幂等摘要,避免跨单或跨操作人复用请求键。 + Map command = Map.of("planNo", planNo, "operatorId", operatorId, "request", request); + InboundPdaOperation replay = replay(customerId, "CONFIRM_SHORTAGE", idempotencyKey, command); + if (replay != null) return read(replay, PdaResponses.Task.class); + InboundPdaTask task = repository.lockTask(customerId, plan.getId()); + if (!"PENDING_RECONCILIATION".equals(plan.getStatus()) || task == null + || !"PENDING_RECONCILIATION".equals(task.getStatus())) { + throw conflict("仅待差异核对的入库单可确认短少,请刷新后重试"); + } + if (!Objects.equals(plan.getVersion(), request.version())) throw conflict("入库单已变化,请刷新后重新确认短少"); + if (mode(plan) != InboundMode.FBA) throw conflict("当前短少确认仅适用于按 SKU 登记实收的 FBA 入库单"); + if (unloadPhotoFileIds(task).isEmpty() || task.getUnloadPhotoUploadedAt() == null || task.getCompletedAt() == null) { + throw conflict("现场收货尚未结束或缺少卸柜凭证,不能确认短少"); + } + List lines = repository.lines(plan.getId()); + List allocations = repository.allocations(plan.getId()); + if (lines.isEmpty() || lines.stream().anyMatch(line -> !"FBA".equals(destinationType(line)))) { + throw conflict("短少确认要求每条明细均为 FBA 实收货物"); + } + int expected = lines.stream().mapToInt(line -> value(line.getRequiredPackageCount())).sum(); + int received = allocations.stream().mapToInt(allocation -> value(allocation.getPickedPackageCount())).sum(); + if (!Objects.equals(expected, request.expectedPackageCount()) || !Objects.equals(received, request.receivedPackageCount())) { + throw conflict("应收或实收数量已变化,请刷新后重新确认"); + } + if (received <= 0 || received >= expected) throw conflict("仅有实际收货且实收少于应收时可确认短少"); + var skuReceipts = repository.skuReceipts(customerId, plan.getId()); + // 批量收货的 SKU 行来自计划顺序分摊;只有单票单 SKU 的整组总数才是无歧义实收。 + if (lines.size() != 1) throw conflict("多票收货不能仅按总箱数确认短少,请先核实每票及每个 SKU 的实际数量"); + var onlyLine = lines.get(0); + var frozenSkus = repository.frozenSkus(customerId, plan.getId(), onlyLine.getId()); + if (repository.fbaGroupLineCount(customerId, plan.getId(), onlyLine.getId()) != 1 + || frozenSkus.size() != 1 || frozenSkus.get(0).skuCode() == null || frozenSkus.get(0).skuCode().isBlank() + || frozenSkus.get(0).packages() != expected) { + throw conflict("当前仅支持同柜同目的仓单票、单 SKU 短少确认;多票或多 SKU 请先核实实际货物归属"); + } + if (skuReceipts.stream().anyMatch(r -> r.getSkuCode() == null + || !frozenSkus.get(0).skuCode().equalsIgnoreCase(r.getSkuCode().trim()))) { + throw conflict("实收 SKU 与冻结申报不一致,不能确认短少"); + } + for (InboundPlanLine line : lines) { + int picked = allocations.stream().filter(a -> Objects.equals(a.getLineId(), line.getId())) + .mapToInt(a -> value(a.getPickedPackageCount())).sum(); + int skuPackages = skuReceipts.stream().filter(r -> Objects.equals(r.getLineId(), line.getId())) + .mapToInt(r -> value(r.getReceivedPackages())).sum(); + if (picked < 0 || picked > value(line.getRequiredPackageCount()) || picked != skuPackages) { + throw conflict("逐票 SKU 实收与托盘数量不一致或存在超收,不能按短少确认"); + } + } + if (allocations.stream().anyMatch(a -> value(a.getPickedPackageCount()) < 0 + || value(a.getPickedPackageCount()) != value(a.getCountedPackageCount()))) { + throw conflict("清点与收货数量不一致,不能确认短少"); + } + List pallets = new ArrayList<>(repository.pallets(plan.getId())); + Set palletIds = pallets.stream().map(InboundPlanPallet::getId).collect(java.util.stream.Collectors.toSet()); + pallets.addAll(repository.palletsByIds(allocations.stream().map(InboundPlanPalletAllocation::getPalletId) + .filter(id -> id != null && !palletIds.contains(id)).collect(java.util.stream.Collectors.toSet()))); + // 库存和库龄采用原现场收货时间,不因后台核对延迟重置;不重复发布柜级卸货事件。 + LocalDateTime now = LocalDateTime.now(); + stockReceipts.stockIn(customerId, plan, task, lines, allocations, pallets, task.getCompletedAt(), true); + task.setStatus("PARTIAL_INBOUND"); task.setUpdatedAt(now); task.setVersion(value(task.getVersion()) + 1); + repository.update(task); + plan.setStatus("PARTIAL_INBOUND"); plan.setUpdatedAt(now); repository.update(plan); + dispatchReceipts.complete(List.of(plan.getId()), operatorId, now); + PdaResponses.Task result = taskResponse(task, plan, planNo); + saveOperation(customerId, task.getId(), null, "CONFIRM_SHORTAGE", idempotencyKey, command, result); + return result; + } + + /** 操作员明确选择仅将当前已收箱数入库。 */ @Transactional public PdaResponses.Task partialComplete(String customerId, String planNo, PdaRequests.ForceComplete request) { InboundPlan plan = requirePlan(customerId, planNo); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java index 1c1106b18..dd77ddf57 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java @@ -50,6 +50,7 @@ public class InboundStockReceiptService { @Resource private DropshipOrderService dropshipOrders; @Resource private LastlegOrderStatusHistoryMapper orderHistories; + /** 按完整实收生成库存凭证。 */ @Transactional public void stockIn(String customerId, InboundPlan plan, InboundPdaTask task, List lines, List allocations, @@ -57,6 +58,7 @@ public class InboundStockReceiptService { stockIn(customerId, plan, task, lines, allocations, pallets, receivedAt, false); } + /** 部分入库仍按逐 SKU 实收生成凭证,原申报箱数仅作为上限。 */ @Transactional public void stockIn(String customerId, InboundPlan plan, InboundPdaTask task, List lines, List allocations, @@ -105,7 +107,7 @@ public class InboundStockReceiptService { List exactSkuReceipts = skuReceiptsByLine.getOrDefault(line.getId(), List.of()); if (!exactSkuReceipts.isEmpty()) { created.addAll(persistExactSkuReceipts(customerId, plan, task, line, lineAllocations, - palletById, order, exactSkuReceipts, pickedPackages, receivedAt)); + palletById, order, exactSkuReceipts, pickedPackages, receivedAt, allowPartial)); continue; } List skuRemainders = "ONE_CLICK_DROPSHIP".equals(order.getOrderType()) @@ -145,14 +147,17 @@ public class InboundStockReceiptService { completeOrders(customerId, plan, orderById, receivedAt, allowPartial); } + /** 逐 SKU 校验实收归属;短少可以少于冻结箱数,但不允许其他 SKU 超收抵扣。 */ private List persistExactSkuReceipts(String customerId, InboundPlan plan, InboundPdaTask task, InboundPlanLine line, List lineAllocations, Map palletById, LastlegOrder order, - List exactReceipts, long pickedPackages, LocalDateTime receivedAt) { + List exactReceipts, long pickedPackages, LocalDateTime receivedAt, boolean allowPartial) { + if (exactReceipts.stream().anyMatch(r -> r.getSkuCode() == null || r.getSkuCode().isBlank() + || value(r.getReceivedPackages()) <= 0)) throw conflict("SKU 实收必须包含有效 SKU 和正数箱数"); long actualPackages = exactReceipts.stream().mapToLong(value -> value(value.getReceivedPackages())).sum(); if (actualPackages != pickedPackages) throw conflict("FBA SKU receipts must equal the picked package total"); - Map expectedBySku = skuRemainders(order.getId(), pickedPackages).stream() + Map expectedBySku = skuRemainders(order.getId(), value(line.getRequiredPackageCount())).stream() .collect(Collectors.toMap(value -> value.skuCode, value -> value.remaining, Long::sum, java.util.LinkedHashMap::new)); Map actualBySku = exactReceipts.stream().collect(Collectors.groupingBy( @@ -161,7 +166,11 @@ public class InboundStockReceiptService { Map normalizedExpected = expectedBySku.entrySet().stream().collect(Collectors.toMap( value -> value.getKey().toUpperCase(java.util.Locale.ROOT), Map.Entry::getValue, Long::sum, java.util.LinkedHashMap::new)); - if (!actualBySku.equals(normalizedExpected)) { + boolean invalid = allowPartial + ? actualBySku.entrySet().stream().anyMatch(entry -> !normalizedExpected.containsKey(entry.getKey()) + || entry.getValue() > normalizedExpected.get(entry.getKey())) + : !actualBySku.equals(normalizedExpected); + if (invalid) { throw conflict("FBA SKU receipts must exactly match the frozen SKU package snapshots"); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java index 949763110..71619b2f6 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java @@ -738,7 +738,7 @@ public class ReceivingContainerService { boolean allPlansReady = totalOrders > 0 && inboundCodes == totalOrders && plannedOrders == totalOrders && planCount == totalOrders; boolean allPlansCompleted = !matchedPlans.isEmpty() - && matchedPlans.stream().allMatch(plan -> List.of("COMPLETED", "PENDING_RECONCILIATION") + && matchedPlans.stream().allMatch(plan -> List.of("COMPLETED", "PARTIAL_INBOUND", "PENDING_RECONCILIATION") .contains(plan.getStatus())); boolean shippingReady = facts == null ? shippingGates.receivingAvailable(sourceContainerBookingId, containerNo) == 1 @@ -812,7 +812,7 @@ public class ReceivingContainerService { if (values.stream().anyMatch(value -> trim(value.getCustomerId()) == null)) { throw conflict("Every inbound plan in the receiving container must have an owning customer"); } - if (values.stream().allMatch(value -> List.of("COMPLETED", "PENDING_RECONCILIATION") + if (values.stream().allMatch(value -> List.of("COMPLETED", "PARTIAL_INBOUND", "PENDING_RECONCILIATION") .contains(value.getStatus()))) { throw conflict("Receiving container is already completed"); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentReceiptDispatchAdapter.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentReceiptDispatchAdapter.java index 144d78424..44337f458 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentReceiptDispatchAdapter.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentReceiptDispatchAdapter.java @@ -37,6 +37,14 @@ public class AppointmentReceiptDispatchAdapter implements InboundDispatchReceipt for(var item:items) { var original=previous.stream().filter(r->r.itemId()==item.itemId()).toList(); var physical=repository.physical(item.planPalletId(),item.customerId()); + // 零实收不再占用任何批次额度;明确核对数量和归属后仅移除本车本托分配。 + if(item.packages()==0) { + if(physical.isEmpty() || physical.stream().anyMatch(p->p.countedPackages()==null || p.countedPackages()!=0) + || original.stream().anyMatch(r->!r.customerId().equals(item.customerId()) || r.palletId()!=item.planPalletId())) + throw conflict("空托盘实收或原预约归属不一致,不能释放预约分配"); + binding.removeItem(dispatch,item.itemId()); + continue; + } var canonical=AppointmentWorkPhysicalSources.canonical(physical,sources); var quantities=new LinkedHashMap,Integer>(); for(var part:physical) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/workflow/OrderWorkflowFactMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/workflow/OrderWorkflowFactMapper.java index dc8dc34e8..058efb950 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/workflow/OrderWorkflowFactMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/workflow/OrderWorkflowFactMapper.java @@ -20,7 +20,7 @@ public interface OrderWorkflowFactMapper { WHERE di.id=i.dispatch_item_id AND di.customer_id=i.customer_id AND di.dispatch_order_id=b.dispatch_order_id AND di.destination_type='PRIVATE' AND TRIM(di.delivery_address)=TRIM(d.delivery_address) AND COALESCE(TRIM(di.postal_code),'')=COALESCE(TRIM(d.postal_code),''))))) delivering, - EXISTS(SELECT 1 FROM ll_inbound_plan p WHERE p.customer_id=o.customer_id AND p.source_order_id=o.id AND p.status IN ('COMPLETED','PENDING_RECONCILIATION')) unloaded + EXISTS(SELECT 1 FROM ll_inbound_plan p WHERE p.customer_id=o.customer_id AND p.source_order_id=o.id AND p.status IN ('COMPLETED','PARTIAL_INBOUND','PENDING_RECONCILIATION')) unloaded FROM ll_order o WHERE o.source_region='FIRSTLEG' AND o.order_type NOT IN ('RELABEL_APPLICATION','WAREHOUSE_TRANSFER') AND (o.customer_id=#{s.tenantKey} AND o.source_inbound_id=#{s.inboundKey}) diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 8c8b975ea..7cefc3ead 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -4663,6 +4663,20 @@ CREATE TABLE IF NOT EXISTS ll_document_sequence ( CONSTRAINT chk_ll_document_sequence_value CHECK (current_value > 0) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='业务单据每日独立原子流水'; +CREATE TABLE IF NOT EXISTS ll_fleet_resource_photo ( + resource_kind VARCHAR(16) NOT NULL COMMENT '档案类型:车辆或司机', + resource_id BIGINT NOT NULL COMMENT '车辆或司机档案标识', + record_version BIGINT NOT NULL COMMENT '照片版本,从一递增', + file_id BIGINT NOT NULL COMMENT '文件服务照片标识', + file_name VARCHAR(512) NOT NULL COMMENT '照片原始文件名', + content_type VARCHAR(64) NOT NULL COMMENT '照片媒体类型', + size_bytes BIGINT NOT NULL COMMENT '照片字节数', + created_by VARCHAR(64) NOT NULL COMMENT '上传操作人标识', + created_at DATETIME(3) NOT NULL COMMENT '上传时间(UTC)', + PRIMARY KEY (resource_kind, resource_id, record_version), + UNIQUE KEY uk_fleet_resource_photo_file (resource_kind, resource_id, file_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车辆及司机档案照片版本记录'; + -- 海外人工费用资金编排。金额与余额以 Finance 钱包为唯一事实,本表仅记录指令及回执状态。 CREATE TABLE IF NOT EXISTS ll_finance_fee_funding ( source_fee_id BIGINT NOT NULL COMMENT '关联海外费用稳定来源ID', diff --git a/lastleg-service/src/main/resources/openapi/dispatch-loading-documents-v1.yaml b/lastleg-service/src/main/resources/openapi/dispatch-loading-documents-v1.yaml index 0b3087b25..c791d967c 100644 --- a/lastleg-service/src/main/resources/openapi/dispatch-loading-documents-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/dispatch-loading-documents-v1.yaml @@ -2,7 +2,7 @@ openapi: 3.0.3 info: title: 排车装车资料(Admin/PDA共用) version: 1.0.0 - description: LOADING阶段即可同步上传并保存五个整车照片框;照片齐全不会提前发车。装车确认时资料已齐则直接进入DELIVERING,否则进入AFTER_LOADING,最后一次绑定自动生成派送并进入DELIVERING。触发推进的装车确认或照片绑定与派送同事务,失败保留先前已保存资料。复用车队照片记录,技术槽位仅表示照片1至5,不代表驾驶证审核或真实签收。PDA每次核验Token冻结仓,平台list只读、edit写;原键仅原操作者恢复。 + description: LOADING阶段即可同步上传并保存五个整车照片框;照片齐全不会提前发车。装车确认时资料已齐则直接进入DELIVERING,否则进入AFTER_LOADING,最后一次绑定自动生成派送并进入DELIVERING。触发推进的装车确认或照片绑定与派送同事务,失败保留先前已保存资料。复用车队照片记录,技术槽位沿用原文件归属,按顺序对应司机驾驶证、车牌照片、装车完成、封车照片、BOL司机签字;每类1至5张,允许追加或指定replaceFileId精确替换,不代表驾驶证审核或真实签收。PDA每次核验Token冻结仓,平台list只读、edit写;原键仅原操作者恢复。 security: - bearerAuth: [] paths: @@ -375,7 +375,7 @@ components: - DELIVERED complete: type: boolean - description: 五个固定框各有一张已保存照片 + description: 五类各至少一张已保存照片,不能用总张数代替类别齐全 BindPhoto: type: object required: @@ -392,3 +392,8 @@ components: maxLength: 128 fileId: type: string + replaceFileId: + type: string + nullable: true + pattern: "^[1-9][0-9]*$" + description: 待替换的当前同类文件ID;省略时装车照片追加,每类最多5张,油耗沿单张替换。 diff --git a/lastleg-service/src/main/resources/openapi/dispatch-vehicle-documents-v1.yaml b/lastleg-service/src/main/resources/openapi/dispatch-vehicle-documents-v1.yaml index e6fe3e2d6..9ef338e1c 100644 --- a/lastleg-service/src/main/resources/openapi/dispatch-vehicle-documents-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/dispatch-vehicle-documents-v1.yaml @@ -240,3 +240,8 @@ components: maxLength: 128 fileId: type: string + replaceFileId: + type: string + nullable: true + pattern: "^[1-9][0-9]*$" + description: 待替换的当前同类文件ID;省略时装车照片追加,每类最多5张,油耗沿单张替换。 diff --git a/lastleg-service/src/main/resources/openapi/fleet-outsourced-business-v1.yaml b/lastleg-service/src/main/resources/openapi/fleet-outsourced-business-v1.yaml index 00f84a83f..0a1cce063 100644 --- a/lastleg-service/src/main/resources/openapi/fleet-outsourced-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/fleet-outsourced-business-v1.yaml @@ -175,6 +175,12 @@ components: recordVersion: { type: string, pattern: "^(0|[1-9][0-9]*)$" } idempotencyKey: { type: string, minLength: 8, maxLength: 128 } fileId: { type: string, pattern: "^[1-9][0-9]*$" } + replaceFileId: + type: string + nullable: true + pattern: "^[1-9][0-9]*$" + description: 待替换的当前同类文件ID;省略时装车照片追加,每类最多5张,油耗沿单张替换。 + Vehicle: type: object x-java-record: OutsourcedBusinessModels.Vehicle diff --git a/lastleg-service/src/main/resources/openapi/fleet-owned-business-v1.yaml b/lastleg-service/src/main/resources/openapi/fleet-owned-business-v1.yaml index 5a8fdf286..c2171cddb 100644 --- a/lastleg-service/src/main/resources/openapi/fleet-owned-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/fleet-owned-business-v1.yaml @@ -500,6 +500,12 @@ components: fileId: type: "string" pattern: "^[1-9][0-9]*$" + replaceFileId: + type: string + nullable: true + pattern: "^[1-9][0-9]*$" + description: 待替换的当前同类文件ID;省略时装车照片追加,每类最多5张,油耗沿单张替换。 + Gate: type: "object" x-java-record: "OwnedBusinessModels.Gate" diff --git a/lastleg-service/src/main/resources/openapi/fleet-resource-photos-v1.yaml b/lastleg-service/src/main/resources/openapi/fleet-resource-photos-v1.yaml new file mode 100644 index 000000000..12e58f0c3 --- /dev/null +++ b/lastleg-service/src/main/resources/openapi/fleet-resource-photos-v1.yaml @@ -0,0 +1,82 @@ +openapi: 3.0.3 +info: + title: 车辆与司机档案照片 + version: 1.0.0 + description: >- + 档案照片使用独立版本并保留历史。车辆读取沿vehicle:list、上传沿vehicle:edit; + 司机读取沿driver:list、上传沿driver:upload,均位于platform:overseas-master-data权限域。 + 只接受非空JPG/PNG,最大10MiB。排车保存时仅对缺失的车辆/司机照片复制当趟附件; + 后续更换档案照片不覆盖已有排车照片,不创建临时外配主档,不改变驾驶证审核。 + 驾驶证仍走既有license接口;排车审核与主档统一接受PDF/JPG/PNG。 +security: + - bearerAuth: [] +paths: + /api/platform/v1/last-leg/fleet/resource-photos/{kind}/{id}: + parameters: + - {$ref: '#/components/parameters/Kind'} + - {$ref: '#/components/parameters/Id'} + get: + summary: 读取当前档案照片,未上传时版本为0且photo为空 + responses: + '200': {$ref: '#/components/responses/Detail'} + '403': {description: 缺少该类档案list权限} + post: + summary: 上传或替换档案照片,照片版本冲突时不覆盖 + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [recordVersion, file] + properties: + recordVersion: {type: string, pattern: '^(0|[1-9][0-9]*)$'} + file: {type: string, format: binary} + responses: + '200': {$ref: '#/components/responses/Detail'} + '403': {description: 缺少车辆edit或司机upload权限} + /api/platform/v1/last-leg/fleet/resource-photos/{kind}/{id}/{fileId}/content: + parameters: + - {$ref: '#/components/parameters/Kind'} + - {$ref: '#/components/parameters/Id'} + - {name: fileId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + get: + summary: 读取本档案已关联的当前或历史图片,no-store且无匿名URL + responses: + '200': + description: 图片内容;业务错误为统一JSON响应,不可当成图片 + content: + image/jpeg: {schema: {type: string, format: binary}} + image/png: {schema: {type: string, format: binary}} + '403': {description: 缺少该类档案list权限} +components: + securitySchemes: + bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT} + parameters: + Kind: {name: kind, in: path, required: true, schema: {type: string, enum: [VEHICLE, DRIVER]}} + Id: {name: id, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + responses: + Detail: + description: 业务成功code为200;版本冲突或归属无效通过业务错误返回 + content: + application/json: + schema: + type: object + required: [code, data] + properties: + code: {type: string} + message: {type: string} + data: + type: object + required: [recordVersion, photo] + properties: + recordVersion: {type: string, pattern: '^(0|[1-9][0-9]*)$'} + photo: + type: object + nullable: true + required: [fileId, fileName, contentType, sizeBytes] + properties: + fileId: {type: string, pattern: '^[1-9][0-9]*$'} + fileName: {type: string} + contentType: {type: string, enum: [image/jpeg, image/png]} + sizeBytes: {type: string, pattern: '^[1-9][0-9]*$'} diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index 9e3ca0c81..94b9dfdc2 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -667,6 +667,32 @@ paths: responses: '200': { description: 平台入库订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/InboundPlanDetailResponse' } } } } '404': { description: 入库订单不存在或不属于指定客户 } + /api/platform/v1/last-leg/inbound-plans/{planNo}/confirm-shortage: + post: + summary: 确认FBA入库短少并按实收入库 + description: 权限 platform:overseas-warehouse:inbound-order:edit;仅平台指定客户操作。仅支持同柜次同目的仓收货组单票、冻结申报单SKU;多SKU或跨票合收不能按自动分摊确认。PENDING_RECONCILIATION且0<实收<应收时,核对版本及逐SKU实收,保留原应收和现场收货时间,按实收写入库存并进入PARTIAL_INBOUND,同事务交接原预排并移除明确零实收托盘的排车明细、占用和预约分配。操作原因与操作人写入收货操作记录;不重复发布卸柜事件。超收、SKU不一致、客户不匹配和陈旧版本拒绝;相同请求键重放不会重复入库。 + security: [{ bearerAuth: [] }] + parameters: + - { name: planNo, in: path, required: true, schema: { type: string } } + - { name: Idempotency-Key, in: header, required: true, schema: { type: string, minLength: 1, maxLength: 128 } } + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [customerId, version, expectedPackageCount, receivedPackageCount, reason] + properties: + customerId: { type: string, pattern: '^[1-9][0-9]*$' } + version: { type: integer, minimum: 0 } + expectedPackageCount: { type: integer, minimum: 1 } + receivedPackageCount: { type: integer, minimum: 1 } + reason: { type: string, minLength: 1, maxLength: 200 } + responses: + '200': { description: ApiResponse包装的收货任务结果,status为PARTIAL_INBOUND } + '403': { description: 非平台身份或无入库编辑权限 } + '404': { description: 入库单不存在或客户不匹配 } + '409': { description: 版本、状态、实收数量、SKU或幂等键冲突 } /api/platform/v1/last-leg/inbound-plans/manual: post: summary: 平台按单票手工生成入库计划 diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchCandidateMapperContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchCandidateMapperContractTest.java index 5b0cdbba1..5a8e83e91 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchCandidateMapperContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchCandidateMapperContractTest.java @@ -16,12 +16,12 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertEquals; class DispatchCandidateMapperContractTest { - /** 执行正式计划/实收判定与离港门禁,覆盖自动预留、人工覆盖、混合来源及库存耗尽。 */ + /** 执行正式候选和提交资格,覆盖在途预排、收货结束后的空预留托盘及真实库存。 */ @Test void reservedPalletsCanBePlannedAfterDepartureWithoutInventingReceivedStock() throws Exception { try (var connection = DriverManager.getConnection("jdbc:h2:mem:dispatch-prearrival;MODE=MySQL")) { var db = connection.createStatement(); - db.execute("CREATE TABLE ll_inbound_plan(id BIGINT,customer_id VARCHAR)"); + db.execute("CREATE TABLE ll_inbound_plan(id BIGINT,customer_id VARCHAR,status VARCHAR DEFAULT 'PDA_PENDING')"); db.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT,plan_id BIGINT,warehouse_pallet_id BIGINT,assignment_status VARCHAR,actual_package_count INT)"); db.execute("CREATE TABLE ll_inbound_plan_line(id BIGINT,customer_id VARCHAR,order_id BIGINT)"); db.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(pallet_id BIGINT,line_id BIGINT)"); @@ -29,8 +29,8 @@ class DispatchCandidateMapperContractTest { db.execute("CREATE TABLE ll_order_manual_node(order_id BIGINT,customer_id VARCHAR,node_code VARCHAR,node_at TIMESTAMP)"); db.execute("CREATE TABLE ll_container_shipping_gate(container_booking_id BIGINT,node_sequence INT,actual_at TIMESTAMP)"); db.execute("CREATE TABLE ll_inventory_position(customer_id VARCHAR,pallet_id BIGINT,available_packages INT)"); - for (int id = 1; id <= 9; id++) { - db.execute("INSERT INTO ll_inbound_plan VALUES (" + id + ",'A')"); + for (int id = 1; id <= 14; id++) { + db.execute("INSERT INTO ll_inbound_plan(id,customer_id) VALUES (" + id + ",'A')"); db.execute("INSERT INTO ll_inbound_plan_pallet VALUES (" + id + "," + id + "," + (100 + id) + ",'RESERVED',0)"); db.execute("INSERT INTO ll_inbound_plan_line VALUES (" + id + ",'A'," + id + ")"); db.execute("INSERT INTO ll_inbound_plan_pallet_allocation VALUES (" + id + "," + id + ")"); @@ -44,24 +44,34 @@ class DispatchCandidateMapperContractTest { db.execute("INSERT INTO ll_order_manual_node VALUES (7,'A','ACTUAL_DEPARTURE',CURRENT_TIMESTAMP),(9,'B','ACTUAL_DEPARTURE',CURRENT_TIMESTAMP)"); db.execute("INSERT INTO ll_inbound_plan_line VALUES (20,'A',2)"); db.execute("INSERT INTO ll_inbound_plan_pallet_allocation VALUES (8,20)"); + // 同一计划原分两托,最终21箱全收在一托;另一预留托盘不能继续冒充可预排货物。 + db.execute("UPDATE ll_inbound_plan SET status='COMPLETED' WHERE id IN (10,13,14)"); + db.execute("UPDATE ll_inbound_plan_pallet SET plan_id=10,assignment_status='ASSIGNED',actual_package_count=21 WHERE id=11"); + db.execute("INSERT INTO ll_inventory_position VALUES ('A',111,21)"); + db.execute("UPDATE ll_inbound_plan SET status='PARTIAL_INBOUND' WHERE id=12"); + db.execute("UPDATE ll_inbound_plan_pallet SET warehouse_pallet_id=NULL WHERE id=13"); + db.execute("UPDATE ll_inbound_plan_pallet SET assignment_status='ASSIGNED' WHERE id=14"); String plan = DispatchCandidateMapper.PLAN_ONLY; - String eligible = "AND (NOT " + plan + " OR (" + DispatchCandidateMapper.DEPARTED_PLAN + ")) " - + "AND (" + plan + " OR EXISTS (SELECT 1 FROM ll_inventory_position pos " - + "WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0))"; - var ids = new ArrayList(); - try (var rows = db.executeQuery("SELECT p.id,CASE WHEN " + plan + " THEN NULL ELSE p.warehouse_pallet_id END AS physical," - + "CASE WHEN " + plan + " THEN 'PLAN' ELSE 'RECEIVED' END AS stage " - + "FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id WHERE 1=1 " + eligible + " ORDER BY p.id")) { - while (rows.next()) { - long id = rows.getLong("id"); ids.add(id); - assertEquals(id == 3 ? "RECEIVED" : "PLAN", rows.getString("stage")); - assertEquals(id == 3 ? 103L : null, rows.getObject("physical", Long.class)); - } - } - assertEquals(List.of(1L, 3L, 7L), ids); - // 查询和写入选中来源共用同一判定;不允许仅在列表展示在途但提交时又要求实收。 + // 直接执行两条生产SQL中的资格片段,避免仅验证测试自行拼出的规则。 for (String sql : List.of(DispatchCandidateMapper.AVAILABLE_PLAN_BODY, DispatchCandidateMapper.SELECTED_PLAN_SQL)) { - assertTrue(sql.contains(eligible.replace("> 0", "> 0").replace("pos.customer_id=ip.customer_id", sql.contains("WHERE pos.customer_id=#{customerId}") ? "pos.customer_id=#{customerId}" : "pos.customer_id=ip.customer_id"))); + int start = sql.indexOf("AND (NOT " + plan + " OR ("); + int stockGate = sql.indexOf("AND (" + plan + " OR EXISTS", start); + int end = sql.indexOf("AND NOT EXISTS (SELECT 1 FROM ll_", stockGate); + String eligible = sql.substring(start, end).replace(">", ">") + .replace("#{customerId}", "'A'"); + var ids = new ArrayList(); + try (var rows = db.executeQuery("SELECT p.id,p.actual_package_count,CASE WHEN " + plan + " THEN NULL ELSE p.warehouse_pallet_id END AS physical," + + "CASE WHEN " + plan + " THEN 'PLAN' ELSE 'RECEIVED' END AS stage " + + "FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id WHERE 1=1 " + eligible + " ORDER BY p.id")) { + while (rows.next()) { + long id = rows.getLong("id"); ids.add(id); + assertEquals(id == 3 || id == 11 ? "RECEIVED" : "PLAN", rows.getString("stage")); + assertEquals(id == 3 || id == 11 ? 100L + id : null, rows.getObject("physical", Long.class)); + if (id == 11) assertEquals(21, rows.getInt("actual_package_count")); + } + } + // 完成入库或已确认短少后,空预留托盘不可排车,真实实收托盘继续按库存校验。 + assertEquals(List.of(1L, 3L, 7L, 11L), ids); assertTrue(sql.contains("CASE WHEN " + plan + " THEN NULL ELSE p.warehouse_pallet_id END AS warehousePalletId")); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java index 9ce97c28b..864c60a93 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java @@ -640,6 +640,9 @@ public class DispatchAggregatePersistenceTest { var dispatchEvidence=mock(com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.class); when(dispatchEvidence.metadata(anyLong(),any(),anyLong())).thenAnswer(call->new com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.File(call.getArgument(2),"照片.png","image/png",10)); org.springframework.test.util.ReflectionTestUtils.setField(service,"evidence",dispatchEvidence); + var profilePhotos=mock(com.baiyue.lastleg.fleet.photo.FleetResourcePhotoService.class); + when(profilePhotos.current(any(),anyLong())).thenReturn(new com.baiyue.lastleg.fleet.photo.FleetResourcePhotoModels.Detail(0,null)); + org.springframework.test.util.ReflectionTestUtils.setField(service,"masterPhotos",new DispatchMasterPhotoService(profilePhotos,dispatchEvidence)); org.springframework.test.util.ReflectionTestUtils.setField(service,"ownedBusinessOwnership",ownedBusinessOwnership); org.springframework.test.util.ReflectionTestUtils.setField(gateway,"workflow",service); org.springframework.test.util.ReflectionTestUtils.setField(service,"appointments",new DispatchAppointmentAllocations(mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class))); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchFeedbackRegressionTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchFeedbackRegressionTest.java index a950ac317..2b055e062 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchFeedbackRegressionTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchFeedbackRegressionTest.java @@ -17,8 +17,9 @@ import static org.mockito.Mockito.*; /** 排车反馈直接回归:图片送审门禁与正式预约的多货件投影。 */ class DispatchFeedbackRegressionTest { - /** 主档PDF与本趟补传图片分属各自文件域,补传不能被主档重建清空。 */ - @Test void masterLicenseKeepsAdditionalDispatchPhotoAndItsOwner() throws Exception { + /** 主档PDF可以单独通过驾驶证校验,也可与本趟补传图片共存;司机照片自动带入。 */ + @ParameterizedTest @ValueSource(booleans={false,true}) + void masterLicenseKeepsAdditionalDispatchPhotoAndItsOwner(boolean supplement) throws Exception { var f=new DispatchAggregatePersistenceTest.Fixture(); var created=f.tx.execute(s->f.service.create(f.actor,f.configured("master-photo",List.of(f.source(1,"101",501,"ONT8",3))))); f.db.execute("CREATE TABLE ll_fleet_driver(id BIGINT PRIMARY KEY)");f.db.update("INSERT INTO ll_fleet_driver VALUES(71)"); @@ -30,15 +31,23 @@ class DispatchFeedbackRegressionTest { when(details.licenseNumber()).thenReturn("LICENSE");when(details.licenseExpiresOn()).thenReturn(java.time.LocalDate.of(2099,1,1)); when(drivers.requireDispatchable(eq(71L),any())).thenReturn(driver); when(drivers.download(71L,81L)).thenReturn(new com.baiyue.lastleg.fleet.driver.DriverModels.Download(new com.baiyue.lastleg.fleet.driver.DriverModels.File("81","主档证照.pdf","application/pdf",3),new byte[]{1,2,3})); + var profilePhotos=mock(com.baiyue.lastleg.fleet.photo.FleetResourcePhotoService.class); + var photo=new com.baiyue.lastleg.fleet.photo.FleetResourcePhotoModels.Photo("88","司机.png","image/png",3); + when(profilePhotos.current(com.baiyue.lastleg.fleet.photo.FleetResourcePhotoModels.Kind.DRIVER,71)).thenReturn(new com.baiyue.lastleg.fleet.photo.FleetResourcePhotoModels.Detail(1,photo)); + when(profilePhotos.download(com.baiyue.lastleg.fleet.photo.FleetResourcePhotoModels.Kind.DRIVER,71,88)).thenReturn(new com.baiyue.lastleg.fleet.photo.FleetResourcePhotoModels.Download(photo,new byte[]{1,2,3})); + var evidence=(DispatchEvidenceGateway)ReflectionTestUtils.getField(f.service,"evidence"); + when(evidence.upload(anyLong(),eq(DispatchEvidenceGateway.Kind.DRIVER_PHOTO),anyString(),any())).thenReturn(new DispatchEvidenceGateway.File(303L,"司机.png","image/png",3)); + ReflectionTestUtils.setField(f.service,"masterPhotos",new DispatchMasterPhotoService(profilePhotos,evidence)); var input=f.configured("update",List.of()); var update=new DispatchCommands.Update(created.recordVersion(),"save-master",input.vehicle(), - new DispatchCommands.Driver(ResourceMode.MASTER,71L,null,null,new DispatchContracts.DriverEvidence(null,null,List.of(91L),input.driver().evidence().expectedTaskEndAt(),List.of(303L))), + new DispatchCommands.Driver(ResourceMode.MASTER,71L,null,null,new DispatchContracts.DriverEvidence(null,null,supplement?List.of(91L):List.of(),input.driver().evidence().expectedTaskEndAt(),List.of())), input.plannedDepartureAt(),null,null,null); var saved=f.tx.execute(s->f.service.update(f.actor,created.dispatchNo(),update)); f.prepareAppointmentNumbers(created.dispatchNo()); var proof=f.service.detail(f.actor,created.dispatchNo()).vehicleInfo().driver().evidence(); - assertThat(proof.fileIds()).containsExactly(81L,91L);assertThat(proof.masterLicenseFileId()).isEqualTo(81L); - assertThat(f.service.evidenceMetadata(f.actor,created.dispatchNo(),DispatchEvidenceGateway.Kind.DRIVER,91L).contentType()).isEqualTo("image/png"); + assertThat(proof.fileIds()).containsExactlyElementsOf(supplement?List.of(81L,91L):List.of(81L));assertThat(proof.masterLicenseFileId()).isEqualTo(81L); + assertThat(proof.photoFileIds()).containsExactly(303L); + if(supplement)assertThat(f.service.evidenceMetadata(f.actor,created.dispatchNo(),DispatchEvidenceGateway.Kind.DRIVER,91L).contentType()).isEqualTo("image/png"); assertThat(f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(saved.recordVersion(),"submit-master",null))).recordVersion()).isGreaterThan(saved.recordVersion()); verify(drivers,never()).download(71L,91L); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchInboundReceiptPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchInboundReceiptPersistenceTest.java index 412c304be..f64d8e8b5 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchInboundReceiptPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchInboundReceiptPersistenceTest.java @@ -24,6 +24,74 @@ import static org.mockito.Mockito.*; /** 使用真实排车、预约绑定和MyBatis验证收货交接,再连续整托出货;库存外部命令沿原夹具隔离。 */ class DispatchInboundReceiptPersistenceTest { + /** 两托原预排21箱,短少后20+0:空托、预约和占用一起移除,20箱可继续整托拣货。 */ + @Test void shortageRemovesEmptyPreplannedPalletAndKeepsTwentyPickable() throws Exception { + var f=new Fixture(); + f.p.f.db.update("UPDATE ll_dispatch_item SET package_count=11 WHERE plan_pallet_id=2"); + f.p.f.db.update("UPDATE ll_dispatch_order SET package_count=21"); + f.p.f.db.update("UPDATE ll_dispatch_appointment_allocation SET package_count=11 WHERE plan_pallet_id=2"); + f.p.f.db.update("UPDATE ll_dispatch_pallet_lock SET customer_id='101' WHERE plan_pallet_id=2"); + f.p.f.candidates.get(1L).packageCount(20); + f.p.f.candidates.get(2L).packageCount(0); + doReturn(true).when(f.p.sources).confirmedEmptyPlan(eq("101"),eq(2L),anyLong(),eq("9")); + f.p.f.db.update("UPDATE ll_dispatch_order SET review_basis_hash=?",(String)ReflectionTestUtils.invokeMethod(f.p.f.service,"basis",f.p.f.repository.find(f.p.number))); + f.confirm(20,0); + assertThat(f.quantities()).containsExactly(20); + assertThat(f.allocations()).containsExactly(20); + var row=f.p.f.repository.find(f.p.number); + assertThat(row.getPackageCount()).isEqualTo(20);assertThat(row.getPalletCount()).isEqualTo(1);assertThat(row.getItemCount()).isEqualTo(1); + assertThat(f.p.f.db.queryForObject("SELECT status FROM ll_dispatch_pallet_lock WHERE plan_pallet_id=2",String.class)).isEqualTo("RELEASED"); + assertThat(f.p.f.db.queryForObject("SELECT status FROM ll_dispatch_physical_pallet_lock WHERE warehouse_pallet_id=502",String.class)).isEqualTo("RELEASED"); + assertThat(f.p.f.db.queryForObject("SELECT status FROM ll_dispatch_physical_pallet_lock WHERE warehouse_pallet_id=501",String.class)).isEqualTo("ACTIVE"); + var version=f.version();f.confirm(20,0);assertThat(f.version()).isEqualTo(version); + var picked=f.p.f.tx.execute(s->f.p.service.pick(f.p.actor,f.p.number,f.p.item,new DispatchCommands.Pick(version,"shortage-twenty",11L,20,0,null,null))); + assertThat(((DispatchResults.Picked)picked.result()).pickingReconciled()).isTrue(); + verify(f.p.inventory).ship(eq("101"),eq("picker"),any()); + } + + /** 原占用身份不匹配时,库存和预约也必须随空托移除失败一起回滚。 */ + @Test void failedEmptyPalletReleaseRollsBackStockAndDispatch() throws Exception { + var f=new Fixture();f.p.f.candidates.get(1L).packageCount(20); + doReturn(true).when(f.p.sources).confirmedEmptyPlan(eq("101"),eq(2L),anyLong(),eq("9")); + f.p.f.db.update("UPDATE ll_dispatch_pallet_lock SET status='RELEASED',active_key=NULL WHERE plan_pallet_id=2"); + assertThatThrownBy(()->f.confirm(20,0)).hasMessageContaining("逻辑占用"); + assertThat(f.quantities()).containsExactly(10,10);assertThat(f.allocations()).containsExactly(10,10); + assertThat(f.p.f.db.queryForObject("SELECT SUM(available_packages) FROM ll_inventory_position",Long.class)).isZero(); + } + + /** 零候选但没有明确零实收证明,不能作为空托删除。 */ + @Test void missingEmptyReceiptEvidenceStillRejects() throws Exception { + var f=new Fixture();f.p.f.candidates.get(1L).packageCount(20);f.p.f.candidates.get(2L).packageCount(0); + assertThatThrownBy(()->f.confirm(20,0)).hasMessageContaining("实收托盘"); + assertThat(f.quantities()).containsExactly(10,10);assertThat(f.allocations()).containsExactly(10,10); + } + + /** 本车只预排了未收到的托盘时,收货后保留空车壳并清除货物与旧审核。 */ + @Test void dispatchWithOnlyEmptyPreplannedCargoReturnsToDraft() throws Exception { + var f=new Fixture(); + f.p.f.db.update("UPDATE ll_dispatch_pallet_lock SET customer_id='101' WHERE plan_pallet_id=2"); + doReturn(true).when(f.p.sources).confirmedEmptyPlan(eq("101"),anyLong(),anyLong(),eq("9")); + f.confirm(0,0); + assertThat(f.quantities()).isEmpty();assertThat(f.allocations()).isEmpty(); + var row=f.p.f.repository.find(f.p.number); + assertThat(row.getPackageCount()).isZero();assertThat(row.getPalletCount()).isZero();assertThat(row.getItemCount()).isZero(); + assertThat(row.getRoute()).isEmpty();assertThat(row.getStage()).isEqualTo("BEFORE_LOADING");assertThat(row.getReviewState()).isEqualTo("DRAFT"); + assertThat(row.getReviewBasisHash()).isNull(); + } + + /** 空托释放后若其他托盘预约交接失败,释放和库存仍须全部回滚。 */ + @Test void appointmentFailureRestoresReleasedEmptyClaim() throws Exception { + var f=new Fixture();f.p.f.candidates.get(1L).packageCount(20); + f.p.f.db.update("UPDATE ll_dispatch_pallet_lock SET customer_id='101' WHERE plan_pallet_id=2"); + doReturn(true).when(f.p.sources).confirmedEmptyPlan(eq("101"),eq(2L),anyLong(),eq("9")); + f.p.f.db.update("UPDATE ll_dispatch_appointment_allocation SET source_key='ORDER:wrong' WHERE plan_pallet_id=1"); + assertThatThrownBy(()->f.confirm(20,0)).hasMessageContaining("实收货物来源"); + assertThat(f.quantities()).containsExactly(10,10); + assertThat(f.p.f.db.queryForObject("SELECT status FROM ll_dispatch_pallet_lock WHERE plan_pallet_id=2",String.class)).isEqualTo("ACTIVE"); + assertThat(f.p.f.db.queryForObject("SELECT status FROM ll_dispatch_physical_pallet_lock WHERE warehouse_pallet_id=502",String.class)).isEqualTo("ACTIVE"); + assertThat(f.p.f.db.queryForObject("SELECT SUM(available_packages) FROM ll_inventory_position",Long.class)).isZero(); + } + /** 直接读取9+11实收及对应货量,不读10+10计划估算,也不依赖可用库存或出货冻结状态。 */ @Test void confirmedSourceMapperReadsReceiptCountsWithoutInventoryAvailability() throws Exception { var ds=new org.springframework.jdbc.datasource.DriverManagerDataSource("jdbc:h2:mem:receipt-source-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa",""); @@ -53,6 +121,8 @@ class DispatchInboundReceiptPersistenceTest { db.update("DELETE FROM ll_order_detail WHERE customer_id='101'"); assertThat(mapper.confirmedPlan("101",1).get(0).grossWeightKg()).isNull(); assertThat(mapper.confirmedPlan("other",1)).isEmpty(); + db.update("UPDATE ll_inbound_plan SET status='PARTIAL_INBOUND'"); + assertThat(mapper.confirmedPlan("101",1).get(0).packageCount()).isEqualTo(9); db.update("UPDATE ll_inbound_plan SET status='RECEIVING'"); assertThat(mapper.confirmedPlan("101",1)).isEmpty(); } @@ -199,6 +269,7 @@ class DispatchInboundReceiptPersistenceTest { doReturn(List.of(p.f.candidates.get(2L))).when(p.sources).selectedPlan("101",2L); doReturn(true).when(p.sources).receivedPlanCompleted("101",2L); for(long id=1;id<=2;id++) { + doReturn(false).when(p.sources).confirmedEmptyPlan(eq("101"),eq(id),anyLong(),eq("9")); doReturn(List.of(9001L)).when(p.sources).sourceOrders("101",null,id); doReturn(List.of(9001L)).when(p.sources).sourceOrders("101",500+id,id); doReturn(id).when(p.sources).lockPlan("101",id); @@ -237,10 +308,15 @@ class DispatchInboundReceiptPersistenceTest { /** 模拟收货库存落账后,在同一事务交接原排车和预约;不执行任何出库命令。 */ void confirm() { + confirm(9,11); + } + + /** 按指定真实数量验证短少和空托交接的同事务持久化。 */ + void confirm(int first,int second) { p.f.tx.executeWithoutResult(s->{ receipt.lock(List.of(1L,2L)); - p.f.db.update("UPDATE ll_inbound_plan_pallet_allocation SET counted_package_count=CASE WHEN pallet_id=1 THEN 9 ELSE 11 END"); - p.f.db.update("UPDATE ll_inventory_position SET available_packages=CASE WHEN id=11 THEN 9 ELSE 11 END"); + p.f.db.update("UPDATE ll_inbound_plan_pallet_allocation SET counted_package_count=CASE WHEN pallet_id=1 THEN ? ELSE ? END",first,second); + p.f.db.update("UPDATE ll_inventory_position SET available_packages=CASE WHEN id=11 THEN ? ELSE ? END",first,second); receipt.complete(List.of(1L,2L),"receiver",LocalDateTime.now(ZoneOffset.UTC)); }); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingDeparturePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingDeparturePersistenceTest.java index b957971ab..51e0a1ce8 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingDeparturePersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingDeparturePersistenceTest.java @@ -183,6 +183,33 @@ class DispatchLoadingDeparturePersistenceTest { verify(f.sourceWorkflow,times(1)).departWholeSources(any(),any(),any(),any()); } + /** 同类可保存五张但不能充当五类;追加、替换、版本与幂等共同保护原照片。 */ + @Test void multiplePhotosPerCategoryPreserveReferencesAndDepartureGate() throws Exception { + authorize();var f=new Fixture(); + for(int i=0;i<5;i++) { + var request=new BindPhoto(Integer.toString(i),"multi-photo-"+i,Long.toString(2000+i)); + assertThat(f.f.tx.execute(s->f.documents.bind(f.f.actor,f.number,Slot.LOADING_SCENE,request)).complete()).isFalse(); + } + assertThat(f.documents.get(f.f.actor,f.number).photos()).hasSize(5); + assertThatThrownBy(()->f.f.tx.execute(s->f.documents.bind(f.f.actor,f.number,Slot.LOADING_SCENE,new BindPhoto("5","too-many-photo","3000")))).hasMessageContaining("最多5张"); + assertThat(f.documents.get(f.f.actor,f.number).recordVersion()).isEqualTo("5"); + var replace=new BindPhoto("5","replace-photo","3000","2002"); + var result=f.f.tx.execute(s->f.documents.bind(f.f.actor,f.number,Slot.LOADING_SCENE,replace)); + assertThat(result.photos()).extracting(p->p.fileId()).containsExactly("2000","2001","3000","2003","2004"); + var replay=f.f.tx.execute(s->f.documents.bind(f.f.actor,f.number,Slot.LOADING_SCENE,replace)); + assertThat(replay).isEqualTo(result); + assertThatThrownBy(()->f.f.tx.execute(s->f.documents.bind(f.f.actor,f.number,Slot.LOADING_LIST,new BindPhoto("6","wrong-photo-slot","4000","2000")))).hasMessageContaining("待替换照片已变化"); + for(int i=1;i<5;i++) { + var slot=DispatchLoadingDocumentService.SLOTS.get(i);var request=new BindPhoto(Integer.toString(5+i),"other-category-"+i,Long.toString(4000+i)); + f.f.tx.execute(s->f.documents.bind(f.f.actor,f.number,slot,request)); + } + assertThat(f.documents.get(f.f.actor,f.number).photos()).hasSize(9); + assertThat(f.documents.get(f.f.actor,f.number).complete()).isTrue(); + verify(f.delivery,never()).prepareForDeparture(any(),any(),anyString()); + assertThat(f.f.tx.execute(s->f.service.complete(f.f.actor,f.number,f.request("multi-ready"))).stage()).isEqualTo(Stage.DELIVERING); + verify(f.delivery,times(1)).prepareForDeparture(any(),any(),anyString()); + } + /** 配置资料与装车权限及PDA既有角色,不授予人工派送、发车权限。 */ private static void authorize(){SecurityContextHolder.getContext().setAuthentication(new TestingAuthenticationToken("operator","", "platform:overseas-warehouse:dispatch:list","platform:overseas-warehouse:dispatch:edit","platform:overseas-warehouse:dispatch:loading-complete","lastleg:pda:operate","ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR"));} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchMasterPhotoServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchMasterPhotoServiceTest.java new file mode 100644 index 000000000..11822f660 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchMasterPhotoServiceTest.java @@ -0,0 +1,52 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway; +import com.baiyue.lastleg.dispatch.port.DispatchContracts.*; +import com.baiyue.lastleg.dispatch.port.DispatchTypes.ResourceMode; +import com.baiyue.lastleg.fleet.photo.FleetResourcePhotoModels.*; +import com.baiyue.lastleg.fleet.photo.FleetResourcePhotoService; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.web.multipart.MultipartFile; +import java.util.List; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** 自动带入不复用跨排车文件ID,也不覆盖已有当趟照片。 */ +class DispatchMasterPhotoServiceTest { + final FleetResourcePhotoService photos=mock(FleetResourcePhotoService.class); + final DispatchEvidenceGateway evidence=mock(DispatchEvidenceGateway.class); + final DispatchMasterPhotoService service=new DispatchMasterPhotoService(photos,evidence); + @Test void copiesBothProfilesAndKeepsFrozenPhotosWithoutReadingChangedMaster() throws Exception { + var photo=new Photo("81","profile.png","image/png",3); + when(photos.current(any(),anyLong())).thenReturn(new Detail(1,photo)); + when(photos.download(any(),anyLong(),eq(81L))).thenReturn(new Download(photo,new byte[]{1,2,3})); + when(evidence.upload(eq(9L),eq(DispatchEvidenceGateway.Kind.VEHICLE),eq("op"),any())).thenReturn(new DispatchEvidenceGateway.File(101L,"vehicle.png","image/png",3)); + when(evidence.upload(eq(9L),eq(DispatchEvidenceGateway.Kind.DRIVER_PHOTO),eq("op"),any())).thenReturn(new DispatchEvidenceGateway.File(102L,"driver.png","image/png",3)); + var resources=new Resources(new VehicleSnapshot(ResourceMode.MASTER,1L,"TEST",null,"IN_SERVICE",null,List.of()), + new DriverSnapshot(ResourceMode.MASTER,2L,"司机","电话",new DriverEvidence(null,null,List.of(8L),null,List.of(),8L)),null); + var saved=service.populate(9,"op",resources); + assertThat(saved.vehicle().evidenceFileIds()).containsExactly(101L); + assertThat(saved.driver().evidence().photoFileIds()).containsExactly(102L); + assertThat(saved.driver().evidence().masterLicenseFileId()).isEqualTo(8L); + var file=ArgumentCaptor.forClass(MultipartFile.class); + verify(evidence).upload(eq(9L),eq(DispatchEvidenceGateway.Kind.VEHICLE),eq("op"),file.capture()); + assertThat(file.getValue().getBytes()).containsExactly(1,2,3); + clearInvocations(photos,evidence); + assertThat(service.populate(9,"op",saved)).isEqualTo(saved); + verifyNoInteractions(photos,evidence); + } + @Test void missingProfileRemainsMissingAndCopyFailureIsNotHidden() { + when(photos.current(Kind.VEHICLE,1)).thenReturn(new Detail(0,null)); + assertThat(service.populate(9,"op",Kind.VEHICLE,1,List.of())).isEmpty(); + verifyNoInteractions(evidence); + when(photos.current(Kind.VEHICLE,1)).thenThrow(new IllegalStateException("File unavailable")); + assertThatThrownBy(() -> service.populate(9,"op",Kind.VEHICLE,1,List.of())).hasMessage("File unavailable"); + } + @Test void externalResourcesNeverReadOrCreateMasterProfiles() { + var resources=new Resources(new VehicleSnapshot(ResourceMode.EXTERNAL,null,"TEST",null,null,null,List.of()), + new DriverSnapshot(ResourceMode.EXTERNAL,null,"司机","电话",new DriverEvidence(null,null,List.of(),null,List.of())),null); + assertThat(service.populate(9,"op",resources)).isEqualTo(resources); + verifyNoInteractions(photos,evidence); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchMergedRelabelPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchMergedRelabelPersistenceTest.java index 326a23dbc..c8efa81e1 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchMergedRelabelPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchMergedRelabelPersistenceTest.java @@ -215,7 +215,7 @@ class DispatchMergedRelabelPersistenceTest { assertThat(f.count("ll_dispatch_order")).isZero();assertThat(f.count("ll_dispatch_pallet_lock")).isZero(); } - /** 只隔离测量和车队外部目录;来源提供者、候选、库存与所有排车Mapper均为实际实现。 */ + /** 隔离测量、车队及仓租门禁;来源提供者、候选、库存与所有排车Mapper均为实际实现。 */ private static final class Merged { final DispatchAggregatePersistenceTest.Fixture f=new DispatchAggregatePersistenceTest.Fixture(); final DispatchService rules=new DispatchService();final DispatchPickingService picking;final DispatchPdaQueryService pda; @@ -261,6 +261,7 @@ class DispatchMergedRelabelPersistenceTest { ReflectionTestUtils.setField(attribution,"attributions",f.session.getMapper(DispatchPdaPickOrderMapper.class));ReflectionTestUtils.setField(attribution,"orders",f.session.getMapper(LastlegOrderMapper.class));ReflectionTestUtils.setField(attribution,"relabelSources",origins); picking=new DispatchPickingService(f.service,f.repository,sourceRepository,new DispatchPickingRepository(f.session.getMapper(DispatchPdaTaskMapper.class),f.session.getMapper(DispatchPdaPickMapper.class),f.session.getMapper(InventoryPositionMapper.class),f.session.getMapper(DispatchItemMapper.class)),attribution,inventory,mock(ReturnRelabelDispatchStock.class),new DispatchCommandLedger(new DispatchCommandRepository(f.session.getMapper(DispatchCommandMapper.class)),new DispatchCommandCodec(f.json)),new DispatchCommandHasher(f.json),f.json); ReflectionTestUtils.setField(picking,"sourceRules",rules);ReflectionTestUtils.setField(picking,"relabelSources",snapshots); + ReflectionTestUtils.setField(picking,"storageRent",mock(com.baiyue.lastleg.storagerent.service.StorageRentService.class)); pda=new DispatchPdaQueryService(new DispatchPdaReadRepository(f.session.getMapper(DispatchWholePdaReadMapper.class)),f.service);ReflectionTestUtils.setField(pda,"sourceOrigins",origins); balance(601,"101",5);position(101,601,"101",crossCustomer?2:5); if(crossCustomer){f.db.update("UPDATE ll_inventory_balance SET hold_packages=2 WHERE id=601");balance(602,"102",3);position(102,602,"102",3);} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPlanPdaReadPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPlanPdaReadPersistenceTest.java index d116164cb..0e44c61cd 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPlanPdaReadPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPlanPdaReadPersistenceTest.java @@ -26,7 +26,7 @@ class DispatchPlanPdaReadPersistenceTest { f.f.db.update("INSERT INTO ll_inbound_plan_pallet VALUES(2,1,'101')"); var mapper=f.f.session.getMapper(com.baiyue.lastleg.dispatch.mapper.DispatchSourceMapper.class); assertThat(mapper.receivedPlanCompleted("101",2L)).isZero(); - for(var status:List.of("COMPLETED","PENDING_RECONCILIATION")) { + for(var status:List.of("COMPLETED","PARTIAL_INBOUND","PENDING_RECONCILIATION")) { f.f.db.update("UPDATE ll_inbound_plan SET status=?",status); assertThat(mapper.receivedPlanCompleted("101",2L)).isOne(); assertThat(mapper.receivedPlanCompleted("other",2L)).isZero(); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessPersistenceTest.java index 868db7081..cbcda59df 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessPersistenceTest.java @@ -148,13 +148,13 @@ class OwnedBusinessPersistenceTest { var original=new BindPhoto("0","bind-original","9"); var bound=service.bind(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",PhotoSlot.LOADING_SCENE,original,first); assertEquals("1",bound.row().recordVersion());assertNotNull(bound.photos().get(0).savedAt()); - assertEquals("保存装车现场照片",repository.revision(1,1).getReason()); + assertEquals("保存司机驾驶证照片",repository.revision(1,1).getReason()); assertEquals("1",service.bind(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",PhotoSlot.LOADING_SCENE,original,first).row().recordVersion()); var replacement=new Photo(PhotoSlot.LOADING_SCENE,"LOADING","10","TEST-two.png","image/png","9","2026-10-20T07:31:00",null); - service.bind(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",PhotoSlot.LOADING_SCENE,new BindPhoto("1","bind-replacement","10"),replacement); + service.bind(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",PhotoSlot.LOADING_SCENE,new BindPhoto("1","bind-replacement","10","9"),replacement); assertEquals("10",projection.photos(repository.record(1)).get(0).fileId()); assertEquals("9",codec.read(repository.revision(1,1).getSnapshotJson(),Snapshot.class).photos().get(0).fileId()); - assertEquals("保存装车现场照片",repository.revision(1,2).getReason()); + assertEquals("保存司机驾驶证照片",repository.revision(1,2).getReason()); var file=mock(com.baiyue.lastleg.fleet.ownedbusiness.gateway.OwnedBusinessFileGateway.class); when(file.content(1,PhotoSlot.LOADING_SCENE,9)).thenReturn(new byte[]{1,2}); var photoService=new OwnedBusinessPhotoService(queries,service,repository,commands,projection,codec,file); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessScopePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessScopePersistenceTest.java index 3e60ea53a..c4206011c 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessScopePersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessScopePersistenceTest.java @@ -247,7 +247,7 @@ class OwnedBusinessScopePersistenceTest { } assertEquals(5,outsourced.detail(actor(),"DS-OUT-2").row().photoSummary().loadingCount()); var replacement=outsourced.upload(actor(),"DS-OUT-2",OutsourcedBusinessModels.PhotoSlot.LOADING_SCENE,"out-replacement-upload",new MockMultipartFile("file","TEST.png","image/png",png)); - outsourced.bind(actor(),"DS-OUT-2",OutsourcedBusinessModels.PhotoSlot.LOADING_SCENE,new OutsourcedBusinessModels.BindPhoto(version,"out-replacement-bind",replacement.fileId())); + outsourced.bind(actor(),"DS-OUT-2",OutsourcedBusinessModels.PhotoSlot.LOADING_SCENE,new OutsourcedBusinessModels.BindPhoto(version,"out-replacement-bind",replacement.fileId(),firstFile)); final long oldId=Long.parseLong(firstFile); assertThrows(BizException.class,()->outsourced.content(actor(),"DS-OUT-2",OutsourcedBusinessModels.PhotoSlot.LOADING_SCENE,oldId,null)); assertArrayEquals(png,outsourced.content(actor(),"DS-OUT-2",OutsourcedBusinessModels.PhotoSlot.LOADING_SCENE,oldId,1L).content()); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessPhotoBindingsTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessPhotoBindingsTest.java new file mode 100644 index 000000000..6be65cdc9 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessPhotoBindingsTest.java @@ -0,0 +1,25 @@ +package com.baiyue.lastleg.fleet.ownedbusiness.service; + +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*; +import org.junit.jupiter.api.Test; +import java.util.List; +import static org.assertj.core.api.Assertions.*; + +class OwnedBusinessPhotoBindingsTest { + /** 保存油耗时不得压缩PDA同类多张装车照片。 */ + @Test void fuelReplacementPreservesEveryLoadingPhoto() { + var existing=List.of(photo(PhotoSlot.LOADING_SCENE,"1"),photo(PhotoSlot.LOADING_SCENE,"2"),photo(PhotoSlot.LOADING_LIST,"3"),photo(PhotoSlot.FUEL_START,"4")); + var saved=OwnedBusinessPhotoBindings.bind(existing,photo(PhotoSlot.FUEL_START,"5"),"4"); + assertThat(saved).extracting(Photo::fileId).containsExactly("1","2","3","5"); + assertThat(OwnedBusinessPhotoBindings.bind(saved,photo(PhotoSlot.FUEL_START,"6"),null)).extracting(Photo::fileId).containsExactly("1","2","3","6"); + } + /** 同一文件不能重复绑定或借替换覆盖另一个类别。 */ + @Test void rejectsDuplicateAndStaleReplacement() { + var existing=List.of(photo(PhotoSlot.LOADING_SCENE,"1"),photo(PhotoSlot.LOADING_SCENE,"2")); + assertThatThrownBy(()->OwnedBusinessPhotoBindings.bind(existing,photo(PhotoSlot.LOADING_SCENE,"1"),null)).hasMessageContaining("重复"); + assertThatThrownBy(()->OwnedBusinessPhotoBindings.bind(existing,photo(PhotoSlot.LOADING_SCENE,"2"),"1")).hasMessageContaining("重复"); + assertThatThrownBy(()->OwnedBusinessPhotoBindings.bind(existing,photo(PhotoSlot.LOADING_LIST,"3"),"1")).hasMessageContaining("待替换"); + } + /** 使用不含真实用户资料的固定文件元数据。 */ + private Photo photo(PhotoSlot slot,String id) { return new Photo(slot,slot.category(),id,"test.png","image/png","5","2026-10-26T00:00:00Z","2026-10-26T00:00:00Z"); } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/photo/FleetResourcePhotoPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/photo/FleetResourcePhotoPersistenceTest.java new file mode 100644 index 000000000..ff90849ee --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/photo/FleetResourcePhotoPersistenceTest.java @@ -0,0 +1,44 @@ +package com.baiyue.lastleg.fleet.photo; + +import org.apache.ibatis.mapping.Environment; +import org.apache.ibatis.session.Configuration; +import org.apache.ibatis.session.SqlSessionFactoryBuilder; +import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory; +import org.h2.jdbcx.JdbcDataSource; +import org.junit.jupiter.api.Test; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.LocalDateTime; +import java.util.UUID; +import static com.baiyue.lastleg.fleet.photo.FleetResourcePhotoModels.*; +import static org.assertj.core.api.Assertions.*; + +/** 使用真实建表定义与Mapper验证类型隔离、最新版本及不可变历史。 */ +class FleetResourcePhotoPersistenceTest { + /** 仅装载照片表定义,避免执行同文件后续的其他业务迁移。 */ + @Test void preservesHistoryAndRejectsTwoWritesToSameVersion() throws Exception { + var source=new JdbcDataSource();source.setURL("jdbc:h2:mem:photos_"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1"); + var schema=Files.readString(Path.of("src/main/resources/db/lastleg.sql")); + int start=schema.indexOf("CREATE TABLE IF NOT EXISTS ll_fleet_resource_photo ("); + assertThat(start).isGreaterThanOrEqualTo(0); + int end=schema.indexOf(';',start); + assertThat(end).isGreaterThan(start); + var ddl=schema.substring(start,end+1); + try(var connection=source.getConnection();var statement=connection.createStatement()) { statement.execute(ddl); } + var config=new Configuration(new Environment("photos",new JdbcTransactionFactory(),source)); + config.setMapUnderscoreToCamelCase(true);config.addMapper(FleetResourcePhotoMapper.class); + try(var session=new SqlSessionFactoryBuilder().build(config).openSession(true)) { + var repository=new FleetResourcePhotoRepository(session.getMapper(FleetResourcePhotoMapper.class)); + assertThat(repository.current(Kind.DRIVER,7)).isNull(); + var at=LocalDateTime.of(2026,10,26,0,0); + repository.append(new Stored("DRIVER",7,1,81,"first.png","image/png",3,"op",at)); + repository.append(new Stored("DRIVER",7,2,82,"second.png","image/png",4,"op",at)); + assertThat(repository.current(Kind.DRIVER,7).fileId()).isEqualTo(82); + assertThat(repository.file(Kind.DRIVER,7,81).fileName()).isEqualTo("first.png"); + assertThat(repository.file(Kind.VEHICLE,7,81)).isNull(); + assertThat(repository.file(Kind.DRIVER,8,81)).isNull(); + assertThatThrownBy(() -> repository.append(new Stored("DRIVER",7,2,83,"conflict.png","image/png",3,"op",at))).isInstanceOf(RuntimeException.class); + assertThat(repository.current(Kind.DRIVER,7).fileId()).isEqualTo(82); + } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/photo/FleetResourcePhotoServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/photo/FleetResourcePhotoServiceTest.java new file mode 100644 index 000000000..4289c8acd --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/photo/FleetResourcePhotoServiceTest.java @@ -0,0 +1,39 @@ +package com.baiyue.lastleg.fleet.photo; + +import com.baiyue.lastleg.fleet.driver.DriverService; +import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository; +import org.junit.jupiter.api.Test; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.mock.web.MockMultipartFile; +import java.time.LocalDateTime; +import static com.baiyue.lastleg.fleet.photo.FleetResourcePhotoModels.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class FleetResourcePhotoServiceTest { + final FleetResourcePhotoRepository repository=mock(FleetResourcePhotoRepository.class); + final FleetResourcePhotoGateway files=mock(FleetResourcePhotoGateway.class); + final DriverService drivers=mock(DriverService.class); + final FleetResourcePhotoService service=new FleetResourcePhotoService(repository,files,mock(VehicleRepository.class),drivers); + final Photo photo=new Photo("81","driver.png","image/png",3); + final MockMultipartFile upload=new MockMultipartFile("file","driver.png","image/png",new byte[]{1,2,3}); + @Test void staleVersionCannotUploadAndConcurrentReplacementCannotOverwrite() { + when(repository.current(Kind.DRIVER,2)).thenReturn(new Stored("DRIVER",2,1,81,"driver.png","image/png",3,"op",LocalDateTime.now())); + assertThatThrownBy(() -> service.upload(Kind.DRIVER,2,0,"op",upload)).hasMessageContaining("已变化"); + verifyNoInteractions(files); + when(files.upload(Kind.DRIVER,2,"op",upload)).thenReturn(photo); + doThrow(new DuplicateKeyException("version")).when(repository).append(any()); + assertThatThrownBy(() -> service.upload(Kind.DRIVER,2,1,"op",upload)).hasMessageContaining("已变化"); + } + @Test void fileFromAnotherProfileIsRejectedBeforeFileDownload() { + assertThatThrownBy(() -> service.download(Kind.DRIVER,2,81)).hasMessageContaining("不存在"); + verifyNoInteractions(files); + } + @Test void firstUploadCreatesIndependentVersionAndDoesNotChangeDriverReview() { + when(files.upload(Kind.DRIVER,2,"op",upload)).thenReturn(photo); + assertThat(service.upload(Kind.DRIVER,2,0,"op",upload)).isEqualTo(new Detail(1,photo)); + verify(drivers).detail(2,null); + verifyNoMoreInteractions(drivers); + verify(repository).append(argThat(row -> row.recordVersion()==1 && row.resourceId()==2 && row.fileId()==81)); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/photo/HttpFleetResourcePhotoGatewayTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/photo/HttpFleetResourcePhotoGatewayTest.java new file mode 100644 index 000000000..93df2aeda --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/photo/HttpFleetResourcePhotoGatewayTest.java @@ -0,0 +1,21 @@ +package com.baiyue.lastleg.fleet.photo; + +import org.junit.jupiter.api.Test; +import static com.baiyue.lastleg.fleet.photo.FleetResourcePhotoModels.Kind.*; +import static org.assertj.core.api.Assertions.*; + +class HttpFleetResourcePhotoGatewayTest { + @Test void validatesResourceKindOwnerIdentityAndImageType() { + var data=new HttpFleetResourcePhotoGateway.Metadata(81L,0L,"FLEET_DRIVER_PHOTO","7","photo.png","image/png",3L); + var envelope=new HttpFleetResourcePhotoGateway.Envelope("200",data); + assertThat(HttpFleetResourcePhotoGateway.verified(envelope,DRIVER,7,81L).fileId()).isEqualTo("81"); + assertThatThrownBy(() -> HttpFleetResourcePhotoGateway.verified(envelope,VEHICLE,7,81L)).hasMessageContaining("不属于"); + assertThatThrownBy(() -> HttpFleetResourcePhotoGateway.verified(envelope,DRIVER,8,81L)).hasMessageContaining("不属于"); + assertThatThrownBy(() -> HttpFleetResourcePhotoGateway.verified(envelope,DRIVER,7,82L)).hasMessageContaining("不属于"); + for(var invalid : new HttpFleetResourcePhotoGateway.Metadata[]{ + new HttpFleetResourcePhotoGateway.Metadata(81L,1L,"FLEET_DRIVER_PHOTO","7","photo.png","image/png",3L), + new HttpFleetResourcePhotoGateway.Metadata(81L,0L,"FLEET_DRIVER_PHOTO","7","photo.pdf","application/pdf",3L)}) { + assertThatThrownBy(() -> HttpFleetResourcePhotoGateway.verified(new HttpFleetResourcePhotoGateway.Envelope("200",invalid),DRIVER,7,81L)).hasMessageContaining("不属于"); + } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/photo/PlatformFleetResourcePhotoControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/photo/PlatformFleetResourcePhotoControllerTest.java new file mode 100644 index 000000000..4b9c58470 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/photo/PlatformFleetResourcePhotoControllerTest.java @@ -0,0 +1,36 @@ +package com.baiyue.lastleg.fleet.photo; + +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.*; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import java.util.List; +import static com.baiyue.lastleg.fleet.photo.FleetResourcePhotoModels.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** 使用真实方法鉴权代理验证资源类型不能借用另一类档案权限。 */ +class PlatformFleetResourcePhotoControllerTest { + @Configuration @EnableMethodSecurity + static class Config { + @Bean FleetResourcePhotoService service() { return mock(FleetResourcePhotoService.class); } + @Bean PlatformFleetResourcePhotoController controller(FleetResourcePhotoService service) { return new PlatformFleetResourcePhotoController(service); } + } + @Test void listPermissionOnlyReadsItsOwnResourceKindAndCannotUpload() { + try(var context=new AnnotationConfigApplicationContext(Config.class)) { + var service=context.getBean(FleetResourcePhotoService.class); + var controller=context.getBean(PlatformFleetResourcePhotoController.class); + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("test","",List.of(new SimpleGrantedAuthority("platform:overseas-master-data:vehicle:list")))); + when(service.current(Kind.VEHICLE,7)).thenReturn(new Detail(0,null)); + controller.current(Kind.VEHICLE,7); + verify(service).current(Kind.VEHICLE,7); + assertThatThrownBy(() -> controller.current(Kind.DRIVER,7)).isInstanceOf(AccessDeniedException.class); + assertThatThrownBy(() -> controller.upload(Kind.VEHICLE,7,0,null,null)).isInstanceOf(AccessDeniedException.class); + assertThatThrownBy(() -> controller.upload(Kind.DRIVER,7,0,null,null)).isInstanceOf(AccessDeniedException.class); + verifyNoMoreInteractions(service); + } finally { SecurityContextHolder.clearContext(); } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanControllerTest.java index c34060f95..3912dbdfd 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanControllerTest.java @@ -31,6 +31,7 @@ class InboundPlanControllerTest { @Mock InboundPlanService service; @Mock InboundFbaAppointmentService appointmentService; @Mock ContainerInboundPlanConversionCoordinator containerConversion; + @Mock com.baiyue.lastleg.inboundplan.pda.service.InboundPdaWorkflowService inboundWorkflow; private InboundPlanController controller; @BeforeEach @@ -39,6 +40,18 @@ class InboundPlanControllerTest { ReflectionTestUtils.setField(controller, "service", service); ReflectionTestUtils.setField(controller, "appointmentService", appointmentService); ReflectionTestUtils.setField(controller, "containerConversion", containerConversion); + ReflectionTestUtils.setField(controller, "inboundWorkflow", inboundWorkflow); + } + + /** 短少确认只允许平台指定具体客户,并保留真实操作人。 */ + @Test void platformConfirmsShortageWithExplicitCustomer() { + var request = new com.baiyue.lastleg.inboundplan.dto.ConfirmInboundShortageRequest("100",4,21,20,"实收短少"); + controller.confirmShortage(platform(), "IP-1", "key", request); + verify(inboundWorkflow).confirmShortage("100", "IP-1", "7", "key", request); + var merchant = new AuthenticatedUser(8L, 100L, 100L, 9L, "customer", "MERCHANT", "WEB", Set.of()); + assertThatThrownBy(() -> controller.confirmShortage(merchant, "IP-1", "key", request)).isInstanceOf(ResponseStatusException.class); + var noCustomer = new com.baiyue.lastleg.inboundplan.dto.ConfirmInboundShortageRequest("0",4,21,20,"实收短少"); + assertThatThrownBy(() -> controller.confirmShortage(platform(), "IP-1", "key", noCustomer)).isInstanceOf(ResponseStatusException.class); } @Test diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundShortageConfirmationTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundShortageConfirmationTest.java new file mode 100644 index 000000000..bc06cb1fa --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundShortageConfirmationTest.java @@ -0,0 +1,153 @@ +package com.baiyue.lastleg.inboundplan.pda.service; + +import com.baiyue.lastleg.inboundplan.dto.ConfirmInboundShortageRequest; +import com.baiyue.lastleg.inboundplan.entity.*; +import com.baiyue.lastleg.inboundplan.pda.entity.*; +import com.baiyue.lastleg.inboundplan.pda.gateway.InboundDispatchReceiptGateway; +import com.baiyue.lastleg.inboundplan.pda.repository.PdaWorkflowRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.*; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.server.ResponseStatusException; +import java.time.LocalDateTime; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class InboundShortageConfirmationTest { + @Mock PdaWorkflowRepository repository; + @Mock InboundStockReceiptService stock; + @Mock InboundDispatchReceiptGateway dispatch; + @Mock InboundUnloadCompletedOutboxService outbox; + InboundPdaWorkflowService service; + InboundPlan plan; + InboundPdaTask task; + InboundPlanLine line; + InboundPlanPalletAllocation allocation; + final LocalDateTime receivedAt = LocalDateTime.of(2026, 10, 20, 12, 0); + + /** 准备已结束现场收货、应收21实收20的冻结事实。 */ + @BeforeEach void setup() { + service = new InboundPdaWorkflowService(); + ReflectionTestUtils.setField(service, "repository", repository); + ReflectionTestUtils.setField(service, "stockReceipts", stock); + ReflectionTestUtils.setField(service, "dispatchReceipts", dispatch); + ReflectionTestUtils.setField(service, "unloadCompletedOutbox", outbox); + ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules()); + plan = new InboundPlan(); plan.setId(1L); plan.setPlanNo("IP-1"); plan.setCustomerId("100"); + plan.setStatus("PENDING_RECONCILIATION"); plan.setVersion(4); + task = new InboundPdaTask(); task.setId(2L); task.setPlanId(1L); task.setCustomerId("100"); + task.setStatus("PENDING_RECONCILIATION"); task.setVersion(2); task.setCompletedAt(receivedAt); + task.setUnloadPhotoFileIds("[10]"); task.setUnloadPhotoUploadedAt(receivedAt); + line = new InboundPlanLine(); line.setId(3L); line.setOrderId(9L); line.setFbaWarehouseCode("ONT8"); line.setRequiredPackageCount(21); + allocation = new InboundPlanPalletAllocation(); allocation.setId(4L); allocation.setLineId(3L); + allocation.setPalletId(5L); allocation.setPickedPackageCount(20); allocation.setCountedPackageCount(20); + var sku = new ReceivingSkuReceipt(); sku.setLineId(3L); sku.setPalletId(5L); sku.setSkuCode("SKU-1"); sku.setReceivedPackages(20); + when(repository.findPlan("100", "IP-1")).thenReturn(plan); + lenient().when(repository.lockPlan("100", "IP-1")).thenReturn(plan); + lenient().when(repository.lockTask("100", 1L)).thenReturn(task); + lenient().when(repository.lines(1L)).thenReturn(List.of(line)); + lenient().when(repository.allocations(1L)).thenReturn(List.of(allocation)); + lenient().when(repository.skuReceipts("100", 1L)).thenReturn(List.of(sku)); + lenient().when(repository.frozenSkus("100", 1L, 3L)).thenReturn(List.of( + new com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingSkuReceiptMapper.FrozenSku("SKU-1",21))); + lenient().when(repository.fbaGroupLineCount("100", 1L, 3L)).thenReturn(1L); + } + + /** 确认后保留原应收,按原收货时点入库并交接运输,重放不会重复记库存。 */ + @Test void stocksActualTwentyOnceAndPreservesShortageAndReceiptTime() { + var request = request(4, 20); + var result = service.confirmShortage("100", "IP-1", "admin", "key", request); + assertEquals("PARTIAL_INBOUND", result.status()); assertEquals(21, line.getRequiredPackageCount()); + assertEquals(receivedAt, task.getCompletedAt()); + var sequence = inOrder(dispatch, repository, stock); + sequence.verify(dispatch).lock(List.of(1L)); sequence.verify(repository).lockPlan("100", "IP-1"); + sequence.verify(stock).stockIn(eq("100"), eq(plan), eq(task), eq(List.of(line)), eq(List.of(allocation)), anyList(), eq(receivedAt), eq(true)); + sequence.verify(dispatch).complete(eq(List.of(1L)), eq("admin"), any()); + var audit = ArgumentCaptor.forClass(InboundPdaOperation.class); + verify(repository).insert(audit.capture()); + assertTrue(audit.getValue().getRequestJson().contains("现场确认短少1箱")); + when(repository.operation("100", "key")).thenReturn(audit.getValue()); + assertEquals(result, service.confirmShortage("100", "IP-1", "admin", "key", request)); + verify(stock, times(1)).stockIn(anyString(), any(), any(), anyList(), anyList(), anyList(), any(), eq(true)); + verifyNoInteractions(outbox); + assertThrows(ResponseStatusException.class, () -> service.confirmShortage("100", "IP-1", "admin", "new-key", request)); + assertThrows(ResponseStatusException.class, () -> service.confirmShortage("100", "IP-1", "admin", "key", request(4, 19))); + } + + /** 页面旧版本或旧实收数不能确认库存。 */ + @Test void rejectsStaleVersionAndCounts() { + assertThrows(ResponseStatusException.class, () -> service.confirmShortage("100", "IP-1", "admin", "key", request(3, 20))); + assertThrows(ResponseStatusException.class, () -> service.confirmShortage("100", "IP-1", "admin", "key", request(4, 19))); + verifyNoInteractions(stock); + } + + /** 没有逐 SKU 实收时不能按计划猜测缺少哪个商品。 */ + @Test void rejectsMissingSkuReceipts() { + when(repository.skuReceipts("100", 1L)).thenReturn(List.of()); + assertThrows(ResponseStatusException.class, () -> service.confirmShortage("100", "IP-1", "admin", "key", request(4, 20))); + verifyNoInteractions(stock); + } + + /** 即使自动分摊后的实收只有一个 SKU,冻结申报中的另一 SKU 仍使短少归属不明确。 */ + @Test void rejectsMultipleFrozenSkusEvenWhenOnlyOneWasAllocated() { + when(repository.frozenSkus("100",1L,3L)).thenReturn(List.of( + new com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingSkuReceiptMapper.FrozenSku("SKU-1",20), + new com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingSkuReceiptMapper.FrozenSku("SKU-2",1))); + var error=assertThrows(ResponseStatusException.class,()->service.confirmShortage("100","IP-1","admin","key",request(4,20))); + assertTrue(error.getReason().contains("单 SKU")); + verifyNoInteractions(stock);verify(dispatch,never()).complete(anyList(),anyString(),any()); + assertEquals("PENDING_RECONCILIATION",plan.getStatus()); + } + + /** 同柜同目的仓跨票合收时,即使每票单 SKU,也不能猜测短少属于哪个客户。 */ + @Test void rejectsSharedFbaReceivingGroup() { + when(repository.fbaGroupLineCount("100",1L,3L)).thenReturn(2L); + assertThrows(ResponseStatusException.class,()->service.confirmShortage("100","IP-1","admin","key",request(4,20))); + verifyNoInteractions(stock);verify(repository,never()).insert(any(InboundPdaOperation.class)); + } + + /** 客户归属不匹配时不触发任何库存或运输操作。 */ + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(ints = {0, 21, 22}) + void rejectsZeroCompleteAndExcessReceipts(int received) { + allocation.setPickedPackageCount(received); allocation.setCountedPackageCount(received); + assertThrows(ResponseStatusException.class, () -> service.confirmShortage("100", "IP-1", "admin", "key", request(4, received))); + verifyNoInteractions(stock); + } + + /** FBA与逐箱入库混合时不能用本确认操作推断SKU归属。 */ + @Test void rejectsMixedDestinations() { + var other = new InboundPlanLine(); other.setId(6L); other.setDestinationType("OVERSEAS"); + when(repository.lines(1L)).thenReturn(List.of(line, other)); + assertThrows(ResponseStatusException.class, () -> service.confirmShortage("100", "IP-1", "admin", "key", request(4, 20))); + verifyNoInteractions(stock); + } + + /** 库存落账失败时不推进计划状态和运输交接。 */ + @Test void doesNotAdvanceWhenStockInFails() { + doThrow(new IllegalStateException("库存写入失败")).when(stock).stockIn(anyString(), any(), any(), anyList(), anyList(), anyList(), any(), eq(true)); + assertThrows(IllegalStateException.class, () -> service.confirmShortage("100", "IP-1", "admin", "key", request(4, 20))); + assertEquals("PENDING_RECONCILIATION", plan.getStatus()); + verify(dispatch, never()).complete(anyList(), anyString(), any()); + verify(repository, never()).insert(any(InboundPdaOperation.class)); + } + + /** 客户归属不匹配时不触发任何库存或运输操作。 */ + @Test void rejectsWrongCustomer() { + when(repository.findPlan("100", "IP-1")).thenReturn(null); + assertThrows(ResponseStatusException.class, () -> service.confirmShortage("100", "IP-1", "admin", "key", request(4, 20))); + verifyNoInteractions(stock, dispatch); + } + + /** 当前实收值随请求一起参与版本及幂等校验。 */ + private ConfirmInboundShortageRequest request(int version, int received) { + return new ConfirmInboundShortageRequest("100", version, 21, received, "现场确认短少1箱"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java index cc62bf9de..461c1de86 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java @@ -325,6 +325,34 @@ class InboundStockReceiptServiceTest { List.of(new DropshipPalletSnapshot.Sku("SKU-1", "Product", packages, packages * 10))); } + /** FBA确认短少后只把20箱登记到库存,不修改21箱的冻结申报。 */ + @Test void exactSkuShortageStocksOnlyActualPackages() { + Fixture f = fixture(21, 21, 20, 210); + when(orders.selectBatchIds(any())).thenReturn(List.of(f.order)); + when(receivingSkuReceipts.listPlan("100", 1L)).thenReturn(List.of(skuReceipt(12, 21, "SKU-1", 20))); + when(inventory.stockIn(eq("100"), eq("operator"), any())) + .thenReturn(new InventoryCommandResponse(8L, "STOCK_IN", 9L, 10L, 20, 0, 0, 0, false, false)); + service.stockIn("100", f.plan, f.task, List.of(f.line), List.of(f.allocation), List.of(f.pallet), LocalDateTime.now(), true); + var request = ArgumentCaptor.forClass(InventoryCommandRequests.StockIn.class); + verify(inventory).stockIn(eq("100"), eq("operator"), request.capture()); + assertEquals(20, request.getValue().packages()); + assertEquals(21, f.detail.getPackageCount()); + assertEquals("INBOUND_COMPLETED", f.order.getCurrentStatus()); + assertEquals("INBOUND_PLAN:PARTIAL_INBOUND", f.order.getSubOrderStatus()); + } + + /** 一个SKU少收不能抵消另一个SKU超收,即使总箱数仍少于应收。 */ + @Test void partialSkuReceiptRejectsOverReceiptOfAnotherSku() { + Fixture f = fixture(21, 21, 20, 210); + f.detail.setPackageCount(10); + when(orderDetails.selectList(any())).thenReturn(List.of(f.detail, detail(202, "SKU-2", 11, 110))); + when(orders.selectBatchIds(any())).thenReturn(List.of(f.order)); + when(receivingSkuReceipts.listPlan("100", 1L)).thenReturn(List.of(skuReceipt(12, 21, "SKU-1", 11), skuReceipt(12, 21, "SKU-2", 9))); + assertThrows(ResponseStatusException.class, () -> service.stockIn("100", f.plan, f.task, + List.of(f.line), List.of(f.allocation), List.of(f.pallet), LocalDateTime.now(), true)); + verify(inventory, never()).stockIn(any(), any(), any()); + } + private Fixture fixture(int required, int planned, int picked, int quantity) { InboundPlan plan = new InboundPlan(); plan.setId(1L); plan.setPlanNo("IP-1"); plan.setOverseasWarehouseId(900L); InboundPdaTask task = new InboundPdaTask(); task.setId(2L); task.setOperatorId("operator"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ShortageReceiptEvidencePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ShortageReceiptEvidencePersistenceTest.java new file mode 100644 index 000000000..d4fe3645f --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ShortageReceiptEvidencePersistenceTest.java @@ -0,0 +1,91 @@ +package com.baiyue.lastleg.inboundplan.pda.service; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.baiyue.lastleg.dispatch.mapper.DispatchSourceMapper; +import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingSkuReceiptMapper; +import org.junit.jupiter.api.Test; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import java.util.UUID; +import static org.assertj.core.api.Assertions.*; + +/** 真实 Mapper 验证短少准入使用冻结申报、整组来源以及明确零实收,避免 Mock 掩盖取数错误。 */ +class ShortageReceiptEvidencePersistenceTest { + /** 尚未分摊到实收的 SKU 仍须被识别,不能把 A20+B1 的短少误判为单 SKU。 */ + @Test void frozenSkusIncludeCompletelyMissingSkuAndRespectCustomer() throws Exception { + var f=new Fixture(); + f.db.update("INSERT INTO ll_order_detail VALUES('101',11,'A',20),('101',11,'B',1),('other',11,'C',99)"); + assertThat(f.skus.frozenSkus("101",1L,10L)).containsExactlyInAnyOrder( + new ReceivingSkuReceiptMapper.FrozenSku("A",20),new ReceivingSkuReceiptMapper.FrozenSku("B",1)); + assertThat(f.skus.frozenSkus("other",1L,10L)).isEmpty(); + f.db.update("UPDATE ll_order_detail SET sku_code=' a ' WHERE sku_code='B'"); + assertThat(f.skus.frozenSkus("101",1L,10L)).containsExactly(new ReceivingSkuReceiptMapper.FrozenSku("A",21)); + } + + /** 同柜同目的仓另一客户也属于合收组;另一柜次、目的仓和一件代发不参与此分摊。 */ + @Test void countsCrossCustomerSourcesOnlyWithinSameBulkReceiptGroup() throws Exception { + var f=new Fixture();assertThat(f.skus.fbaGroupLineCount("101",1L,10L)).isEqualTo(1); + f.db.update("INSERT INTO ll_inbound_plan VALUES(2,'202',9,'CONT','COMPLETED')"); + f.db.update("INSERT INTO ll_inbound_plan_line VALUES(20,2,'202',22,'FBA',' ont8 ')"); + f.db.update("INSERT INTO ll_order VALUES(22,'202','BOOK','CONT','FIRST_LEG','FBA')"); + assertThat(f.skus.fbaGroupLineCount("101",1L,10L)).isEqualTo(2); + f.db.update("UPDATE ll_order SET source_container_booking_id='ANOTHER' WHERE id=22"); + assertThat(f.skus.fbaGroupLineCount("101",1L,10L)).isEqualTo(1); + f.db.update("UPDATE ll_order SET source_container_booking_id='BOOK',order_type='ONE_CLICK_DROPSHIP' WHERE id=22"); + assertThat(f.skus.fbaGroupLineCount("101",1L,10L)).isEqualTo(1); + f.db.update("UPDATE ll_order SET order_type='FIRST_LEG' WHERE id=22"); + f.db.update("UPDATE ll_inbound_plan_line SET fba_warehouse_code='LAX9' WHERE id=20"); + assertThat(f.skus.fbaGroupLineCount("101",1L,10L)).isEqualTo(1); + assertThat(f.skus.fbaGroupLineCount("202",1L,10L)).isZero(); + } + + /** 缺记录、未结束、非零或已有库存凭据都不能释放预排,只有明确零实收可以。 */ + @Test void emptyPlanRequiresTerminalZeroEvidenceAndOriginalIdentity() throws Exception { + var f=new Fixture(); + f.db.update("INSERT INTO ll_inbound_plan_pallet VALUES(100,1,'101',0)"); + assertThat(f.dispatch.confirmedEmptyPlan("101",100,1,"9")).isZero(); + f.db.update("INSERT INTO ll_inbound_plan_pallet_allocation VALUES(1000,100,'101',0,0)"); + assertThat(f.dispatch.confirmedEmptyPlan("101",100,1,"9")).isEqualTo(1); + assertThat(f.dispatch.confirmedEmptyPlan("other",100,1,"9")).isZero(); + assertThat(f.dispatch.confirmedEmptyPlan("101",100,2,"9")).isZero(); + assertThat(f.dispatch.confirmedEmptyPlan("101",100,1,"10")).isZero(); + f.db.update("UPDATE ll_inbound_plan SET status='PENDING_RECONCILIATION'"); + assertThat(f.dispatch.confirmedEmptyPlan("101",100,1,"9")).isZero(); + f.db.update("UPDATE ll_inbound_plan SET status='PARTIAL_INBOUND'"); + f.db.update("UPDATE ll_inbound_plan_pallet_allocation SET counted_package_count=NULL"); + assertThat(f.dispatch.confirmedEmptyPlan("101",100,1,"9")).isZero(); + f.db.update("UPDATE ll_inbound_plan_pallet_allocation SET counted_package_count=0,picked_package_count=1"); + assertThat(f.dispatch.confirmedEmptyPlan("101",100,1,"9")).isZero(); + f.db.update("UPDATE ll_inbound_plan_pallet_allocation SET picked_package_count=0"); + f.db.update("INSERT INTO ll_inbound_stock_receipt VALUES(1000,'101')"); + assertThat(f.dispatch.confirmedEmptyPlan("101",100,1,"9")).isZero(); + } + + private static final class Fixture { + final JdbcTemplate db; + final ReceivingSkuReceiptMapper skus; + final DispatchSourceMapper dispatch; + + /** 每个测试使用独立内存库,只建立当前查询所需的真实字段。 */ + Fixture() throws Exception { + var ds=new DriverManagerDataSource("jdbc:h2:mem:shortage-evidence-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa",""); + db=new JdbcTemplate(ds); + db.execute("CREATE TABLE ll_inbound_plan(id BIGINT,customer_id VARCHAR,overseas_warehouse_id BIGINT,container_no VARCHAR,status VARCHAR)"); + db.execute("CREATE TABLE ll_inbound_plan_line(id BIGINT,plan_id BIGINT,customer_id VARCHAR,order_id BIGINT,destination_type VARCHAR,fba_warehouse_code VARCHAR)"); + db.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR,source_container_booking_id VARCHAR,container_no VARCHAR,order_type VARCHAR,destination_type VARCHAR)"); + db.execute("CREATE TABLE ll_order_detail(customer_id VARCHAR,order_id BIGINT,sku_code VARCHAR,package_count INT)"); + db.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT,plan_id BIGINT,customer_id VARCHAR,actual_package_count INT)"); + db.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(id BIGINT,pallet_id BIGINT,customer_id VARCHAR,counted_package_count INT,picked_package_count INT)"); + db.execute("CREATE TABLE ll_inbound_stock_receipt(allocation_id BIGINT,customer_id VARCHAR)"); + db.update("INSERT INTO ll_inbound_plan VALUES(1,'101',9,'CONT','PARTIAL_INBOUND')"); + db.update("INSERT INTO ll_inbound_plan_line VALUES(10,1,'101',11,'FBA','ONT8')"); + db.update("INSERT INTO ll_order VALUES(11,'101','BOOK','CONT','FIRST_LEG','FBA')"); + var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true); + config.addMapper(ReceivingSkuReceiptMapper.class);config.addMapper(DispatchSourceMapper.class); + var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);factory.afterPropertiesSet(); + var session=new SqlSessionTemplate(factory.getObject());skus=session.getMapper(ReceivingSkuReceiptMapper.class);dispatch=session.getMapper(DispatchSourceMapper.class); + } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java index 205a90d5b..bea732115 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java @@ -424,9 +424,10 @@ class LastlegOrderServiceTest { } @Test - void pageUsesPaginationAndPlatformFiltersWithoutCustomerScope() { + void departedPageUsesPaginationAndPlatformFiltersWithoutCustomerScope() { var query = new LastlegOrderRequests.Query(3L, 25L, "LL-", "FIRST_LEG", "IN-", null, - null, "", "PENDING_ASSIGNMENT", null, "LAX", null, null, null, null, null, null, null, null, null); + null, "", "PENDING_ASSIGNMENT", null, "LAX", null, null, null, null, null, + LastlegOrderRequests.SailingStage.AFTER_DEPARTURE, null, null, null); var resultPage = new com.baomidou.mybatisplus.extension.plugins.pagination.Page(3, 25); resultPage.setTotal(0); when(orders.selectPage(any(), any())).thenReturn(resultPage); @@ -438,6 +439,7 @@ class LastlegOrderServiceTest { verify(orders).selectPage(page.capture(), any()); assertEquals(3, page.getValue().getCurrent()); assertEquals(25, page.getValue().getSize()); + verifyNoInteractions(preDeparture); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/workflow/OrderWorkflowFactMapperTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/workflow/OrderWorkflowFactMapperTest.java index 6daca4429..472137f4c 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/workflow/OrderWorkflowFactMapperTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/workflow/OrderWorkflowFactMapperTest.java @@ -33,6 +33,8 @@ class OrderWorkflowFactMapperTest { db.update("INSERT INTO ll_delivery_order VALUES(9,'11','DELIVERING',CURRENT_TIMESTAMP,'FBA1','3','FBA',NULL,NULL)"); db.update("INSERT INTO ll_inbound_plan VALUES('11',1,'PENDING_RECONCILIATION')"); assertThat(mapper.read(sources)).singleElement().satisfies(f->{assertThat(f.outboundExists()).isTrue();assertThat(f.pendingOutbound()).isFalse();assertThat(f.delivering()).isTrue();assertThat(f.unloaded()).isTrue();}); + db.update("UPDATE ll_inbound_plan SET status='PARTIAL_INBOUND'"); + assertThat(mapper.read(sources)).singleElement().satisfies(f->assertThat(f.unloaded()).isTrue()); db.update("UPDATE ll_delivery_order SET destination_type='PRIVATE',destination_warehouse_code=NULL,destination_warehouse_id=NULL,delivery_address='Private A',postal_code='10001'"); db.update("INSERT INTO ll_dispatch_item VALUES(60,'11',50,'PRIVATE','Private A','10001')"); assertThat(mapper.read(sources)).singleElement().satisfies(f->assertThat(f.delivering()).isTrue());