From 9df6f60a1706a44a9ec167f0ca2844aef5388903 Mon Sep 17 00:00:00 2001 From: huangping Date: Mon, 6 Apr 2026 21:49:04 +0800 Subject: [PATCH] feat(platform): I4 delivery batches, lines, and license SN APIs Add Flyway V4 tables, delivery-batches and license-sns endpoints with validation, audit actions, controller tests, and OpenAPI snapshot update. Made-with: Cursor --- contracts/openapi/delivery-platform-api.json | 827 +++++++++++++++++- .../platform/api/audit/AuditActions.java | 11 + .../platform/api/audit/AuditEntityTypes.java | 2 + .../api/delivery/DeliveryBatchController.java | 100 +++ .../api/domain/DeliveryBatchStatus.java | 8 + .../platform/api/domain/LicenseSnStatus.java | 10 + .../api/license/LicenseSnController.java | 68 ++ .../delivery/PlatformDeliveryBatch.java | 121 +++ .../delivery/PlatformDeliveryBatchMapper.java | 7 + .../delivery/PlatformDeliveryLine.java | 99 +++ .../delivery/PlatformDeliveryLineMapper.java | 7 + .../license/PlatformLicenseSn.java | 99 +++ .../license/PlatformLicenseSnMapper.java | 7 + .../api/service/DeliveryBatchService.java | 453 ++++++++++ .../api/service/LicenseSnService.java | 321 +++++++ .../web/dto/DeliveryBatchCreateRequest.java | 62 ++ .../api/web/dto/DeliveryBatchResponse.java | 112 +++ .../dto/DeliveryBatchStatusPatchRequest.java | 17 + .../web/dto/DeliveryBatchUpdateRequest.java | 27 + .../api/web/dto/DeliveryLineRequest.java | 55 ++ .../api/web/dto/DeliveryLineResponse.java | 80 ++ .../api/web/dto/LicenseSnCreateRequest.java | 49 ++ .../api/web/dto/LicenseSnResponse.java | 79 ++ .../web/dto/LicenseSnStatusPatchRequest.java | 17 + .../api/web/dto/LicenseSnUpdateRequest.java | 37 + .../V4__delivery_batch_and_license_sn.sql | 45 + .../delivery/DeliveryBatchControllerTest.java | 250 ++++++ .../api/license/LicenseSnControllerTest.java | 195 +++++ 28 files changed, 3151 insertions(+), 14 deletions(-) create mode 100644 services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/delivery/DeliveryBatchController.java create mode 100644 services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/domain/DeliveryBatchStatus.java create mode 100644 services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/domain/LicenseSnStatus.java create mode 100644 services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/license/LicenseSnController.java create mode 100644 services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/delivery/PlatformDeliveryBatch.java create mode 100644 services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/delivery/PlatformDeliveryBatchMapper.java create mode 100644 services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/delivery/PlatformDeliveryLine.java create mode 100644 services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/delivery/PlatformDeliveryLineMapper.java create mode 100644 services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/license/PlatformLicenseSn.java create mode 100644 services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/license/PlatformLicenseSnMapper.java create mode 100644 services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/service/DeliveryBatchService.java create mode 100644 services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/service/LicenseSnService.java create mode 100644 services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/DeliveryBatchCreateRequest.java create mode 100644 services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/DeliveryBatchResponse.java create mode 100644 services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/DeliveryBatchStatusPatchRequest.java create mode 100644 services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/DeliveryBatchUpdateRequest.java create mode 100644 services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/DeliveryLineRequest.java create mode 100644 services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/DeliveryLineResponse.java create mode 100644 services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/LicenseSnCreateRequest.java create mode 100644 services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/LicenseSnResponse.java create mode 100644 services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/LicenseSnStatusPatchRequest.java create mode 100644 services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/LicenseSnUpdateRequest.java create mode 100644 services/delivery-platform-api/src/main/resources/db/migration/V4__delivery_batch_and_license_sn.sql create mode 100644 services/delivery-platform-api/src/test/java/cn/craftlabs/platform/api/delivery/DeliveryBatchControllerTest.java create mode 100644 services/delivery-platform-api/src/test/java/cn/craftlabs/platform/api/license/LicenseSnControllerTest.java diff --git a/contracts/openapi/delivery-platform-api.json b/contracts/openapi/delivery-platform-api.json index 79de8f8..d7307af 100644 --- a/contracts/openapi/delivery-platform-api.json +++ b/contracts/openapi/delivery-platform-api.json @@ -90,10 +90,205 @@ } } }, + "/api/v1/license-sns/{id}" : { + "get" : { + "tags" : [ "license-sn-controller" ], + "operationId" : "get_1", + "parameters" : [ { + "name" : "id", + "in" : "path", + "required" : true, + "schema" : { + "type" : "integer", + "format" : "int64" + } + } ], + "responses" : { + "200" : { + "description" : "OK", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/LicenseSnResponse" + } + } + } + } + } + }, + "put" : { + "tags" : [ "license-sn-controller" ], + "operationId" : "update_1", + "parameters" : [ { + "name" : "id", + "in" : "path", + "required" : true, + "schema" : { + "type" : "integer", + "format" : "int64" + } + } ], + "requestBody" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/LicenseSnUpdateRequest" + } + } + }, + "required" : true + }, + "responses" : { + "200" : { + "description" : "OK", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/LicenseSnResponse" + } + } + } + } + } + } + }, + "/api/v1/delivery-batches/{id}" : { + "get" : { + "tags" : [ "delivery-batch-controller" ], + "operationId" : "get_2", + "parameters" : [ { + "name" : "id", + "in" : "path", + "required" : true, + "schema" : { + "type" : "integer", + "format" : "int64" + } + } ], + "responses" : { + "200" : { + "description" : "OK", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/DeliveryBatchResponse" + } + } + } + } + } + }, + "put" : { + "tags" : [ "delivery-batch-controller" ], + "operationId" : "update_2", + "parameters" : [ { + "name" : "id", + "in" : "path", + "required" : true, + "schema" : { + "type" : "integer", + "format" : "int64" + } + } ], + "requestBody" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/DeliveryBatchUpdateRequest" + } + } + }, + "required" : true + }, + "responses" : { + "200" : { + "description" : "OK", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/DeliveryBatchResponse" + } + } + } + } + } + } + }, + "/api/v1/delivery-batches/{id}/lines/{lineId}" : { + "put" : { + "tags" : [ "delivery-batch-controller" ], + "operationId" : "updateLine", + "parameters" : [ { + "name" : "id", + "in" : "path", + "required" : true, + "schema" : { + "type" : "integer", + "format" : "int64" + } + }, { + "name" : "lineId", + "in" : "path", + "required" : true, + "schema" : { + "type" : "integer", + "format" : "int64" + } + } ], + "requestBody" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/DeliveryLineRequest" + } + } + }, + "required" : true + }, + "responses" : { + "200" : { + "description" : "OK", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/DeliveryLineResponse" + } + } + } + } + } + }, + "delete" : { + "tags" : [ "delivery-batch-controller" ], + "operationId" : "deleteLine", + "parameters" : [ { + "name" : "id", + "in" : "path", + "required" : true, + "schema" : { + "type" : "integer", + "format" : "int64" + } + }, { + "name" : "lineId", + "in" : "path", + "required" : true, + "schema" : { + "type" : "integer", + "format" : "int64" + } + } ], + "responses" : { + "204" : { + "description" : "No Content" + } + } + } + }, "/api/v1/customers/{id}" : { "get" : { "tags" : [ "customer-controller" ], - "operationId" : "get_1", + "operationId" : "get_3", "parameters" : [ { "name" : "id", "in" : "path", @@ -118,7 +313,7 @@ }, "put" : { "tags" : [ "customer-controller" ], - "operationId" : "update_1", + "operationId" : "update_3", "parameters" : [ { "name" : "id", "in" : "path", @@ -173,7 +368,7 @@ "/api/v1/contracts/{id}" : { "get" : { "tags" : [ "contract-controller" ], - "operationId" : "get_2", + "operationId" : "get_4", "parameters" : [ { "name" : "id", "in" : "path", @@ -198,7 +393,7 @@ }, "put" : { "tags" : [ "contract-controller" ], - "operationId" : "update_2", + "operationId" : "update_4", "parameters" : [ { "name" : "id", "in" : "path", @@ -235,7 +430,7 @@ "/api/v1/contracts/{id}/lines/{lineId}" : { "put" : { "tags" : [ "contract-controller" ], - "operationId" : "updateLine", + "operationId" : "updateLine_1", "parameters" : [ { "name" : "id", "in" : "path", @@ -278,7 +473,7 @@ }, "delete" : { "tags" : [ "contract-controller" ], - "operationId" : "deleteLine", + "operationId" : "deleteLine_1", "parameters" : [ { "name" : "id", "in" : "path", @@ -377,10 +572,252 @@ } } }, + "/api/v1/license-sns" : { + "get" : { + "tags" : [ "license-sn-controller" ], + "operationId" : "list_1", + "parameters" : [ { + "name" : "page", + "in" : "query", + "required" : false, + "schema" : { + "type" : "integer", + "format" : "int32", + "default" : 0, + "minimum" : 0 + } + }, { + "name" : "size", + "in" : "query", + "required" : false, + "schema" : { + "type" : "integer", + "format" : "int32", + "default" : 20, + "maximum" : 200, + "minimum" : 1 + } + }, { + "name" : "projectId", + "in" : "query", + "required" : false, + "schema" : { + "type" : "integer", + "format" : "int64" + } + }, { + "name" : "keyword", + "in" : "query", + "required" : false, + "schema" : { + "type" : "string" + } + }, { + "name" : "status", + "in" : "query", + "required" : false, + "schema" : { + "type" : "string" + } + } ], + "responses" : { + "200" : { + "description" : "OK", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/PageResponseLicenseSnResponse" + } + } + } + } + } + }, + "post" : { + "tags" : [ "license-sn-controller" ], + "operationId" : "create_1", + "requestBody" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/LicenseSnCreateRequest" + } + } + }, + "required" : true + }, + "responses" : { + "201" : { + "description" : "Created", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/LicenseSnResponse" + } + } + } + } + } + } + }, + "/api/v1/delivery-batches" : { + "get" : { + "tags" : [ "delivery-batch-controller" ], + "operationId" : "list_2", + "parameters" : [ { + "name" : "page", + "in" : "query", + "required" : false, + "schema" : { + "type" : "integer", + "format" : "int32", + "default" : 0, + "minimum" : 0 + } + }, { + "name" : "size", + "in" : "query", + "required" : false, + "schema" : { + "type" : "integer", + "format" : "int32", + "default" : 20, + "maximum" : 200, + "minimum" : 1 + } + }, { + "name" : "projectId", + "in" : "query", + "required" : false, + "schema" : { + "type" : "integer", + "format" : "int64" + } + }, { + "name" : "contractId", + "in" : "query", + "required" : false, + "schema" : { + "type" : "integer", + "format" : "int64" + } + }, { + "name" : "keyword", + "in" : "query", + "required" : false, + "schema" : { + "type" : "string" + } + } ], + "responses" : { + "200" : { + "description" : "OK", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/PageResponseDeliveryBatchResponse" + } + } + } + } + } + }, + "post" : { + "tags" : [ "delivery-batch-controller" ], + "operationId" : "create_2", + "requestBody" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/DeliveryBatchCreateRequest" + } + } + }, + "required" : true + }, + "responses" : { + "201" : { + "description" : "Created", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/DeliveryBatchResponse" + } + } + } + } + } + } + }, + "/api/v1/delivery-batches/{id}/lines" : { + "get" : { + "tags" : [ "delivery-batch-controller" ], + "operationId" : "listLines", + "parameters" : [ { + "name" : "id", + "in" : "path", + "required" : true, + "schema" : { + "type" : "integer", + "format" : "int64" + } + } ], + "responses" : { + "200" : { + "description" : "OK", + "content" : { + "*/*" : { + "schema" : { + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/DeliveryLineResponse" + } + } + } + } + } + } + }, + "post" : { + "tags" : [ "delivery-batch-controller" ], + "operationId" : "addLine", + "parameters" : [ { + "name" : "id", + "in" : "path", + "required" : true, + "schema" : { + "type" : "integer", + "format" : "int64" + } + } ], + "requestBody" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/DeliveryLineRequest" + } + } + }, + "required" : true + }, + "responses" : { + "201" : { + "description" : "Created", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/DeliveryLineResponse" + } + } + } + } + } + } + }, "/api/v1/customers" : { "get" : { "tags" : [ "customer-controller" ], - "operationId" : "list_1", + "operationId" : "list_3", "parameters" : [ { "name" : "page", "in" : "query", @@ -425,7 +862,7 @@ }, "post" : { "tags" : [ "customer-controller" ], - "operationId" : "create_1", + "operationId" : "create_3", "requestBody" : { "content" : { "application/json" : { @@ -453,7 +890,7 @@ "/api/v1/contracts" : { "get" : { "tags" : [ "contract-controller" ], - "operationId" : "list_2", + "operationId" : "list_4", "parameters" : [ { "name" : "page", "in" : "query", @@ -514,7 +951,7 @@ }, "post" : { "tags" : [ "contract-controller" ], - "operationId" : "create_2", + "operationId" : "create_4", "requestBody" : { "content" : { "application/json" : { @@ -542,7 +979,7 @@ "/api/v1/contracts/{id}/lines" : { "get" : { "tags" : [ "contract-controller" ], - "operationId" : "listLines", + "operationId" : "listLines_1", "parameters" : [ { "name" : "id", "in" : "path", @@ -570,7 +1007,7 @@ }, "post" : { "tags" : [ "contract-controller" ], - "operationId" : "addLine", + "operationId" : "addLine_1", "parameters" : [ { "name" : "id", "in" : "path", @@ -636,10 +1073,84 @@ } } }, + "/api/v1/license-sns/{id}/status" : { + "patch" : { + "tags" : [ "license-sn-controller" ], + "operationId" : "patchStatus", + "parameters" : [ { + "name" : "id", + "in" : "path", + "required" : true, + "schema" : { + "type" : "integer", + "format" : "int64" + } + } ], + "requestBody" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/LicenseSnStatusPatchRequest" + } + } + }, + "required" : true + }, + "responses" : { + "200" : { + "description" : "OK", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/LicenseSnResponse" + } + } + } + } + } + } + }, + "/api/v1/delivery-batches/{id}/status" : { + "patch" : { + "tags" : [ "delivery-batch-controller" ], + "operationId" : "patchStatus_1", + "parameters" : [ { + "name" : "id", + "in" : "path", + "required" : true, + "schema" : { + "type" : "integer", + "format" : "int64" + } + } ], + "requestBody" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/DeliveryBatchStatusPatchRequest" + } + } + }, + "required" : true + }, + "responses" : { + "200" : { + "description" : "OK", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/DeliveryBatchResponse" + } + } + } + } + } + } + }, "/api/v1/contracts/{id}/status" : { "patch" : { "tags" : [ "contract-controller" ], - "operationId" : "patchStatus", + "operationId" : "patchStatus_2", "parameters" : [ { "name" : "id", "in" : "path", @@ -726,7 +1237,7 @@ "/api/v1/audit-events" : { "get" : { "tags" : [ "audit-controller" ], - "operationId" : "list_3", + "operationId" : "list_5", "parameters" : [ { "name" : "entityType", "in" : "query", @@ -829,6 +1340,177 @@ } } }, + "LicenseSnUpdateRequest" : { + "type" : "object", + "properties" : { + "projectId" : { + "type" : "integer", + "format" : "int64" + }, + "contractLineId" : { + "type" : "integer", + "format" : "int64" + }, + "activationRemark" : { + "type" : "string", + "maxLength" : 512, + "minLength" : 0 + } + } + }, + "LicenseSnResponse" : { + "type" : "object", + "properties" : { + "id" : { + "type" : "integer", + "format" : "int64" + }, + "snCode" : { + "type" : "string" + }, + "projectId" : { + "type" : "integer", + "format" : "int64" + }, + "contractLineId" : { + "type" : "integer", + "format" : "int64" + }, + "status" : { + "type" : "string" + }, + "activationRemark" : { + "type" : "string" + }, + "createdAt" : { + "type" : "string", + "format" : "date-time" + }, + "updatedAt" : { + "type" : "string", + "format" : "date-time" + } + } + }, + "DeliveryBatchUpdateRequest" : { + "type" : "object", + "properties" : { + "plannedDeliveryDate" : { + "type" : "string" + }, + "remarks" : { + "type" : "string", + "maxLength" : 4000, + "minLength" : 0 + } + } + }, + "DeliveryBatchResponse" : { + "type" : "object", + "properties" : { + "id" : { + "type" : "integer", + "format" : "int64" + }, + "projectId" : { + "type" : "integer", + "format" : "int64" + }, + "contractId" : { + "type" : "integer", + "format" : "int64" + }, + "batchCode" : { + "type" : "string" + }, + "plannedDeliveryDate" : { + "type" : "string", + "format" : "date" + }, + "status" : { + "type" : "string" + }, + "finishedAt" : { + "type" : "string", + "format" : "date-time" + }, + "remarks" : { + "type" : "string" + }, + "createdAt" : { + "type" : "string", + "format" : "date-time" + }, + "updatedAt" : { + "type" : "string", + "format" : "date-time" + }, + "lines" : { + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/DeliveryLineResponse" + } + } + } + }, + "DeliveryLineResponse" : { + "type" : "object", + "properties" : { + "id" : { + "type" : "integer", + "format" : "int64" + }, + "batchId" : { + "type" : "integer", + "format" : "int64" + }, + "sortOrder" : { + "type" : "integer", + "format" : "int32" + }, + "description" : { + "type" : "string" + }, + "quantity" : { + "type" : "number" + }, + "contractLineId" : { + "type" : "integer", + "format" : "int64" + }, + "createdAt" : { + "type" : "string", + "format" : "date-time" + }, + "updatedAt" : { + "type" : "string", + "format" : "date-time" + } + } + }, + "DeliveryLineRequest" : { + "type" : "object", + "properties" : { + "sortOrder" : { + "type" : "integer", + "format" : "int32" + }, + "description" : { + "type" : "string", + "maxLength" : 512, + "minLength" : 0 + }, + "quantity" : { + "type" : "number", + "minimum" : 1.0E-4 + }, + "contractLineId" : { + "type" : "integer", + "format" : "int64" + } + }, + "required" : [ "description", "quantity" ] + }, "CustomerRequest" : { "type" : "object", "properties" : { @@ -1003,6 +1685,57 @@ }, "required" : [ "itemName", "quantity" ] }, + "LicenseSnCreateRequest" : { + "type" : "object", + "properties" : { + "snCode" : { + "type" : "string", + "maxLength" : 128, + "minLength" : 0 + }, + "projectId" : { + "type" : "integer", + "format" : "int64" + }, + "contractLineId" : { + "type" : "integer", + "format" : "int64" + }, + "activationRemark" : { + "type" : "string", + "maxLength" : 512, + "minLength" : 0 + } + }, + "required" : [ "snCode" ] + }, + "DeliveryBatchCreateRequest" : { + "type" : "object", + "properties" : { + "projectId" : { + "type" : "integer", + "format" : "int64" + }, + "contractId" : { + "type" : "integer", + "format" : "int64" + }, + "batchCode" : { + "type" : "string", + "maxLength" : 64, + "minLength" : 0 + }, + "plannedDeliveryDate" : { + "type" : "string" + }, + "remarks" : { + "type" : "string", + "maxLength" : 4000, + "minLength" : 0 + } + }, + "required" : [ "batchCode", "projectId" ] + }, "ContractCreateRequest" : { "type" : "object", "properties" : { @@ -1027,6 +1760,26 @@ }, "required" : [ "customerId", "projectId" ] }, + "LicenseSnStatusPatchRequest" : { + "type" : "object", + "properties" : { + "status" : { + "type" : "string", + "minLength" : 1 + } + }, + "required" : [ "status" ] + }, + "DeliveryBatchStatusPatchRequest" : { + "type" : "object", + "properties" : { + "status" : { + "type" : "string", + "minLength" : 1 + } + }, + "required" : [ "status" ] + }, "ContractStatusPatchRequest" : { "type" : "object", "properties" : { @@ -1060,6 +1813,29 @@ } } }, + "PageResponseLicenseSnResponse" : { + "type" : "object", + "properties" : { + "content" : { + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/LicenseSnResponse" + } + }, + "totalElements" : { + "type" : "integer", + "format" : "int64" + }, + "number" : { + "type" : "integer", + "format" : "int32" + }, + "size" : { + "type" : "integer", + "format" : "int32" + } + } + }, "DictionaryItemResponse" : { "type" : "object", "properties" : { @@ -1075,6 +1851,29 @@ } } }, + "PageResponseDeliveryBatchResponse" : { + "type" : "object", + "properties" : { + "content" : { + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/DeliveryBatchResponse" + } + }, + "totalElements" : { + "type" : "integer", + "format" : "int64" + }, + "number" : { + "type" : "integer", + "format" : "int32" + }, + "size" : { + "type" : "integer", + "format" : "int32" + } + } + }, "PageResponseCustomerResponse" : { "type" : "object", "properties" : { diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/audit/AuditActions.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/audit/AuditActions.java index 65a3206..2573a92 100644 --- a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/audit/AuditActions.java +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/audit/AuditActions.java @@ -10,5 +10,16 @@ public final class AuditActions { public static final String CONTRACT_LINE_DELETED = "CONTRACT_LINE_DELETED"; public static final String CONTRACT_STATUS_CHANGED = "CONTRACT_STATUS_CHANGED"; + public static final String DELIVERY_BATCH_CREATED = "DELIVERY_BATCH_CREATED"; + public static final String DELIVERY_BATCH_UPDATED = "DELIVERY_BATCH_UPDATED"; + public static final String DELIVERY_BATCH_STATUS_CHANGED = "DELIVERY_BATCH_STATUS_CHANGED"; + public static final String DELIVERY_LINE_ADDED = "DELIVERY_LINE_ADDED"; + public static final String DELIVERY_LINE_UPDATED = "DELIVERY_LINE_UPDATED"; + public static final String DELIVERY_LINE_DELETED = "DELIVERY_LINE_DELETED"; + + public static final String LICENSE_SN_CREATED = "LICENSE_SN_CREATED"; + public static final String LICENSE_SN_UPDATED = "LICENSE_SN_UPDATED"; + public static final String LICENSE_SN_STATUS_CHANGED = "LICENSE_SN_STATUS_CHANGED"; + private AuditActions() {} } diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/audit/AuditEntityTypes.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/audit/AuditEntityTypes.java index 115faf9..ac6790b 100644 --- a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/audit/AuditEntityTypes.java +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/audit/AuditEntityTypes.java @@ -3,6 +3,8 @@ package cn.craftlabs.platform.api.audit; public final class AuditEntityTypes { public static final String CONTRACT = "CONTRACT"; + public static final String DELIVERY_BATCH = "DELIVERY_BATCH"; + public static final String LICENSE_SN = "LICENSE_SN"; private AuditEntityTypes() {} } diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/delivery/DeliveryBatchController.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/delivery/DeliveryBatchController.java new file mode 100644 index 0000000..117a4a3 --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/delivery/DeliveryBatchController.java @@ -0,0 +1,100 @@ +package cn.craftlabs.platform.api.delivery; + +import cn.craftlabs.platform.api.service.DeliveryBatchService; +import cn.craftlabs.platform.api.web.dto.DeliveryBatchCreateRequest; +import cn.craftlabs.platform.api.web.dto.DeliveryBatchResponse; +import cn.craftlabs.platform.api.web.dto.DeliveryBatchStatusPatchRequest; +import cn.craftlabs.platform.api.web.dto.DeliveryBatchUpdateRequest; +import cn.craftlabs.platform.api.web.dto.DeliveryLineRequest; +import cn.craftlabs.platform.api.web.dto.DeliveryLineResponse; +import cn.craftlabs.platform.api.web.dto.PageResponse; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import org.springframework.http.HttpStatus; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequestMapping("/api/v1/delivery-batches") +@Validated +public class DeliveryBatchController { + + private final DeliveryBatchService deliveryBatchService; + + public DeliveryBatchController(DeliveryBatchService deliveryBatchService) { + this.deliveryBatchService = deliveryBatchService; + } + + @GetMapping + public PageResponse list( + @RequestParam(value = "page", defaultValue = "0") @Min(0) int page, + @RequestParam(value = "size", defaultValue = "20") @Min(1) @Max(200) int size, + @RequestParam(value = "projectId", required = false) Long projectId, + @RequestParam(value = "contractId", required = false) Long contractId, + @RequestParam(value = "keyword", required = false) String keyword) { + return deliveryBatchService.page(page, size, projectId, contractId, keyword); + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public DeliveryBatchResponse create(@Valid @RequestBody DeliveryBatchCreateRequest request) { + return deliveryBatchService.create(request); + } + + @GetMapping("/{id}") + public DeliveryBatchResponse get(@PathVariable("id") long id) { + return deliveryBatchService.getById(id); + } + + @PutMapping("/{id}") + public DeliveryBatchResponse update( + @PathVariable("id") long id, @Valid @RequestBody DeliveryBatchUpdateRequest request) { + return deliveryBatchService.update(id, request); + } + + @PatchMapping("/{id}/status") + public DeliveryBatchResponse patchStatus( + @PathVariable("id") long id, @Valid @RequestBody DeliveryBatchStatusPatchRequest request) { + return deliveryBatchService.patchStatus(id, request); + } + + @GetMapping("/{id}/lines") + public List listLines(@PathVariable("id") long batchId) { + return deliveryBatchService.listLines(batchId); + } + + @PostMapping("/{id}/lines") + @ResponseStatus(HttpStatus.CREATED) + public DeliveryLineResponse addLine( + @PathVariable("id") long batchId, @Valid @RequestBody DeliveryLineRequest request) { + return deliveryBatchService.addLine(batchId, request); + } + + @PutMapping("/{id}/lines/{lineId}") + public DeliveryLineResponse updateLine( + @PathVariable("id") long batchId, + @PathVariable("lineId") long lineId, + @Valid @RequestBody DeliveryLineRequest request) { + return deliveryBatchService.updateLine(batchId, lineId, request); + } + + @DeleteMapping("/{id}/lines/{lineId}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void deleteLine( + @PathVariable("id") long batchId, @PathVariable("lineId") long lineId) { + deliveryBatchService.deleteLine(batchId, lineId); + } +} diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/domain/DeliveryBatchStatus.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/domain/DeliveryBatchStatus.java new file mode 100644 index 0000000..17541bf --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/domain/DeliveryBatchStatus.java @@ -0,0 +1,8 @@ +package cn.craftlabs.platform.api.domain; + +/** M3:交付批次状态(P0)。 */ +public enum DeliveryBatchStatus { + PENDING, + DELIVERED, + CANCELLED; +} diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/domain/LicenseSnStatus.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/domain/LicenseSnStatus.java new file mode 100644 index 0000000..1d81369 --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/domain/LicenseSnStatus.java @@ -0,0 +1,10 @@ +package cn.craftlabs.platform.api.domain; + +/** M4:SN 生命周期状态(P0 子集)。 */ +public enum LicenseSnStatus { + REGISTERED, + ISSUED, + ACTIVATED, + SUSPENDED, + REVOKED; +} diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/license/LicenseSnController.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/license/LicenseSnController.java new file mode 100644 index 0000000..1954809 --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/license/LicenseSnController.java @@ -0,0 +1,68 @@ +package cn.craftlabs.platform.api.license; + +import cn.craftlabs.platform.api.service.LicenseSnService; +import cn.craftlabs.platform.api.web.dto.LicenseSnCreateRequest; +import cn.craftlabs.platform.api.web.dto.LicenseSnResponse; +import cn.craftlabs.platform.api.web.dto.LicenseSnStatusPatchRequest; +import cn.craftlabs.platform.api.web.dto.LicenseSnUpdateRequest; +import cn.craftlabs.platform.api.web.dto.PageResponse; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import org.springframework.http.HttpStatus; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/license-sns") +@Validated +public class LicenseSnController { + + private final LicenseSnService licenseSnService; + + public LicenseSnController(LicenseSnService licenseSnService) { + this.licenseSnService = licenseSnService; + } + + @GetMapping + public PageResponse list( + @RequestParam(value = "page", defaultValue = "0") @Min(0) int page, + @RequestParam(value = "size", defaultValue = "20") @Min(1) @Max(200) int size, + @RequestParam(value = "projectId", required = false) Long projectId, + @RequestParam(value = "keyword", required = false) String keyword, + @RequestParam(value = "status", required = false) String status) { + return licenseSnService.page(page, size, projectId, keyword, status); + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public LicenseSnResponse create(@Valid @RequestBody LicenseSnCreateRequest request) { + return licenseSnService.create(request); + } + + @GetMapping("/{id}") + public LicenseSnResponse get(@PathVariable("id") long id) { + return licenseSnService.getById(id); + } + + @PutMapping("/{id}") + public LicenseSnResponse update( + @PathVariable("id") long id, @Valid @RequestBody LicenseSnUpdateRequest request) { + return licenseSnService.update(id, request); + } + + @PatchMapping("/{id}/status") + public LicenseSnResponse patchStatus( + @PathVariable("id") long id, @Valid @RequestBody LicenseSnStatusPatchRequest request) { + return licenseSnService.patchStatus(id, request); + } +} diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/delivery/PlatformDeliveryBatch.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/delivery/PlatformDeliveryBatch.java new file mode 100644 index 0000000..8e0d025 --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/delivery/PlatformDeliveryBatch.java @@ -0,0 +1,121 @@ +package cn.craftlabs.platform.api.persistence.delivery; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; + +import java.time.LocalDate; +import java.time.OffsetDateTime; + +@TableName("platform_delivery_batch") +public class PlatformDeliveryBatch { + + @TableId(type = IdType.AUTO) + private Long id; + + @TableField("project_id") + private Long projectId; + + @TableField("contract_id") + private Long contractId; + + @TableField("batch_code") + private String batchCode; + + @TableField("planned_delivery_date") + private LocalDate plannedDeliveryDate; + + private String status; + + @TableField("finished_at") + private OffsetDateTime finishedAt; + + private String remarks; + + @TableField("created_at") + private OffsetDateTime createdAt; + + @TableField("updated_at") + private OffsetDateTime updatedAt; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Long getProjectId() { + return projectId; + } + + public void setProjectId(Long projectId) { + this.projectId = projectId; + } + + public Long getContractId() { + return contractId; + } + + public void setContractId(Long contractId) { + this.contractId = contractId; + } + + public String getBatchCode() { + return batchCode; + } + + public void setBatchCode(String batchCode) { + this.batchCode = batchCode; + } + + public LocalDate getPlannedDeliveryDate() { + return plannedDeliveryDate; + } + + public void setPlannedDeliveryDate(LocalDate plannedDeliveryDate) { + this.plannedDeliveryDate = plannedDeliveryDate; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public OffsetDateTime getFinishedAt() { + return finishedAt; + } + + public void setFinishedAt(OffsetDateTime finishedAt) { + this.finishedAt = finishedAt; + } + + public String getRemarks() { + return remarks; + } + + public void setRemarks(String remarks) { + this.remarks = remarks; + } + + public OffsetDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(OffsetDateTime createdAt) { + this.createdAt = createdAt; + } + + public OffsetDateTime getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(OffsetDateTime updatedAt) { + this.updatedAt = updatedAt; + } +} diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/delivery/PlatformDeliveryBatchMapper.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/delivery/PlatformDeliveryBatchMapper.java new file mode 100644 index 0000000..121eac3 --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/delivery/PlatformDeliveryBatchMapper.java @@ -0,0 +1,7 @@ +package cn.craftlabs.platform.api.persistence.delivery; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface PlatformDeliveryBatchMapper extends BaseMapper {} diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/delivery/PlatformDeliveryLine.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/delivery/PlatformDeliveryLine.java new file mode 100644 index 0000000..9318565 --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/delivery/PlatformDeliveryLine.java @@ -0,0 +1,99 @@ +package cn.craftlabs.platform.api.persistence.delivery; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; + +import java.math.BigDecimal; +import java.time.OffsetDateTime; + +@TableName("platform_delivery_line") +public class PlatformDeliveryLine { + + @TableId(type = IdType.AUTO) + private Long id; + + @TableField("batch_id") + private Long batchId; + + @TableField("sort_order") + private Integer sortOrder; + + private String description; + + private BigDecimal quantity; + + @TableField("contract_line_id") + private Long contractLineId; + + @TableField("created_at") + private OffsetDateTime createdAt; + + @TableField("updated_at") + private OffsetDateTime updatedAt; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Long getBatchId() { + return batchId; + } + + public void setBatchId(Long batchId) { + this.batchId = batchId; + } + + public Integer getSortOrder() { + return sortOrder; + } + + public void setSortOrder(Integer sortOrder) { + this.sortOrder = sortOrder; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public BigDecimal getQuantity() { + return quantity; + } + + public void setQuantity(BigDecimal quantity) { + this.quantity = quantity; + } + + public Long getContractLineId() { + return contractLineId; + } + + public void setContractLineId(Long contractLineId) { + this.contractLineId = contractLineId; + } + + public OffsetDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(OffsetDateTime createdAt) { + this.createdAt = createdAt; + } + + public OffsetDateTime getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(OffsetDateTime updatedAt) { + this.updatedAt = updatedAt; + } +} diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/delivery/PlatformDeliveryLineMapper.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/delivery/PlatformDeliveryLineMapper.java new file mode 100644 index 0000000..864d3a1 --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/delivery/PlatformDeliveryLineMapper.java @@ -0,0 +1,7 @@ +package cn.craftlabs.platform.api.persistence.delivery; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface PlatformDeliveryLineMapper extends BaseMapper {} diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/license/PlatformLicenseSn.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/license/PlatformLicenseSn.java new file mode 100644 index 0000000..3b31655 --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/license/PlatformLicenseSn.java @@ -0,0 +1,99 @@ +package cn.craftlabs.platform.api.persistence.license; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; + +import java.time.OffsetDateTime; + +@TableName("platform_license_sn") +public class PlatformLicenseSn { + + @TableId(type = IdType.AUTO) + private Long id; + + @TableField("sn_code") + private String snCode; + + @TableField("project_id") + private Long projectId; + + @TableField("contract_line_id") + private Long contractLineId; + + private String status; + + @TableField("activation_remark") + private String activationRemark; + + @TableField("created_at") + private OffsetDateTime createdAt; + + @TableField("updated_at") + private OffsetDateTime updatedAt; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getSnCode() { + return snCode; + } + + public void setSnCode(String snCode) { + this.snCode = snCode; + } + + public Long getProjectId() { + return projectId; + } + + public void setProjectId(Long projectId) { + this.projectId = projectId; + } + + public Long getContractLineId() { + return contractLineId; + } + + public void setContractLineId(Long contractLineId) { + this.contractLineId = contractLineId; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public String getActivationRemark() { + return activationRemark; + } + + public void setActivationRemark(String activationRemark) { + this.activationRemark = activationRemark; + } + + public OffsetDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(OffsetDateTime createdAt) { + this.createdAt = createdAt; + } + + public OffsetDateTime getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(OffsetDateTime updatedAt) { + this.updatedAt = updatedAt; + } +} diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/license/PlatformLicenseSnMapper.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/license/PlatformLicenseSnMapper.java new file mode 100644 index 0000000..c90fe74 --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/license/PlatformLicenseSnMapper.java @@ -0,0 +1,7 @@ +package cn.craftlabs.platform.api.persistence.license; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface PlatformLicenseSnMapper extends BaseMapper {} diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/service/DeliveryBatchService.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/service/DeliveryBatchService.java new file mode 100644 index 0000000..930894e --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/service/DeliveryBatchService.java @@ -0,0 +1,453 @@ +package cn.craftlabs.platform.api.service; + +import cn.craftlabs.platform.api.audit.AuditActions; +import cn.craftlabs.platform.api.audit.AuditEntityTypes; +import cn.craftlabs.platform.api.domain.DeliveryBatchStatus; +import cn.craftlabs.platform.api.persistence.contract.PlatformContract; +import cn.craftlabs.platform.api.persistence.contract.PlatformContractLine; +import cn.craftlabs.platform.api.persistence.contract.PlatformContractLineMapper; +import cn.craftlabs.platform.api.persistence.contract.PlatformContractMapper; +import cn.craftlabs.platform.api.persistence.delivery.PlatformDeliveryBatch; +import cn.craftlabs.platform.api.persistence.delivery.PlatformDeliveryBatchMapper; +import cn.craftlabs.platform.api.persistence.delivery.PlatformDeliveryLine; +import cn.craftlabs.platform.api.persistence.delivery.PlatformDeliveryLineMapper; +import cn.craftlabs.platform.api.persistence.project.PlatformProject; +import cn.craftlabs.platform.api.persistence.project.PlatformProjectMapper; +import cn.craftlabs.platform.api.web.dto.DeliveryBatchCreateRequest; +import cn.craftlabs.platform.api.web.dto.DeliveryBatchResponse; +import cn.craftlabs.platform.api.web.dto.DeliveryBatchStatusPatchRequest; +import cn.craftlabs.platform.api.web.dto.DeliveryBatchUpdateRequest; +import cn.craftlabs.platform.api.web.dto.DeliveryLineRequest; +import cn.craftlabs.platform.api.web.dto.DeliveryLineResponse; +import cn.craftlabs.platform.api.web.dto.PageResponse; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import org.springframework.web.server.ResponseStatusException; + +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeParseException; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Service +public class DeliveryBatchService { + + private final PlatformDeliveryBatchMapper batchMapper; + private final PlatformDeliveryLineMapper lineMapper; + private final PlatformProjectMapper projectMapper; + private final PlatformContractMapper contractMapper; + private final PlatformContractLineMapper contractLineMapper; + private final AuditService auditService; + private final ObjectMapper objectMapper; + + public DeliveryBatchService( + PlatformDeliveryBatchMapper batchMapper, + PlatformDeliveryLineMapper lineMapper, + PlatformProjectMapper projectMapper, + PlatformContractMapper contractMapper, + PlatformContractLineMapper contractLineMapper, + AuditService auditService, + ObjectMapper objectMapper) { + this.batchMapper = batchMapper; + this.lineMapper = lineMapper; + this.projectMapper = projectMapper; + this.contractMapper = contractMapper; + this.contractLineMapper = contractLineMapper; + this.auditService = auditService; + this.objectMapper = objectMapper; + } + + @Transactional + public DeliveryBatchResponse create(DeliveryBatchCreateRequest request) { + requireProject(request.getProjectId()); + Long contractId = request.getContractId(); + if (contractId != null) { + PlatformContract c = requireContract(contractId); + if (!c.getProjectId().equals(request.getProjectId())) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "contract.projectId must match batch projectId"); + } + } + String code = request.getBatchCode().trim(); + if (existsBatchCode(code)) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "duplicate batch code"); + } + OffsetDateTime now = OffsetDateTime.now(ZoneOffset.UTC); + PlatformDeliveryBatch b = new PlatformDeliveryBatch(); + b.setProjectId(request.getProjectId()); + b.setContractId(contractId); + b.setBatchCode(code); + b.setPlannedDeliveryDate(parsePlannedDateOrNull(request.getPlannedDeliveryDate())); + b.setStatus(DeliveryBatchStatus.PENDING.name()); + b.setRemarks(blankToNull(request.getRemarks())); + b.setCreatedAt(now); + b.setUpdatedAt(now); + batchMapper.insert(b); + auditService.record( + AuditEntityTypes.DELIVERY_BATCH, + b.getId(), + AuditActions.DELIVERY_BATCH_CREATED, + null, + null, + toJson(batchSnapshot(b))); + return toResponse(b); + } + + @Transactional(readOnly = true) + public PageResponse page( + int page, int size, Long projectId, Long contractId, String keyword) { + String kw = StringUtils.hasText(keyword) ? keyword.trim() : null; + LambdaQueryWrapper q = + Wrappers.lambdaQuery(PlatformDeliveryBatch.class) + .eq(projectId != null, PlatformDeliveryBatch::getProjectId, projectId) + .eq(contractId != null, PlatformDeliveryBatch::getContractId, contractId) + .like(kw != null, PlatformDeliveryBatch::getBatchCode, kw) + .orderByDesc(PlatformDeliveryBatch::getId); + Page mpPage = new Page<>(page + 1L, size); + batchMapper.selectPage(mpPage, q); + List content = + mpPage.getRecords().stream().map(this::toResponse).collect(Collectors.toList()); + return new PageResponse<>(content, mpPage.getTotal(), page, size); + } + + @Transactional(readOnly = true) + public DeliveryBatchResponse getById(long id) { + PlatformDeliveryBatch b = requireBatch(id); + DeliveryBatchResponse r = toResponse(b); + r.setLines(listLines(id)); + return r; + } + + @Transactional + public DeliveryBatchResponse update(long id, DeliveryBatchUpdateRequest request) { + PlatformDeliveryBatch b = requireBatch(id); + requirePendingForHeaderEdit(b); + if (request.getPlannedDeliveryDate() == null && request.getRemarks() == null) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, + "at least one of plannedDeliveryDate or remarks must be provided"); + } + String oldJson = toJson(batchSnapshot(b)); + if (request.getPlannedDeliveryDate() != null) { + b.setPlannedDeliveryDate(parsePlannedDateOrNull(request.getPlannedDeliveryDate())); + } + if (request.getRemarks() != null) { + b.setRemarks(blankToNull(request.getRemarks())); + } + b.setUpdatedAt(OffsetDateTime.now(ZoneOffset.UTC)); + batchMapper.updateById(b); + auditService.record( + AuditEntityTypes.DELIVERY_BATCH, + id, + AuditActions.DELIVERY_BATCH_UPDATED, + null, + oldJson, + toJson(batchSnapshot(b))); + return toResponse(b); + } + + @Transactional + public DeliveryBatchResponse patchStatus(long id, DeliveryBatchStatusPatchRequest request) { + PlatformDeliveryBatch b = requireBatch(id); + DeliveryBatchStatus from = parseBatchStatus(b.getStatus()); + DeliveryBatchStatus to = parseBatchStatusOrBadRequest(request.getStatus()); + if (from == to) { + return toResponse(b); + } + if (from != DeliveryBatchStatus.PENDING) { + throw new ResponseStatusException( + HttpStatus.CONFLICT, "delivery batch status can only change from PENDING"); + } + if (to != DeliveryBatchStatus.DELIVERED && to != DeliveryBatchStatus.CANCELLED) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "only DELIVERED or CANCELLED allowed from PENDING"); + } + String oldJson = toJson(Map.of("status", from.name())); + b.setStatus(to.name()); + if (to == DeliveryBatchStatus.DELIVERED) { + b.setFinishedAt(OffsetDateTime.now(ZoneOffset.UTC)); + } else { + b.setFinishedAt(null); + } + b.setUpdatedAt(OffsetDateTime.now(ZoneOffset.UTC)); + batchMapper.updateById(b); + auditService.record( + AuditEntityTypes.DELIVERY_BATCH, + id, + AuditActions.DELIVERY_BATCH_STATUS_CHANGED, + "status", + oldJson, + toJson(Map.of("status", to.name()))); + return toResponse(b); + } + + @Transactional(readOnly = true) + public List listLines(long batchId) { + requireBatch(batchId); + return selectLines(batchId); + } + + @Transactional + public DeliveryLineResponse addLine(long batchId, DeliveryLineRequest request) { + PlatformDeliveryBatch b = requireBatch(batchId); + requirePendingForLineMutation(b); + validateContractLineForBatch(b, request.getContractLineId()); + OffsetDateTime now = OffsetDateTime.now(ZoneOffset.UTC); + PlatformDeliveryLine line = new PlatformDeliveryLine(); + line.setBatchId(batchId); + line.setSortOrder(resolveSortOrder(batchId, request.getSortOrder())); + line.setDescription(request.getDescription().trim()); + line.setQuantity(request.getQuantity()); + line.setContractLineId(request.getContractLineId()); + line.setCreatedAt(now); + line.setUpdatedAt(now); + lineMapper.insert(line); + auditService.record( + AuditEntityTypes.DELIVERY_BATCH, + batchId, + AuditActions.DELIVERY_LINE_ADDED, + null, + null, + toJson(lineSnapshot(line))); + return toLineResponse(line); + } + + @Transactional + public DeliveryLineResponse updateLine(long batchId, long lineId, DeliveryLineRequest request) { + PlatformDeliveryBatch b = requireBatch(batchId); + requirePendingForLineMutation(b); + PlatformDeliveryLine line = requireLine(batchId, lineId); + validateContractLineForBatch(b, request.getContractLineId()); + String oldJson = toJson(lineSnapshot(line)); + line.setSortOrder(resolveSortOrder(batchId, request.getSortOrder(), line.getSortOrder())); + line.setDescription(request.getDescription().trim()); + line.setQuantity(request.getQuantity()); + line.setContractLineId(request.getContractLineId()); + line.setUpdatedAt(OffsetDateTime.now(ZoneOffset.UTC)); + lineMapper.updateById(line); + auditService.record( + AuditEntityTypes.DELIVERY_BATCH, + batchId, + AuditActions.DELIVERY_LINE_UPDATED, + "line:" + lineId, + oldJson, + toJson(lineSnapshot(line))); + return toLineResponse(line); + } + + @Transactional + public void deleteLine(long batchId, long lineId) { + PlatformDeliveryBatch b = requireBatch(batchId); + requirePendingForLineMutation(b); + PlatformDeliveryLine line = requireLine(batchId, lineId); + String oldJson = toJson(lineSnapshot(line)); + lineMapper.deleteById(lineId); + auditService.record( + AuditEntityTypes.DELIVERY_BATCH, + batchId, + AuditActions.DELIVERY_LINE_DELETED, + "line:" + lineId, + oldJson, + null); + } + + private List selectLines(long batchId) { + LambdaQueryWrapper q = + Wrappers.lambdaQuery(PlatformDeliveryLine.class) + .eq(PlatformDeliveryLine::getBatchId, batchId) + .orderByAsc(PlatformDeliveryLine::getSortOrder) + .orderByAsc(PlatformDeliveryLine::getId); + return lineMapper.selectList(q).stream().map(this::toLineResponse).collect(Collectors.toList()); + } + + private void requireProject(long projectId) { + if (projectMapper.selectById(projectId) == null) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "project not found"); + } + } + + private PlatformContract requireContract(long id) { + PlatformContract c = contractMapper.selectById(id); + if (c == null) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "contract not found"); + } + return c; + } + + private PlatformDeliveryBatch requireBatch(long id) { + PlatformDeliveryBatch b = batchMapper.selectById(id); + if (b == null) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "delivery batch not found"); + } + return b; + } + + private PlatformDeliveryLine requireLine(long batchId, long lineId) { + PlatformDeliveryLine line = lineMapper.selectById(lineId); + if (line == null || !line.getBatchId().equals(batchId)) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "delivery line not found"); + } + return line; + } + + private void requirePendingForHeaderEdit(PlatformDeliveryBatch b) { + if (parseBatchStatus(b.getStatus()) != DeliveryBatchStatus.PENDING) { + throw new ResponseStatusException( + HttpStatus.CONFLICT, + "delivery batch can only be updated in PENDING status"); + } + } + + private void requirePendingForLineMutation(PlatformDeliveryBatch b) { + requirePendingForHeaderEdit(b); + } + + private void validateContractLineForBatch(PlatformDeliveryBatch batch, Long contractLineId) { + if (contractLineId == null) { + return; + } + PlatformContractLine cl = contractLineMapper.selectById(contractLineId); + if (cl == null) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "contract line not found"); + } + PlatformContract c = requireContract(cl.getContractId()); + if (!c.getProjectId().equals(batch.getProjectId())) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, + "contract line must belong to a contract in the same project as the batch"); + } + } + + private boolean existsBatchCode(String batchCode) { + return batchMapper.selectCount( + Wrappers.lambdaQuery(PlatformDeliveryBatch.class) + .eq(PlatformDeliveryBatch::getBatchCode, batchCode)) + > 0; + } + + private static DeliveryBatchStatus parseBatchStatus(String raw) { + try { + return DeliveryBatchStatus.valueOf(raw); + } catch (Exception e) { + throw new ResponseStatusException( + HttpStatus.INTERNAL_SERVER_ERROR, + "invalid delivery batch status stored: " + raw); + } + } + + private static DeliveryBatchStatus parseBatchStatusOrBadRequest(String raw) { + try { + return DeliveryBatchStatus.valueOf(raw.trim()); + } catch (Exception e) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "unknown delivery batch status: " + raw); + } + } + + private int resolveSortOrder(long batchId, Integer requested) { + return resolveSortOrder(batchId, requested, null); + } + + private int resolveSortOrder(long batchId, Integer requested, Integer fallbackExisting) { + if (requested != null) { + return requested; + } + if (fallbackExisting != null) { + return fallbackExisting; + } + LambdaQueryWrapper q = + Wrappers.lambdaQuery(PlatformDeliveryLine.class) + .eq(PlatformDeliveryLine::getBatchId, batchId) + .orderByDesc(PlatformDeliveryLine::getSortOrder) + .last("LIMIT 1"); + PlatformDeliveryLine last = lineMapper.selectOne(q); + return last == null || last.getSortOrder() == null ? 0 : last.getSortOrder() + 1; + } + + private static LocalDate parsePlannedDateOrNull(String raw) { + if (!StringUtils.hasText(raw)) { + return null; + } + try { + return LocalDate.parse(raw.trim()); + } catch (DateTimeParseException e) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "invalid plannedDeliveryDate, expected yyyy-MM-dd"); + } + } + + private static String blankToNull(String s) { + return StringUtils.hasText(s) ? s.trim() : null; + } + + private Map batchSnapshot(PlatformDeliveryBatch b) { + Map m = new LinkedHashMap<>(); + m.put("id", b.getId()); + m.put("projectId", b.getProjectId()); + m.put("contractId", b.getContractId()); + m.put("batchCode", b.getBatchCode()); + m.put("plannedDeliveryDate", b.getPlannedDeliveryDate() != null ? b.getPlannedDeliveryDate().toString() : null); + m.put("status", b.getStatus()); + m.put("finishedAt", b.getFinishedAt()); + m.put("remarks", b.getRemarks()); + return m; + } + + private Map lineSnapshot(PlatformDeliveryLine line) { + Map m = new LinkedHashMap<>(); + m.put("id", line.getId()); + m.put("batchId", line.getBatchId()); + m.put("sortOrder", line.getSortOrder()); + m.put("description", line.getDescription()); + m.put("quantity", line.getQuantity()); + m.put("contractLineId", line.getContractLineId()); + return m; + } + + private String toJson(Object value) { + try { + return objectMapper.writeValueAsString(value); + } catch (JsonProcessingException e) { + throw new IllegalStateException(e); + } + } + + private DeliveryBatchResponse toResponse(PlatformDeliveryBatch b) { + DeliveryBatchResponse r = new DeliveryBatchResponse(); + r.setId(b.getId()); + r.setProjectId(b.getProjectId()); + r.setContractId(b.getContractId()); + r.setBatchCode(b.getBatchCode()); + r.setPlannedDeliveryDate(b.getPlannedDeliveryDate()); + r.setStatus(b.getStatus()); + r.setFinishedAt(b.getFinishedAt()); + r.setRemarks(b.getRemarks()); + r.setCreatedAt(b.getCreatedAt()); + r.setUpdatedAt(b.getUpdatedAt()); + return r; + } + + private DeliveryLineResponse toLineResponse(PlatformDeliveryLine line) { + DeliveryLineResponse r = new DeliveryLineResponse(); + r.setId(line.getId()); + r.setBatchId(line.getBatchId()); + r.setSortOrder(line.getSortOrder()); + r.setDescription(line.getDescription()); + r.setQuantity(line.getQuantity()); + r.setContractLineId(line.getContractLineId()); + r.setCreatedAt(line.getCreatedAt()); + r.setUpdatedAt(line.getUpdatedAt()); + return r; + } +} diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/service/LicenseSnService.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/service/LicenseSnService.java new file mode 100644 index 0000000..5a5e8b5 --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/service/LicenseSnService.java @@ -0,0 +1,321 @@ +package cn.craftlabs.platform.api.service; + +import cn.craftlabs.platform.api.audit.AuditActions; +import cn.craftlabs.platform.api.audit.AuditEntityTypes; +import cn.craftlabs.platform.api.domain.LicenseSnStatus; +import cn.craftlabs.platform.api.persistence.contract.PlatformContract; +import cn.craftlabs.platform.api.persistence.contract.PlatformContractLine; +import cn.craftlabs.platform.api.persistence.contract.PlatformContractLineMapper; +import cn.craftlabs.platform.api.persistence.contract.PlatformContractMapper; +import cn.craftlabs.platform.api.persistence.license.PlatformLicenseSn; +import cn.craftlabs.platform.api.persistence.license.PlatformLicenseSnMapper; +import cn.craftlabs.platform.api.persistence.project.PlatformProjectMapper; +import cn.craftlabs.platform.api.web.dto.LicenseSnCreateRequest; +import cn.craftlabs.platform.api.web.dto.LicenseSnResponse; +import cn.craftlabs.platform.api.web.dto.LicenseSnStatusPatchRequest; +import cn.craftlabs.platform.api.web.dto.LicenseSnUpdateRequest; +import cn.craftlabs.platform.api.web.dto.PageResponse; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import org.springframework.web.server.ResponseStatusException; + +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Service +public class LicenseSnService { + + private final PlatformLicenseSnMapper licenseSnMapper; + private final PlatformProjectMapper projectMapper; + private final PlatformContractLineMapper contractLineMapper; + private final PlatformContractMapper contractMapper; + private final AuditService auditService; + private final ObjectMapper objectMapper; + + public LicenseSnService( + PlatformLicenseSnMapper licenseSnMapper, + PlatformProjectMapper projectMapper, + PlatformContractLineMapper contractLineMapper, + PlatformContractMapper contractMapper, + AuditService auditService, + ObjectMapper objectMapper) { + this.licenseSnMapper = licenseSnMapper; + this.projectMapper = projectMapper; + this.contractLineMapper = contractLineMapper; + this.contractMapper = contractMapper; + this.auditService = auditService; + this.objectMapper = objectMapper; + } + + @Transactional + public LicenseSnResponse create(LicenseSnCreateRequest request) { + String code = request.getSnCode().trim(); + if (!StringUtils.hasText(code)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "snCode must not be blank"); + } + Long projectId = request.getProjectId(); + Long contractLineId = request.getContractLineId(); + if (projectId == null && contractLineId == null) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "projectId or contractLineId is required"); + } + if (contractLineId != null) { + long derived = projectIdFromContractLine(contractLineId); + if (projectId != null && !projectId.equals(derived)) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "projectId does not match contract line's project"); + } + projectId = derived; + } else { + requireProject(projectId); + } + if (existsSnCode(code)) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "duplicate sn code"); + } + OffsetDateTime now = OffsetDateTime.now(ZoneOffset.UTC); + PlatformLicenseSn row = new PlatformLicenseSn(); + row.setSnCode(code); + row.setProjectId(projectId); + row.setContractLineId(contractLineId); + row.setStatus(LicenseSnStatus.REGISTERED.name()); + row.setActivationRemark(blankToNull(request.getActivationRemark())); + row.setCreatedAt(now); + row.setUpdatedAt(now); + licenseSnMapper.insert(row); + auditService.record( + AuditEntityTypes.LICENSE_SN, + row.getId(), + AuditActions.LICENSE_SN_CREATED, + null, + null, + toJson(licenseSnapshot(row))); + return toResponse(row); + } + + @Transactional(readOnly = true) + public PageResponse page( + int page, int size, Long projectId, String keyword, String status) { + String kw = StringUtils.hasText(keyword) ? keyword.trim() : null; + String st = StringUtils.hasText(status) ? status.trim() : null; + if (st != null) { + parseLicenseStatusOrBadRequest(st); + } + LambdaQueryWrapper q = + Wrappers.lambdaQuery(PlatformLicenseSn.class) + .eq(projectId != null, PlatformLicenseSn::getProjectId, projectId) + .like(kw != null, PlatformLicenseSn::getSnCode, kw) + .eq(st != null, PlatformLicenseSn::getStatus, st) + .orderByDesc(PlatformLicenseSn::getId); + Page mpPage = new Page<>(page + 1L, size); + licenseSnMapper.selectPage(mpPage, q); + List content = + mpPage.getRecords().stream().map(this::toResponse).collect(Collectors.toList()); + return new PageResponse<>(content, mpPage.getTotal(), page, size); + } + + @Transactional(readOnly = true) + public LicenseSnResponse getById(long id) { + return toResponse(requireLicense(id)); + } + + @Transactional + public LicenseSnResponse update(long id, LicenseSnUpdateRequest request) { + PlatformLicenseSn row = requireLicense(id); + if (parseLicenseStatus(row.getStatus()) == LicenseSnStatus.REVOKED) { + throw new ResponseStatusException( + HttpStatus.CONFLICT, "revoked license SN cannot be updated"); + } + if (request.getProjectId() == null + && request.getContractLineId() == null + && request.getActivationRemark() == null) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, + "at least one of projectId, contractLineId, or activationRemark must be provided"); + } + String oldJson = toJson(licenseSnapshot(row)); + if (request.getContractLineId() != null) { + long derivedProject = projectIdFromContractLine(request.getContractLineId()); + row.setContractLineId(request.getContractLineId()); + row.setProjectId(derivedProject); + if (request.getProjectId() != null && !request.getProjectId().equals(derivedProject)) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "projectId does not match contract line's project"); + } + } else if (request.getProjectId() != null) { + requireProject(request.getProjectId()); + if (row.getContractLineId() != null) { + long lineProject = projectIdFromContractLine(row.getContractLineId()); + if (!request.getProjectId().equals(lineProject)) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, + "projectId does not match existing contract line binding"); + } + } + row.setProjectId(request.getProjectId()); + } + if (request.getActivationRemark() != null) { + row.setActivationRemark(blankToNull(request.getActivationRemark())); + } + if (row.getProjectId() == null && row.getContractLineId() == null) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "projectId or contractLineId must remain set"); + } + row.setUpdatedAt(OffsetDateTime.now(ZoneOffset.UTC)); + licenseSnMapper.updateById(row); + auditService.record( + AuditEntityTypes.LICENSE_SN, + id, + AuditActions.LICENSE_SN_UPDATED, + null, + oldJson, + toJson(licenseSnapshot(row))); + return toResponse(row); + } + + @Transactional + public LicenseSnResponse patchStatus(long id, LicenseSnStatusPatchRequest request) { + PlatformLicenseSn row = requireLicense(id); + LicenseSnStatus from = parseLicenseStatus(row.getStatus()); + LicenseSnStatus to = parseLicenseStatusOrBadRequest(request.getStatus()); + if (from == to) { + return toResponse(row); + } + if (from == LicenseSnStatus.REVOKED) { + throw new ResponseStatusException( + HttpStatus.CONFLICT, "revoked license SN status cannot be changed"); + } + if (!allowedLicenseTransition(from, to)) { + throw new ResponseStatusException( + HttpStatus.CONFLICT, "illegal license SN status transition"); + } + String oldJson = toJson(Map.of("status", from.name())); + row.setStatus(to.name()); + row.setUpdatedAt(OffsetDateTime.now(ZoneOffset.UTC)); + licenseSnMapper.updateById(row); + auditService.record( + AuditEntityTypes.LICENSE_SN, + id, + AuditActions.LICENSE_SN_STATUS_CHANGED, + "status", + oldJson, + toJson(Map.of("status", to.name()))); + return toResponse(row); + } + + private static boolean allowedLicenseTransition(LicenseSnStatus from, LicenseSnStatus to) { + if (to == LicenseSnStatus.REVOKED) { + return true; + } + if (from == LicenseSnStatus.REGISTERED) { + return to == LicenseSnStatus.ISSUED; + } + if (from == LicenseSnStatus.ISSUED) { + return to == LicenseSnStatus.ACTIVATED; + } + if (from == LicenseSnStatus.ACTIVATED) { + return to == LicenseSnStatus.SUSPENDED; + } + if (from == LicenseSnStatus.SUSPENDED) { + return to == LicenseSnStatus.ACTIVATED; + } + return false; + } + + private long projectIdFromContractLine(long contractLineId) { + PlatformContractLine line = contractLineMapper.selectById(contractLineId); + if (line == null) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "contract line not found"); + } + PlatformContract c = contractMapper.selectById(line.getContractId()); + if (c == null) { + throw new ResponseStatusException( + HttpStatus.INTERNAL_SERVER_ERROR, "contract missing for contract line"); + } + return c.getProjectId(); + } + + private void requireProject(long projectId) { + if (projectMapper.selectById(projectId) == null) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "project not found"); + } + } + + private PlatformLicenseSn requireLicense(long id) { + PlatformLicenseSn row = licenseSnMapper.selectById(id); + if (row == null) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "license SN not found"); + } + return row; + } + + private boolean existsSnCode(String snCode) { + return licenseSnMapper.selectCount( + Wrappers.lambdaQuery(PlatformLicenseSn.class) + .eq(PlatformLicenseSn::getSnCode, snCode)) + > 0; + } + + private static LicenseSnStatus parseLicenseStatus(String raw) { + try { + return LicenseSnStatus.valueOf(raw); + } catch (Exception e) { + throw new ResponseStatusException( + HttpStatus.INTERNAL_SERVER_ERROR, "invalid license SN status stored: " + raw); + } + } + + private static LicenseSnStatus parseLicenseStatusOrBadRequest(String raw) { + try { + return LicenseSnStatus.valueOf(raw.trim()); + } catch (Exception e) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "unknown license SN status: " + raw); + } + } + + private static String blankToNull(String s) { + return StringUtils.hasText(s) ? s.trim() : null; + } + + private Map licenseSnapshot(PlatformLicenseSn row) { + Map m = new LinkedHashMap<>(); + m.put("id", row.getId()); + m.put("snCode", row.getSnCode()); + m.put("projectId", row.getProjectId()); + m.put("contractLineId", row.getContractLineId()); + m.put("status", row.getStatus()); + m.put("activationRemark", row.getActivationRemark()); + return m; + } + + private String toJson(Object value) { + try { + return objectMapper.writeValueAsString(value); + } catch (JsonProcessingException e) { + throw new IllegalStateException(e); + } + } + + private LicenseSnResponse toResponse(PlatformLicenseSn row) { + LicenseSnResponse r = new LicenseSnResponse(); + r.setId(row.getId()); + r.setSnCode(row.getSnCode()); + r.setProjectId(row.getProjectId()); + r.setContractLineId(row.getContractLineId()); + r.setStatus(row.getStatus()); + r.setActivationRemark(row.getActivationRemark()); + r.setCreatedAt(row.getCreatedAt()); + r.setUpdatedAt(row.getUpdatedAt()); + return r; + } +} diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/DeliveryBatchCreateRequest.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/DeliveryBatchCreateRequest.java new file mode 100644 index 0000000..df38225 --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/DeliveryBatchCreateRequest.java @@ -0,0 +1,62 @@ +package cn.craftlabs.platform.api.web.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +public class DeliveryBatchCreateRequest { + + @NotNull private Long projectId; + + private Long contractId; + + @NotBlank + @Size(max = 64) + private String batchCode; + + /** ISO-8601 date string {@code yyyy-MM-dd},可选 */ + private String plannedDeliveryDate; + + @Size(max = 4000) + private String remarks; + + public Long getProjectId() { + return projectId; + } + + public void setProjectId(Long projectId) { + this.projectId = projectId; + } + + public Long getContractId() { + return contractId; + } + + public void setContractId(Long contractId) { + this.contractId = contractId; + } + + public String getBatchCode() { + return batchCode; + } + + public void setBatchCode(String batchCode) { + this.batchCode = batchCode; + } + + public String getPlannedDeliveryDate() { + return plannedDeliveryDate; + } + + public void setPlannedDeliveryDate(String plannedDeliveryDate) { + this.plannedDeliveryDate = plannedDeliveryDate; + } + + public String getRemarks() { + return remarks; + } + + public void setRemarks(String remarks) { + this.remarks = remarks; + } +} diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/DeliveryBatchResponse.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/DeliveryBatchResponse.java new file mode 100644 index 0000000..14a52d3 --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/DeliveryBatchResponse.java @@ -0,0 +1,112 @@ +package cn.craftlabs.platform.api.web.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.util.List; + +public class DeliveryBatchResponse { + + private Long id; + private Long projectId; + private Long contractId; + private String batchCode; + private LocalDate plannedDeliveryDate; + private String status; + private OffsetDateTime finishedAt; + private String remarks; + private OffsetDateTime createdAt; + private OffsetDateTime updatedAt; + + @JsonInclude(JsonInclude.Include.NON_NULL) + private List lines; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Long getProjectId() { + return projectId; + } + + public void setProjectId(Long projectId) { + this.projectId = projectId; + } + + public Long getContractId() { + return contractId; + } + + public void setContractId(Long contractId) { + this.contractId = contractId; + } + + public String getBatchCode() { + return batchCode; + } + + public void setBatchCode(String batchCode) { + this.batchCode = batchCode; + } + + public LocalDate getPlannedDeliveryDate() { + return plannedDeliveryDate; + } + + public void setPlannedDeliveryDate(LocalDate plannedDeliveryDate) { + this.plannedDeliveryDate = plannedDeliveryDate; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public OffsetDateTime getFinishedAt() { + return finishedAt; + } + + public void setFinishedAt(OffsetDateTime finishedAt) { + this.finishedAt = finishedAt; + } + + public String getRemarks() { + return remarks; + } + + public void setRemarks(String remarks) { + this.remarks = remarks; + } + + public OffsetDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(OffsetDateTime createdAt) { + this.createdAt = createdAt; + } + + public OffsetDateTime getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(OffsetDateTime updatedAt) { + this.updatedAt = updatedAt; + } + + public List getLines() { + return lines; + } + + public void setLines(List lines) { + this.lines = lines; + } +} diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/DeliveryBatchStatusPatchRequest.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/DeliveryBatchStatusPatchRequest.java new file mode 100644 index 0000000..3bb2d4f --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/DeliveryBatchStatusPatchRequest.java @@ -0,0 +1,17 @@ +package cn.craftlabs.platform.api.web.dto; + +import jakarta.validation.constraints.NotBlank; + +public class DeliveryBatchStatusPatchRequest { + + @NotBlank + private String status; + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } +} diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/DeliveryBatchUpdateRequest.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/DeliveryBatchUpdateRequest.java new file mode 100644 index 0000000..d1b1708 --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/DeliveryBatchUpdateRequest.java @@ -0,0 +1,27 @@ +package cn.craftlabs.platform.api.web.dto; + +import jakarta.validation.constraints.Size; + +public class DeliveryBatchUpdateRequest { + + private String plannedDeliveryDate; + + @Size(max = 4000) + private String remarks; + + public String getPlannedDeliveryDate() { + return plannedDeliveryDate; + } + + public void setPlannedDeliveryDate(String plannedDeliveryDate) { + this.plannedDeliveryDate = plannedDeliveryDate; + } + + public String getRemarks() { + return remarks; + } + + public void setRemarks(String remarks) { + this.remarks = remarks; + } +} diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/DeliveryLineRequest.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/DeliveryLineRequest.java new file mode 100644 index 0000000..a926053 --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/DeliveryLineRequest.java @@ -0,0 +1,55 @@ +package cn.craftlabs.platform.api.web.dto; + +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import java.math.BigDecimal; + +public class DeliveryLineRequest { + + private Integer sortOrder; + + @NotBlank + @Size(max = 512) + private String description; + + @NotNull + @DecimalMin(value = "0.0001", inclusive = true) + private BigDecimal quantity; + + private Long contractLineId; + + public Integer getSortOrder() { + return sortOrder; + } + + public void setSortOrder(Integer sortOrder) { + this.sortOrder = sortOrder; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public BigDecimal getQuantity() { + return quantity; + } + + public void setQuantity(BigDecimal quantity) { + this.quantity = quantity; + } + + public Long getContractLineId() { + return contractLineId; + } + + public void setContractLineId(Long contractLineId) { + this.contractLineId = contractLineId; + } +} diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/DeliveryLineResponse.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/DeliveryLineResponse.java new file mode 100644 index 0000000..906852e --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/DeliveryLineResponse.java @@ -0,0 +1,80 @@ +package cn.craftlabs.platform.api.web.dto; + +import java.math.BigDecimal; +import java.time.OffsetDateTime; + +public class DeliveryLineResponse { + + private Long id; + private Long batchId; + private Integer sortOrder; + private String description; + private BigDecimal quantity; + private Long contractLineId; + private OffsetDateTime createdAt; + private OffsetDateTime updatedAt; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Long getBatchId() { + return batchId; + } + + public void setBatchId(Long batchId) { + this.batchId = batchId; + } + + public Integer getSortOrder() { + return sortOrder; + } + + public void setSortOrder(Integer sortOrder) { + this.sortOrder = sortOrder; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public BigDecimal getQuantity() { + return quantity; + } + + public void setQuantity(BigDecimal quantity) { + this.quantity = quantity; + } + + public Long getContractLineId() { + return contractLineId; + } + + public void setContractLineId(Long contractLineId) { + this.contractLineId = contractLineId; + } + + public OffsetDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(OffsetDateTime createdAt) { + this.createdAt = createdAt; + } + + public OffsetDateTime getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(OffsetDateTime updatedAt) { + this.updatedAt = updatedAt; + } +} diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/LicenseSnCreateRequest.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/LicenseSnCreateRequest.java new file mode 100644 index 0000000..67ca2fb --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/LicenseSnCreateRequest.java @@ -0,0 +1,49 @@ +package cn.craftlabs.platform.api.web.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public class LicenseSnCreateRequest { + + @NotBlank + @Size(max = 128) + private String snCode; + + private Long projectId; + private Long contractLineId; + + @Size(max = 512) + private String activationRemark; + + public String getSnCode() { + return snCode; + } + + public void setSnCode(String snCode) { + this.snCode = snCode; + } + + public Long getProjectId() { + return projectId; + } + + public void setProjectId(Long projectId) { + this.projectId = projectId; + } + + public Long getContractLineId() { + return contractLineId; + } + + public void setContractLineId(Long contractLineId) { + this.contractLineId = contractLineId; + } + + public String getActivationRemark() { + return activationRemark; + } + + public void setActivationRemark(String activationRemark) { + this.activationRemark = activationRemark; + } +} diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/LicenseSnResponse.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/LicenseSnResponse.java new file mode 100644 index 0000000..d53b667 --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/LicenseSnResponse.java @@ -0,0 +1,79 @@ +package cn.craftlabs.platform.api.web.dto; + +import java.time.OffsetDateTime; + +public class LicenseSnResponse { + + private Long id; + private String snCode; + private Long projectId; + private Long contractLineId; + private String status; + private String activationRemark; + private OffsetDateTime createdAt; + private OffsetDateTime updatedAt; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getSnCode() { + return snCode; + } + + public void setSnCode(String snCode) { + this.snCode = snCode; + } + + public Long getProjectId() { + return projectId; + } + + public void setProjectId(Long projectId) { + this.projectId = projectId; + } + + public Long getContractLineId() { + return contractLineId; + } + + public void setContractLineId(Long contractLineId) { + this.contractLineId = contractLineId; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public String getActivationRemark() { + return activationRemark; + } + + public void setActivationRemark(String activationRemark) { + this.activationRemark = activationRemark; + } + + public OffsetDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(OffsetDateTime createdAt) { + this.createdAt = createdAt; + } + + public OffsetDateTime getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(OffsetDateTime updatedAt) { + this.updatedAt = updatedAt; + } +} diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/LicenseSnStatusPatchRequest.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/LicenseSnStatusPatchRequest.java new file mode 100644 index 0000000..44105a2 --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/LicenseSnStatusPatchRequest.java @@ -0,0 +1,17 @@ +package cn.craftlabs.platform.api.web.dto; + +import jakarta.validation.constraints.NotBlank; + +public class LicenseSnStatusPatchRequest { + + @NotBlank + private String status; + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } +} diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/LicenseSnUpdateRequest.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/LicenseSnUpdateRequest.java new file mode 100644 index 0000000..1516246 --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/LicenseSnUpdateRequest.java @@ -0,0 +1,37 @@ +package cn.craftlabs.platform.api.web.dto; + +import jakarta.validation.constraints.Size; + +/** 更新绑定关系或激活备注(非 TERMINAL 状态下允许调整,具体见服务校验)。 */ +public class LicenseSnUpdateRequest { + + private Long projectId; + private Long contractLineId; + + @Size(max = 512) + private String activationRemark; + + public Long getProjectId() { + return projectId; + } + + public void setProjectId(Long projectId) { + this.projectId = projectId; + } + + public Long getContractLineId() { + return contractLineId; + } + + public void setContractLineId(Long contractLineId) { + this.contractLineId = contractLineId; + } + + public String getActivationRemark() { + return activationRemark; + } + + public void setActivationRemark(String activationRemark) { + this.activationRemark = activationRemark; + } +} diff --git a/services/delivery-platform-api/src/main/resources/db/migration/V4__delivery_batch_and_license_sn.sql b/services/delivery-platform-api/src/main/resources/db/migration/V4__delivery_batch_and_license_sn.sql new file mode 100644 index 0000000..781ada3 --- /dev/null +++ b/services/delivery-platform-api/src/main/resources/db/migration/V4__delivery_batch_and_license_sn.sql @@ -0,0 +1,45 @@ +-- I4:M3 交付批次与清单;M4 许可 SN 台账(PostgreSQL 15;H2 MODE=PostgreSQL) +CREATE TABLE platform_delivery_batch ( + id BIGSERIAL PRIMARY KEY, + project_id BIGINT NOT NULL REFERENCES platform_project (id), + contract_id BIGINT REFERENCES platform_contract (id), + batch_code VARCHAR(64) NOT NULL, + planned_delivery_date DATE, + status VARCHAR(32) NOT NULL DEFAULT 'PENDING', + finished_at TIMESTAMP WITH TIME ZONE, + remarks TEXT, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uq_platform_delivery_batch_code UNIQUE (batch_code) +); + +CREATE INDEX idx_platform_delivery_batch_project ON platform_delivery_batch (project_id); +CREATE INDEX idx_platform_delivery_batch_contract ON platform_delivery_batch (contract_id); + +CREATE TABLE platform_delivery_line ( + id BIGSERIAL PRIMARY KEY, + batch_id BIGINT NOT NULL REFERENCES platform_delivery_batch (id) ON DELETE CASCADE, + sort_order INT NOT NULL DEFAULT 0, + description VARCHAR(512) NOT NULL, + quantity NUMERIC(18, 4) NOT NULL DEFAULT 1, + contract_line_id BIGINT REFERENCES platform_contract_line (id), + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_platform_delivery_line_batch ON platform_delivery_line (batch_id); + +CREATE TABLE platform_license_sn ( + id BIGSERIAL PRIMARY KEY, + sn_code VARCHAR(128) NOT NULL, + project_id BIGINT REFERENCES platform_project (id), + contract_line_id BIGINT REFERENCES platform_contract_line (id), + status VARCHAR(32) NOT NULL DEFAULT 'REGISTERED', + activation_remark VARCHAR(512), + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uq_platform_license_sn_code UNIQUE (sn_code) +); + +CREATE INDEX idx_platform_license_sn_project ON platform_license_sn (project_id); +CREATE INDEX idx_platform_license_sn_contract_line ON platform_license_sn (contract_line_id); diff --git a/services/delivery-platform-api/src/test/java/cn/craftlabs/platform/api/delivery/DeliveryBatchControllerTest.java b/services/delivery-platform-api/src/test/java/cn/craftlabs/platform/api/delivery/DeliveryBatchControllerTest.java new file mode 100644 index 0000000..1075b32 --- /dev/null +++ b/services/delivery-platform-api/src/test/java/cn/craftlabs/platform/api/delivery/DeliveryBatchControllerTest.java @@ -0,0 +1,250 @@ +package cn.craftlabs.platform.api.delivery; + +import cn.craftlabs.platform.api.support.JwtTestSupport; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +class DeliveryBatchControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Test + void deliveryBatchLinesStatusAuditAndGuards() throws Exception { + String token = JwtTestSupport.obtainBearerToken(mockMvc, objectMapper); + String auth = "Bearer " + token; + + String customerBody = "{\"name\":\"交付客户\",\"creditCode\":\"DB001\",\"status\":\"ACTIVE\"}"; + String customerJson = + mockMvc.perform( + post("/api/v1/customers") + .header("Authorization", auth) + .contentType(MediaType.APPLICATION_JSON) + .content(customerBody)) + .andExpect(status().isCreated()) + .andReturn() + .getResponse() + .getContentAsString(); + long customerId = objectMapper.readTree(customerJson).get("id").asLong(); + + String projectBody = + String.format( + "{\"customerId\":%d,\"name\":\"交付项目\",\"phase\":\"PLANNING\"}", customerId); + String projectJson = + mockMvc.perform( + post("/api/v1/projects") + .header("Authorization", auth) + .contentType(MediaType.APPLICATION_JSON) + .content(projectBody)) + .andExpect(status().isCreated()) + .andReturn() + .getResponse() + .getContentAsString(); + long projectId = objectMapper.readTree(projectJson).get("id").asLong(); + + String otherProjectBody = + String.format( + "{\"customerId\":%d,\"name\":\"其他项目\",\"phase\":\"PLANNING\"}", customerId); + String otherProjectJson = + mockMvc.perform( + post("/api/v1/projects") + .header("Authorization", auth) + .contentType(MediaType.APPLICATION_JSON) + .content(otherProjectBody)) + .andExpect(status().isCreated()) + .andReturn() + .getResponse() + .getContentAsString(); + long otherProjectId = objectMapper.readTree(otherProjectJson).get("id").asLong(); + + String contractBody = + String.format( + "{\"customerId\":%d,\"projectId\":%d,\"title\":\"交付合同\",\"remarks\":\"\"}", + customerId, projectId); + String contractJson = + mockMvc.perform( + post("/api/v1/contracts") + .header("Authorization", auth) + .contentType(MediaType.APPLICATION_JSON) + .content(contractBody)) + .andExpect(status().isCreated()) + .andReturn() + .getResponse() + .getContentAsString(); + long contractId = objectMapper.readTree(contractJson).get("id").asLong(); + + String lineBody = "{\"itemName\":\"合同行A\",\"quantity\":1,\"unit\":\"套\",\"amount\":1,\"remark\":\"\"}"; + String lineJson = + mockMvc.perform( + post("/api/v1/contracts/" + contractId + "/lines") + .header("Authorization", auth) + .contentType(MediaType.APPLICATION_JSON) + .content(lineBody)) + .andExpect(status().isCreated()) + .andReturn() + .getResponse() + .getContentAsString(); + long contractLineId = objectMapper.readTree(lineJson).get("id").asLong(); + + String wrongContractBody = + String.format( + "{\"customerId\":%d,\"projectId\":%d,\"title\":\"错项目合同\",\"remarks\":\"\"}", + customerId, otherProjectId); + String wrongContractJson = + mockMvc.perform( + post("/api/v1/contracts") + .header("Authorization", auth) + .contentType(MediaType.APPLICATION_JSON) + .content(wrongContractBody)) + .andExpect(status().isCreated()) + .andReturn() + .getResponse() + .getContentAsString(); + long wrongContractId = objectMapper.readTree(wrongContractJson).get("id").asLong(); + + String batchMismatch = + String.format( + "{\"projectId\":%d,\"contractId\":%d,\"batchCode\":\"B-MISMATCH\"," + + "\"plannedDeliveryDate\":\"2026-05-01\",\"remarks\":\"x\"}", + projectId, wrongContractId); + mockMvc.perform( + post("/api/v1/delivery-batches") + .header("Authorization", auth) + .contentType(MediaType.APPLICATION_JSON) + .content(batchMismatch)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value(containsString("projectId"))); + + String batchCreate = + String.format( + "{\"projectId\":%d,\"contractId\":%d,\"batchCode\":\"B-001\"," + + "\"plannedDeliveryDate\":\"2026-04-01\",\"remarks\":\"首批\"}", + projectId, contractId); + String batchJson = + mockMvc.perform( + post("/api/v1/delivery-batches") + .header("Authorization", auth) + .contentType(MediaType.APPLICATION_JSON) + .content(batchCreate)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.status").value("PENDING")) + .andReturn() + .getResponse() + .getContentAsString(); + long batchId = objectMapper.readTree(batchJson).get("id").asLong(); + + mockMvc.perform( + post("/api/v1/delivery-batches") + .header("Authorization", auth) + .contentType(MediaType.APPLICATION_JSON) + .content(batchCreate)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.message").value(containsString("duplicate"))); + + mockMvc.perform( + get("/api/v1/delivery-batches") + .header("Authorization", auth) + .param("page", "0") + .param("size", "10") + .param("keyword", "B-0")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content[0].id").value(batchId)) + .andExpect(jsonPath("$.content[0].lines").doesNotExist()); + + mockMvc.perform(get("/api/v1/delivery-batches/" + batchId).header("Authorization", auth)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.lines").isArray()); + + String dLine = + String.format( + "{\"description\":\"清单项\",\"quantity\":2,\"contractLineId\":%d}", + contractLineId); + mockMvc.perform( + post("/api/v1/delivery-batches/" + batchId + "/lines") + .header("Authorization", auth) + .contentType(MediaType.APPLICATION_JSON) + .content(dLine)) + .andExpect(status().isCreated()); + + mockMvc.perform( + put("/api/v1/delivery-batches/" + batchId) + .header("Authorization", auth) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"remarks\":\"改备注\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.remarks").value("改备注")); + + mockMvc.perform( + patch("/api/v1/delivery-batches/" + batchId + "/status") + .header("Authorization", auth) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"status\":\"DELIVERED\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("DELIVERED")) + .andExpect(jsonPath("$.finishedAt").exists()); + + mockMvc.perform( + put("/api/v1/delivery-batches/" + batchId) + .header("Authorization", auth) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"remarks\":\"迟了\"}")) + .andExpect(status().isConflict()); + + mockMvc.perform( + post("/api/v1/delivery-batches/" + batchId + "/lines") + .header("Authorization", auth) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"description\":\"晚加\",\"quantity\":1}")) + .andExpect(status().isConflict()); + + String auditBody = + mockMvc.perform( + get("/api/v1/audit-events") + .header("Authorization", auth) + .param("entityType", "DELIVERY_BATCH") + .param("entityId", String.valueOf(batchId)) + .param("page", "0") + .param("size", "50")) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(); + + JsonNode root = objectMapper.readTree(auditBody); + boolean hasCreated = false; + boolean hasLine = false; + for (JsonNode row : root.get("content")) { + String action = row.get("action").asText(); + if ("DELIVERY_BATCH_CREATED".equals(action)) { + hasCreated = true; + } + if ("DELIVERY_LINE_ADDED".equals(action)) { + hasLine = true; + } + } + assertThat(hasCreated).isTrue(); + assertThat(hasLine).isTrue(); + } +} diff --git a/services/delivery-platform-api/src/test/java/cn/craftlabs/platform/api/license/LicenseSnControllerTest.java b/services/delivery-platform-api/src/test/java/cn/craftlabs/platform/api/license/LicenseSnControllerTest.java new file mode 100644 index 0000000..834db2f --- /dev/null +++ b/services/delivery-platform-api/src/test/java/cn/craftlabs/platform/api/license/LicenseSnControllerTest.java @@ -0,0 +1,195 @@ +package cn.craftlabs.platform.api.license; + +import cn.craftlabs.platform.api.support.JwtTestSupport; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import static org.hamcrest.Matchers.containsString; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +class LicenseSnControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Test + void licenseSnCreateDuplicateStatusTransitionsAndRevoke() throws Exception { + String token = JwtTestSupport.obtainBearerToken(mockMvc, objectMapper); + String auth = "Bearer " + token; + + String customerBody = "{\"name\":\"SN客户\",\"creditCode\":\"SN001\",\"status\":\"ACTIVE\"}"; + String customerJson = + mockMvc.perform( + post("/api/v1/customers") + .header("Authorization", auth) + .contentType(MediaType.APPLICATION_JSON) + .content(customerBody)) + .andExpect(status().isCreated()) + .andReturn() + .getResponse() + .getContentAsString(); + long customerId = objectMapper.readTree(customerJson).get("id").asLong(); + + String projectBody = + String.format( + "{\"customerId\":%d,\"name\":\"SN项目\",\"phase\":\"PLANNING\"}", customerId); + String projectJson = + mockMvc.perform( + post("/api/v1/projects") + .header("Authorization", auth) + .contentType(MediaType.APPLICATION_JSON) + .content(projectBody)) + .andExpect(status().isCreated()) + .andReturn() + .getResponse() + .getContentAsString(); + long projectId = objectMapper.readTree(projectJson).get("id").asLong(); + + String contractBody = + String.format( + "{\"customerId\":%d,\"projectId\":%d,\"title\":\"SN合同\",\"remarks\":\"\"}", + customerId, projectId); + String contractJson = + mockMvc.perform( + post("/api/v1/contracts") + .header("Authorization", auth) + .contentType(MediaType.APPLICATION_JSON) + .content(contractBody)) + .andExpect(status().isCreated()) + .andReturn() + .getResponse() + .getContentAsString(); + long contractId = objectMapper.readTree(contractJson).get("id").asLong(); + + String lineBody = "{\"itemName\":\"许可行\",\"quantity\":1,\"unit\":\"个\",\"amount\":1,\"remark\":\"\"}"; + String lineJson = + mockMvc.perform( + post("/api/v1/contracts/" + contractId + "/lines") + .header("Authorization", auth) + .contentType(MediaType.APPLICATION_JSON) + .content(lineBody)) + .andExpect(status().isCreated()) + .andReturn() + .getResponse() + .getContentAsString(); + long contractLineId = objectMapper.readTree(lineJson).get("id").asLong(); + + mockMvc.perform( + post("/api/v1/license-sns") + .header("Authorization", auth) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"snCode\":\" SN-001 \",\"activationRemark\":\"a\"}")) + .andExpect(status().isBadRequest()); + + String createByProject = + String.format( + "{\"snCode\":\"SN-001\",\"projectId\":%d,\"activationRemark\":\"备注\"}", + projectId); + String snJson = + mockMvc.perform( + post("/api/v1/license-sns") + .header("Authorization", auth) + .contentType(MediaType.APPLICATION_JSON) + .content(createByProject)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.status").value("REGISTERED")) + .andExpect(jsonPath("$.snCode").value("SN-001")) + .andReturn() + .getResponse() + .getContentAsString(); + long snId = objectMapper.readTree(snJson).get("id").asLong(); + + mockMvc.perform( + post("/api/v1/license-sns") + .header("Authorization", auth) + .contentType(MediaType.APPLICATION_JSON) + .content(createByProject)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.message").value(containsString("duplicate"))); + + String createByLineOnly = + String.format("{\"snCode\":\"SN-LINE\",\"contractLineId\":%d}", contractLineId); + mockMvc.perform( + post("/api/v1/license-sns") + .header("Authorization", auth) + .contentType(MediaType.APPLICATION_JSON) + .content(createByLineOnly)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.projectId").value(projectId)) + .andExpect(jsonPath("$.contractLineId").value(contractLineId)); + + mockMvc.perform( + get("/api/v1/license-sns") + .header("Authorization", auth) + .param("page", "0") + .param("size", "20") + .param("projectId", String.valueOf(projectId)) + .param("keyword", "SN-") + .param("status", "REGISTERED")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content.length()").value(2)); + + mockMvc.perform( + patch("/api/v1/license-sns/" + snId + "/status") + .header("Authorization", auth) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"status\":\"ACTIVATED\"}")) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.message").value(containsString("illegal"))); + + mockMvc.perform( + patch("/api/v1/license-sns/" + snId + "/status") + .header("Authorization", auth) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"status\":\"ISSUED\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("ISSUED")); + + mockMvc.perform( + patch("/api/v1/license-sns/" + snId + "/status") + .header("Authorization", auth) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"status\":\"ACTIVATED\"}")) + .andExpect(status().isOk()); + + mockMvc.perform( + put("/api/v1/license-sns/" + snId) + .header("Authorization", auth) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"activationRemark\":\"已激活\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.activationRemark").value("已激活")); + + mockMvc.perform( + patch("/api/v1/license-sns/" + snId + "/status") + .header("Authorization", auth) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"status\":\"REVOKED\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("REVOKED")); + + mockMvc.perform( + put("/api/v1/license-sns/" + snId) + .header("Authorization", auth) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"activationRemark\":\"no\"}")) + .andExpect(status().isConflict()); + } +}