diff --git a/deploy/sql/README.md b/deploy/sql/README.md index ca918e5f0..d860394dd 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -145,3 +145,5 @@ node --test scripts/admin-test-env-modules/initialization-baseline-contract.test 本次整理使用隔离 MySQL 8.4,国内与海外分别在不存在对方区域 schema 的空库执行两轮,逐表比对整理前最终字段、默认值、索引、约束、注释及业务触发器。SHOW CREATE 的冗余字符集显示与运行态自增计数不视为结构差异;移除示例种子和强制改密是明确行为变更。这不代表已部署或完成真实 HTTP 验收。 码头车队邮件配置见 [Firstleg 车队邮件](../../firstleg/docs/terminal-drayage-mail.md)。 + +`migrations/overseas/baiyue_lastleg/V24__cargo_discard_photos.sql` 仅新增废弃照片关联表,按客户、申请和托盘关联文件及提交时间。与 Lastleg、File 和三端照片功能共同发布;不回填历史废弃单,不修改库存。脚本存在不表示目标环境已执行。 diff --git a/deploy/sql/migrations/overseas/baiyue_lastleg/V24__cargo_discard_photos.sql b/deploy/sql/migrations/overseas/baiyue_lastleg/V24__cargo_discard_photos.sql new file mode 100644 index 000000000..1373e87ce --- /dev/null +++ b/deploy/sql/migrations/overseas/baiyue_lastleg/V24__cargo_discard_photos.sql @@ -0,0 +1,16 @@ +CREATE TABLE IF NOT EXISTS ll_cargo_discard_photo ( + id BIGINT NOT NULL, + customer_id VARCHAR(64) NOT NULL, + application_id BIGINT NOT NULL, + pallet_no VARCHAR(128) NOT NULL, + file_id BIGINT NOT NULL, + file_name VARCHAR(255) NOT NULL, + content_type VARCHAR(128) NOT NULL, + size_bytes BIGINT NOT NULL, + operator_id VARCHAR(64) NOT NULL, + created_at DATETIME(6) NOT NULL, + submitted_at DATETIME(6) NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_cargo_discard_photo_file (file_id), + KEY idx_cargo_discard_photo_application (customer_id, application_id, pallet_no) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 0d8e39888..616512ab8 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -4303,3 +4303,21 @@ CREATE TABLE ll_return_sku_conversion ( KEY idx_return_sku_conversion_carton(customer_id,carton_id), CONSTRAINT chk_return_sku_conversion_quantity CHECK(quantity>0) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='退货实物SKU换标库存转换和冲回记录'; + +-- PDA cargo discard photo evidence +CREATE TABLE IF NOT EXISTS ll_cargo_discard_photo ( + id BIGINT NOT NULL, + customer_id VARCHAR(64) NOT NULL, + application_id BIGINT NOT NULL, + pallet_no VARCHAR(128) NOT NULL, + file_id BIGINT NOT NULL, + file_name VARCHAR(255) NOT NULL, + content_type VARCHAR(128) NOT NULL, + size_bytes BIGINT NOT NULL, + operator_id VARCHAR(64) NOT NULL, + created_at DATETIME(6) NOT NULL, + submitted_at DATETIME(6) NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_cargo_discard_photo_file (file_id), + KEY idx_cargo_discard_photo_application (customer_id, application_id, pallet_no) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; 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 d88862760..52f3dc3ba 100644 --- a/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java +++ b/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java @@ -31,6 +31,7 @@ enum BusinessFileCategory { CONTAINER_DELIVERY_ORDER(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), CONTAINER_PICKUP_ORDER(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_UNLOAD_PHOTO(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), + OVERSEAS_CARGO_DISCARD_PHOTO(true, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_RELABEL_PHOTO(true, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_TRANSFER_MEASUREMENT_PHOTO(true, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_RELABEL_TOTAL_DOCUMENT(true, FormatPolicy.PDF, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), diff --git a/file/src/main/resources/openapi/cargo-discard-photo-v1.yaml b/file/src/main/resources/openapi/cargo-discard-photo-v1.yaml new file mode 100644 index 000000000..f9c2f6779 --- /dev/null +++ b/file/src/main/resources/openapi/cargo-discard-photo-v1.yaml @@ -0,0 +1,12 @@ +openapi: 3.0.3 +info: + title: Cargo discard photo storage ownership contract + version: 1.0.0 + description: >- + Uses the existing internal business-file upload, metadata and content endpoints, protected + by X-Internal-Service-Token. businessType is OVERSEAS_CARGO_DISCARD_PHOTO; tenantId is a + positive customer ID and businessId is the positive cargo discard application ID. + File enforces composite ownership and non-empty JPG/PNG bytes with image signature and + 10 MiB limit. Lastleg owns the pallet binding, submitted visibility and immutable confirmation. + No anonymous or public file endpoint is introduced. +paths: {} diff --git a/file/src/test/java/com/baiyue/file/service/CargoDiscardPhotoStorageTest.java b/file/src/test/java/com/baiyue/file/service/CargoDiscardPhotoStorageTest.java new file mode 100644 index 000000000..91c0ebf0f --- /dev/null +++ b/file/src/test/java/com/baiyue/file/service/CargoDiscardPhotoStorageTest.java @@ -0,0 +1,25 @@ +package com.baiyue.file.service; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.util.ReflectionTestUtils; +import java.nio.file.Path; +import static org.assertj.core.api.Assertions.*; + +class CargoDiscardPhotoStorageTest { + @TempDir Path root; + @Test void discardEvidenceIsCustomerOwnedAndRequiresRealImageSignature() throws Exception { + var category = BusinessFileCategory.require("OVERSEAS_CARGO_DISCARD_PHOTO"); + category.validateOwner(7); + assertThatThrownBy(() -> category.validateOwner(0)).isInstanceOf(com.baiyue.common.core.exception.BizException.class); + assertThatThrownBy(() -> category.storageBusinessId("0")).isInstanceOf(com.baiyue.common.core.exception.BizException.class); + var storage = new BusinessFileStorage(); ReflectionTestUtils.setField(storage, "configuredRoot", root); storage.initializeRoot(); + byte[] bytes = {(byte)0x89, 0x50, 0x4e, 0x47, 13, 10, 26, 10}; + var result = storage.store(7, "11", new MockMultipartFile("file", "discard.png", "image/png", bytes), category); + assertThat(storage.read(result.objectKey())).isEqualTo(bytes); + assertThatThrownBy(() -> storage.store(7, "11", new MockMultipartFile("file", "fake.png", "image/png", "fake".getBytes()), category)).hasMessageContaining("不是有效图片"); + assertThatThrownBy(() -> storage.store(7, "11", new MockMultipartFile("file", "doc.pdf", "application/pdf", "%PDF-test".getBytes()), category)).hasMessageContaining("仅支持JPG"); + assertThatThrownBy(() -> storage.store(7, "11", new MockMultipartFile("file", "large.png", "image/png", new byte[10*1024*1024+1]), category)).isInstanceOf(com.baiyue.common.core.exception.BizException.class); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/config/LastlegRestClientProperties.java b/lastleg-service/src/main/java/com/baiyue/lastleg/config/LastlegRestClientProperties.java index b647f3a9b..1efa078ce 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/config/LastlegRestClientProperties.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/config/LastlegRestClientProperties.java @@ -9,6 +9,7 @@ import java.time.Duration; @ConfigurationProperties(prefix = "baiyue.http.clients") public class LastlegRestClientProperties { private ClientTimeouts unloadPhotoFile = new ClientTimeouts(Duration.ofSeconds(3), Duration.ofSeconds(30)); + private ClientTimeouts cargoDiscardPhotoFile = new ClientTimeouts(Duration.ofSeconds(3), Duration.ofSeconds(30)); private ClientTimeouts relabelPhotoFile = new ClientTimeouts(Duration.ofSeconds(3), Duration.ofSeconds(30)); private ClientTimeouts customerVisitFile = new ClientTimeouts(Duration.ofSeconds(3), Duration.ofSeconds(30)); private ClientTimeouts procurementFile = new ClientTimeouts(Duration.ofSeconds(3), Duration.ofSeconds(30)); @@ -18,6 +19,8 @@ public class LastlegRestClientProperties { public ClientTimeouts getUnloadPhotoFile() { return unloadPhotoFile; } public void setUnloadPhotoFile(ClientTimeouts value) { unloadPhotoFile = required(value, "unload-photo-file"); } + public ClientTimeouts getCargoDiscardPhotoFile() { return cargoDiscardPhotoFile; } + public void setCargoDiscardPhotoFile(ClientTimeouts value) { cargoDiscardPhotoFile = required(value, "cargo-discard-photo-file"); } public ClientTimeouts getRelabelPhotoFile() { return relabelPhotoFile; } public void setRelabelPhotoFile(ClientTimeouts value) { relabelPhotoFile = required(value, "relabel-photo-file"); } public ClientTimeouts getCustomerVisitFile() { return customerVisitFile; } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/controller/CargoDiscardController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/controller/CargoDiscardController.java index 60d987237..e63473c3f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/controller/CargoDiscardController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/controller/CargoDiscardController.java @@ -52,6 +52,16 @@ public class CargoDiscardController { return ApiResponse.success(service.detail(scope(user, customerId), discardNo)); } + @GetMapping("/{discardNo}/photos/files/{fileId}/download") + @org.springframework.security.access.prepost.PreAuthorize("hasAuthority('platform:overseas-warehouse:discard:list')") + public org.springframework.http.ResponseEntity downloadPhoto(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam String customerId, @PathVariable String discardNo, @PathVariable long fileId) { + var file = service.downloadPhoto(scope(user, customerId), discardNo, fileId); + return org.springframework.http.ResponseEntity.ok().cacheControl(org.springframework.http.CacheControl.noStore()) + .header(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION, + org.springframework.http.ContentDisposition.inline().filename(file.fileName(), java.nio.charset.StandardCharsets.UTF_8).build().toString()) + .contentType(org.springframework.http.MediaType.parseMediaType(file.contentType())).body(file.content()); + } @PostMapping("/{discardNo}/review") public ApiResponse review(@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @PathVariable String discardNo, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/controller/MerchantCargoDiscardController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/controller/MerchantCargoDiscardController.java index f49e899da..d69a705d7 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/controller/MerchantCargoDiscardController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/controller/MerchantCargoDiscardController.java @@ -36,6 +36,16 @@ public class MerchantCargoDiscardController { @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DISCARD_LIST + "')") public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String discardNo) { return ApiResponse.success(service.detail(customer(user), discardNo)); } + @GetMapping("/{discardNo}/photos/files/{fileId}/download") + @org.springframework.security.access.prepost.PreAuthorize("hasAuthority('merchant:overseas-warehouse:discard:list')") + public org.springframework.http.ResponseEntity downloadPhoto(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String discardNo, @PathVariable long fileId) { + var file = service.downloadPhoto(customer(user), discardNo, fileId); + return org.springframework.http.ResponseEntity.ok().cacheControl(org.springframework.http.CacheControl.noStore()) + .header(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION, + org.springframework.http.ContentDisposition.inline().filename(file.fileName(), java.nio.charset.StandardCharsets.UTF_8).build().toString()) + .contentType(org.springframework.http.MediaType.parseMediaType(file.contentType())).body(file.content()); + } @PostMapping("/{discardNo}/cancel") @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DISCARD_CANCEL + "')") public ApiResponse cancel(@AuthenticationPrincipal AuthenticatedUser user, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardResponses.java index 14fb36737..168140564 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardResponses.java @@ -25,7 +25,10 @@ public final class CargoDiscardResponses { public record History(String fromStatus, String toStatus, String operatorId, String remark, LocalDateTime createdAt) {} + public record Photo(Long fileId, String palletNo, String fileName, String contentType, Long sizeBytes, + String operatorId, LocalDateTime createdAt, LocalDateTime submittedAt) {} + public record Detail(Summary summary, BigDecimal feePerPackage, String remark, String reviewedBy, String reviewRemark, List items, - List histories) {} + List histories, List photos) {} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardPhoto.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardPhoto.java new file mode 100644 index 000000000..3fe05d37d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardPhoto.java @@ -0,0 +1,22 @@ +package com.baiyue.lastleg.discard.entity; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import java.time.LocalDateTime; + +@Data +@TableName("ll_cargo_discard_photo") +public class CargoDiscardPhoto { + @TableId private Long id; + private String customerId; + private Long applicationId; + private String palletNo; + private Long fileId; + private String fileName; + private String contentType; + private Long sizeBytes; + private String operatorId; + private LocalDateTime createdAt; + private LocalDateTime submittedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/gateway/CargoDiscardPhotoFileGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/gateway/CargoDiscardPhotoFileGateway.java new file mode 100644 index 000000000..b27f510ce --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/gateway/CargoDiscardPhotoFileGateway.java @@ -0,0 +1,12 @@ +package com.baiyue.lastleg.discard.gateway; + +import org.springframework.web.multipart.MultipartFile; + +public interface CargoDiscardPhotoFileGateway { + UploadedFile upload(String customerId, long taskId, long operatorId, MultipartFile file); + void validate(String customerId, long taskId, long fileId); + DownloadedFile download(String customerId, long taskId, long fileId); + + record UploadedFile(long fileId, String fileName, String contentType, long sizeBytes) { } + record DownloadedFile(byte[] content, String fileName, String contentType) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/gateway/HttpCargoDiscardPhotoFileGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/gateway/HttpCargoDiscardPhotoFileGateway.java new file mode 100644 index 000000000..c84bc2290 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/gateway/HttpCargoDiscardPhotoFileGateway.java @@ -0,0 +1,135 @@ +package com.baiyue.lastleg.discard.gateway; + +import com.baiyue.lastleg.config.LastlegRestClientFactory; +import com.baiyue.lastleg.config.LastlegRestClientProperties; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.MediaType; +import org.springframework.http.client.MultipartBodyBuilder; +import org.springframework.http.ResponseEntity; +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; + +@Component +public class HttpCargoDiscardPhotoFileGateway implements CargoDiscardPhotoFileGateway { + private static final String BUSINESS_TYPE = "OVERSEAS_CARGO_DISCARD_PHOTO"; + @Resource private RestClient.Builder restClientBuilder; + @Resource private LastlegRestClientFactory restClientFactory; + @Resource private LastlegRestClientProperties restClientProperties; + @Value("${baiyue.clients.file.url:http://127.0.0.1:9205}") private String baseUrl; + @Value("${baiyue.security.internal-service-token:}") private String internalToken; + private RestClient client; + + @PostConstruct + void initializeClient() { + client = restClientFactory.build(restClientBuilder, baseUrl, + restClientProperties.getCargoDiscardPhotoFile()); + } + + @Override + public UploadedFile upload(String customerId, long taskId, long operatorId, MultipartFile file) { + long tenantId = tenantId(customerId); + MultipartBodyBuilder body = new MultipartBodyBuilder(); + try { + body.part("file", new ByteArrayResource(file.getBytes()) { + @Override public String getFilename() { return file.getOriginalFilename(); } + }).contentType(file.getContentType() == null ? MediaType.APPLICATION_OCTET_STREAM + : MediaType.parseMediaType(file.getContentType())); + } catch (IOException exception) { + throw new IllegalArgumentException("Cannot read cargo-discard photo", exception); + } + MetadataEnvelope response = client.post() + .uri("/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}", + tenantId, BUSINESS_TYPE, taskId) + .header("X-Internal-Service-Token", internalToken) + .header("X-Operator-Id", String.valueOf(operatorId)) + .contentType(MediaType.MULTIPART_FORM_DATA).body(body.build()).retrieve() + .onStatus(HttpStatusCode::isError, (request, result) -> { + throw new ResponseStatusException(result.getStatusCode().is4xxClientError() + ? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE, + "Cargo discard photo upload failed"); + }).body(MetadataEnvelope.class); + requireSuccess(response, "Cargo discard photo upload failed"); + Metadata data = response == null ? null : response.data(); + if (data == null || data.fileId() == null) { + throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, + "File service returned no cargo-discard photo metadata"); + } + return new UploadedFile(data.fileId(), data.fileName(), data.contentType(), + data.sizeBytes() == null ? 0L : data.sizeBytes()); + } + + @Override + public void validate(String customerId, long taskId, long fileId) { + long tenantId = tenantId(customerId); + MetadataEnvelope response = client.get() + .uri("/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}/{fileId}", + tenantId, BUSINESS_TYPE, taskId, fileId) + .header("X-Internal-Service-Token", internalToken).retrieve() + .onStatus(HttpStatusCode::isError, (request, result) -> { + throw new ResponseStatusException(result.getStatusCode().is4xxClientError() + ? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE, + "Cargo discard photo does not exist or its ownership is invalid"); + }).body(MetadataEnvelope.class); + requireSuccess(response, "Cargo discard photo does not exist or its ownership is invalid"); + Metadata data = response == null ? null : response.data(); + if (data == null || data.fileId() == null || data.fileId() != fileId + || data.tenantId() == null || data.tenantId() != tenantId + || !BUSINESS_TYPE.equals(data.businessType()) + || !String.valueOf(taskId).equals(data.businessId())) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Cargo discard photo metadata does not match the PDA task"); + } + } + + @Override + public DownloadedFile download(String customerId, long taskId, long fileId) { + validate(customerId, taskId, fileId); + ResponseEntity response = client.get() + .uri("/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}/{fileId}/content", + tenantId(customerId), BUSINESS_TYPE, taskId, fileId) + .header("X-Internal-Service-Token", internalToken).retrieve() + .onStatus(HttpStatusCode::isError, (request, result) -> { + throw new ResponseStatusException(result.getStatusCode().is4xxClientError() + ? HttpStatus.NOT_FOUND : HttpStatus.SERVICE_UNAVAILABLE, "Cargo discard photo download failed"); + }).toEntity(byte[].class); + String fileName = response.getHeaders().getContentDisposition().getFilename(); + String contentType = response.getHeaders().getContentType() == null + ? MediaType.APPLICATION_OCTET_STREAM_VALUE : response.getHeaders().getContentType().toString(); + return new DownloadedFile(response.getBody() == null ? new byte[0] : response.getBody(), + fileName == null ? "cargo-discard-photo" : fileName, contentType); + } + + private static long tenantId(String customerId) { + try { return Long.parseLong(customerId); } + catch (NumberFormatException exception) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Customer ID must be numeric", exception); + } + } + + private static void requireSuccess(MetadataEnvelope response, String fallback) { + if (response == null) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, fallback); + if ("200".equals(response.code())) return; + HttpStatus status = switch (String.valueOf(response.code())) { + case "A0400", "A0422" -> HttpStatus.BAD_REQUEST; + case "A0403" -> HttpStatus.FORBIDDEN; + case "A0404" -> HttpStatus.NOT_FOUND; + default -> HttpStatus.SERVICE_UNAVAILABLE; + }; + throw new ResponseStatusException(status, + response.message() == null || response.message().isBlank() ? fallback : response.message()); + } + + private record MetadataEnvelope(String code, String message, Metadata data) { } + private 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/discard/mapper/CargoDiscardApplicationMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardApplicationMapper.java index 3b314f9bc..7c53ccf11 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardApplicationMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardApplicationMapper.java @@ -19,4 +19,7 @@ public interface CargoDiscardApplicationMapper extends BaseMapper { + @Select("SELECT * FROM ll_cargo_discard_photo WHERE customer_id=#{customerId} AND application_id=#{applicationId} ORDER BY created_at,id") + List listApplication(@Param("customerId") String customerId, @Param("applicationId") Long applicationId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/controller/PdaCargoDiscardController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/controller/PdaCargoDiscardController.java index 16c3f1400..0c533cd30 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/controller/PdaCargoDiscardController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/controller/PdaCargoDiscardController.java @@ -24,6 +24,27 @@ public class PdaCargoDiscardController { @Valid @RequestBody PdaCargoDiscardDtos.Scan request) { return ApiResponse.success(service.resolve(PdaWarehouseIdentity.require(user), request.palletNo())); } + @PostMapping(value = "/{discardNo}/pallets/{palletNo}/photos/files", consumes = org.springframework.http.MediaType.MULTIPART_FORM_DATA_VALUE) + public ApiResponse uploadPhoto( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String discardNo, @PathVariable String palletNo, + @RequestParam("file") org.springframework.web.multipart.MultipartFile file) { + return ApiResponse.success(service.uploadPhoto(PdaWarehouseIdentity.require(user), discardNo, palletNo, + user.userId() == null ? 0L : user.userId(), file)); + } + @GetMapping("/{discardNo}/pallets/{palletNo}/photos/files") + public ApiResponse> photos( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String discardNo, @PathVariable String palletNo) { + return ApiResponse.success(service.photos(PdaWarehouseIdentity.require(user), discardNo, palletNo)); + } + @GetMapping("/{discardNo}/pallets/{palletNo}/photos/files/{fileId}/download") + public org.springframework.http.ResponseEntity downloadPhoto(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String discardNo, @PathVariable String palletNo, @PathVariable long fileId) { + var file = service.downloadPhoto(PdaWarehouseIdentity.require(user), discardNo, palletNo, fileId); + return org.springframework.http.ResponseEntity.ok().cacheControl(org.springframework.http.CacheControl.noStore()) + .header(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION, + org.springframework.http.ContentDisposition.inline().filename(file.fileName(), java.nio.charset.StandardCharsets.UTF_8).build().toString()) + .contentType(org.springframework.http.MediaType.parseMediaType(file.contentType())).body(file.content()); + } @PostMapping("/{discardNo}/pallets/{palletNo}/confirm") public ApiResponse confirm(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String discardNo, @PathVariable String palletNo, @Valid @RequestBody PdaCargoDiscardDtos.Confirm request) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/dto/PdaCargoDiscardDtos.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/dto/PdaCargoDiscardDtos.java index feb1ca451..9b8caf5d4 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/dto/PdaCargoDiscardDtos.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/dto/PdaCargoDiscardDtos.java @@ -12,7 +12,8 @@ public final class PdaCargoDiscardDtos { private PdaCargoDiscardDtos() { } public record Scan(@NotBlank @Size(max = 128) String palletNo) { } public record ActualLine(@NotNull Long itemId, @PositiveOrZero int actualPackages) { } - public record Confirm(@NotEmpty List<@Valid ActualLine> lines) { } + public record Confirm(@NotEmpty List<@Valid ActualLine> lines, + @NotEmpty @Size(max = 20) List<@NotNull @jakarta.validation.constraints.Positive Long> photoFileIds) { } public record Sku(Long itemId, String skuCode, String productName, int plannedPackages, Integer actualPackages, String status) { } public record PalletTask(String discardNo, String customerId, String overseasWarehouseId, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardService.java index b496842bb..0125c003b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardService.java @@ -23,6 +23,7 @@ import java.util.stream.Collectors; @Service public class PdaCargoDiscardService { + @Resource private com.baiyue.lastleg.discard.service.CargoDiscardPhotoService photoService; @Resource private CargoDiscardApplicationMapper applications; @Resource private CargoDiscardItemMapper items; @Resource private CargoDiscardCartonMapper cartons; @@ -78,6 +79,8 @@ public class PdaCargoDiscardService { || !application.getCustomerId().equals(item.getCustomerId()) || !Objects.equals(application.getId(), item.getApplicationId()))) throw conflict("废弃申请终态存在未完成明细,不能恢复作业结果"); + photoService.confirm(application, task.palletNo(), request.photoFileIds(), + palletItems.stream().allMatch(item -> "COMPLETED".equals(item.getPdaStatus()))); // A completed pallet is an immutable result, including the final pallet after application completion. if (palletItems.stream().allMatch(item -> "COMPLETED".equals(item.getPdaStatus()))) return task; if (terminal) throw conflict("废弃终态不能执行未完成托盘"); @@ -109,6 +112,36 @@ public class PdaCargoDiscardService { .findFirst().orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "托盘不属于该废弃单")); } + @Transactional + public CargoDiscardResponses.Photo uploadPhoto(PdaWarehouseIdentity identity, String discardNo, String palletNo, + long operatorUserId, org.springframework.web.multipart.MultipartFile file) { + CargoDiscardApplication application = photoApplication(identity, discardNo, palletNo, true); + var task = palletTasks(application).stream().filter(t -> t.palletNo().equals(palletNo.trim())).findFirst().orElseThrow(); + if (!CargoDiscardService.APPROVED.equals(application.getStatus()) || "COMPLETED".equals(task.status())) + throw conflict("已完成或未审核的废弃托盘不能上传照片"); + return photoService.upload(application, task.palletNo(), identity.operatorKey(), operatorUserId, file); + } + + @Transactional(readOnly = true) + public List photos(PdaWarehouseIdentity identity, String discardNo, String palletNo) { + return photoService.list(photoApplication(identity, discardNo, palletNo, false), palletNo.trim(), false); + } + + @Transactional(readOnly = true) + public com.baiyue.lastleg.discard.gateway.CargoDiscardPhotoFileGateway.DownloadedFile downloadPhoto( + PdaWarehouseIdentity identity, String discardNo, String palletNo, long fileId) { + return photoService.download(photoApplication(identity, discardNo, palletNo, false), palletNo.trim(), fileId, false); + } + + private CargoDiscardApplication photoApplication(PdaWarehouseIdentity identity, String discardNo, String palletNo, boolean lock) { + var warehouseId = String.valueOf(identity.overseasWarehouseId()); + var application = lock ? applications.findNoByWarehouse(discardNo, warehouseId) : applications.readNoByWarehouse(discardNo, warehouseId); + if (application == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "废弃任务不存在或不属于当前仓库"); + if (palletTasks(application).stream().noneMatch(t -> t.palletNo().equals(palletNo.trim()))) + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "托盘不属于该废弃单"); + return application; + } + private List palletTasks(CargoDiscardApplication application) { return items.listApplication(application.getCustomerId(), application.getId()).stream() .collect(Collectors.groupingBy(this::physicalGroupKey, LinkedHashMap::new, Collectors.toList())) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardPhotoService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardPhotoService.java new file mode 100644 index 000000000..39de34a6f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardPhotoService.java @@ -0,0 +1,75 @@ +package com.baiyue.lastleg.discard.service; + +import com.baiyue.lastleg.discard.dto.CargoDiscardResponses; +import com.baiyue.lastleg.discard.entity.CargoDiscardApplication; +import com.baiyue.lastleg.discard.entity.CargoDiscardPhoto; +import com.baiyue.lastleg.discard.gateway.CargoDiscardPhotoFileGateway; +import com.baiyue.lastleg.discard.mapper.CargoDiscardPhotoMapper; +import jakarta.annotation.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.server.ResponseStatusException; +import java.time.LocalDateTime; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; + +@Service +public class CargoDiscardPhotoService { + @Resource private CargoDiscardPhotoMapper photos; + @Resource private CargoDiscardPhotoFileGateway files; + + public List list(CargoDiscardApplication application, String palletNo, boolean submittedOnly) { + return rows(application, palletNo).stream().filter(p -> !submittedOnly || p.getSubmittedAt() != null) + .map(this::view).toList(); + } + + public CargoDiscardResponses.Photo upload(CargoDiscardApplication application, String palletNo, + String operatorId, long operatorUserId, MultipartFile file) { + if (rows(application, palletNo).size() >= 20) throw conflict("每个托盘最多上传 20 张废弃照片"); + var uploaded = files.upload(application.getCustomerId(), application.getId(), operatorUserId, file); + CargoDiscardPhoto photo = new CargoDiscardPhoto(); + photo.setCustomerId(application.getCustomerId()); photo.setApplicationId(application.getId()); + photo.setPalletNo(palletNo); photo.setFileId(uploaded.fileId()); photo.setFileName(uploaded.fileName()); + photo.setContentType(uploaded.contentType()); photo.setSizeBytes(uploaded.sizeBytes()); + photo.setOperatorId(operatorId); photo.setCreatedAt(LocalDateTime.now()); photos.insert(photo); + return view(photo); + } + + // Called under the application row lock, in the same transaction as inventory deduction. + public void confirm(CargoDiscardApplication application, String palletNo, List fileIds, boolean completed) { + if (fileIds == null || fileIds.isEmpty() || fileIds.size() > 20 + || fileIds.stream().anyMatch(id -> id == null || id <= 0) || new HashSet<>(fileIds).size() != fileIds.size()) + throw conflict("每个托盘须提交 1–20 张不重复的废弃照片"); + var rows = rows(application, palletNo); + var submitted = rows.stream().filter(p -> p.getSubmittedAt() != null).map(CargoDiscardPhoto::getFileId).toList(); + if (completed) { + if (!new HashSet<>(submitted).equals(new HashSet<>(fileIds))) throw conflict("已完成托盘的废弃照片不能修改"); + return; + } + var selected = rows.stream().filter(p -> fileIds.contains(p.getFileId())).toList(); + if (selected.size() != fileIds.size()) throw conflict("照片不属于当前废弃单或托盘"); + selected.forEach(p -> files.validate(application.getCustomerId(), application.getId(), p.getFileId())); + var now = LocalDateTime.now(); + selected.forEach(p -> { p.setSubmittedAt(now); photos.updateById(p); }); + } + + public CargoDiscardPhotoFileGateway.DownloadedFile download(CargoDiscardApplication application, String palletNo, + long fileId, boolean submittedOnly) { + var photo = rows(application, palletNo).stream().filter(p -> Objects.equals(p.getFileId(), fileId)) + .filter(p -> !submittedOnly || p.getSubmittedAt() != null).findFirst() + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "废弃照片不存在或尚未提交")); + return files.download(application.getCustomerId(), application.getId(), photo.getFileId()); + } + + private List rows(CargoDiscardApplication application, String palletNo) { + return photos.listApplication(application.getCustomerId(), application.getId()).stream() + .filter(p -> palletNo == null || palletNo.equals(p.getPalletNo())).toList(); + } + private CargoDiscardResponses.Photo view(CargoDiscardPhoto photo) { + return new CargoDiscardResponses.Photo(photo.getFileId(), photo.getPalletNo(), photo.getFileName(), + photo.getContentType(), photo.getSizeBytes(), photo.getOperatorId(), photo.getCreatedAt(), photo.getSubmittedAt()); + } + private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardService.java index 56fccc1b7..fc1ac706a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardService.java @@ -60,6 +60,7 @@ public class CargoDiscardService { public static final String COMPLETED = "COMPLETED"; private static final Set STATUSES = Set.of(PENDING_REVIEW, APPROVED, REJECTED, CANCELLED, PDA_COMPLETED, COMPLETED); + @Resource private CargoDiscardPhotoService photoService; @Resource private CargoDiscardApplicationMapper applications; @Resource private CargoDiscardItemMapper items; @Resource private CargoDiscardCartonMapper discardCartons; @@ -214,7 +215,13 @@ public class CargoDiscardService { .map(value -> new CargoDiscardResponses.History(value.getFromStatus(), value.getToStatus(), value.getOperatorId(), value.getRemark(), value.getCreatedAt())).toList(); return new CargoDiscardResponses.Detail(summary(application), application.getFeePerPackage(), - application.getRemark(), application.getReviewedBy(), application.getReviewRemark(), itemViews, historyViews); + application.getRemark(), application.getReviewedBy(), application.getReviewRemark(), itemViews, historyViews, photoService.list(application, null, true)); + } + + public com.baiyue.lastleg.discard.gateway.CargoDiscardPhotoFileGateway.DownloadedFile downloadPhoto(String customerId, String discardNo, long fileId) { + CargoDiscardApplication application = applications.findNo(customerId, discardNo); + if (application == null) throw notFound("Cargo discard application not found"); + return photoService.download(application, null, fileId, true); } @Transactional diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java index 122601547..665db4ed9 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java @@ -434,6 +434,11 @@ public class RelabelApplicationService implements WalletResultHandler { long quantity = requestedSku.merge(skuItem.skuCode().toUpperCase(java.util.Locale.ROOT), (long) skuItem.quantity(), Long::sum); if (quantity > availableSku.getOrDefault(skuItem.skuCode().toUpperCase(java.util.Locale.ROOT), 0L)) throw conflict("SKU " + skuItem.skuCode() + " 的换标数量超过来源箱可用数量"); + var physical = returnCartonItems.lockCarton(sourceReturnCartonId).stream() + .filter(line -> line.getSkuCode().equalsIgnoreCase(skuItem.skuCode())).findFirst().orElse(null); + if (physical != null && quantity < value(physical.getQuantity()) + && returnPackingTraces.ownership(customerId, carton.getCartonMark(), physical.getSkuCode()).size() > 1) + throw conflict("跨来源单同 SKU 部分贴标须先现场分拣确认,不能按单号猜测实物来源"); } if ("CARTON_AND_SKU".equals(operationType(item)) && (!requestedSku.equals(availableSku) || returnCartonItems.lockCarton(sourceReturnCartonId).stream() diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnInventoryResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnInventoryResponses.java index 723cd5e31..1589e311a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnInventoryResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnInventoryResponses.java @@ -46,7 +46,8 @@ public final class ReturnInventoryResponses { public record Label(String cartonMark, String overseasWarehouseId, String customerId, String sourceBatchNo, String sourceReturnNo, long totalQuantity, BigDecimal length, BigDecimal width, BigDecimal height, BigDecimal weight, List items, LocalDateTime generatedAt, String printableHtml) { } - public record MergeResult(String cartonMark, String skuCode, long totalQuantity, Label label, String planNo) { + public record MergeResult(String cartonMark, String skuCode, long totalQuantity, Label label, String planNo, String operatorName) { + public MergeResult(String cartonMark, String skuCode, long totalQuantity, Label label, String planNo) { this(cartonMark, skuCode, totalQuantity, label, planNo, null); } public MergeResult(String cartonMark, String skuCode, long totalQuantity, Label label) { this(cartonMark, skuCode, totalQuantity, label, null); } } public record PutawayResult(String cartonMark, String locationCode, String palletCode, LocalDateTime putawayAt) { } @@ -59,8 +60,13 @@ public final class ReturnInventoryResponses { public record PackingPlan(String planNo, String customerId, String overseasWarehouseId, List returnNos, String mode, String status, List boxes, String remark, LocalDateTime pushedAt, List sourceCartonMarks, List sources, - LocalDateTime createdAt, List resultCartons, LocalDateTime completedAt) { } - public record PlanSource(String cartonMark, List items) { } + LocalDateTime createdAt, List resultCartons, LocalDateTime completedAt, + List