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
This commit is contained in:
2026-04-06 21:49:04 +08:00
parent df91ab0673
commit 9df6f60a17
28 changed files with 3151 additions and 14 deletions
+813 -14
View File
@@ -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}" : { "/api/v1/customers/{id}" : {
"get" : { "get" : {
"tags" : [ "customer-controller" ], "tags" : [ "customer-controller" ],
"operationId" : "get_1", "operationId" : "get_3",
"parameters" : [ { "parameters" : [ {
"name" : "id", "name" : "id",
"in" : "path", "in" : "path",
@@ -118,7 +313,7 @@
}, },
"put" : { "put" : {
"tags" : [ "customer-controller" ], "tags" : [ "customer-controller" ],
"operationId" : "update_1", "operationId" : "update_3",
"parameters" : [ { "parameters" : [ {
"name" : "id", "name" : "id",
"in" : "path", "in" : "path",
@@ -173,7 +368,7 @@
"/api/v1/contracts/{id}" : { "/api/v1/contracts/{id}" : {
"get" : { "get" : {
"tags" : [ "contract-controller" ], "tags" : [ "contract-controller" ],
"operationId" : "get_2", "operationId" : "get_4",
"parameters" : [ { "parameters" : [ {
"name" : "id", "name" : "id",
"in" : "path", "in" : "path",
@@ -198,7 +393,7 @@
}, },
"put" : { "put" : {
"tags" : [ "contract-controller" ], "tags" : [ "contract-controller" ],
"operationId" : "update_2", "operationId" : "update_4",
"parameters" : [ { "parameters" : [ {
"name" : "id", "name" : "id",
"in" : "path", "in" : "path",
@@ -235,7 +430,7 @@
"/api/v1/contracts/{id}/lines/{lineId}" : { "/api/v1/contracts/{id}/lines/{lineId}" : {
"put" : { "put" : {
"tags" : [ "contract-controller" ], "tags" : [ "contract-controller" ],
"operationId" : "updateLine", "operationId" : "updateLine_1",
"parameters" : [ { "parameters" : [ {
"name" : "id", "name" : "id",
"in" : "path", "in" : "path",
@@ -278,7 +473,7 @@
}, },
"delete" : { "delete" : {
"tags" : [ "contract-controller" ], "tags" : [ "contract-controller" ],
"operationId" : "deleteLine", "operationId" : "deleteLine_1",
"parameters" : [ { "parameters" : [ {
"name" : "id", "name" : "id",
"in" : "path", "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" : { "/api/v1/customers" : {
"get" : { "get" : {
"tags" : [ "customer-controller" ], "tags" : [ "customer-controller" ],
"operationId" : "list_1", "operationId" : "list_3",
"parameters" : [ { "parameters" : [ {
"name" : "page", "name" : "page",
"in" : "query", "in" : "query",
@@ -425,7 +862,7 @@
}, },
"post" : { "post" : {
"tags" : [ "customer-controller" ], "tags" : [ "customer-controller" ],
"operationId" : "create_1", "operationId" : "create_3",
"requestBody" : { "requestBody" : {
"content" : { "content" : {
"application/json" : { "application/json" : {
@@ -453,7 +890,7 @@
"/api/v1/contracts" : { "/api/v1/contracts" : {
"get" : { "get" : {
"tags" : [ "contract-controller" ], "tags" : [ "contract-controller" ],
"operationId" : "list_2", "operationId" : "list_4",
"parameters" : [ { "parameters" : [ {
"name" : "page", "name" : "page",
"in" : "query", "in" : "query",
@@ -514,7 +951,7 @@
}, },
"post" : { "post" : {
"tags" : [ "contract-controller" ], "tags" : [ "contract-controller" ],
"operationId" : "create_2", "operationId" : "create_4",
"requestBody" : { "requestBody" : {
"content" : { "content" : {
"application/json" : { "application/json" : {
@@ -542,7 +979,7 @@
"/api/v1/contracts/{id}/lines" : { "/api/v1/contracts/{id}/lines" : {
"get" : { "get" : {
"tags" : [ "contract-controller" ], "tags" : [ "contract-controller" ],
"operationId" : "listLines", "operationId" : "listLines_1",
"parameters" : [ { "parameters" : [ {
"name" : "id", "name" : "id",
"in" : "path", "in" : "path",
@@ -570,7 +1007,7 @@
}, },
"post" : { "post" : {
"tags" : [ "contract-controller" ], "tags" : [ "contract-controller" ],
"operationId" : "addLine", "operationId" : "addLine_1",
"parameters" : [ { "parameters" : [ {
"name" : "id", "name" : "id",
"in" : "path", "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" : { "/api/v1/contracts/{id}/status" : {
"patch" : { "patch" : {
"tags" : [ "contract-controller" ], "tags" : [ "contract-controller" ],
"operationId" : "patchStatus", "operationId" : "patchStatus_2",
"parameters" : [ { "parameters" : [ {
"name" : "id", "name" : "id",
"in" : "path", "in" : "path",
@@ -726,7 +1237,7 @@
"/api/v1/audit-events" : { "/api/v1/audit-events" : {
"get" : { "get" : {
"tags" : [ "audit-controller" ], "tags" : [ "audit-controller" ],
"operationId" : "list_3", "operationId" : "list_5",
"parameters" : [ { "parameters" : [ {
"name" : "entityType", "name" : "entityType",
"in" : "query", "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" : { "CustomerRequest" : {
"type" : "object", "type" : "object",
"properties" : { "properties" : {
@@ -1003,6 +1685,57 @@
}, },
"required" : [ "itemName", "quantity" ] "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" : { "ContractCreateRequest" : {
"type" : "object", "type" : "object",
"properties" : { "properties" : {
@@ -1027,6 +1760,26 @@
}, },
"required" : [ "customerId", "projectId" ] "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" : { "ContractStatusPatchRequest" : {
"type" : "object", "type" : "object",
"properties" : { "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" : { "DictionaryItemResponse" : {
"type" : "object", "type" : "object",
"properties" : { "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" : { "PageResponseCustomerResponse" : {
"type" : "object", "type" : "object",
"properties" : { "properties" : {
@@ -10,5 +10,16 @@ public final class AuditActions {
public static final String CONTRACT_LINE_DELETED = "CONTRACT_LINE_DELETED"; public static final String CONTRACT_LINE_DELETED = "CONTRACT_LINE_DELETED";
public static final String CONTRACT_STATUS_CHANGED = "CONTRACT_STATUS_CHANGED"; 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() {} private AuditActions() {}
} }
@@ -3,6 +3,8 @@ package cn.craftlabs.platform.api.audit;
public final class AuditEntityTypes { public final class AuditEntityTypes {
public static final String CONTRACT = "CONTRACT"; public static final String CONTRACT = "CONTRACT";
public static final String DELIVERY_BATCH = "DELIVERY_BATCH";
public static final String LICENSE_SN = "LICENSE_SN";
private AuditEntityTypes() {} private AuditEntityTypes() {}
} }
@@ -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<DeliveryBatchResponse> 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<DeliveryLineResponse> 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);
}
}
@@ -0,0 +1,8 @@
package cn.craftlabs.platform.api.domain;
/** M3:交付批次状态(P0)。 */
public enum DeliveryBatchStatus {
PENDING,
DELIVERED,
CANCELLED;
}
@@ -0,0 +1,10 @@
package cn.craftlabs.platform.api.domain;
/** M4:SN 生命周期状态(P0 子集)。 */
public enum LicenseSnStatus {
REGISTERED,
ISSUED,
ACTIVATED,
SUSPENDED,
REVOKED;
}
@@ -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<LicenseSnResponse> 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);
}
}
@@ -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;
}
}
@@ -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<PlatformDeliveryBatch> {}
@@ -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;
}
}
@@ -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<PlatformDeliveryLine> {}
@@ -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;
}
}
@@ -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<PlatformLicenseSn> {}
@@ -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<DeliveryBatchResponse> page(
int page, int size, Long projectId, Long contractId, String keyword) {
String kw = StringUtils.hasText(keyword) ? keyword.trim() : null;
LambdaQueryWrapper<PlatformDeliveryBatch> 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<PlatformDeliveryBatch> mpPage = new Page<>(page + 1L, size);
batchMapper.selectPage(mpPage, q);
List<DeliveryBatchResponse> 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<DeliveryLineResponse> 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<DeliveryLineResponse> selectLines(long batchId) {
LambdaQueryWrapper<PlatformDeliveryLine> 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<PlatformDeliveryLine> 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<String, Object> batchSnapshot(PlatformDeliveryBatch b) {
Map<String, Object> 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<String, Object> lineSnapshot(PlatformDeliveryLine line) {
Map<String, Object> 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;
}
}
@@ -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<LicenseSnResponse> 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<PlatformLicenseSn> 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<PlatformLicenseSn> mpPage = new Page<>(page + 1L, size);
licenseSnMapper.selectPage(mpPage, q);
List<LicenseSnResponse> 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<String, Object> licenseSnapshot(PlatformLicenseSn row) {
Map<String, Object> 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;
}
}
@@ -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;
}
}
@@ -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<DeliveryLineResponse> 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<DeliveryLineResponse> getLines() {
return lines;
}
public void setLines(List<DeliveryLineResponse> lines) {
this.lines = lines;
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -0,0 +1,45 @@
-- I4:M3 交付批次与清单;M4 许可 SN 台账(PostgreSQL 15H2 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);
@@ -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();
}
}
@@ -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());
}
}