From 63ce4c48a7dd9e19b018fc211df70eca354d9231 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 11 Sep 2026 17:27:27 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- firstleg/AGENTS.md | 2 +- ...atformDropshipPalletizationController.java | 12 ++++ .../mapper/DropshipPalletizationMapper.java | 5 +- .../service/DropshipPalletizationService.java | 6 ++ .../InboundCartonLabelPrintService.java | 34 +++++++--- .../openapi/dashboard-metrics-v1.yaml | 64 +++++++++++++++++++ .../openapi/dropship-palletization-v1.yaml | 8 +++ .../DashboardMetricsContractTest.java | 10 +++ .../InboundCartonLabelPrintServiceTest.java | 25 ++++++++ 9 files changed, 154 insertions(+), 12 deletions(-) create mode 100644 firstleg/src/main/resources/openapi/dashboard-metrics-v1.yaml diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index b9d5265f7..9bc650679 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -29,7 +29,7 @@ | 客户专属价格簿、柜级阶梯运费与调价边界 | `controller/PlatformPriceBookController.java`、`service/PlatformPriceBookService.java`、`pricing/ActivePriceBookEstimator.java`、`service/FbaPricingService.java`、`pricing/ContainerTierAmountAllocator.java`;公共价格簿固定 `customerId=0`,客户专属版本按客户独立发布与替换,计价优先专属 ACTIVE、缺失才回退公共 ACTIVE。`REGION_WEIGHT_TIER` 对同柜同客户有效成员聚合实际计费量命中一个阶梯,再按成员计费量守恒分摊;装柜成员变化调用 `recalculateUnlockedContainerCustomerFees`,已确认/已发送或客户已确认的版本不得改写。人工调整以系统基准量为中心限制上下 10%(含边界),保存版本、原因和二次确认;一件代发 `WAIT_PALLETIZE` 可在预装确认前试算,`markInboundCalculated` 只更新费用并保留该托盘化状态 | | 入库费用明细 | `controller/PlatformInboundFeeDetailController.java`、`service/InboundFeeDetailService.java`、`dto/InboundFeeDetailDtos.java`;`PRIVATE_LAST_MILE_DELIVERY` 是独立私人仓尾程派送费,只允许 `destinationType=PRIVATE`,按稳定费用代码校验而不按中文名判断;Admin 消费端见 `../../apps/admin-web/src/modules/unified-orders/api/inboundFeeDetails.ts` | | 入库/柜次直接已付快照与凭证校验 | `service/FeePaymentSnapshotValidator.java`、`client/FeePaymentVoucherFileClient.java`;写入入口分别为 `InboundFeeDetailService.java`、`ContainerFeeDetailService.java`,File 业务类型固定 `FINANCE_VOUCHER` | -| 客户运营统计/在途柜量 | `customerstatistics/controller/PlatformCustomerTransitStatisticsController.java`、`customerstatistics/service/CustomerTransitStatisticsService.java`;契约见 `src/main/resources/openapi/customer-statistics-v1.yaml` | +| 首页实时指标、客户运营统计/在途柜量 | 首页物理在途柜 TEU 与待清关票数从 `dashboard/PlatformDashboardMetricsController.java` 进入,契约见 `src/main/resources/openapi/dashboard-metrics-v1.yaml`;客户维度统计为 `customerstatistics/adapter/in/web/PlatformCustomerTransitStatisticsController.java`、`customerstatistics/application/CustomerTransitStatisticsService.java`,契约见 `src/main/resources/openapi/customer-statistics-v1.yaml` | | 费用项目与价格簿 | `controller/PlatformFeeItemController.java`、`PlatformPriceBookController.java`、`MerchantPriceBookController.java` | | 客户入库结果与头程费用确认 | `controller/MerchantInboundBillingController.java`、`service/MerchantInboundBillingService.java`、`FbaPricingService.java`;查询与确认接口为 `/api/merchant/v1/first-leg/orders/{orderId}/inbound-billing`、`/confirm`,契约 `src/main/resources/openapi/firstleg-v1.yaml` | | 价格簿查询内部切片 | `pricebookquery/application/port/in/PriceBookQueryUseCase.java`、`application/PriceBookQueryService.java`、`application/port/out/PriceBookQueryReader.java`、`adapter/out/persistence/PriceBookQueryMapper.java`;查询行模型位于 `application/model/PriceBookQueryRows.java` | diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformDropshipPalletizationController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformDropshipPalletizationController.java index 1ca74b2fa..59b3c9e17 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformDropshipPalletizationController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformDropshipPalletizationController.java @@ -129,6 +129,18 @@ public class PlatformDropshipPalletizationController { return printService.generateDropshipPallet(pallet, task.inboundCode(), requestId); } + @GetMapping("/tasks/{taskId}/pallets/print") + @PreAuthorize("hasAuthority('platform:firstleg:pda:view')") + public ResponseEntity printTaskPallets(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long taskId, + @RequestParam @Positive long warehouseId, + @RequestHeader(value = "X-Request-Id", required = false) String requestId) { + FirstlegScopeGuard.platform(user); + Task task = service.task(taskId, warehouseId, user.userId()); + return printService.generateDropshipPallets( + service.generatedPallets(taskId, warehouseId, user.userId()), task.inboundCode(), requestId); + } + @GetMapping("/inbound-orders/{inboundId}/pallets") @PreAuthorize("hasAuthority('firstleg:inbound:list')") public ApiResponse> inboundPallets(@AuthenticationPrincipal AuthenticatedUser user, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/DropshipPalletizationMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/DropshipPalletizationMapper.java index 5c5715965..f639861df 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/DropshipPalletizationMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/DropshipPalletizationMapper.java @@ -67,10 +67,11 @@ public interface DropshipPalletizationMapper { PalletRow pallet(@Param("taskId") long taskId,@Param("palletId") long palletId,@Param("warehouseId") long warehouseId); @Select(""" SELECT p.* FROM fl_dropship_pallet p - WHERE p.task_id=#{taskId} - OR EXISTS(SELECT 1 FROM fl_dropship_pallet_carton c WHERE c.pallet_id=p.id AND c.task_id=#{taskId}) + WHERE EXISTS(SELECT 1 FROM fl_dropship_pallet_carton c WHERE c.pallet_id=p.id AND c.task_id=#{taskId}) ORDER BY p.id """) List pallets(@Param("taskId") long taskId); + @Select("SELECT * FROM fl_dropship_pallet WHERE task_id=#{taskId} ORDER BY id") + List generatedPallets(@Param("taskId") long taskId); @Select(""" SELECT p.* FROM fl_dropship_pallet p WHERE ((p.tenant_id=#{tenantId} AND p.inbound_id=#{inboundId}) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/DropshipPalletizationService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/DropshipPalletizationService.java index 224386def..e28433341 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/DropshipPalletizationService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/DropshipPalletizationService.java @@ -156,6 +156,12 @@ public class DropshipPalletizationService { return code; } + public List generatedPallets(long taskId,long warehouseId,long operatorId){ + TaskRow task=requireTask(taskId,warehouseId); + if(task.getAssigneeId()!=null && task.getAssigneeId()!=operatorId) throw conflict("任务已由其他人员领取"); + return mapper.generatedPallets(taskId).stream().map(p->palletView(p,task.getInboundCode())).toList(); + } + private PalletRow requireAssignablePallet(TaskRow task,String palletNo){ PalletRow existing=mapper.palletByNo(palletNo); if(existing==null) throw new BizException(ResultCode.NOT_FOUND,"托盘码不存在,请扫描测量审核后生成的 DP 托盘码"); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundCartonLabelPrintService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundCartonLabelPrintService.java index b142007c3..a2cd51904 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundCartonLabelPrintService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundCartonLabelPrintService.java @@ -117,6 +117,30 @@ public class InboundCartonLabelPrintService { /** 一件代发 A4 托盘信息单:托盘号、Code 128 一维码和 SKU 归托明细。 */ public ResponseEntity generateDropshipPallet(Pallet pallet, String inboundCode, String requestId) { + return generateDropshipPallets(List.of(pallet), inboundCode, requestId); + } + + /** 一票预生成托盘码合并为一个多页 PDF,避免 PDA 展开或逐个下载大量空托盘。 */ + public ResponseEntity generateDropshipPallets(List pallets, String inboundCode, String requestId) { + if (pallets == null || pallets.isEmpty()) { + throw new BizException(ResultCode.CONFLICT, "托盘码尚未生成,不能打印"); + } + List documents = pallets.stream() + .map(pallet -> dropshipPalletDocument(pallet, inboundCode)).toList(); + String fileName = pallets.size() == 1 + ? pallets.get(0).palletNo() + "-A4托盘信息单.pdf" + : inboundCode + "-" + pallets.size() + "托-A4托盘信息单.pdf"; + PrintPdfClient.DropshipPalletA4PdfRequest request = new PrintPdfClient.DropshipPalletA4PdfRequest( + "DROPSHIP_PALLET_A4_V1", new PrintPdfClient.PageSpec(A4_WIDTH_MM, A4_HEIGHT_MM), + fileName, documents); + try { + return requirePdf(printClient.generateDropshipPalletA4Documents(internalToken, requestId, request)); + } catch (FeignException | NoFallbackAvailableException exception) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "打印服务暂不可用,请稍后重试"); + } + } + + private PrintPdfClient.DropshipPalletDocument dropshipPalletDocument(Pallet pallet, String inboundCode) { if (pallet == null || !StringUtils.hasText(pallet.palletNo())) { throw new BizException(ResultCode.CONFLICT, "托盘码尚未生成,不能打印"); } @@ -127,17 +151,9 @@ public class InboundCartonLabelPrintService { .map(item -> new PrintPdfClient.DropshipPalletSkuRow(item.inboundCode(), item.skuCode(), item.cargoName(), number(item.cartonCount()), number(item.skuQuantity()))) .toList(); - PrintPdfClient.DropshipPalletDocument document = new PrintPdfClient.DropshipPalletDocument( + return new PrintPdfClient.DropshipPalletDocument( pallet.palletNo(), inboundCodes, number(pallet.cartonCount()), number(pallet.skuCount()), pallet.weightKg(), pallet.volumeCbm(), pallet.palletNo(), skuRows); - PrintPdfClient.DropshipPalletA4PdfRequest request = new PrintPdfClient.DropshipPalletA4PdfRequest( - "DROPSHIP_PALLET_A4_V1", new PrintPdfClient.PageSpec(A4_WIDTH_MM, A4_HEIGHT_MM), - pallet.palletNo() + "-A4托盘信息单.pdf", List.of(document)); - try { - return requirePdf(printClient.generateDropshipPalletA4Documents(internalToken, requestId, request)); - } catch (FeignException | NoFallbackAvailableException exception) { - throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "打印服务暂不可用,请稍后重试"); - } } private int number(Integer value) { diff --git a/firstleg/src/main/resources/openapi/dashboard-metrics-v1.yaml b/firstleg/src/main/resources/openapi/dashboard-metrics-v1.yaml new file mode 100644 index 000000000..3c7c73c2c --- /dev/null +++ b/firstleg/src/main/resources/openapi/dashboard-metrics-v1.yaml @@ -0,0 +1,64 @@ +openapi: 3.0.3 +info: + title: Platform dashboard operational metrics + version: 1.0.0 +paths: + /api/platform/v1/first-leg/dashboard/operational-metrics: + get: + summary: 查询首页货柜与清关实时指标 + description: >- + 在途柜量按已实际开船且尚未到达海外仓的有效物理柜去重,TEU 按 20*=1、40*=2、45*=2.25 + 折算,未知箱型不计入 TEU;待清关票数按已进入整柜清关且清关未完成的去重订单统计。 + 本接口只返回当前时点事实,不提供历史环比。 + responses: + '200': + description: 查询成功 + content: + application/json: + schema: + $ref: '#/components/schemas/OperationalMetricsEnvelope' + '401': {description: 需要登录} + '403': {description: 需要货柜列表与报关列表权限} +components: + schemas: + OperationalMetricsEnvelope: + type: object + required: [code, message, data] + properties: + code: {type: string, example: '200'} + message: {type: string} + data: {$ref: '#/components/schemas/OperationalMetrics'} + OperationalMetrics: + type: object + required: + - inTransitContainerCount + - inTransitTeu + - unknownContainerTypeCount + - pendingCustomsOrderCount + properties: + inTransitContainerCount: + type: integer + format: int64 + minimum: 0 + description: 已开船未到海外仓的物理柜数 + inTransitTeu: + type: number + format: decimal + multipleOf: 0.01 + minimum: 0 + description: 已知箱型的在途柜 TEU 合计 + unknownContainerTypeCount: + type: integer + format: int64 + minimum: 0 + description: 未计入 TEU 的未知箱型在途柜数 + pendingCustomsOrderCount: + type: integer + format: int64 + minimum: 0 + description: 已进入整柜清关且清关未完成的去重订单数 + asOf: + type: string + format: date-time + nullable: true + description: 数据库统计时点 diff --git a/firstleg/src/main/resources/openapi/dropship-palletization-v1.yaml b/firstleg/src/main/resources/openapi/dropship-palletization-v1.yaml index afedbe05a..267fe9015 100644 --- a/firstleg/src/main/resources/openapi/dropship-palletization-v1.yaml +++ b/firstleg/src/main/resources/openapi/dropship-palletization-v1.yaml @@ -76,6 +76,14 @@ paths: - {$ref: '#/components/parameters/WarehouseId'} responses: '200': {description: A4 托盘信息单 PDF, content: {application/pdf: {schema: {type: string, format: binary}}}} + /api/platform/v1/first-leg/pda-dropship/tasks/{taskId}/pallets/print: + get: + summary: 合并获取本票全部预生成托盘码的多页 A4 PDF + description: 每个预生成托盘固定一页,PDA 无需展开托盘列表或逐个下载,最多按本票预计托数生成。 + x-required-authority: platform:firstleg:pda:view + parameters: [{$ref: '#/components/parameters/TaskId'}, {$ref: '#/components/parameters/WarehouseId'}] + responses: + '200': {description: 多页 A4 托盘信息单 PDF, content: {application/pdf: {schema: {type: string, format: binary}}}} /api/platform/v1/first-leg/pda-dropship/inbound-orders/{inboundId}/pallets: get: summary: Admin 查询入仓单权威托盘结果 diff --git a/firstleg/src/test/java/com/baiyue/firstleg/dashboard/DashboardMetricsContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/dashboard/DashboardMetricsContractTest.java index 2521681d8..e34d835d8 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/dashboard/DashboardMetricsContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/dashboard/DashboardMetricsContractTest.java @@ -1,11 +1,13 @@ package com.baiyue.firstleg.dashboard; import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import java.util.Arrays; +import java.nio.charset.StandardCharsets; import static org.assertj.core.api.Assertions.assertThat; @@ -30,4 +32,12 @@ class DashboardMetricsContractTest { "COUNT(DISTINCT inbound.order_id)", "customs_container.status <> 'COMPLETED'", "item.deleted = 0", "inbound.deleted = 0", "LIKE '20%'", "LIKE '40%'", "LIKE '45%'"); } + + @Test void openApiDocumentsTheCurrentPointInTimeDefinitions() throws Exception { + var openApi = new ClassPathResource("openapi/dashboard-metrics-v1.yaml") + .getContentAsString(StandardCharsets.UTF_8); + assertThat(openApi).contains("/api/platform/v1/first-leg/dashboard/operational-metrics:", + "inTransitContainerCount:", "inTransitTeu:", "unknownContainerTypeCount:", + "pendingCustomsOrderCount:", "本接口只返回当前时点事实,不提供历史环比"); + } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCartonLabelPrintServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCartonLabelPrintServiceTest.java index a73362faa..469493151 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCartonLabelPrintServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCartonLabelPrintServiceTest.java @@ -69,6 +69,31 @@ class InboundCartonLabelPrintServiceTest { }); } + @Test + void multiplePreGeneratedPalletsAreReturnedInOnePdf() { + Pallet first = dropshipPallet("OPEN"); + Pallet second = new Pallet(90002L, "DP-260911-0002", "OPEN", 0, 0, 0, + BigDecimal.ZERO, BigDecimal.ZERO, 1, List.of(), List.of(), null); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_PDF); + when(print.generateDropshipPalletA4Documents(org.mockito.ArgumentMatchers.anyString(), + org.mockito.ArgumentMatchers.eq("request-all"), org.mockito.ArgumentMatchers.any())) + .thenReturn(new ResponseEntity<>(new byte[] {4, 5, 6}, headers, + org.springframework.http.HttpStatus.OK)); + + ResponseEntity response = service.generateDropshipPallets( + List.of(first, second), "PCHK2608040174", "request-all"); + + ArgumentCaptor request = + ArgumentCaptor.forClass(PrintPdfClient.DropshipPalletA4PdfRequest.class); + verify(print).generateDropshipPalletA4Documents(org.mockito.ArgumentMatchers.eq("internal-token"), + org.mockito.ArgumentMatchers.eq("request-all"), request.capture()); + assertThat(request.getValue().documents()).extracting("palletNo") + .containsExactly("DP-260911-0001", "DP-260911-0002"); + assertThat(request.getValue().downloadFileName()).contains("2托"); + assertThat(response.getBody()).containsExactly(4, 5, 6); + } + @Test void forwardsStableLabelsAndOnlySafePdfHeaders() { when(labels.list(10001L, 70001L, List.of(1))).thenReturn(batch());