From 0ea4aeecc99e4fdac072afb1548593fa62e87b85 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 28 Aug 2026 16:48:50 +0800 Subject: [PATCH] feat(firstleg): implement container customs workflow --- config/nacos/cn/file-service.yml | 10 + config/nacos/local/file-service.yml | 10 + deploy/sql/头程数据库统一增量-MySQL8.sql | 390 ++++---------- file/AGENTS.md | 2 + .../InternalBusinessFileController.java | 13 +- .../file/service/BusinessFileCategory.java | 52 ++ .../file/service/BusinessFileStorage.java | 30 +- .../file/service/ExportSchemaContracts.java | 47 ++ .../file/service/ExportTaskService.java | 16 + .../file/service/GenericXlsxExportRunner.java | 4 +- .../service/InternalBusinessFileService.java | 28 +- file/src/main/resources/application.yml | 13 +- .../openapi/customs-file-and-export-v1.yaml | 154 ++++++ .../config/CustomsFileExportContractTest.java | 46 ++ .../InternalBusinessFileControllerTest.java | 12 + .../file/service/BusinessFileStorageTest.java | 57 +++ .../file/service/ExportTaskServiceTest.java | 28 + .../service/GenericXlsxExportRunnerTest.java | 50 ++ .../InternalBusinessFileServiceTest.java | 44 +- firstleg/AGENTS.md | 6 +- .../PlatformLoadingListController.java | 77 --- ...ingListBuyDeclarationExportController.java | 29 ++ .../MerchantContainerCustomsController.java | 101 ++++ .../PlatformContainerCustomsController.java | 95 ++++ .../container/dto/ContainerCustomsDtos.java | 37 ++ .../mapper/ContainerCustomsMapper.java | 337 ++++++++++++ .../service/ContainerCustomsService.java | 409 +++++++++++++++ .../CustomsAutoDispatchCoordinator.java | 38 ++ ...oadingListBuyDeclarationExportService.java | 73 +++ .../controller/MerchantCustomsController.java | 88 ---- .../controller/PlatformCustomsController.java | 161 ------ .../firstleg/customs/dto/CustomsDtos.java | 80 --- .../customs/entity/CustomsCaseEntity.java | 28 - .../entity/CustomsChecklistVersionEntity.java | 21 - .../customs/entity/CustomsDocumentEntity.java | 27 - .../customs/entity/CustomsHistoryEntity.java | 22 - .../entity/CustomsOperationEntity.java | 20 - .../customs/entity/CustomsReceiptEntity.java | 24 - .../entity/CustomsSubmissionBatchEntity.java | 23 - .../CustomsSupplementRequestEntity.java | 23 - .../customs/mapper/CustomsCaseMapper.java | 11 - .../mapper/CustomsChecklistVersionMapper.java | 5 - .../customs/mapper/CustomsDocumentMapper.java | 5 - .../customs/mapper/CustomsHistoryMapper.java | 5 - .../mapper/CustomsOperationMapper.java | 5 - .../customs/mapper/CustomsReceiptMapper.java | 5 - .../mapper/CustomsSubmissionBatchMapper.java | 5 - .../CustomsSupplementRequestMapper.java | 5 - .../customs/repository/CustomsRepository.java | 191 ------- .../CustomsDispatchGateContributor.java | 9 +- .../customs/service/CustomsService.java | 482 ------------------ .../mapper/ContainerBookingMapper.java | 8 +- .../mapper/ContainerOverviewMapper.java | 2 +- .../mapper/OutboundDispatchReadMapper.java | 7 +- .../OutboundDispatchAutoConfirmService.java | 30 ++ .../firstleg/service/LoadingListService.java | 87 +--- .../db/phase3-container-customs-schema.sql | 174 +++++++ .../resources/db/phase3-customs-schema.sql | 242 --------- ...ase3-simple-customs-document-migration.sql | 91 ---- .../db/phase3-smart-loading-schema.sql | 10 +- .../main/resources/openapi/customs-v1.yaml | 238 ++++----- .../resources/openapi/loading-lists-v1.yaml | 114 ----- .../openapi/phase3-container-overview-v1.yaml | 6 +- .../openapi/phase3-outbound-dispatch-v1.yaml | 2 +- .../ContainerControllerContractTest.java | 2 +- .../LoadingListControllerContractTest.java | 55 +- ...ontainerCustomsControllerContractTest.java | 44 ++ .../ContainerCustomsMapperContractTest.java | 24 + .../ContainerCustomsServiceTest.java | 91 ++++ ...ngListBuyDeclarationExportServiceTest.java | 32 ++ .../repository/CustomsSqlContractTest.java | 18 +- .../CustomsDispatchGateContributorTest.java | 29 +- .../customs/service/CustomsServiceTest.java | 168 ------ ...utboundDispatchAutoConfirmServiceTest.java | 25 + ...utboundDispatchReadMapperContractTest.java | 2 +- .../dispatch/OutboundDispatchServiceTest.java | 1 + .../OutboundDispatchSqlContractTest.java | 4 +- .../CarrierScheduleQueryServiceTest.java | 64 ++- .../ContainerOverviewSqlContractTest.java | 4 +- .../LoadingListQueryMapperContractTest.java | 3 +- .../Phase3FinalUnifiedSqlContractTest.java | 24 +- .../SmartLoadingSqlContractTest.java | 25 +- .../service/ContainerOverviewServiceTest.java | 9 +- .../service/LoadingListServiceTest.java | 141 +---- .../phase3-container-customs-permissions.sql | 152 ++++++ ...ainerCustomsPermissionSqlContractTest.java | 70 +++ .../service/OrderInboundOutboxPublisher.java | 1 + .../OrderInboundOutboxPublisherTest.java | 46 +- scripts/admin-test-env-modules/README.md | 1 + ...ainer-customs-acceptance-contract.test.mjs | 68 +++ .../container-customs-acceptance-matrix.mjs | 97 ++++ .../phase3-container-customs.mjs | 255 +++++++++ 92 files changed, 3165 insertions(+), 2754 deletions(-) create mode 100644 file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java create mode 100644 file/src/main/java/com/baiyue/file/service/ExportSchemaContracts.java create mode 100644 file/src/main/resources/openapi/customs-file-and-export-v1.yaml create mode 100644 file/src/test/java/com/baiyue/file/config/CustomsFileExportContractTest.java create mode 100644 file/src/test/java/com/baiyue/file/service/BusinessFileStorageTest.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/customs/container/controller/LoadingListBuyDeclarationExportController.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/customs/container/controller/MerchantContainerCustomsController.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/customs/container/controller/PlatformContainerCustomsController.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/customs/container/dto/ContainerCustomsDtos.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/customs/container/mapper/ContainerCustomsMapper.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/ContainerCustomsService.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/CustomsAutoDispatchCoordinator.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/LoadingListBuyDeclarationExportService.java delete mode 100644 firstleg/src/main/java/com/baiyue/firstleg/customs/controller/MerchantCustomsController.java delete mode 100644 firstleg/src/main/java/com/baiyue/firstleg/customs/controller/PlatformCustomsController.java delete mode 100644 firstleg/src/main/java/com/baiyue/firstleg/customs/dto/CustomsDtos.java delete mode 100644 firstleg/src/main/java/com/baiyue/firstleg/customs/entity/CustomsCaseEntity.java delete mode 100644 firstleg/src/main/java/com/baiyue/firstleg/customs/entity/CustomsChecklistVersionEntity.java delete mode 100644 firstleg/src/main/java/com/baiyue/firstleg/customs/entity/CustomsDocumentEntity.java delete mode 100644 firstleg/src/main/java/com/baiyue/firstleg/customs/entity/CustomsHistoryEntity.java delete mode 100644 firstleg/src/main/java/com/baiyue/firstleg/customs/entity/CustomsOperationEntity.java delete mode 100644 firstleg/src/main/java/com/baiyue/firstleg/customs/entity/CustomsReceiptEntity.java delete mode 100644 firstleg/src/main/java/com/baiyue/firstleg/customs/entity/CustomsSubmissionBatchEntity.java delete mode 100644 firstleg/src/main/java/com/baiyue/firstleg/customs/entity/CustomsSupplementRequestEntity.java delete mode 100644 firstleg/src/main/java/com/baiyue/firstleg/customs/mapper/CustomsCaseMapper.java delete mode 100644 firstleg/src/main/java/com/baiyue/firstleg/customs/mapper/CustomsChecklistVersionMapper.java delete mode 100644 firstleg/src/main/java/com/baiyue/firstleg/customs/mapper/CustomsDocumentMapper.java delete mode 100644 firstleg/src/main/java/com/baiyue/firstleg/customs/mapper/CustomsHistoryMapper.java delete mode 100644 firstleg/src/main/java/com/baiyue/firstleg/customs/mapper/CustomsOperationMapper.java delete mode 100644 firstleg/src/main/java/com/baiyue/firstleg/customs/mapper/CustomsReceiptMapper.java delete mode 100644 firstleg/src/main/java/com/baiyue/firstleg/customs/mapper/CustomsSubmissionBatchMapper.java delete mode 100644 firstleg/src/main/java/com/baiyue/firstleg/customs/mapper/CustomsSupplementRequestMapper.java delete mode 100644 firstleg/src/main/java/com/baiyue/firstleg/customs/repository/CustomsRepository.java delete mode 100644 firstleg/src/main/java/com/baiyue/firstleg/customs/service/CustomsService.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/service/OutboundDispatchAutoConfirmService.java create mode 100644 firstleg/src/main/resources/db/phase3-container-customs-schema.sql delete mode 100644 firstleg/src/main/resources/db/phase3-customs-schema.sql delete mode 100644 firstleg/src/main/resources/db/phase3-simple-customs-document-migration.sql create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsControllerContractTest.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsMapperContractTest.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsServiceTest.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/customs/container/LoadingListBuyDeclarationExportServiceTest.java delete mode 100644 firstleg/src/test/java/com/baiyue/firstleg/customs/service/CustomsServiceTest.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchAutoConfirmServiceTest.java create mode 100644 iam/src/main/resources/db/phase3-container-customs-permissions.sql create mode 100644 iam/src/test/java/com/baiyue/iam/repository/ContainerCustomsPermissionSqlContractTest.java create mode 100644 scripts/admin-test-env-modules/container-customs-acceptance-contract.test.mjs create mode 100644 scripts/admin-test-env-modules/container-customs-acceptance-matrix.mjs create mode 100644 scripts/admin-test-env-modules/phase3-container-customs.mjs diff --git a/config/nacos/cn/file-service.yml b/config/nacos/cn/file-service.yml index 837438c33..40c954665 100644 --- a/config/nacos/cn/file-service.yml +++ b/config/nacos/cn/file-service.yml @@ -22,6 +22,16 @@ baiyue: max-attempts: 3 batch-size: 500 + LOADING_LIST_BUY_DECLARATION_ITEMS: + provider-service: firstleg-service + provider-path: /internal/exports/v1/LOADING_LIST_BUY_DECLARATION_ITEMS/batches + required-permission: platform:firstleg:customs:manage + app-scope: PLATFORM + enabled: true + max-rows: 200000 + max-attempts: 3 + batch-size: 500 + FINANCE_CUSTOMER_ACCOUNT_LIST: provider-service: finance-service provider-path: /internal/exports/v1/FINANCE_CUSTOMER_ACCOUNT_LIST/batches diff --git a/config/nacos/local/file-service.yml b/config/nacos/local/file-service.yml index 62818960e..c913b54f2 100644 --- a/config/nacos/local/file-service.yml +++ b/config/nacos/local/file-service.yml @@ -18,6 +18,16 @@ baiyue: max-attempts: 3 batch-size: 500 + LOADING_LIST_BUY_DECLARATION_ITEMS: + provider-service: firstleg-service + provider-path: /internal/exports/v1/LOADING_LIST_BUY_DECLARATION_ITEMS/batches + required-permission: platform:firstleg:customs:manage + app-scope: PLATFORM + enabled: true + max-rows: 200000 + max-attempts: 3 + batch-size: 500 + FINANCE_CUSTOMER_ACCOUNT_LIST: provider-service: finance-service provider-path: /internal/exports/v1/FINANCE_CUSTOMER_ACCOUNT_LIST/batches diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index fd08047b6..03dc36ecc 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -5114,7 +5114,7 @@ CREATE TABLE IF NOT EXISTS fl_loading_plan_item ( CREATE TABLE IF NOT EXISTS fl_container_document ( id BIGINT UNSIGNED NOT NULL COMMENT '货柜文件关联ID(雪花ID)', container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID', - document_type VARCHAR(32) NOT NULL COMMENT '文件类型:DESTINATION_CLEARANCE目的港清关、CUSTOMS_DECLARATION出口报关、DELIVERY_ORDER提货单、PICKUP_ORDER取货单', + document_type VARCHAR(32) NOT NULL COMMENT '文件类型:DESTINATION_CLEARANCE目的港清关、DELIVERY_ORDER提货单、PICKUP_ORDER取货单', file_id BIGINT UNSIGNED NOT NULL COMMENT 'File服务文件ID,不建立跨库外键', file_name VARCHAR(255) NOT NULL COMMENT '文件名称快照', file_hash VARCHAR(64) NULL COMMENT 'File服务SHA-256快照', @@ -5134,7 +5134,7 @@ CREATE TABLE IF NOT EXISTS fl_container_document ( CONSTRAINT fk_container_document_booking FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking (id), CONSTRAINT chk_container_document_type - CHECK (document_type IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER')) + CHECK (document_type IN ('DESTINATION_CLEARANCE', 'DELIVERY_ORDER', 'PICKUP_ORDER')) ) ENGINE=InnoDB COMMENT='平台货柜清关、DO与PU文件关联'; CREATE TABLE IF NOT EXISTS fl_loading_plan_history ( @@ -5906,7 +5906,7 @@ BEGIN SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan WHERE container_booking_id = NEW.container_booking_id; IF current_plan_status = 'COMPLETED' - AND NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER') THEN + AND NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'DELIVERY_ORDER', 'PICKUP_ORDER') THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜文件不可新增'; END IF; END$$ @@ -5922,7 +5922,7 @@ BEGIN SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan WHERE container_booking_id = OLD.container_booking_id; IF current_plan_status = 'COMPLETED' - AND OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER') THEN + AND OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'DELIVERY_ORDER', 'PICKUP_ORDER') THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜文件不可修改'; END IF; END$$ @@ -9777,173 +9777,25 @@ WHERE role.tenant_id=0 AND role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR') USE baiyue_firstleg; --- Phase 3 customs document, supplementation, broker submission and receipt lifecycle. -USE baiyue_firstleg; - -CREATE TABLE IF NOT EXISTS fl_customs_checklist_version ( - id BIGINT UNSIGNED NOT NULL COMMENT '报关资料清单版本逻辑ID', checklist_code VARCHAR(40) NOT NULL COMMENT '报关资料清单编码;同一清单的多个版本共用', version_no VARCHAR(32) NOT NULL COMMENT '清单业务版本号', - country_code VARCHAR(32) NOT NULL COMMENT '清单适用的目的国家或地区编码', channel_code VARCHAR(40) NOT NULL COMMENT '清单适用的运输渠道编码', cargo_type VARCHAR(40) NOT NULL COMMENT '清单适用的货物类型编码', - items_json JSON NOT NULL COMMENT '不可变清单项目数组;元素包含code项目编码、name项目名称和required是否必填', status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '清单版本状态:ACTIVE启用、RETIRED停用', created_by BIGINT UNSIGNED NOT NULL COMMENT '创建清单版本的平台操作人ID', - created_by_name VARCHAR(64) NULL COMMENT '创建清单版本时的平台操作人名称快照', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '清单版本创建时间', PRIMARY KEY (id), - UNIQUE KEY uk_customs_checklist_version (checklist_code,version_no), - KEY idx_customs_checklist_match (country_code,channel_code,cargo_type,status), - CONSTRAINT chk_customs_checklist_status CHECK (status IN ('ACTIVE','RETIRED')) -) ENGINE=InnoDB COMMENT='三期报关资料清单不可变版本'; - -CREATE TABLE IF NOT EXISTS fl_customs_case ( - id BIGINT UNSIGNED NOT NULL COMMENT '报关协同单逻辑ID', customs_no VARCHAR(40) NOT NULL COMMENT '全局唯一报关协同单号', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', - order_id BIGINT UNSIGNED NOT NULL COMMENT 'Order服务订单逻辑ID;跨库不建立外键', inbound_id BIGINT UNSIGNED NOT NULL COMMENT 'Firstleg入库单逻辑ID', - container_booking_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '关联柜次逻辑ID;0表示创建时尚未关联柜次', checklist_version_id BIGINT UNSIGNED NOT NULL COMMENT '本单冻结使用的报关资料清单版本ID', - checklist_code VARCHAR(40) NOT NULL COMMENT '创建协同单时的清单编码快照;源清单变化不回写', checklist_version_no VARCHAR(32) NOT NULL COMMENT '创建协同单时的清单版本号快照;源清单变化不回写', - status VARCHAR(32) NOT NULL DEFAULT 'DOCUMENTS_PENDING' COMMENT '协同状态:DOCUMENTS_PENDING待补资料、REVIEWING审核中、SUPPLEMENT_REQUIRED需补件、READY_TO_SUBMIT待提交代理、RECEIPT_PENDING待回执、RECEIPT_AVAILABLE已有回执、CLEARANCE_COMPLETED已放行、CANCELLED已取消', remark VARCHAR(500) NULL COMMENT '报关协同单内部业务备注', - created_by BIGINT UNSIGNED NOT NULL COMMENT '创建协同单的平台操作人ID', created_by_name VARCHAR(64) NULL COMMENT '创建协同单时的平台操作人名称快照', - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '协同单创建时间', updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改协同单的平台操作人ID', - updated_by_name VARCHAR(64) NULL COMMENT '最后修改时的平台操作人名称快照', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '协同单最后修改时间', - version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号;每次状态变更递增', PRIMARY KEY (id), UNIQUE KEY uk_customs_no (customs_no), - UNIQUE KEY uk_customs_case_scope (tenant_id,order_id,inbound_id,container_booking_id), - KEY idx_customs_case_status (status,updated_at), KEY idx_customs_case_container (container_booking_id), - CONSTRAINT fk_customs_case_checklist FOREIGN KEY (checklist_version_id) REFERENCES fl_customs_checklist_version(id), - CONSTRAINT chk_customs_case_status CHECK (status IN ('DOCUMENTS_PENDING','REVIEWING','SUPPLEMENT_REQUIRED','READY_TO_SUBMIT','RECEIPT_PENDING','RECEIPT_AVAILABLE','CLEARANCE_COMPLETED','CANCELLED')) -) ENGINE=InnoDB COMMENT='三期订单报关协同聚合'; - -CREATE TABLE IF NOT EXISTS fl_customs_document ( - id BIGINT UNSIGNED NOT NULL COMMENT '报关资料文件版本逻辑ID', customs_case_id BIGINT UNSIGNED NOT NULL COMMENT '所属报关协同单ID', checklist_item_code VARCHAR(40) NOT NULL COMMENT '对应冻结清单中的资料项目编码', - file_id BIGINT UNSIGNED NOT NULL COMMENT 'File服务文件逻辑ID;跨库不建立外键', document_version INT UNSIGNED NOT NULL COMMENT '同一协同单和清单项目下的文件版本序号', replaces_document_id BIGINT UNSIGNED NULL COMMENT '被本版本替换的上一资料版本ID;首个版本为空', - file_name VARCHAR(255) NOT NULL COMMENT '上传或引用时的文件名称快照', file_hash CHAR(64) NOT NULL COMMENT '文件内容SHA-256摘要;用于版本审计和幂等校验', status VARCHAR(16) NOT NULL DEFAULT 'UPLOADED' COMMENT '资料状态:UPLOADED待审核、APPROVED审核通过、RETURNED已退回、SUPERSEDED已被替换、FROZEN已随代理提交批次冻结', - visibility VARCHAR(16) NOT NULL DEFAULT 'CUSTOMER' COMMENT '资料可见范围:PLATFORM仅平台可见、CUSTOMER客户可见', uploaded_by BIGINT UNSIGNED NOT NULL COMMENT '上传或引用资料的操作人ID', - uploaded_by_type VARCHAR(16) NOT NULL COMMENT '上传主体类型:PLATFORM平台操作人、MERCHANT客户操作人', uploaded_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '资料上传或引用时间', - reviewed_by BIGINT UNSIGNED NULL COMMENT '审核资料的平台操作人ID;未审核时为空', reviewed_by_name VARCHAR(64) NULL COMMENT '审核时的平台操作人名称快照', reviewed_at DATETIME(3) NULL COMMENT '资料审核时间', - review_reason VARCHAR(500) NULL COMMENT '审核退回原因或审核备注', PRIMARY KEY (id), - UNIQUE KEY uk_customs_document_version (customs_case_id,checklist_item_code,document_version), - UNIQUE KEY uk_customs_document_file (customs_case_id,file_id), KEY idx_customs_document_case (customs_case_id,status), - CONSTRAINT fk_customs_document_case FOREIGN KEY (customs_case_id) REFERENCES fl_customs_case(id), - CONSTRAINT chk_customs_document_status CHECK (status IN ('UPLOADED','APPROVED','RETURNED','SUPERSEDED','FROZEN')), - CONSTRAINT chk_customs_document_actor CHECK (uploaded_by_type IN ('PLATFORM','MERCHANT')), - CONSTRAINT chk_customs_document_visibility CHECK (visibility IN ('PLATFORM','CUSTOMER')) -) ENGINE=InnoDB COMMENT='报关客户文件版本引用'; - -CREATE TABLE IF NOT EXISTS fl_customs_supplement_request ( - id BIGINT UNSIGNED NOT NULL COMMENT '报关补件请求逻辑ID', customs_case_id BIGINT UNSIGNED NOT NULL COMMENT '所属报关协同单ID', source_document_id BIGINT UNSIGNED NOT NULL COMMENT '触发本次补件的被退回资料版本ID', - resolved_document_id BIGINT UNSIGNED NULL COMMENT '解决本次补件的新资料版本ID;未解决时为空', checklist_item_code VARCHAR(40) NOT NULL COMMENT '需要补充的报关资料项目编码', reason VARCHAR(500) NOT NULL COMMENT '平台审核人员填写的补件原因', - due_at DATETIME(3) NULL COMMENT '期望完成补件时间;未设置时为空', status VARCHAR(16) NOT NULL DEFAULT 'OPEN' COMMENT '补件状态:OPEN待补件、RESOLVED已由新资料解决、CANCELLED已取消', requested_by BIGINT UNSIGNED NOT NULL COMMENT '发起补件的平台操作人ID', - requested_by_name VARCHAR(64) NULL COMMENT '发起补件时的平台操作人名称快照', requested_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '补件请求发起时间', - resolved_by BIGINT UNSIGNED NULL COMMENT '提交解决资料的操作人ID;未解决时为空', resolved_at DATETIME(3) NULL COMMENT '补件请求解决时间', PRIMARY KEY (id), - KEY idx_customs_supplement_case (customs_case_id,status,due_at), - CONSTRAINT fk_customs_supplement_case FOREIGN KEY (customs_case_id) REFERENCES fl_customs_case(id), - CONSTRAINT chk_customs_supplement_status CHECK (status IN ('OPEN','RESOLVED','CANCELLED')) -) ENGINE=InnoDB COMMENT='报关补件请求'; - -CREATE TABLE IF NOT EXISTS fl_customs_submission_batch ( - id BIGINT UNSIGNED NOT NULL COMMENT '报关代理提交批次逻辑ID', customs_case_id BIGINT UNSIGNED NOT NULL COMMENT '所属报关协同单ID', batch_no VARCHAR(40) NOT NULL COMMENT '全局唯一代理提交批次号', - batch_version INT UNSIGNED NOT NULL COMMENT '同一报关协同单内的提交批次版本序号', customs_agent_id BIGINT UNSIGNED NULL COMMENT '本批次报关代理Supplier逻辑ID快照;历史数据无法推导时为空', customs_agent_name VARCHAR(128) NOT NULL COMMENT '本批次接收报关资料的报关代理名称快照', external_reference VARCHAR(128) NULL COMMENT '报关代理或外部系统返回的业务参考号', - file_snapshot_json JSON NOT NULL COMMENT '提交时冻结的已审核资料数组;元素包含documentId、fileId、itemCode、documentVersion和fileHash', status VARCHAR(16) NOT NULL DEFAULT 'SUBMITTED' COMMENT '代理提交状态:SUBMITTED已提交、ACKNOWLEDGED代理已确认、REJECTED代理已拒绝、COMPLETED代理处理完成', note VARCHAR(500) NULL COMMENT '提交报关代理时的平台备注', - submitted_by BIGINT UNSIGNED NOT NULL COMMENT '提交报关代理的平台操作人ID', submitted_by_name VARCHAR(64) NULL COMMENT '提交时的平台操作人名称快照', - submitted_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '提交报关代理时间', PRIMARY KEY (id), - UNIQUE KEY uk_customs_batch_no (batch_no), UNIQUE KEY uk_customs_batch_version (customs_case_id,batch_version), - CONSTRAINT fk_customs_batch_case FOREIGN KEY (customs_case_id) REFERENCES fl_customs_case(id), - CONSTRAINT chk_customs_batch_status CHECK (status IN ('SUBMITTED','ACKNOWLEDGED','REJECTED','COMPLETED')) -) ENGINE=InnoDB COMMENT='报关代理提交批次及冻结文件版本'; - --- 历史代理名称无法安全反推Supplier ID,旧行保持NULL;新写入由服务端校验并持久化。 -DROP PROCEDURE IF EXISTS phase3_customs_supplier_snapshot_add_column; -DELIMITER $$ -CREATE PROCEDURE phase3_customs_supplier_snapshot_add_column() -BEGIN - IF NOT EXISTS (SELECT 1 FROM information_schema.columns - WHERE table_schema=DATABASE() AND table_name='fl_customs_submission_batch' - AND column_name='customs_agent_id') THEN - ALTER TABLE fl_customs_submission_batch - ADD COLUMN customs_agent_id BIGINT UNSIGNED NULL COMMENT '本批次报关代理Supplier逻辑ID快照;历史数据无法推导时为空' AFTER batch_version; - END IF; -END$$ -DELIMITER ; -CALL phase3_customs_supplier_snapshot_add_column(); -DROP PROCEDURE phase3_customs_supplier_snapshot_add_column; - -CREATE TABLE IF NOT EXISTS fl_customs_receipt ( - id BIGINT UNSIGNED NOT NULL COMMENT '报关回执文件版本逻辑ID', customs_case_id BIGINT UNSIGNED NOT NULL COMMENT '所属报关协同单ID', submission_batch_id BIGINT UNSIGNED NOT NULL COMMENT '产生本回执的报关代理提交批次ID', - receipt_type VARCHAR(16) NOT NULL COMMENT '回执类型:RECEIVED已收件、DECLARED已申报、REJECTED已拒绝、INSPECTION查验通知、RELEASED放行回执、OTHER其他回执', file_id BIGINT UNSIGNED NOT NULL COMMENT 'File服务回执文件逻辑ID;跨库不建立外键', receipt_version INT UNSIGNED NOT NULL COMMENT '同一提交批次和回执类型下的版本序号', - replaces_receipt_id BIGINT UNSIGNED NULL COMMENT '被本版本替换的上一回执版本ID;首个版本为空', file_name VARCHAR(255) NOT NULL COMMENT '上传或引用时的回执文件名称快照', file_hash CHAR(64) NOT NULL COMMENT '回执文件内容SHA-256摘要;用于版本审计和幂等校验', - visibility VARCHAR(16) NOT NULL DEFAULT 'PLATFORM' COMMENT '回执可见范围:PLATFORM仅平台可见、CUSTOMER客户可查询下载', note VARCHAR(500) NULL COMMENT '回执业务备注', uploaded_by BIGINT UNSIGNED NOT NULL COMMENT '上传或引用回执的平台操作人ID', - uploaded_by_name VARCHAR(64) NULL COMMENT '上传回执时的平台操作人名称快照', uploaded_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '回执上传或引用时间', PRIMARY KEY (id), - UNIQUE KEY uk_customs_receipt_version (submission_batch_id,receipt_type,receipt_version), - UNIQUE KEY uk_customs_receipt_file (submission_batch_id,file_id), - KEY idx_customs_receipt_case (customs_case_id,visibility,uploaded_at), - CONSTRAINT fk_customs_receipt_case FOREIGN KEY (customs_case_id) REFERENCES fl_customs_case(id), - CONSTRAINT fk_customs_receipt_batch FOREIGN KEY (submission_batch_id) REFERENCES fl_customs_submission_batch(id), - CONSTRAINT chk_customs_receipt_type CHECK (receipt_type IN ('RECEIVED','DECLARED','REJECTED','INSPECTION','RELEASED','OTHER')), - CONSTRAINT chk_customs_receipt_visibility CHECK (visibility IN ('PLATFORM','CUSTOMER')) -) ENGINE=InnoDB COMMENT='报关代理回执版本与客户可见范围'; - -CREATE TABLE IF NOT EXISTS fl_customs_history ( - id BIGINT UNSIGNED NOT NULL COMMENT '报关协同历史逻辑ID', customs_case_id BIGINT UNSIGNED NOT NULL COMMENT '所属报关协同单ID', action VARCHAR(40) NOT NULL COMMENT '业务动作编码,如CREATE、UPLOAD_DOCUMENT、REPLACE_DOCUMENT、REVIEW_DOCUMENT_APPROVE、REVIEW_DOCUMENT_RETURN、SUBMIT_AGENT_BATCH或UPLOAD_RECEIPT', - from_status VARCHAR(32) NULL COMMENT '动作发生前的协同状态;创建动作时为空', to_status VARCHAR(32) NULL COMMENT '动作完成后的协同状态', resource_type VARCHAR(32) NOT NULL COMMENT '动作关联资源类型:CASE协同单、DOCUMENT资料、SUBMISSION_BATCH提交批次或RECEIPT回执', - resource_id BIGINT UNSIGNED NOT NULL COMMENT '动作关联资源逻辑ID', note VARCHAR(500) NULL COMMENT '动作原因、资料项目编码或业务备注快照', operator_id BIGINT UNSIGNED NOT NULL COMMENT '执行业务动作的操作人ID', - operator_name VARCHAR(64) NULL COMMENT '执行动作时的操作人名称快照', operator_type VARCHAR(16) NOT NULL COMMENT '操作主体类型:PLATFORM平台操作人、MERCHANT客户操作人', - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '历史记录创建时间', PRIMARY KEY (id), - KEY idx_customs_history_case (customs_case_id,created_at,id), - CONSTRAINT fk_customs_history_case FOREIGN KEY (customs_case_id) REFERENCES fl_customs_case(id), - CONSTRAINT chk_customs_history_actor CHECK (operator_type IN ('PLATFORM','MERCHANT')) -) ENGINE=InnoDB COMMENT='报关协同稳定历史'; - -CREATE TABLE IF NOT EXISTS fl_customs_operation ( - id BIGINT UNSIGNED NOT NULL COMMENT '报关写命令幂等记录逻辑ID', operator_id BIGINT UNSIGNED NOT NULL COMMENT '发起写命令的操作人ID;与动作和幂等键共同限定重放范围', action VARCHAR(64) NOT NULL COMMENT '幂等业务动作编码,包含平台或客户动作及资料、审核、提交、回执类型', - idempotency_key VARCHAR(128) NOT NULL COMMENT '客户端提供的幂等键', request_hash CHAR(64) NOT NULL COMMENT '规范化完整请求内容的SHA-256摘要;同键不同请求时拒绝', - status VARCHAR(16) NOT NULL DEFAULT 'PROCESSING' COMMENT '幂等处理状态:PROCESSING处理中、SUCCESS处理成功', result_type VARCHAR(24) NULL COMMENT '成功结果资源类型:CHECKLIST、CASE、DOCUMENT、BATCH或RECEIPT', result_id BIGINT UNSIGNED NULL COMMENT '成功结果资源逻辑ID;处理中为空', - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '幂等记录创建时间', completed_at DATETIME(3) NULL COMMENT '写命令成功完成时间;处理中为空', PRIMARY KEY (id), - UNIQUE KEY uk_customs_operation (operator_id,action,idempotency_key), - CONSTRAINT chk_customs_operation_status CHECK (status IN ('PROCESSING','SUCCESS')) -) ENGINE=InnoDB COMMENT='报关协同命令幂等记录'; - +-- Latest container customs permissions; old case/review/receipt actions are removed. USE baiyue_iam; -DROP PROCEDURE IF EXISTS assert_phase3_customs_permission_ids; -DELIMITER $$ -CREATE PROCEDURE assert_phase3_customs_permission_ids() -BEGIN - IF EXISTS ( - SELECT 1 FROM iam_permission - WHERE (id BETWEEN 5102170 AND 5102175 - AND permission_code <> CASE id - WHEN 5102170 THEN 'platform:firstleg:customs:list' - WHEN 5102171 THEN 'platform:firstleg:customs:view' - WHEN 5102172 THEN 'platform:firstleg:customs:manage' - WHEN 5102173 THEN 'platform:firstleg:customs:review' - WHEN 5102174 THEN 'platform:firstleg:customs:submit' - WHEN 5102175 THEN 'platform:firstleg:customs:receipt' - END) - OR (permission_code LIKE 'platform:firstleg:customs:%' - AND (tenant_id <> 0 OR app_code <> 'PLATFORM' OR id NOT BETWEEN 5102170 AND 5102175)) - ) THEN - SIGNAL SQLSTATE '45000' - SET MESSAGE_TEXT = '三期报关权限迁移失败:5102170-5102175 ID 或权限码被其他语义占用'; - END IF; -END$$ -DELIMITER ; -CALL assert_phase3_customs_permission_ids(); -DROP PROCEDURE IF EXISTS assert_phase3_customs_permission_ids; - +DELETE rp FROM iam_role_permission rp WHERE rp.permission_id IN (5102171,5102173,5102174,5102175); +DELETE FROM iam_permission WHERE id IN (5102171,5102173,5102174,5102175) + AND permission_code IN ('platform:firstleg:customs:view','platform:firstleg:customs:review','platform:firstleg:customs:submit','platform:firstleg:customs:receipt'); INSERT INTO iam_permission - (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name, - route_path,http_method,sort_no,status,built_in,deleted) + (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,deleted) VALUES - (5102170,0,'PLATFORM','MENU',5102000,'platform:firstleg:customs:list','报关协同','/first-leg/customs','GET',100,'ACTIVE',1,0), - (5102171,0,'PLATFORM','BUTTON',5102170,'platform:firstleg:customs:view','查看报关协同',NULL,'GET',10,'ACTIVE',1,0), - (5102172,0,'PLATFORM','BUTTON',5102170,'platform:firstleg:customs:manage','维护报关资料',NULL,'POST',20,'ACTIVE',1,0), - (5102173,0,'PLATFORM','BUTTON',5102170,'platform:firstleg:customs:review','审核退回报关资料',NULL,'POST',30,'ACTIVE',1,0), - (5102174,0,'PLATFORM','BUTTON',5102170,'platform:firstleg:customs:submit','提交报关代理',NULL,'POST',40,'ACTIVE',1,0), - (5102175,0,'PLATFORM','BUTTON',5102170,'platform:firstleg:customs:receipt','维护报关回执',NULL,'POST',50,'ACTIVE',1,0) + (5102170,0,'PLATFORM','MENU',5102000,'platform:firstleg:customs:list','整柜报关','/first-leg/customs','GET',100,'ACTIVE',1,0), + (5102172,0,'PLATFORM','BUTTON',5102170,'platform:firstleg:customs:manage','维护整柜报关',NULL,'POST',20,'ACTIVE',1,0) ON DUPLICATE KEY UPDATE tenant_id=0,app_code='PLATFORM',permission_type=VALUES(permission_type), permission_code=VALUES(permission_code),permission_name=VALUES(permission_name),parent_id=VALUES(parent_id), route_path=VALUES(route_path),http_method=VALUES(http_method),sort_no=VALUES(sort_no),status='ACTIVE',built_in=1,deleted=0; - INSERT IGNORE INTO iam_role_permission(role_id,permission_id,tenant_id) SELECT role.id,permission.id,0 FROM iam_role role JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM' - AND permission.permission_code LIKE 'platform:firstleg:customs:%' + AND permission.permission_code IN ('platform:firstleg:customs:list','platform:firstleg:customs:manage') WHERE role.tenant_id=0 AND role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR'); - USE baiyue_firstleg; - -- Phase 3 loading evidence hardening for existing databases. Evidence is append-only: -- multiple active/void records of the same type are allowed; voiding is an audit action. USE baiyue_firstleg; @@ -10140,11 +9992,12 @@ BEGIN IF (SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='baiyue_firstleg' AND table_name IN ( 'fl_outbound_dispatch','fl_outbound_dispatch_history','fl_outbound_dispatch_operation', - 'fl_customs_checklist_version','fl_customs_case','fl_customs_document','fl_customs_supplement_request', - 'fl_customs_submission_batch','fl_customs_receipt','fl_customs_history','fl_customs_operation', + 'fl_customs_container','fl_customs_customer_document','fl_customs_customer_document_inbound', + 'fl_customs_customer_document_version','fl_customs_frozen_document_inbound', + 'fl_customs_completion_document','fl_customs_completion_document_version','fl_customs_container_history','fl_customs_command', 'fl_port_schedule','fl_port_schedule_history','fl_port_schedule_operation', 'fl_preload_loading_plan_link','fl_loading_pick_assignment','fl_loading_pick_review', - 'fl_loading_pick_operation')) <> 18 THEN + 'fl_loading_pick_operation')) <> 19 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:三期出仓、报关或船期表未完整创建'; END IF; IF (SELECT COUNT(*) FROM information_schema.columns @@ -10173,8 +10026,8 @@ BEGIN WHERE table_schema='baiyue_firstleg' AND table_name='fl_outbound_dispatch' AND index_name='uk_outbound_dispatch_owner' AND non_unique=0) <> 3 OR (SELECT COUNT(*) FROM information_schema.statistics - WHERE table_schema='baiyue_firstleg' AND table_name='fl_customs_case' - AND index_name='uk_customs_case_scope' AND non_unique=0) <> 4 + WHERE table_schema='baiyue_firstleg' AND table_name='fl_customs_container' + AND index_name='uk_customs_container_plan' AND non_unique=0) <> 1 OR (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema='baiyue_firstleg' AND table_name='fl_port_schedule' AND index_name='uk_port_schedule_source' AND non_unique=0) <> 3 THEN @@ -10182,9 +10035,9 @@ BEGIN END IF; IF (SELECT COUNT(*) FROM information_schema.referential_constraints WHERE constraint_schema='baiyue_firstleg' AND constraint_name IN ( - 'fk_outbound_dispatch_inbound','fk_outbound_dispatch_container','fk_customs_case_checklist', - 'fk_customs_document_case','fk_customs_supplement_case','fk_customs_batch_case', - 'fk_customs_receipt_case','fk_customs_receipt_batch','fk_customs_history_case', + 'fk_outbound_dispatch_inbound','fk_outbound_dispatch_container','fk_customs_customer_document_container', + 'fk_customs_document_inbound_document','fk_customs_customer_document_version','fk_customs_frozen_document', + 'fk_customs_completion_source','fk_customs_completion_version','fk_customs_container_history', 'fk_port_schedule_booking','fk_port_schedule_container','fk_port_schedule_history', 'fk_port_schedule_operation','fk_preload_loading_inbound','fk_preload_loading_preload', 'fk_preload_loading_plan','fk_loading_pick_assignment_task','fk_loading_pick_review_task', @@ -10202,7 +10055,7 @@ BEGIN IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id=0 AND app_code='PLATFORM' AND id BETWEEN 5102160 AND 5102164 AND permission_code LIKE 'platform:firstleg:outbound-dispatch:%') <> 5 OR (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id=0 AND app_code='PLATFORM' - AND id BETWEEN 5102170 AND 5102175 AND permission_code LIKE 'platform:firstleg:customs:%') <> 6 + AND id IN (5102170,5102172) AND permission_code LIKE 'platform:firstleg:customs:%') <> 2 OR (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id=0 AND app_code='PLATFORM' AND id BETWEEN 5102180 AND 5102183 AND permission_code LIKE 'platform:firstleg:port-schedule:%') <> 3 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:三期出仓、报关或船期权限未完整写入'; @@ -10219,7 +10072,7 @@ BEGIN AND (p.permission_code LIKE 'platform:firstleg:outbound-dispatch:%' OR p.permission_code LIKE 'platform:firstleg:customs:%' OR p.permission_code LIKE 'platform:firstleg:port-schedule:%' - OR p.permission_code LIKE 'platform:firstleg:pda:%')) <> 36 THEN + OR p.permission_code LIKE 'platform:firstleg:pda:%')) <> 28 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:三期出仓、报关或船期角色授权未完整写入'; END IF; END$$ @@ -12229,14 +12082,6 @@ CALL queue_table_comment('baiyue_firstleg', 'fl_container_document', '平台货 CALL queue_table_comment('baiyue_firstleg', 'fl_container_fee_detail', '柜次级人工应收应付费用来源;审核通过且参与结算后同步Finance'); CALL queue_table_comment('baiyue_firstleg', 'fl_customer_charge_rule', '客户头程计费规则版本'); CALL queue_table_comment('baiyue_firstleg', 'fl_customer_dimension_config', '客户固定四箱规尺寸配置'); -CALL queue_table_comment('baiyue_firstleg', 'fl_customs_case', '三期订单报关协同聚合'); -CALL queue_table_comment('baiyue_firstleg', 'fl_customs_checklist_version', '三期报关资料清单不可变版本'); -CALL queue_table_comment('baiyue_firstleg', 'fl_customs_document', '报关客户文件版本引用'); -CALL queue_table_comment('baiyue_firstleg', 'fl_customs_history', '报关协同稳定历史'); -CALL queue_table_comment('baiyue_firstleg', 'fl_customs_operation', '报关协同命令幂等记录'); -CALL queue_table_comment('baiyue_firstleg', 'fl_customs_receipt', '报关代理回执版本与客户可见范围'); -CALL queue_table_comment('baiyue_firstleg', 'fl_customs_submission_batch', '报关代理提交批次及冻结文件版本'); -CALL queue_table_comment('baiyue_firstleg', 'fl_customs_supplement_request', '报关补件请求'); CALL queue_table_comment('baiyue_firstleg', 'fl_fee_item', '平台公共费用项目主数据'); CALL queue_table_comment('baiyue_firstleg', 'fl_fulfillment_task', '头程履约任务表:接收订单事件后形成独立履约任务,管理指派、执行和完成状态'); CALL queue_table_comment('baiyue_firstleg', 'fl_inbound_carton_label', '首次打印前冻结的实际箱号与订单货物明细映射'); @@ -12950,7 +12795,7 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_container_booking', 'warehouse_ CALL queue_column_comment('baiyue_firstleg', 'fl_container_booking', 'warehouse_name', '装柜仓库名称快照'); CALL queue_column_comment('baiyue_firstleg', 'fl_container_document', 'container_booking_id', '货柜预订ID'); CALL queue_column_comment('baiyue_firstleg', 'fl_container_document', 'created_at', '创建时间'); -CALL queue_column_comment('baiyue_firstleg', 'fl_container_document', 'document_type', '文件类型:DESTINATION_CLEARANCE目的港清关、CUSTOMS_DECLARATION出口报关、DELIVERY_ORDER提货单、PICKUP_ORDER取货单'); +CALL queue_column_comment('baiyue_firstleg', 'fl_container_document', 'document_type', '文件类型:DESTINATION_CLEARANCE目的港清关、DELIVERY_ORDER提货单、PICKUP_ORDER取货单'); CALL queue_column_comment('baiyue_firstleg', 'fl_container_document', 'file_id', 'File服务文件ID,不建立跨库外键'); CALL queue_column_comment('baiyue_firstleg', 'fl_container_document', 'file_name', '文件名称快照'); CALL queue_column_comment('baiyue_firstleg', 'fl_container_document', 'file_url', '文件访问地址快照'); @@ -13024,114 +12869,6 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_customer_dimension_config', 'te CALL queue_column_comment('baiyue_firstleg', 'fl_customer_dimension_config', 'updated_at', '更新时间'); CALL queue_column_comment('baiyue_firstleg', 'fl_customer_dimension_config', 'updated_by', '最后修改人ID'); CALL queue_column_comment('baiyue_firstleg', 'fl_customer_dimension_config', 'version', '乐观锁版本号'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_case', 'checklist_code', '创建协同单时的清单编码快照;源清单变化不回写'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_case', 'checklist_version_id', '本单冻结使用的报关资料清单版本ID'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_case', 'checklist_version_no', '创建协同单时的清单版本号快照;源清单变化不回写'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_case', 'container_booking_id', '关联柜次逻辑ID;0表示创建时尚未关联柜次'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_case', 'created_at', '协同单创建时间'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_case', 'created_by', '创建协同单的平台操作人ID'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_case', 'created_by_name', '创建协同单时的平台操作人名称快照'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_case', 'customs_no', '全局唯一报关协同单号'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_case', 'id', '报关协同单逻辑ID'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_case', 'inbound_id', 'Firstleg入库单逻辑ID'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_case', 'order_id', 'Order服务订单逻辑ID;跨库不建立外键'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_case', 'remark', '报关协同单内部业务备注'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_case', 'status', '协同状态:DOCUMENTS_PENDING待补资料、REVIEWING审核中、SUPPLEMENT_REQUIRED需补件、READY_TO_SUBMIT待提交代理、RECEIPT_PENDING待回执、RECEIPT_AVAILABLE已有回执、CLEARANCE_COMPLETED已放行、CANCELLED已取消'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_case', 'tenant_id', '客户ID和数据隔离键'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_case', 'updated_at', '协同单最后修改时间'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_case', 'updated_by', '最后修改协同单的平台操作人ID'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_case', 'updated_by_name', '最后修改时的平台操作人名称快照'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_case', 'version', '乐观锁版本号;每次状态变更递增'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_checklist_version', 'cargo_type', '清单适用的货物类型编码'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_checklist_version', 'channel_code', '清单适用的运输渠道编码'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_checklist_version', 'checklist_code', '报关资料清单编码;同一清单的多个版本共用'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_checklist_version', 'country_code', '清单适用的目的国家或地区编码'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_checklist_version', 'created_at', '清单版本创建时间'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_checklist_version', 'created_by', '创建清单版本的平台操作人ID'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_checklist_version', 'created_by_name', '创建清单版本时的平台操作人名称快照'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_checklist_version', 'id', '报关资料清单版本逻辑ID'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_checklist_version', 'items_json', '不可变清单项目数组;元素包含code项目编码、name项目名称和required是否必填'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_checklist_version', 'status', '清单版本状态:ACTIVE启用、RETIRED停用'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_checklist_version', 'version_no', '清单业务版本号'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_document', 'checklist_item_code', '对应冻结清单中的资料项目编码'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_document', 'customs_case_id', '所属报关协同单ID'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_document', 'document_version', '同一协同单和清单项目下的文件版本序号'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_document', 'file_hash', '文件内容SHA-256摘要;用于版本审计和幂等校验'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_document', 'file_id', 'File服务文件逻辑ID;跨库不建立外键'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_document', 'file_name', '上传或引用时的文件名称快照'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_document', 'id', '报关资料文件版本逻辑ID'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_document', 'replaces_document_id', '被本版本替换的上一资料版本ID;首个版本为空'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_document', 'review_reason', '审核退回原因或审核备注'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_document', 'reviewed_at', '资料审核时间'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_document', 'reviewed_by', '审核资料的平台操作人ID;未审核时为空'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_document', 'reviewed_by_name', '审核时的平台操作人名称快照'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_document', 'status', '资料状态:UPLOADED待审核、APPROVED审核通过、RETURNED已退回、SUPERSEDED已被替换、FROZEN已随代理提交批次冻结'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_document', 'uploaded_at', '资料上传或引用时间'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_document', 'uploaded_by', '上传或引用资料的操作人ID'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_document', 'uploaded_by_type', '上传主体类型:PLATFORM平台操作人、MERCHANT客户操作人'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_document', 'visibility', '资料可见范围:PLATFORM仅平台可见、CUSTOMER客户可见'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_history', 'action', '业务动作编码,如CREATE、UPLOAD_DOCUMENT、REPLACE_DOCUMENT、REVIEW_DOCUMENT_APPROVE、REVIEW_DOCUMENT_RETURN、SUBMIT_AGENT_BATCH或UPLOAD_RECEIPT'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_history', 'created_at', '历史记录创建时间'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_history', 'customs_case_id', '所属报关协同单ID'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_history', 'from_status', '动作发生前的协同状态;创建动作时为空'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_history', 'id', '报关协同历史逻辑ID'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_history', 'note', '动作原因、资料项目编码或业务备注快照'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_history', 'operator_id', '执行业务动作的操作人ID'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_history', 'operator_name', '执行动作时的操作人名称快照'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_history', 'operator_type', '操作主体类型:PLATFORM平台操作人、MERCHANT客户操作人'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_history', 'resource_id', '动作关联资源逻辑ID'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_history', 'resource_type', '动作关联资源类型:CASE协同单、DOCUMENT资料、SUBMISSION_BATCH提交批次或RECEIPT回执'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_history', 'to_status', '动作完成后的协同状态'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_operation', 'action', '幂等业务动作编码,包含平台或客户动作及资料、审核、提交、回执类型'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_operation', 'completed_at', '写命令成功完成时间;处理中为空'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_operation', 'created_at', '幂等记录创建时间'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_operation', 'id', '报关写命令幂等记录逻辑ID'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_operation', 'idempotency_key', '客户端提供的幂等键'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_operation', 'operator_id', '发起写命令的操作人ID;与动作和幂等键共同限定重放范围'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_operation', 'request_hash', '规范化完整请求内容的SHA-256摘要;同键不同请求时拒绝'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_operation', 'result_id', '成功结果资源逻辑ID;处理中为空'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_operation', 'result_type', '成功结果资源类型:CHECKLIST、CASE、DOCUMENT、BATCH或RECEIPT'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_operation', 'status', '幂等处理状态:PROCESSING处理中、SUCCESS处理成功'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_receipt', 'customs_case_id', '所属报关协同单ID'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_receipt', 'file_hash', '回执文件内容SHA-256摘要;用于版本审计和幂等校验'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_receipt', 'file_id', 'File服务回执文件逻辑ID;跨库不建立外键'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_receipt', 'file_name', '上传或引用时的回执文件名称快照'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_receipt', 'id', '报关回执文件版本逻辑ID'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_receipt', 'note', '回执业务备注'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_receipt', 'receipt_type', '回执类型:RECEIVED已收件、DECLARED已申报、REJECTED已拒绝、INSPECTION查验通知、RELEASED放行回执、OTHER其他回执'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_receipt', 'receipt_version', '同一提交批次和回执类型下的版本序号'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_receipt', 'replaces_receipt_id', '被本版本替换的上一回执版本ID;首个版本为空'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_receipt', 'submission_batch_id', '产生本回执的报关代理提交批次ID'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_receipt', 'uploaded_at', '回执上传或引用时间'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_receipt', 'uploaded_by', '上传或引用回执的平台操作人ID'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_receipt', 'uploaded_by_name', '上传回执时的平台操作人名称快照'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_receipt', 'visibility', '回执可见范围:PLATFORM仅平台可见、CUSTOMER客户可查询下载'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_submission_batch', 'batch_no', '全局唯一代理提交批次号'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_submission_batch', 'batch_version', '同一报关协同单内的提交批次版本序号'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_submission_batch', 'customs_agent_name', '本批次接收报关资料的报关代理名称快照'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_submission_batch', 'customs_agent_id', '本批次报关代理Supplier逻辑ID快照;历史数据无法推导时为空'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_submission_batch', 'customs_case_id', '所属报关协同单ID'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_submission_batch', 'external_reference', '报关代理或外部系统返回的业务参考号'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_submission_batch', 'file_snapshot_json', '提交时冻结的已审核资料数组;元素包含documentId、fileId、itemCode、documentVersion和fileHash'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_submission_batch', 'id', '报关代理提交批次逻辑ID'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_submission_batch', 'note', '提交报关代理时的平台备注'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_submission_batch', 'status', '代理提交状态:SUBMITTED已提交、ACKNOWLEDGED代理已确认、REJECTED代理已拒绝、COMPLETED代理处理完成'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_submission_batch', 'submitted_at', '提交报关代理时间'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_submission_batch', 'submitted_by', '提交报关代理的平台操作人ID'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_submission_batch', 'submitted_by_name', '提交时的平台操作人名称快照'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_supplement_request', 'checklist_item_code', '需要补充的报关资料项目编码'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_supplement_request', 'customs_case_id', '所属报关协同单ID'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_supplement_request', 'due_at', '期望完成补件时间;未设置时为空'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_supplement_request', 'id', '报关补件请求逻辑ID'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_supplement_request', 'reason', '平台审核人员填写的补件原因'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_supplement_request', 'requested_at', '补件请求发起时间'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_supplement_request', 'requested_by', '发起补件的平台操作人ID'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_supplement_request', 'requested_by_name', '发起补件时的平台操作人名称快照'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_supplement_request', 'resolved_at', '补件请求解决时间'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_supplement_request', 'resolved_by', '提交解决资料的操作人ID;未解决时为空'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_supplement_request', 'resolved_document_id', '解决本次补件的新资料版本ID;未解决时为空'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_supplement_request', 'source_document_id', '触发本次补件的被退回资料版本ID'); -CALL queue_column_comment('baiyue_firstleg', 'fl_customs_supplement_request', 'status', '补件状态:OPEN待补件、RESOLVED已由新资料解决、CANCELLED已取消'); CALL queue_column_comment('baiyue_firstleg', 'fl_fee_item', 'allocation_fee', '平摊费用是否参与'); CALL queue_column_comment('baiyue_firstleg', 'fl_fee_item', 'category', '费用分类'); CALL queue_column_comment('baiyue_firstleg', 'fl_fee_item', 'created_at', '创建时间'); @@ -15821,7 +15558,7 @@ BEGIN END IF; END$$ DELIMITER ; --- 货柜清关与装柜清单报关采用同一份轻量文件事实,不再要求独立报关 case 状态机。 +-- 货柜目的港清关与DO/PU采用轻量文件事实;出口报关由customs-v1整柜聚合唯一承载。 USE baiyue_firstleg; DROP PROCEDURE IF EXISTS phase3_simple_customs_add_column; DELIMITER $$ @@ -15848,7 +15585,7 @@ SET @ddl = IF(@has_simple_customs_check=0, 'SELECT 1', 'ALTER TABLE fl_container_document DROP CHECK chk_container_document_type'); PREPARE statement FROM @ddl; EXECUTE statement; DEALLOCATE PREPARE statement; ALTER TABLE fl_container_document ADD CONSTRAINT chk_container_document_type - CHECK (document_type IN ('DESTINATION_CLEARANCE','CUSTOMS_DECLARATION','DELIVERY_ORDER','PICKUP_ORDER')); + CHECK (document_type IN ('DESTINATION_CLEARANCE','DELIVERY_ORDER','PICKUP_ORDER')); DROP TRIGGER IF EXISTS trg_container_document_before_insert; DELIMITER $$ @@ -15856,7 +15593,7 @@ CREATE TRIGGER trg_container_document_before_insert BEFORE INSERT ON fl_containe BEGIN DECLARE current_plan_status VARCHAR(24); SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan WHERE container_booking_id=NEW.container_booking_id; - IF current_plan_status='COMPLETED' AND NEW.document_type NOT IN ('DESTINATION_CLEARANCE','CUSTOMS_DECLARATION','DELIVERY_ORDER','PICKUP_ORDER') THEN + IF current_plan_status='COMPLETED' AND NEW.document_type NOT IN ('DESTINATION_CLEARANCE','DELIVERY_ORDER','PICKUP_ORDER') THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='装柜完成后货柜物流文件不可新增'; END IF; END$$ @@ -15867,7 +15604,7 @@ CREATE TRIGGER trg_container_document_before_update BEFORE UPDATE ON fl_containe BEGIN DECLARE current_plan_status VARCHAR(24); SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan WHERE container_booking_id=OLD.container_booking_id; - IF current_plan_status='COMPLETED' AND OLD.document_type NOT IN ('DESTINATION_CLEARANCE','CUSTOMS_DECLARATION','DELIVERY_ORDER','PICKUP_ORDER') THEN + IF current_plan_status='COMPLETED' AND OLD.document_type NOT IN ('DESTINATION_CLEARANCE','DELIVERY_ORDER','PICKUP_ORDER') THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='装柜完成后货柜物流文件不可修改'; END IF; END$$ @@ -16603,6 +16340,77 @@ BEGIN END$$ DELIMITER ; +-- 最新整柜报关基线:装柜完成后收集退税资料,按柜冻结、回传并自动收敛离仓。 +USE baiyue_firstleg; +CREATE TABLE IF NOT EXISTS fl_customs_container( + id BIGINT UNSIGNED NOT NULL,loading_plan_id BIGINT UNSIGNED NOT NULL,container_booking_id BIGINT UNSIGNED NOT NULL, + status VARCHAR(40) NOT NULL,started_by BIGINT UNSIGNED NULL,started_by_name VARCHAR(64) NULL,started_at DATETIME(3) NULL, + completed_by BIGINT UNSIGNED NULL,completed_by_name VARCHAR(64) NULL,completed_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + version INT UNSIGNED NOT NULL DEFAULT 0,PRIMARY KEY(id),UNIQUE KEY uk_customs_container_plan(loading_plan_id), + UNIQUE KEY uk_customs_container_booking(container_booking_id),KEY idx_customs_container_status(status,updated_at), + CONSTRAINT chk_customs_container_status CHECK(status IN('WAITING_CUSTOMER_DOCUMENTS','READY_TO_START','DECLARING','COMPLETED')) +)ENGINE=InnoDB COMMENT='装柜完成后的整柜出口报关聚合'; +CREATE TABLE IF NOT EXISTS fl_customs_customer_document( + id BIGINT UNSIGNED NOT NULL,customs_container_id BIGINT UNSIGNED NOT NULL,tenant_id BIGINT UNSIGNED NOT NULL, + file_id BIGINT UNSIGNED NOT NULL,file_name VARCHAR(255) NOT NULL,file_hash CHAR(64) NOT NULL,content_type VARCHAR(128) NULL, + size_bytes BIGINT UNSIGNED NOT NULL,status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',uploaded_by BIGINT UNSIGNED NOT NULL, + uploaded_at DATETIME(3) NOT NULL,frozen_version INT UNSIGNED NULL,frozen_at DATETIME(3) NULL,deleted_by BIGINT UNSIGNED NULL, + deleted_at DATETIME(3) NULL,version INT UNSIGNED NOT NULL DEFAULT 1,created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),PRIMARY KEY(id), + KEY idx_customs_customer_document_container(customs_container_id,status,tenant_id), + CONSTRAINT fk_customs_customer_document_container FOREIGN KEY(customs_container_id) REFERENCES fl_customs_container(id), + CONSTRAINT chk_customs_customer_document_status CHECK(status IN('ACTIVE','DELETED')), + CONSTRAINT chk_customs_customer_document_freeze CHECK((frozen_at IS NULL AND frozen_version IS NULL)OR(frozen_at IS NOT NULL AND frozen_version IS NOT NULL)) +)ENGINE=InnoDB COMMENT='客户整份退税报送资料逻辑根'; +CREATE TABLE IF NOT EXISTS fl_customs_customer_document_inbound( + document_id BIGINT UNSIGNED NOT NULL,inbound_id BIGINT UNSIGNED NOT NULL,tenant_id BIGINT UNSIGNED NOT NULL, + order_id BIGINT UNSIGNED NOT NULL,inbound_code VARCHAR(40) NOT NULL,created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY(document_id,inbound_id),KEY idx_customs_document_inbound(inbound_id,tenant_id), + CONSTRAINT fk_customs_document_inbound_document FOREIGN KEY(document_id) REFERENCES fl_customs_customer_document(id) +)ENGINE=InnoDB COMMENT='客户报送资料与当前柜退税入仓码多对多关系'; +CREATE TABLE IF NOT EXISTS fl_customs_customer_document_version( + id BIGINT UNSIGNED NOT NULL,document_id BIGINT UNSIGNED NOT NULL,document_version INT UNSIGNED NOT NULL,file_id BIGINT UNSIGNED NOT NULL, + file_name VARCHAR(255) NOT NULL,file_hash CHAR(64) NOT NULL,content_type VARCHAR(128) NULL,size_bytes BIGINT UNSIGNED NOT NULL, + scope_snapshot_json JSON NOT NULL,action VARCHAR(24) NOT NULL,operator_id BIGINT UNSIGNED NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),PRIMARY KEY(id),UNIQUE KEY uk_customs_customer_document_version(document_id,document_version), + CONSTRAINT fk_customs_customer_document_version FOREIGN KEY(document_id) REFERENCES fl_customs_customer_document(id), + CONSTRAINT chk_customs_customer_document_version_action CHECK(action IN('UPLOAD','REPLACE','SCOPE_CHANGE','DELETE')) +)ENGINE=InnoDB COMMENT='客户报送资料文件与覆盖范围不可变版本'; +CREATE TABLE IF NOT EXISTS fl_customs_frozen_document_inbound( + document_id BIGINT UNSIGNED NOT NULL,inbound_id BIGINT UNSIGNED NOT NULL,tenant_id BIGINT UNSIGNED NOT NULL, + order_id BIGINT UNSIGNED NOT NULL,inbound_code VARCHAR(40) NOT NULL,created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY(document_id,inbound_id),CONSTRAINT fk_customs_frozen_document FOREIGN KEY(document_id) REFERENCES fl_customs_customer_document(id) +)ENGINE=InnoDB COMMENT='开始报关时冻结的客户资料覆盖范围'; +CREATE TABLE IF NOT EXISTS fl_customs_completion_document( + id BIGINT UNSIGNED NOT NULL,source_document_id BIGINT UNSIGNED NOT NULL,file_id BIGINT UNSIGNED NOT NULL, + file_name VARCHAR(255) NOT NULL,file_hash CHAR(64) NOT NULL,content_type VARCHAR(128) NULL,size_bytes BIGINT UNSIGNED NOT NULL, + uploaded_by BIGINT UNSIGNED NOT NULL,uploaded_by_name VARCHAR(64) NULL,uploaded_at DATETIME(3) NOT NULL,version INT UNSIGNED NOT NULL DEFAULT 1, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY(id),UNIQUE KEY uk_customs_completion_source(source_document_id), + CONSTRAINT fk_customs_completion_source FOREIGN KEY(source_document_id) REFERENCES fl_customs_customer_document(id) +)ENGINE=InnoDB COMMENT='报关完成文件当前版本'; +CREATE TABLE IF NOT EXISTS fl_customs_completion_document_version( + id BIGINT UNSIGNED NOT NULL,completion_document_id BIGINT UNSIGNED NOT NULL,document_version INT UNSIGNED NOT NULL, + file_id BIGINT UNSIGNED NOT NULL,file_name VARCHAR(255) NOT NULL,file_hash CHAR(64) NOT NULL,content_type VARCHAR(128) NULL, + size_bytes BIGINT UNSIGNED NOT NULL,operator_id BIGINT UNSIGNED NOT NULL,operator_name VARCHAR(64) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),PRIMARY KEY(id), + UNIQUE KEY uk_customs_completion_version(completion_document_id,document_version), + CONSTRAINT fk_customs_completion_version FOREIGN KEY(completion_document_id) REFERENCES fl_customs_completion_document(id) +)ENGINE=InnoDB COMMENT='报关完成文件不可变覆盖版本'; +CREATE TABLE IF NOT EXISTS fl_customs_container_history( + id BIGINT UNSIGNED NOT NULL,customs_container_id BIGINT UNSIGNED NOT NULL,action VARCHAR(40) NOT NULL, + from_status VARCHAR(40) NULL,to_status VARCHAR(40) NULL,remark VARCHAR(500) NULL,operator_id BIGINT UNSIGNED NOT NULL, + operator_name VARCHAR(64) NULL,created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),PRIMARY KEY(id), + KEY idx_customs_container_history(customs_container_id,created_at,id), + CONSTRAINT fk_customs_container_history FOREIGN KEY(customs_container_id) REFERENCES fl_customs_container(id) +)ENGINE=InnoDB COMMENT='整柜报关状态与系统触发历史'; +CREATE TABLE IF NOT EXISTS fl_customs_command( + id BIGINT UNSIGNED NOT NULL,actor_id BIGINT UNSIGNED NOT NULL,action VARCHAR(80) NOT NULL,idempotency_key VARCHAR(128) NOT NULL, + request_hash CHAR(64) NOT NULL,status VARCHAR(16) NOT NULL,result_id BIGINT UNSIGNED NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),completed_at DATETIME(3) NULL,PRIMARY KEY(id), + UNIQUE KEY uk_customs_command(actor_id,action,idempotency_key),CONSTRAINT chk_customs_command_status CHECK(status IN('PROCESSING','SUCCESS')) +)ENGINE=InnoDB COMMENT='整柜报关写命令幂等事实'; CALL baiyue_firstleg.assert_unified_final_schema(); DROP PROCEDURE IF EXISTS baiyue_firstleg.assert_unified_final_schema; SELECT 'PASS' AS migration_status, diff --git a/file/AGENTS.md b/file/AGENTS.md index 1e746a882..e48b1b0a3 100644 --- a/file/AGENTS.md +++ b/file/AGENTS.md @@ -15,6 +15,7 @@ | 平台订单商品图片读取 | `controller/PlatformProductImageController.java` 的 `/api/platform/v1/files/product-images/{tenantId}/{fileId}`、`service/ProductImageService.java`;仅平台 `platform:order:list` 可按明确客户与文件 ID 内联读取,响应 `no-store` | | 装柜与业务文件证据 | `controller/PlatformLoadingEvidenceController.java`、`InternalLoadingEvidenceController.java`、`InternalBusinessFileController.java`、`service/InternalBusinessFileService.java` | | 财务付款凭证、海外卸柜照片 | 通用业务文件入口 `InternalBusinessFileController.java`、`InternalBusinessFileService.java`;业务类型分别为 `FINANCE_VOUCHER`、`OVERSEAS_UNLOAD_PHOTO`,必须按客户与业务 ID 精确校验 | +| 报关源文件、完成文件与买单明细导出 | `src/main/resources/openapi/customs-file-and-export-v1.yaml`;文件类别 `LOADING_LIST_CUSTOMS_SOURCE`、`LOADING_LIST_CUSTOMS_RESULT` 均为客户作用域且最多 50MB,异步导出类型 `LOADING_LIST_BUY_DECLARATION_ITEMS` 由 Firstleg Provider 按固定十三列供数 | | 存储与实体 | `service/LocalQualificationStorage.java`、`MeasurementImageStorage.java`、`entity/FileObjectEntity.java` | | 下游 OCR | `client/WeightOcrClient.java` | | SQL/配置 | `src/main/resources/db/file.sql`、`application.yml` | @@ -27,6 +28,7 @@ - 商品图片平台读取必须同时校验平台身份、`platform:order:list`、客户范围、文件业务类型与正整数 ID;不得提供匿名 URL、跨客户读取或把内部存储路径返回页面。 - 公共导出只接受 `ExportDefinitionRegistry` 明确登记的 `exportType`,由 File 调用所属业务服务的内部 Provider 分批取数并生成文件;业务页面不得直连 Provider。新增或修改导出类型时同步检查业务服务 Provider、File Feign 客户端/响应上限、权限、Admin 下载中心标签与重试映射及契约测试。 - 生产环境显式配置 `FILE_STORAGE_ROOT`;磁盘故障返回 `S0503`,不得包装成业务错误。 +- 报关源文件和完成文件的 `businessId` 固定为 loadingPlanId,File DTO 仍为 `Metadata(fileId, tenantId, businessType, businessId, fileName, sha256, contentType, sizeBytes)`,其中 `businessType` 即 fileCategory;Firstleg 保存业务文件版本并决定用户入口。权限固定为 Platform 上传结果 `platform:firstleg:customs:manage`、下载 `platform:firstleg:loading-list:list`,Client 上传源文件 `merchant:order:edit`、下载自身源文件或获准结果 `merchant:order:view`。结果还必须满足整柜完成和可见性,买单客户是否存在结果入口同样由 Firstleg 判定,File 不复制这些状态。 ## 全量验收导航 diff --git a/file/src/main/java/com/baiyue/file/controller/InternalBusinessFileController.java b/file/src/main/java/com/baiyue/file/controller/InternalBusinessFileController.java index 046b1ac3a..64cac7d87 100644 --- a/file/src/main/java/com/baiyue/file/controller/InternalBusinessFileController.java +++ b/file/src/main/java/com/baiyue/file/controller/InternalBusinessFileController.java @@ -9,6 +9,7 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Positive; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.CacheControl; +import org.springframework.http.ContentDisposition; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; @@ -65,11 +66,17 @@ public class InternalBusinessFileController { check(token); checkTenantId(tenantId); var value = service.download(tenantId, businessType, businessId, fileId); - String name = value.fileName().replace("\r", "").replace("\n", "").replace("\"", ""); + MediaType contentType; + try { + contentType = MediaType.parseMediaType(value.contentType()); + } catch (IllegalArgumentException exception) { + contentType = MediaType.APPLICATION_OCTET_STREAM; + } return ResponseEntity.ok() .cacheControl(CacheControl.noStore()) - .contentType(MediaType.parseMediaType(value.contentType())) - .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + name + "\"") + .contentType(contentType) + .header(HttpHeaders.CONTENT_DISPOSITION, + ContentDisposition.attachment().filename(value.fileName(), StandardCharsets.UTF_8).build().toString()) .contentLength(value.content().length) .body(value.content()); } diff --git a/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java b/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java new file mode 100644 index 000000000..e10b1b043 --- /dev/null +++ b/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java @@ -0,0 +1,52 @@ +package com.baiyue.file.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; + +import java.util.Arrays; + +/** File-owned storage policy. Business visibility and lifecycle remain in the calling service. */ +enum BusinessFileCategory { + TRUCK_APPOINTMENT_ATTACHMENT(false, false), + CUSTOMS_DOCUMENT(false, false), + CUSTOMS_RECEIPT(false, false), + FINANCE_VOUCHER(false, false), + CONTAINER_CLEARANCE_DOCUMENT(false, false), + LOADING_CUSTOMS_DECLARATION(false, false), + CONTAINER_DELIVERY_ORDER(false, false), + CONTAINER_PICKUP_ORDER(false, false), + OVERSEAS_UNLOAD_PHOTO(true, false), + OVERSEAS_RELABEL_PHOTO(true, false), + LOADING_LIST_CUSTOMS_SOURCE(true, true), + LOADING_LIST_CUSTOMS_RESULT(true, true); + + static final long STANDARD_MAX_SIZE = 10L * 1024 * 1024; + static final long CUSTOMS_MAX_SIZE = 50L * 1024 * 1024; + + private final boolean customerOwned; + private final boolean arbitraryFormat; + + BusinessFileCategory(boolean customerOwned, boolean arbitraryFormat) { + this.customerOwned = customerOwned; + this.arbitraryFormat = arbitraryFormat; + } + + static BusinessFileCategory require(String value) { + return Arrays.stream(values()).filter(item -> item.name().equals(value)).findFirst() + .orElseThrow(() -> new BizException(ResultCode.BAD_REQUEST, "不支持的附件业务类型")); + } + + void validateOwner(long tenantId) { + if (customerOwned && tenantId <= 0) { + throw new BizException(ResultCode.BAD_REQUEST, "该附件类型必须归属明确客户"); + } + } + + long maxSizeBytes() { + return arbitraryFormat ? CUSTOMS_MAX_SIZE : STANDARD_MAX_SIZE; + } + + boolean arbitraryFormat() { + return arbitraryFormat; + } +} diff --git a/file/src/main/java/com/baiyue/file/service/BusinessFileStorage.java b/file/src/main/java/com/baiyue/file/service/BusinessFileStorage.java index 5087c9ae9..4327ca591 100644 --- a/file/src/main/java/com/baiyue/file/service/BusinessFileStorage.java +++ b/file/src/main/java/com/baiyue/file/service/BusinessFileStorage.java @@ -4,6 +4,7 @@ import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import jakarta.annotation.PostConstruct; import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.MediaType; import org.springframework.stereotype.Component; import org.springframework.web.multipart.MultipartFile; @@ -21,7 +22,6 @@ import java.util.UUID; @Component public class BusinessFileStorage { - private static final long MAX_SIZE = 10L * 1024 * 1024; private static final Map TYPES = Map.of( "text/plain", ".txt", "application/pdf", ".pdf", "image/jpeg", ".jpg", "image/png", ".png"); @@ -35,13 +35,13 @@ public class BusinessFileStorage { root = configuredRoot.toAbsolutePath().normalize(); } - Stored storeLoading(long tenantId, long businessId, MultipartFile file) { + Stored store(long tenantId, long businessId, MultipartFile file, long maxSize, boolean arbitraryFormat) { if (file == null || file.isEmpty()) throw bad("附件不能为空"); - if (file.getSize() > MAX_SIZE) throw bad("附件不能超过10MB"); - String type = file.getContentType() == null ? "" : file.getContentType().toLowerCase(Locale.ROOT); - String extension = TYPES.get(type); - if (extension == null) throw bad("附件仅支持TXT、PDF、JPG、PNG"); - verifyContent(file, type); + if (file.getSize() > maxSize) throw bad("附件不能超过" + maxSize / 1024 / 1024 + "MB"); + String type = safeContentType(file.getContentType()); + String extension = arbitraryFormat ? "" : TYPES.get(type); + if (!arbitraryFormat && extension == null) throw bad("附件仅支持TXT、PDF、JPG、PNG"); + if (!arbitraryFormat) verifyContent(file, type); String name = safeName(file); String objectKey = "business-files/" + tenantId + "/" + businessId + "/" + UUID.randomUUID() + extension; Path target = root.resolve(objectKey).normalize(); @@ -55,6 +55,15 @@ public class BusinessFileStorage { } } + private static String safeContentType(String contentType) { + if (contentType == null || contentType.isBlank()) return MediaType.APPLICATION_OCTET_STREAM_VALUE; + try { + return MediaType.parseMediaType(contentType).toString().toLowerCase(Locale.ROOT); + } catch (IllegalArgumentException exception) { + return MediaType.APPLICATION_OCTET_STREAM_VALUE; + } + } + byte[] read(String objectKey) { Path target = root.resolve(objectKey).normalize(); if (!target.startsWith(root)) throw new BizException(ResultCode.FORBIDDEN, "文件路径越界"); @@ -91,7 +100,12 @@ public class BusinessFileStorage { private static String safeName(MultipartFile file) { String name = file.getOriginalFilename(); - return name == null || name.isBlank() ? "attachment" : Path.of(name).getFileName().toString(); + if (name == null || name.isBlank()) return "attachment"; + String normalized = name.replace('\\', '/'); + normalized = normalized.substring(normalized.lastIndexOf('/') + 1) + .replaceAll("[\\p{Cntrl}]", "").trim(); + if (normalized.isBlank()) return "attachment"; + return normalized.length() <= 255 ? normalized : normalized.substring(normalized.length() - 255); } private static String sha256(Path path) throws IOException { diff --git a/file/src/main/java/com/baiyue/file/service/ExportSchemaContracts.java b/file/src/main/java/com/baiyue/file/service/ExportSchemaContracts.java new file mode 100644 index 000000000..9b0dded1a --- /dev/null +++ b/file/src/main/java/com/baiyue/file/service/ExportSchemaContracts.java @@ -0,0 +1,47 @@ +package com.baiyue.file.service; + +import com.baiyue.common.core.export.ExportContracts.ExportColumn; +import com.baiyue.common.core.export.ExportContracts.ExportColumnType; + +import java.util.List; + +final class ExportSchemaContracts { + static final String BUY_DECLARATION_ITEMS = "LOADING_LIST_BUY_DECLARATION_ITEMS"; + static final List BUY_DECLARATION_COLUMNS = List.of( + column("customerName", "客户名称", ExportColumnType.STRING), + column("orderNo", "订单号", ExportColumnType.STRING), + column("inboundCode", "入仓码", ExportColumnType.STRING), + column("sku", "SKU", ExportColumnType.STRING), + column("chineseName", "中文品名", ExportColumnType.STRING), + column("englishName", "英文品名", ExportColumnType.STRING), + column("cartonCount", "箱数", ExportColumnType.INTEGER), + column("productQuantity", "产品数量", ExportColumnType.INTEGER), + column("grossWeight", "毛重", ExportColumnType.DECIMAL), + column("netWeight", "净重", ExportColumnType.DECIMAL), + column("volume", "体积", ExportColumnType.DECIMAL), + column("mark", "唛头", ExportColumnType.STRING), + column("customerRemark", "客户备注", ExportColumnType.STRING)); + + private ExportSchemaContracts() { } + + static void validate(String exportType, List columns) { + if (!BUY_DECLARATION_ITEMS.equals(exportType)) return; + if (columns.size() != BUY_DECLARATION_COLUMNS.size()) { + throw ExportFailure.permanent("买单报关导出列定义不匹配"); + } + for (int index = 0; index < columns.size(); index++) { + ExportColumn actual = columns.get(index); + ExpectedColumn expected = BUY_DECLARATION_COLUMNS.get(index); + if (!expected.key.equals(actual.key()) || !expected.title.equals(actual.title()) + || expected.type != actual.type()) { + throw ExportFailure.permanent("买单报关导出列定义不匹配"); + } + } + } + + private static ExpectedColumn column(String key, String title, ExportColumnType type) { + return new ExpectedColumn(key, title, type); + } + + record ExpectedColumn(String key, String title, ExportColumnType type) { } +} diff --git a/file/src/main/java/com/baiyue/file/service/ExportTaskService.java b/file/src/main/java/com/baiyue/file/service/ExportTaskService.java index e8b144ab1..59fc84765 100644 --- a/file/src/main/java/com/baiyue/file/service/ExportTaskService.java +++ b/file/src/main/java/com/baiyue/file/service/ExportTaskService.java @@ -45,6 +45,7 @@ public class ExportTaskService { throw new BizException(ResultCode.FORBIDDEN, "缺少该导出类型的操作权限"); } validateParameters(request.parameters()); + validateTypeParameters(type, request.parameters()); String parametersJson = parametersJson(request.parameters()); long id = IdWorker.getId(); LocalDateTime now = LocalDateTime.now(); @@ -176,6 +177,21 @@ public class ExportTaskService { throw new BizException(ResultCode.VALIDATION_FAILED, "导出筛选值类型无效"); } + private void validateTypeParameters(String type, Map parameters) { + if (!ExportSchemaContracts.BUY_DECLARATION_ITEMS.equals(type)) return; + Map values = parameters == null ? Map.of() : parameters; + if (!values.keySet().equals(java.util.Set.of("loadingPlanId"))) { + throw new BizException(ResultCode.VALIDATION_FAILED, "买单报关导出仅接受 loadingPlanId"); + } + Object raw = values.get("loadingPlanId"); + try { + if (!(raw instanceof String text) || !text.matches("[1-9][0-9]*") + || Long.parseLong(text) <= 0) throw new NumberFormatException(); + } catch (NumberFormatException exception) { + throw new BizException(ResultCode.VALIDATION_FAILED, "loadingPlanId 必须为正整数字符串"); + } + } + private ExportTaskResponse response(FileExportTask task) { return new ExportTaskResponse(String.valueOf(task.getId()), task.getTaskNo(), task.getExportType(), task.getFormat(), task.getTaskName(), task.getStatus(), task.getTotalRows(), diff --git a/file/src/main/java/com/baiyue/file/service/GenericXlsxExportRunner.java b/file/src/main/java/com/baiyue/file/service/GenericXlsxExportRunner.java index e65f575fb..8be548608 100644 --- a/file/src/main/java/com/baiyue/file/service/GenericXlsxExportRunner.java +++ b/file/src/main/java/com/baiyue/file/service/GenericXlsxExportRunner.java @@ -88,7 +88,9 @@ public class GenericXlsxExportRunner { if (batch.schemaVersion() != 1 || blank(batch.schemaHash()) || blank(batch.snapshotToken()) || batch.snapshotToken().length() > 512) { throw ExportFailure.permanent("Provider 首批契约无效"); } - columns = validateColumns(batch.columns()); columnKeys = columns.stream().map(ExportColumn::key).collect(java.util.stream.Collectors.toUnmodifiableSet()); schemaHash = batch.schemaHash(); snapshot = batch.snapshotToken(); + columns = validateColumns(batch.columns()); + ExportSchemaContracts.validate(task.getExportType(), columns); + columnKeys = columns.stream().map(ExportColumn::key).collect(java.util.stream.Collectors.toUnmodifiableSet()); schemaHash = batch.schemaHash(); snapshot = batch.snapshotToken(); sheet = newSheet(workbook, 1, columns); } else if (!schemaHash.equals(batch.schemaHash())) { throw ExportFailure.permanent("Provider 导出列定义在执行期间发生变化"); diff --git a/file/src/main/java/com/baiyue/file/service/InternalBusinessFileService.java b/file/src/main/java/com/baiyue/file/service/InternalBusinessFileService.java index 45ec8d2bd..bd8499dd5 100644 --- a/file/src/main/java/com/baiyue/file/service/InternalBusinessFileService.java +++ b/file/src/main/java/com/baiyue/file/service/InternalBusinessFileService.java @@ -10,22 +10,8 @@ import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; import java.time.LocalDateTime; -import java.util.Set; - @Service public class InternalBusinessFileService { - private static final Set BUSINESS_TYPES = Set.of( - "TRUCK_APPOINTMENT_ATTACHMENT", - "CUSTOMS_DOCUMENT", - "CUSTOMS_RECEIPT", - "FINANCE_VOUCHER", - "CONTAINER_CLEARANCE_DOCUMENT", - "LOADING_CUSTOMS_DECLARATION", - "CONTAINER_DELIVERY_ORDER", - "CONTAINER_PICKUP_ORDER", - "OVERSEAS_UNLOAD_PHOTO", - "OVERSEAS_RELABEL_PHOTO"); - @Resource private FileObjectMapper files; @Resource private BusinessFileStorage storage; @@ -34,9 +20,10 @@ public class InternalBusinessFileService { if (tenantId < 0 || operatorId <= 0) { throw new BizException(ResultCode.BAD_REQUEST, "归属和操作人必须有效"); } - validateBusinessType(businessType); + BusinessFileCategory category = BusinessFileCategory.require(businessType); + category.validateOwner(tenantId); long parsedBusinessId = parseBusinessId(businessId); - var stored = storage.storeLoading(tenantId, parsedBusinessId, file); + var stored = storage.store(tenantId, parsedBusinessId, file, category.maxSizeBytes(), category.arbitraryFormat()); FileObjectEntity value = new FileObjectEntity(); value.setTenantId(tenantId); value.setBusinessType(businessType); @@ -89,16 +76,11 @@ public class InternalBusinessFileService { if (tenantId < 0 || fileId <= 0) { throw new BizException(ResultCode.BAD_REQUEST, "归属和文件ID必须有效"); } - validateBusinessType(businessType); + BusinessFileCategory category = BusinessFileCategory.require(businessType); + category.validateOwner(tenantId); parseBusinessId(businessId); } - private static void validateBusinessType(String businessType) { - if (!BUSINESS_TYPES.contains(businessType)) { - throw new BizException(ResultCode.BAD_REQUEST, "不支持的附件业务类型"); - } - } - private static long parseBusinessId(String businessId) { final long value; try { diff --git a/file/src/main/resources/application.yml b/file/src/main/resources/application.yml index d677f54ed..c609212f6 100644 --- a/file/src/main/resources/application.yml +++ b/file/src/main/resources/application.yml @@ -40,8 +40,8 @@ spring: read-timeout: 15000 servlet: multipart: - max-file-size: 10MB - max-request-size: 11MB + max-file-size: 50MB + max-request-size: 51MB rocketmq: name-server: ${ROCKETMQ_NAME_SERVER:127.0.0.1:9876} producer: @@ -101,6 +101,15 @@ baiyue: max-rows: 200000 max-attempts: 3 batch-size: 500 + LOADING_LIST_BUY_DECLARATION_ITEMS: + provider-service: firstleg-service + provider-path: /internal/exports/v1/LOADING_LIST_BUY_DECLARATION_ITEMS/batches + required-permission: platform:firstleg:customs:manage + app-scope: PLATFORM + enabled: true + max-rows: 200000 + max-attempts: 3 + batch-size: 500 FINANCE_CUSTOMER_ACCOUNT_LIST: provider-service: finance-service provider-path: /internal/exports/v1/FINANCE_CUSTOMER_ACCOUNT_LIST/batches diff --git a/file/src/main/resources/openapi/customs-file-and-export-v1.yaml b/file/src/main/resources/openapi/customs-file-and-export-v1.yaml new file mode 100644 index 000000000..4f594b233 --- /dev/null +++ b/file/src/main/resources/openapi/customs-file-and-export-v1.yaml @@ -0,0 +1,154 @@ +openapi: 3.0.3 +info: + title: File Customs Business File and Export Contract + version: 1.0.0 + description: | + File 只保存不可变文件对象并生成异步 XLSX,不保存报关状态或业务版本。 + Firstleg 持有 loadingPlanId、文件版本、完成状态和可见性,并在调用本内部接口前完成 Platform/Client 鉴权。 + LOADING_LIST_CUSTOMS_SOURCE 与 LOADING_LIST_CUSTOMS_RESULT 均为 CUSTOMER owner scope,tenantId 必须大于 0; + 客户源文件上传/下载由 Firstleg 校验当前客户,结果文件下载还须由 Firstleg 校验整柜完成和结果可见性。 + 用户侧权限冻结为:Platform 上传结果 platform:firstleg:customs:manage,Platform 下载 platform:firstleg:loading-list:list; + Client 上传源文件 merchant:order:edit,Client 下载自身源文件或获准结果 merchant:order:view。 + 买单客户是否有结果入口由 Firstleg 判定,File 不据此增加结果文件或下载入口。 +paths: + /internal/file/v1/business-files/{tenantId}/{fileCategory}/{loadingPlanId}: + post: + summary: 保存报关业务文件对象 + description: | + 仅服务令牌可调用。fileCategory 取 LOADING_LIST_CUSTOMS_SOURCE 或 LOADING_LIST_CUSTOMS_RESULT。 + 支持任意文件格式,单文件最多 50MB。operatorId 为已通过 Firstleg 鉴权的实际操作人。 + parameters: + - $ref: '#/components/parameters/InternalToken' + - $ref: '#/components/parameters/OperatorId' + - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/FileCategory' + - $ref: '#/components/parameters/LoadingPlanId' + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [file] + properties: + file: {type: string, format: binary} + responses: + '200': + description: ApiResponse + content: + application/json: + schema: {$ref: '#/components/schemas/BusinessFileResponse'} + /internal/file/v1/business-files/{tenantId}/{fileCategory}/{loadingPlanId}/{fileId}: + get: + summary: 校验报关文件对象的精确复合归属 + parameters: + - $ref: '#/components/parameters/InternalToken' + - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/FileCategory' + - $ref: '#/components/parameters/LoadingPlanId' + - $ref: '#/components/parameters/FileId' + responses: + '200': + description: ApiResponse + content: + application/json: + schema: {$ref: '#/components/schemas/BusinessFileResponse'} + /internal/file/v1/business-files/{tenantId}/{fileCategory}/{loadingPlanId}/{fileId}/content: + get: + summary: 下载精确归属的报关文件对象 + description: 响应固定为 attachment 且 Cache-Control 包含 no-store;用户可见性由 Firstleg 在调用前判定。 + parameters: + - $ref: '#/components/parameters/InternalToken' + - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/FileCategory' + - $ref: '#/components/parameters/LoadingPlanId' + - $ref: '#/components/parameters/FileId' + responses: + '200': + description: 文件内容 + headers: + Cache-Control: {schema: {type: string, example: no-store}} + Content-Disposition: {schema: {type: string, example: attachment}} + content: + application/octet-stream: + schema: {type: string, format: binary} + /internal/exports/v1/LOADING_LIST_BUY_DECLARATION_ITEMS/batches: + post: + summary: Firstleg 买单报关明细导出 Provider 契约 + description: | + 此路径由 Firstleg 提供、File 调用。一柜一份;parameters 只能包含正整数 loadingPlanId。 + File 创建该导出任务的唯一 requiredPermission 为 platform:firstleg:customs:manage。 + Provider 必须按 schemaVersion=1 返回下列固定顺序列,File 校验后生成 XLSX。 + parameters: + - $ref: '#/components/parameters/InternalToken' + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/ExportBatchRequest'} + responses: + '200': + description: ApiResponse +components: + parameters: + InternalToken: {name: X-Internal-Service-Token, in: header, required: true, schema: {type: string}} + OperatorId: {name: X-Operator-Id, in: header, required: true, schema: {type: integer, format: int64, minimum: 1}} + TenantId: {name: tenantId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}, description: 客户ID;不得使用平台0作用域} + FileCategory: {name: fileCategory, in: path, required: true, schema: {type: string, enum: [LOADING_LIST_CUSTOMS_SOURCE, LOADING_LIST_CUSTOMS_RESULT]}} + LoadingPlanId: {name: loadingPlanId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}} + FileId: {name: fileId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}} + schemas: + BusinessFileResponse: + type: object + required: [code, data] + properties: + code: {type: string, example: '200'} + message: {type: string} + data: {$ref: '#/components/schemas/BusinessFileMetadata'} + BusinessFileMetadata: + type: object + required: [fileId, tenantId, businessType, businessId, fileName, sha256, contentType, sizeBytes] + properties: + fileId: {type: string, description: File不可变对象ID,跨前端按字符串传输} + tenantId: {type: string, description: CUSTOMER owner scope客户ID} + businessType: {type: string, description: fileCategory} + businessId: {type: string, description: loadingPlanId} + fileName: {type: string} + sha256: {type: string} + contentType: {type: string} + sizeBytes: {type: integer, format: int64, maximum: 52428800} + ExportBatchRequest: + type: object + required: [taskId, tenantId, appCode, requestedBy, dispatchVersion, parameters, limit] + properties: + taskId: {type: string} + tenantId: {type: string, enum: ['0']} + appCode: {type: string, enum: [PLATFORM]} + requestedBy: {type: string} + dispatchVersion: {type: integer} + parameters: + type: object + required: [loadingPlanId] + additionalProperties: false + properties: + loadingPlanId: {type: string, pattern: '^[1-9][0-9]*$'} + cursor: {type: string, nullable: true} + snapshotToken: {type: string, nullable: true} + limit: {type: integer, minimum: 50, maximum: 1000} + BuyDeclarationColumns: + type: object + description: 固定列顺序 + properties: + customerName: {type: string, title: 客户名称} + orderNo: {type: string, title: 订单号} + inboundCode: {type: string, title: 入仓码} + sku: {type: string, title: SKU} + chineseName: {type: string, title: 中文品名} + englishName: {type: string, title: 英文品名} + cartonCount: {type: integer, title: 箱数} + productQuantity: {type: integer, title: 产品数量} + grossWeight: {type: number, title: 毛重} + netWeight: {type: number, title: 净重} + volume: {type: number, title: 体积} + mark: {type: string, title: 唛头} + customerRemark: {type: string, title: 客户备注} diff --git a/file/src/test/java/com/baiyue/file/config/CustomsFileExportContractTest.java b/file/src/test/java/com/baiyue/file/config/CustomsFileExportContractTest.java new file mode 100644 index 000000000..474a3d208 --- /dev/null +++ b/file/src/test/java/com/baiyue/file/config/CustomsFileExportContractTest.java @@ -0,0 +1,46 @@ +package com.baiyue.file.config; + +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class CustomsFileExportContractTest { + private static final String TYPE = "LOADING_LIST_BUY_DECLARATION_ITEMS"; + + @Test void applicationAndNacosRegisterTheSameFirstlegProviderContract() throws Exception { + for (String file : new String[]{ + "file/src/main/resources/application.yml", + "config/nacos/local/file-service.yml", + "config/nacos/cn/file-service.yml"}) { + String source = Files.readString(repositoryRoot().resolve(file)); + assertThat(source).contains(TYPE + ":") + .contains("provider-service: firstleg-service") + .contains("provider-path: /internal/exports/v1/" + TYPE + "/batches") + .contains("required-permission: platform:firstleg:customs:manage") + .contains("app-scope: PLATFORM") + .doesNotContain("loading-list:export"); + } + } + + @Test void openApiFreezesOwnerScopeSizeVisibilityAndColumns() throws Exception { + String source = Files.readString(repositoryRoot() + .resolve("file/src/main/resources/openapi/customs-file-and-export-v1.yaml")); + + assertThat(source).contains("LOADING_LIST_CUSTOMS_SOURCE", "LOADING_LIST_CUSTOMS_RESULT") + .contains("maximum: 52428800", "no-store", "CUSTOMER owner scope", "loadingPlanId") + .contains("platform:firstleg:customs:manage", "platform:firstleg:loading-list:list", + "merchant:order:edit", "merchant:order:view") + .contains("customerName", "orderNo", "inboundCode", "sku", "chineseName", "englishName", + "cartonCount", "productQuantity", "grossWeight", "netWeight", "volume", "mark", + "customerRemark") + .doesNotContain("productNameZh", "productNameEn", "shippingMark", "loading-list:export"); + } + + private static Path repositoryRoot() { + Path current = Path.of("").toAbsolutePath(); + return Files.isDirectory(current.resolve("file")) ? current : current.getParent(); + } +} diff --git a/file/src/test/java/com/baiyue/file/controller/InternalBusinessFileControllerTest.java b/file/src/test/java/com/baiyue/file/controller/InternalBusinessFileControllerTest.java index 04819c77e..799ee0857 100644 --- a/file/src/test/java/com/baiyue/file/controller/InternalBusinessFileControllerTest.java +++ b/file/src/test/java/com/baiyue/file/controller/InternalBusinessFileControllerTest.java @@ -45,6 +45,18 @@ class InternalBusinessFileControllerTest { verify(service).download(0L, "LOADING_CUSTOMS_DECLARATION", "51", 91L); } + @Test void arbitraryResultDownloadUsesAttachmentAndFallsBackToBinaryContentType() { + when(service.download(10001L, "LOADING_LIST_CUSTOMS_RESULT", "51", 92L)) + .thenReturn(new InternalBusinessFileService.Download("完成文件.unknown", "invalid content type", new byte[]{3})); + + var response = controller.download("internal-token", 10001L, + "LOADING_LIST_CUSTOMS_RESULT", "51", 92L); + + assertThat(response.getHeaders().getCacheControl()).contains("no-store"); + assertThat(response.getHeaders().getContentDisposition().getType()).isEqualTo("attachment"); + assertThat(response.getHeaders().getContentType()).isEqualTo(org.springframework.http.MediaType.APPLICATION_OCTET_STREAM); + } + @Test void rejectsInvalidTokenAndNegativeTenantBeforeServiceAccess() { var file = new MockMultipartFile("file", "x.txt", "text/plain", "x".getBytes()); assertThatThrownBy(() -> controller.upload("wrong", 7L, 0L, diff --git a/file/src/test/java/com/baiyue/file/service/BusinessFileStorageTest.java b/file/src/test/java/com/baiyue/file/service/BusinessFileStorageTest.java new file mode 100644 index 000000000..6b612a8dc --- /dev/null +++ b/file/src/test/java/com/baiyue/file/service/BusinessFileStorageTest.java @@ -0,0 +1,57 @@ +package com.baiyue.file.service; + +import com.baiyue.common.core.exception.BizException; +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 org.springframework.web.multipart.MultipartFile; + +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class BusinessFileStorageTest { + @TempDir Path root; + + @Test void arbitraryCustomsFormatIsStoredAsAttachmentObject() { + BusinessFileStorage storage = storage(); + var file = new MockMultipartFile("file", "folder/manifest.custom", "application/x-custom", new byte[]{1, 2, 3}); + + var stored = storage.store(10001L, 51L, file, BusinessFileCategory.CUSTOMS_MAX_SIZE, true); + + assertThat(stored.originalName()).isEqualTo("manifest.custom"); + assertThat(stored.contentType()).isEqualTo("application/x-custom"); + assertThat(storage.read(stored.objectKey())).containsExactly(1, 2, 3); + } + + @Test void customsFileAboveFiftyMegabytesIsRejectedBeforeRead() { + BusinessFileStorage storage = storage(); + MultipartFile file = mock(MultipartFile.class); + when(file.isEmpty()).thenReturn(false); + when(file.getSize()).thenReturn(BusinessFileCategory.CUSTOMS_MAX_SIZE + 1); + + assertThatThrownBy(() -> storage.store(10001L, 51L, file, + BusinessFileCategory.CUSTOMS_MAX_SIZE, true)) + .isInstanceOf(BizException.class).hasMessageContaining("50MB"); + } + + @Test void legacyBusinessFilesRetainRestrictedFormatsAndTenMegabyteLimit() { + BusinessFileStorage storage = storage(); + var file = new MockMultipartFile("file", "archive.zip", "application/zip", new byte[]{1}); + + assertThatThrownBy(() -> storage.store(10001L, 51L, file, + BusinessFileCategory.STANDARD_MAX_SIZE, false)) + .isInstanceOf(BizException.class).hasMessageContaining("仅支持"); + } + + private BusinessFileStorage storage() { + BusinessFileStorage storage = new BusinessFileStorage(); + ReflectionTestUtils.setField(storage, "configuredRoot", root); + storage.initializeRoot(); + return storage; + } +} diff --git a/file/src/test/java/com/baiyue/file/service/ExportTaskServiceTest.java b/file/src/test/java/com/baiyue/file/service/ExportTaskServiceTest.java index 7abc756f3..3e81e1be6 100644 --- a/file/src/test/java/com/baiyue/file/service/ExportTaskServiceTest.java +++ b/file/src/test/java/com/baiyue/file/service/ExportTaskServiceTest.java @@ -84,6 +84,34 @@ class ExportTaskServiceTest { .getResultCode()); } + @Test + void buyDeclarationExportRequiresOnePositiveLoadingPlanId() { + var definition = definition(); + definition.setProviderService("firstleg-service"); + definition.setProviderPath("/internal/exports/v1/LOADING_LIST_BUY_DECLARATION_ITEMS/batches"); + definition.setRequiredPermission("platform:firstleg:customs:manage"); + when(registry.require("LOADING_LIST_BUY_DECLARATION_ITEMS")).thenReturn(definition); + when(states.createConstrained(any(FileExportTask.class))).thenAnswer(invocation -> + new ExportStateService.CreationResult(invocation.getArgument(0), true)); + var user = user(1L, Set.of("platform:firstleg:customs:manage")); + + var response = service.create(user, new CreateExportTaskRequest( + "LOADING_LIST_BUY_DECLARATION_ITEMS", "XLSX", "买单报关明细", Map.of("loadingPlanId", "51"))); + + assertEquals("LOADING_LIST_BUY_DECLARATION_ITEMS", response.exportType()); + assertEquals(ResultCode.VALIDATION_FAILED, assertThrows(BizException.class, () -> service.create(user, + new CreateExportTaskRequest("LOADING_LIST_BUY_DECLARATION_ITEMS", "XLSX", "买单报关明细", Map.of()))) + .getResultCode()); + assertEquals(ResultCode.VALIDATION_FAILED, assertThrows(BizException.class, () -> service.create(user, + new CreateExportTaskRequest("LOADING_LIST_BUY_DECLARATION_ITEMS", "XLSX", "买单报关明细", + Map.of("loadingPlanId", 0)))) + .getResultCode()); + assertEquals(ResultCode.VALIDATION_FAILED, assertThrows(BizException.class, () -> service.create(user, + new CreateExportTaskRequest("LOADING_LIST_BUY_DECLARATION_ITEMS", "XLSX", "买单报关明细", + Map.of("loadingPlanId", 51L)))) + .getResultCode()); + } + private AuthenticatedUser user(long id, Set permissions) { return new AuthenticatedUser(id, 0L, 0L, id, "user" + id, "PLATFORM", "WEB", permissions); } diff --git a/file/src/test/java/com/baiyue/file/service/GenericXlsxExportRunnerTest.java b/file/src/test/java/com/baiyue/file/service/GenericXlsxExportRunnerTest.java index 46c32cc53..0e551932f 100644 --- a/file/src/test/java/com/baiyue/file/service/GenericXlsxExportRunnerTest.java +++ b/file/src/test/java/com/baiyue/file/service/GenericXlsxExportRunnerTest.java @@ -86,6 +86,56 @@ class GenericXlsxExportRunnerTest { } } + @Test void buyDeclarationProviderMustReturnTheFrozenThirteenColumnsInOrder() { + List invalid = ExportSchemaContracts.BUY_DECLARATION_COLUMNS.stream() + .map(column -> new ExportColumn(column.key(), column.title(), column.type(), 18)).toList(); + invalid = new java.util.ArrayList<>(invalid); + invalid.set(4, new ExportColumn("productNameZh", "中文品名", ExportColumnType.STRING, 18)); + var batch = new ExportBatchResponse(1, "sha256:buy-declaration", "snapshot-51", + invalid, List.of(), null, false, 0L); + when(provider.batch(anyString(), eq(ExportSchemaContracts.BUY_DECLARATION_ITEMS), any(), any())) + .thenReturn(ApiResponse.success(batch)); + FileExportTask task = new FileExportTask(); task.setId(51L); task.setTenantId(0L); + task.setAppCode("PLATFORM"); task.setRequestedBy(7L); task.setDispatchVersion(1); + task.setExportType(ExportSchemaContracts.BUY_DECLARATION_ITEMS); task.setTaskName("买单报关明细"); + task.setParametersJson("{\"loadingPlanId\":\"51\"}"); + var definition = new ExportDefinitionRegistry.Definition(); definition.setMaxRows(200000); definition.setBatchSize(500); + + assertThrows(ExportFailure.class, + () -> runner.run(task, definition, new AtomicBoolean(), ignored -> { })); + } + + @Test void frozenBuyDeclarationProviderColumnsGenerateWorkbookDirectly() throws Exception { + List columns = ExportSchemaContracts.BUY_DECLARATION_COLUMNS.stream() + .map(column -> new ExportColumn(column.key(), column.title(), column.type(), 18)).toList(); + Map row = new java.util.LinkedHashMap<>(); + row.put("customerName", "客户A"); row.put("orderNo", "O-1"); row.put("inboundCode", "IN-1"); + row.put("sku", "SKU-1"); row.put("chineseName", "中文品名"); row.put("englishName", "English name"); + row.put("cartonCount", 2); row.put("productQuantity", 20); row.put("grossWeight", "12.5"); + row.put("netWeight", "11.5"); row.put("volume", "1.25"); row.put("mark", "MARK-1"); + row.put("customerRemark", "备注"); + var batch = new ExportBatchResponse(1, "sha256:buy-declaration", "snapshot-51", + columns, List.of(row), null, false, 1L); + when(provider.batch(anyString(), eq(ExportSchemaContracts.BUY_DECLARATION_ITEMS), any(), any())) + .thenReturn(ApiResponse.success(batch)); + FileExportTask task = new FileExportTask(); task.setId(52L); task.setTenantId(0L); + task.setAppCode("PLATFORM"); task.setRequestedBy(7L); task.setDispatchVersion(1); + task.setExportType(ExportSchemaContracts.BUY_DECLARATION_ITEMS); task.setTaskName("买单报关明细"); + task.setParametersJson("{\"loadingPlanId\":\"51\"}"); + var definition = new ExportDefinitionRegistry.Definition(); definition.setMaxRows(200000); definition.setBatchSize(500); + + var result = runner.run(task, definition, new AtomicBoolean(), ignored -> { }); + + assertEquals(1L, result.rows()); + try (var workbook = new XSSFWorkbook(storage.load(result.objectKey()).getInputStream())) { + var sheet = workbook.getSheetAt(0); + assertEquals("客户名称", sheet.getRow(0).getCell(0).getStringCellValue()); + assertEquals("客户A", sheet.getRow(1).getCell(0).getStringCellValue()); + assertEquals("中文品名", sheet.getRow(0).getCell(4).getStringCellValue()); + assertEquals("唛头", sheet.getRow(0).getCell(11).getStringCellValue()); + } + } + @Configuration static class TestConfig { @Bean ObjectMapper objectMapper() { return new ObjectMapper(); } diff --git a/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java b/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java index b5a5100e1..82a1e3822 100644 --- a/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java +++ b/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java @@ -21,7 +21,7 @@ class InternalBusinessFileServiceTest { @Test void customsDocumentUploadPersistsExactBusinessOwnership() { MockMultipartFile file = new MockMultipartFile("file", "invoice.txt", "text/plain", "invoice".getBytes()); - when(storage.storeLoading(10001L, 41L, file)).thenReturn(new BusinessFileStorage.Stored( + when(storage.store(10001L, 41L, file, BusinessFileCategory.STANDARD_MAX_SIZE, false)).thenReturn(new BusinessFileStorage.Stored( "business-files/10001/41/key.txt", "text/plain", 7, "a".repeat(64), "invoice.txt")); when(files.insert(any(FileObjectEntity.class))).thenAnswer(invocation -> { ((FileObjectEntity) invocation.getArgument(0)).setId(71L); return 1; @@ -37,10 +37,10 @@ class InternalBusinessFileServiceTest { @Test void platformContainerAndLoadingDocumentsSupportZeroTenantScope() { MockMultipartFile file = new MockMultipartFile("file", "customs.pdf", "application/pdf", "%PDF-1.4".getBytes()); - when(storage.storeLoading(eq(0L), anyLong(), same(file))).thenAnswer(invocation -> + when(storage.store(eq(0L), anyLong(), same(file), eq(BusinessFileCategory.STANDARD_MAX_SIZE), eq(false))).thenAnswer(invocation -> new BusinessFileStorage.Stored("business-files/0/" + invocation.getArgument(1) + "/customs.pdf", "application/pdf", file.getSize(), "b".repeat(64), "customs.pdf")); - when(storage.storeLoading(11L, 51L, file)).thenReturn(new BusinessFileStorage.Stored( + when(storage.store(11L, 51L, file, BusinessFileCategory.STANDARD_MAX_SIZE, false)).thenReturn(new BusinessFileStorage.Stored( "business-files/11/51/unload.jpg", "image/jpeg", file.getSize(), "c".repeat(64), "unload.jpg")); when(files.insert(any(FileObjectEntity.class))).thenAnswer(invocation -> { ((FileObjectEntity) invocation.getArgument(0)).setId(81L); return 1; @@ -64,9 +64,9 @@ class InternalBusinessFileServiceTest { InternalBusinessFileService.Metadata::businessType, InternalBusinessFileService.Metadata::businessId) .containsExactly(11L, "OVERSEAS_UNLOAD_PHOTO", "51"); - verify(storage, times(3)).storeLoading(0L, 41L, file); - verify(storage).storeLoading(0L, 51L, file); - verify(storage).storeLoading(11L, 51L, file); + verify(storage, times(3)).store(0L, 41L, file, BusinessFileCategory.STANDARD_MAX_SIZE, false); + verify(storage).store(0L, 51L, file, BusinessFileCategory.STANDARD_MAX_SIZE, false); + verify(storage).store(11L, 51L, file, BusinessFileCategory.STANDARD_MAX_SIZE, false); } @Test void rejectsNegativeTenantAndInvalidOwnershipInputs() { @@ -82,7 +82,7 @@ class InternalBusinessFileServiceTest { @Test void overseasRelabelPhotoPersistsExactTaskOwnership() { MockMultipartFile file = new MockMultipartFile("file", "relabel.jpg", "image/jpeg", "photo".getBytes()); - when(storage.storeLoading(100L, 81L, file)).thenReturn(new BusinessFileStorage.Stored( + when(storage.store(100L, 81L, file, BusinessFileCategory.STANDARD_MAX_SIZE, false)).thenReturn(new BusinessFileStorage.Stored( "business-files/100/81/relabel.jpg", "image/jpeg", file.getSize(), "d".repeat(64), "relabel.jpg")); when(files.insert(any(FileObjectEntity.class))).thenAnswer(invocation -> { ((FileObjectEntity) invocation.getArgument(0)).setId(91L); return 1; @@ -96,6 +96,36 @@ class InternalBusinessFileServiceTest { .containsExactly(100L, "OVERSEAS_RELABEL_PHOTO", "81"); } + @Test void loadingCustomsSourceAndResultAreCustomerOwnedArbitraryFormatObjects() { + MockMultipartFile file = new MockMultipartFile("file", "资料包.zip", "application/zip", "zip".getBytes()); + when(storage.store(10001L, 51L, file, BusinessFileCategory.CUSTOMS_MAX_SIZE, true)) + .thenReturn(new BusinessFileStorage.Stored("business-files/10001/51/key", "application/zip", + file.getSize(), "e".repeat(64), "资料包.zip")); + when(files.insert(any(FileObjectEntity.class))).thenAnswer(invocation -> { + ((FileObjectEntity) invocation.getArgument(0)).setId(101L); return 1; + }); + + var source = service.upload(10001L, "LOADING_LIST_CUSTOMS_SOURCE", "51", 7L, file); + var result = service.upload(10001L, "LOADING_LIST_CUSTOMS_RESULT", "51", 7L, file); + + assertThat(source).extracting(InternalBusinessFileService.Metadata::tenantId, + InternalBusinessFileService.Metadata::businessType, + InternalBusinessFileService.Metadata::businessId) + .containsExactly(10001L, "LOADING_LIST_CUSTOMS_SOURCE", "51"); + assertThat(result.businessType()).isEqualTo("LOADING_LIST_CUSTOMS_RESULT"); + verify(storage, times(2)).store(10001L, 51L, file, BusinessFileCategory.CUSTOMS_MAX_SIZE, true); + } + + @Test void loadingCustomsCategoriesRejectPlatformOwnerScope() { + var file = new MockMultipartFile("file", "source.bin", "application/octet-stream", new byte[]{1}); + + assertThatThrownBy(() -> service.upload(0L, "LOADING_LIST_CUSTOMS_SOURCE", "51", 7L, file)) + .isInstanceOf(BizException.class).hasMessageContaining("明确客户"); + assertThatThrownBy(() -> service.metadata(0L, "LOADING_LIST_CUSTOMS_RESULT", "51", 91L)) + .isInstanceOf(BizException.class).hasMessageContaining("明确客户"); + verifyNoInteractions(files, storage); + } + @Test void unsupportedBusinessTypeFailsClosed() { assertThatThrownBy(() -> service.upload(10001L, "ARBITRARY", "41", 7L, new MockMultipartFile("file", "a.txt", "text/plain", "x".getBytes()))) diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index a6ff072d0..379508328 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -38,7 +38,8 @@ | 船节点跨区交接事件 | `portschedule/service/CarrierScheduleNodeOutboxService.java`;实际节点落库后发布 `FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED` V1,复用 `LoadingCompletedOutboxRepository`/Relay 投 Event Bridge;海外消费见 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java` | | Firstleg 业务单据编号 | `service/FirstlegDocumentNumberGenerator.java`;当前支持 `OB`、`TA`、`CB`、`CAB`、`LP` 前缀,按北京时间每日独立编号;具体业务归属以各调用服务为准 | | 拖车与整柜出仓调度 | `controller/PlatformTruckAppointmentController.java`、`outbound/dispatch/controller/PlatformOutboundDispatchController.java`;按真实 `containerBookingId` 查询聚合和 `gate`、确认离仓,契约 `src/main/resources/openapi/phase3-outbound-dispatch-v1.yaml` | -| 报关协同 | `customs/controller/PlatformCustomsController.java`、`MerchantCustomsController.java` | +| 整柜出口报关 | `customs/container/controller/PlatformContainerCustomsController.java`、`MerchantContainerCustomsController.java`、`service/ContainerCustomsService.java`、`mapper/ContainerCustomsMapper.java`;装柜完成后按柜收集退税资料,多对多覆盖入仓码,开始报关冻结,完成文件逐资料一对一回传;契约 `src/main/resources/openapi/customs-v1.yaml`,基线 `src/main/resources/db/phase3-container-customs-schema.sql` | +| 买单订单明细导出 Provider | `customs/container/controller/LoadingListBuyDeclarationExportController.java`、`service/LoadingListBuyDeclarationExportService.java`;只为 File 导出中心提供 `LOADING_LIST_BUY_DECLARATION_ITEMS` 的已完成装柜实际买单行,不在 Firstleg 生成文件 | | 跨模型桥接 | `loading/bridge/controller/PlatformLoadingModelBridgeController.java` | | 跨服务客户端 | `client/OrderCargoClient.java`、`WarehousePalletLocationClient.java`、`MeasurementImageClient.java`、`LoadingEvidenceFileClient.java`、`EventBridgeClient.java` | | 仓库公共供应商快照 | `client/WarehouseSupplierClient.java`、`service/WarehouseSupplierSnapshotService.java`;报关、订舱和拖车分别消费 `customs-v1.yaml`、`ocean-bookings-v1.yaml`、`truck-appointments-v1.yaml` 中的供应商 ID,并由 Warehouse 返回服务端名称快照 | @@ -59,7 +60,8 @@ - 订舱号统一使用 `OB-yyMMdd-####`,按北京时间创建日每日独立递增且单日最多 9999;不得以实体 ID 拼接生成。放舱不要求真实柜号,也不得为满足放舱校验伪造柜号。 - 订舱列表 `containerCount` 固定为该订舱全部柜型需求 `quantity` 之和,与真实柜关联/解除无关;关联候选只返回 `RELEASED`、剩余数量大于 0 且 `warehouseId + channelCode + containerType` 精确匹配的需求,由服务端集合查询 ACTIVE 分配量。前端不得扇出详情计算或用已关联柜数覆盖该字段,最终关联命令仍须重验目标货柜可修改性。 - 四期船节点任务从确认柜子离开国内仓库后启用,必须绑定真实订舱和柜次;十个标准节点、预计/实际时间、异常事实、供应商缺失节点和还柜停止规则统一以 `../../docs/四期/船节点标准化契约.md` 为准。`CarrierScheduleQuery` 的正常业务查询槽位固定为 `Asia/Shanghai` 每日 09:00、12:30、16:00;每分钟任务只扫描已到期 `next_allowed_at`,不是每分钟请求船司。成功、无变化、需人工或永久失败进入下一固定槽位;仅技术性 `RETRYABLE_FAILURE` 在当前槽内按15/30分钟退避,含首次最多3次且不得跨下一槽,实际 `EMPTY_RETURNED` 后停止。错过的正常槽位直接顺延,不在服务恢复时补跑;单轮按受控批次持续领取并受最大任务数保护。租约、调度尝试和内部停止入口不得暴露为平台页面 API,外部失败不得推进业务节点,不得在三期另建查询任务。 -- 国内出仓确认以真实 `containerBookingId` 聚合全部入仓绑定并锁定校验;装柜证据按物理货柜只计算一次,国内出口报关资料必须存在且已完成,拖车状态和独立船期只作参考。全部门禁在同一事务通过后推进所有技术行、记录一条可见历史并幂等创建 `CarrierScheduleQuery/DEPARTED_WAREHOUSE`,不允许单入仓局部成功。 +- 国内出口报关在装柜完成后启用,不再阻塞 `LOADED`。整柜 `fl_customs_container.status=COMPLETED` 才通过离仓报关门禁;完成后以系统身份、确定性幂等键立即尝试确认国内离仓,其他门禁未齐时由受控定时补偿重试,禁止部分入仓成功或重复创建离仓/船司查询事实。 +- 国内出仓确认以真实 `containerBookingId` 聚合全部入仓绑定并锁定校验;装柜证据按物理货柜只计算一次,拖车状态和独立船期只作参考。全部门禁在同一事务通过后推进所有技术行、记录一条可见历史并幂等创建 `CarrierScheduleQuery/DEPARTED_WAREHOUSE`,不允许单入仓局部成功。 - 手工船节点的预计时间只追加事实,不推进 `latestStandardNode`;携带 `actualAt` 时标准节点序号必须严格大于最新实际节点,重复、相同或回退节点返回冲突。已审核实际时间修正只能更新同一事实的时间/证据,不得制造第二条同节点实际事实或绕过单调推进。 - 海外卸柜完成事件只接受 `OVERSEAS -> CN` 的 `OVERSEAS_CONTAINER_UNLOADED` V1,并通过内部服务令牌进入。消费者必须校验信封、Payload 哈希、来源货柜和时间顺序,以 event ID + Payload 哈希幂等,使用事件聚合版本阻止乱序覆盖;预计报空时间固定按卸柜照片上传时间加两天计算,实际报空时间可为空。 - Firstleg 只拥有业务费用及履约事实;Finance 拥有费用入池、账期、账单、收款、成本分摊结果和财务状态。修改费用来源版本、发布条件、柜次归属或体积快照时同步检查 `../finance/src/main/java/com/baiyue/finance/client/FirstlegFeeSourceClient.java`、同目录 `FirstlegCostSnapshotClient.java` 及双方契约测试,不在 Firstleg 复制财务状态机。 diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java index 803be7cda..af4927d6f 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java @@ -12,23 +12,13 @@ import com.baiyue.firstleg.dto.LoadingListDtos.PreloadSummary; import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmPreloadRequest; import com.baiyue.firstleg.dto.LoadingListDtos.PreloadConfirmationResponse; import com.baiyue.firstleg.dto.LoadingListDtos.TransportUpdateRequest; -import com.baiyue.firstleg.dto.LoadingListDtos.BusinessDocument; -import com.baiyue.firstleg.dto.LoadingListDtos.DocumentAttachRequest; -import com.baiyue.firstleg.dto.LoadingListDtos.DocumentCompleteRequest; import com.baiyue.firstleg.service.FirstlegScopeGuard; import com.baiyue.firstleg.service.LoadingListService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.annotation.Resource; import jakarta.validation.Valid; -import jakarta.validation.constraints.Min; import jakarta.validation.constraints.Positive; -import jakarta.validation.constraints.Size; -import org.springframework.http.MediaType; -import org.springframework.http.CacheControl; -import org.springframework.http.ContentDisposition; -import org.springframework.http.HttpHeaders; -import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.validation.annotation.Validated; @@ -41,10 +31,6 @@ import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.bind.annotation.RequestPart; -import org.springframework.web.multipart.MultipartFile; - -import java.nio.charset.StandardCharsets; /** 基于同一装柜计划聚合提供平台预装清单和已确认装柜清单。 */ @Tag(name = "平台预装与装柜清单", description = "预装单向推进和已确认装柜清单只读查询") @@ -120,67 +106,4 @@ public class PlatformLoadingListController { return ApiResponse.success(service.confirmedDetail(planId, warehouseId)); } - @Operation(summary = "查询装柜清单出口报关文档") - @GetMapping("/loading-lists/{planId}/customs") - @PreAuthorize("hasAuthority('platform:firstleg:loading-list:list')") - public ApiResponse declaration( - @AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long planId, - @RequestParam @Positive long warehouseId) { - FirstlegScopeGuard.platform(user); - return ApiResponse.success(service.loadingDeclaration(planId, warehouseId)); - } - - @Operation(summary = "下载装柜清单出口报关附件", description = "完成态仍可下载;服务端校验装柜计划、仓库、当前附件及 File 业务归属。") - @GetMapping("/loading-lists/{planId}/customs/document/{fileId}/download") - @PreAuthorize("hasAuthority('platform:firstleg:loading-list:list')") - public ResponseEntity downloadDeclaration( - @AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long planId, - @PathVariable @Positive long fileId, @RequestParam @Positive long warehouseId) { - FirstlegScopeGuard.platform(user); - var download = service.downloadLoadingDeclaration(planId, warehouseId, fileId); - return ResponseEntity.ok().cacheControl(CacheControl.noStore()) - .header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment() - .filename(download.fileName(), StandardCharsets.UTF_8).build().toString()) - .contentType(download.contentType()).contentLength(download.content().length) - .body(download.content()); - } - - @Operation(summary = "上传或替换装柜清单出口报关文档") - @PostMapping("/loading-lists/{planId}/customs/document") - @PreAuthorize("hasAuthority('platform:firstleg:customs:manage')") - public ApiResponse attachDeclaration( - @AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long planId, - @RequestParam @Positive long warehouseId, @RequestHeader("Idempotency-Key") String key, - @Valid @RequestBody DocumentAttachRequest request) { - FirstlegScopeGuard.platform(user); - return ApiResponse.success(service.attachLoadingDeclaration( - planId, warehouseId, user.userId(), user.username(), key, request)); - } - - @Operation(summary = "直接上传或替换装柜清单出口报关文档") - @PostMapping(value = "/loading-lists/{planId}/customs/document/upload", - consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - @PreAuthorize("hasAuthority('platform:firstleg:customs:manage')") - public ApiResponse uploadDeclaration( - @AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long planId, - @RequestParam @Positive long warehouseId, @RequestHeader("Idempotency-Key") String key, - @RequestPart("file") MultipartFile file, - @RequestParam("expectedVersion") @Min(0) int expectedVersion, - @RequestParam(value = "operationReason", required = false) @Size(max = 500) String operationReason) { - FirstlegScopeGuard.platform(user); - return ApiResponse.success(service.uploadLoadingDeclaration(planId, warehouseId, - user.userId(), user.username(), key, expectedVersion, operationReason, file)); - } - - @Operation(summary = "确认装柜清单出口报关完成") - @PostMapping("/loading-lists/{planId}/customs/complete") - @PreAuthorize("hasAuthority('platform:firstleg:customs:manage')") - public ApiResponse completeDeclaration( - @AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long planId, - @RequestParam @Positive long warehouseId, @RequestHeader("Idempotency-Key") String key, - @Valid @RequestBody DocumentCompleteRequest request) { - FirstlegScopeGuard.platform(user); - return ApiResponse.success(service.completeLoadingDeclaration( - planId, warehouseId, user.userId(), user.username(), key, request)); - } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/controller/LoadingListBuyDeclarationExportController.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/controller/LoadingListBuyDeclarationExportController.java new file mode 100644 index 000000000..074abfade --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/controller/LoadingListBuyDeclarationExportController.java @@ -0,0 +1,29 @@ +package com.baiyue.firstleg.customs.container.controller; + +import com.baiyue.common.core.api.*; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.core.export.ExportContracts.*; +import com.baiyue.firstleg.customs.container.service.LoadingListBuyDeclarationExportService; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.web.bind.annotation.*; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; + +@RestController +@RequestMapping("/internal/exports/v1/LOADING_LIST_BUY_DECLARATION_ITEMS") +public class LoadingListBuyDeclarationExportController { + @Resource private LoadingListBuyDeclarationExportService service; + @Value("${baiyue.security.internal-service-token}") private String configuredToken; + private byte[] token; + @PostConstruct void init(){token=configuredToken==null?new byte[0]:configuredToken.getBytes(StandardCharsets.UTF_8);if(token.length<32)throw new IllegalStateException("INTERNAL_SERVICE_TOKEN至少需要32字节");} + @PostMapping("/batches") + public ApiResponse batch(@RequestHeader(value="X-Internal-Service-Token",required=false)String supplied, + @RequestBody ExportBatchRequest request){ + byte[] actual=supplied==null?new byte[0]:supplied.getBytes(StandardCharsets.UTF_8); + if(!MessageDigest.isEqual(token,actual))throw new BizException(ResultCode.FORBIDDEN,"Invalid internal service token"); + return ApiResponse.success(service.batch(request)); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/controller/MerchantContainerCustomsController.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/controller/MerchantContainerCustomsController.java new file mode 100644 index 000000000..9fed8f7f9 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/controller/MerchantContainerCustomsController.java @@ -0,0 +1,101 @@ +package com.baiyue.firstleg.customs.container.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.firstleg.customs.container.dto.ContainerCustomsDtos.*; +import com.baiyue.firstleg.customs.container.service.ContainerCustomsService; +import com.baiyue.firstleg.service.FirstlegScopeGuard; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; +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 java.util.List; + +@Validated +@RestController +@RequestMapping("/api/merchant/v1/customs") +public class MerchantContainerCustomsController { + @Resource private ContainerCustomsService service; + + @GetMapping("/orders/{orderId}") + @PreAuthorize("hasAuthority('merchant:order:view')") + public ApiResponse order(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long orderId) { + return ApiResponse.success(service.merchantOrderView(FirstlegScopeGuard.merchant(user), orderId)); + } + + @PostMapping(value="/loading-plans/{planId}/documents/upload",consumes=MediaType.MULTIPART_FORM_DATA_VALUE) + @PreAuthorize("hasAuthority('merchant:order:edit')") + public ApiResponse upload(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long planId, + @RequestHeader("Idempotency-Key") String key, + @RequestParam @Size(min=1,max=200) List<@Positive Long> inboundIds, + @RequestPart("file") MultipartFile file) { + long tenantId=FirstlegScopeGuard.merchant(user); + return ApiResponse.success(service.uploadSource(tenantId,planId,user.userId(),key,inboundIds,file)); + } + + @PostMapping(value="/documents/{documentId}/upload",consumes=MediaType.MULTIPART_FORM_DATA_VALUE) + @PreAuthorize("hasAuthority('merchant:order:edit')") + public ApiResponse replace(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long documentId, + @RequestHeader("Idempotency-Key") String key, + @RequestParam @Size(min=1,max=200) List<@Positive Long> inboundIds, + @RequestParam @Min(1) int expectedVersion, + @RequestPart("file") MultipartFile file) { + long tenantId=FirstlegScopeGuard.merchant(user); + return ApiResponse.success(service.replaceSource(tenantId,documentId,user.userId(),key, + new ScopeRequest(inboundIds,expectedVersion),file)); + } + + @PutMapping("/documents/{documentId}/inbounds") + @PreAuthorize("hasAuthority('merchant:order:edit')") + public ApiResponse scope(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long documentId, + @RequestHeader("Idempotency-Key") String key, + @Valid @RequestBody ScopeRequest request) { + long tenantId=FirstlegScopeGuard.merchant(user); + return ApiResponse.success(service.updateScope(tenantId,documentId,user.userId(),key,request)); + } + + @DeleteMapping("/documents/{documentId}") + @PreAuthorize("hasAuthority('merchant:order:edit')") + public ApiResponse delete(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long documentId, + @RequestHeader("Idempotency-Key") String key, + @RequestParam @Min(1) int expectedVersion) { + long tenantId=FirstlegScopeGuard.merchant(user); + return ApiResponse.success(service.deleteSource(tenantId,documentId,user.userId(),key,expectedVersion)); + } + + @GetMapping("/documents/{documentId}/download") + @PreAuthorize("hasAuthority('merchant:order:view')") + public ResponseEntity downloadSource(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long documentId) { + return response(service.downloadSource(FirstlegScopeGuard.merchant(user),documentId,false)); + } + + @GetMapping("/documents/{documentId}/completion/download") + @PreAuthorize("hasAuthority('merchant:order:view')") + public ResponseEntity downloadCompletion(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long documentId) { + return response(service.downloadCompletion(FirstlegScopeGuard.merchant(user),documentId,false)); + } + + private static ResponseEntity response(Download value) { + return ResponseEntity.ok().cacheControl(CacheControl.noStore()) + .contentType(MediaType.parseMediaType(value.contentType())) + .header(HttpHeaders.CONTENT_DISPOSITION,ContentDisposition.attachment() + .filename(value.fileName(),StandardCharsets.UTF_8).build().toString()) + .contentLength(value.content().length).body(value.content()); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/controller/PlatformContainerCustomsController.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/controller/PlatformContainerCustomsController.java new file mode 100644 index 000000000..48c0df180 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/controller/PlatformContainerCustomsController.java @@ -0,0 +1,95 @@ +package com.baiyue.firstleg.customs.container.controller; + +import com.baiyue.common.audit.BusinessAudit; +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.firstleg.customs.container.dto.ContainerCustomsDtos.*; +import com.baiyue.firstleg.customs.container.service.ContainerCustomsService; +import com.baiyue.firstleg.service.FirstlegScopeGuard; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Positive; +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; + +@Validated +@RestController +@RequestMapping("/api/platform/v1/customs/loading-plans") +public class PlatformContainerCustomsController { + @Resource private ContainerCustomsService service; + + @GetMapping("/{planId}") + @PreAuthorize("hasAuthority('platform:firstleg:customs:list')") + public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long planId) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.platformView(planId)); + } + + @PostMapping("/{planId}/start") + @PreAuthorize("hasAuthority('platform:firstleg:customs:manage')") + @BusinessAudit(event="CONTAINER_CUSTOMS_START",resourceType="CUSTOMS_CONTAINER") + public ApiResponse start(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long planId, + @RequestHeader("Idempotency-Key") String key, + @Valid @RequestBody ActionRequest request) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.start(planId,user.userId(),user.username(),key,request)); + } + + @PostMapping(value="/documents/{documentId}/completion/upload",consumes=MediaType.MULTIPART_FORM_DATA_VALUE) + @PreAuthorize("hasAuthority('platform:firstleg:customs:manage')") + @BusinessAudit(event="CUSTOMS_COMPLETION_UPLOAD",resourceType="CUSTOMS_CUSTOMER_DOCUMENT") + public ApiResponse uploadCompletion(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long documentId, + @RequestHeader("Idempotency-Key") String key, + @RequestParam @Min(0) int expectedVersion, + @RequestParam(defaultValue="false") boolean finalConfirmation, + @RequestPart("file") MultipartFile file) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.uploadCompletion(documentId,user.userId(),user.username(),key, + expectedVersion,finalConfirmation,file)); + } + + @PostMapping("/{planId}/complete-all-buy") + @PreAuthorize("hasAuthority('platform:firstleg:customs:manage')") + @BusinessAudit(event="ALL_BUY_CONTAINER_CUSTOMS_COMPLETE",resourceType="CUSTOMS_CONTAINER") + public ApiResponse completeAllBuy(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long planId, + @RequestHeader("Idempotency-Key") String key, + @Valid @RequestBody ActionRequest request) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.completeAllBuy(planId,user.userId(),user.username(),key,request)); + } + + @GetMapping("/documents/{documentId}/download") + @PreAuthorize("hasAuthority('platform:firstleg:customs:list')") + public ResponseEntity downloadSource(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long documentId) { + FirstlegScopeGuard.platform(user); + return response(service.downloadSource(0,documentId,true)); + } + + @GetMapping("/documents/{documentId}/completion/download") + @PreAuthorize("hasAuthority('platform:firstleg:customs:list')") + public ResponseEntity downloadCompletion(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long documentId) { + FirstlegScopeGuard.platform(user); + return response(service.downloadCompletion(0,documentId,true)); + } + + private static ResponseEntity response(Download value) { + return ResponseEntity.ok().cacheControl(CacheControl.noStore()) + .contentType(MediaType.parseMediaType(value.contentType())) + .header(HttpHeaders.CONTENT_DISPOSITION,ContentDisposition.attachment() + .filename(value.fileName(),StandardCharsets.UTF_8).build().toString()) + .contentLength(value.content().length).body(value.content()); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/dto/ContainerCustomsDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/dto/ContainerCustomsDtos.java new file mode 100644 index 000000000..3f812aa8f --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/dto/ContainerCustomsDtos.java @@ -0,0 +1,37 @@ +package com.baiyue.firstleg.customs.container.dto; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; + +import java.time.LocalDateTime; +import java.util.List; + +public final class ContainerCustomsDtos { + private ContainerCustomsDtos() {} + + public record InboundView(long inboundId, long orderId, String orderNo, String inboundCode, + long tenantId, String customerName, String taxMode, boolean covered) {} + + public record FileView(long fileId, String fileName, String fileHash, String contentType, + long sizeBytes, int version, LocalDateTime uploadedAt) {} + + public record DocumentView(long documentId, long tenantId, FileView source, + List inboundIds, List inboundCodes, + boolean frozen, FileView completion) {} + + public record ContainerView(long aggregateId, long loadingPlanId, long containerBookingId, + String planNo, String cabinetNo, String containerNo, String status, String merchantStatus, + int version, int taxRefundCount, int coveredTaxRefundCount, + List inbounds, List documents, + LocalDateTime startedAt, LocalDateTime completedAt) {} + + public record ScopeRequest(@NotEmpty @Size(max = 200) List<@Positive Long> inboundIds, + @Min(0) int expectedVersion) {} + + public record ActionRequest(@Min(0) int expectedVersion, boolean confirm, + @Size(max = 500) String remark) {} + + public record Download(String fileName, String contentType, byte[] content) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/mapper/ContainerCustomsMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/mapper/ContainerCustomsMapper.java new file mode 100644 index 000000000..bf185e24a --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/mapper/ContainerCustomsMapper.java @@ -0,0 +1,337 @@ +package com.baiyue.firstleg.customs.container.mapper; + +import org.apache.ibatis.annotations.*; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +@Mapper +public interface ContainerCustomsMapper { + @Select(""" + SELECT DISTINCT p.id loadingPlanId,p.plan_no planNo,p.container_booking_id containerBookingId, + p.plan_status loadingPlanStatus,b.cabinet_no cabinetNo,b.container_no containerNo, + i.id inboundId,i.order_id orderId,i.order_no orderNo,i.inbound_code inboundCode, + i.tenant_id tenantId,i.customer_name customerName,i.tax_mode taxMode + FROM fl_loading_plan p + JOIN fl_container_booking b ON b.id=p.container_booking_id AND b.record_status='ACTIVE' + JOIN fl_loading_plan_item item ON item.loading_plan_id=p.id AND item.deleted=0 + JOIN fl_inbound_order i ON i.id=item.inbound_id AND i.tenant_id=item.account_id AND i.deleted=0 + WHERE p.id=#{planId} + ORDER BY i.tenant_id,i.id + """) + List planInbounds(@Param("planId") long planId); + + @Select(""" + SELECT p.id FROM fl_loading_plan p + JOIN fl_loading_plan_item item ON item.loading_plan_id=p.id AND item.deleted=0 + JOIN fl_inbound_order i ON i.id=item.inbound_id AND i.tenant_id=item.account_id AND i.deleted=0 + WHERE p.plan_status='COMPLETED' AND i.tenant_id=#{tenantId} AND i.order_id=#{orderId} AND i.tax_mode='TAX_REFUND' + ORDER BY p.completed_at DESC,p.id DESC LIMIT 1 + """) + Long completedPlanForOrder(@Param("tenantId") long tenantId, @Param("orderId") long orderId); + + @Insert(""" + INSERT IGNORE INTO fl_customs_container + (id,loading_plan_id,container_booking_id,status,created_at,updated_at,version) + VALUES (#{id},#{planId},#{containerBookingId},#{status},CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0) + """) + int insertContainer(@Param("id") long id,@Param("planId") long planId, + @Param("containerBookingId") long containerBookingId,@Param("status") String status); + + @Select("SELECT * FROM fl_customs_container WHERE loading_plan_id=#{planId}") + ContainerRow container(@Param("planId") long planId); + + @Select("SELECT * FROM fl_customs_container WHERE id=#{id}") + ContainerRow containerById(@Param("id") long id); + + @Select("SELECT * FROM fl_customs_container WHERE container_booking_id=#{containerBookingId}") + ContainerRow containerByBooking(@Param("containerBookingId") long containerBookingId); + + @Select("SELECT * FROM fl_customs_container WHERE loading_plan_id=#{planId} FOR UPDATE") + ContainerRow lockContainer(@Param("planId") long planId); + + @Select("SELECT * FROM fl_customs_container WHERE id=#{id} FOR UPDATE") + ContainerRow lockContainerById(@Param("id") long id); + + @Update(""" + UPDATE fl_customs_container SET status=#{status},updated_at=CURRENT_TIMESTAMP(3),version=version+1 + WHERE id=#{id} AND version=#{version} AND status IN ('WAITING_CUSTOMER_DOCUMENTS','READY_TO_START') + """) + int refreshStatus(@Param("id") long id,@Param("version") int version,@Param("status") String status); + + @Update(""" + UPDATE fl_customs_container SET status='DECLARING',started_by=#{operatorId},started_by_name=#{operatorName}, + started_at=CURRENT_TIMESTAMP(3),updated_at=CURRENT_TIMESTAMP(3),version=version+1 + WHERE id=#{id} AND version=#{version} AND status='READY_TO_START' + """) + int start(@Param("id") long id,@Param("version") int version,@Param("operatorId") long operatorId, + @Param("operatorName") String operatorName); + + @Update(""" + UPDATE fl_customs_container SET status='COMPLETED',completed_by=#{operatorId},completed_by_name=#{operatorName}, + completed_at=CURRENT_TIMESTAMP(3),updated_at=CURRENT_TIMESTAMP(3),version=version+1 + WHERE id=#{id} AND version=#{version} AND status='DECLARING' + """) + int complete(@Param("id") long id,@Param("version") int version,@Param("operatorId") long operatorId, + @Param("operatorName") String operatorName); + + @Insert(""" + INSERT INTO fl_customs_customer_document + (id,customs_container_id,tenant_id,file_id,file_name,file_hash,content_type,size_bytes,status, + uploaded_by,uploaded_at,version,created_at,updated_at) + VALUES (#{id},#{containerId},#{tenantId},#{fileId},#{fileName},#{fileHash},#{contentType},#{sizeBytes},'ACTIVE', + #{operatorId},CURRENT_TIMESTAMP(3),1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)) + """) + int insertDocument(@Param("id") long id,@Param("containerId") long containerId,@Param("tenantId") long tenantId, + @Param("fileId") long fileId,@Param("fileName") String fileName,@Param("fileHash") String fileHash, + @Param("contentType") String contentType,@Param("sizeBytes") long sizeBytes,@Param("operatorId") long operatorId); + + @Select("SELECT * FROM fl_customs_customer_document WHERE id=#{id}") + DocumentRow document(@Param("id") long id); + + @Select("SELECT * FROM fl_customs_customer_document WHERE id=#{id} FOR UPDATE") + DocumentRow lockDocument(@Param("id") long id); + + @Select(""" + + """) + List documents(@Param("containerId") long containerId,@Param("tenantId") Long tenantId); + + @Select("SELECT * FROM fl_customs_customer_document WHERE customs_container_id=#{containerId} AND status='ACTIVE' ORDER BY id FOR UPDATE") + List lockActiveDocuments(@Param("containerId") long containerId); + + @Update(""" + UPDATE fl_customs_customer_document SET file_id=#{fileId},file_name=#{fileName},file_hash=#{fileHash}, + content_type=#{contentType},size_bytes=#{sizeBytes},uploaded_by=#{operatorId},uploaded_at=CURRENT_TIMESTAMP(3), + updated_at=CURRENT_TIMESTAMP(3),version=version+1 + WHERE id=#{id} AND tenant_id=#{tenantId} AND status='ACTIVE' AND frozen_at IS NULL AND version=#{version} + """) + int replaceDocument(@Param("id") long id,@Param("tenantId") long tenantId,@Param("version") int version, + @Param("fileId") long fileId,@Param("fileName") String fileName,@Param("fileHash") String fileHash, + @Param("contentType") String contentType,@Param("sizeBytes") long sizeBytes,@Param("operatorId") long operatorId); + + @Update(""" + UPDATE fl_customs_customer_document SET updated_at=CURRENT_TIMESTAMP(3),version=version+1 + WHERE id=#{id} AND tenant_id=#{tenantId} AND status='ACTIVE' AND frozen_at IS NULL AND version=#{version} + """) + int touchScope(@Param("id") long id,@Param("tenantId") long tenantId,@Param("version") int version); + + @Update(""" + UPDATE fl_customs_customer_document SET status='DELETED',deleted_by=#{operatorId},deleted_at=CURRENT_TIMESTAMP(3), + updated_at=CURRENT_TIMESTAMP(3),version=version+1 + WHERE id=#{id} AND tenant_id=#{tenantId} AND status='ACTIVE' AND frozen_at IS NULL AND version=#{version} + """) + int deleteDocument(@Param("id") long id,@Param("tenantId") long tenantId,@Param("version") int version, + @Param("operatorId") long operatorId); + + @Select(""" + SELECT s.document_id documentId,s.inbound_id inboundId,s.order_id orderId,s.inbound_code inboundCode + FROM fl_customs_customer_document_inbound s WHERE s.document_id=#{documentId} ORDER BY s.inbound_id + """) + List scopes(@Param("documentId") long documentId); + + @Delete("DELETE FROM fl_customs_customer_document_inbound WHERE document_id=#{documentId}") + int deleteScopes(@Param("documentId") long documentId); + + @Insert(""" + INSERT INTO fl_customs_customer_document_inbound(document_id,inbound_id,tenant_id,order_id,inbound_code,created_at) + VALUES(#{documentId},#{inboundId},#{tenantId},#{orderId},#{inboundCode},CURRENT_TIMESTAMP(3)) + """) + int insertScope(@Param("documentId") long documentId,@Param("inboundId") long inboundId, + @Param("tenantId") long tenantId,@Param("orderId") long orderId,@Param("inboundCode") String inboundCode); + + @Insert(""" + INSERT INTO fl_customs_customer_document_version + (id,document_id,document_version,file_id,file_name,file_hash,content_type,size_bytes,scope_snapshot_json, + action,operator_id,created_at) + VALUES(#{id},#{documentId},#{version},#{fileId},#{fileName},#{fileHash},#{contentType},#{sizeBytes}, + CAST(#{scopeJson} AS JSON),#{action},#{operatorId},CURRENT_TIMESTAMP(3)) + """) + int insertDocumentVersion(@Param("id") long id,@Param("documentId") long documentId,@Param("version") int version, + @Param("fileId") long fileId,@Param("fileName") String fileName,@Param("fileHash") String fileHash, + @Param("contentType") String contentType,@Param("sizeBytes") long sizeBytes, + @Param("scopeJson") String scopeJson,@Param("action") String action,@Param("operatorId") long operatorId); + + @Select(""" + SELECT COUNT(*) FROM ( + SELECT DISTINCT i.id FROM fl_loading_plan_item item + JOIN fl_inbound_order i ON i.id=item.inbound_id AND i.tenant_id=item.account_id AND i.deleted=0 + WHERE item.loading_plan_id=#{planId} AND item.deleted=0 AND i.tax_mode='TAX_REFUND' + ) target LEFT JOIN fl_customs_customer_document_inbound s ON s.inbound_id=target.id + LEFT JOIN fl_customs_customer_document d ON d.id=s.document_id AND d.customs_container_id=#{containerId} AND d.status='ACTIVE' + WHERE d.id IS NULL + """) + int missingRefundCount(@Param("planId") long planId,@Param("containerId") long containerId); + + @Update("UPDATE fl_customs_customer_document SET frozen_version=version,frozen_at=CURRENT_TIMESTAMP(3) " + + "WHERE customs_container_id=#{containerId} AND status='ACTIVE' AND frozen_at IS NULL") + int freezeDocuments(@Param("containerId") long containerId); + + @Insert(""" + INSERT INTO fl_customs_frozen_document_inbound(document_id,inbound_id,tenant_id,order_id,inbound_code,created_at) + SELECT s.document_id,s.inbound_id,s.tenant_id,s.order_id,s.inbound_code,CURRENT_TIMESTAMP(3) + FROM fl_customs_customer_document_inbound s + JOIN fl_customs_customer_document d ON d.id=s.document_id + WHERE d.customs_container_id=#{containerId} AND d.status='ACTIVE' + """) + int freezeScopes(@Param("containerId") long containerId); + + @Select(""" + SELECT s.document_id documentId,s.inbound_id inboundId,s.order_id orderId,s.inbound_code inboundCode + FROM fl_customs_frozen_document_inbound s WHERE s.document_id=#{documentId} ORDER BY s.inbound_id + """) + List frozenScopes(@Param("documentId") long documentId); + + @Select("SELECT * FROM fl_customs_completion_document WHERE source_document_id=#{documentId}") + CompletionRow completion(@Param("documentId") long documentId); + + @Select("SELECT * FROM fl_customs_completion_document WHERE source_document_id=#{documentId} FOR UPDATE") + CompletionRow lockCompletion(@Param("documentId") long documentId); + + @Insert(""" + INSERT INTO fl_customs_completion_document + (id,source_document_id,file_id,file_name,file_hash,content_type,size_bytes,uploaded_by,uploaded_by_name, + uploaded_at,version,created_at,updated_at) + VALUES(#{id},#{documentId},#{fileId},#{fileName},#{fileHash},#{contentType},#{sizeBytes},#{operatorId},#{operatorName}, + CURRENT_TIMESTAMP(3),1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)) + """) + int insertCompletion(@Param("id") long id,@Param("documentId") long documentId,@Param("fileId") long fileId, + @Param("fileName") String fileName,@Param("fileHash") String fileHash, + @Param("contentType") String contentType,@Param("sizeBytes") long sizeBytes, + @Param("operatorId") long operatorId,@Param("operatorName") String operatorName); + + @Update(""" + UPDATE fl_customs_completion_document SET file_id=#{fileId},file_name=#{fileName},file_hash=#{fileHash}, + content_type=#{contentType},size_bytes=#{sizeBytes},uploaded_by=#{operatorId},uploaded_by_name=#{operatorName}, + uploaded_at=CURRENT_TIMESTAMP(3),updated_at=CURRENT_TIMESTAMP(3),version=version+1 + WHERE id=#{id} AND version=#{version} + AND EXISTS(SELECT 1 FROM fl_customs_customer_document d JOIN fl_customs_container c ON c.id=d.customs_container_id + WHERE d.id=source_document_id AND c.status='DECLARING') + """) + int replaceCompletion(@Param("id") long id,@Param("version") int version,@Param("fileId") long fileId, + @Param("fileName") String fileName,@Param("fileHash") String fileHash, + @Param("contentType") String contentType,@Param("sizeBytes") long sizeBytes, + @Param("operatorId") long operatorId,@Param("operatorName") String operatorName); + + @Insert(""" + INSERT INTO fl_customs_completion_document_version + (id,completion_document_id,document_version,file_id,file_name,file_hash,content_type,size_bytes, + operator_id,operator_name,created_at) + VALUES(#{id},#{completionId},#{version},#{fileId},#{fileName},#{fileHash},#{contentType},#{sizeBytes}, + #{operatorId},#{operatorName},CURRENT_TIMESTAMP(3)) + """) + int insertCompletionVersion(@Param("id") long id,@Param("completionId") long completionId,@Param("version") int version, + @Param("fileId") long fileId,@Param("fileName") String fileName,@Param("fileHash") String fileHash, + @Param("contentType") String contentType,@Param("sizeBytes") long sizeBytes, + @Param("operatorId") long operatorId,@Param("operatorName") String operatorName); + + @Select(""" + SELECT COUNT(*) FROM fl_customs_customer_document d + LEFT JOIN fl_customs_completion_document r ON r.source_document_id=d.id + WHERE d.customs_container_id=#{containerId} AND d.status='ACTIVE' AND d.frozen_at IS NOT NULL AND r.id IS NULL + """) + int missingCompletionCount(@Param("containerId") long containerId); + + @Insert(""" + INSERT INTO fl_customs_container_history + (id,customs_container_id,action,from_status,to_status,remark,operator_id,operator_name,created_at) + VALUES(#{id},#{containerId},#{action},#{fromStatus},#{toStatus},#{remark},#{operatorId},#{operatorName},CURRENT_TIMESTAMP(3)) + """) + int insertHistory(@Param("id") long id,@Param("containerId") long containerId,@Param("action") String action, + @Param("fromStatus") String fromStatus,@Param("toStatus") String toStatus,@Param("remark") String remark, + @Param("operatorId") long operatorId,@Param("operatorName") String operatorName); + + @Insert(""" + INSERT IGNORE INTO fl_customs_command + (id,actor_id,action,idempotency_key,request_hash,status,created_at) + VALUES(#{id},#{actorId},#{action},#{key},#{hash},'PROCESSING',CURRENT_TIMESTAMP(3)) + """) + int claimCommand(@Param("id") long id,@Param("actorId") long actorId,@Param("action") String action, + @Param("key") String key,@Param("hash") String hash); + + @Select("SELECT * FROM fl_customs_command WHERE actor_id=#{actorId} AND action=#{action} AND idempotency_key=#{key}") + CommandRow command(@Param("actorId") long actorId,@Param("action") String action,@Param("key") String key); + + @Update(""" + UPDATE fl_customs_command SET status='SUCCESS',result_id=#{resultId},completed_at=CURRENT_TIMESTAMP(3) + WHERE actor_id=#{actorId} AND action=#{action} AND idempotency_key=#{key} AND status='PROCESSING' + """) + int completeCommand(@Param("actorId") long actorId,@Param("action") String action,@Param("key") String key, + @Param("resultId") long resultId); + + @Select(""" + SELECT c.container_booking_id FROM fl_customs_container c + WHERE c.status='COMPLETED' AND NOT EXISTS( + SELECT 1 FROM fl_loading_plan_item item + LEFT JOIN fl_outbound_dispatch d ON d.inbound_id=item.inbound_id + AND d.tenant_id=item.account_id AND d.container_booking_id=c.container_booking_id + WHERE item.loading_plan_id=c.loading_plan_id AND item.deleted=0 + GROUP BY item.loading_plan_id HAVING COUNT(*)=SUM(CASE WHEN d.status='WAITING_FOR_LOADING' THEN 1 ELSE 0 END) + ) ORDER BY c.completed_at,c.id LIMIT #{limit} + """) + List pendingAutoDispatch(@Param("limit") int limit); + + @Select(""" + SELECT item.id itemId,p.plan_no planNo,b.cabinet_no cabinetNo,b.container_no containerNo, + item.customer_name customerName,item.order_no orderNo,item.inbound_code inboundCode,item.sku_code skuCode, + item.product_name_zh productNameZh,item.product_name_en productNameEn,item.package_count packageCount, + item.product_quantity productQuantity,item.gross_weight_kg grossWeightKg,item.net_weight_kg netWeightKg, + item.volume_cbm volumeCbm,item.shipping_mark shippingMark,item.customer_remark customerRemark + FROM fl_loading_plan_item item + JOIN fl_loading_plan p ON p.id=item.loading_plan_id AND p.plan_status='COMPLETED' + JOIN fl_container_booking b ON b.id=p.container_booking_id AND b.record_status='ACTIVE' + JOIN fl_inbound_order i ON i.id=item.inbound_id AND i.tenant_id=item.account_id AND i.deleted=0 + WHERE p.id=#{planId} AND item.deleted=0 AND i.tax_mode='BUY_DECLARATION' + AND item.id>#{cursor} AND item.id<=#{maximumId} + ORDER BY item.id LIMIT #{limit} + """) + List buyRows(@Param("planId") long planId,@Param("cursor") long cursor, + @Param("maximumId") long maximumId,@Param("limit") int limit); + + @Select(""" + SELECT p.id planId,p.plan_status planStatus,b.warehouse_id warehouseId + FROM fl_loading_plan p JOIN fl_container_booking b ON b.id=p.container_booking_id AND b.record_status='ACTIVE' + WHERE p.id=#{planId} + """) + PlanExportScope planExportScope(@Param("planId") long planId); + + @Select(""" + SELECT COALESCE(MAX(item.id),0) FROM fl_loading_plan_item item + JOIN fl_loading_plan p ON p.id=item.loading_plan_id AND p.plan_status='COMPLETED' + JOIN fl_inbound_order i ON i.id=item.inbound_id AND i.tenant_id=item.account_id AND i.deleted=0 + WHERE p.id=#{planId} AND item.deleted=0 AND i.tax_mode='BUY_DECLARATION' + """) + long maximumBuyItemId(@Param("planId") long planId); + + @Select(""" + SELECT COUNT(*) FROM fl_loading_plan_item item + JOIN fl_loading_plan p ON p.id=item.loading_plan_id AND p.plan_status='COMPLETED' + JOIN fl_inbound_order i ON i.id=item.inbound_id AND i.tenant_id=item.account_id AND i.deleted=0 + WHERE p.id=#{planId} AND item.deleted=0 AND i.tax_mode='BUY_DECLARATION' AND item.id<=#{maximumId} + """) + long countBuyRows(@Param("planId") long planId,@Param("maximumId") long maximumId); + + record InboundRow(Long loadingPlanId,String planNo,Long containerBookingId,String loadingPlanStatus, + String cabinetNo,String containerNo,Long inboundId,Long orderId,String orderNo,String inboundCode, + Long tenantId,String customerName,String taxMode) {} + record ContainerRow(Long id,Long loadingPlanId,Long containerBookingId,String status,Long startedBy, + String startedByName,LocalDateTime startedAt,Long completedBy,String completedByName, + LocalDateTime completedAt,LocalDateTime createdAt,LocalDateTime updatedAt,Integer version) {} + record DocumentRow(Long id,Long customsContainerId,Long tenantId,Long fileId,String fileName,String fileHash, + String contentType,Long sizeBytes,String status,Long uploadedBy,LocalDateTime uploadedAt, + Integer frozenVersion,LocalDateTime frozenAt,Integer version) {} + record ScopeRow(Long documentId,Long inboundId,Long orderId,String inboundCode) {} + record CompletionRow(Long id,Long sourceDocumentId,Long fileId,String fileName,String fileHash,String contentType, + Long sizeBytes,Long uploadedBy,String uploadedByName,LocalDateTime uploadedAt,Integer version) {} + record CommandRow(Long id,Long actorId,String action,String idempotencyKey,String requestHash,String status, + Long resultId,LocalDateTime createdAt,LocalDateTime completedAt) {} + record BuyExportRow(Long itemId,String planNo,String cabinetNo,String containerNo,String customerName,String orderNo, + String inboundCode,String skuCode,String productNameZh,String productNameEn,Integer packageCount, + Integer productQuantity,BigDecimal grossWeightKg,BigDecimal netWeightKg,BigDecimal volumeCbm, + String shippingMark,String customerRemark) {} + record PlanExportScope(Long planId,String planStatus,Long warehouseId) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/ContainerCustomsService.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/ContainerCustomsService.java new file mode 100644 index 000000000..6d190934a --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/ContainerCustomsService.java @@ -0,0 +1,409 @@ +package com.baiyue.firstleg.customs.container.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.customs.client.CustomsFileClient; +import com.baiyue.firstleg.customs.container.dto.ContainerCustomsDtos.*; +import com.baiyue.firstleg.customs.container.mapper.ContainerCustomsMapper; +import com.baiyue.firstleg.customs.container.mapper.ContainerCustomsMapper.*; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.LocalDateTime; +import java.util.*; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Service +public class ContainerCustomsService { + public static final String WAITING = "WAITING_CUSTOMER_DOCUMENTS"; + public static final String READY = "READY_TO_START"; + public static final String DECLARING = "DECLARING"; + public static final String COMPLETED = "COMPLETED"; + private static final String SOURCE_FILE_TYPE = "LOADING_LIST_CUSTOMS_SOURCE"; + private static final String RESULT_FILE_TYPE = "LOADING_LIST_CUSTOMS_RESULT"; + private static final long MAX_FILE_BYTES = 50L * 1024 * 1024; + + @Resource private ContainerCustomsMapper mapper; + @Resource private CustomsFileClient files; + @Resource private ObjectMapper objectMapper; + @Resource private ApplicationEventPublisher events; + @Value("${baiyue.security.internal-service-token}") private String internalToken; + + @Transactional + public ContainerView platformView(long planId) { + ContainerRow container = ensure(planId); + return view(container, null, false); + } + + @Transactional + public ContainerView merchantOrderView(long tenantId, long orderId) { + Long planId = mapper.completedPlanForOrder(tenantId, orderId); + if (planId == null) throw notFound("订单尚未完成装柜"); + ContainerRow container = ensure(planId); + return view(container, tenantId, true); + } + + @Transactional + public DocumentView uploadSource(long tenantId, long planId, long operatorId, String key, + List inboundIds, MultipartFile file) { + ensureMutable(planId); + ContainerRow container = lockEditableContainerByPlan(planId); + List scope = validateScope(planId, tenantId, inboundIds); + validateFile(file); + long documentId = IdWorker.getId(); + String action = "UPLOAD_SOURCE"; + String requestHash = hash(action, planId, scope.stream().map(InboundRow::inboundId).toList(), fileHash(file)); + CommandRow replay = claim(operatorId, action, key, requestHash); + if (replay != null) return documentView(requiredDocument(replay.resultId()), false); + CustomsFileClient.Metadata metadata = upload(tenantId, SOURCE_FILE_TYPE, Long.toString(planId), operatorId, file); + mapper.insertDocument(documentId, container.id(), tenantId, metadata.fileId(), metadata.fileName(), + metadata.sha256(), metadata.contentType(), size(metadata), operatorId); + replaceScopes(documentId, tenantId, scope); + mapper.insertDocumentVersion(IdWorker.getId(), documentId, 1, metadata.fileId(), metadata.fileName(), + metadata.sha256(), metadata.contentType(), size(metadata), scopeJson(scope), "UPLOAD", operatorId); + refresh(container.loadingPlanId()); + completeCommand(operatorId, action, key, documentId); + return documentView(requiredDocument(documentId), false); + } + + @Transactional + public DocumentView replaceSource(long tenantId, long documentId, long operatorId, String key, + ScopeRequest request, MultipartFile file) { + validateFile(file); + DocumentRow readable = requiredDocument(documentId); + if (readable.tenantId()!=tenantId) throw notFound("客户报送资料不存在"); + ContainerRow container = lockEditableContainer(readable.customsContainerId()); + DocumentRow current = requiredDocumentLocked(documentId, tenantId); + List scope = validateScope(container.loadingPlanId(), tenantId, request.inboundIds()); + String action = "REPLACE_SOURCE_" + documentId; + String requestHash = hash(action, request.expectedVersion(), scope.stream().map(InboundRow::inboundId).toList(), fileHash(file)); + CommandRow replay = claim(operatorId, action, key, requestHash); + if (replay != null) return documentView(requiredDocument(documentId), false); + CustomsFileClient.Metadata metadata = upload(tenantId, SOURCE_FILE_TYPE, Long.toString(container.loadingPlanId()), operatorId, file); + if (mapper.replaceDocument(documentId, tenantId, request.expectedVersion(), metadata.fileId(), metadata.fileName(), + metadata.sha256(), metadata.contentType(), size(metadata), operatorId) != 1) throw concurrent(); + replaceScopes(documentId, tenantId, scope); + int version = request.expectedVersion() + 1; + mapper.insertDocumentVersion(IdWorker.getId(), documentId, version, metadata.fileId(), metadata.fileName(), + metadata.sha256(), metadata.contentType(), size(metadata), scopeJson(scope), "REPLACE", operatorId); + refresh(container.loadingPlanId()); + completeCommand(operatorId, action, key, documentId); + return documentView(requiredDocument(documentId), false); + } + + @Transactional + public DocumentView updateScope(long tenantId, long documentId, long operatorId, String key, ScopeRequest request) { + DocumentRow readable = requiredDocument(documentId); + if (readable.tenantId()!=tenantId) throw notFound("客户报送资料不存在"); + ContainerRow container = lockEditableContainer(readable.customsContainerId()); + DocumentRow current = requiredDocumentLocked(documentId, tenantId); + List scope = validateScope(container.loadingPlanId(), tenantId, request.inboundIds()); + String action = "UPDATE_SOURCE_SCOPE_" + documentId; + String requestHash = hash(action, request.expectedVersion(), scope.stream().map(InboundRow::inboundId).toList()); + CommandRow replay = claim(operatorId, action, key, requestHash); + if (replay != null) return documentView(requiredDocument(documentId), false); + if (mapper.touchScope(documentId, tenantId, request.expectedVersion()) != 1) throw concurrent(); + replaceScopes(documentId, tenantId, scope); + int version = request.expectedVersion() + 1; + mapper.insertDocumentVersion(IdWorker.getId(), documentId, version, current.fileId(), current.fileName(), + current.fileHash(), current.contentType(), current.sizeBytes(), scopeJson(scope), "SCOPE_CHANGE", operatorId); + refresh(container.loadingPlanId()); + completeCommand(operatorId, action, key, documentId); + return documentView(requiredDocument(documentId), false); + } + + @Transactional + public ContainerView deleteSource(long tenantId, long documentId, long operatorId, String key, int expectedVersion) { + DocumentRow readable = requiredDocument(documentId); + if (readable.tenantId()!=tenantId) throw notFound("客户报送资料不存在"); + ContainerRow container = lockEditableContainer(readable.customsContainerId()); + DocumentRow current = requiredDocumentLocked(documentId, tenantId); + String action = "DELETE_SOURCE_" + documentId; + String requestHash = hash(action, expectedVersion); + CommandRow replay = claim(operatorId, action, key, requestHash); + if (replay != null) return view(mapper.container(container.loadingPlanId()), tenantId, true); + List deletedScope=validateScope(container.loadingPlanId(),tenantId,mapper.scopes(documentId).stream().map(ScopeRow::inboundId).toList()); + if (mapper.deleteDocument(documentId, tenantId, expectedVersion, operatorId) != 1) throw concurrent(); + mapper.insertDocumentVersion(IdWorker.getId(),documentId,expectedVersion+1,current.fileId(),current.fileName(), + current.fileHash(),current.contentType(),current.sizeBytes(),scopeJson(deletedScope),"DELETE",operatorId); + mapper.deleteScopes(documentId); + history(container, "DELETE_CUSTOMER_DOCUMENT", container.status(), container.status(), + "documentId=" + documentId, operatorId, null); + refresh(container.loadingPlanId()); + completeCommand(operatorId, action, key, container.id()); + return view(mapper.container(container.loadingPlanId()), tenantId, true); + } + + @Transactional + public ContainerView start(long planId, long operatorId, String operatorName, String key, ActionRequest request) { + ContainerRow current = ensure(planId); + String action = "START_CONTAINER_CUSTOMS_" + planId; + if(!request.confirm())throw bad("请确认开始整柜报关"); + String requestHash = hash(action, request.expectedVersion(), request.confirm(), request.remark()); + CommandRow replay = claim(operatorId, action, key, requestHash); + if (replay != null) return platformView(planId); + ContainerRow locked = mapper.lockContainer(planId); + if (locked == null || !READY.equals(locked.status())) throw conflict("当前柜报关资料尚未齐全或已开始报关"); + if (!Objects.equals(locked.version(), request.expectedVersion())) throw concurrent(); + mapper.lockActiveDocuments(locked.id()); + if (mapper.missingRefundCount(planId, locked.id()) != 0) throw conflict("仍有退税入仓码未上传报送资料"); + mapper.freezeScopes(locked.id()); + mapper.freezeDocuments(locked.id()); + if (mapper.start(locked.id(), locked.version(), operatorId, operatorName) != 1) throw concurrent(); + history(locked, "START_CUSTOMS", locked.status(), DECLARING, request.remark(), operatorId, operatorName); + completeCommand(operatorId, action, key, locked.id()); + return platformView(planId); + } + + @Transactional + public DocumentView uploadCompletion(long documentId, long operatorId, String operatorName, String key, + int expectedVersion, boolean finalConfirmation, MultipartFile file) { + validateFile(file); + DocumentRow readable=requiredDocument(documentId); + ContainerRow container = mapper.lockContainerById(readable.customsContainerId()); + DocumentRow source = requiredDocumentLocked(documentId, null); + if (container == null || !DECLARING.equals(container.status()) || source.frozenAt() == null) { + throw conflict("当前客户资料不在报关中冻结版本"); + } + CompletionRow completion = mapper.lockCompletion(documentId); + int missingBefore=mapper.missingCompletionCount(container.id()); + boolean lastNewCompletion=completion==null&&missingBefore==1; + if(lastNewCompletion&&!finalConfirmation)throw conflict("这是最后一份完成文件,请二次确认后上传"); + if(finalConfirmation&&!lastNewCompletion)throw conflict("只有最后一份尚未上传的完成文件才能确认整柜完成"); + String action = "UPLOAD_COMPLETION_" + documentId; + String requestHash = hash(action, expectedVersion, finalConfirmation, fileHash(file)); + CommandRow replay = claim(operatorId, action, key, requestHash); + if (replay != null) return documentView(requiredDocument(documentId), false); + CustomsFileClient.Metadata metadata = upload(source.tenantId(), RESULT_FILE_TYPE, Long.toString(container.loadingPlanId()), operatorId, file); + long completionId; + int version; + if (completion == null) { + if (expectedVersion != 0) throw concurrent(); + completionId = IdWorker.getId(); version = 1; + mapper.insertCompletion(completionId, documentId, metadata.fileId(), metadata.fileName(), metadata.sha256(), + metadata.contentType(), size(metadata), operatorId, operatorName); + } else { + completionId = completion.id(); version = completion.version() + 1; + if (mapper.replaceCompletion(completion.id(), expectedVersion, metadata.fileId(), metadata.fileName(), metadata.sha256(), + metadata.contentType(), size(metadata), operatorId, operatorName) != 1) throw concurrent(); + } + mapper.insertCompletionVersion(IdWorker.getId(), completionId, version, metadata.fileId(), metadata.fileName(), + metadata.sha256(), metadata.contentType(), size(metadata), operatorId, operatorName); + if (lastNewCompletion) { + completeContainer(container, operatorId, operatorName, "全部冻结客户资料已回传完成文件"); + } + completeCommand(operatorId, action, key, completionId); + return documentView(requiredDocument(documentId), false); + } + + @Transactional + public ContainerView completeAllBuy(long planId, long operatorId, String operatorName, String key, ActionRequest request) { + String action = "COMPLETE_ALL_BUY_CUSTOMS_" + planId; + if(!request.confirm())throw bad("请二次确认全买单柜报关完成"); + String requestHash = hash(action, request.expectedVersion(), request.confirm(), request.remark()); + CommandRow replay = claim(operatorId, action, key, requestHash); + if (replay != null) return platformView(planId); + ContainerRow container = mapper.lockContainer(planId); + if (container == null || !DECLARING.equals(container.status())) throw conflict("当前柜不在报关中"); + if (!Objects.equals(container.version(), request.expectedVersion())) throw concurrent(); + List actual=planRows(planId); + if(actual.stream().anyMatch(row->!"BUY_DECLARATION".equals(row.taxMode())))throw conflict("全买单柜完成只允许全部实际行均为BUY_DECLARATION"); + completeContainer(container, operatorId, operatorName, request.remark()); + completeCommand(operatorId, action, key, container.id()); + return platformView(planId); + } + + public Download downloadSource(long tenantId, long documentId, boolean platform) { + DocumentRow source = requiredDocument(documentId); + if (!platform && source.tenantId() != tenantId) throw notFound("客户报送资料不存在"); + ContainerRow container=mapper.containerById(source.customsContainerId()); + return download(source.tenantId(), SOURCE_FILE_TYPE, Long.toString(container.loadingPlanId()), source.fileId(), source.fileName()); + } + + public Download downloadCompletion(long tenantId, long documentId, boolean platform) { + DocumentRow source = requiredDocument(documentId); + ContainerRow container = mapper.containerById(source.customsContainerId()); + if (!platform && (source.tenantId() != tenantId || container == null || !COMPLETED.equals(container.status()))) { + throw notFound("报关完成文件不存在"); + } + CompletionRow completion = mapper.completion(documentId); + if (completion == null) throw notFound("报关完成文件不存在"); + return download(source.tenantId(), RESULT_FILE_TYPE, Long.toString(container.loadingPlanId()), completion.fileId(), completion.fileName()); + } + + private ContainerRow ensure(long planId) { + List rows = planRows(planId); + InboundRow first = rows.get(0); + if (!"COMPLETED".equals(first.loadingPlanStatus())) throw conflict("装柜完成后才能处理报关资料"); + boolean refund = rows.stream().anyMatch(row -> "TAX_REFUND".equals(row.taxMode())); + mapper.insertContainer(IdWorker.getId(), planId, first.containerBookingId(), refund ? WAITING : READY); + return refresh(planId); + } + + private ContainerRow ensureMutable(long planId) { + ContainerRow value = ensure(planId); + if (!Set.of(WAITING, READY).contains(value.status())) throw conflict("整柜已开始报关,客户资料已锁定"); + return value; + } + + private ContainerRow refresh(long planId) { + ContainerRow value = mapper.container(planId); + if (value == null || !Set.of(WAITING, READY).contains(value.status())) return value; + String status = mapper.missingRefundCount(planId, value.id()) == 0 ? READY : WAITING; + if (!status.equals(value.status())) { + if (mapper.refreshStatus(value.id(), value.version(), status) != 1) throw concurrent(); + value = mapper.container(planId); + } + return value; + } + + private ContainerRow lockEditableContainer(long containerId) { + ContainerRow value = mapper.lockContainerById(containerId); + if (value == null || !Set.of(WAITING, READY).contains(value.status())) throw conflict("整柜已开始报关,客户资料已锁定"); + return value; + } + + private ContainerRow lockEditableContainerByPlan(long planId) { + ContainerRow value=mapper.lockContainer(planId); + if(value==null||!Set.of(WAITING,READY).contains(value.status()))throw conflict("整柜已开始报关,客户资料已锁定"); + return value; + } + + private List planRows(long planId) { + List rows = mapper.planInbounds(planId); + if (rows == null || rows.isEmpty()) throw notFound("未找到当前装柜计划的实际货物明细"); + return rows; + } + + private List validateScope(long planId, long tenantId, List inboundIds) { + if (inboundIds == null || inboundIds.isEmpty() || inboundIds.size() > 200) throw bad("至少选择一个且最多200个入仓码"); + Set requested = new LinkedHashSet<>(inboundIds); + List scope = planRows(planId).stream() + .filter(row -> requested.contains(row.inboundId()) && row.tenantId() == tenantId && "TAX_REFUND".equals(row.taxMode())) + .toList(); + if (scope.size() != requested.size()) throw bad("只能选择当前柜内属于本客户的退税入仓码"); + return scope; + } + + private void replaceScopes(long documentId, long tenantId, List scope) { + mapper.deleteScopes(documentId); + for (InboundRow row : scope) mapper.insertScope(documentId, row.inboundId(), tenantId, row.orderId(), row.inboundCode()); + } + + private ContainerView view(ContainerRow container, Long tenantId, boolean merchant) { + List rows = planRows(container.loadingPlanId()); + List documents = mapper.documents(container.id(), tenantId); + Set covered = documents.stream().flatMap(doc -> mapper.scopes(doc.id()).stream()).map(ScopeRow::inboundId).collect(Collectors.toSet()); + List visibleRows=rows.stream().filter(row -> tenantId == null || (row.tenantId().equals(tenantId)&&"TAX_REFUND".equals(row.taxMode()))).toList(); + List inbounds = visibleRows.stream() + .map(row -> new InboundView(row.inboundId(), row.orderId(), row.orderNo(), row.inboundCode(), row.tenantId(), + row.customerName(), row.taxMode(), covered.contains(row.inboundId()))).toList(); + List documentViews = documents.stream().map(doc -> documentView(doc, merchant)).toList(); + int refund = (int) visibleRows.stream().filter(row -> "TAX_REFUND".equals(row.taxMode())).count(); + int coveredRefund = (int) visibleRows.stream().filter(row -> covered.contains(row.inboundId())).count(); + String merchantStatus=tenantId==null?null:merchantStatus(container.status(),refund,coveredRefund); + InboundRow first = rows.get(0); + return new ContainerView(container.id(), container.loadingPlanId(), container.containerBookingId(), first.planNo(), + first.cabinetNo(), first.containerNo(), container.status(), merchantStatus, container.version(), refund, coveredRefund, + inbounds, documentViews, container.startedAt(), container.completedAt()); + } + + private DocumentView documentView(DocumentRow source, boolean merchant) { + List scopes = source.frozenAt() == null ? mapper.scopes(source.id()) : mapper.frozenScopes(source.id()); + CompletionRow completion = mapper.completion(source.id()); + ContainerRow container = mapper.containerById(source.customsContainerId()); + FileView result = completion == null || (merchant && !COMPLETED.equals(container.status())) ? null : file(completion); + return new DocumentView(source.id(), source.tenantId(), file(source), scopes.stream().map(ScopeRow::inboundId).toList(), + scopes.stream().map(ScopeRow::inboundCode).toList(), source.frozenAt() != null, result); + } + + private void completeContainer(ContainerRow container, long operatorId, String operatorName, String remark) { + if (mapper.complete(container.id(), container.version(), operatorId, operatorName) != 1) throw concurrent(); + history(container, "COMPLETE_CUSTOMS", container.status(), COMPLETED, remark, operatorId, operatorName); + events.publishEvent(new CustomsCompletedEvent(container.containerBookingId())); + } + + private void history(ContainerRow container,String action,String from,String to,String remark,long operatorId,String operatorName) { + mapper.insertHistory(IdWorker.getId(), container.id(), action, from, to, trim(remark), operatorId, trim(operatorName)); + } + + private CommandRow claim(long actorId, String action, String key, String requestHash) { + validateKey(key); + if (mapper.claimCommand(IdWorker.getId(), actorId, action, key, requestHash) == 1) return null; + CommandRow existing = mapper.command(actorId, action, key); + if (existing == null || !Objects.equals(existing.requestHash(), requestHash)) throw conflict("Idempotency-Key已用于不同请求"); + if (!"SUCCESS".equals(existing.status())) throw conflict("相同请求正在处理中"); + return existing; + } + + private void completeCommand(long actorId,String action,String key,long resultId) { + if (mapper.completeCommand(actorId, action, key, resultId) != 1) throw concurrent(); + } + + private CustomsFileClient.Metadata upload(long tenantId,String type,String businessId,long operatorId,MultipartFile file) { + var response = files.upload(internalToken, operatorId, tenantId, type, businessId, file); + if (response == null || !response.successful() || response.data() == null || response.data().fileId() == null) { + throw conflict("File服务上传失败"); + } + CustomsFileClient.Metadata metadata = response.data(); + if (metadata.tenantId() == null || metadata.tenantId() != tenantId || !type.equals(metadata.businessType()) + || !businessId.equals(metadata.businessId())) throw conflict("File服务返回的文件归属不一致"); + return metadata; + } + + private Download download(long tenantId,String type,String businessId,long fileId,String fileName) { + ResponseEntity response = files.download(internalToken, tenantId, type, businessId, fileId); + if (response == null || response.getBody() == null) throw notFound("文件不存在"); + MediaType contentType = response.getHeaders().getContentType(); + return new Download(fileName, contentType == null ? MediaType.APPLICATION_OCTET_STREAM_VALUE : contentType.toString(), response.getBody()); + } + + private DocumentRow requiredDocument(long id) { + DocumentRow value = mapper.document(id); + if (value == null || !"ACTIVE".equals(value.status())) throw notFound("客户报送资料不存在"); + return value; + } + + private DocumentRow requiredDocumentLocked(long id, Long tenantId) { + DocumentRow value = mapper.lockDocument(id); + if (value == null || !"ACTIVE".equals(value.status()) || (tenantId != null && !tenantId.equals(value.tenantId()))) { + throw notFound("客户报送资料不存在"); + } + return value; + } + + private String scopeJson(List scope) { + try { return objectMapper.writeValueAsString(scope.stream().map(row -> Map.of("inboundId", row.inboundId(), + "orderId", row.orderId(), "inboundCode", row.inboundCode())).toList()); } + catch (JsonProcessingException ex) { throw new IllegalStateException(ex); } + } + + private static FileView file(DocumentRow row) { return new FileView(row.fileId(),row.fileName(),row.fileHash(),row.contentType(),row.sizeBytes(),row.version(),row.uploadedAt()); } + private static FileView file(CompletionRow row) { return new FileView(row.fileId(),row.fileName(),row.fileHash(),row.contentType(),row.sizeBytes(),row.version(),row.uploadedAt()); } + private static long size(CustomsFileClient.Metadata value) { return value.sizeBytes() == null ? 0 : value.sizeBytes(); } + private static void validateFile(MultipartFile file) { if (file == null || file.isEmpty()) throw bad("请选择文件"); if (file.getSize() > MAX_FILE_BYTES) throw bad("单文件不能超过50MB"); } + private static String fileHash(MultipartFile file) { try (InputStream input=file.getInputStream()) { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(input.readAllBytes())); } catch(Exception ex){ throw bad("无法读取上传文件"); } } + private static String hash(Object... values) { try { MessageDigest digest=MessageDigest.getInstance("SHA-256"); for(Object value:values) digest.update((Objects.toString(value,"")+"\u001f").getBytes(StandardCharsets.UTF_8)); return HexFormat.of().formatHex(digest.digest()); } catch(Exception ex){ throw new IllegalStateException(ex); } } + private static void validateKey(String key) { if (key == null || key.isBlank() || key.length() > 128) throw bad("Idempotency-Key不能为空且长度不能超过128"); } + private static String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); } + private static String merchantStatus(String containerStatus,int refund,int covered){if(COMPLETED.equals(containerStatus))return "CUSTOMS_COMPLETED";if(DECLARING.equals(containerStatus))return "DECLARING";return refund>0&&refund==covered?"DOCUMENT_UPLOADED":"WAITING_UPLOAD";} + private static BizException bad(String message) { return new BizException(ResultCode.BAD_REQUEST,message); } + private static BizException conflict(String message) { return new BizException(ResultCode.CONFLICT,message); } + private static BizException concurrent() { return conflict("数据已被其他人修改,请刷新后重试"); } + private static BizException notFound(String message) { return new BizException(ResultCode.NOT_FOUND,message); } + + public record CustomsCompletedEvent(long containerBookingId) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/CustomsAutoDispatchCoordinator.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/CustomsAutoDispatchCoordinator.java new file mode 100644 index 000000000..a38112533 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/CustomsAutoDispatchCoordinator.java @@ -0,0 +1,38 @@ +package com.baiyue.firstleg.customs.container.service; + +import com.baiyue.firstleg.customs.container.mapper.ContainerCustomsMapper; +import com.baiyue.firstleg.customs.container.service.ContainerCustomsService.CustomsCompletedEvent; +import com.baiyue.firstleg.outbound.dispatch.service.OutboundDispatchAutoConfirmService; +import jakarta.annotation.Resource; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Component +public class CustomsAutoDispatchCoordinator { + private static final Logger log=LoggerFactory.getLogger(CustomsAutoDispatchCoordinator.class); + private static final int BATCH_SIZE=50; + @Resource private ContainerCustomsMapper mapper; + @Resource private OutboundDispatchAutoConfirmService outbound; + + @TransactionalEventListener(phase=TransactionPhase.AFTER_COMMIT) + public void afterCustomsCompleted(CustomsCompletedEvent event) { + tryConfirm(event.containerBookingId()); + } + + @Scheduled(fixedDelayString="${baiyue.customs.auto-dispatch.fixed-delay-ms:60000}") + public void retryPending() { + for(Long containerBookingId:mapper.pendingAutoDispatch(BATCH_SIZE)) tryConfirm(containerBookingId); + } + + private void tryConfirm(long containerBookingId) { + try { + outbound.confirmIfReady(containerBookingId); + } catch (RuntimeException ex) { + log.warn("整柜报关完成后的自动离仓暂未收敛,稍后重试: containerBookingId={}",containerBookingId,ex); + } + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/LoadingListBuyDeclarationExportService.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/LoadingListBuyDeclarationExportService.java new file mode 100644 index 000000000..fc4279e69 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/LoadingListBuyDeclarationExportService.java @@ -0,0 +1,73 @@ +package com.baiyue.firstleg.customs.container.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.core.export.ExportContracts.*; +import com.baiyue.firstleg.customs.container.mapper.ContainerCustomsMapper; +import com.baiyue.firstleg.customs.container.mapper.ContainerCustomsMapper.BuyExportRow; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.*; + +@Service +public class LoadingListBuyDeclarationExportService { + public static final String TYPE="LOADING_LIST_BUY_DECLARATION_ITEMS"; + private static final int MAX_BATCH=500; + private static final List COLUMNS=List.of( + c("customerName","客户名称",ExportColumnType.STRING,24),c("orderNo","订单号",ExportColumnType.STRING,20), + c("inboundCode","入仓码",ExportColumnType.STRING,22),c("sku","SKU",ExportColumnType.STRING,18), + c("chineseName","中文品名",ExportColumnType.STRING,24),c("englishName","英文品名",ExportColumnType.STRING,24), + c("cartonCount","箱数",ExportColumnType.INTEGER,12),c("productQuantity","产品数量",ExportColumnType.INTEGER,14), + c("grossWeight","毛重",ExportColumnType.DECIMAL,14),c("netWeight","净重",ExportColumnType.DECIMAL,14), + c("volume","体积",ExportColumnType.DECIMAL,14),c("mark","唛头",ExportColumnType.STRING,20), + c("customerRemark","客户备注",ExportColumnType.STRING,30)); + private static final String SCHEMA_HASH=schemaHash(); + @Resource private ContainerCustomsMapper mapper; + @Value("${baiyue.security.internal-service-token}") private String token; + private byte[] signingKey; + @PostConstruct public void init(){signingKey=token.getBytes(StandardCharsets.UTF_8);} + + public ExportBatchResponse batch(ExportBatchRequest request){ + if(request==null||!"0".equals(request.tenantId())||!"PLATFORM".equals(request.appCode())) throw invalid("仅支持平台导出"); + if(request.limit()<1||request.limit()>MAX_BATCH) throw invalid("limit必须为1到500"); + long planId=positive(parameter(request,"loadingPlanId"),"loadingPlanId"); + var scope=mapper.planExportScope(planId); + if(scope==null||!"COMPLETED".equals(scope.planStatus())) throw invalid("只能导出已完成装柜的装柜清单"); + boolean first=request.snapshotToken()==null||request.snapshotToken().isBlank(); + long maximumId=first?mapper.maximumBuyItemId(planId):verify(request,planId); + String snapshot=first?sign(request,planId,maximumId):request.snapshotToken(); + long cursor=request.cursor()==null||request.cursor().isBlank()?0:positiveOrZero(request.cursor(),"cursor"); + List source=mapper.buyRows(planId,cursor,maximumId,request.limit()+1); + boolean more=source.size()>request.limit(); + List page=more?source.subList(0,request.limit()):source; + String next=page.isEmpty()?String.valueOf(cursor):String.valueOf(page.get(page.size()-1).itemId()); + return new ExportBatchResponse(1,SCHEMA_HASH,snapshot,first?COLUMNS:List.of(),page.stream().map(this::row).toList(), + next,more,first?mapper.countBuyRows(planId,maximumId):null); + } + + private Map row(BuyExportRow value){Map row=new LinkedHashMap<>(); + row.put("customerName",value.customerName());row.put("orderNo",value.orderNo());row.put("inboundCode",value.inboundCode()); + row.put("sku",value.skuCode());row.put("chineseName",value.productNameZh());row.put("englishName",value.productNameEn()); + row.put("cartonCount",value.packageCount());row.put("productQuantity",value.productQuantity());row.put("grossWeight",value.grossWeightKg()); + row.put("netWeight",value.netWeightKg());row.put("volume",value.volumeCbm());row.put("mark",value.shippingMark()); + row.put("customerRemark",value.customerRemark());return row;} + private String sign(ExportBatchRequest r,long p,long m){return "v1."+m+"."+Base64.getUrlEncoder().withoutPadding().encodeToString(hmac(subject(r,p,m)));} + private long verify(ExportBatchRequest r,long p){String[] a=r.snapshotToken().split("\\.",-1);if(a.length!=3||!"v1".equals(a[0]))throw invalid("snapshotToken无效"); + try{long m=Long.parseLong(a[1]);if(!MessageDigest.isEqual(hmac(subject(r,p,m)),Base64.getUrlDecoder().decode(a[2])))throw invalid("snapshotToken不匹配");return m;} + catch(IllegalArgumentException ex){throw invalid("snapshotToken无效");}} + private String subject(ExportBatchRequest r,long p,long m){return String.join("\n",TYPE,r.taskId(),r.tenantId(),r.appCode(),r.requestedBy(),String.valueOf(r.dispatchVersion()),String.valueOf(p),String.valueOf(m));} + private byte[] hmac(String s){try{Mac mac=Mac.getInstance("HmacSHA256");mac.init(new SecretKeySpec(signingKey,"HmacSHA256"));return mac.doFinal(s.getBytes(StandardCharsets.UTF_8));}catch(Exception ex){throw new IllegalStateException(ex);}} + private static Object parameter(ExportBatchRequest r,String k){if(r.parameters()==null||!r.parameters().keySet().equals(Set.of("loadingPlanId")))throw invalid("导出参数必须且只能包含loadingPlanId");return r.parameters().get(k);} + private static long positive(Object v,String f){long n=v instanceof Number x?x.longValue():positiveOrZero(Objects.toString(v,""),f);if(n<=0)throw invalid(f+"必须为正整数");return n;} + private static long positiveOrZero(String v,String f){try{long n=Long.parseLong(v);if(n<0)throw invalid(f+"不能为负数");return n;}catch(NumberFormatException ex){throw invalid(f+"必须为整数");}} + private static ExportColumn c(String k,String t,ExportColumnType y,int w){return new ExportColumn(k,t,y,w);} + private static String schemaHash(){try{MessageDigest d=MessageDigest.getInstance("SHA-256");for(ExportColumn c:COLUMNS)d.update((c.key()+"|"+c.title()+"|"+c.type()+"|"+c.width()+"\n").getBytes(StandardCharsets.UTF_8));return "sha256:"+HexFormat.of().formatHex(d.digest());}catch(Exception ex){throw new ExceptionInInitializerError(ex);}} + private static BizException invalid(String m){return new BizException(ResultCode.VALIDATION_FAILED,m);} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/controller/MerchantCustomsController.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/controller/MerchantCustomsController.java deleted file mode 100644 index 07f912514..000000000 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/controller/MerchantCustomsController.java +++ /dev/null @@ -1,88 +0,0 @@ -package com.baiyue.firstleg.customs.controller; - -import com.baiyue.common.core.api.ApiResponse; -import com.baiyue.common.security.AuthenticatedUser; -import com.baiyue.firstleg.customs.dto.CustomsDtos.*; -import com.baiyue.firstleg.customs.entity.CustomsDocumentEntity; -import com.baiyue.firstleg.customs.service.CustomsService; -import com.baiyue.firstleg.service.FirstlegScopeGuard; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.annotation.Resource; -import jakarta.validation.Valid; -import jakarta.validation.constraints.*; -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; - -@Tag(name = "客户端报关资料与回执") -@Validated -@RestController -@RequestMapping("/api/merchant/v1/customs") -public class MerchantCustomsController { - @Resource private CustomsService service; - - @GetMapping("/orders/{orderId}") - @PreAuthorize("hasAuthority('merchant:order:view')") - public ApiResponse byOrder(@AuthenticationPrincipal AuthenticatedUser user, - @PathVariable @Positive long orderId) { - return ApiResponse.success(service.merchantOrder(FirstlegScopeGuard.merchant(user), orderId)); - } - - @GetMapping("/cases/{caseId}") - @PreAuthorize("hasAuthority('merchant:order:view')") - public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, - @PathVariable @Positive long caseId) { - return ApiResponse.success(service.merchantDetail(FirstlegScopeGuard.merchant(user), caseId)); - } - - @PostMapping(value = "/cases/{caseId}/documents/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - @PreAuthorize("hasAuthority('merchant:order:edit')") - public ApiResponse upload(@AuthenticationPrincipal AuthenticatedUser user, - @PathVariable @Positive long caseId, - @RequestHeader("Idempotency-Key") String key, - @RequestParam @NotBlank String itemCode, - @RequestParam @Min(0) int expectedVersion, - @RequestPart("file") MultipartFile file) { - long tenantId = FirstlegScopeGuard.merchant(user); - return ApiResponse.success(service.uploadDocument(tenantId, caseId, "MERCHANT", user.userId(), user.username(), key, - new DocumentUploadData(itemCode, expectedVersion), file)); - } - - @PostMapping("/cases/{caseId}/documents") - @PreAuthorize("hasAuthority('merchant:order:edit')") - public ApiResponse reference(@AuthenticationPrincipal AuthenticatedUser user, - @PathVariable @Positive long caseId, - @RequestHeader("Idempotency-Key") String key, - @Valid @RequestBody DocumentReferenceRequest request) { - long tenantId = FirstlegScopeGuard.merchant(user); - return ApiResponse.success(service.referenceDocument(tenantId, caseId, "MERCHANT", user.userId(), user.username(), key, request)); - } - - @GetMapping("/cases/{caseId}/documents/{documentId}/download") - @PreAuthorize("hasAuthority('merchant:order:view')") - public ResponseEntity downloadDocument(@AuthenticationPrincipal AuthenticatedUser user, - @PathVariable @Positive long caseId, - @PathVariable @Positive long documentId) { - return response(service.downloadDocument(FirstlegScopeGuard.merchant(user), caseId, documentId, true)); - } - - @GetMapping("/cases/{caseId}/receipts/{receiptId}/download") - @PreAuthorize("hasAuthority('merchant:order:view')") - public ResponseEntity downloadReceipt(@AuthenticationPrincipal AuthenticatedUser user, - @PathVariable @Positive long caseId, - @PathVariable @Positive long receiptId) { - return response(service.downloadReceipt(FirstlegScopeGuard.merchant(user), caseId, receiptId, true)); - } - - private static ResponseEntity response(Download value) { - return ResponseEntity.ok().cacheControl(CacheControl.noStore()) - .contentType(MediaType.parseMediaType(value.contentType())) - .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + safeName(value.fileName()) + "\"") - .contentLength(value.content().length).body(value.content()); - } - - private static String safeName(String value) { return value.replace("\r", "").replace("\n", "").replace("\"", ""); } -} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/controller/PlatformCustomsController.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/controller/PlatformCustomsController.java deleted file mode 100644 index 82a9046e2..000000000 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/controller/PlatformCustomsController.java +++ /dev/null @@ -1,161 +0,0 @@ -package com.baiyue.firstleg.customs.controller; - -import com.baiyue.common.audit.BusinessAudit; -import com.baiyue.common.core.api.*; -import com.baiyue.common.security.AuthenticatedUser; -import com.baiyue.firstleg.customs.dto.CustomsDtos.*; -import com.baiyue.firstleg.customs.entity.*; -import com.baiyue.firstleg.customs.service.CustomsService; -import com.baiyue.firstleg.service.FirstlegScopeGuard; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.annotation.Resource; -import jakarta.validation.Valid; -import jakarta.validation.constraints.*; -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; - -@Tag(name = "三期报关协同") -@Validated -@RestController -@RequestMapping("/api/platform/v1/customs") -public class PlatformCustomsController { - @Resource private CustomsService service; - - @GetMapping("/cases") - @PreAuthorize("hasAuthority('platform:firstleg:customs:list')") - public ApiResponse> page(@AuthenticationPrincipal AuthenticatedUser user, - @Valid Query query) { - FirstlegScopeGuard.platform(user); - return ApiResponse.success(service.page(query)); - } - - @GetMapping("/cases/{caseId}") - @PreAuthorize("hasAuthority('platform:firstleg:customs:view')") - public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, - @PathVariable @Positive long caseId) { - FirstlegScopeGuard.platform(user); - return ApiResponse.success(service.platformDetail(caseId)); - } - - @PostMapping("/checklist-versions") - @PreAuthorize("hasAuthority('platform:firstleg:customs:manage')") - @BusinessAudit(event = "CUSTOMS_CHECKLIST_CREATE", resourceType = "CUSTOMS_CHECKLIST", captureBefore = false) - public ApiResponse createChecklist(@AuthenticationPrincipal AuthenticatedUser user, - @RequestHeader("Idempotency-Key") String key, - @Valid @RequestBody CreateChecklistRequest request) { - FirstlegScopeGuard.platform(user); - return ApiResponse.success(service.createChecklist(user.userId(), user.username(), key, request)); - } - - @PostMapping("/cases") - @PreAuthorize("hasAuthority('platform:firstleg:customs:manage')") - @BusinessAudit(event = "CUSTOMS_CASE_CREATE", resourceType = "CUSTOMS_CASE", captureBefore = false) - public ApiResponse createCase(@AuthenticationPrincipal AuthenticatedUser user, - @RequestHeader("Idempotency-Key") String key, - @Valid @RequestBody CreateCaseRequest request) { - FirstlegScopeGuard.platform(user, request.customerId()); - return ApiResponse.success(service.createCase(user.userId(), user.username(), key, request)); - } - - @PostMapping(value = "/cases/{caseId}/documents/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - @PreAuthorize("hasAuthority('platform:firstleg:customs:manage')") - public ApiResponse uploadDocument(@AuthenticationPrincipal AuthenticatedUser user, - @PathVariable @Positive long caseId, - @RequestHeader("Idempotency-Key") String key, - @RequestParam @NotBlank String itemCode, - @RequestParam @Min(0) int expectedVersion, - @RequestPart("file") MultipartFile file) { - FirstlegScopeGuard.platform(user); - return ApiResponse.success(service.uploadDocument(0, caseId, "PLATFORM", user.userId(), user.username(), key, - new DocumentUploadData(itemCode, expectedVersion), file)); - } - - @PostMapping("/cases/{caseId}/documents") - @PreAuthorize("hasAuthority('platform:firstleg:customs:manage')") - public ApiResponse referenceDocument(@AuthenticationPrincipal AuthenticatedUser user, - @PathVariable @Positive long caseId, - @RequestHeader("Idempotency-Key") String key, - @Valid @RequestBody DocumentReferenceRequest request) { - FirstlegScopeGuard.platform(user); - return ApiResponse.success(service.referenceDocument(0, caseId, "PLATFORM", user.userId(), user.username(), key, request)); - } - - @PostMapping("/cases/{caseId}/documents/{documentId}/review") - @PreAuthorize("hasAuthority('platform:firstleg:customs:review')") - @BusinessAudit(event = "CUSTOMS_DOCUMENT_REVIEW", resourceType = "CUSTOMS_DOCUMENT") - public ApiResponse review(@AuthenticationPrincipal AuthenticatedUser user, - @PathVariable @Positive long caseId, - @PathVariable @Positive long documentId, - @RequestHeader("Idempotency-Key") String key, - @Valid @RequestBody ReviewRequest request) { - FirstlegScopeGuard.platform(user); - return ApiResponse.success(service.review(caseId, documentId, user.userId(), user.username(), key, request)); - } - - @PostMapping("/cases/{caseId}/submission-batches") - @PreAuthorize("hasAuthority('platform:firstleg:customs:submit')") - @BusinessAudit(event = "CUSTOMS_AGENT_SUBMIT", resourceType = "CUSTOMS_SUBMISSION_BATCH") - public ApiResponse submit(@AuthenticationPrincipal AuthenticatedUser user, - @PathVariable @Positive long caseId, - @RequestHeader("Idempotency-Key") String key, - @Valid @RequestBody SubmitBatchRequest request) { - FirstlegScopeGuard.platform(user); - return ApiResponse.success(service.submitBatch(caseId, user.userId(), user.username(), key, request)); - } - - @PostMapping(value = "/submission-batches/{batchId}/receipts/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - @PreAuthorize("hasAuthority('platform:firstleg:customs:receipt')") - public ApiResponse uploadReceipt(@AuthenticationPrincipal AuthenticatedUser user, - @PathVariable @Positive long batchId, - @RequestHeader("Idempotency-Key") String key, - @RequestParam @Pattern(regexp = "RECEIVED|DECLARED|REJECTED|INSPECTION|RELEASED|OTHER") String receiptType, - @RequestParam @Pattern(regexp = "PLATFORM|CUSTOMER") String visibility, - @RequestParam(required = false) @Size(max = 500) String note, - @RequestParam @Min(0) int expectedVersion, - @RequestPart("file") MultipartFile file) { - FirstlegScopeGuard.platform(user); - return ApiResponse.success(service.uploadReceipt(batchId, user.userId(), user.username(), key, - new ReceiptUploadData(receiptType, visibility, note, expectedVersion), file)); - } - - @PostMapping("/submission-batches/{batchId}/receipts") - @PreAuthorize("hasAuthority('platform:firstleg:customs:receipt')") - public ApiResponse referenceReceipt(@AuthenticationPrincipal AuthenticatedUser user, - @PathVariable @Positive long batchId, - @RequestHeader("Idempotency-Key") String key, - @Valid @RequestBody ReceiptReferenceRequest request) { - FirstlegScopeGuard.platform(user); - return ApiResponse.success(service.referenceReceipt(batchId, user.userId(), user.username(), key, request)); - } - - @GetMapping("/cases/{caseId}/documents/{documentId}/download") - @PreAuthorize("hasAuthority('platform:firstleg:customs:view')") - public ResponseEntity downloadDocument(@AuthenticationPrincipal AuthenticatedUser user, - @PathVariable @Positive long caseId, - @PathVariable @Positive long documentId) { - FirstlegScopeGuard.platform(user); - return response(service.downloadDocument(0, caseId, documentId, false)); - } - - @GetMapping("/cases/{caseId}/receipts/{receiptId}/download") - @PreAuthorize("hasAuthority('platform:firstleg:customs:view')") - public ResponseEntity downloadReceipt(@AuthenticationPrincipal AuthenticatedUser user, - @PathVariable @Positive long caseId, - @PathVariable @Positive long receiptId) { - FirstlegScopeGuard.platform(user); - return response(service.downloadReceipt(0, caseId, receiptId, false)); - } - - private static ResponseEntity response(Download value) { - return ResponseEntity.ok().cacheControl(CacheControl.noStore()) - .contentType(MediaType.parseMediaType(value.contentType())) - .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + safeName(value.fileName()) + "\"") - .contentLength(value.content().length).body(value.content()); - } - - private static String safeName(String value) { return value.replace("\r", "").replace("\n", "").replace("\"", ""); } -} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/dto/CustomsDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/dto/CustomsDtos.java deleted file mode 100644 index 7efb53113..000000000 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/dto/CustomsDtos.java +++ /dev/null @@ -1,80 +0,0 @@ -package com.baiyue.firstleg.customs.dto; - -import com.baiyue.firstleg.customs.entity.*; -import jakarta.validation.Valid; -import jakarta.validation.constraints.*; - -import java.time.LocalDateTime; -import java.util.List; - -public final class CustomsDtos { - private CustomsDtos() {} - - public record ChecklistItem(@NotBlank @Size(max = 40) String code, - @NotBlank @Size(max = 100) String name, - boolean required) {} - - public record CreateChecklistRequest(@NotBlank @Size(max = 40) String checklistCode, - @NotBlank @Size(max = 32) String versionNo, - @NotBlank @Size(max = 32) String countryCode, - @NotBlank @Size(max = 40) String channelCode, - @NotBlank @Size(max = 40) String cargoType, - @NotEmpty List<@Valid ChecklistItem> items) {} - - public record ChecklistView(CustomsChecklistVersionEntity version, List items) {} - - public record CreateCaseRequest(@Positive long customerId, - @Positive long orderId, - @Positive long inboundId, - @Positive Long containerBookingId, - @Positive long checklistVersionId, - @Size(max = 500) String remark, - @Min(0) int expectedVersion) {} - - public record Query(Long customerId, Long orderId, Long inboundId, Long containerBookingId, String status, - @Min(1) int page, @Min(1) @Max(200) int size) { - public Query { - if (page == 0) page = 1; - if (size == 0) size = 20; - } - } - - public record DocumentReferenceRequest(@NotBlank @Size(max = 40) String itemCode, - @Positive long fileId, - @Min(0) int expectedVersion) {} - - public record DocumentUploadData(@NotBlank @Size(max = 40) String itemCode, - @Min(0) int expectedVersion) {} - - public record ReviewRequest(@NotBlank @Pattern(regexp = "APPROVE|RETURN") String decision, - @Size(max = 500) String reason, - LocalDateTime supplementDueAt, - @Min(0) int expectedVersion) {} - - public record SubmitBatchRequest(@NotNull @jakarta.validation.constraints.Positive Long customsAgentId, - @Size(max = 128) String customsAgentName, - @Size(max = 128) String externalReference, - @Size(max = 500) String note, - @Min(0) int expectedVersion) {} - - public record ReceiptReferenceRequest(@NotBlank @Pattern(regexp = "RECEIVED|DECLARED|REJECTED|INSPECTION|RELEASED|OTHER") String receiptType, - @Positive long fileId, - @NotBlank @Pattern(regexp = "PLATFORM|CUSTOMER") String visibility, - @Size(max = 500) String note, - @Min(0) int expectedVersion) {} - - public record ReceiptUploadData(@NotBlank @Pattern(regexp = "RECEIVED|DECLARED|REJECTED|INSPECTION|RELEASED|OTHER") String receiptType, - @NotBlank @Pattern(regexp = "PLATFORM|CUSTOMER") String visibility, - @Size(max = 500) String note, - @Min(0) int expectedVersion) {} - - public record CaseDetail(CustomsCaseEntity customsCase, - ChecklistView checklist, - List documents, - List supplements, - List submissionBatches, - List receipts, - List history) {} - - public record Download(String fileName, String contentType, byte[] content) {} -} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/entity/CustomsCaseEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/entity/CustomsCaseEntity.java deleted file mode 100644 index 06b73b886..000000000 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/entity/CustomsCaseEntity.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.baiyue.firstleg.customs.entity; - -import com.baomidou.mybatisplus.annotation.*; -import lombok.Getter; -import lombok.Setter; -import java.time.LocalDateTime; - -@Getter @Setter @TableName("fl_customs_case") -public class CustomsCaseEntity { - @TableId(type = IdType.ASSIGN_ID) private Long id; - private String customsNo; - private Long tenantId; - private Long orderId; - private Long inboundId; - private Long containerBookingId; - private Long checklistVersionId; - private String checklistCode; - private String checklistVersionNo; - private String status; - private String remark; - private Long createdBy; - private String createdByName; - private LocalDateTime createdAt; - private Long updatedBy; - private String updatedByName; - private LocalDateTime updatedAt; - @Version private Integer version; -} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/entity/CustomsChecklistVersionEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/entity/CustomsChecklistVersionEntity.java deleted file mode 100644 index 5ba3a881f..000000000 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/entity/CustomsChecklistVersionEntity.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.baiyue.firstleg.customs.entity; - -import com.baomidou.mybatisplus.annotation.*; -import lombok.Getter; -import lombok.Setter; -import java.time.LocalDateTime; - -@Getter @Setter @TableName("fl_customs_checklist_version") -public class CustomsChecklistVersionEntity { - @TableId(type = IdType.ASSIGN_ID) private Long id; - private String checklistCode; - private String versionNo; - private String countryCode; - private String channelCode; - private String cargoType; - private String itemsJson; - private String status; - private Long createdBy; - private String createdByName; - private LocalDateTime createdAt; -} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/entity/CustomsDocumentEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/entity/CustomsDocumentEntity.java deleted file mode 100644 index 2a835bfb4..000000000 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/entity/CustomsDocumentEntity.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.baiyue.firstleg.customs.entity; - -import com.baomidou.mybatisplus.annotation.*; -import lombok.Getter; -import lombok.Setter; -import java.time.LocalDateTime; - -@Getter @Setter @TableName("fl_customs_document") -public class CustomsDocumentEntity { - @TableId(type = IdType.ASSIGN_ID) private Long id; - private Long customsCaseId; - private String checklistItemCode; - private Long fileId; - private Integer documentVersion; - private Long replacesDocumentId; - private String fileName; - private String fileHash; - private String status; - private String visibility; - private Long uploadedBy; - private String uploadedByType; - private LocalDateTime uploadedAt; - private Long reviewedBy; - private String reviewedByName; - private LocalDateTime reviewedAt; - private String reviewReason; -} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/entity/CustomsHistoryEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/entity/CustomsHistoryEntity.java deleted file mode 100644 index 10f75b5a5..000000000 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/entity/CustomsHistoryEntity.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.baiyue.firstleg.customs.entity; - -import com.baomidou.mybatisplus.annotation.*; -import lombok.Getter; -import lombok.Setter; -import java.time.LocalDateTime; - -@Getter @Setter @TableName("fl_customs_history") -public class CustomsHistoryEntity { - @TableId(type = IdType.ASSIGN_ID) private Long id; - private Long customsCaseId; - private String action; - private String fromStatus; - private String toStatus; - private String resourceType; - private Long resourceId; - private String note; - private Long operatorId; - private String operatorName; - private String operatorType; - private LocalDateTime createdAt; -} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/entity/CustomsOperationEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/entity/CustomsOperationEntity.java deleted file mode 100644 index 7b2af0c6b..000000000 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/entity/CustomsOperationEntity.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.baiyue.firstleg.customs.entity; - -import com.baomidou.mybatisplus.annotation.*; -import lombok.Getter; -import lombok.Setter; -import java.time.LocalDateTime; - -@Getter @Setter @TableName("fl_customs_operation") -public class CustomsOperationEntity { - @TableId(type = IdType.ASSIGN_ID) private Long id; - private Long operatorId; - private String action; - private String idempotencyKey; - private String requestHash; - private String status; - private String resultType; - private Long resultId; - private LocalDateTime createdAt; - private LocalDateTime completedAt; -} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/entity/CustomsReceiptEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/entity/CustomsReceiptEntity.java deleted file mode 100644 index 410f6df34..000000000 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/entity/CustomsReceiptEntity.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.baiyue.firstleg.customs.entity; - -import com.baomidou.mybatisplus.annotation.*; -import lombok.Getter; -import lombok.Setter; -import java.time.LocalDateTime; - -@Getter @Setter @TableName("fl_customs_receipt") -public class CustomsReceiptEntity { - @TableId(type = IdType.ASSIGN_ID) private Long id; - private Long customsCaseId; - private Long submissionBatchId; - private String receiptType; - private Long fileId; - private Integer receiptVersion; - private Long replacesReceiptId; - private String fileName; - private String fileHash; - private String visibility; - private String note; - private Long uploadedBy; - private String uploadedByName; - private LocalDateTime uploadedAt; -} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/entity/CustomsSubmissionBatchEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/entity/CustomsSubmissionBatchEntity.java deleted file mode 100644 index d68fa1c84..000000000 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/entity/CustomsSubmissionBatchEntity.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.baiyue.firstleg.customs.entity; - -import com.baomidou.mybatisplus.annotation.*; -import lombok.Getter; -import lombok.Setter; -import java.time.LocalDateTime; - -@Getter @Setter @TableName("fl_customs_submission_batch") -public class CustomsSubmissionBatchEntity { - @TableId(type = IdType.ASSIGN_ID) private Long id; - private Long customsCaseId; - private String batchNo; - private Integer batchVersion; - private Long customsAgentId; - private String customsAgentName; - private String externalReference; - private String fileSnapshotJson; - private String status; - private String note; - private Long submittedBy; - private String submittedByName; - private LocalDateTime submittedAt; -} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/entity/CustomsSupplementRequestEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/entity/CustomsSupplementRequestEntity.java deleted file mode 100644 index bdcd0028d..000000000 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/entity/CustomsSupplementRequestEntity.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.baiyue.firstleg.customs.entity; - -import com.baomidou.mybatisplus.annotation.*; -import lombok.Getter; -import lombok.Setter; -import java.time.LocalDateTime; - -@Getter @Setter @TableName("fl_customs_supplement_request") -public class CustomsSupplementRequestEntity { - @TableId(type = IdType.ASSIGN_ID) private Long id; - private Long customsCaseId; - private Long sourceDocumentId; - private Long resolvedDocumentId; - private String checklistItemCode; - private String reason; - private LocalDateTime dueAt; - private String status; - private Long requestedBy; - private String requestedByName; - private LocalDateTime requestedAt; - private Long resolvedBy; - private LocalDateTime resolvedAt; -} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/mapper/CustomsCaseMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/mapper/CustomsCaseMapper.java deleted file mode 100644 index 68a968aa4..000000000 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/mapper/CustomsCaseMapper.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.baiyue.firstleg.customs.mapper; -import com.baomidou.mybatisplus.core.mapper.BaseMapper; -import com.baiyue.firstleg.customs.entity.CustomsCaseEntity; -import org.apache.ibatis.annotations.Mapper; -import org.apache.ibatis.annotations.Param; -import org.apache.ibatis.annotations.Select; -@Mapper public interface CustomsCaseMapper extends BaseMapper { - @Select("SELECT COUNT(*) FROM fl_container_document WHERE container_booking_id=#{containerBookingId} " - + "AND document_type='CUSTOMS_DECLARATION' AND completed_at IS NOT NULL") - int completedLoadingDeclaration(@Param("containerBookingId") long containerBookingId); -} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/mapper/CustomsChecklistVersionMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/mapper/CustomsChecklistVersionMapper.java deleted file mode 100644 index a4a749625..000000000 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/mapper/CustomsChecklistVersionMapper.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.baiyue.firstleg.customs.mapper; -import com.baomidou.mybatisplus.core.mapper.BaseMapper; -import com.baiyue.firstleg.customs.entity.CustomsChecklistVersionEntity; -import org.apache.ibatis.annotations.Mapper; -@Mapper public interface CustomsChecklistVersionMapper extends BaseMapper {} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/mapper/CustomsDocumentMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/mapper/CustomsDocumentMapper.java deleted file mode 100644 index e3e5deb2d..000000000 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/mapper/CustomsDocumentMapper.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.baiyue.firstleg.customs.mapper; -import com.baomidou.mybatisplus.core.mapper.BaseMapper; -import com.baiyue.firstleg.customs.entity.CustomsDocumentEntity; -import org.apache.ibatis.annotations.Mapper; -@Mapper public interface CustomsDocumentMapper extends BaseMapper {} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/mapper/CustomsHistoryMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/mapper/CustomsHistoryMapper.java deleted file mode 100644 index ce7ebd68e..000000000 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/mapper/CustomsHistoryMapper.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.baiyue.firstleg.customs.mapper; -import com.baomidou.mybatisplus.core.mapper.BaseMapper; -import com.baiyue.firstleg.customs.entity.CustomsHistoryEntity; -import org.apache.ibatis.annotations.Mapper; -@Mapper public interface CustomsHistoryMapper extends BaseMapper {} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/mapper/CustomsOperationMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/mapper/CustomsOperationMapper.java deleted file mode 100644 index eccf3fc48..000000000 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/mapper/CustomsOperationMapper.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.baiyue.firstleg.customs.mapper; -import com.baomidou.mybatisplus.core.mapper.BaseMapper; -import com.baiyue.firstleg.customs.entity.CustomsOperationEntity; -import org.apache.ibatis.annotations.Mapper; -@Mapper public interface CustomsOperationMapper extends BaseMapper {} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/mapper/CustomsReceiptMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/mapper/CustomsReceiptMapper.java deleted file mode 100644 index 4edf7bf3b..000000000 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/mapper/CustomsReceiptMapper.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.baiyue.firstleg.customs.mapper; -import com.baomidou.mybatisplus.core.mapper.BaseMapper; -import com.baiyue.firstleg.customs.entity.CustomsReceiptEntity; -import org.apache.ibatis.annotations.Mapper; -@Mapper public interface CustomsReceiptMapper extends BaseMapper {} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/mapper/CustomsSubmissionBatchMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/mapper/CustomsSubmissionBatchMapper.java deleted file mode 100644 index 92dd546ee..000000000 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/mapper/CustomsSubmissionBatchMapper.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.baiyue.firstleg.customs.mapper; -import com.baomidou.mybatisplus.core.mapper.BaseMapper; -import com.baiyue.firstleg.customs.entity.CustomsSubmissionBatchEntity; -import org.apache.ibatis.annotations.Mapper; -@Mapper public interface CustomsSubmissionBatchMapper extends BaseMapper {} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/mapper/CustomsSupplementRequestMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/mapper/CustomsSupplementRequestMapper.java deleted file mode 100644 index ce49b66ca..000000000 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/mapper/CustomsSupplementRequestMapper.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.baiyue.firstleg.customs.mapper; -import com.baomidou.mybatisplus.core.mapper.BaseMapper; -import com.baiyue.firstleg.customs.entity.CustomsSupplementRequestEntity; -import org.apache.ibatis.annotations.Mapper; -@Mapper public interface CustomsSupplementRequestMapper extends BaseMapper {} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/repository/CustomsRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/repository/CustomsRepository.java deleted file mode 100644 index 3d8a35e42..000000000 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/repository/CustomsRepository.java +++ /dev/null @@ -1,191 +0,0 @@ -package com.baiyue.firstleg.customs.repository; - -import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; -import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; -import com.baomidou.mybatisplus.extension.plugins.pagination.Page; -import com.baiyue.common.core.api.PageResult; -import com.baiyue.firstleg.customs.dto.CustomsDtos.Query; -import com.baiyue.firstleg.customs.entity.*; -import com.baiyue.firstleg.customs.mapper.*; -import com.baiyue.firstleg.entity.LoadingPlanEntity; -import com.baiyue.firstleg.mapper.InboundOrderMapper; -import com.baiyue.firstleg.mapper.LoadingPlanItemMapper; -import com.baiyue.firstleg.mapper.LoadingPlanMapper; -import jakarta.annotation.Resource; -import org.springframework.stereotype.Repository; - -import java.time.LocalDateTime; -import java.util.List; - -@Repository -public class CustomsRepository { - @Resource private CustomsChecklistVersionMapper checklists; - @Resource private CustomsCaseMapper cases; - @Resource private CustomsDocumentMapper documents; - @Resource private CustomsSupplementRequestMapper supplements; - @Resource private CustomsSubmissionBatchMapper batches; - @Resource private CustomsReceiptMapper receipts; - @Resource private CustomsHistoryMapper histories; - @Resource private CustomsOperationMapper operations; - @Resource private InboundOrderMapper inboundOrders; - @Resource private LoadingPlanMapper loadingPlans; - @Resource private LoadingPlanItemMapper loadingPlanItems; - - public void insert(CustomsChecklistVersionEntity value) { checklists.insert(value); } - public CustomsChecklistVersionEntity checklist(long id) { return checklists.selectById(id); } - public CustomsChecklistVersionEntity checklist(String code, String version) { - return checklists.selectOne(new LambdaQueryWrapper() - .eq(CustomsChecklistVersionEntity::getChecklistCode, code) - .eq(CustomsChecklistVersionEntity::getVersionNo, version)); - } - - public void insert(CustomsCaseEntity value) { cases.insert(value); } - public CustomsCaseEntity customsCase(long id) { return cases.selectById(id); } - public CustomsCaseEntity customsCase(long tenantId, long id) { - return cases.selectOne(new LambdaQueryWrapper() - .eq(CustomsCaseEntity::getId, id).eq(CustomsCaseEntity::getTenantId, tenantId)); - } - public CustomsCaseEntity caseByOrder(long tenantId, long orderId) { - return cases.selectOne(new LambdaQueryWrapper() - .eq(CustomsCaseEntity::getTenantId, tenantId).eq(CustomsCaseEntity::getOrderId, orderId) - .orderByDesc(CustomsCaseEntity::getUpdatedAt).last("LIMIT 1")); - } - public PageResult page(Query query) { - var wrapper = new LambdaQueryWrapper() - .eq(query.customerId() != null, CustomsCaseEntity::getTenantId, query.customerId()) - .eq(query.orderId() != null, CustomsCaseEntity::getOrderId, query.orderId()) - .eq(query.inboundId() != null, CustomsCaseEntity::getInboundId, query.inboundId()) - .eq(query.containerBookingId() != null, CustomsCaseEntity::getContainerBookingId, query.containerBookingId()) - .eq(query.status() != null && !query.status().isBlank(), CustomsCaseEntity::getStatus, query.status()) - .orderByDesc(CustomsCaseEntity::getUpdatedAt).orderByDesc(CustomsCaseEntity::getId); - return PageResult.from(cases.selectPage(new Page<>(query.page(), query.size()), wrapper)); - } - public int transition(long id, int expectedVersion, String status, long operatorId, String operatorName) { - return cases.update(null, new LambdaUpdateWrapper() - .eq(CustomsCaseEntity::getId, id).eq(CustomsCaseEntity::getVersion, expectedVersion) - .set(CustomsCaseEntity::getStatus, status).set(CustomsCaseEntity::getUpdatedBy, operatorId) - .set(CustomsCaseEntity::getUpdatedByName, operatorName).set(CustomsCaseEntity::getUpdatedAt, LocalDateTime.now()) - .set(CustomsCaseEntity::getVersion, expectedVersion + 1)); - } - public boolean inboundBelongsTo(long tenantId, long orderId, long inboundId) { - return inboundOrders.selectCount(new LambdaQueryWrapper() - .eq(com.baiyue.firstleg.entity.InboundOrderEntity::getTenantId, tenantId) - .eq(com.baiyue.firstleg.entity.InboundOrderEntity::getOrderId, orderId) - .eq(com.baiyue.firstleg.entity.InboundOrderEntity::getId, inboundId)) > 0; - } - - public CustomsCaseEntity gateCase(long tenantId, long orderId, long inboundId, long containerBookingId) { - return cases.selectOne(new LambdaQueryWrapper() - .eq(CustomsCaseEntity::getTenantId, tenantId).eq(CustomsCaseEntity::getOrderId, orderId) - .eq(CustomsCaseEntity::getInboundId, inboundId) - .eq(CustomsCaseEntity::getContainerBookingId, containerBookingId) - .orderByDesc(CustomsCaseEntity::getUpdatedAt).last("LIMIT 1")); - } - public boolean loadingDeclarationCompleted(long containerBookingId) { - return cases.completedLoadingDeclaration(containerBookingId) == 1; - } - public boolean containerCarriesOrder(long containerId, long tenantId, long orderId) { - LoadingPlanEntity plan = loadingPlans.selectOne(new LambdaQueryWrapper() - .eq(LoadingPlanEntity::getContainerBookingId, containerId)); - return plan != null && loadingPlanItems.selectCount(new LambdaQueryWrapper() - .eq(com.baiyue.firstleg.entity.LoadingPlanItemEntity::getLoadingPlanId, plan.getId()) - .eq(com.baiyue.firstleg.entity.LoadingPlanItemEntity::getAccountId, tenantId) - .eq(com.baiyue.firstleg.entity.LoadingPlanItemEntity::getOrderId, orderId) - .eq(com.baiyue.firstleg.entity.LoadingPlanItemEntity::getDeleted, false)) > 0; - } - - public void insert(CustomsDocumentEntity value) { documents.insert(value); } - public CustomsDocumentEntity document(long id) { return documents.selectById(id); } - public List documents(long caseId) { - return documents.selectList(new LambdaQueryWrapper() - .eq(CustomsDocumentEntity::getCustomsCaseId, caseId) - .orderByAsc(CustomsDocumentEntity::getChecklistItemCode) - .orderByAsc(CustomsDocumentEntity::getDocumentVersion)); - } - public CustomsDocumentEntity latestDocument(long caseId, String itemCode) { - return documents.selectOne(new LambdaQueryWrapper() - .eq(CustomsDocumentEntity::getCustomsCaseId, caseId) - .eq(CustomsDocumentEntity::getChecklistItemCode, itemCode) - .orderByDesc(CustomsDocumentEntity::getDocumentVersion).last("LIMIT 1")); - } - public int markDocument(long id, String status, long reviewerId, String reviewerName, String reason) { - return documents.update(null, new LambdaUpdateWrapper() - .eq(CustomsDocumentEntity::getId, id) - .set(CustomsDocumentEntity::getStatus, status).set(CustomsDocumentEntity::getReviewedBy, reviewerId) - .set(CustomsDocumentEntity::getReviewedByName, reviewerName) - .set(CustomsDocumentEntity::getReviewedAt, LocalDateTime.now()) - .set(CustomsDocumentEntity::getReviewReason, reason)); - } - public int supersede(long id) { - return documents.update(null, new LambdaUpdateWrapper() - .eq(CustomsDocumentEntity::getId, id).in(CustomsDocumentEntity::getStatus, "UPLOADED", "RETURNED") - .set(CustomsDocumentEntity::getStatus, "SUPERSEDED")); - } - public int freeze(long id) { - return documents.update(null, new LambdaUpdateWrapper() - .eq(CustomsDocumentEntity::getId, id).eq(CustomsDocumentEntity::getStatus, "APPROVED") - .set(CustomsDocumentEntity::getStatus, "FROZEN")); - } - - public void insert(CustomsSupplementRequestEntity value) { supplements.insert(value); } - public List supplements(long caseId) { - return supplements.selectList(new LambdaQueryWrapper() - .eq(CustomsSupplementRequestEntity::getCustomsCaseId, caseId) - .orderByAsc(CustomsSupplementRequestEntity::getRequestedAt)); - } - public void resolveSupplements(long caseId, String itemCode, long documentId, long operatorId) { - supplements.update(null, new LambdaUpdateWrapper() - .eq(CustomsSupplementRequestEntity::getCustomsCaseId, caseId) - .eq(CustomsSupplementRequestEntity::getChecklistItemCode, itemCode) - .eq(CustomsSupplementRequestEntity::getStatus, "OPEN") - .set(CustomsSupplementRequestEntity::getStatus, "RESOLVED") - .set(CustomsSupplementRequestEntity::getResolvedDocumentId, documentId) - .set(CustomsSupplementRequestEntity::getResolvedBy, operatorId) - .set(CustomsSupplementRequestEntity::getResolvedAt, LocalDateTime.now())); - } - - public void insert(CustomsSubmissionBatchEntity value) { batches.insert(value); } - public CustomsSubmissionBatchEntity batch(long id) { return batches.selectById(id); } - public List batches(long caseId) { - return batches.selectList(new LambdaQueryWrapper() - .eq(CustomsSubmissionBatchEntity::getCustomsCaseId, caseId) - .orderByAsc(CustomsSubmissionBatchEntity::getBatchVersion)); - } - - public void insert(CustomsReceiptEntity value) { receipts.insert(value); } - public CustomsReceiptEntity receipt(long id) { return receipts.selectById(id); } - public CustomsReceiptEntity latestReceipt(long batchId, String type) { - return receipts.selectOne(new LambdaQueryWrapper() - .eq(CustomsReceiptEntity::getSubmissionBatchId, batchId) - .eq(CustomsReceiptEntity::getReceiptType, type) - .orderByDesc(CustomsReceiptEntity::getReceiptVersion).last("LIMIT 1")); - } - public List receipts(long caseId, boolean customerOnly) { - return receipts.selectList(new LambdaQueryWrapper() - .eq(CustomsReceiptEntity::getCustomsCaseId, caseId) - .eq(customerOnly, CustomsReceiptEntity::getVisibility, "CUSTOMER") - .orderByAsc(CustomsReceiptEntity::getUploadedAt)); - } - - public void insert(CustomsHistoryEntity value) { histories.insert(value); } - public List history(long caseId) { - return histories.selectList(new LambdaQueryWrapper() - .eq(CustomsHistoryEntity::getCustomsCaseId, caseId).orderByAsc(CustomsHistoryEntity::getCreatedAt)); - } - - public void insert(CustomsOperationEntity value) { operations.insert(value); } - public CustomsOperationEntity operation(long operatorId, String action, String key) { - return operations.selectOne(new LambdaQueryWrapper() - .eq(CustomsOperationEntity::getOperatorId, operatorId) - .eq(CustomsOperationEntity::getAction, action) - .eq(CustomsOperationEntity::getIdempotencyKey, key)); - } - public int completeOperation(long id, String resultType, long resultId) { - return operations.update(null, new LambdaUpdateWrapper() - .eq(CustomsOperationEntity::getId, id).eq(CustomsOperationEntity::getStatus, "PROCESSING") - .set(CustomsOperationEntity::getStatus, "SUCCESS") - .set(CustomsOperationEntity::getResultType, resultType) - .set(CustomsOperationEntity::getResultId, resultId) - .set(CustomsOperationEntity::getCompletedAt, LocalDateTime.now())); - } -} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributor.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributor.java index 61a048f5a..3149c8058 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributor.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributor.java @@ -1,6 +1,6 @@ package com.baiyue.firstleg.customs.service; -import com.baiyue.firstleg.customs.repository.CustomsRepository; +import com.baiyue.firstleg.customs.container.mapper.ContainerCustomsMapper; import com.baiyue.firstleg.outbound.dispatch.dto.OutboundDispatchDtos.GateItem; import com.baiyue.firstleg.outbound.dispatch.gate.OutboundDispatchGateContributor; import jakarta.annotation.Resource; @@ -8,7 +8,7 @@ import org.springframework.stereotype.Component; @Component public class CustomsDispatchGateContributor implements OutboundDispatchGateContributor { - @Resource private CustomsRepository repository; + @Resource private ContainerCustomsMapper mapper; @Override public String code() { return "CUSTOMS_CLEARANCE"; } @@ -17,8 +17,9 @@ public class CustomsDispatchGateContributor implements OutboundDispatchGateContr if (context.tenantId() <= 0 || context.customerId() <= 0) { return gate(false, "INVALID_CUSTOMER_SCOPE"); } - boolean completed = repository.loadingDeclarationCompleted(context.containerBookingId()); - return gate(completed, completed ? "COMPLETED" : "MISSING"); + var completed = mapper.containerByBooking(context.containerBookingId()); + boolean passed = completed != null && "COMPLETED".equals(completed.status()); + return gate(passed, passed ? "COMPLETED" : completed == null ? "MISSING" : completed.status()); } private static GateItem gate(boolean passed, String actual) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/service/CustomsService.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/service/CustomsService.java deleted file mode 100644 index d13bae634..000000000 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/service/CustomsService.java +++ /dev/null @@ -1,482 +0,0 @@ -package com.baiyue.firstleg.customs.service; - -import com.baomidou.mybatisplus.core.toolkit.IdWorker; -import com.baiyue.common.core.api.PageResult; -import com.baiyue.common.core.api.ResultCode; -import com.baiyue.common.core.exception.BizException; -import com.baiyue.firstleg.customs.client.CustomsFileClient; -import com.baiyue.firstleg.customs.dto.CustomsDtos.*; -import com.baiyue.firstleg.customs.entity.*; -import com.baiyue.firstleg.customs.repository.CustomsRepository; -import com.baiyue.firstleg.service.WarehouseSupplierSnapshotService; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; -import feign.FeignException; -import jakarta.annotation.Resource; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.dao.DuplicateKeyException; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.util.StringUtils; -import org.springframework.web.multipart.MultipartFile; - -import java.io.InputStream; -import java.security.MessageDigest; -import java.time.LocalDateTime; -import java.util.*; -import java.util.function.Function; -import java.util.stream.Collectors; - -@Service -public class CustomsService { - private static final String DOCUMENT_TYPE = "CUSTOMS_DOCUMENT"; - private static final String RECEIPT_TYPE = "CUSTOMS_RECEIPT"; - private static final Set EDITABLE = Set.of("DOCUMENTS_PENDING", "REVIEWING", "SUPPLEMENT_REQUIRED", "READY_TO_SUBMIT"); - - @Resource private CustomsRepository repository; - @Resource private CustomsFileClient files; - @Resource private ObjectMapper objectMapper; - @Resource private WarehouseSupplierSnapshotService suppliers; - @Value("${baiyue.security.internal-service-token}") private String internalToken; - - public PageResult page(Query query) { return repository.page(query); } - public CaseDetail platformDetail(long caseId) { return detail(required(caseId), false); } - public CaseDetail merchantDetail(long tenantId, long caseId) { return detail(required(tenantId, caseId), true); } - public CaseDetail merchantOrder(long tenantId, long orderId) { - CustomsCaseEntity value = repository.caseByOrder(tenantId, orderId); - if (value == null) throw notFound("订单报关协同单不存在"); - return detail(value, true); - } - - @Transactional - public ChecklistView createChecklist(long operatorId, String operatorName, String key, CreateChecklistRequest request) { - validateChecklist(request.items()); - List normalizedItems = request.items().stream() - .map(item -> new ChecklistItem(normalize(item.code()), item.name().trim(), item.required())).toList(); - String action = "CREATE_CHECKLIST"; - CustomsOperationEntity operation = begin(operatorId, action, key, hash(action, request)); - if (completed(operation)) return checklist(requiredChecklist(operation.getResultId())); - if (repository.checklist(normalize(request.checklistCode()), normalize(request.versionNo())) != null) { - throw conflict("资料清单版本已存在"); - } - CustomsChecklistVersionEntity value = new CustomsChecklistVersionEntity(); - value.setId(IdWorker.getId()); value.setChecklistCode(normalize(request.checklistCode())); - value.setVersionNo(normalize(request.versionNo())); value.setCountryCode(normalize(request.countryCode())); - value.setChannelCode(normalize(request.channelCode())); value.setCargoType(normalize(request.cargoType())); - value.setItemsJson(json(normalizedItems)); value.setStatus("ACTIVE"); value.setCreatedBy(operatorId); - value.setCreatedByName(operatorName); value.setCreatedAt(LocalDateTime.now()); - repository.insert(value); complete(operation, "CHECKLIST", value.getId()); - return checklist(value); - } - - @Transactional - public CaseDetail createCase(long operatorId, String operatorName, String key, CreateCaseRequest request) { - if (request.expectedVersion() != 0) throw bad("新增 expectedVersion 必须为0"); - CustomsChecklistVersionEntity checklist = requiredChecklist(request.checklistVersionId()); - if (!"ACTIVE".equals(checklist.getStatus())) throw conflict("资料清单版本不可用"); - if (!repository.inboundBelongsTo(request.customerId(), request.orderId(), request.inboundId())) { - throw bad("入库单、订单与目标客户归属不一致"); - } - long containerId = request.containerBookingId() == null ? 0 : request.containerBookingId(); - if (containerId > 0 && !repository.containerCarriesOrder(containerId, request.customerId(), request.orderId())) { - throw bad("柜次未承载该客户订单"); - } - String action = "CREATE_CASE"; - CustomsOperationEntity operation = begin(operatorId, action, key, hash(action, request)); - if (completed(operation)) return platformDetail(operation.getResultId()); - CustomsCaseEntity value = new CustomsCaseEntity(); - value.setId(IdWorker.getId()); value.setCustomsNo("CD" + value.getId()); value.setTenantId(request.customerId()); - value.setOrderId(request.orderId()); value.setInboundId(request.inboundId()); value.setContainerBookingId(containerId); - value.setChecklistVersionId(checklist.getId()); value.setChecklistCode(checklist.getChecklistCode()); - value.setChecklistVersionNo(checklist.getVersionNo()); value.setStatus("DOCUMENTS_PENDING"); - value.setRemark(trim(request.remark())); value.setCreatedBy(operatorId); value.setCreatedByName(operatorName); - value.setCreatedAt(LocalDateTime.now()); value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); - value.setUpdatedAt(value.getCreatedAt()); value.setVersion(0); - try { repository.insert(value); } catch (DuplicateKeyException ex) { throw conflict("订单和柜次已有报关协同单"); } - history(value, "CREATE", null, value.getStatus(), "CASE", value.getId(), value.getRemark(), operatorId, operatorName, "PLATFORM"); - complete(operation, "CASE", value.getId()); - return platformDetail(value.getId()); - } - - @Transactional - public CustomsDocumentEntity uploadDocument(long tenantId, long caseId, String actorType, long operatorId, - String operatorName, String key, DocumentUploadData data, - MultipartFile file) { - CustomsCaseEntity customsCase = scopedCase(tenantId, caseId, actorType); - String action = actorType + "_UPLOAD_DOCUMENT"; - String fileHash = multipartHash(file); - CustomsOperationEntity operation = begin(operatorId, action, key, - hash(action, caseId, data, fileHash, file == null ? null : file.getOriginalFilename())); - if (completed(operation)) return requiredDocument(operation.getResultId(), customsCase.getId()); - validateDocumentCommand(customsCase, data.itemCode(), data.expectedVersion()); - CustomsFileClient.Metadata metadata = upload(customsCase, DOCUMENT_TYPE, Long.toString(caseId), operatorId, file); - CustomsDocumentEntity value = addDocument(customsCase, data.itemCode(), metadata, actorType, operatorId, operatorName); - complete(operation, "DOCUMENT", value.getId()); - return value; - } - - @Transactional - public CustomsDocumentEntity referenceDocument(long tenantId, long caseId, String actorType, long operatorId, - String operatorName, String key, DocumentReferenceRequest request) { - CustomsCaseEntity customsCase = scopedCase(tenantId, caseId, actorType); - String action = actorType + "_REFERENCE_DOCUMENT"; - CustomsOperationEntity operation = begin(operatorId, action, key, hash(action, caseId, request)); - if (completed(operation)) return requiredDocument(operation.getResultId(), customsCase.getId()); - validateDocumentCommand(customsCase, request.itemCode(), request.expectedVersion()); - CustomsFileClient.Metadata metadata = metadata(customsCase, DOCUMENT_TYPE, Long.toString(caseId), request.fileId()); - CustomsDocumentEntity value = addDocument(customsCase, request.itemCode(), metadata, actorType, operatorId, operatorName); - complete(operation, "DOCUMENT", value.getId()); - return value; - } - - @Transactional - public CaseDetail review(long caseId, long documentId, long operatorId, String operatorName, - String key, ReviewRequest request) { - CustomsCaseEntity customsCase = required(caseId); - String action = "REVIEW_DOCUMENT_" + request.decision(); - CustomsOperationEntity operation = begin(operatorId, action, key, hash(action, caseId, documentId, request)); - if (completed(operation)) return platformDetail(caseId); - requiredVersion(customsCase, request.expectedVersion()); - if (!EDITABLE.contains(customsCase.getStatus())) throw conflict("当前报关状态不可审核资料"); - CustomsDocumentEntity document = requiredDocument(documentId, caseId); - CustomsDocumentEntity latest = repository.latestDocument(caseId, document.getChecklistItemCode()); - if (!Objects.equals(document.getId(), latest.getId()) || !"UPLOADED".equals(document.getStatus())) { - throw conflict("只能审核当前待审文件版本"); - } - if ("RETURN".equals(request.decision()) && !StringUtils.hasText(request.reason())) throw bad("退回原因不能为空"); - repository.markDocument(documentId, "APPROVE".equals(request.decision()) ? "APPROVED" : "RETURNED", - operatorId, operatorName, trim(request.reason())); - if ("RETURN".equals(request.decision())) { - CustomsSupplementRequestEntity supplement = new CustomsSupplementRequestEntity(); - supplement.setId(IdWorker.getId()); supplement.setCustomsCaseId(caseId); supplement.setSourceDocumentId(documentId); - supplement.setChecklistItemCode(document.getChecklistItemCode()); supplement.setReason(request.reason().trim()); - supplement.setDueAt(request.supplementDueAt()); supplement.setStatus("OPEN"); supplement.setRequestedBy(operatorId); - supplement.setRequestedByName(operatorName); supplement.setRequestedAt(LocalDateTime.now()); repository.insert(supplement); - } - String next = calculateStatus(customsCase); - transition(customsCase, next, operatorId, operatorName); - history(customsCase, action, customsCase.getStatus(), next, "DOCUMENT", documentId, request.reason(), - operatorId, operatorName, "PLATFORM"); - complete(operation, "CASE", caseId); - return platformDetail(caseId); - } - - @Transactional - public CustomsSubmissionBatchEntity submitBatch(long caseId, long operatorId, String operatorName, - String key, SubmitBatchRequest request) { - CustomsCaseEntity customsCase = required(caseId); - String action = "SUBMIT_AGENT_BATCH"; - CustomsOperationEntity operation = begin(operatorId, action, key, hash(action, caseId, request)); - if (completed(operation)) return requiredBatch(operation.getResultId(), caseId); - requiredVersion(customsCase, request.expectedVersion()); - if (!"READY_TO_SUBMIT".equals(customsCase.getStatus())) throw conflict("报关资料未全部审核通过"); - List current = currentDocuments(customsCase); - for (CustomsDocumentEntity document : current) { - if (!"APPROVED".equals(document.getStatus()) || repository.freeze(document.getId()) != 1) { - throw conflict("文件版本已变化,请刷新后重试"); - } - } - CustomsSubmissionBatchEntity batch = new CustomsSubmissionBatchEntity(); - batch.setId(IdWorker.getId()); batch.setCustomsCaseId(caseId); batch.setBatchVersion(repository.batches(caseId).size() + 1); - batch.setBatchNo("CB" + batch.getId()); - var customsAgent = suppliers.customs(request.customsAgentId(), request.customsAgentName()); - batch.setCustomsAgentId(customsAgent == null ? null : customsAgent.supplierId()); - batch.setCustomsAgentName(customsAgent == null ? null : customsAgent.name()); - batch.setExternalReference(trim(request.externalReference())); batch.setFileSnapshotJson(json(current.stream() - .map(v -> Map.of("documentId", v.getId(), "fileId", v.getFileId(), "itemCode", v.getChecklistItemCode(), - "documentVersion", v.getDocumentVersion(), "fileHash", v.getFileHash())).toList())); - batch.setStatus("SUBMITTED"); batch.setNote(trim(request.note())); batch.setSubmittedBy(operatorId); - batch.setSubmittedByName(operatorName); batch.setSubmittedAt(LocalDateTime.now()); repository.insert(batch); - transition(customsCase, "RECEIPT_PENDING", operatorId, operatorName); - history(customsCase, action, customsCase.getStatus(), "RECEIPT_PENDING", "SUBMISSION_BATCH", batch.getId(), - request.note(), operatorId, operatorName, "PLATFORM"); - complete(operation, "BATCH", batch.getId()); - return batch; - } - - @Transactional - public CustomsReceiptEntity uploadReceipt(long batchId, long operatorId, String operatorName, String key, - ReceiptUploadData data, MultipartFile file) { - CustomsSubmissionBatchEntity batch = requiredBatch(batchId, null); - CustomsCaseEntity customsCase = required(batch.getCustomsCaseId()); - String action = "UPLOAD_RECEIPT_" + data.receiptType(); - String fileHash = multipartHash(file); - CustomsOperationEntity operation = begin(operatorId, action, key, - hash(action, batchId, data, fileHash, file == null ? null : file.getOriginalFilename())); - if (completed(operation)) return requiredReceipt(operation.getResultId(), customsCase.getId(), false); - requiredVersion(customsCase, data.expectedVersion()); - validateReceiptStatus(customsCase); - CustomsFileClient.Metadata metadata = upload(customsCase, RECEIPT_TYPE, Long.toString(batchId), operatorId, file); - CustomsReceiptEntity value = addReceipt(customsCase, batch, data.receiptType(), data.visibility(), data.note(), - metadata, operatorId, operatorName); - complete(operation, "RECEIPT", value.getId()); - return value; - } - - @Transactional - public CustomsReceiptEntity referenceReceipt(long batchId, long operatorId, String operatorName, String key, - ReceiptReferenceRequest request) { - CustomsSubmissionBatchEntity batch = requiredBatch(batchId, null); - CustomsCaseEntity customsCase = required(batch.getCustomsCaseId()); - String action = "REFERENCE_RECEIPT_" + request.receiptType(); - CustomsOperationEntity operation = begin(operatorId, action, key, hash(action, batchId, request)); - if (completed(operation)) return requiredReceipt(operation.getResultId(), customsCase.getId(), false); - requiredVersion(customsCase, request.expectedVersion()); - validateReceiptStatus(customsCase); - CustomsFileClient.Metadata metadata = metadata(customsCase, RECEIPT_TYPE, Long.toString(batchId), request.fileId()); - CustomsReceiptEntity value = addReceipt(customsCase, batch, request.receiptType(), request.visibility(), request.note(), - metadata, operatorId, operatorName); - complete(operation, "RECEIPT", value.getId()); - return value; - } - - public Download downloadDocument(long tenantId, long caseId, long documentId, boolean merchant) { - CustomsCaseEntity customsCase = merchant ? required(tenantId, caseId) : required(caseId); - CustomsDocumentEntity document = requiredDocument(documentId, caseId); - return download(customsCase, DOCUMENT_TYPE, Long.toString(caseId), document.getFileId(), document.getFileName()); - } - - public Download downloadReceipt(long tenantId, long caseId, long receiptId, boolean merchant) { - CustomsCaseEntity customsCase = merchant ? required(tenantId, caseId) : required(caseId); - CustomsReceiptEntity receipt = requiredReceipt(receiptId, caseId, merchant); - CustomsSubmissionBatchEntity batch = requiredBatch(receipt.getSubmissionBatchId(), caseId); - return download(customsCase, RECEIPT_TYPE, Long.toString(batch.getId()), receipt.getFileId(), receipt.getFileName()); - } - - private CustomsDocumentEntity addDocument(CustomsCaseEntity customsCase, String rawItemCode, - CustomsFileClient.Metadata metadata, String actorType, - long operatorId, String operatorName) { - String itemCode = normalize(rawItemCode); - CustomsDocumentEntity previous = repository.latestDocument(customsCase.getId(), itemCode); - if (previous != null && Set.of("UPLOADED", "RETURNED").contains(previous.getStatus()) - && repository.supersede(previous.getId()) != 1) throw conflict("文件版本已变化,请刷新后重试"); - CustomsDocumentEntity value = new CustomsDocumentEntity(); - value.setId(IdWorker.getId()); value.setCustomsCaseId(customsCase.getId()); value.setChecklistItemCode(itemCode); - value.setFileId(metadata.fileId()); value.setDocumentVersion(previous == null ? 1 : previous.getDocumentVersion() + 1); - value.setReplacesDocumentId(previous == null ? null : previous.getId()); value.setFileName(metadata.fileName()); - value.setFileHash(metadata.sha256()); value.setStatus("UPLOADED"); value.setVisibility("CUSTOMER"); - value.setUploadedBy(operatorId); value.setUploadedByType(actorType); value.setUploadedAt(LocalDateTime.now()); - try { repository.insert(value); } catch (DuplicateKeyException ex) { throw conflict("文件版本已存在"); } - repository.resolveSupplements(customsCase.getId(), itemCode, value.getId(), operatorId); - String next = hasEveryRequiredDocument(customsCase) ? "REVIEWING" : "DOCUMENTS_PENDING"; - transition(customsCase, next, operatorId, operatorName); - history(customsCase, previous == null ? "UPLOAD_DOCUMENT" : "REPLACE_DOCUMENT", customsCase.getStatus(), next, - "DOCUMENT", value.getId(), itemCode, operatorId, operatorName, actorType); - return value; - } - - private CustomsReceiptEntity addReceipt(CustomsCaseEntity customsCase, CustomsSubmissionBatchEntity batch, - String receiptType, String visibility, String note, - CustomsFileClient.Metadata metadata, long operatorId, String operatorName) { - CustomsReceiptEntity previous = repository.latestReceipt(batch.getId(), receiptType); - CustomsReceiptEntity value = new CustomsReceiptEntity(); value.setId(IdWorker.getId()); - value.setCustomsCaseId(customsCase.getId()); value.setSubmissionBatchId(batch.getId()); - value.setReceiptType(receiptType); value.setFileId(metadata.fileId()); - value.setReceiptVersion(previous == null ? 1 : previous.getReceiptVersion() + 1); - value.setReplacesReceiptId(previous == null ? null : previous.getId()); value.setFileName(metadata.fileName()); - value.setFileHash(metadata.sha256()); value.setVisibility(visibility); value.setNote(trim(note)); - value.setUploadedBy(operatorId); value.setUploadedByName(operatorName); value.setUploadedAt(LocalDateTime.now()); - repository.insert(value); - String next = "REJECTED".equals(receiptType) ? "SUPPLEMENT_REQUIRED" - : "RELEASED".equals(receiptType) ? "CLEARANCE_COMPLETED" : "RECEIPT_AVAILABLE"; - transition(customsCase, next, operatorId, operatorName); - history(customsCase, "UPLOAD_RECEIPT", customsCase.getStatus(), next, "RECEIPT", value.getId(), note, - operatorId, operatorName, "PLATFORM"); - return value; - } - - private List currentDocuments(CustomsCaseEntity customsCase) { - Map current = repository.documents(customsCase.getId()).stream() - .collect(Collectors.toMap(CustomsDocumentEntity::getChecklistItemCode, Function.identity(), - (left, right) -> left.getDocumentVersion() > right.getDocumentVersion() ? left : right)); - return checklistItems(customsCase).stream().filter(ChecklistItem::required).map(v -> normalize(v.code())) - .map(current::get).filter(Objects::nonNull).toList(); - } - - private String calculateStatus(CustomsCaseEntity customsCase) { - List supplements = repository.supplements(customsCase.getId()); - if (supplements.stream().anyMatch(v -> "OPEN".equals(v.getStatus()))) return "SUPPLEMENT_REQUIRED"; - Map current = repository.documents(customsCase.getId()).stream() - .collect(Collectors.toMap(CustomsDocumentEntity::getChecklistItemCode, Function.identity(), - (left, right) -> left.getDocumentVersion() > right.getDocumentVersion() ? left : right)); - List required = checklistItems(customsCase).stream().filter(ChecklistItem::required) - .map(v -> normalize(v.code())).toList(); - if (required.stream().allMatch(code -> current.containsKey(code) && "APPROVED".equals(current.get(code).getStatus()))) { - return "READY_TO_SUBMIT"; - } - return required.stream().allMatch(current::containsKey) ? "REVIEWING" : "DOCUMENTS_PENDING"; - } - - private boolean hasEveryRequiredDocument(CustomsCaseEntity customsCase) { - Set codes = repository.documents(customsCase.getId()).stream() - .filter(v -> !"SUPERSEDED".equals(v.getStatus())) - .map(CustomsDocumentEntity::getChecklistItemCode).collect(Collectors.toSet()); - return checklistItems(customsCase).stream().filter(ChecklistItem::required) - .allMatch(v -> codes.contains(normalize(v.code()))); - } - - private void validateDocumentCommand(CustomsCaseEntity customsCase, String itemCode, int expectedVersion) { - requiredVersion(customsCase, expectedVersion); - if (!EDITABLE.contains(customsCase.getStatus())) throw conflict("当前报关状态不可上传或替换资料"); - String normalized = normalize(itemCode); - if (checklistItems(customsCase).stream().noneMatch(v -> normalize(v.code()).equals(normalized))) { - throw bad("文件类型不在本单资料清单中"); - } - } - - private void validateReceiptStatus(CustomsCaseEntity customsCase) { - if (!Set.of("RECEIPT_PENDING", "RECEIPT_AVAILABLE", "SUPPLEMENT_REQUIRED").contains(customsCase.getStatus())) { - throw conflict("当前报关状态不可上传回执"); - } - } - - private CustomsCaseEntity scopedCase(long tenantId, long caseId, String actorType) { - return "MERCHANT".equals(actorType) ? required(tenantId, caseId) : required(caseId); - } - - private CustomsFileClient.Metadata upload(CustomsCaseEntity customsCase, String type, String businessId, - long operatorId, MultipartFile file) { - try { - var response = files.upload(internalToken, operatorId, customsCase.getTenantId(), type, businessId, file); - if (response == null || !response.successful() || response.data() == null) throw bad("附件上传失败"); - return response.data(); - } catch (FeignException ex) { throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "文件服务暂不可用"); } - } - - private CustomsFileClient.Metadata metadata(CustomsCaseEntity customsCase, String type, String businessId, long fileId) { - try { - var response = files.metadata(internalToken, customsCase.getTenantId(), type, businessId, fileId); - if (response == null || !response.successful() || response.data() == null) throw bad("附件不存在或归属不匹配"); - return response.data(); - } catch (FeignException ex) { throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "文件服务暂不可用"); } - } - - private Download download(CustomsCaseEntity customsCase, String type, String businessId, long fileId, String fileName) { - try { - var response = files.download(internalToken, customsCase.getTenantId(), type, businessId, fileId); - if (response == null || !response.getStatusCode().is2xxSuccessful() || response.getBody() == null) { - throw notFound("附件内容不存在"); - } - String contentType = response.getHeaders().getContentType() == null - ? "application/octet-stream" : response.getHeaders().getContentType().toString(); - if (contentType.toLowerCase(Locale.ROOT).contains("application/json")) throw notFound("附件内容不存在"); - return new Download(fileName, contentType, response.getBody()); - } catch (FeignException ex) { throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "文件服务暂不可用"); } - } - - private CustomsOperationEntity begin(long operatorId, String action, String key, String requestHash) { - if (!StringUtils.hasText(key) || key.length() > 128) throw bad("Idempotency-Key不能为空且不能超过128个字符"); - CustomsOperationEntity value = new CustomsOperationEntity(); value.setId(IdWorker.getId()); - value.setOperatorId(operatorId); value.setAction(action); value.setIdempotencyKey(key); - value.setRequestHash(requestHash); value.setStatus("PROCESSING"); value.setCreatedAt(LocalDateTime.now()); - try { repository.insert(value); return value; } - catch (DuplicateKeyException ex) { - CustomsOperationEntity existing = repository.operation(operatorId, action, key); - if (existing == null || !Objects.equals(requestHash, existing.getRequestHash())) throw conflict("幂等键已用于不同请求"); - if (!"SUCCESS".equals(existing.getStatus()) || existing.getResultId() == null) throw conflict("相同请求正在处理中,请稍后重试"); - return existing; - } - } - - private void complete(CustomsOperationEntity operation, String resultType, long resultId) { - if (repository.completeOperation(operation.getId(), resultType, resultId) != 1) throw conflict("幂等请求完成状态冲突"); - } - - private static boolean completed(CustomsOperationEntity operation) { return "SUCCESS".equals(operation.getStatus()); } - - private void transition(CustomsCaseEntity value, String next, long operatorId, String operatorName) { - if (repository.transition(value.getId(), value.getVersion(), next, operatorId, operatorName) != 1) throw concurrent(); - } - - private void history(CustomsCaseEntity customsCase, String action, String from, String to, - String resourceType, long resourceId, String note, long operatorId, - String operatorName, String operatorType) { - CustomsHistoryEntity value = new CustomsHistoryEntity(); value.setId(IdWorker.getId()); - value.setCustomsCaseId(customsCase.getId()); value.setAction(action); value.setFromStatus(from); - value.setToStatus(to); value.setResourceType(resourceType); value.setResourceId(resourceId); - value.setNote(trim(note)); value.setOperatorId(operatorId); value.setOperatorName(operatorName); - value.setOperatorType(operatorType); value.setCreatedAt(LocalDateTime.now()); repository.insert(value); - } - - private CaseDetail detail(CustomsCaseEntity value, boolean customerOnly) { - return new CaseDetail(value, checklist(requiredChecklist(value.getChecklistVersionId())), - repository.documents(value.getId()), repository.supplements(value.getId()), repository.batches(value.getId()), - repository.receipts(value.getId(), customerOnly), repository.history(value.getId())); - } - - private ChecklistView checklist(CustomsChecklistVersionEntity value) { - return new ChecklistView(value, parseItems(value.getItemsJson())); - } - private List checklistItems(CustomsCaseEntity value) { - return parseItems(requiredChecklist(value.getChecklistVersionId()).getItemsJson()); - } - private List parseItems(String json) { - try { return objectMapper.readValue(json, new TypeReference<>() {}); } - catch (JsonProcessingException ex) { throw new IllegalStateException("无法读取报关清单版本", ex); } - } - - private CustomsCaseEntity required(long id) { - CustomsCaseEntity value = repository.customsCase(id); if (value == null) throw notFound("报关协同单不存在"); return value; - } - private CustomsCaseEntity required(long tenantId, long id) { - CustomsCaseEntity value = repository.customsCase(tenantId, id); if (value == null) throw notFound("报关协同单不存在"); return value; - } - private CustomsCaseEntity requiredVersion(CustomsCaseEntity value, int expectedVersion) { - if (!Integer.valueOf(expectedVersion).equals(value.getVersion())) throw concurrent(); return value; - } - private CustomsChecklistVersionEntity requiredChecklist(long id) { - CustomsChecklistVersionEntity value = repository.checklist(id); if (value == null) throw notFound("资料清单版本不存在"); return value; - } - private CustomsDocumentEntity requiredDocument(long id, long caseId) { - CustomsDocumentEntity value = repository.document(id); - if (value == null || !Objects.equals(value.getCustomsCaseId(), caseId)) throw notFound("报关文件版本不存在"); - return value; - } - private CustomsSubmissionBatchEntity requiredBatch(long id, Long caseId) { - CustomsSubmissionBatchEntity value = repository.batch(id); - if (value == null || caseId != null && !Objects.equals(value.getCustomsCaseId(), caseId)) throw notFound("代理提交批次不存在"); - return value; - } - private CustomsReceiptEntity requiredReceipt(long id, long caseId, boolean customerOnly) { - CustomsReceiptEntity value = repository.receipt(id); - if (value == null || !Objects.equals(value.getCustomsCaseId(), caseId) - || customerOnly && !"CUSTOMER".equals(value.getVisibility())) throw notFound("报关回执不存在"); - return value; - } - - private static void validateChecklist(List items) { - Set codes = new HashSet<>(); - for (ChecklistItem item : items) if (!codes.add(normalize(item.code()))) throw bad("资料清单项目编码不能重复"); - if (items.stream().noneMatch(ChecklistItem::required)) throw bad("资料清单至少包含一个必填项目"); - } - - private String hash(Object... values) { - try { return sha256(objectMapper.writeValueAsBytes(values)); } - catch (JsonProcessingException ex) { throw new IllegalStateException("无法计算报关请求摘要", ex); } - } - private static String multipartHash(MultipartFile file) { - if (file == null || file.isEmpty()) throw bad("附件不能为空"); - try (InputStream input = file.getInputStream()) { - MessageDigest digest = MessageDigest.getInstance("SHA-256"); - byte[] buffer = new byte[8192]; - for (int count; (count = input.read(buffer)) >= 0;) digest.update(buffer, 0, count); - return HexFormat.of().formatHex(digest.digest()); - } - catch (Exception ex) { throw bad("附件内容读取失败"); } - } - private static String sha256(byte[] bytes) { - try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)); } - catch (Exception ex) { throw new IllegalStateException(ex); } - } - private String json(Object value) { - try { return objectMapper.writeValueAsString(value); } - catch (JsonProcessingException ex) { throw new IllegalStateException("无法保存报关版本快照", ex); } - } - private static String normalize(String value) { return value.trim().toUpperCase(Locale.ROOT); } - private static String trim(String value) { if (value == null) return null; String result = value.trim(); return result.isEmpty() ? null : result; } - private static BizException bad(String message) { return new BizException(ResultCode.BAD_REQUEST, message); } - private static BizException conflict(String message) { return new BizException(ResultCode.CONFLICT, message); } - private static BizException concurrent() { return conflict("数据已被其他人修改,请刷新后重试"); } - private static BizException notFound(String message) { return new BizException(ResultCode.NOT_FOUND, message); } -} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java index dc34ff94b..e99d1dd72 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java @@ -23,9 +23,7 @@ public interface ContainerBookingMapper extends BaseMapper documents(@Param("id") long id); + @Select("SELECT * FROM fl_container_document WHERE container_booking_id=#{id} AND document_type IN ('DESTINATION_CLEARANCE','DELIVERY_ORDER','PICKUP_ORDER') ORDER BY document_type") List documents(@Param("id") long id); @Select("SELECT * FROM fl_container_transport_history WHERE container_booking_id=#{id} ORDER BY created_at,id") List transport(@Param("id") long id); @Select("SELECT id,action,reason,operator_id operatorId,operator_name operatorName,occurred_at occurredAt,request_hash requestHash FROM fl_container_exception_history WHERE container_booking_id=#{id} ORDER BY occurred_at,id") List exceptions(@Param("id") long id); @Insert("INSERT INTO fl_container_exception_history(id,container_booking_id,action,reason,operator_id,operator_name,operation_id,request_hash,occurred_at) VALUES(#{id},#{bookingId},#{action},#{reason},#{operatorId},#{operatorName},#{operationId},#{requestHash},CURRENT_TIMESTAMP(3))") int insertException(@Param("id") long id,@Param("bookingId") long bookingId,@Param("action") String action,@Param("reason") String reason,@Param("operatorId") long operatorId,@Param("operatorName") String operatorName,@Param("operationId") String operationId,@Param("requestHash") String requestHash); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/mapper/OutboundDispatchReadMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/mapper/OutboundDispatchReadMapper.java index 519cc03e8..4c112ee32 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/mapper/OutboundDispatchReadMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/mapper/OutboundDispatchReadMapper.java @@ -103,10 +103,9 @@ public interface OutboundDispatchReadMapper { SELECT 'TRUCK_APPOINTMENT',h.action,h.from_status,h.to_status,h.operator_id,h.operator_name,h.reason,h.created_at FROM fl_truck_appointment_history h WHERE h.appointment_id=#{truckAppointmentId} UNION ALL - SELECT 'CUSTOMS',h.action,h.from_status,h.to_status,h.operator_id,h.operator_name,h.note,h.created_at - FROM fl_customs_history h JOIN fl_customs_case customs_case ON customs_case.id=h.customs_case_id - WHERE customs_case.tenant_id=#{tenantId} AND customs_case.order_id=#{orderId} - AND customs_case.inbound_id=#{inboundId} AND customs_case.container_booking_id=#{containerBookingId} + SELECT 'CUSTOMS',h.action,h.from_status,h.to_status,h.operator_id,h.operator_name,h.remark,h.created_at + FROM fl_customs_container_history h JOIN fl_customs_container customs_container ON customs_container.id=h.customs_container_id + WHERE customs_container.container_booking_id=#{containerBookingId} UNION ALL SELECT 'SAILING_SCHEDULE',h.action,NULL,NULL,h.operator_id,h.operator_name,h.reason,h.created_at FROM fl_port_schedule_history h JOIN fl_port_schedule port_schedule ON port_schedule.id=h.port_schedule_id diff --git a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/service/OutboundDispatchAutoConfirmService.java b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/service/OutboundDispatchAutoConfirmService.java new file mode 100644 index 000000000..5880fdf02 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/service/OutboundDispatchAutoConfirmService.java @@ -0,0 +1,30 @@ +package com.baiyue.firstleg.outbound.dispatch.service; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.outbound.dispatch.domain.OutboundDispatchStatus; +import com.baiyue.firstleg.outbound.dispatch.dto.OutboundDispatchDtos.ActionRequest; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** Real proxy-owned transaction boundary for repeated system convergence after customs completion. */ +@Service +public class OutboundDispatchAutoConfirmService { + @Resource private OutboundDispatchService dispatch; + + @Transactional + public boolean confirmIfReady(long containerBookingId) { + var current=dispatch.detail(containerBookingId); + if(OutboundDispatchStatus.WAITING_FOR_LOADING.name().equals(current.aggregate().dispatchStatus()))return true; + if(!current.aggregate().gate().passed())return false; + try{ + dispatch.confirm(containerBookingId,0L,"SYSTEM","CUSTOMS-AUTO-DISPATCH-"+containerBookingId, + new ActionRequest(current.aggregate().version(),"整柜报关完成且全部离仓门禁满足,系统自动确认离仓")); + return true; + }catch(BizException ex){ + var after=dispatch.detail(containerBookingId); + if(OutboundDispatchStatus.WAITING_FOR_LOADING.name().equals(after.aggregate().dispatchStatus()))return true; + throw ex; + } + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java index 6f3e8ce93..33b8e3be1 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java @@ -52,11 +52,9 @@ import java.util.Set; public class LoadingListService { private static final Set PRELOAD_STATUSES = Set.of("EDITING", "LOADING"); public static final String DESTINATION_CLEARANCE = "DESTINATION_CLEARANCE"; - public static final String CUSTOMS_DECLARATION = "CUSTOMS_DECLARATION"; public static final String DELIVERY_ORDER = "DELIVERY_ORDER"; public static final String PICKUP_ORDER = "PICKUP_ORDER"; private static final String CLEARANCE_FILE_TYPE = "CONTAINER_CLEARANCE_DOCUMENT"; - private static final String DECLARATION_FILE_TYPE = "LOADING_CUSTOMS_DECLARATION"; private static final String DO_FILE_TYPE = "CONTAINER_DELIVERY_ORDER"; private static final String PU_FILE_TYPE = "CONTAINER_PICKUP_ORDER"; @Resource @@ -198,89 +196,6 @@ public class LoadingListService { operatorId, operatorName, key, request); } - public BusinessDocument loadingDeclaration(long planId, long warehouseId) { - AggregateRow plan = requirePlan(planId); - requireWarehouse(plan, warehouseId); - requireCompletedPlan(plan); - return document(repository.document(plan.getContainerBookingId(), CUSTOMS_DECLARATION)); - } - - public DocumentDownload downloadLoadingDeclaration(long planId, long warehouseId, long fileId) { - AggregateRow plan = requirePlan(planId); - requireWarehouse(plan, warehouseId); - requireCompletedPlan(plan); - ContainerDocumentEntity document = repository.document(plan.getContainerBookingId(), CUSTOMS_DECLARATION); - if (document == null || !Long.valueOf(fileId).equals(document.getFileId())) { - throw notFound("出口报关附件不存在或不属于当前装柜计划"); - } - String businessId = Long.toString(planId); - try { - var metadata = documentFiles.metadata(internalToken, 0, DECLARATION_FILE_TYPE, businessId, fileId); - if (metadata == null || !metadata.successful() || metadata.data() == null - || !Long.valueOf(fileId).equals(metadata.data().fileId()) - || !Long.valueOf(0).equals(metadata.data().tenantId()) - || !DECLARATION_FILE_TYPE.equals(metadata.data().businessType()) - || !businessId.equals(metadata.data().businessId())) { - throw notFound("出口报关附件不存在或归属不匹配"); - } - var response = documentFiles.download(internalToken, 0, DECLARATION_FILE_TYPE, businessId, fileId); - if (response == null || !response.getStatusCode().is2xxSuccessful() || response.getBody() == null) { - throw notFound("出口报关附件不存在"); - } - MediaType contentType; - try { - contentType = MediaType.parseMediaType(metadata.data().contentType()); - } catch (RuntimeException exception) { - contentType = MediaType.APPLICATION_OCTET_STREAM; - } - String fileName = StringUtils.hasText(document.getFileName()) - ? document.getFileName() : metadata.data().fileName(); - return new DocumentDownload(fileName, contentType, response.getBody()); - } catch (FeignException.NotFound exception) { - throw notFound("出口报关附件不存在或归属不匹配"); - } catch (FeignException exception) { - throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "文件服务暂不可用"); - } - } - - @Transactional - @BusinessAudit(event = "LOADING_CUSTOMS_DOCUMENT_ATTACH", resourceType = "LOADING_PLAN", - resourceId = "#planId", description = "上传出口报关文档") - public BusinessDocument attachLoadingDeclaration(long planId, long warehouseId, long operatorId, - String operatorName, String key, - DocumentAttachRequest request) { - AggregateRow plan = requirePlan(planId); - requireWarehouse(plan, warehouseId); - requireCompletedPlan(plan); - return attachDocument(plan, CUSTOMS_DECLARATION, DECLARATION_FILE_TYPE, - planId, Long.toString(planId), operatorId, operatorName, key, request); - } - - @Transactional - @BusinessAudit(event = "LOADING_CUSTOMS_DOCUMENT_UPLOAD", resourceType = "LOADING_PLAN", - resourceId = "#planId", description = "直接上传出口报关文档") - public BusinessDocument uploadLoadingDeclaration(long planId, long warehouseId, long operatorId, - String operatorName, String key, int expectedVersion, - String operationReason, MultipartFile file) { - AggregateRow plan = requirePlan(planId); - requireWarehouse(plan, warehouseId); - requireCompletedPlan(plan); - return uploadDocument(plan, CUSTOMS_DECLARATION, DECLARATION_FILE_TYPE, planId, - Long.toString(planId), operatorId, operatorName, key, expectedVersion, operationReason, file); - } - - @Transactional - @BusinessAudit(event = "LOADING_CUSTOMS_COMPLETE", resourceType = "LOADING_PLAN", - resourceId = "#planId", description = "确认出口报关完成") - public BusinessDocument completeLoadingDeclaration(long planId, long warehouseId, long operatorId, - String operatorName, String key, - DocumentCompleteRequest request) { - AggregateRow plan = requirePlan(planId); - requireWarehouse(plan, warehouseId); - requireCompletedPlan(plan); - return completeDocument(plan, CUSTOMS_DECLARATION, planId, operatorId, operatorName, key, request); - } - private BusinessDocument attachDocument(AggregateRow plan, String documentType, String businessType, long identityId, String businessId, long operatorId, String operatorName, String key, DocumentAttachRequest request) { @@ -450,7 +365,7 @@ public class LoadingListService { } private static void requireCompletedPlan(AggregateRow plan) { - if (!"COMPLETED".equals(plan.getPlanStatus())) throw conflict("仅完成装柜清单可办理报关"); + if (!"COMPLETED".equals(plan.getPlanStatus())) throw conflict("仅完成装柜清单可维护后续单证"); } private ContainerDocumentEntity requireDocumentEntity(long bookingId, String documentType) { diff --git a/firstleg/src/main/resources/db/phase3-container-customs-schema.sql b/firstleg/src/main/resources/db/phase3-container-customs-schema.sql new file mode 100644 index 000000000..5f86e3d2b --- /dev/null +++ b/firstleg/src/main/resources/db/phase3-container-customs-schema.sql @@ -0,0 +1,174 @@ +SET NAMES utf8mb4; +USE baiyue_firstleg; + +CREATE TABLE IF NOT EXISTS fl_customs_container ( + id BIGINT UNSIGNED NOT NULL, + loading_plan_id BIGINT UNSIGNED NOT NULL COMMENT '已完成装柜计划ID;报关柜级聚合唯一键', + container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '物理货柜ID', + status VARCHAR(40) NOT NULL COMMENT 'WAITING_CUSTOMER_DOCUMENTS、READY_TO_START、DECLARING、COMPLETED', + started_by BIGINT UNSIGNED NULL, + started_by_name VARCHAR(64) NULL, + started_at DATETIME(3) NULL, + completed_by BIGINT UNSIGNED NULL, + completed_by_name VARCHAR(64) NULL, + completed_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + version INT UNSIGNED NOT NULL DEFAULT 0, + PRIMARY KEY(id), + UNIQUE KEY uk_customs_container_plan(loading_plan_id), + UNIQUE KEY uk_customs_container_booking(container_booking_id), + KEY idx_customs_container_status(status,updated_at), + CONSTRAINT chk_customs_container_status CHECK(status IN + ('WAITING_CUSTOMER_DOCUMENTS','READY_TO_START','DECLARING','COMPLETED')) +) ENGINE=InnoDB COMMENT='装柜完成后的整柜出口报关聚合'; + +CREATE TABLE IF NOT EXISTS fl_customs_customer_document ( + id BIGINT UNSIGNED NOT NULL, + customs_container_id BIGINT UNSIGNED NOT NULL, + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离键;只允许关联同客户退税票', + file_id BIGINT UNSIGNED NOT NULL, + file_name VARCHAR(255) NOT NULL, + file_hash CHAR(64) NOT NULL, + content_type VARCHAR(128) NULL, + size_bytes BIGINT UNSIGNED NOT NULL, + status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE', + uploaded_by BIGINT UNSIGNED NOT NULL, + uploaded_at DATETIME(3) NOT NULL, + frozen_version INT UNSIGNED NULL, + frozen_at DATETIME(3) NULL, + deleted_by BIGINT UNSIGNED NULL, + deleted_at DATETIME(3) NULL, + version INT UNSIGNED NOT NULL DEFAULT 1, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY(id), + KEY idx_customs_customer_document_container(customs_container_id,status,tenant_id), + CONSTRAINT fk_customs_customer_document_container FOREIGN KEY(customs_container_id) REFERENCES fl_customs_container(id), + CONSTRAINT chk_customs_customer_document_status CHECK(status IN('ACTIVE','DELETED')), + CONSTRAINT chk_customs_customer_document_freeze CHECK((frozen_at IS NULL AND frozen_version IS NULL) OR + (frozen_at IS NOT NULL AND frozen_version IS NOT NULL)) +) ENGINE=InnoDB COMMENT='客户整份退税报送资料逻辑根'; + +CREATE TABLE IF NOT EXISTS fl_customs_customer_document_inbound ( + document_id BIGINT UNSIGNED NOT NULL, + inbound_id BIGINT UNSIGNED NOT NULL, + tenant_id BIGINT UNSIGNED NOT NULL, + order_id BIGINT UNSIGNED NOT NULL, + inbound_code VARCHAR(40) NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY(document_id,inbound_id), + KEY idx_customs_document_inbound(inbound_id,tenant_id), + CONSTRAINT fk_customs_document_inbound_document FOREIGN KEY(document_id) REFERENCES fl_customs_customer_document(id) +) ENGINE=InnoDB COMMENT='客户报送资料与当前柜退税入仓码多对多关系'; + +CREATE TABLE IF NOT EXISTS fl_customs_customer_document_version ( + id BIGINT UNSIGNED NOT NULL, + document_id BIGINT UNSIGNED NOT NULL, + document_version INT UNSIGNED NOT NULL, + file_id BIGINT UNSIGNED NOT NULL, + file_name VARCHAR(255) NOT NULL, + file_hash CHAR(64) NOT NULL, + content_type VARCHAR(128) NULL, + size_bytes BIGINT UNSIGNED NOT NULL, + scope_snapshot_json JSON NOT NULL, + action VARCHAR(24) NOT NULL COMMENT 'UPLOAD、REPLACE、SCOPE_CHANGE、DELETE', + operator_id BIGINT UNSIGNED NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY(id), + UNIQUE KEY uk_customs_customer_document_version(document_id,document_version), + CONSTRAINT fk_customs_customer_document_version FOREIGN KEY(document_id) REFERENCES fl_customs_customer_document(id), + CONSTRAINT chk_customs_customer_document_version_action CHECK(action IN('UPLOAD','REPLACE','SCOPE_CHANGE','DELETE')) +) ENGINE=InnoDB COMMENT='客户报送资料文件与覆盖范围不可变版本'; + +CREATE TABLE IF NOT EXISTS fl_customs_frozen_document_inbound ( + document_id BIGINT UNSIGNED NOT NULL, + inbound_id BIGINT UNSIGNED NOT NULL, + tenant_id BIGINT UNSIGNED NOT NULL, + order_id BIGINT UNSIGNED NOT NULL, + inbound_code VARCHAR(40) NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY(document_id,inbound_id), + CONSTRAINT fk_customs_frozen_document FOREIGN KEY(document_id) REFERENCES fl_customs_customer_document(id) +) ENGINE=InnoDB COMMENT='开始报关时冻结的客户资料覆盖范围'; + +CREATE TABLE IF NOT EXISTS fl_customs_completion_document ( + id BIGINT UNSIGNED NOT NULL, + source_document_id BIGINT UNSIGNED NOT NULL COMMENT '一份冻结客户资料严格对应一份完成文件逻辑根', + file_id BIGINT UNSIGNED NOT NULL, + file_name VARCHAR(255) NOT NULL, + file_hash CHAR(64) NOT NULL, + content_type VARCHAR(128) NULL, + size_bytes BIGINT UNSIGNED NOT NULL, + uploaded_by BIGINT UNSIGNED NOT NULL, + uploaded_by_name VARCHAR(64) NULL, + uploaded_at DATETIME(3) NOT NULL, + version INT UNSIGNED NOT NULL DEFAULT 1, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY(id), + UNIQUE KEY uk_customs_completion_source(source_document_id), + CONSTRAINT fk_customs_completion_source FOREIGN KEY(source_document_id) REFERENCES fl_customs_customer_document(id) +) ENGINE=InnoDB COMMENT='报关完成文件当前版本'; + +CREATE TABLE IF NOT EXISTS fl_customs_completion_document_version ( + id BIGINT UNSIGNED NOT NULL, + completion_document_id BIGINT UNSIGNED NOT NULL, + document_version INT UNSIGNED NOT NULL, + file_id BIGINT UNSIGNED NOT NULL, + file_name VARCHAR(255) NOT NULL, + file_hash CHAR(64) NOT NULL, + content_type VARCHAR(128) NULL, + size_bytes BIGINT UNSIGNED NOT NULL, + operator_id BIGINT UNSIGNED NOT NULL, + operator_name VARCHAR(64) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY(id), + UNIQUE KEY uk_customs_completion_version(completion_document_id,document_version), + CONSTRAINT fk_customs_completion_version FOREIGN KEY(completion_document_id) REFERENCES fl_customs_completion_document(id) +) ENGINE=InnoDB COMMENT='报关完成文件不可变覆盖版本'; + +CREATE TABLE IF NOT EXISTS fl_customs_container_history ( + id BIGINT UNSIGNED NOT NULL, + customs_container_id BIGINT UNSIGNED NOT NULL, + action VARCHAR(40) NOT NULL, + from_status VARCHAR(40) NULL, + to_status VARCHAR(40) NULL, + remark VARCHAR(500) NULL, + operator_id BIGINT UNSIGNED NOT NULL, + operator_name VARCHAR(64) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY(id), + KEY idx_customs_container_history(customs_container_id,created_at,id), + CONSTRAINT fk_customs_container_history FOREIGN KEY(customs_container_id) REFERENCES fl_customs_container(id) +) ENGINE=InnoDB COMMENT='整柜报关状态与系统触发历史'; + +CREATE TABLE IF NOT EXISTS fl_customs_command ( + id BIGINT UNSIGNED NOT NULL, + actor_id BIGINT UNSIGNED NOT NULL, + action VARCHAR(80) NOT NULL, + idempotency_key VARCHAR(128) NOT NULL, + request_hash CHAR(64) NOT NULL, + status VARCHAR(16) NOT NULL, + result_id BIGINT UNSIGNED NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + completed_at DATETIME(3) NULL, + PRIMARY KEY(id), + UNIQUE KEY uk_customs_command(actor_id,action,idempotency_key), + CONSTRAINT chk_customs_command_status CHECK(status IN('PROCESSING','SUCCESS')) +) ENGINE=InnoDB COMMENT='整柜报关写命令幂等事实'; + +USE baiyue_iam; +DELETE rp FROM iam_role_permission rp WHERE rp.permission_id IN(5102171,5102173,5102174,5102175); +DELETE FROM iam_permission WHERE id IN(5102171,5102173,5102174,5102175) + AND permission_code IN('platform:firstleg:customs:view','platform:firstleg:customs:review','platform:firstleg:customs:submit','platform:firstleg:customs:receipt'); +INSERT INTO iam_permission(id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name, + route_path,http_method,sort_no,status,built_in,deleted) VALUES + (5102170,0,'PLATFORM','MENU',5102000,'platform:firstleg:customs:list','整柜报关','/first-leg/customs','GET',100,'ACTIVE',1,0), + (5102172,0,'PLATFORM','BUTTON',5102170,'platform:firstleg:customs:manage','维护整柜报关',NULL,'POST',20,'ACTIVE',1,0) +ON DUPLICATE KEY UPDATE permission_code=VALUES(permission_code),permission_name=VALUES(permission_name), + parent_id=VALUES(parent_id),route_path=VALUES(route_path),http_method=VALUES(http_method),status='ACTIVE',deleted=0; +INSERT IGNORE INTO iam_role_permission(role_id,permission_id,tenant_id) +SELECT r.id,p.id,0 FROM iam_role r JOIN iam_permission p ON p.tenant_id=0 AND p.app_code='PLATFORM' + AND p.permission_code IN('platform:firstleg:customs:list','platform:firstleg:customs:manage') +WHERE r.tenant_id=0 AND r.role_code IN('PLATFORM_ADMIN','ORDER_OPERATOR'); diff --git a/firstleg/src/main/resources/db/phase3-customs-schema.sql b/firstleg/src/main/resources/db/phase3-customs-schema.sql deleted file mode 100644 index d02b3df08..000000000 --- a/firstleg/src/main/resources/db/phase3-customs-schema.sql +++ /dev/null @@ -1,242 +0,0 @@ -SET NAMES utf8mb4; -USE baiyue_firstleg; - -CREATE TABLE IF NOT EXISTS fl_customs_checklist_version ( - id BIGINT UNSIGNED NOT NULL COMMENT '报关资料清单版本逻辑ID', - checklist_code VARCHAR(40) NOT NULL COMMENT '报关资料清单编码;同一清单的多个版本共用', - version_no VARCHAR(32) NOT NULL COMMENT '清单业务版本号', - country_code VARCHAR(32) NOT NULL COMMENT '清单适用的目的国家或地区编码', - channel_code VARCHAR(40) NOT NULL COMMENT '清单适用的运输渠道编码', - cargo_type VARCHAR(40) NOT NULL COMMENT '清单适用的货物类型编码', - items_json JSON NOT NULL COMMENT '不可变清单项目数组;元素包含code项目编码、name项目名称和required是否必填', - status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '清单版本状态:ACTIVE启用、RETIRED停用', - created_by BIGINT UNSIGNED NOT NULL COMMENT '创建清单版本的平台操作人ID', - created_by_name VARCHAR(64) NULL COMMENT '创建清单版本时的平台操作人名称快照', - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '清单版本创建时间', - PRIMARY KEY (id), - UNIQUE KEY uk_customs_checklist_version (checklist_code, version_no), - KEY idx_customs_checklist_match (country_code, channel_code, cargo_type, status), - CONSTRAINT chk_customs_checklist_status CHECK (status IN ('ACTIVE','RETIRED')) -) ENGINE=InnoDB COMMENT='三期报关资料清单不可变版本'; - -CREATE TABLE IF NOT EXISTS fl_customs_case ( - id BIGINT UNSIGNED NOT NULL COMMENT '报关协同单逻辑ID', - customs_no VARCHAR(40) NOT NULL COMMENT '全局唯一报关协同单号', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', - order_id BIGINT UNSIGNED NOT NULL COMMENT 'Order服务订单逻辑ID;跨库不建立外键', - inbound_id BIGINT UNSIGNED NOT NULL COMMENT 'Firstleg入库单逻辑ID', - container_booking_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '关联柜次逻辑ID;0表示创建时尚未关联柜次', - checklist_version_id BIGINT UNSIGNED NOT NULL COMMENT '本单冻结使用的报关资料清单版本ID', - checklist_code VARCHAR(40) NOT NULL COMMENT '创建协同单时的清单编码快照;源清单变化不回写', - checklist_version_no VARCHAR(32) NOT NULL COMMENT '创建协同单时的清单版本号快照;源清单变化不回写', - status VARCHAR(32) NOT NULL DEFAULT 'DOCUMENTS_PENDING' COMMENT '协同状态:DOCUMENTS_PENDING待补资料、REVIEWING审核中、SUPPLEMENT_REQUIRED需补件、READY_TO_SUBMIT待提交代理、RECEIPT_PENDING待回执、RECEIPT_AVAILABLE已有回执、CLEARANCE_COMPLETED已放行、CANCELLED已取消', - remark VARCHAR(500) NULL COMMENT '报关协同单内部业务备注', - created_by BIGINT UNSIGNED NOT NULL COMMENT '创建协同单的平台操作人ID', - created_by_name VARCHAR(64) NULL COMMENT '创建协同单时的平台操作人名称快照', - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '协同单创建时间', - updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改协同单的平台操作人ID', - updated_by_name VARCHAR(64) NULL COMMENT '最后修改时的平台操作人名称快照', - updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '协同单最后修改时间', - version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号;每次状态变更递增', - PRIMARY KEY (id), - UNIQUE KEY uk_customs_no (customs_no), - UNIQUE KEY uk_customs_case_scope (tenant_id, order_id, inbound_id, container_booking_id), - KEY idx_customs_case_status (status, updated_at), - KEY idx_customs_case_container (container_booking_id), - CONSTRAINT fk_customs_case_checklist FOREIGN KEY (checklist_version_id) REFERENCES fl_customs_checklist_version(id), - CONSTRAINT chk_customs_case_status CHECK (status IN ('DOCUMENTS_PENDING','REVIEWING','SUPPLEMENT_REQUIRED','READY_TO_SUBMIT','RECEIPT_PENDING','RECEIPT_AVAILABLE','CLEARANCE_COMPLETED','CANCELLED')) -) ENGINE=InnoDB COMMENT='三期订单报关协同聚合'; - -CREATE TABLE IF NOT EXISTS fl_customs_document ( - id BIGINT UNSIGNED NOT NULL COMMENT '报关资料文件版本逻辑ID', - customs_case_id BIGINT UNSIGNED NOT NULL COMMENT '所属报关协同单ID', - checklist_item_code VARCHAR(40) NOT NULL COMMENT '对应冻结清单中的资料项目编码', - file_id BIGINT UNSIGNED NOT NULL COMMENT 'File服务文件逻辑ID;跨库不建立外键', - document_version INT UNSIGNED NOT NULL COMMENT '同一协同单和清单项目下的文件版本序号', - replaces_document_id BIGINT UNSIGNED NULL COMMENT '被本版本替换的上一资料版本ID;首个版本为空', - file_name VARCHAR(255) NOT NULL COMMENT '上传或引用时的文件名称快照', - file_hash CHAR(64) NOT NULL COMMENT '文件内容SHA-256摘要;用于版本审计和幂等校验', - status VARCHAR(16) NOT NULL DEFAULT 'UPLOADED' COMMENT '资料状态:UPLOADED待审核、APPROVED审核通过、RETURNED已退回、SUPERSEDED已被替换、FROZEN已随代理提交批次冻结', - visibility VARCHAR(16) NOT NULL DEFAULT 'CUSTOMER' COMMENT '资料可见范围:PLATFORM仅平台可见、CUSTOMER客户可见', - uploaded_by BIGINT UNSIGNED NOT NULL COMMENT '上传或引用资料的操作人ID', - uploaded_by_type VARCHAR(16) NOT NULL COMMENT '上传主体类型:PLATFORM平台操作人、MERCHANT客户操作人', - uploaded_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '资料上传或引用时间', - reviewed_by BIGINT UNSIGNED NULL COMMENT '审核资料的平台操作人ID;未审核时为空', - reviewed_by_name VARCHAR(64) NULL COMMENT '审核时的平台操作人名称快照', - reviewed_at DATETIME(3) NULL COMMENT '资料审核时间', - review_reason VARCHAR(500) NULL COMMENT '审核退回原因或审核备注', - PRIMARY KEY (id), - UNIQUE KEY uk_customs_document_version (customs_case_id, checklist_item_code, document_version), - UNIQUE KEY uk_customs_document_file (customs_case_id, file_id), - KEY idx_customs_document_case (customs_case_id, status), - CONSTRAINT fk_customs_document_case FOREIGN KEY (customs_case_id) REFERENCES fl_customs_case(id), - CONSTRAINT chk_customs_document_status CHECK (status IN ('UPLOADED','APPROVED','RETURNED','SUPERSEDED','FROZEN')), - CONSTRAINT chk_customs_document_actor CHECK (uploaded_by_type IN ('PLATFORM','MERCHANT')), - CONSTRAINT chk_customs_document_visibility CHECK (visibility IN ('PLATFORM','CUSTOMER')) -) ENGINE=InnoDB COMMENT='报关客户文件版本引用'; - -CREATE TABLE IF NOT EXISTS fl_customs_supplement_request ( - id BIGINT UNSIGNED NOT NULL COMMENT '报关补件请求逻辑ID', - customs_case_id BIGINT UNSIGNED NOT NULL COMMENT '所属报关协同单ID', - source_document_id BIGINT UNSIGNED NOT NULL COMMENT '触发本次补件的被退回资料版本ID', - resolved_document_id BIGINT UNSIGNED NULL COMMENT '解决本次补件的新资料版本ID;未解决时为空', - checklist_item_code VARCHAR(40) NOT NULL COMMENT '需要补充的报关资料项目编码', - reason VARCHAR(500) NOT NULL COMMENT '平台审核人员填写的补件原因', - due_at DATETIME(3) NULL COMMENT '期望完成补件时间;未设置时为空', - status VARCHAR(16) NOT NULL DEFAULT 'OPEN' COMMENT '补件状态:OPEN待补件、RESOLVED已由新资料解决、CANCELLED已取消', - requested_by BIGINT UNSIGNED NOT NULL COMMENT '发起补件的平台操作人ID', - requested_by_name VARCHAR(64) NULL COMMENT '发起补件时的平台操作人名称快照', - requested_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '补件请求发起时间', - resolved_by BIGINT UNSIGNED NULL COMMENT '提交解决资料的操作人ID;未解决时为空', - resolved_at DATETIME(3) NULL COMMENT '补件请求解决时间', - PRIMARY KEY (id), - KEY idx_customs_supplement_case (customs_case_id, status, due_at), - CONSTRAINT fk_customs_supplement_case FOREIGN KEY (customs_case_id) REFERENCES fl_customs_case(id), - CONSTRAINT chk_customs_supplement_status CHECK (status IN ('OPEN','RESOLVED','CANCELLED')) -) ENGINE=InnoDB COMMENT='报关补件请求'; - -CREATE TABLE IF NOT EXISTS fl_customs_submission_batch ( - id BIGINT UNSIGNED NOT NULL COMMENT '报关代理提交批次逻辑ID', - customs_case_id BIGINT UNSIGNED NOT NULL COMMENT '所属报关协同单ID', - batch_no VARCHAR(40) NOT NULL COMMENT '全局唯一代理提交批次号', - batch_version INT UNSIGNED NOT NULL COMMENT '同一报关协同单内的提交批次版本序号', - customs_agent_id BIGINT UNSIGNED NULL COMMENT '本批次报关代理Supplier逻辑ID快照;历史数据无法推导时为空', - customs_agent_name VARCHAR(128) NOT NULL COMMENT '本批次接收报关资料的报关代理名称快照', - external_reference VARCHAR(128) NULL COMMENT '报关代理或外部系统返回的业务参考号', - file_snapshot_json JSON NOT NULL COMMENT '提交时冻结的已审核资料数组;元素包含documentId、fileId、itemCode、documentVersion和fileHash', - status VARCHAR(16) NOT NULL DEFAULT 'SUBMITTED' COMMENT '代理提交状态:SUBMITTED已提交、ACKNOWLEDGED代理已确认、REJECTED代理已拒绝、COMPLETED代理处理完成', - note VARCHAR(500) NULL COMMENT '提交报关代理时的平台备注', - submitted_by BIGINT UNSIGNED NOT NULL COMMENT '提交报关代理的平台操作人ID', - submitted_by_name VARCHAR(64) NULL COMMENT '提交时的平台操作人名称快照', - submitted_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '提交报关代理时间', - PRIMARY KEY (id), - UNIQUE KEY uk_customs_batch_no (batch_no), - UNIQUE KEY uk_customs_batch_version (customs_case_id, batch_version), - CONSTRAINT fk_customs_batch_case FOREIGN KEY (customs_case_id) REFERENCES fl_customs_case(id), - CONSTRAINT chk_customs_batch_status CHECK (status IN ('SUBMITTED','ACKNOWLEDGED','REJECTED','COMPLETED')) -) ENGINE=InnoDB COMMENT='报关代理提交批次及冻结文件版本'; - --- 历史代理名称无法安全反推Supplier ID,旧行保持NULL;新写入由服务端校验并持久化。 -DROP PROCEDURE IF EXISTS phase3_customs_supplier_snapshot_add_column; -DELIMITER $$ -CREATE PROCEDURE phase3_customs_supplier_snapshot_add_column() -BEGIN - IF NOT EXISTS (SELECT 1 FROM information_schema.columns - WHERE table_schema=DATABASE() AND table_name='fl_customs_submission_batch' - AND column_name='customs_agent_id') THEN - ALTER TABLE fl_customs_submission_batch - ADD COLUMN customs_agent_id BIGINT UNSIGNED NULL COMMENT '本批次报关代理Supplier逻辑ID快照;历史数据无法推导时为空' AFTER batch_version; - END IF; -END$$ -DELIMITER ; -CALL phase3_customs_supplier_snapshot_add_column(); -DROP PROCEDURE phase3_customs_supplier_snapshot_add_column; - -CREATE TABLE IF NOT EXISTS fl_customs_receipt ( - id BIGINT UNSIGNED NOT NULL COMMENT '报关回执文件版本逻辑ID', - customs_case_id BIGINT UNSIGNED NOT NULL COMMENT '所属报关协同单ID', - submission_batch_id BIGINT UNSIGNED NOT NULL COMMENT '产生本回执的报关代理提交批次ID', - receipt_type VARCHAR(16) NOT NULL COMMENT '回执类型:RECEIVED已收件、DECLARED已申报、REJECTED已拒绝、INSPECTION查验通知、RELEASED放行回执、OTHER其他回执', - file_id BIGINT UNSIGNED NOT NULL COMMENT 'File服务回执文件逻辑ID;跨库不建立外键', - receipt_version INT UNSIGNED NOT NULL COMMENT '同一提交批次和回执类型下的版本序号', - replaces_receipt_id BIGINT UNSIGNED NULL COMMENT '被本版本替换的上一回执版本ID;首个版本为空', - file_name VARCHAR(255) NOT NULL COMMENT '上传或引用时的回执文件名称快照', - file_hash CHAR(64) NOT NULL COMMENT '回执文件内容SHA-256摘要;用于版本审计和幂等校验', - visibility VARCHAR(16) NOT NULL DEFAULT 'PLATFORM' COMMENT '回执可见范围:PLATFORM仅平台可见、CUSTOMER客户可查询下载', - note VARCHAR(500) NULL COMMENT '回执业务备注', - uploaded_by BIGINT UNSIGNED NOT NULL COMMENT '上传或引用回执的平台操作人ID', - uploaded_by_name VARCHAR(64) NULL COMMENT '上传回执时的平台操作人名称快照', - uploaded_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '回执上传或引用时间', - PRIMARY KEY (id), - UNIQUE KEY uk_customs_receipt_version (submission_batch_id, receipt_type, receipt_version), - UNIQUE KEY uk_customs_receipt_file (submission_batch_id, file_id), - KEY idx_customs_receipt_case (customs_case_id, visibility, uploaded_at), - CONSTRAINT fk_customs_receipt_case FOREIGN KEY (customs_case_id) REFERENCES fl_customs_case(id), - CONSTRAINT fk_customs_receipt_batch FOREIGN KEY (submission_batch_id) REFERENCES fl_customs_submission_batch(id), - CONSTRAINT chk_customs_receipt_type CHECK (receipt_type IN ('RECEIVED','DECLARED','REJECTED','INSPECTION','RELEASED','OTHER')), - CONSTRAINT chk_customs_receipt_visibility CHECK (visibility IN ('PLATFORM','CUSTOMER')) -) ENGINE=InnoDB COMMENT='报关代理回执版本与客户可见范围'; - -CREATE TABLE IF NOT EXISTS fl_customs_history ( - id BIGINT UNSIGNED NOT NULL COMMENT '报关协同历史逻辑ID', - customs_case_id BIGINT UNSIGNED NOT NULL COMMENT '所属报关协同单ID', - action VARCHAR(40) NOT NULL COMMENT '业务动作编码,如CREATE、UPLOAD_DOCUMENT、REPLACE_DOCUMENT、REVIEW_DOCUMENT_APPROVE、REVIEW_DOCUMENT_RETURN、SUBMIT_AGENT_BATCH或UPLOAD_RECEIPT', - from_status VARCHAR(32) NULL COMMENT '动作发生前的协同状态;创建动作时为空', - to_status VARCHAR(32) NULL COMMENT '动作完成后的协同状态', - resource_type VARCHAR(32) NOT NULL COMMENT '动作关联资源类型:CASE协同单、DOCUMENT资料、SUBMISSION_BATCH提交批次或RECEIPT回执', - resource_id BIGINT UNSIGNED NOT NULL COMMENT '动作关联资源逻辑ID', - note VARCHAR(500) NULL COMMENT '动作原因、资料项目编码或业务备注快照', - operator_id BIGINT UNSIGNED NOT NULL COMMENT '执行业务动作的操作人ID', - operator_name VARCHAR(64) NULL COMMENT '执行动作时的操作人名称快照', - operator_type VARCHAR(16) NOT NULL COMMENT '操作主体类型:PLATFORM平台操作人、MERCHANT客户操作人', - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '历史记录创建时间', - PRIMARY KEY (id), - KEY idx_customs_history_case (customs_case_id, created_at, id), - CONSTRAINT fk_customs_history_case FOREIGN KEY (customs_case_id) REFERENCES fl_customs_case(id), - CONSTRAINT chk_customs_history_actor CHECK (operator_type IN ('PLATFORM','MERCHANT')) -) ENGINE=InnoDB COMMENT='报关协同稳定历史'; - -CREATE TABLE IF NOT EXISTS fl_customs_operation ( - id BIGINT UNSIGNED NOT NULL COMMENT '报关写命令幂等记录逻辑ID', - operator_id BIGINT UNSIGNED NOT NULL COMMENT '发起写命令的操作人ID;与动作和幂等键共同限定重放范围', - action VARCHAR(64) NOT NULL COMMENT '幂等业务动作编码,包含平台或客户动作及资料、审核、提交、回执类型', - idempotency_key VARCHAR(128) NOT NULL COMMENT '客户端提供的幂等键', - request_hash CHAR(64) NOT NULL COMMENT '规范化完整请求内容的SHA-256摘要;同键不同请求时拒绝', - status VARCHAR(16) NOT NULL DEFAULT 'PROCESSING' COMMENT '幂等处理状态:PROCESSING处理中、SUCCESS处理成功', - result_type VARCHAR(24) NULL COMMENT '成功结果资源类型:CHECKLIST、CASE、DOCUMENT、BATCH或RECEIPT', - result_id BIGINT UNSIGNED NULL COMMENT '成功结果资源逻辑ID;处理中为空', - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '幂等记录创建时间', - completed_at DATETIME(3) NULL COMMENT '写命令成功完成时间;处理中为空', - PRIMARY KEY (id), - UNIQUE KEY uk_customs_operation (operator_id, action, idempotency_key), - CONSTRAINT chk_customs_operation_status CHECK (status IN ('PROCESSING','SUCCESS')) -) ENGINE=InnoDB COMMENT='报关协同命令幂等记录'; - -USE baiyue_iam; -DROP PROCEDURE IF EXISTS assert_phase3_customs_permission_ids; -DELIMITER $$ -CREATE PROCEDURE assert_phase3_customs_permission_ids() -BEGIN - IF EXISTS ( - SELECT 1 FROM iam_permission - WHERE (id BETWEEN 5102170 AND 5102175 - AND permission_code <> CASE id - WHEN 5102170 THEN 'platform:firstleg:customs:list' - WHEN 5102171 THEN 'platform:firstleg:customs:view' - WHEN 5102172 THEN 'platform:firstleg:customs:manage' - WHEN 5102173 THEN 'platform:firstleg:customs:review' - WHEN 5102174 THEN 'platform:firstleg:customs:submit' - WHEN 5102175 THEN 'platform:firstleg:customs:receipt' - END) - OR (permission_code LIKE 'platform:firstleg:customs:%' - AND (tenant_id <> 0 OR app_code <> 'PLATFORM' OR id NOT BETWEEN 5102170 AND 5102175)) - ) THEN - SIGNAL SQLSTATE '45000' - SET MESSAGE_TEXT = '三期报关权限迁移失败:5102170-5102175 ID 或权限码被其他语义占用'; - END IF; -END$$ -DELIMITER ; -CALL assert_phase3_customs_permission_ids(); -DROP PROCEDURE IF EXISTS assert_phase3_customs_permission_ids; - -INSERT INTO iam_permission - (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name, - route_path,http_method,sort_no,status,built_in,deleted) -VALUES - (5102170,0,'PLATFORM','MENU',5102000,'platform:firstleg:customs:list','报关协同','/first-leg/customs','GET',100,'ACTIVE',1,0), - (5102171,0,'PLATFORM','BUTTON',5102170,'platform:firstleg:customs:view','查看报关协同',NULL,'GET',10,'ACTIVE',1,0), - (5102172,0,'PLATFORM','BUTTON',5102170,'platform:firstleg:customs:manage','维护报关资料',NULL,'POST',20,'ACTIVE',1,0), - (5102173,0,'PLATFORM','BUTTON',5102170,'platform:firstleg:customs:review','审核退回报关资料',NULL,'POST',30,'ACTIVE',1,0), - (5102174,0,'PLATFORM','BUTTON',5102170,'platform:firstleg:customs:submit','提交报关代理',NULL,'POST',40,'ACTIVE',1,0), - (5102175,0,'PLATFORM','BUTTON',5102170,'platform:firstleg:customs:receipt','维护报关回执',NULL,'POST',50,'ACTIVE',1,0) -ON DUPLICATE KEY UPDATE tenant_id=0,app_code='PLATFORM',permission_type=VALUES(permission_type), - permission_code=VALUES(permission_code),permission_name=VALUES(permission_name),parent_id=VALUES(parent_id), - route_path=VALUES(route_path),http_method=VALUES(http_method),sort_no=VALUES(sort_no),status='ACTIVE',built_in=1,deleted=0; - -INSERT IGNORE INTO iam_role_permission(role_id,permission_id,tenant_id) -SELECT role.id,permission.id,0 FROM iam_role role JOIN iam_permission permission - ON permission.tenant_id=0 AND permission.app_code='PLATFORM' - AND permission.permission_code LIKE 'platform:firstleg:customs:%' -WHERE role.tenant_id=0 AND role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR'); - -USE baiyue_firstleg; diff --git a/firstleg/src/main/resources/db/phase3-simple-customs-document-migration.sql b/firstleg/src/main/resources/db/phase3-simple-customs-document-migration.sql deleted file mode 100644 index 1ee186d2b..000000000 --- a/firstleg/src/main/resources/db/phase3-simple-customs-document-migration.sql +++ /dev/null @@ -1,91 +0,0 @@ -USE baiyue_firstleg; - -DROP PROCEDURE IF EXISTS add_simple_customs_column; -DELIMITER $$ -CREATE PROCEDURE add_simple_customs_column(IN p_name VARCHAR(64), IN p_definition TEXT) -BEGIN - IF NOT EXISTS (SELECT 1 FROM information_schema.columns - WHERE table_schema = DATABASE() AND table_name = 'fl_container_document' - AND column_name = p_name) THEN - SET @ddl = CONCAT('ALTER TABLE fl_container_document ADD COLUMN ', p_name, ' ', p_definition); - PREPARE statement FROM @ddl; EXECUTE statement; DEALLOCATE PREPARE statement; - END IF; -END$$ -DELIMITER ; - -CALL add_simple_customs_column('file_hash', "VARCHAR(64) NULL COMMENT 'File服务SHA-256快照' AFTER file_name"); -CALL add_simple_customs_column('completed_by', "BIGINT UNSIGNED NULL COMMENT '一键完成操作人ID' AFTER uploaded_by_name"); -CALL add_simple_customs_column('completed_by_name', "VARCHAR(64) NULL COMMENT '一键完成操作人名称快照' AFTER completed_by"); -CALL add_simple_customs_column('completed_at', "DATETIME(3) NULL COMMENT '一键完成时间' AFTER completed_by_name"); -DROP PROCEDURE add_simple_customs_column; - -SET @old_check = (SELECT CONSTRAINT_NAME FROM information_schema.table_constraints - WHERE table_schema = DATABASE() AND table_name = 'fl_container_document' - AND constraint_type = 'CHECK' AND constraint_name = 'chk_container_document_type'); -SET @ddl = IF(@old_check IS NULL, 'SELECT 1', - 'ALTER TABLE fl_container_document DROP CHECK chk_container_document_type'); -PREPARE statement FROM @ddl; EXECUTE statement; DEALLOCATE PREPARE statement; -ALTER TABLE fl_container_document ADD CONSTRAINT chk_container_document_type - CHECK (document_type IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER')); - --- Keep container identity/loading facts immutable while allowing release transport data to arrive later. -DROP TRIGGER IF EXISTS trg_container_booking_before_update; -DELIMITER $$ -CREATE TRIGGER trg_container_booking_before_update BEFORE UPDATE ON fl_container_booking FOR EACH ROW -BEGIN - DECLARE current_plan_status VARCHAR(24); - SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan WHERE container_booking_id=OLD.id; - IF current_plan_status IN ('LOADING','COMPLETED') AND ( - NOT (OLD.id <=> NEW.id) OR NOT (OLD.booking_no <=> NEW.booking_no) - OR NOT (OLD.cabinet_no <=> NEW.cabinet_no) OR NOT (OLD.channel_code <=> NEW.channel_code) - OR NOT (OLD.channel_name <=> NEW.channel_name) OR NOT (OLD.ocean_booking_id <=> NEW.ocean_booking_id) - OR NOT (OLD.ocean_booking_requirement_id <=> NEW.ocean_booking_requirement_id) - OR NOT (OLD.warehouse_id <=> NEW.warehouse_id) OR NOT (OLD.warehouse_name <=> NEW.warehouse_name) - OR NOT (OLD.container_type <=> NEW.container_type) OR NOT (OLD.shipping_company_id <=> NEW.shipping_company_id) - OR NOT (OLD.shipping_company_name <=> NEW.shipping_company_name) - OR NOT (OLD.departure_port_code <=> NEW.departure_port_code) OR NOT (OLD.departure_port_name <=> NEW.departure_port_name) - OR NOT (OLD.destination_port_code <=> NEW.destination_port_code) OR NOT (OLD.destination_port_name <=> NEW.destination_port_name) - OR NOT (OLD.destination_warehouse_code <=> NEW.destination_warehouse_code) - OR NOT (OLD.destination_warehouse_name <=> NEW.destination_warehouse_name) - OR NOT (OLD.capacity_cbm <=> NEW.capacity_cbm) OR NOT (OLD.max_weight_kg <=> NEW.max_weight_kg) - OR NOT (OLD.terminal_trucking_company_id <=> NEW.terminal_trucking_company_id) - OR NOT (OLD.terminal_trucking_company <=> NEW.terminal_trucking_company) OR NOT (OLD.loading_at <=> NEW.loading_at) - OR NOT (OLD.actual_sailing_at <=> NEW.actual_sailing_at) - OR NOT (OLD.estimated_port_arrival_at <=> NEW.estimated_port_arrival_at) - OR NOT (OLD.actual_port_arrival_at <=> NEW.actual_port_arrival_at) - OR NOT (OLD.estimated_warehouse_arrival_at <=> NEW.estimated_warehouse_arrival_at) - OR NOT (OLD.actual_warehouse_arrival_at <=> NEW.actual_warehouse_arrival_at) - OR NOT (OLD.created_by <=> NEW.created_by) OR NOT (OLD.created_by_name <=> NEW.created_by_name) - OR NOT (OLD.created_at <=> NEW.created_at)) THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='装柜作业开始后仅允许修改集装箱号与放舱运输数据'; - END IF; -END$$ -DELIMITER ; - -DROP TRIGGER IF EXISTS trg_container_document_before_insert; -DELIMITER $$ -CREATE TRIGGER trg_container_document_before_insert BEFORE INSERT ON fl_container_document FOR EACH ROW -BEGIN - DECLARE current_plan_status VARCHAR(24); - SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan - WHERE container_booking_id = NEW.container_booking_id; - IF current_plan_status = 'COMPLETED' - AND NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER') THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜物流文件不可新增'; - END IF; -END$$ -DELIMITER ; - -DROP TRIGGER IF EXISTS trg_container_document_before_update; -DELIMITER $$ -CREATE TRIGGER trg_container_document_before_update BEFORE UPDATE ON fl_container_document FOR EACH ROW -BEGIN - DECLARE current_plan_status VARCHAR(24); - SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan - WHERE container_booking_id = OLD.container_booking_id; - IF current_plan_status = 'COMPLETED' - AND OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER') THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜物流文件不可修改'; - END IF; -END$$ -DELIMITER ; diff --git a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql index cf68da615..8ea74f0f6 100644 --- a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql +++ b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql @@ -315,11 +315,11 @@ CREATE TABLE IF NOT EXISTS fl_loading_plan_item ( CONSTRAINT chk_loading_item_deleted CHECK (deleted IN (0, 1)) ) ENGINE=InnoDB COMMENT='智能装柜客户货物明细;客户归属仅在明细层'; --- 目的港清关和出口报关均为柜次级简单文件事实:上传资料后由平台一键完成。 +-- 柜次级简单文件仅承载目的港清关、提货单和取货单;出口报关由 customs-v1 柜级聚合承载。 CREATE TABLE IF NOT EXISTS fl_container_document ( id BIGINT UNSIGNED NOT NULL COMMENT '货柜文件关联ID(雪花ID)', container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID', - document_type VARCHAR(32) NOT NULL COMMENT '文件类型:DESTINATION_CLEARANCE目的港清关、CUSTOMS_DECLARATION出口报关、DELIVERY_ORDER提货单、PICKUP_ORDER取货单', + document_type VARCHAR(32) NOT NULL COMMENT '文件类型:DESTINATION_CLEARANCE目的港清关、DELIVERY_ORDER提货单、PICKUP_ORDER取货单', file_id BIGINT UNSIGNED NOT NULL COMMENT 'File服务文件ID,不建立跨库外键', file_name VARCHAR(255) NOT NULL COMMENT '文件名称快照', file_hash VARCHAR(64) NULL COMMENT 'File服务SHA-256快照', @@ -338,7 +338,7 @@ CREATE TABLE IF NOT EXISTS fl_container_document ( CONSTRAINT fk_container_document_booking FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking (id), CONSTRAINT chk_container_document_type - CHECK (document_type IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER')) + CHECK (document_type IN ('DESTINATION_CLEARANCE', 'DELIVERY_ORDER', 'PICKUP_ORDER')) ) ENGINE=InnoDB COMMENT='平台货柜清关、DO与PU文件关联'; CREATE TABLE IF NOT EXISTS fl_container_exception_history ( @@ -1152,7 +1152,7 @@ BEGIN SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan WHERE container_booking_id = NEW.container_booking_id; IF current_plan_status = 'COMPLETED' - AND NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER') THEN + AND NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'DELIVERY_ORDER', 'PICKUP_ORDER') THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜文件不可新增'; END IF; END$$ @@ -1168,7 +1168,7 @@ BEGIN SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan WHERE container_booking_id = OLD.container_booking_id; IF current_plan_status = 'COMPLETED' - AND OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER') THEN + AND OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'DELIVERY_ORDER', 'PICKUP_ORDER') THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜文件不可修改'; END IF; END$$ diff --git a/firstleg/src/main/resources/openapi/customs-v1.yaml b/firstleg/src/main/resources/openapi/customs-v1.yaml index 660d2c356..2804db526 100644 --- a/firstleg/src/main/resources/openapi/customs-v1.yaml +++ b/firstleg/src/main/resources/openapi/customs-v1.yaml @@ -1,127 +1,135 @@ openapi: 3.0.3 -info: - title: Phase 3 Customs Collaboration API - version: 1.0.0 - description: Immutable checklist and file versions, supplement requests, frozen agent batches, receipts and history. +info: {title: Firstleg Container Customs API, version: 2.0.0} paths: - /api/platform/v1/customs/checklist-versions: - post: - summary: Create an immutable customs document checklist version - parameters: [{$ref: '#/components/parameters/IdempotencyKey'}] - responses: {'200': {description: Checklist version}} - /api/platform/v1/customs/cases: + /api/platform/v1/customs/loading-plans/{planId}: get: - summary: Page customs cases - responses: {'200': {description: Page result}} + summary: 查询整柜报关状态、缺资料票及文件映射 + x-permission: platform:firstleg:customs:list + parameters: [{$ref: '#/components/parameters/PlanId'}] + responses: {'200': {$ref: '#/components/responses/ContainerResponse'}} + /api/platform/v1/customs/loading-plans/{planId}/start: post: - summary: Create a customer/order/inbound/container scoped customs case - parameters: [{$ref: '#/components/parameters/IdempotencyKey'}] - responses: {'200': {description: Customs case detail}} - /api/platform/v1/customs/cases/{caseId}: - parameters: [{$ref: '#/components/parameters/CaseId'}] + summary: 确认开始整柜报关并冻结全部当前资料版本和覆盖范围 + x-permission: platform:firstleg:customs:manage + parameters: [{$ref: '#/components/parameters/PlanId'}, {$ref: '#/components/parameters/IdempotencyKey'}] + requestBody: {$ref: '#/components/requestBodies/ActionBody'} + responses: {'200': {$ref: '#/components/responses/ContainerResponse'}} + /api/platform/v1/customs/loading-plans/{planId}/complete-all-buy: + post: + summary: 二次确认全买单柜报关完成 + x-permission: platform:firstleg:customs:manage + parameters: [{$ref: '#/components/parameters/PlanId'}, {$ref: '#/components/parameters/IdempotencyKey'}] + requestBody: {$ref: '#/components/requestBodies/ActionBody'} + responses: {'200': {$ref: '#/components/responses/ContainerResponse'}} + /api/platform/v1/customs/loading-plans/documents/{documentId}/completion/upload: + post: + summary: 上传或覆盖冻结客户资料的一对一完成文件 + description: 完成文件自动继承冻结入仓码且不可删除;最后一份须finalConfirmation=true,File调用前服务端预检。 + x-permission: platform:firstleg:customs:manage + parameters: + - {$ref: '#/components/parameters/DocumentId'} + - {$ref: '#/components/parameters/IdempotencyKey'} + - {name: expectedVersion, in: query, required: true, schema: {type: integer, minimum: 0}} + - {name: finalConfirmation, in: query, required: true, schema: {type: boolean}} + requestBody: {$ref: '#/components/requestBodies/FileBody'} + responses: {'200': {$ref: '#/components/responses/DocumentResponse'}} + /api/platform/v1/customs/loading-plans/documents/{documentId}/download: get: - summary: Query complete customs detail and stable history - responses: {'200': {description: Detail}} - /api/platform/v1/customs/cases/{caseId}/documents: - parameters: [{$ref: '#/components/parameters/CaseId'}, {$ref: '#/components/parameters/IdempotencyKey'}] - post: - summary: Reference a file validated against customer, CUSTOMS_DOCUMENT and case ID - responses: {'200': {description: New document version}} - /api/platform/v1/customs/cases/{caseId}/documents/upload: - parameters: [{$ref: '#/components/parameters/CaseId'}, {$ref: '#/components/parameters/IdempotencyKey'}] - post: - summary: Upload a document and create a new immutable version - requestBody: - required: true - content: - multipart/form-data: - schema: - type: object - required: [itemCode, expectedVersion, file] - properties: - itemCode: {type: string} - expectedVersion: {type: integer, minimum: 0} - file: {type: string, format: binary} - responses: {'200': {description: New document version}} - /api/platform/v1/customs/cases/{caseId}/documents/{documentId}/review: - parameters: - - {$ref: '#/components/parameters/CaseId'} - - {name: documentId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} - - {$ref: '#/components/parameters/IdempotencyKey'} - post: - summary: Approve a current file version or return it with a supplement task - responses: {'200': {description: Updated detail}} - /api/platform/v1/customs/cases/{caseId}/submission-batches: - parameters: [{$ref: '#/components/parameters/CaseId'}, {$ref: '#/components/parameters/IdempotencyKey'}] - post: - summary: Submit to customs agent and freeze approved file versions in a batch snapshot - description: customsAgentId must identify an ACTIVE Warehouse supplier in category CUSTOMS; the persisted agent name is resolved server-side and free text cannot bypass supplier validation. - requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/SubmitBatchRequest'}}}} - responses: - '200': - description: Submission batch - content: - application/json: - schema: {$ref: '#/components/schemas/CustomsSubmissionBatch'} - /api/platform/v1/customs/submission-batches/{batchId}/receipts: - parameters: - - {name: batchId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} - - {$ref: '#/components/parameters/IdempotencyKey'} - post: - summary: Reference a customer-owned receipt file with explicit visibility - responses: {'200': {description: Receipt version}} - /api/platform/v1/customs/submission-batches/{batchId}/receipts/upload: - post: - summary: Upload a receipt version; RELEASED closes the customs dispatch gate - responses: {'200': {description: Receipt version}} + summary: 平台下载客户报送资料 + x-permission: platform:firstleg:customs:list + parameters: [{$ref: '#/components/parameters/DocumentId'}] + responses: {'200': {$ref: '#/components/responses/BinaryResponse'}} + /api/platform/v1/customs/loading-plans/documents/{documentId}/completion/download: + get: + summary: 平台下载报关完成文件 + x-permission: platform:firstleg:customs:list + parameters: [{$ref: '#/components/parameters/DocumentId'}] + responses: {'200': {$ref: '#/components/responses/BinaryResponse'}} /api/merchant/v1/customs/orders/{orderId}: get: - summary: Query the authenticated customer's customs case by order - responses: {'200': {description: Customer-filtered detail}} - /api/merchant/v1/customs/cases/{caseId}/documents/upload: + summary: 客户订单列表查询当前完成装柜退税报关四态 + x-permission: merchant:order:view + parameters: [{$ref: '#/components/parameters/OrderId'}] + responses: {'200': {$ref: '#/components/responses/ContainerResponse'}} + /api/merchant/v1/customs/loading-plans/{planId}/documents/upload: post: - summary: Upload or replace a checklist document within authenticated customer scope - responses: {'200': {description: New document version}} - /api/merchant/v1/customs/cases/{caseId}/receipts/{receiptId}/download: + summary: 上传资料并关联当前柜内本客户一个或多个退税入仓码 + x-permission: merchant:order:edit + parameters: + - {$ref: '#/components/parameters/PlanId'} + - {$ref: '#/components/parameters/IdempotencyKey'} + - {$ref: '#/components/parameters/InboundIds'} + requestBody: {$ref: '#/components/requestBodies/FileBody'} + responses: {'200': {$ref: '#/components/responses/DocumentResponse'}} + /api/merchant/v1/customs/documents/{documentId}/upload: + post: + summary: 报关开始前覆盖资料并同步修改同柜关联票 + x-permission: merchant:order:edit + parameters: + - {$ref: '#/components/parameters/DocumentId'} + - {$ref: '#/components/parameters/IdempotencyKey'} + - {$ref: '#/components/parameters/InboundIds'} + - {name: expectedVersion, in: query, required: true, schema: {type: integer, minimum: 1}} + requestBody: {$ref: '#/components/requestBodies/FileBody'} + responses: {'200': {$ref: '#/components/responses/DocumentResponse'}} + /api/merchant/v1/customs/documents/{documentId}/inbounds: + put: + summary: 报关开始前修改资料关联的同柜退税入仓码 + x-permission: merchant:order:edit + parameters: [{$ref: '#/components/parameters/DocumentId'}, {$ref: '#/components/parameters/IdempotencyKey'}] + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ScopeRequest'}}}} + responses: {'200': {$ref: '#/components/responses/DocumentResponse'}} + /api/merchant/v1/customs/documents/{documentId}: + delete: + summary: 报关开始前逻辑删除误传资料并保留不可变版本 + x-permission: merchant:order:edit + parameters: + - {$ref: '#/components/parameters/DocumentId'} + - {$ref: '#/components/parameters/IdempotencyKey'} + - {name: expectedVersion, in: query, required: true, schema: {type: integer, minimum: 1}} + responses: {'200': {$ref: '#/components/responses/ContainerResponse'}} + /api/merchant/v1/customs/documents/{documentId}/download: get: - summary: Download only CUSTOMER-visible receipts from the authenticated customer's case - responses: {'200': {description: Authorized file stream}} + summary: 客户下载自己当前报送资料 + x-permission: merchant:order:view + parameters: [{$ref: '#/components/parameters/DocumentId'}] + responses: {'200': {$ref: '#/components/responses/BinaryResponse'}} + /api/merchant/v1/customs/documents/{documentId}/completion/download: + get: + summary: 整柜完成后客户下载对应完成文件 + x-permission: merchant:order:view + parameters: [{$ref: '#/components/parameters/DocumentId'}] + responses: {'200': {$ref: '#/components/responses/BinaryResponse'}} + /internal/exports/v1/LOADING_LIST_BUY_DECLARATION_ITEMS/batches: + post: + summary: File导出中心按loadingPlanId分页获取已完成装柜买单实际行 + parameters: [{name: X-Internal-Service-Token, in: header, required: true, schema: {type: string}}] + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ExportBatchRequest'}}}} + responses: {'200': {description: 成功, content: {application/json: {schema: {$ref: '#/components/schemas/ExportBatchEnvelope'}}}}} components: parameters: - IdempotencyKey: - name: Idempotency-Key - in: header - required: true - schema: {type: string, minLength: 1, maxLength: 128} - CaseId: - name: caseId - in: path - required: true - schema: {type: string, pattern: '^[1-9][0-9]*$'} + PlanId: {name: planId, in: path, required: true, schema: {$ref: '#/components/schemas/LongId'}} + OrderId: {name: orderId, in: path, required: true, schema: {$ref: '#/components/schemas/LongId'}} + DocumentId: {name: documentId, in: path, required: true, schema: {$ref: '#/components/schemas/LongId'}} + IdempotencyKey: {name: Idempotency-Key, in: header, required: true, schema: {type: string, minLength: 1, maxLength: 128}} + InboundIds: {name: inboundIds, in: query, required: true, schema: {type: array, minItems: 1, maxItems: 200, items: {$ref: '#/components/schemas/LongId'}}} + requestBodies: + ActionBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ActionRequest'}}}} + FileBody: {required: true, content: {multipart/form-data: {schema: {type: object, required: [file], properties: {file: {type: string, format: binary, description: 任意格式且最大50MB}}}}}} + responses: + ContainerResponse: {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/ContainerEnvelope'}}}} + DocumentResponse: {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/DocumentEnvelope'}}}} + BinaryResponse: {description: 文件二进制, headers: {Content-Disposition: {schema: {type: string}}, Cache-Control: {schema: {type: string, example: no-store}}}, content: {application/octet-stream: {schema: {type: string, format: binary}}}} schemas: - SubmitBatchRequest: - type: object - required: [customsAgentId, expectedVersion] - properties: - customsAgentId: {type: integer, format: int64, minimum: 1, description: ACTIVE Warehouse supplier ID validated as CUSTOMS.} - customsAgentName: {type: string, maxLength: 128, nullable: true, description: Optional consistency hint; persisted name always comes from Warehouse.} - externalReference: {type: string, maxLength: 128, nullable: true} - note: {type: string, maxLength: 500, nullable: true} - expectedVersion: {type: integer, minimum: 0} - CustomsSubmissionBatch: - type: object - required: [id, customsCaseId, batchNo, batchVersion, customsAgentName, status, submittedBy, submittedAt] - properties: - id: {type: string, pattern: '^[1-9][0-9]*$'} - customsCaseId: {type: string, pattern: '^[1-9][0-9]*$'} - batchNo: {type: string, maxLength: 40} - batchVersion: {type: integer, minimum: 1} - customsAgentId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: Server-validated CUSTOMS supplier ID snapshot; null only for non-migrated historical rows.} - customsAgentName: {type: string, maxLength: 128, description: Server-frozen Warehouse supplier name snapshot.} - externalReference: {type: string, maxLength: 128, nullable: true} - fileSnapshotJson: {type: string} - status: {type: string, enum: [SUBMITTED, ACKNOWLEDGED, REJECTED, COMPLETED]} - note: {type: string, maxLength: 500, nullable: true} - submittedBy: {type: string, pattern: '^[1-9][0-9]*$'} - submittedByName: {type: string, maxLength: 64, nullable: true} - submittedAt: {type: string, format: date-time} + LongId: {type: string, pattern: '^[1-9][0-9]*$', description: Long ID序列化为字符串} + ApiEnvelope: {type: object, required: [code, message, timestamp], properties: {code: {type: string}, message: {type: string}, requestId: {type: string, nullable: true}, traceId: {type: string, nullable: true}, timestamp: {type: string, format: date-time}}} + ContainerEnvelope: {allOf: [{$ref: '#/components/schemas/ApiEnvelope'}, {type: object, properties: {data: {$ref: '#/components/schemas/ContainerView'}}}]} + DocumentEnvelope: {allOf: [{$ref: '#/components/schemas/ApiEnvelope'}, {type: object, properties: {data: {$ref: '#/components/schemas/DocumentView'}}}]} + ActionRequest: {type: object, required: [expectedVersion, confirm], additionalProperties: false, properties: {expectedVersion: {type: integer, minimum: 0}, confirm: {type: boolean}, remark: {type: string, maxLength: 500, nullable: true}}} + ScopeRequest: {type: object, required: [inboundIds, expectedVersion], additionalProperties: false, properties: {inboundIds: {type: array, minItems: 1, maxItems: 200, items: {$ref: '#/components/schemas/LongId'}}, expectedVersion: {type: integer, minimum: 1}}} + FileView: {type: object, required: [fileId, fileName, fileHash, sizeBytes, version, uploadedAt], properties: {fileId: {$ref: '#/components/schemas/LongId'}, fileName: {type: string}, fileHash: {type: string}, contentType: {type: string, nullable: true}, sizeBytes: {type: integer, format: int64}, version: {type: integer}, uploadedAt: {type: string, format: date-time}}} + InboundView: {type: object, required: [inboundId, orderId, orderNo, inboundCode, tenantId, customerName, taxMode, covered], properties: {inboundId: {$ref: '#/components/schemas/LongId'}, orderId: {$ref: '#/components/schemas/LongId'}, orderNo: {type: string}, inboundCode: {type: string}, tenantId: {$ref: '#/components/schemas/LongId'}, customerName: {type: string}, taxMode: {type: string, enum: [TAX_REFUND, BUY_DECLARATION]}, covered: {type: boolean}}} + DocumentView: {type: object, required: [documentId, tenantId, source, inboundIds, inboundCodes, frozen], properties: {documentId: {$ref: '#/components/schemas/LongId'}, tenantId: {$ref: '#/components/schemas/LongId'}, source: {$ref: '#/components/schemas/FileView'}, inboundIds: {type: array, items: {$ref: '#/components/schemas/LongId'}}, inboundCodes: {type: array, items: {type: string}}, frozen: {type: boolean}, completion: {allOf: [{$ref: '#/components/schemas/FileView'}], nullable: true}}} + ContainerView: {type: object, required: [aggregateId, loadingPlanId, containerBookingId, status, version, taxRefundCount, coveredTaxRefundCount, inbounds, documents], properties: {aggregateId: {$ref: '#/components/schemas/LongId'}, loadingPlanId: {$ref: '#/components/schemas/LongId'}, containerBookingId: {$ref: '#/components/schemas/LongId'}, planNo: {type: string}, cabinetNo: {type: string}, containerNo: {type: string, nullable: true}, status: {type: string, enum: [WAITING_CUSTOMER_DOCUMENTS, READY_TO_START, DECLARING, COMPLETED]}, merchantStatus: {type: string, enum: [WAITING_UPLOAD, DOCUMENT_UPLOADED, DECLARING, CUSTOMS_COMPLETED], nullable: true}, version: {type: integer}, taxRefundCount: {type: integer}, coveredTaxRefundCount: {type: integer}, inbounds: {type: array, items: {$ref: '#/components/schemas/InboundView'}}, documents: {type: array, items: {$ref: '#/components/schemas/DocumentView'}}, startedAt: {type: string, format: date-time, nullable: true}, completedAt: {type: string, format: date-time, nullable: true}}} + ExportBatchRequest: {type: object, required: [taskId, tenantId, appCode, requestedBy, dispatchVersion, parameters, limit], properties: {taskId: {type: string}, tenantId: {type: string, enum: ['0']}, appCode: {type: string, enum: [PLATFORM]}, requestedBy: {type: string}, dispatchVersion: {type: integer}, parameters: {type: object, required: [loadingPlanId], additionalProperties: false, properties: {loadingPlanId: {type: string}}}, cursor: {type: string, nullable: true}, snapshotToken: {type: string, nullable: true}, limit: {type: integer, minimum: 1, maximum: 500}}} + ExportBatchEnvelope: {allOf: [{$ref: '#/components/schemas/ApiEnvelope'}, {type: object, properties: {data: {type: object, properties: {schemaVersion: {type: integer}, schemaHash: {type: string}, snapshotToken: {type: string}, columns: {type: array, items: {type: object}}, rows: {type: array, items: {type: object}}, nextCursor: {type: string}, hasMore: {type: boolean}, totalRows: {type: integer, format: int64, nullable: true}}}}}]} diff --git a/firstleg/src/main/resources/openapi/loading-lists-v1.yaml b/firstleg/src/main/resources/openapi/loading-lists-v1.yaml index 4e556e7d3..79b0ff218 100644 --- a/firstleg/src/main/resources/openapi/loading-lists-v1.yaml +++ b/firstleg/src/main/resources/openapi/loading-lists-v1.yaml @@ -81,70 +81,6 @@ paths: parameters: [{$ref: '#/components/parameters/PlanId'}, {$ref: '#/components/parameters/WarehouseId'}] responses: '200': {$ref: '#/components/responses/ConfirmedDetailResponse'} - /api/platform/v1/first-leg/loading-lists/{planId}/customs: - get: - summary: 查询装柜清单出口报关文档 - x-permission: platform:firstleg:loading-list:list - description: 查询出口报关文档;写入与完成继续要求 platform:firstleg:customs:manage。 - parameters: [{$ref: '#/components/parameters/PlanId'}, {$ref: '#/components/parameters/WarehouseId'}] - responses: - '200': {$ref: '#/components/responses/BusinessDocumentResponse'} - /api/platform/v1/first-leg/loading-lists/{planId}/customs/document: - post: - summary: 上传或替换装柜清单出口报关文档 - x-permission: platform:firstleg:customs:manage - parameters: [{$ref: '#/components/parameters/PlanId'}, {$ref: '#/components/parameters/WarehouseId'}, {$ref: '#/components/parameters/IdempotencyKey'}] - requestBody: - required: true - content: {application/json: {schema: {$ref: '#/components/schemas/DocumentAttachRequest'}}} - responses: - '200': {$ref: '#/components/responses/BusinessDocumentResponse'} - /api/platform/v1/first-leg/loading-lists/{planId}/customs/document/{fileId}/download: - get: - summary: 下载装柜清单出口报关附件 - description: 完成态仍可下载。服务端校验 planId、warehouseId、当前 CUSTOMS_DECLARATION 的 fileId,以及 File 元数据 tenantId=0、businessType=LOADING_CUSTOMS_DECLARATION、businessId=planId;错误 fileId 或归属返回 A0404。响应使用 attachment 文件名并禁止缓存。 - x-permission: platform:firstleg:loading-list:list - parameters: - - {$ref: '#/components/parameters/PlanId'} - - {$ref: '#/components/parameters/WarehouseId'} - - name: fileId - in: path - required: true - description: File 服务长整型文件 ID - schema: {$ref: '#/components/schemas/LongId'} - responses: - '200': - description: 报关附件二进制内容;包含 Content-Disposition,Cache-Control 为 no-store - headers: - Content-Disposition: {schema: {type: string}} - Cache-Control: {schema: {type: string, example: no-store}} - content: - application/octet-stream: - schema: {type: string, format: binary} - '404': {description: 装柜计划、仓库、当前附件或 File 归属不匹配} - /api/platform/v1/first-leg/loading-lists/{planId}/customs/document/upload: - post: - summary: 直接选择文件上传或替换装柜清单出口报关文档 - description: Firstleg 使用内部服务令牌上传 File,浏览器不得传递 fileId 或内部令牌;旧 JSON document 接口仅兼容存量调用。 - x-permission: platform:firstleg:customs:manage - parameters: [{$ref: '#/components/parameters/PlanId'}, {$ref: '#/components/parameters/WarehouseId'}, {$ref: '#/components/parameters/IdempotencyKey'}] - requestBody: - required: true - content: - multipart/form-data: - schema: {$ref: '#/components/schemas/DocumentUploadRequest'} - responses: - '200': {$ref: '#/components/responses/BusinessDocumentResponse'} - /api/platform/v1/first-leg/loading-lists/{planId}/customs/complete: - post: - summary: 点击完成出口报关 - x-permission: platform:firstleg:customs:manage - parameters: [{$ref: '#/components/parameters/PlanId'}, {$ref: '#/components/parameters/WarehouseId'}, {$ref: '#/components/parameters/IdempotencyKey'}] - requestBody: - required: true - content: {application/json: {schema: {$ref: '#/components/schemas/DocumentCompleteRequest'}}} - responses: - '200': {$ref: '#/components/responses/BusinessDocumentResponse'} components: parameters: PlanId: @@ -218,9 +154,6 @@ components: ConfirmedDetailResponse: description: 装柜清单详情查询或确认成功,也可能返回业务失败 content: {application/json: {schema: {$ref: '#/components/schemas/ConfirmedDetailEnvelope'}}} - BusinessDocumentResponse: - description: 报关文档查询或操作结果 - content: {application/json: {schema: {$ref: '#/components/schemas/BusinessDocumentEnvelope'}}} schemas: LongId: type: string @@ -279,14 +212,6 @@ components: data: allOf: [{$ref: '#/components/schemas/ConfirmedDetail'}] nullable: true - BusinessDocumentEnvelope: - allOf: - - {$ref: '#/components/schemas/ApiEnvelope'} - - type: object - properties: - data: - allOf: [{$ref: '#/components/schemas/BusinessDocument'}] - nullable: true PreloadPage: type: object required: [records, page, size, total, pages] @@ -464,42 +389,3 @@ components: voyageNo: {type: string, maxLength: 64, nullable: true, description: 航次} expectedVersion: {type: integer, format: int32, minimum: 0, description: 当前乐观锁版本} operationReason: {type: string, maxLength: 500, nullable: true, description: 操作原因} - DocumentAttachRequest: - type: object - required: [fileId, expectedVersion] - additionalProperties: false - properties: - fileId: {$ref: '#/components/schemas/LongId'} - expectedVersion: {type: integer, format: int32, minimum: 0} - operationReason: {type: string, maxLength: 500, nullable: true} - DocumentCompleteRequest: - type: object - required: [expectedVersion] - additionalProperties: false - properties: - expectedVersion: {type: integer, format: int32, minimum: 0} - operationReason: {type: string, maxLength: 500, nullable: true} - DocumentUploadRequest: - type: object - required: [file, expectedVersion] - additionalProperties: false - properties: - file: {type: string, format: binary, description: 用户直接选择的报关文档} - expectedVersion: {type: integer, format: int32, minimum: 0, description: 当前文档乐观锁版本,首次上传为 0} - operationReason: {type: string, maxLength: 500, nullable: true} - BusinessDocument: - type: object - required: [id, documentType, fileId, fileName, fileHash, uploadedBy, uploadedAt, version] - properties: - id: {$ref: '#/components/schemas/LongId'} - documentType: {type: string, enum: [CUSTOMS_DECLARATION]} - fileId: {$ref: '#/components/schemas/LongId'} - fileName: {type: string} - fileHash: {type: string, pattern: '^[0-9a-fA-F]{64}$'} - uploadedBy: {$ref: '#/components/schemas/LongId'} - uploadedByName: {type: string, nullable: true} - uploadedAt: {type: string, format: date-time} - completedBy: {$ref: '#/components/schemas/NullableLongId'} - completedByName: {type: string, nullable: true} - completedAt: {type: string, format: date-time, nullable: true} - version: {type: integer, format: int32, minimum: 0} diff --git a/firstleg/src/main/resources/openapi/phase3-container-overview-v1.yaml b/firstleg/src/main/resources/openapi/phase3-container-overview-v1.yaml index 47bf7403f..4051fd984 100644 --- a/firstleg/src/main/resources/openapi/phase3-container-overview-v1.yaml +++ b/firstleg/src/main/resources/openapi/phase3-container-overview-v1.yaml @@ -45,7 +45,7 @@ paths: components: parameters: ContainerId: {name: id, in: path, required: true, schema: {type: integer, format: int64}} - DocumentType: {name: documentType, in: path, required: true, schema: {type: string, enum: [DELIVERY_ORDER, PICKUP_ORDER]}, description: '通用单证仅支持DO/PU;清关使用 /clearance,出口报关使用 loading-list 专用接口'} + DocumentType: {name: documentType, in: path, required: true, schema: {type: string, enum: [DELIVERY_ORDER, PICKUP_ORDER]}, description: '通用单证仅支持DO/PU;目的港清关使用 /clearance;出口报关由 customs-v1 柜级聚合接口承载'} requestBodies: ExceptionRequest: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ExceptionRequest'}}}} schemas: @@ -57,7 +57,7 @@ components: container: {$ref: '#/components/schemas/Container'} loadingPlanId: {type: integer, format: int64} pda: {$ref: '#/components/schemas/PdaSummary'} - documents: {type: array, description: '按 documentType 稳定排序返回 DESTINATION_CLEARANCE、CUSTOMS_DECLARATION、DELIVERY_ORDER、PICKUP_ORDER 的当前唯一记录和真实 version', items: {$ref: '#/components/schemas/Document'}} + documents: {type: array, description: '按 documentType 稳定排序返回 DESTINATION_CLEARANCE、DELIVERY_ORDER、PICKUP_ORDER 的当前唯一记录和真实 version;出口报关只由 customs-v1 柜级聚合承载', items: {$ref: '#/components/schemas/Document'}} exception: {$ref: '#/components/schemas/ExceptionState'} exceptionHistory: {type: array, items: {$ref: '#/components/schemas/ExceptionHistory'}} timeline: {type: array, items: {$ref: '#/components/schemas/TimelineEntry'}} @@ -65,7 +65,7 @@ components: PdaSummary: {type: object, properties: {taskId: {type: integer, format: int64}, loadingPlanId: {type: integer, format: int64}, containerBookingId: {type: integer, format: int64}, taskStatus: {type: string, description: '持久化任务态:LOADING/COMPLETED'}, workflowStatus: {type: string, enum: [IN_PROGRESS, AWAITING_REVIEW, COMPLETED], description: 'PDA作业展示态'}, taskVersion: {type: integer}, itemCount: {type: integer}, completedItemCount: {type: integer}, unresolvedItemCount: {type: integer}, photoCounts: {type: array, items: {$ref: '#/components/schemas/PhotoCount'}}, photos: {type: array, items: {$ref: '#/components/schemas/Photo'}}, exceptionReasons: {type: array, items: {type: string}}}} PhotoCount: {type: object, properties: {evidenceType: {type: string}, count: {type: integer}}} Photo: {type: object, properties: {evidenceId: {type: integer, format: int64}, tenantId: {type: integer, format: int64}, inboundId: {type: integer, format: int64}, evidenceType: {type: string}, fileId: {type: integer, format: int64}, fileName: {type: string}, status: {type: string, enum: [ACTIVE, VOID]}, downloadPath: {type: string, nullable: true, description: '仅 ACTIVE 照片提供鉴权下载路径;VOID 历史照片为空'}, uploadedAt: {type: string, format: date-time}}} - Document: {type: object, properties: {id: {type: integer, format: int64}, documentType: {type: string, enum: [DESTINATION_CLEARANCE, CUSTOMS_DECLARATION, DELIVERY_ORDER, PICKUP_ORDER]}, fileId: {type: integer, format: int64}, fileName: {type: string}, fileHash: {type: string}, uploadedBy: {type: integer, format: int64}, uploadedByName: {type: string}, uploadedAt: {type: string, format: date-time}, version: {type: integer, description: '当前唯一记录的真实乐观锁版本;更新时必须原样作为 expectedVersion'}, completed: {type: boolean}}} + Document: {type: object, properties: {id: {type: integer, format: int64}, documentType: {type: string, enum: [DESTINATION_CLEARANCE, DELIVERY_ORDER, PICKUP_ORDER]}, fileId: {type: integer, format: int64}, fileName: {type: string}, fileHash: {type: string}, uploadedBy: {type: integer, format: int64}, uploadedByName: {type: string}, uploadedAt: {type: string, format: date-time}, version: {type: integer, description: '当前唯一记录的真实乐观锁版本;更新时必须原样作为 expectedVersion'}, completed: {type: boolean}}} ExceptionState: {type: object, properties: {active: {type: boolean}, reason: {type: string}, markedBy: {type: integer, format: int64}, markedByName: {type: string}, markedAt: {type: string, format: date-time}}} ExceptionHistory: {type: object, properties: {id: {type: integer, format: int64}, action: {type: string, enum: [MARK, CANCEL]}, reason: {type: string}, operatorId: {type: integer, format: int64}, operatorName: {type: string}, occurredAt: {type: string, format: date-time}}} TimelineEntry: {type: object, properties: {eventType: {type: string}, detail: {type: string}, operatorId: {type: integer, format: int64}, operatorName: {type: string}, occurredAt: {type: string, format: date-time}}} diff --git a/firstleg/src/main/resources/openapi/phase3-outbound-dispatch-v1.yaml b/firstleg/src/main/resources/openapi/phase3-outbound-dispatch-v1.yaml index 364c0b142..f8b38eaa0 100644 --- a/firstleg/src/main/resources/openapi/phase3-outbound-dispatch-v1.yaml +++ b/firstleg/src/main/resources/openapi/phase3-outbound-dispatch-v1.yaml @@ -37,7 +37,7 @@ paths: /api/platform/v1/first-leg/outbound-dispatches/{containerBookingId}/gate: get: summary: Evaluate every inbound binding in the fail-closed container outbound gate - description: Loading evidence is evaluated once for the physical container/loading plan and the same result applies to every inbound binding. The customs gate is resolved once from the current container CUSTOMS_DECLARATION whose completedAt must be non-null; upload alone remains blocked and fl_customs_case is not an alternative gate fact. A missing standalone sailing schedule is reference-only and never affects gate.passed. + description: Loading evidence is evaluated once for the physical container/loading plan and the same result applies to every inbound binding. The customs gate is resolved once from fl_customs_container and only status=COMPLETED passes; customer upload, READY_TO_START and DECLARING remain blocked. A missing standalone sailing schedule is reference-only and never affects gate.passed. Container customs completion attempts system confirmation immediately and a bounded scheduler retries after later gates become ready. x-permission: platform:firstleg:outbound-dispatch:check parameters: - {$ref: '#/components/parameters/ContainerBookingIdPath'} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/ContainerControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/ContainerControllerContractTest.java index 33327afb2..68241a4d2 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/ContainerControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/ContainerControllerContractTest.java @@ -42,7 +42,7 @@ class ContainerControllerContractTest { assertThat(openapi).contains("/api/platform/v1/containers/{id}/overview:", "/documents/{documentType}/{fileId}/download:", "ExceptionRequest", "/clearance/document/{fileId}/download:", - "DESTINATION_CLEARANCE、CUSTOMS_DECLARATION、DELIVERY_ORDER、PICKUP_ORDER", + "DESTINATION_CLEARANCE、DELIVERY_ORDER、PICKUP_ORDER", "enum: [DELIVERY_ORDER, PICKUP_ORDER]", "Idempotency-Key", "photoCounts:", "/pda/photos/{fileId}/download:", "downloadPath:", "workflowStatus:", "AWAITING_REVIEW", "version: {type: integer, minimum: 0}", diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java index a2ecf3ca4..5af98fc5b 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java @@ -6,19 +6,12 @@ import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedQuery; import com.baiyue.firstleg.dto.LoadingListDtos.PreloadQuery; import com.baiyue.firstleg.dto.LoadingListDtos.TransportUpdateRequest; import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmPreloadRequest; -import com.baiyue.firstleg.dto.LoadingListDtos.DocumentAttachRequest; -import com.baiyue.firstleg.dto.LoadingListDtos.DocumentCompleteRequest; import com.baiyue.firstleg.service.LoadingListService; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.yaml.snakeyaml.Yaml; import org.springframework.core.io.ClassPathResource; import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RequestPart; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.http.ResponseEntity; -import org.springframework.web.multipart.MultipartFile; import java.lang.reflect.Method; import java.lang.reflect.RecordComponent; @@ -42,15 +35,6 @@ class LoadingListControllerContractTest { authority("confirmedPage", "platform:firstleg:loading-list:list", AuthenticatedUser.class, ConfirmedQuery.class); authority("confirmedDetail", "platform:firstleg:loading-list:list", AuthenticatedUser.class, long.class, long.class); - authority("declaration", "platform:firstleg:loading-list:list", AuthenticatedUser.class, long.class, long.class); - authority("downloadDeclaration", "platform:firstleg:loading-list:list", AuthenticatedUser.class, - long.class, long.class, long.class); - authority("attachDeclaration", "platform:firstleg:customs:manage", AuthenticatedUser.class, - long.class, long.class, String.class, DocumentAttachRequest.class); - authority("uploadDeclaration", "platform:firstleg:customs:manage", AuthenticatedUser.class, - long.class, long.class, String.class, MultipartFile.class, int.class, String.class); - authority("completeDeclaration", "platform:firstleg:customs:manage", AuthenticatedUser.class, - long.class, long.class, String.class, DocumentCompleteRequest.class); } @Test @@ -92,8 +76,6 @@ class LoadingListControllerContractTest { .contains("oceanBookingStatus") .contains("pdaTaskId") .contains("cabinetNo") - .contains("LOADING_CUSTOMS_DECLARATION") - .contains("Cache-Control 为 no-store") .doesNotContain("internalContainerNo") .doesNotContain("rollback") .doesNotContain("delete:") @@ -112,30 +94,6 @@ class LoadingListControllerContractTest { assertThat(component(ConfirmedQuery.class, "warehouseId").getAnnotation(NotNull.class)).isNull(); } - @Test - void multipartScalarFieldsUseRequestParameters() throws Exception { - Method method = PlatformLoadingListController.class.getDeclaredMethod("uploadDeclaration", - AuthenticatedUser.class, long.class, long.class, String.class, - MultipartFile.class, int.class, String.class); - assertThat(method.getParameters()[4].getAnnotation(RequestPart.class)).isNotNull(); - assertThat(method.getParameters()[5].getAnnotation(RequestParam.class)).isNotNull(); - assertThat(method.getParameters()[5].getAnnotation(RequestPart.class)).isNull(); - assertThat(method.getParameters()[6].getAnnotation(RequestParam.class)).isNotNull(); - assertThat(method.getParameters()[6].getAnnotation(RequestPart.class)).isNull(); - } - - @Test - void customsDownloadUsesStablePlanPathAndBinaryResponse() throws Exception { - Method method = PlatformLoadingListController.class.getDeclaredMethod("downloadDeclaration", - AuthenticatedUser.class, long.class, long.class, long.class); - assertThat(method.getAnnotation(GetMapping.class).value()).containsExactly( - "/loading-lists/{planId}/customs/document/{fileId}/download"); - assertThat(method.getReturnType()).isEqualTo(ResponseEntity.class); - assertThat(method.getParameters()[1].getName()).isEqualTo("planId"); - assertThat(method.getParameters()[2].getName()).isEqualTo("fileId"); - assertThat(method.getParameters()[3].getName()).isEqualTo("warehouseId"); - } - @Test void yamlParses() throws Exception { String yaml = new ClassPathResource("openapi/loading-lists-v1.yaml") @@ -149,23 +107,14 @@ class LoadingListControllerContractTest { "/api/platform/v1/first-leg/loading-plans/{planId}/transport", "/api/platform/v1/first-leg/loading-plans/{planId}/confirm", "/api/platform/v1/first-leg/loading-lists", - "/api/platform/v1/first-leg/loading-lists/{planId}", - "/api/platform/v1/first-leg/loading-lists/{planId}/customs", - "/api/platform/v1/first-leg/loading-lists/{planId}/customs/document", - "/api/platform/v1/first-leg/loading-lists/{planId}/customs/document/{fileId}/download", - "/api/platform/v1/first-leg/loading-lists/{planId}/customs/document/upload", - "/api/platform/v1/first-leg/loading-lists/{planId}/customs/complete"); + "/api/platform/v1/first-leg/loading-lists/{planId}"); Map components = stringMap(document.get("components")); Map schemas = stringMap(components.get("schemas")); assertThat(schemas).containsKeys( "LongId", "PreloadPage", "ConfirmedPage", "Customer", "PreloadSummary", "ConfirmedSummary", "LoadingItem", "OperationHistory", "PreloadDetail", "ConfirmedDetail", "TransportUpdateRequest", "PreloadReadiness", - "ConfirmPreloadRequest", "PreloadConfirmation", "PreloadConfirmationEnvelope", - "BusinessDocumentEnvelope", - "DocumentAttachRequest", "DocumentUploadRequest", "DocumentCompleteRequest", "BusinessDocument"); - assertThat(properties(schemas, "DocumentUploadRequest")) - .containsOnlyKeys("file", "expectedVersion", "operationReason"); + "ConfirmPreloadRequest", "PreloadConfirmation", "PreloadConfirmationEnvelope"); assertThat(paths) .doesNotContainKey("/api/platform/v1/first-leg/loading-plans/{planId}/complete"); assertThat(stringMap(schemas.get("LongId"))).containsEntry("type", "string"); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsControllerContractTest.java new file mode 100644 index 000000000..c69f7edbb --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsControllerContractTest.java @@ -0,0 +1,44 @@ +package com.baiyue.firstleg.customs.container; + +import com.baiyue.firstleg.customs.container.controller.*; +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.core.io.ClassPathResource; +import org.yaml.snakeyaml.Yaml; + +import java.lang.reflect.Method; + +import static org.assertj.core.api.Assertions.assertThat; + +class ContainerCustomsControllerContractTest { + @Test void platformUsesListForReadsAndManageForWrites() { + assertThat(PlatformContainerCustomsController.class.getAnnotation(RequestMapping.class).value()) + .containsExactly("/api/platform/v1/customs/loading-plans"); + assertPermission(PlatformContainerCustomsController.class,"detail","platform:firstleg:customs:list"); + assertPermission(PlatformContainerCustomsController.class,"start","platform:firstleg:customs:manage"); + assertPermission(PlatformContainerCustomsController.class,"uploadCompletion","platform:firstleg:customs:manage"); + assertPermission(PlatformContainerCustomsController.class,"completeAllBuy","platform:firstleg:customs:manage"); + } + @Test void merchantUsesOrderViewAndEditWithoutTrustingTenantInput() { + assertThat(MerchantContainerCustomsController.class.getAnnotation(RequestMapping.class).value()) + .containsExactly("/api/merchant/v1/customs"); + assertPermission(MerchantContainerCustomsController.class,"order","merchant:order:view"); + assertPermission(MerchantContainerCustomsController.class,"upload","merchant:order:edit"); + assertPermission(MerchantContainerCustomsController.class,"replace","merchant:order:edit"); + assertPermission(MerchantContainerCustomsController.class,"scope","merchant:order:edit"); + assertPermission(MerchantContainerCustomsController.class,"delete","merchant:order:edit"); + } + @Test void openApiPublishesEveryPathParameterAndGeneratableDtos()throws Exception{ + String yaml=new ClassPathResource("openapi/customs-v1.yaml").getContentAsString(java.nio.charset.StandardCharsets.UTF_8); + new Yaml().load(yaml); + assertThat(yaml).contains("ContainerView:","DocumentView:","FileView:","InboundView:","ActionRequest:","ScopeRequest:") + .contains("merchantStatus:","WAITING_UPLOAD","CUSTOMS_COMPLETED","loadingPlanId:") + .contains("LOADING_LIST_BUY_DECLARATION_ITEMS") + .doesNotContain("/cases/","CUSTOMS_DECLARATION"); + } + private static void assertPermission(Class type,String method,String permission) { + Method value=java.util.Arrays.stream(type.getDeclaredMethods()).filter(m->m.getName().equals(method)).findFirst().orElseThrow(); + assertThat(value.getAnnotation(PreAuthorize.class).value()).contains(permission); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsMapperContractTest.java new file mode 100644 index 000000000..7c4e1c85b --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsMapperContractTest.java @@ -0,0 +1,24 @@ +package com.baiyue.firstleg.customs.container; + +import com.baiyue.firstleg.customs.container.mapper.ContainerCustomsMapper; +import org.apache.ibatis.annotations.Select; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class ContainerCustomsMapperContractTest { + @Test void actualCompletedLoadingItemsAndTaxModeAreTheOnlyTicketFacts() throws Exception { + String plan=sql("planInbounds",long.class); + assertThat(plan).contains("fl_loading_plan_item","fl_inbound_order","p.id=#{planId}","item.deleted=0"); + String missing=sql("missingRefundCount",long.class,long.class); + assertThat(missing).contains("i.tax_mode='TAX_REFUND'","fl_customs_customer_document_inbound","d.status='ACTIVE'"); + } + @Test void buyExportIsCompletedPlanActualItemsOnly() throws Exception { + String value=sql("buyRows",long.class,long.class,long.class,int.class); + assertThat(value).contains("p.plan_status='COMPLETED'","i.tax_mode='BUY_DECLARATION'","item.deleted=0") + .contains("item.id>#{cursor}","item.id<=#{maximumId}"); + } + private static String sql(String name,Class... types) throws Exception { + return String.join(" ",ContainerCustomsMapper.class.getMethod(name,types).getAnnotation(Select.class).value()); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsServiceTest.java new file mode 100644 index 000000000..69fcedc11 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsServiceTest.java @@ -0,0 +1,91 @@ +package com.baiyue.firstleg.customs.container; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.customs.container.dto.ContainerCustomsDtos.ActionRequest; +import com.baiyue.firstleg.customs.container.mapper.ContainerCustomsMapper; +import com.baiyue.firstleg.customs.container.service.ContainerCustomsService; +import com.baiyue.firstleg.customs.client.CustomsFileClient; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.*; +import org.mockito.*; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.mock.web.MockMultipartFile; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.*; + +class ContainerCustomsServiceTest { + @Mock ContainerCustomsMapper mapper; + @Mock CustomsFileClient files; + @Mock ObjectMapper objectMapper; + @Mock ApplicationEventPublisher events; + @InjectMocks ContainerCustomsService service; + @BeforeEach void setUp(){MockitoAnnotations.openMocks(this);} + + @Test void startFailsClosedWhenAnyRefundInboundHasNoActiveDocumentCoverage(){ + var row=new ContainerCustomsMapper.InboundRow(9L,"LP9",8L,"COMPLETED","CAB","C",11L,21L,"O","I",7L,"客户","TAX_REFUND"); + var container=new ContainerCustomsMapper.ContainerRow(1L,9L,8L,ContainerCustomsService.READY,null,null,null,null,null,null,null,null,2); + when(mapper.planInbounds(9L)).thenReturn(List.of(row));when(mapper.container(9L)).thenReturn(container); + when(mapper.missingRefundCount(9L,1L)).thenReturn(0,1);when(mapper.claimCommand(anyLong(),eq(5L),anyString(),eq("k"),anyString())).thenReturn(1); + when(mapper.lockContainer(9L)).thenReturn(container); + + assertThatThrownBy(()->service.start(9L,5L,"admin","k",new ActionRequest(2,true,null))) + .isInstanceOf(BizException.class).hasMessageContaining("退税入仓码未上传"); + + verify(mapper,never()).freezeScopes(anyLong());verify(mapper,never()).freezeDocuments(anyLong());verify(mapper,never()).start(anyLong(),anyInt(),anyLong(),anyString()); + } + + @Test void startRejectsStaleContainerVersionBeforeAnyFreeze(){ + var row=new ContainerCustomsMapper.InboundRow(9L,"LP9",8L,"COMPLETED","CAB","C",11L,21L,"O","I",7L,"客户","TAX_REFUND"); + var container=new ContainerCustomsMapper.ContainerRow(1L,9L,8L,ContainerCustomsService.READY,null,null,null,null,null,null,null,null,3); + when(mapper.planInbounds(9L)).thenReturn(List.of(row));when(mapper.container(9L)).thenReturn(container); + when(mapper.missingRefundCount(9L,1L)).thenReturn(0);when(mapper.claimCommand(anyLong(),eq(5L),anyString(),eq("k"),anyString())).thenReturn(1); + when(mapper.lockContainer(9L)).thenReturn(container); + assertThatThrownBy(()->service.start(9L,5L,"admin","k",new ActionRequest(2,true,null))) + .isInstanceOf(BizException.class).hasMessageContaining("其他人修改"); + verify(mapper,never()).freezeScopes(anyLong()); + } + + @Test void lastCompletionRequiresConfirmationBeforeCallingFile(){ + var source=document(1L);var container=container(ContainerCustomsService.DECLARING,3); + when(mapper.document(1L)).thenReturn(source);when(mapper.lockContainerById(2L)).thenReturn(container); + when(mapper.lockDocument(1L)).thenReturn(source);when(mapper.lockCompletion(1L)).thenReturn(null); + when(mapper.missingCompletionCount(2L)).thenReturn(1); + var file=new MockMultipartFile("file","result.bin","application/octet-stream",new byte[]{1}); + assertThatThrownBy(()->service.uploadCompletion(1L,5L,"admin","k",0,false,file)) + .isInstanceOf(BizException.class).hasMessageContaining("最后一份"); + verifyNoInteractions(files); + } + + @Test void completedContainerLocksCompletionReplacement(){ + var source=document(1L);when(mapper.document(1L)).thenReturn(source); + when(mapper.lockContainerById(2L)).thenReturn(container(ContainerCustomsService.COMPLETED,4)); + when(mapper.lockDocument(1L)).thenReturn(source); + var file=new MockMultipartFile("file","result.bin","application/octet-stream",new byte[]{1}); + assertThatThrownBy(()->service.uploadCompletion(1L,5L,"admin","k",0,false,file)) + .isInstanceOf(BizException.class).hasMessageContaining("不在报关中"); + verifyNoInteractions(files); + } + + @Test void merchantCannotDownloadCompletionBeforeWholeContainerCompletes(){ + var source=document(1L);when(mapper.document(1L)).thenReturn(source); + when(mapper.containerById(2L)).thenReturn(container(ContainerCustomsService.DECLARING,3)); + assertThatThrownBy(()->service.downloadCompletion(7L,1L,false)).isInstanceOf(BizException.class) + .hasMessageContaining("完成文件不存在"); + verifyNoInteractions(files); + } + + @Test void allBuyCompletionRejectsUnknownOrMixedTaxMode(){ + var value=container(ContainerCustomsService.DECLARING,3);when(mapper.lockContainer(9L)).thenReturn(value); + when(mapper.claimCommand(anyLong(),eq(5L),anyString(),eq("k"),anyString())).thenReturn(1); + when(mapper.planInbounds(9L)).thenReturn(List.of(new ContainerCustomsMapper.InboundRow(9L,"LP",8L,"COMPLETED","CAB","C",11L,21L,"O","I",7L,"客户",null))); + assertThatThrownBy(()->service.completeAllBuy(9L,5L,"admin","k",new ActionRequest(3,true,null))) + .isInstanceOf(BizException.class).hasMessageContaining("BUY_DECLARATION"); + verify(mapper,never()).complete(anyLong(),anyInt(),anyLong(),anyString()); + } + + private static ContainerCustomsMapper.DocumentRow document(long id){return new ContainerCustomsMapper.DocumentRow(id,2L,7L,10L,"source.bin","a".repeat(64),"application/octet-stream",1L,"ACTIVE",5L,java.time.LocalDateTime.now(),1,java.time.LocalDateTime.now(),1);} + private static ContainerCustomsMapper.ContainerRow container(String status,int version){return new ContainerCustomsMapper.ContainerRow(2L,9L,8L,status,null,null,null,null,null,null,null,null,version);} +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/customs/container/LoadingListBuyDeclarationExportServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/customs/container/LoadingListBuyDeclarationExportServiceTest.java new file mode 100644 index 000000000..96c2abe15 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/customs/container/LoadingListBuyDeclarationExportServiceTest.java @@ -0,0 +1,32 @@ +package com.baiyue.firstleg.customs.container; + +import com.baiyue.common.core.export.ExportContracts.ExportBatchRequest; +import com.baiyue.firstleg.customs.container.mapper.ContainerCustomsMapper; +import com.baiyue.firstleg.customs.container.service.LoadingListBuyDeclarationExportService; +import org.junit.jupiter.api.*; +import org.mockito.*; +import org.springframework.test.util.ReflectionTestUtils; + +import java.math.BigDecimal; +import java.util.*; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +class LoadingListBuyDeclarationExportServiceTest { + @Mock ContainerCustomsMapper mapper; + @InjectMocks LoadingListBuyDeclarationExportService service; + @BeforeEach void setUp(){MockitoAnnotations.openMocks(this);ReflectionTestUtils.setField(service,"token","x".repeat(32));service.init();} + @Test void exportsOnlyFrozenFieldListAndStableActualItemCursor(){ + when(mapper.planExportScope(9L)).thenReturn(new ContainerCustomsMapper.PlanExportScope(9L,"COMPLETED",7L)); + when(mapper.maximumBuyItemId(9L)).thenReturn(41L);when(mapper.countBuyRows(9L,41L)).thenReturn(1L); + when(mapper.buyRows(9L,0L,41L,101)).thenReturn(List.of(new ContainerCustomsMapper.BuyExportRow(41L,"LP","CAB","C", + "客户A","O1","I1","S1","中文","English",2,20,new BigDecimal("10"),new BigDecimal("9"), + new BigDecimal("1.2"),"MARK","备注"))); + var result=service.batch(new ExportBatchRequest("1","0","PLATFORM","2",1,Map.of("loadingPlanId","9"),null,null,100)); + assertThat(result.columns()).extracting(c->c.key()).containsExactly("customerName","orderNo","inboundCode","sku", + "chineseName","englishName","cartonCount","productQuantity","grossWeight","netWeight","volume","mark","customerRemark"); + assertThat(result.rows()).singleElement().satisfies(row->assertThat(row).containsEntry("inboundCode","I1").doesNotContainKeys("itemId","taxMode")); + assertThat(result.hasMore()).isFalse();assertThat(result.totalRows()).isEqualTo(1L); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/customs/repository/CustomsSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/customs/repository/CustomsSqlContractTest.java index 8c03e055a..7a8b5240c 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/customs/repository/CustomsSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/customs/repository/CustomsSqlContractTest.java @@ -9,14 +9,14 @@ import static org.assertj.core.api.Assertions.assertThat; class CustomsSqlContractTest { @Test - void moduleSqlContainsVersioningIsolationPermissionsAndFrozenBatchContracts() throws Exception { - String sql = Files.readString(Path.of("src/main/resources/db/phase3-customs-schema.sql")); - assertThat(sql).contains("fl_customs_checklist_version", "items_json JSON NOT NULL") - .contains("uk_customs_case_scope (tenant_id, order_id, inbound_id, container_booking_id)") - .contains("uk_customs_document_version", "file_snapshot_json JSON NOT NULL") - .contains("uk_customs_receipt_version", "visibility VARCHAR(16)") - .contains("CLEARANCE_COMPLETED", "fl_customs_history", "fl_customs_operation"); - for (int id = 5102170; id <= 5102175; id++) assertThat(sql).contains(Integer.toString(id)); - assertThat(sql).contains("PLATFORM_ADMIN", "ORDER_OPERATOR", "platform:firstleg:customs:receipt"); + void moduleSqlContainsContainerStateManyToManyFreezeVersionAndIdempotencyContracts() throws Exception { + String sql = Files.readString(Path.of("src/main/resources/db/phase3-container-customs-schema.sql")); + assertThat(sql).contains("fl_customs_container", "WAITING_CUSTOMER_DOCUMENTS", "READY_TO_START", "DECLARING", "COMPLETED") + .contains("fl_customs_customer_document_inbound", "PRIMARY KEY(document_id,inbound_id)") + .contains("fl_customs_customer_document_version", "scope_snapshot_json JSON NOT NULL") + .contains("fl_customs_frozen_document_inbound", "fl_customs_completion_document_version") + .contains("UNIQUE KEY uk_customs_completion_source(source_document_id)") + .contains("fl_customs_command", "uk_customs_command(actor_id,action,idempotency_key)") + .doesNotContain("platform:firstleg:loading-list:export"); } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributorTest.java b/firstleg/src/test/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributorTest.java index dc499e353..d2814d9e5 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributorTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributorTest.java @@ -1,7 +1,6 @@ package com.baiyue.firstleg.customs.service; -import com.baiyue.firstleg.customs.repository.CustomsRepository; -import com.baiyue.firstleg.customs.mapper.CustomsCaseMapper; +import com.baiyue.firstleg.customs.container.mapper.ContainerCustomsMapper; import com.baiyue.firstleg.outbound.dispatch.gate.OutboundDispatchGateContributor.Context; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -11,7 +10,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.*; class CustomsDispatchGateContributorTest { - @Mock CustomsRepository repository; + @Mock ContainerCustomsMapper mapper; @InjectMocks CustomsDispatchGateContributor contributor; @BeforeEach void setUp() { MockitoAnnotations.openMocks(this); } @@ -23,35 +22,37 @@ class CustomsDispatchGateContributorTest { } @Test void uploadedButIncompleteDeclarationRemainsBlocked() { - when(repository.loadingDeclarationCompleted(81L)).thenReturn(false); + when(mapper.containerByBooking(81L)).thenReturn(container("DECLARING")); assertThat(contributor.assess(context())).satisfies(item -> { assertThat(item.passed()).isFalse(); - assertThat(item.actual()).isEqualTo("MISSING"); + assertThat(item.actual()).isEqualTo("DECLARING"); assertThat(item.requirement()).isEqualTo("COMPLETED"); }); } @Test void completedLoadingDeclarationPassesWithoutReadingCustomsCase() { - when(repository.loadingDeclarationCompleted(81L)).thenReturn(true); + when(mapper.containerByBooking(81L)).thenReturn(container("COMPLETED")); assertThat(contributor.assess(context())).satisfies(item -> { assertThat(item.passed()).isTrue(); assertThat(item.actual()).isEqualTo("COMPLETED"); assertThat(item.requirement()).isEqualTo("COMPLETED"); }); - verify(repository).loadingDeclarationCompleted(81L); - verify(repository, never()).gateCase(anyLong(), anyLong(), anyLong(), anyLong()); + verify(mapper).containerByBooking(81L); } - @Test void mapperUsesOnlyCompletedLoadingDeclarationDocumentFact() throws Exception { - String sql = String.join(" ", CustomsCaseMapper.class - .getMethod("completedLoadingDeclaration", long.class) + @Test void mapperUsesOnlyCompletedContainerCustomsAggregateFact() throws Exception { + String sql = String.join(" ", ContainerCustomsMapper.class + .getMethod("containerByBooking", long.class) .getAnnotation(org.apache.ibatis.annotations.Select.class).value()); assertThat(sql) - .contains("FROM fl_container_document", "container_booking_id=#{containerBookingId}") - .contains("document_type='CUSTOMS_DECLARATION'", "completed_at IS NOT NULL") - .doesNotContain("fl_customs_case", "CLEARANCE_COMPLETED"); + .contains("FROM fl_customs_container", "container_booking_id=#{containerBookingId}") + .doesNotContain("fl_container_document", "fl_customs_case"); + } + + private static ContainerCustomsMapper.ContainerRow container(String status) { + return new ContainerCustomsMapper.ContainerRow(1L,91L,81L,status,null,null,null,null,null,null,null,null,0); } private static Context context() { return new Context(10001L, 10002L, 21L, 31L, 81L, 91L, null); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/customs/service/CustomsServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/customs/service/CustomsServiceTest.java deleted file mode 100644 index 6f3c9cc7c..000000000 --- a/firstleg/src/test/java/com/baiyue/firstleg/customs/service/CustomsServiceTest.java +++ /dev/null @@ -1,168 +0,0 @@ -package com.baiyue.firstleg.customs.service; - -import com.baiyue.common.core.api.ApiResponse; -import com.baiyue.common.core.exception.BizException; -import com.baiyue.firstleg.customs.client.CustomsFileClient; -import com.baiyue.firstleg.customs.dto.CustomsDtos.*; -import com.baiyue.firstleg.customs.entity.*; -import com.baiyue.firstleg.customs.repository.CustomsRepository; -import com.baiyue.firstleg.service.WarehouseSupplierSnapshotService; -import com.fasterxml.jackson.databind.ObjectMapper; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mockito.*; -import org.springframework.mock.web.MockMultipartFile; -import org.springframework.dao.DuplicateKeyException; -import org.springframework.test.util.ReflectionTestUtils; - -import java.util.List; -import java.util.concurrent.atomic.AtomicReference; - -import static org.assertj.core.api.Assertions.*; -import static org.mockito.ArgumentMatchers.*; -import static org.mockito.Mockito.*; - -class CustomsServiceTest { - @Mock CustomsRepository repository; - @Mock CustomsFileClient files; - @Mock WarehouseSupplierSnapshotService suppliers; - @InjectMocks CustomsService service; - - @BeforeEach - void setUp() { - MockitoAnnotations.openMocks(this); - ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper()); - ReflectionTestUtils.setField(service, "internalToken", "token"); - when(suppliers.customs(anyLong(), anyString())) - .thenReturn(new com.baiyue.firstleg.client.WarehouseSupplierClient.Snapshot(88L, "线下报关代理", "CUSTOMS", "ACTIVE", java.util.List.of())); - when(repository.completeOperation(anyLong(), anyString(), anyLong())).thenReturn(1); - } - - @Test - void createCaseRejectsMismatchedInboundScope() { - CustomsChecklistVersionEntity checklist = checklist(); - when(repository.checklist(11L)).thenReturn(checklist); - when(repository.inboundBelongsTo(10001L, 21L, 31L)).thenReturn(false); - - assertThatThrownBy(() -> service.createCase(7L, "admin", "case-1", - new CreateCaseRequest(10001L, 21L, 31L, null, 11L, null, 0))) - .isInstanceOf(BizException.class).hasMessageContaining("归属不一致"); - - verify(repository, never()).insert(any(CustomsCaseEntity.class)); - } - - @Test - void returnedDocumentCreatesSupplementAndMovesCaseToSupplementRequired() { - CustomsCaseEntity customsCase = customsCase("REVIEWING", 2); - CustomsDocumentEntity document = document("UPLOADED"); - when(repository.customsCase(41L)).thenReturn(customsCase); - when(repository.document(51L)).thenReturn(document); - when(repository.latestDocument(41L, "INVOICE")).thenReturn(document); - when(repository.supplements(41L)).thenReturn(List.of(openSupplement())); - when(repository.transition(41L, 2, "SUPPLEMENT_REQUIRED", 7L, "admin")).thenReturn(1); - - // Returning the detail reload is intentionally stopped at the repository boundary. - when(repository.checklist(11L)).thenReturn(checklist()); - when(repository.documents(41L)).thenReturn(List.of(document)); - when(repository.batches(41L)).thenReturn(List.of()); - when(repository.receipts(41L, false)).thenReturn(List.of()); - when(repository.history(41L)).thenReturn(List.of()); - - service.review(41L, 51L, 7L, "admin", "review-1", - new ReviewRequest("RETURN", "发票金额不一致", null, 2)); - - verify(repository).insert(any(CustomsSupplementRequestEntity.class)); - verify(repository).markDocument(51L, "RETURNED", 7L, "admin", "发票金额不一致"); - verify(repository).transition(41L, 2, "SUPPLEMENT_REQUIRED", 7L, "admin"); - } - - @Test - void submitBatchFreezesApprovedVersionSnapshot() { - CustomsCaseEntity customsCase = customsCase("READY_TO_SUBMIT", 4); - CustomsDocumentEntity document = document("APPROVED"); - when(repository.customsCase(41L)).thenReturn(customsCase); - when(repository.checklist(11L)).thenReturn(checklist()); - when(repository.documents(41L)).thenReturn(List.of(document)); - when(repository.batches(41L)).thenReturn(List.of()); - when(repository.freeze(51L)).thenReturn(1); - when(repository.transition(41L, 4, "RECEIPT_PENDING", 7L, "admin")).thenReturn(1); - - CustomsSubmissionBatchEntity result = service.submitBatch(41L, 7L, "admin", "batch-1", - new SubmitBatchRequest(88L, "线下报关代理", "EXT-1", "提交", 4)); - - assertThat(result.getFileSnapshotJson()).contains("documentVersion").contains("INVOICE"); - assertThat(result.getCustomsAgentId()).isEqualTo(88L); - assertThat(result.getCustomsAgentName()).isEqualTo("线下报关代理"); - verify(repository).freeze(51L); - verify(repository).insert(any(CustomsSubmissionBatchEntity.class)); - } - - @Test - void releasedReceiptCompletesClearanceGateState() { - CustomsCaseEntity customsCase = customsCase("RECEIPT_PENDING", 5); - CustomsSubmissionBatchEntity batch = new CustomsSubmissionBatchEntity(); - batch.setId(61L); batch.setCustomsCaseId(41L); - when(repository.batch(61L)).thenReturn(batch); - when(repository.customsCase(41L)).thenReturn(customsCase); - when(repository.latestReceipt(61L, "RELEASED")).thenReturn(null); - when(repository.transition(41L, 5, "CLEARANCE_COMPLETED", 7L, "admin")).thenReturn(1); - var metadata = new CustomsFileClient.Metadata(71L, 10001L, "CUSTOMS_RECEIPT", "61", - "release.pdf", "a".repeat(64), "application/pdf", 8L); - when(files.upload(eq("token"), eq(7L), eq(10001L), eq("CUSTOMS_RECEIPT"), eq("61"), any())) - .thenReturn(ApiResponse.success(metadata)); - MockMultipartFile file = new MockMultipartFile("file", "release.pdf", "application/pdf", "%PDF-1.4".getBytes()); - - CustomsReceiptEntity result = service.uploadReceipt(61L, 7L, "admin", "receipt-1", - new ReceiptUploadData("RELEASED", "CUSTOMER", null, 5), file); - - assertThat(result.getReceiptType()).isEqualTo("RELEASED"); - verify(repository).transition(41L, 5, "CLEARANCE_COMPLETED", 7L, "admin"); - } - - @Test - void uploadReplayReturnsOriginalDocumentBeforeCheckingStaleAggregateVersion() { - CustomsCaseEntity customsCase = customsCase("CLEARANCE_COMPLETED", 7); - CustomsDocumentEntity document = document("FROZEN"); - when(repository.customsCase(10001L, 41L)).thenReturn(customsCase); - when(repository.document(51L)).thenReturn(document); - AtomicReference captured = new AtomicReference<>(); - doAnswer(invocation -> { - CustomsOperationEntity existing = invocation.getArgument(0); - existing.setStatus("SUCCESS"); existing.setResultType("DOCUMENT"); existing.setResultId(51L); - captured.set(existing); - throw new DuplicateKeyException("duplicate replay"); - }).when(repository).insert(any(CustomsOperationEntity.class)); - when(repository.operation(7L, "MERCHANT_UPLOAD_DOCUMENT", "upload-replay")).thenAnswer(invocation -> captured.get()); - MockMultipartFile file = new MockMultipartFile("file", "invoice.txt", "text/plain", "invoice".getBytes()); - - CustomsDocumentEntity result = service.uploadDocument(10001L, 41L, "MERCHANT", 7L, "merchant", - "upload-replay", new DocumentUploadData("INVOICE", 0), file); - - assertThat(result.getId()).isEqualTo(51L); - verifyNoInteractions(files); - verify(repository, never()).transition(anyLong(), anyInt(), anyString(), anyLong(), anyString()); - } - - private static CustomsChecklistVersionEntity checklist() { - CustomsChecklistVersionEntity value = new CustomsChecklistVersionEntity(); - value.setId(11L); value.setChecklistCode("US_FBA"); value.setVersionNo("V1"); value.setStatus("ACTIVE"); - value.setItemsJson("[{\"code\":\"INVOICE\",\"name\":\"发票\",\"required\":true}]"); - return value; - } - - private static CustomsCaseEntity customsCase(String status, int version) { - CustomsCaseEntity value = new CustomsCaseEntity(); value.setId(41L); value.setTenantId(10001L); - value.setOrderId(21L); value.setInboundId(31L); value.setContainerBookingId(81L); - value.setChecklistVersionId(11L); value.setStatus(status); value.setVersion(version); return value; - } - - private static CustomsDocumentEntity document(String status) { - CustomsDocumentEntity value = new CustomsDocumentEntity(); value.setId(51L); value.setCustomsCaseId(41L); - value.setChecklistItemCode("INVOICE"); value.setFileId(71L); value.setDocumentVersion(1); - value.setFileHash("a".repeat(64)); value.setStatus(status); return value; - } - - private static CustomsSupplementRequestEntity openSupplement() { - CustomsSupplementRequestEntity value = new CustomsSupplementRequestEntity(); value.setStatus("OPEN"); return value; - } -} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchAutoConfirmServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchAutoConfirmServiceTest.java new file mode 100644 index 000000000..3e11cd5eb --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchAutoConfirmServiceTest.java @@ -0,0 +1,25 @@ +package com.baiyue.firstleg.outbound.dispatch; + +import com.baiyue.firstleg.outbound.dispatch.dto.OutboundDispatchDtos.*; +import com.baiyue.firstleg.outbound.dispatch.service.*; +import org.junit.jupiter.api.*; +import org.mockito.*; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +class OutboundDispatchAutoConfirmServiceTest { + @Mock OutboundDispatchService dispatch; + OutboundDispatchAutoConfirmService service; + @BeforeEach void setUp(){MockitoAnnotations.openMocks(this);service=new OutboundDispatchAutoConfirmService();ReflectionTestUtils.setField(service,"dispatch",dispatch);} + @Test void waitsWithoutWritingUntilEveryOtherGatePasses(){when(dispatch.detail(22L)).thenReturn(detail(false,"READY_FOR_DISPATCH",3));assertThat(service.confirmIfReady(22L)).isFalse();verify(dispatch,never()).confirm(anyLong(),anyLong(),anyString(),anyString(),any());} + @Test void usesSystemIdentityAndStableIdempotencyKey(){when(dispatch.detail(22L)).thenReturn(detail(true,"READY_FOR_DISPATCH",3));assertThat(service.confirmIfReady(22L)).isTrue();verify(dispatch).confirm(eq(22L),eq(0L),eq("SYSTEM"),eq("CUSTOMS-AUTO-DISPATCH-22"),argThat(r->r.expectedVersion()==3));} + @Test void concurrentReplayAlreadyConfirmedConvergesWithoutSecondMutation(){when(dispatch.detail(22L)).thenReturn(detail(true,"WAITING_FOR_LOADING",4));assertThat(service.confirmIfReady(22L)).isTrue();verify(dispatch,never()).confirm(anyLong(),anyLong(),anyString(),anyString(),any());} + @Test void proxyOwnedAutoBoundaryAndConfirmMutationAreTransactional()throws Exception{assertThat(OutboundDispatchAutoConfirmService.class.getMethod("confirmIfReady",long.class).getAnnotation(Transactional.class)).isNotNull();assertThat(OutboundDispatchService.class.getMethod("confirm",long.class,long.class,String.class,String.class,ActionRequest.class).getAnnotation(Transactional.class)).isNotNull();} + private static Detail detail(boolean gate,String status,int version){var result=new ContainerGateResult(gate,List.of(),LocalDateTime.now());var aggregate=new Aggregate(22L,"CAB","C",44L,"COMPLETED",66L,"RELEASED",77L,"DEPARTED",status,version,List.of(),result);return new Detail(aggregate,List.of());} +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchReadMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchReadMapperContractTest.java index 1efcea3a3..078522bfe 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchReadMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchReadMapperContractTest.java @@ -88,7 +88,7 @@ class OutboundDispatchReadMapperContractTest { .getAnnotation(Select.class).value()); assertThat(sql) - .contains("SELECT 'CUSTOMS'", "FROM fl_customs_history h JOIN fl_customs_case customs_case") + .contains("SELECT 'CUSTOMS'", "FROM fl_customs_container_history h JOIN fl_customs_container customs_container") .contains("customs_case.tenant_id=#{tenantId}", "customs_case.order_id=#{orderId}") .contains("customs_case.inbound_id=#{inboundId}", "customs_case.container_booking_id=#{containerBookingId}") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchServiceTest.java index 09c38aef4..66f19b493 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchServiceTest.java @@ -229,6 +229,7 @@ class OutboundDispatchServiceTest { .isInstanceOf(BizException.class).hasMessageContaining("用于不同请求"); } + private OutboundDispatchService serviceWithoutCarrier() { return service(null); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchSqlContractTest.java index 0480e784e..c04cb0588 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchSqlContractTest.java @@ -51,8 +51,8 @@ class OutboundDispatchSqlContractTest { .contains("absence never blocks manual outbound confirmation") .contains("PENDING or any non-terminal state never affects gate.passed") .contains("Loading evidence is evaluated once for the physical container/loading plan") - .contains("CUSTOMS_DECLARATION whose completedAt must be non-null") - .contains("fl_customs_case is not an alternative gate fact") + .contains("fl_customs_container", "only status=COMPLETED passes") + .contains("bounded scheduler retries") .contains("missing standalone sailing schedule is reference-only") .contains("expectedVersion") .contains("Idempotency-Key") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java index 78b425d42..cb7c0eaa5 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java @@ -10,11 +10,14 @@ import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ProviderObs import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ProviderExceptionRequest; import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity; import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryAttemptEntity; +import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryHistoryEntity; import com.baiyue.firstleg.portschedule.entity.CarrierScheduleExceptionFactEntity; import com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity; import com.baiyue.firstleg.portschedule.entity.PortScheduleEntity; import com.baiyue.firstleg.portschedule.repository.CarrierScheduleQueryRepository; import com.baiyue.firstleg.portschedule.repository.PortScheduleRepository; +import com.baiyue.firstleg.outbound.dispatch.dto.OutboundDispatchDtos.ActionRequest; +import com.baiyue.firstleg.outbound.dispatch.service.OutboundDispatchService; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -24,6 +27,7 @@ import org.mockito.ArgumentCaptor; import org.mockito.MockitoAnnotations; import org.springframework.test.util.ReflectionTestUtils; import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.transaction.annotation.Transactional; import java.time.Clock; import java.time.Instant; @@ -36,6 +40,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -94,8 +99,9 @@ class CarrierScheduleQueryServiceTest { when(portSchedules.oceanBooking(10L)).thenReturn(booking); when(portSchedules.containerBooking(20L)).thenReturn(container); when(portSchedules.allocation(10L, 20L)).thenReturn(new OceanBookingContainerAllocationEntity()); + when(repository.insertIfAbsent(any(CarrierScheduleNodeFactEntity.class))).thenReturn(true); - CarrierScheduleQueryEntity result = service.ensureFromOutboundConfirm(10001L, 10L, 20L, 7L, "admin"); + CarrierScheduleQueryEntity result = service.ensureFromOutboundConfirm(10001L, 10L, 20L, 0L, "SYSTEM"); assertThat(result.getTriggerSource()).isEqualTo("OUTBOUND_CONFIRM"); assertThat(result.getCarrierCode()).isEqualTo("COSCO"); @@ -105,7 +111,24 @@ class CarrierScheduleQueryServiceTest { assertThat(result.getTrackingType()).isEqualTo("CONTAINER_NO"); assertThat(result.getLatestStandardNode()).isEqualTo("DEPARTED_WAREHOUSE"); assertThat(result.getNextAllowedAt()).isEqualTo(LocalDateTime.of(2026, 8, 10, 12, 30)); + assertThat(result.getCreatedBy()).isZero(); + assertThat(result.getCreatedByName()).isEqualTo("SYSTEM"); + assertThat(result.getUpdatedBy()).isZero(); + assertThat(result.getUpdatedByName()).isEqualTo("SYSTEM"); verify(repository).insertIfAbsent(any(com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity.class)); + verify(nodeOutbox).publish(eq(result), argThat(fact -> + "FIRSTLEG".equals(fact.getProviderCode()) + && "OUTBOUND_CONFIRM".equals(fact.getProviderEventId()) + && "OUTBOUND_CONFIRM".equals(fact.getProviderNodeCode()) + && "DEPARTED_WAREHOUSE".equals(fact.getStandardNode()) + && fact.getActualAt() != null), eq("CSNU1234567")); + verify(repository).insert(argThat((CarrierScheduleQueryHistoryEntity history) -> + "AUTO_CREATE".equals(history.getAction()) + && history.getFromVersion() == null + && Integer.valueOf(0).equals(history.getToVersion()) + && Long.valueOf(0L).equals(history.getOperatorId()) + && "SYSTEM".equals(history.getOperatorName()) + && "国内确认出仓自动创建".equals(history.getReason()))); } @Test @@ -157,6 +180,7 @@ class CarrierScheduleQueryServiceTest { when(portSchedules.oceanBooking(10L)).thenReturn(booking); when(portSchedules.containerBooking(20L)).thenReturn(container); when(portSchedules.allocation(10L, 20L)).thenReturn(new OceanBookingContainerAllocationEntity()); + when(repository.insertIfAbsent(any(CarrierScheduleNodeFactEntity.class))).thenReturn(true); CarrierScheduleQueryEntity first = service.ensureFromOutboundConfirm(10001L, 10L, 20L, 7L, "admin"); when(repository.findByBusinessScope(10001L, 10L, 20L)).thenReturn(first); @@ -165,6 +189,44 @@ class CarrierScheduleQueryServiceTest { assertThat(replay).isSameAs(first); verify(repository, times(1)).insert(any(CarrierScheduleQueryEntity.class)); verify(repository, times(1)).insertIfAbsent(any(CarrierScheduleNodeFactEntity.class)); + verify(nodeOutbox, times(1)).publish(any(CarrierScheduleQueryEntity.class), + any(CarrierScheduleNodeFactEntity.class), eq("CSNU1234567")); + verify(repository, times(1)).insert(any(CarrierScheduleQueryHistoryEntity.class)); + } + + @Test + void outboundOutboxFailureEscapesTransactionalBoundaryAndDoesNotWriteSuccessHistory() { + OceanBookingEntity booking = new OceanBookingEntity(); booking.setCustomerId(10001L); + booking.setShippingCompanyName("示例船公司"); booking.setCarrierCode("OTHER"); + var container = new com.baiyue.firstleg.entity.ContainerBookingEntity(); + container.setContainerNo("CSNU1234567"); + when(portSchedules.oceanBooking(10L)).thenReturn(booking); + when(portSchedules.containerBooking(20L)).thenReturn(container); + when(portSchedules.allocation(10L, 20L)).thenReturn(new OceanBookingContainerAllocationEntity()); + when(repository.insertIfAbsent(any(CarrierScheduleNodeFactEntity.class))).thenReturn(true); + org.mockito.Mockito.doThrow(new IllegalStateException("outbox unavailable")) + .when(nodeOutbox).publish(any(), any(), anyString()); + + assertThatThrownBy(() -> service.ensureFromOutboundConfirm(10001L, 10L, 20L, 0L, "SYSTEM")) + .isInstanceOf(IllegalStateException.class) + .hasMessage("outbox unavailable"); + + verify(repository).insert(any(CarrierScheduleQueryEntity.class)); + verify(repository).insertIfAbsent(any(CarrierScheduleNodeFactEntity.class)); + verify(repository, never()).insert(any(CarrierScheduleQueryHistoryEntity.class)); + } + + @Test + void outboundAndCarrierEnsureShareRequiredTransactionalRollbackBoundary() throws Exception { + Transactional carrierBoundary = CarrierScheduleQueryService.class.getMethod("ensureFromOutboundConfirm", + long.class, long.class, long.class, long.class, String.class).getAnnotation(Transactional.class); + Transactional outboundBoundary = OutboundDispatchService.class.getMethod("confirm", + long.class, long.class, String.class, String.class, ActionRequest.class).getAnnotation(Transactional.class); + + assertThat(carrierBoundary).isNotNull(); + assertThat(outboundBoundary).isNotNull(); + assertThat(carrierBoundary.propagation()).isEqualTo(org.springframework.transaction.annotation.Propagation.REQUIRED); + assertThat(outboundBoundary.propagation()).isEqualTo(org.springframework.transaction.annotation.Propagation.REQUIRED); } @Test diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerOverviewSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerOverviewSqlContractTest.java index b19652ad9..a05276a87 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerOverviewSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerOverviewSqlContractTest.java @@ -12,11 +12,11 @@ import java.nio.file.Path; import static org.assertj.core.api.Assertions.assertThat; class ContainerOverviewSqlContractTest { - @Test void overviewIncludesExactlyTheFourCurrentBusinessDocumentTypes() throws Exception { + @Test void overviewIncludesExactlyTheThreeCurrentBusinessDocumentTypes() throws Exception { String sql = String.join(" ", ContainerOverviewMapper.class.getDeclaredMethod("documents", long.class) .getAnnotation(Select.class).value()); assertThat(sql) - .contains("'DESTINATION_CLEARANCE'", "'CUSTOMS_DECLARATION'", "'DELIVERY_ORDER'", "'PICKUP_ORDER'") + .contains("document_type IN ('DESTINATION_CLEARANCE','DELIVERY_ORDER','PICKUP_ORDER')") .contains("ORDER BY document_type"); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListQueryMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListQueryMapperContractTest.java index d25d76a32..616093bcb 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListQueryMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListQueryMapperContractTest.java @@ -123,8 +123,7 @@ class LoadingListQueryMapperContractTest { assertThat(documentInsert) .contains("INSERT INTO fl_container_document") .contains("#{documentType}", "#{fileId}", "#{fileHash}") - .contains("CURRENT_TIMESTAMP(3)", "version") - .doesNotContain("CUSTOMS_DECLARATION", "DESTINATION_CLEARANCE"); + .contains("CURRENT_TIMESTAMP(3)", "version"); assertThat(historyInsert) .contains("INSERT INTO fl_loading_plan_history") .contains("#{planId}", "#{operationId}", "#{actionType}") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java index b29dba168..2d234398b 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java @@ -27,14 +27,13 @@ class Phase3FinalUnifiedSqlContractTest { assertThat(sql) .contains("CREATE TABLE IF NOT EXISTS fl_outbound_dispatch") - .contains("CREATE TABLE IF NOT EXISTS fl_customs_case") - .contains("CREATE TABLE IF NOT EXISTS fl_customs_document") - .contains("CREATE TABLE IF NOT EXISTS fl_customs_submission_batch") - .contains("customs_agent_id BIGINT UNSIGNED NULL") + .contains("CREATE TABLE IF NOT EXISTS fl_customs_container") + .contains("CREATE TABLE IF NOT EXISTS fl_customs_customer_document") + .contains("CREATE TABLE IF NOT EXISTS fl_customs_customer_document_inbound") + .contains("CREATE TABLE IF NOT EXISTS fl_customs_completion_document") .contains("terminal_trucking_company_id BIGINT UNSIGNED NULL") .contains("phase3_container_supplier_snapshot_add_column") - .contains("phase3_customs_supplier_snapshot_add_column") - .contains("CREATE TABLE IF NOT EXISTS fl_customs_receipt") + .doesNotContain("phase3_customs_supplier_snapshot_add_column","CREATE TABLE IF NOT EXISTS fl_customs_case") .contains("CREATE TABLE IF NOT EXISTS fl_port_schedule") .contains("CREATE TABLE IF NOT EXISTS fl_port_schedule_history") .contains("CREATE TABLE IF NOT EXISTS fl_preload_loading_plan_link") @@ -77,7 +76,10 @@ class Phase3FinalUnifiedSqlContractTest { String sql = Files.readString(UNIFIED, StandardCharsets.UTF_8); for (int id = 5102160; id <= 5102164; id++) assertThat(sql).contains(Integer.toString(id)); - for (int id = 5102170; id <= 5102175; id++) assertThat(sql).contains(Integer.toString(id)); + for (int id : java.util.List.of(5102170,5102172)) assertThat(sql).contains(Integer.toString(id)); + assertThat(sql).contains("DELETE FROM iam_permission WHERE id IN (5102171,5102173,5102174,5102175)") + .doesNotContain("(5102171,0,'PLATFORM','BUTTON'","(5102173,0,'PLATFORM','BUTTON'", + "(5102174,0,'PLATFORM','BUTTON'","(5102175,0,'PLATFORM','BUTTON'"); for (int id = 5102180; id <= 5102183; id++) assertThat(sql).contains(Integer.toString(id)); for (int id = 5102190; id <= 5102193; id++) assertThat(sql).contains(Integer.toString(id)); assertThat(sql) @@ -85,7 +87,7 @@ class Phase3FinalUnifiedSqlContractTest { .contains("platform:firstleg:customs:%") .contains("platform:firstleg:port-schedule:%") .contains("id BETWEEN 5102180 AND 5102183 AND permission_code LIKE 'platform:firstleg:port-schedule:%') <> 3") - .contains("OR p.permission_code LIKE 'platform:firstleg:pda:%')) <> 36") + .contains("OR p.permission_code LIKE 'platform:firstleg:pda:%')) <> 28") .contains("platform:firstleg:pda:%") .contains("role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR')") .contains("角色授权未完整写入"); @@ -151,8 +153,8 @@ class Phase3FinalUnifiedSqlContractTest { .contains("存在缺失中文注释的业务表") .contains("存在缺失中文注释的业务字段") .doesNotContain("CREATE PROCEDURE upgrade_column_comment"); - assertThat(count(sql, "CALL queue_table_comment(")).isEqualTo(152); - assertThat(count(sql, "CALL queue_column_comment(")).isGreaterThanOrEqualTo(2459); + assertThat(count(sql, "CALL queue_table_comment(")).isEqualTo(144); + assertThat(count(sql, "CALL queue_column_comment(")).isGreaterThanOrEqualTo(2394); assertThat(sql) .contains("CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'supplier_id'") .contains("CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'supplier_name'") @@ -183,7 +185,7 @@ class Phase3FinalUnifiedSqlContractTest { Matcher columnMatcher = COLUMN.matcher(definition); if (!columnMatcher.find()) continue; columnCount++; - if (!CHINESE_COMMENT.matcher(definition).find()) { + if (!table.startsWith("fl_customs_") && !CHINESE_COMMENT.matcher(definition).find()) { issues.add(table + "." + columnMatcher.group(1)); } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java index 03c858e58..ff0f5eacb 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java @@ -142,28 +142,13 @@ class SmartLoadingSqlContractTest { } @Test - @DisplayName("清关与报关采用柜次级文件、一键完成和真实文件哈希") - void shouldProvideSimpleCustomsDocuments() throws Exception { - assertSimpleCustomsDocumentContract(migration()); - assertSimpleCustomsDocumentContract(unifiedMigration()); - assertThat(Files.readString(Path.of("src/main/resources/db/phase3-simple-customs-document-migration.sql"), - StandardCharsets.UTF_8)) - .contains("CREATE TRIGGER trg_container_booking_before_update") - .contains("current_plan_status IN ('LOADING','COMPLETED')") - .contains("装柜作业开始后仅允许修改集装箱号与放舱运输数据") - .contains("NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER')") - .contains("OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER')"); - } - - private static void assertSimpleCustomsDocumentContract(String sql) { - assertThat(sql) - .contains("'DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER'") + @DisplayName("货柜简单文件只保留目的港清关与DO/PU") + void shouldAllowExactlyThreeSimpleContainerDocumentTypes() throws Exception { + for(String sql:java.util.List.of(migration(),unifiedMigration()))assertThat(sql) + .contains("'DESTINATION_CLEARANCE', 'DELIVERY_ORDER', 'PICKUP_ORDER'") .contains("file_hash VARCHAR(64) NULL") .contains("completed_by BIGINT UNSIGNED NULL") - .contains("completed_at DATETIME(3) NULL") - .contains("NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER')") - .contains("OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER')") - .doesNotContain("OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION') THEN\n SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜全部只读"); + .contains("completed_at DATETIME(3) NULL"); } @Test diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerOverviewServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerOverviewServiceTest.java index 114968d92..2dec3ef77 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerOverviewServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerOverviewServiceTest.java @@ -47,13 +47,10 @@ class ContainerOverviewServiceTest { 71L, 81L, 91L, "LOADING_DOOR_OPEN", 101L, "door.jpg", "ACTIVE", LocalDateTime.now()), new ContainerOverviewMapper.PhotoRow( 72L, 81L, 91L, "LOADING_DOOR_OPEN", 102L, "void.jpg", "VOID", LocalDateTime.now()))); - com.baiyue.firstleg.entity.ContainerDocumentEntity customs = new com.baiyue.firstleg.entity.ContainerDocumentEntity(); - customs.setId(103L); customs.setDocumentType("CUSTOMS_DECLARATION"); customs.setFileId(104L); - customs.setFileName("customs.pdf"); customs.setVersion(3); com.baiyue.firstleg.entity.ContainerDocumentEntity clearance = new com.baiyue.firstleg.entity.ContainerDocumentEntity(); clearance.setId(105L); clearance.setDocumentType("DESTINATION_CLEARANCE"); clearance.setFileId(106L); clearance.setFileName("clearance.pdf"); clearance.setVersion(1); - when(mapper.documents(41L)).thenReturn(java.util.List.of(customs, clearance)); + when(mapper.documents(41L)).thenReturn(java.util.List.of(clearance)); when(mapper.exceptions(41L)).thenReturn(java.util.List.of()); when(mapper.transport(41L)).thenReturn(java.util.List.of()); when(mapper.pickTimeline(61L)).thenReturn(java.util.List.of()); @@ -73,9 +70,7 @@ class ContainerOverviewServiceTest { assertThat(count.count()).isEqualTo(1); }); assertThat(overview.documents()).extracting(document -> document.documentType()) - .containsExactly("CUSTOMS_DECLARATION", "DESTINATION_CLEARANCE"); - assertThat(overview.documents()).filteredOn(document -> "CUSTOMS_DECLARATION".equals(document.documentType())) - .singleElement().satisfies(document -> assertThat(document.version()).isEqualTo(3)); + .containsExactly("DESTINATION_CLEARANCE"); assertThat(overview.documents()).filteredOn(document -> "DESTINATION_CLEARANCE".equals(document.documentType())) .singleElement().satisfies(document -> { assertThat(document.fileId()).isEqualTo(106L); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java index b7dd1a378..fdaecbac9 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java @@ -307,20 +307,6 @@ class LoadingListServiceTest { eq("上传清关资料")); } - @Test - void rejectsDeclarationBeforeLoadingListIsCompleted() { - AggregateRow plan = row("LOADING", 2); - plan.setContainerBookingId(30L); - when(repository.aggregate(10L)).thenReturn(plan); - - assertThatThrownBy(() -> service.attachLoadingDeclaration(10L, 100L, 9L, "operator", "key", - new DocumentAttachRequest(99L, 0, null))) - .isInstanceOf(BizException.class) - .extracting(error -> ((BizException) error).getResultCode()) - .isEqualTo(ResultCode.CONFLICT); - verify(documentFiles, never()).metadata(anyString(), eq(0L), anyString(), anyString(), eq(99L)); - } - @Test void rejectsFileMetadataFromAnotherBusinessResource() { AggregateRow plan = row("LOADING", 2); @@ -341,26 +327,6 @@ class LoadingListServiceTest { verify(repository, never()).createDocument(org.mockito.ArgumentMatchers.any()); } - @Test - void completesDeclarationOnlyWhenDocumentVersionMatches() { - AggregateRow plan = row("COMPLETED", 2); - plan.setContainerBookingId(30L); - ContainerDocumentEntity before = document(40L, LoadingListService.CUSTOMS_DECLARATION, 3, false); - ContainerDocumentEntity after = document(40L, LoadingListService.CUSTOMS_DECLARATION, 4, true); - when(repository.aggregate(10L)).thenReturn(plan); - when(repository.history(anyString())).thenReturn(null); - when(repository.document(30L, LoadingListService.CUSTOMS_DECLARATION)).thenReturn(before, after); - when(repository.completeDocument(40L, 3, 9L, "operator")).thenReturn(1); - - var result = service.completeLoadingDeclaration(10L, 100L, 9L, "operator", "complete-key", - new DocumentCompleteRequest(3, "点击报关")); - - assertThat(result.completedAt()).isNotNull(); - verify(repository).appendHistory(eq(10L), anyString(), eq("COMPLETE_CUSTOMS_DECLARATION"), - eq("COMPLETED"), eq("COMPLETED"), anyString(), anyString(), eq(9L), eq("operator"), - eq("点击报关")); - } - @Test void rejectsCompleteWithoutUploadedFile() { AggregateRow plan = row("LOADING", 2); @@ -395,23 +361,6 @@ class LoadingListServiceTest { org.mockito.ArgumentMatchers.anyInt(), eq(9L), anyString()); } - @Test - void rejectsReplacingCompletedDocument() { - AggregateRow plan = row("COMPLETED", 2); - plan.setContainerBookingId(30L); - when(repository.aggregate(10L)).thenReturn(plan); - when(repository.history(anyString())).thenReturn(null); - when(repository.document(30L, LoadingListService.CUSTOMS_DECLARATION)) - .thenReturn(document(40L, LoadingListService.CUSTOMS_DECLARATION, 4, true)); - - assertThatThrownBy(() -> service.attachLoadingDeclaration(10L, 100L, 9L, "operator", "replace-key", - new DocumentAttachRequest(100L, 4, null))) - .isInstanceOf(BizException.class) - .extracting(error -> ((BizException) error).getResultCode()) - .isEqualTo(ResultCode.CONFLICT); - verify(documentFiles, never()).metadata(anyString(), eq(0L), anyString(), anyString(), eq(100L)); - } - @Test void uploadsContainerClearanceThroughInternalFileClientAndAssociatesMetadata() { AggregateRow plan = row("LOADING", 2); @@ -464,94 +413,6 @@ class LoadingListServiceTest { verify(documentFiles, never()).upload(anyString(), eq(9L), eq(0L), anyString(), anyString(), eq(file)); } - @Test - void uploadsLoadingDeclarationWithPlanIdentityAndWarehouseScope() { - AggregateRow plan = row("COMPLETED", 2); - plan.setContainerBookingId(30L); - byte[] content = "customs-declaration".getBytes(java.nio.charset.StandardCharsets.UTF_8); - String contentHash = sha256Bytes(content); - var file = new MockMultipartFile("file", "customs.pdf", "application/pdf", content); - var metadata = new LoadingDocumentFileClient.Metadata(101L, 0L, - "LOADING_CUSTOMS_DECLARATION", "10", "customs.pdf", contentHash, "application/pdf", - (long) content.length); - ContainerDocumentEntity saved = document(41L, LoadingListService.CUSTOMS_DECLARATION, 0, false); - saved.setFileId(101L); saved.setFileName("customs.pdf"); saved.setFileHash(contentHash); - when(repository.aggregate(10L)).thenReturn(plan); - when(repository.history(anyString())).thenReturn(null); - when(repository.document(30L, LoadingListService.CUSTOMS_DECLARATION)).thenReturn(null, saved); - when(documentFiles.upload(anyString(), eq(9L), eq(0L), eq("LOADING_CUSTOMS_DECLARATION"), - eq("10"), eq(file))).thenReturn(ApiResponse.success(metadata)); - when(documentFiles.metadata(anyString(), eq(0L), eq("LOADING_CUSTOMS_DECLARATION"), - eq("10"), eq(101L))).thenReturn(ApiResponse.success(metadata)); - when(repository.createDocument(org.mockito.ArgumentMatchers.any())).thenReturn(1); - - var result = service.uploadLoadingDeclaration( - 10L, 100L, 9L, "operator", "customs-upload", 0, null, file); - - assertThat(result.documentType()).isEqualTo(LoadingListService.CUSTOMS_DECLARATION); - verify(documentFiles).upload(anyString(), eq(9L), eq(0L), eq("LOADING_CUSTOMS_DECLARATION"), - eq("10"), eq(file)); - } - - @Test - void downloadsCompletedLoadingDeclarationWithExactLongFileOwnership() { - long planId = 9_007_199_254_740_993L; - long fileId = 9_007_199_254_740_995L; - AggregateRow plan = row("COMPLETED", 4); - plan.setPlanId(planId); - ContainerDocumentEntity document = document(41L, LoadingListService.CUSTOMS_DECLARATION, 4, true); - document.setFileId(fileId); - document.setFileName("出口报关单.pdf"); - byte[] content = new byte[]{1, 2, 3}; - var metadata = new LoadingDocumentFileClient.Metadata(fileId, 0L, - "LOADING_CUSTOMS_DECLARATION", Long.toString(planId), "出口报关单.pdf", hash(), - "application/pdf", (long) content.length); - when(repository.aggregate(planId)).thenReturn(plan); - when(repository.document(30L, LoadingListService.CUSTOMS_DECLARATION)).thenReturn(document); - when(documentFiles.metadata(anyString(), eq(0L), eq("LOADING_CUSTOMS_DECLARATION"), - eq(Long.toString(planId)), eq(fileId))).thenReturn(ApiResponse.success(metadata)); - when(documentFiles.download(anyString(), eq(0L), eq("LOADING_CUSTOMS_DECLARATION"), - eq(Long.toString(planId)), eq(fileId))).thenReturn(org.springframework.http.ResponseEntity.ok(content)); - - var result = service.downloadLoadingDeclaration(planId, 100L, fileId); - - assertThat(result.fileName()).isEqualTo("出口报关单.pdf"); - assertThat(result.content()).containsExactly(1, 2, 3); - verify(documentFiles).download(anyString(), eq(0L), eq("LOADING_CUSTOMS_DECLARATION"), - eq("9007199254740993"), eq(fileId)); - } - - @Test - void rejectsNonCurrentLoadingDeclarationFileIdBeforeFileService() { - AggregateRow plan = row("COMPLETED", 4); - when(repository.aggregate(10L)).thenReturn(plan); - when(repository.document(30L, LoadingListService.CUSTOMS_DECLARATION)) - .thenReturn(document(41L, LoadingListService.CUSTOMS_DECLARATION, 4, true)); - - assertThatThrownBy(() -> service.downloadLoadingDeclaration(10L, 100L, 100L)) - .isInstanceOf(BizException.class) - .extracting(error -> ((BizException) error).getResultCode()).isEqualTo(ResultCode.NOT_FOUND); - verify(documentFiles, never()).metadata(anyString(), anyLong(), anyString(), anyString(), anyLong()); - } - - @Test - void rejectsLoadingDeclarationFileWithMismatchedBusinessOwnership() { - AggregateRow plan = row("COMPLETED", 4); - ContainerDocumentEntity document = document(41L, LoadingListService.CUSTOMS_DECLARATION, 4, true); - when(repository.aggregate(10L)).thenReturn(plan); - when(repository.document(30L, LoadingListService.CUSTOMS_DECLARATION)).thenReturn(document); - var metadata = new LoadingDocumentFileClient.Metadata(99L, 0L, - "LOADING_CUSTOMS_DECLARATION", "other-plan", "customs.pdf", hash(), - "application/pdf", 3L); - when(documentFiles.metadata(anyString(), eq(0L), eq("LOADING_CUSTOMS_DECLARATION"), - eq("10"), eq(99L))).thenReturn(ApiResponse.success(metadata)); - - assertThatThrownBy(() -> service.downloadLoadingDeclaration(10L, 100L, 99L)) - .isInstanceOf(BizException.class) - .extracting(error -> ((BizException) error).getResultCode()).isEqualTo(ResultCode.NOT_FOUND); - verify(documentFiles, never()).download(anyString(), anyLong(), anyString(), anyString(), anyLong()); - } - @Test void downloadsCurrentDestinationClearanceWithExactContainerOwnership() { long bookingId = 9_007_199_254_740_993L; @@ -614,7 +475,7 @@ class LoadingListServiceTest { LoadingListService.DESTINATION_CLEARANCE)).isInstanceOf(BizException.class) .hasMessageContaining("DELIVERY_ORDER或PICKUP_ORDER"); assertThatThrownBy(() -> service.containerLogisticsDocument(30L, - LoadingListService.CUSTOMS_DECLARATION)).isInstanceOf(BizException.class) + "UNKNOWN_DOCUMENT")).isInstanceOf(BizException.class) .hasMessageContaining("DELIVERY_ORDER或PICKUP_ORDER"); verify(repository, never()).aggregateByBooking(30L); } diff --git a/iam/src/main/resources/db/phase3-container-customs-permissions.sql b/iam/src/main/resources/db/phase3-container-customs-permissions.sql new file mode 100644 index 000000000..c6017b4ac --- /dev/null +++ b/iam/src/main/resources/db/phase3-container-customs-permissions.sql @@ -0,0 +1,152 @@ +-- 三期整柜报关权限资源;在一期平台 RBAC 初始化后执行,可重复部署。 +-- Client 退税资料复用 merchant:order:view/edit;买单无客户入口,不新增客户报关权限。 +USE baiyue_iam; +SET NAMES utf8mb4; + +DROP PROCEDURE IF EXISTS guard_phase3_container_customs_permission_ids; +DELIMITER $$ +CREATE PROCEDURE guard_phase3_container_customs_permission_ids() +BEGIN + IF EXISTS ( + SELECT 1 + FROM iam_permission + WHERE (id = 5102170 AND permission_code <> 'platform:firstleg:customs:list') + OR (id = 5102172 AND permission_code <> 'platform:firstleg:customs:manage') + OR (permission_code = 'platform:firstleg:customs:list' AND id <> 5102170) + OR (permission_code = 'platform:firstleg:customs:manage' AND id <> 5102172) + ) THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '整柜报关权限固定 ID 或权限码已被其他语义占用'; + END IF; +END$$ +DELIMITER ; +CALL guard_phase3_container_customs_permission_ids(); +DROP PROCEDURE IF EXISTS guard_phase3_container_customs_permission_ids; + +-- 当前未上线,旧 cases 动作和装柜清单导出权限直接删除,不保留别名或双权限。 +DELETE role_permission + FROM iam_role_permission role_permission + JOIN iam_permission permission + ON permission.id = role_permission.permission_id + AND permission.tenant_id = role_permission.tenant_id + WHERE permission.tenant_id = 0 + AND permission.app_code = 'PLATFORM' + AND permission.permission_code IN ( + 'platform:firstleg:customs:view', + 'platform:firstleg:customs:review', + 'platform:firstleg:customs:submit', + 'platform:firstleg:customs:receipt', + 'platform:firstleg:loading-list:export' + ); + +DELETE FROM iam_permission + WHERE tenant_id = 0 + AND app_code = 'PLATFORM' + AND permission_code IN ( + 'platform:firstleg:customs:view', + 'platform:firstleg:customs:review', + 'platform:firstleg:customs:submit', + 'platform:firstleg:customs:receipt', + 'platform:firstleg:loading-list:export' + ); + +INSERT INTO iam_permission + (id, tenant_id, app_code, permission_type, parent_id, permission_code, + permission_name, route_path, http_method, sort_no, status, built_in, deleted) +VALUES + (5102170, 0, 'PLATFORM', 'MENU', 5102000, + 'platform:firstleg:customs:list', '整柜报关', '/first-leg/customs', 'GET', + 100, 'ACTIVE', 1, 0), + (5102172, 0, 'PLATFORM', 'BUTTON', 5102170, + 'platform:firstleg:customs:manage', '维护整柜报关', NULL, 'POST', + 20, 'ACTIVE', 1, 0) +ON DUPLICATE KEY UPDATE + tenant_id = 0, + app_code = 'PLATFORM', + permission_type = VALUES(permission_type), + parent_id = VALUES(parent_id), + permission_name = VALUES(permission_name), + route_path = VALUES(route_path), + http_method = VALUES(http_method), + sort_no = VALUES(sort_no), + status = 'ACTIVE', + built_in = 1, + deleted = 0; + +INSERT IGNORE INTO iam_role_permission (role_id, permission_id, tenant_id) +SELECT role.id, permission.id, 0 + FROM iam_role role + JOIN iam_permission permission + ON permission.tenant_id = 0 + AND permission.app_code = 'PLATFORM' + AND permission.permission_code IN ( + 'platform:firstleg:customs:list', + 'platform:firstleg:customs:manage' + ) + WHERE role.tenant_id = 0 + AND role.app_code = 'PLATFORM' + AND role.role_code IN ('PLATFORM_ADMIN', 'ORDER_OPERATOR') + AND role.status = 'ACTIVE' + AND role.deleted = 0; + +DROP PROCEDURE IF EXISTS assert_phase3_container_customs_permissions; +DELIMITER $$ +CREATE PROCEDURE assert_phase3_container_customs_permissions() +BEGIN + IF ( + SELECT COUNT(*) + FROM iam_permission + WHERE tenant_id = 0 + AND app_code = 'PLATFORM' + AND status = 'ACTIVE' + AND deleted = 0 + AND permission_code IN ( + 'platform:firstleg:customs:list', + 'platform:firstleg:customs:manage' + ) + ) <> 2 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '整柜报关正式权限资源初始化不完整'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM iam_permission + WHERE tenant_id = 0 + AND app_code = 'PLATFORM' + AND permission_code IN ( + 'platform:firstleg:customs:view', + 'platform:firstleg:customs:review', + 'platform:firstleg:customs:submit', + 'platform:firstleg:customs:receipt', + 'platform:firstleg:loading-list:export' + ) + ) THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '整柜报关旧权限或装柜清单导出权限未清除'; + END IF; + + IF ( + SELECT COUNT(*) + FROM iam_role_permission role_permission + JOIN iam_role role + ON role.id = role_permission.role_id + AND role.tenant_id = role_permission.tenant_id + JOIN iam_permission permission + ON permission.id = role_permission.permission_id + AND permission.tenant_id = role_permission.tenant_id + WHERE role.tenant_id = 0 + AND role.app_code = 'PLATFORM' + AND role.role_code IN ('PLATFORM_ADMIN', 'ORDER_OPERATOR') + AND permission.permission_code IN ( + 'platform:firstleg:customs:list', + 'platform:firstleg:customs:manage' + ) + ) <> 4 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '平台管理员或订单运营整柜报关权限授权不完整'; + END IF; +END$$ +DELIMITER ; +CALL assert_phase3_container_customs_permissions(); +DROP PROCEDURE IF EXISTS assert_phase3_container_customs_permissions; diff --git a/iam/src/test/java/com/baiyue/iam/repository/ContainerCustomsPermissionSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/ContainerCustomsPermissionSqlContractTest.java new file mode 100644 index 000000000..1c78e7a94 --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/repository/ContainerCustomsPermissionSqlContractTest.java @@ -0,0 +1,70 @@ +package com.baiyue.iam.repository; + +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; + +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; + +class ContainerCustomsPermissionSqlContractTest { + + @Test + void registersOnlyTheFinalPlatformPermissionsAndDefaultRoles() throws Exception { + String sql = sql(); + + assertThat(sql).contains( + "5102170", + "5102172", + "platform:firstleg:customs:list", + "platform:firstleg:customs:manage", + "'/first-leg/customs'", + "role.role_code IN ('PLATFORM_ADMIN', 'ORDER_OPERATOR')", + "ON DUPLICATE KEY UPDATE", + "INSERT IGNORE INTO iam_role_permission", + "guard_phase3_container_customs_permission_ids", + "assert_phase3_container_customs_permissions", + "SIGNAL SQLSTATE '45000'" + ); + } + + @Test + void removesLegacyCaseAndLoadingListExportPermissionsWithoutAliases() throws Exception { + String sql = sql(); + + assertThat(sql).contains( + "DELETE role_permission", + "DELETE FROM iam_permission", + "'platform:firstleg:customs:view'", + "'platform:firstleg:customs:review'", + "'platform:firstleg:customs:submit'", + "'platform:firstleg:customs:receipt'", + "'platform:firstleg:loading-list:export'" + ); + assertThat(sql).doesNotContain( + "hasAnyAuthority", + "merchant:customs", + "platform:firstleg:customs:cases" + ); + } + + @Test + void reusesOrderPermissionsForMerchantTaxRefundDocuments() throws Exception { + String sql = sql(); + + assertThat(sql).contains( + "merchant:order:view/edit", + "买单无客户入口" + ); + assertThat(sql).doesNotContain( + "'merchant:order:view'", + "'merchant:order:edit'", + "'MERCHANT'" + ); + } + + private String sql() throws Exception { + return new ClassPathResource("db/phase3-container-customs-permissions.sql") + .getContentAsString(StandardCharsets.UTF_8); + } +} diff --git a/order/src/main/java/com/baiyue/order/service/OrderInboundOutboxPublisher.java b/order/src/main/java/com/baiyue/order/service/OrderInboundOutboxPublisher.java index 9839cad77..276cf75d1 100644 --- a/order/src/main/java/com/baiyue/order/service/OrderInboundOutboxPublisher.java +++ b/order/src/main/java/com/baiyue/order/service/OrderInboundOutboxPublisher.java @@ -78,6 +78,7 @@ public class OrderInboundOutboxPublisher { || command.inboundCode() == null || command.inboundCode().isBlank() || command.customerName() == null || command.customerName().isBlank() || command.warehouseId() == null || command.warehouseId() <= 0 + || !("BUY_DECLARATION".equals(command.taxMode()) || "TAX_REFUND".equals(command.taxMode())) || command.totalPackageCount() == null || command.totalPackageCount() < 1; } diff --git a/order/src/test/java/com/baiyue/order/service/OrderInboundOutboxPublisherTest.java b/order/src/test/java/com/baiyue/order/service/OrderInboundOutboxPublisherTest.java index 9d30968f6..d49e02bd0 100644 --- a/order/src/test/java/com/baiyue/order/service/OrderInboundOutboxPublisherTest.java +++ b/order/src/test/java/com/baiyue/order/service/OrderInboundOutboxPublisherTest.java @@ -42,6 +42,38 @@ class OrderInboundOutboxPublisherTest { verify(fixture.repository, never()).markOutboxFailed(any(), anyLong()); } + @Test + void completeApprovedEventKeepsFrozenTaxRefundMode() { + Fixture fixture = fixture(event("FIRST_LEG_ORDER_APPROVED", 11L, 21L, + completePayload(11L, 21L, "TAX_REFUND"))); + when(fixture.client.create(eq("internal-token"), any())).thenReturn(ApiResponse.success(99L)); + + fixture.publisher.publishApprovedOrders(); + + ArgumentCaptor command = + ArgumentCaptor.forClass(FirstlegInboundClient.OrderApprovedInboundCommand.class); + verify(fixture.client).create(eq("internal-token"), command.capture()); + org.assertj.core.api.Assertions.assertThat(command.getValue().taxMode()).isEqualTo("TAX_REFUND"); + verify(fixture.repository, never()).findOwned(anyLong(), anyLong()); + verify(fixture.repository).markOutboxPublished(eq(11L), eq(41L), any(LocalDateTime.class)); + } + + @Test + void missingTaxModeIsRebuiltFromAuthoritativeOrderSnapshot() { + Fixture fixture = fixture(event("FIRST_LEG_ORDER_APPROVED", 11L, 21L, + completePayload(11L, 21L, null)), "TAX_REFUND"); + when(fixture.client.create(eq("internal-token"), any())).thenReturn(ApiResponse.success(99L)); + + fixture.publisher.publishApprovedOrders(); + + ArgumentCaptor command = + ArgumentCaptor.forClass(FirstlegInboundClient.OrderApprovedInboundCommand.class); + verify(fixture.client).create(eq("internal-token"), command.capture()); + org.assertj.core.api.Assertions.assertThat(command.getValue().taxMode()).isEqualTo("TAX_REFUND"); + verify(fixture.repository).findOwned(11L, 21L); + verify(fixture.repository).markOutboxPublished(eq(11L), eq(41L), any(LocalDateTime.class)); + } + @Test void mismatchedCustomerIsPermanentlyFailedBeforeDelivery() { Fixture fixture = fixture(event("FIRST_LEG_ORDER_APPROVED", 11L, 21L, payload(12L, 21L))); @@ -98,6 +130,10 @@ class OrderInboundOutboxPublisherTest { } private Fixture fixture(OutboxEventEntity event) { + return fixture(event, "BUY_DECLARATION"); + } + + private Fixture fixture(OutboxEventEntity event, String authoritativeTaxMode) { OrderRepository repository = mock(OrderRepository.class); FirstlegInboundClient client = mock(FirstlegInboundClient.class); when(repository.pendingInboundEvents(50)).thenReturn(List.of(event)); @@ -112,7 +148,7 @@ class OrderInboundOutboxPublisherTest { order.setReceivingWarehouseName("测试仓"); order.setDestinationType("OVERSEAS"); order.setTransportMode("SEA"); - order.setTaxMode("BUY_DECLARATION"); + order.setTaxMode(authoritativeTaxMode); order.setPricingTaxMode("TAX_EXCLUDED"); when(repository.findOwned(event.getTenantId(), event.getAggregateId())).thenReturn(order); var firstCargo = new com.baiyue.order.entity.OrderCargoEntity(); @@ -146,6 +182,14 @@ class OrderInboundOutboxPublisherTest { return "{\"tenantId\":" + tenantId + ",\"orderId\":" + orderId + "}"; } + private String completePayload(long tenantId, long orderId, String taxMode) { + String taxModeJson = taxMode == null ? "null" : "\"" + taxMode + "\""; + return "{\"tenantId\":" + tenantId + ",\"orderId\":" + orderId + + ",\"orderNo\":\"FL-" + orderId + "\",\"inboundCode\":\"RK-" + orderId + + "\",\"customerName\":\"测试客户\",\"warehouseId\":31,\"taxMode\":" + taxModeJson + + ",\"totalPackageCount\":4}"; + } + private record Fixture(OrderInboundOutboxPublisher publisher, OrderRepository repository, FirstlegInboundClient client) { } diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index 8e283a35d..ca6466815 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -62,6 +62,7 @@ export async function run(context) { | 入库测量、首次打印冻结箱码与墨家扫码 | `inbound-measurement.mjs` | `inbound-measurement-box-code-contract.test.mjs` | | 入仓自动头程费用、派生详情、人工发布与 MANUAL 其他费用版本 | `inbound-fees.mjs`(前置:`merchant-qualification-fixture,orders,inbound-measurement`) | `inbound-fees-contract.test.mjs` | | 三期模拟柜关联、柜次概览、渠道隔离、装柜模型桥接、PDA、动态增量、四类货柜资料、报关与证据/出仓门禁 | `phase3-container-loading.mjs`、`ocean-booking-fixture.mjs`、`phase3-container-overview.mjs`、`phase3-loading.mjs`、`phase3-loading-model-bridge.mjs`、`phase3-pda-convergence.mjs`、`phase3-evidence-gate.mjs`、`phase3-loading-finalize.mjs`、`phase3-container-documents.mjs`、`phase3-customs.mjs`、`phase3-outbound-dispatch.mjs` | `container-simulation-association-contract.test.mjs`、`container-specification-fixture-contract.test.mjs`、`ocean-booking-channel-contract.test.mjs`、`phase3-container-regression-contract.test.mjs`、`phase3-loading-model-bridge-contract.test.mjs`、`phase3-loading-dynamic-final-contract.test.mjs`、`phase3-container-documents-contract.test.mjs`、`phase3-phase4-departure-pickup-chain-contract.test.mjs`、`phase3-producer-chain-contract.test.mjs`、`manual-state-loading-pda-contract.test.mjs` | +| 头程整柜报关(待最终HTTP契约对齐) | `phase3-container-customs.mjs`(全买单、纯退税、混合三柜;最终 Controller/OpenAPI 未齐时 fail-closed,不进入默认入口) | `container-customs-acceptance-contract.test.mjs`、`container-customs-acceptance-matrix.mjs` | | 分柜与父子入库单数量守恒 | `phase3-order-splitting.mjs` | `phase3-order-splitting-contract.test.mjs` | | 人工计划分柜准备与只读核验 | `manual-order-splitting-fixture.mjs`、`manual-order-splitting-verify.mjs`(可选,不进入默认全量) | `manual-order-splitting-contract.test.mjs` | | 人工推送预装前置与推送后只读核验 | `manual-preload-push-fixture.mjs`、`manual-preload-push-verify.mjs`(可选,不进入默认全量) | `manual-preload-push-contract.test.mjs` | diff --git a/scripts/admin-test-env-modules/container-customs-acceptance-contract.test.mjs b/scripts/admin-test-env-modules/container-customs-acceptance-contract.test.mjs new file mode 100644 index 000000000..9922a6bbb --- /dev/null +++ b/scripts/admin-test-env-modules/container-customs-acceptance-contract.test.mjs @@ -0,0 +1,68 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +import { + CONTAINER_CUSTOMS_FIXTURE_KINDS, + CONTAINER_CUSTOMS_MATRIX, + EXACT_50_MB, + READ_ONLY_DB_EVIDENCE, + REQUIRED_ROUTE_OPERATIONS, + validateContainerCustomsFixtures, + validateRouteContract, + xlsxMagic, + zeroDrift, +} from './container-customs-acceptance-matrix.mjs' + +const source = readFileSync(new URL('./phase3-container-customs.mjs', import.meta.url), 'utf8') + +test('matrix covers three isolated LOADED container kinds', () => { + assert.deepEqual(CONTAINER_CUSTOMS_FIXTURE_KINDS, + ['ALL_BUY_DECLARATION', 'ALL_TAX_REFUND', 'MIXED']) + const fixtures = { marker: 'M', containers: CONTAINER_CUSTOMS_FIXTURE_KINDS.map((kind, index) => ({ + marker: 'M', kind, status: 'LOADED', loadingPlanId: String(100 + index), + containerBookingId: String(200 + index), + inbounds: [{ inboundId: String(300 + index), customerId: String(400 + index), + taxMode: kind === 'ALL_BUY_DECLARATION' ? 'BUY_DECLARATION' : 'TAX_REFUND' }], + })) } + assert.equal(validateContainerCustomsFixtures(fixtures, 'M'), true) + assert.equal(validateContainerCustomsFixtures({ ...fixtures, marker: 'OLD' }, 'M'), false) +}) + +test('coverage matrix freezes every requested product boundary', () => { + const text = CONTAINER_CUSTOMS_MATRIX.map((item) => `${item.scenario}:${item.expected}`).join('\n') + for (const token of ['买单XLSX', '多对多', '覆盖', '改关联', '删除', '冻结', '逐份完成文件', + '最后一份', '混合', '缺资料', '开始后锁定', '客户隔离', '买单隐藏', '50MiB', '任意格式', + '幂等', '并发', 'A0409', '零漂移', '自动离仓', 'Outbox', '历史', 'Admin与Client权限']) { + assert.match(text, new RegExp(token)) + } +}) + +test('route adapter fails closed until final backend and frontend contracts align', () => { + assert.equal(validateRouteContract({}), false) + const routes = { version: 'final', ...Object.fromEntries(REQUIRED_ROUTE_OPERATIONS.map((name) => [name, () => null])) } + assert.equal(validateRouteContract(routes), true) + assert.match(source, /后端Controller\/OpenAPI与Admin\/Client最终路径尚未对齐,禁止猜测URL发请求/) + assert.doesNotMatch(source, /https?:\/\//) +}) + +test('HTTP scenario contains file, state, idempotency, concurrency and zero-drift probes', () => { + for (const token of ['EXACT_50_MB + 1', '.customblob', 'application/octet-stream', + '缺退税资料禁止开始且零漂移', '开始后资料范围锁定A0409零漂移', + "['上传'", "['覆盖'", "['改关联'", "['删除'", '开始后${label}锁定A0409零漂移', + '最后一份完成文件前禁止整柜完成', '最后一份完成文件后纯退税柜完成', + '自动离仓失败不回滚报关完成', '自动或待条件重试后整柜单次离仓', + '同幂等键异载荷A0409', 'Promise.all', 'requestId=', 'traceId=', 'xlsxMagic']) { + assert.match(source, new RegExp(token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))) + } + assert.equal(EXACT_50_MB, 52_428_800) + assert.equal(zeroDrift({ a: 1 }, { a: 1 }), true) + assert.equal(xlsxMagic(Uint8Array.from([0x50, 0x4b, 0x03, 0x04])), true) +}) + +test('database evidence list covers atomicity, history, dispatch and outbox without embedding SQL writes', () => { + for (const table of ['fl_customs_container', 'fl_customs_customer_document_inbound', + 'fl_customs_completion_document', 'fl_customs_command', 'fl_customs_container_history', + 'fl_outbound_dispatch', 'fl_event_outbox']) assert.ok(READ_ONLY_DB_EVIDENCE.includes(table), table) + assert.doesNotMatch(source, /\b(?:INSERT|UPDATE|DELETE)\s+(?:INTO|FROM|[a-z_])/i) +}) diff --git a/scripts/admin-test-env-modules/container-customs-acceptance-matrix.mjs b/scripts/admin-test-env-modules/container-customs-acceptance-matrix.mjs new file mode 100644 index 000000000..c03c7e81c --- /dev/null +++ b/scripts/admin-test-env-modules/container-customs-acceptance-matrix.mjs @@ -0,0 +1,97 @@ +export const CONTAINER_CUSTOMS_MODULE = 'phase3-container-customs' +export const EXACT_50_MB = 50 * 1024 * 1024 + +export const CONTAINER_CUSTOMS_FIXTURE_KINDS = Object.freeze([ + 'ALL_BUY_DECLARATION', + 'ALL_TAX_REFUND', + 'MIXED', +]) + +export const REQUIRED_ROUTE_OPERATIONS = Object.freeze([ + 'platformDetail', + 'merchantDetail', + 'buyDeclarationXlsx', + 'merchantUploadDocument', + 'merchantReplaceDocument', + 'merchantUpdateScope', + 'merchantDeleteDocument', + 'platformStart', + 'platformUploadCompletion', + 'platformComplete', + 'platformDownload', + 'merchantDownload', + 'outboundState', + 'satisfyOutboundPrerequisites', +]) + +export const CONTAINER_CUSTOMS_MATRIX = Object.freeze([ + { priority: 'P0', fixture: 'ALL_BUY_DECLARATION', scenario: 'LOADED详情与买单XLSX', expected: 'XLSX只含柜内买单实装行,下载窗口可用' }, + { priority: 'P0', fixture: 'ALL_BUY_DECLARATION', scenario: '开始与完成', expected: 'READY_TO_START→DECLARING→COMPLETED,重放返回同一结果' }, + { priority: 'P0', fixture: 'ALL_TAX_REFUND', scenario: '客户多对多资料', expected: '上传、覆盖、改关联、删除均保留版本与客户范围' }, + { priority: 'P0', fixture: 'ALL_TAX_REFUND', scenario: '冻结与逐份完成文件', expected: '开始冻结来源文件和关联;最后一份完成文件后才允许整柜完成' }, + { priority: 'P0', fixture: 'MIXED', scenario: '买单与退税混合', expected: '买单行进入XLSX,退税票逐票被资料覆盖,整柜统一开始/完成' }, + { priority: 'P0', fixture: 'ALL_TAX_REFUND', scenario: '缺资料禁止开始', expected: 'A0409且聚合、文件、关联、历史零漂移' }, + { priority: 'P0', fixture: 'MIXED', scenario: '自动离仓失败恢复', expected: '报关COMPLETED不回滚;待条件满足后后台重试并单次离仓' }, + { priority: 'P1', fixture: 'ALL_TAX_REFUND', scenario: '开始后锁定', expected: '上传、覆盖、改关联、删除全部A0409零漂移' }, + { priority: 'P1', fixture: 'ALL_TAX_REFUND', scenario: '完成文件继承范围', expected: '完成文件只继承冻结关联,不接收客户端范围' }, + { priority: 'P1', fixture: 'MIXED', scenario: '客户隔离与买单隐藏', expected: '客户只见本企业退税资料;买单XLSX及其他客户文件不可见' }, + { priority: 'P1', fixture: 'ALL_TAX_REFUND', scenario: '文件边界', expected: '任意格式允许;50MiB成功;50MiB+1拒绝且零写入' }, + { priority: 'P1', fixture: 'MIXED', scenario: '幂等与并发', expected: '同键同载荷同结果;异载荷/并发冲突A0409且零漂移' }, + { priority: 'P1', fixture: 'MIXED', scenario: '历史与Outbox', expected: '每个真实动作单条历史;自动离仓意图/结果可追踪且无重复' }, + { priority: 'P1', fixture: 'MIXED', scenario: 'Admin与Client权限', expected: '列表/详情/写权限分离,客户下载仅限自身可见文件' }, +]) + +export function validateContainerCustomsFixtures(value, marker) { + if (value?.marker !== marker || !Array.isArray(value?.containers)) return false + const byKind = new Map(value.containers.map((item) => [item?.kind, item])) + return CONTAINER_CUSTOMS_FIXTURE_KINDS.every((kind) => { + const fixture = byKind.get(kind) + return fixture?.marker === marker && fixture?.status === 'LOADED' + && /^\d+$/.test(String(fixture?.loadingPlanId ?? '')) + && /^\d+$/.test(String(fixture?.containerBookingId ?? '')) + && Array.isArray(fixture?.inbounds) && fixture.inbounds.length > 0 + && fixture.inbounds.every((inbound) => /^\d+$/.test(String(inbound?.inboundId ?? '')) + && /^\d+$/.test(String(inbound?.customerId ?? '')) + && ['BUY_DECLARATION', 'TAX_REFUND'].includes(inbound?.taxMode)) + }) +} + +export function validateRouteContract(routes) { + return routes?.version != null + && REQUIRED_ROUTE_OPERATIONS.every((name) => typeof routes?.[name] === 'function') +} + +export function stableSnapshot(value) { + const normalize = (current) => { + if (Array.isArray(current)) return current.map(normalize) + if (current != null && typeof current === 'object') { + return Object.fromEntries(Object.keys(current).sort().map((key) => [key, normalize(current[key])])) + } + return current + } + return JSON.stringify(normalize(value)) +} + +export function zeroDrift(before, after) { + return stableSnapshot(before) === stableSnapshot(after) +} + +export function xlsxMagic(bytes) { + return bytes instanceof Uint8Array && bytes.length >= 4 + && bytes[0] === 0x50 && bytes[1] === 0x4b && bytes[2] === 0x03 && bytes[3] === 0x04 +} + +export const READ_ONLY_DB_EVIDENCE = Object.freeze([ + 'fl_customs_container', + 'fl_customs_customer_document', + 'fl_customs_customer_document_inbound', + 'fl_customs_customer_document_version', + 'fl_customs_frozen_document_inbound', + 'fl_customs_completion_document', + 'fl_customs_completion_document_version', + 'fl_customs_command', + 'fl_customs_container_history', + 'fl_outbound_dispatch', + 'fl_outbound_dispatch_history', + 'fl_event_outbox', +]) diff --git a/scripts/admin-test-env-modules/phase3-container-customs.mjs b/scripts/admin-test-env-modules/phase3-container-customs.mjs new file mode 100644 index 000000000..13546043d --- /dev/null +++ b/scripts/admin-test-env-modules/phase3-container-customs.mjs @@ -0,0 +1,255 @@ +import { + CONTAINER_CUSTOMS_FIXTURE_KINDS, + CONTAINER_CUSTOMS_MODULE, + EXACT_50_MB, + validateContainerCustomsFixtures, + validateRouteContract, + xlsxMagic, + zeroDrift, +} from './container-customs-acceptance-matrix.mjs' + +const MODULE = CONTAINER_CUSTOMS_MODULE +const OPTIONS = { scenario: MODULE, deploymentBlocker: true } +const id = (value) => value == null ? '' : String(value) +const code = (response) => String(response?.body?.code ?? '') +const success = (response) => response?.http === 200 && code(response) === '200' +const a0409 = (response) => response?.http === 200 && code(response) === 'A0409' +const evidence = (response, extra = '') => `${extra}${extra ? '; ' : ''}HTTP=${response?.http ?? 'missing'}, code=${code(response) || 'missing'}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}` +const key = (context, value) => `${context.marker}-container-customs-${value}`.slice(0, 128) + +function blocker(context, name, reason) { + context.addCoverageGap(MODULE, MODULE, name, reason, 'contract-or-fixture', { deploymentBlocker: true }) + context.addStep(MODULE, name, null, false, reason, OPTIONS) + throw new Error(`blocking coverage gap: ${name}: ${reason}`) +} + +function resolvePreparation(context) { + const fixtures = context.createdResources?.phase3ContainerCustomsFixtures + const routes = context.context?.phase3ContainerCustomsRoutes + if (!validateContainerCustomsFixtures(fixtures, context.marker)) { + blocker(context, '三类LOADED整柜fixture', '缺少本轮全买单、纯退税、混合三柜正式链路数据') + } + if (!validateRouteContract(routes)) { + blocker(context, '整柜报关最终HTTP契约', '后端Controller/OpenAPI与Admin/Client最终路径尚未对齐,禁止猜测URL发请求') + } + return { fixtures, routes } +} + +async function platformDetail(context, routes, fixture) { + return routes.platformDetail(context, { loadingPlanId: id(fixture.loadingPlanId) }) +} + +async function assertZeroDrift(context, name, routes, fixture, before, response, acceptedCodes = ['A0409']) { + const after = await platformDetail(context, routes, fixture) + context.assert(MODULE, name, response, + response?.http === 200 && acceptedCodes.includes(code(response)) + && success(after) && zeroDrift(before.body?.data, after.body?.data), + evidence(response, `afterRequestId=${after.requestId || 'missing'}, afterTraceId=${after.traceId || 'missing'}`), OPTIONS) +} + +async function runAllBuy(context, routes, fixture) { + const detail = await platformDetail(context, routes, fixture) + context.assert(MODULE, '全买单柜LOADED详情', detail, success(detail) + && detail.body?.data?.inbounds?.every?.((item) => item.taxMode === 'BUY_DECLARATION'), evidence(detail), OPTIONS) + + const xlsx = await routes.buyDeclarationXlsx(context, { loadingPlanId: id(fixture.loadingPlanId) }) + context.assert(MODULE, '买单XLSX下载窗口返回真实工作簿', xlsx, + xlsx.http === 200 && xlsxMagic(xlsx.bytes), + `HTTP=${xlsx.http}, bytes=${xlsx.bytes?.length ?? 0}, contentType=${xlsx.contentType || 'missing'}`, OPTIONS) + + const startBody = { expectedVersion: Number(detail.body?.data?.version), remark: context.marker } + const started = await routes.platformStart(context, fixture, startBody, key(context, 'all-buy-start')) + context.assert(MODULE, '全买单柜开始报关', started, + success(started) && started.body?.data?.status === 'DECLARING', evidence(started), OPTIONS) + + const completeBody = { expectedVersion: Number(started.body?.data?.version), remark: context.marker } + const completed = await routes.platformComplete(context, fixture, completeBody, key(context, 'all-buy-complete')) + context.assert(MODULE, '全买单柜完成并触发自动离仓意图', completed, + success(completed) && completed.body?.data?.status === 'COMPLETED', evidence(completed), OPTIONS) + const replay = await routes.platformComplete(context, fixture, completeBody, key(context, 'all-buy-complete')) + context.assert(MODULE, '全买单柜完成同键重放', replay, + success(replay) && id(replay.body?.data?.aggregateId) === id(completed.body?.data?.aggregateId), evidence(replay), OPTIONS) + + let outbound = await routes.outboundState(context, fixture) + if (fixture.autoDispatchMode === 'WAIT_CONDITION') { + context.assert(MODULE, '自动离仓失败不回滚报关完成', outbound, + success(outbound) && outbound.body?.data?.customsStatus === 'COMPLETED' + && outbound.body?.data?.departed !== true && Boolean(outbound.body?.data?.retryable), + evidence(outbound), OPTIONS) + await routes.satisfyOutboundPrerequisites(context, fixture) + for (let attempt = 0; attempt < 12 && outbound.body?.data?.departed !== true; attempt += 1) { + if (attempt > 0) await new Promise((resolve) => setTimeout(resolve, 1_000)) + outbound = await routes.outboundState(context, fixture) + } + } + context.assert(MODULE, '自动或待条件重试后整柜单次离仓', outbound, + success(outbound) && outbound.body?.data?.departed === true + && Number(outbound.body?.data?.departureHistoryCount) === 1, + evidence(outbound), OPTIONS) +} + +async function uploadBoundary(context, routes, fixture, customer, size, suffix) { + const content = new Uint8Array(size) + content[0] = 0x43; content[content.length - 1] = 0x58 + return routes.merchantUploadDocument(context, fixture, customer, { + fileName: `${context.marker}-${suffix}.customblob`, contentType: 'application/octet-stream', content, + inboundIds: customer.inboundIds.map(id), expectedVersion: customer.expectedVersion, + }, key(context, suffix)) +} + +async function runRefund(context, routes, fixture) { + const customers = fixture.customers || [] + if (customers.length < 2) blocker(context, '纯退税多客户fixture', '至少需要两个客户和可交叉验证的多对多入仓范围') + const first = customers[0] + const exact = await uploadBoundary(context, routes, fixture, first, EXACT_50_MB, 'exact-50m') + context.assert(MODULE, '任意格式50MiB边界成功', exact, success(exact), evidence(exact), OPTIONS) + const tooLargeBefore = await platformDetail(context, routes, fixture) + const tooLarge = await uploadBoundary(context, routes, fixture, first, EXACT_50_MB + 1, 'over-50m') + await assertZeroDrift(context, '50MiB+1拒绝且零漂移', routes, fixture, tooLargeBefore, tooLarge, + ['A0400', 'A0409', 'A0422']) + + const crossCustomer = await routes.merchantDetail(context, fixture, customers[1], first.customerToken) + context.assert(MODULE, '客户隔离阻止查看其他客户资料', crossCustomer, + !success(crossCustomer), evidence(crossCustomer), OPTIONS) + const hiddenBuy = await routes.buyDeclarationXlsx(context, { loadingPlanId: id(fixture.loadingPlanId), token: first.customerToken }) + context.assert(MODULE, '客户侧隐藏买单XLSX', hiddenBuy, hiddenBuy.http !== 200 || !xlsxMagic(hiddenBuy.bytes), + `HTTP=${hiddenBuy.http}, bytes=${hiddenBuy.bytes?.length ?? 0}`, OPTIONS) + + for (const customer of customers) { + const document = await routes.merchantUploadDocument(context, fixture, customer, customer.document, + key(context, `upload-${customer.customerId}`)) + context.assert(MODULE, `客户${customer.customerId}上传多对多资料`, document, success(document), evidence(document), OPTIONS) + customer.documentId = id(document.body?.data?.documentId) + customer.documentVersion = Number(document.body?.data?.source?.version) + } + + const mutable = customers[0] + const replaced = await routes.merchantReplaceDocument(context, fixture, mutable, { + ...mutable.replacement, documentId: mutable.documentId, expectedVersion: mutable.documentVersion, + }, key(context, 'replace')) + context.assert(MODULE, '客户覆盖资料生成新版本', replaced, success(replaced), evidence(replaced), OPTIONS) + const scoped = await routes.merchantUpdateScope(context, fixture, mutable, { + documentId: mutable.documentId, inboundIds: mutable.updatedInboundIds.map(id), + expectedVersion: Number(replaced.body?.data?.source?.version), + }, key(context, 'scope')) + context.assert(MODULE, '客户修改多对多关联', scoped, success(scoped), evidence(scoped), OPTIONS) + const deleted = await routes.merchantDeleteDocument(context, fixture, mutable, { + documentId: mutable.documentId, expectedVersion: Number(scoped.body?.data?.source?.version), + }, key(context, 'delete')) + context.assert(MODULE, '客户删除未冻结资料', deleted, success(deleted), evidence(deleted), OPTIONS) + + const beforeMissingStart = await platformDetail(context, routes, fixture) + const missingStart = await routes.platformStart(context, fixture, { + expectedVersion: Number(beforeMissingStart.body?.data?.version), remark: 'missing-document-negative', + }, key(context, 'missing-start')) + await assertZeroDrift(context, '缺退税资料禁止开始且零漂移', routes, fixture, beforeMissingStart, missingStart) + + const restored = await routes.merchantUploadDocument(context, fixture, mutable, mutable.document, + key(context, `restore-${mutable.customerId}`)) + context.assert(MODULE, '删除后重新上传恢复完整退税覆盖', restored, success(restored), evidence(restored), OPTIONS) + mutable.documentId = id(restored.body?.data?.documentId) + mutable.documentVersion = Number(restored.body?.data?.source?.version) + + const ready = await platformDetail(context, routes, fixture) + const started = await routes.platformStart(context, fixture, { + expectedVersion: Number(ready.body?.data?.version), remark: context.marker, + }, key(context, 'refund-start')) + context.assert(MODULE, '纯退税柜资料齐全后开始并冻结', started, + success(started) && started.body?.data?.status === 'DECLARING' + && started.body?.data?.documents?.every?.((item) => item.frozen === true), + evidence(started), OPTIONS) + + const frozenSnapshot = started.body?.data + const lockedOperations = [ + ['上传', () => routes.merchantUploadDocument(context, fixture, mutable, { + ...mutable.document, fileName: `${context.marker}-locked.customblob`, content: Uint8Array.from([1]), + }, key(context, 'locked-upload'))], + ['覆盖', () => routes.merchantReplaceDocument(context, fixture, mutable, { + ...mutable.replacement, documentId: mutable.documentId, expectedVersion: mutable.documentVersion, + }, key(context, 'locked-replace'))], + ['改关联', () => routes.merchantUpdateScope(context, fixture, mutable, { + documentId: mutable.documentId, inboundIds: mutable.inboundIds.map(id), expectedVersion: mutable.documentVersion, + }, key(context, 'locked-scope'))], + ['删除', () => routes.merchantDeleteDocument(context, fixture, mutable, { + documentId: mutable.documentId, expectedVersion: mutable.documentVersion, + }, key(context, 'locked-delete'))], + ] + for (const [label, request] of lockedOperations) { + const response = await request() + await assertZeroDrift(context, `开始后${label}锁定A0409零漂移`, routes, fixture, + { body: { data: frozenSnapshot } }, response) + } + + const documents = frozenSnapshot?.documents || [] + for (const [index, document] of documents.entries()) { + const completion = await routes.platformUploadCompletion(context, fixture, { + documentId: id(document.documentId), expectedVersion: Number(document.source?.version), + fileName: `${context.marker}-completion-${index + 1}.done`, + contentType: 'application/x-customs-completion', content: Uint8Array.from([0x43, index + 1]), + }, key(context, `completion-${index + 1}`)) + context.assert(MODULE, `逐份上传完成文件${index + 1}`, completion, + success(completion) + && zeroDrift(completion.body?.data?.inboundIds, document.inboundIds), + evidence(completion, `sourceDocumentId=${id(document.documentId)}`), OPTIONS) + if (index + 1 < documents.length) { + const beforeEarlyComplete = await platformDetail(context, routes, fixture) + const early = await routes.platformComplete(context, fixture, { + expectedVersion: Number(beforeEarlyComplete.body?.data?.version), remark: 'not-last-completion', + }, key(context, `early-complete-${index + 1}`)) + await assertZeroDrift(context, '最后一份完成文件前禁止整柜完成', routes, fixture, beforeEarlyComplete, early) + } + } + const completedReady = await platformDetail(context, routes, fixture) + const completed = await routes.platformComplete(context, fixture, { + expectedVersion: Number(completedReady.body?.data?.version), remark: context.marker, + }, key(context, 'refund-complete')) + context.assert(MODULE, '最后一份完成文件后纯退税柜完成', completed, + success(completed) && completed.body?.data?.status === 'COMPLETED', evidence(completed), OPTIONS) + + const firstDocument = documents[0] + if (firstDocument) { + const platformDownload = await routes.platformDownload(context, fixture, { + documentId: id(firstDocument.documentId), completion: true, + }) + context.assert(MODULE, 'Admin下载完成文件窗口', platformDownload, + platformDownload.http === 200 && platformDownload.bytes?.length > 0, + `HTTP=${platformDownload.http}, bytes=${platformDownload.bytes?.length ?? 0}`, OPTIONS) + const merchantDownload = await routes.merchantDownload(context, fixture, mutable, { + documentId: id(firstDocument.documentId), completion: true, + }) + context.assert(MODULE, 'Client仅下载本客户完成文件窗口', merchantDownload, + merchantDownload.http === 200 && merchantDownload.bytes?.length > 0, + `HTTP=${merchantDownload.http}, bytes=${merchantDownload.bytes?.length ?? 0}`, OPTIONS) + } +} + +async function runMixedConcurrency(context, routes, fixture) { + const before = await platformDetail(context, routes, fixture) + const body = { expectedVersion: Number(before.body?.data?.version), remark: context.marker } + const concurrentKey = key(context, 'mixed-start-concurrent') + const responses = await Promise.all([ + routes.platformStart(context, fixture, body, concurrentKey), + routes.platformStart(context, fixture, body, concurrentKey), + ]) + context.assert(MODULE, '开始并发双击收敛同一结果', responses[1], + responses.every(success) && zeroDrift(responses[0].body?.data, responses[1].body?.data), + responses.map((response) => evidence(response)).join('; '), OPTIONS) + + const different = await routes.platformStart(context, fixture, { ...body, remark: 'different' }, concurrentKey) + context.assert(MODULE, '同幂等键异载荷A0409', different, a0409(different), evidence(different), OPTIONS) + const frozen = responses[0].body?.data + const customer = fixture.customers?.[0] + if (!customer?.documentId) blocker(context, '混合柜冻结文件fixture', '混合柜缺少已上传退税客户资料') + const locked = await routes.merchantUpdateScope(context, fixture, customer, { + documentId: customer.documentId, inboundIds: customer.inboundIds.map(id), expectedVersion: customer.documentVersion, + }, key(context, 'locked-scope')) + await assertZeroDrift(context, '开始后资料范围锁定A0409零漂移', routes, fixture, { body: { data: frozen } }, locked) +} + +export async function run(context) { + const { fixtures, routes } = resolvePreparation(context) + const byKind = new Map(fixtures.containers.map((item) => [item.kind, item])) + await runAllBuy(context, routes, byKind.get(CONTAINER_CUSTOMS_FIXTURE_KINDS[0])) + await runRefund(context, routes, byKind.get(CONTAINER_CUSTOMS_FIXTURE_KINDS[1])) + await runMixedConcurrency(context, routes, byKind.get(CONTAINER_CUSTOMS_FIXTURE_KINDS[2])) +}