From 946a357f8ba3caa0373b7441e84b4ac5d706796e Mon Sep 17 00:00:00 2001 From: shunli Date: Sat, 7 Mar 2026 23:20:34 +0800 Subject: [PATCH] =?UTF-8?q?feat(P18B-1,P18B-2):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E8=B5=84=E4=BA=A7=E6=B5=81=E8=BD=AC=E6=A8=A1=E5=9E=8B=E4=B8=8E?= =?UTF-8?q?=E5=8F=97=E6=8E=A7=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../migration.sql | 113 ++++ server/prisma/schema.prisma | 98 +++ server/src/app.js | 4 + server/src/routes/assetMovements.js | 447 ++++++++++++++ server/src/routes/assets.js | 22 +- server/src/routes/inventoryChecks.js | 574 ++++++++++++++++++ server/src/services/documentSequence.js | 15 + .../unit/documentSequence.service.test.js | 10 + 8 files changed, 1276 insertions(+), 7 deletions(-) create mode 100644 server/prisma/migrations/20260307145457_p18b_asset_movements_inventory/migration.sql create mode 100644 server/src/routes/assetMovements.js create mode 100644 server/src/routes/inventoryChecks.js diff --git a/server/prisma/migrations/20260307145457_p18b_asset_movements_inventory/migration.sql b/server/prisma/migrations/20260307145457_p18b_asset_movements_inventory/migration.sql new file mode 100644 index 00000000..ea03e9b9 --- /dev/null +++ b/server/prisma/migrations/20260307145457_p18b_asset_movements_inventory/migration.sql @@ -0,0 +1,113 @@ +-- CreateEnum +CREATE TYPE "InventoryCheckScopeType" AS ENUM ('all', 'legal_entity', 'cost_center', 'location'); + +-- CreateEnum +CREATE TYPE "InventoryCheckStatus" AS ENUM ('draft', 'counting', 'completed'); + +-- CreateEnum +CREATE TYPE "InventoryCheckResult" AS ENUM ('matched', 'missing', 'extra', 'damaged'); + +-- CreateTable +CREATE TABLE "asset_assignments" ( + "id" UUID NOT NULL, + "asset_id" UUID NOT NULL, + "employee_id" UUID NOT NULL, + "assigned_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "returned_at" TIMESTAMP(3), + "note" TEXT, + + CONSTRAINT "asset_assignments_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "asset_transfers" ( + "id" UUID NOT NULL, + "asset_id" UUID NOT NULL, + "from_employee_id" UUID, + "to_employee_id" UUID, + "from_location" TEXT, + "to_location" TEXT, + "transferred_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "note" TEXT, + + CONSTRAINT "asset_transfers_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "inventory_checks" ( + "id" UUID NOT NULL, + "check_no" TEXT NOT NULL, + "scope_type" "InventoryCheckScopeType" NOT NULL, + "scope_ref_id" VARCHAR(200), + "status" "InventoryCheckStatus" NOT NULL DEFAULT 'draft', + "started_at" TIMESTAMP(3), + "completed_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "created_by" UUID, + "updated_at" TIMESTAMP(3) NOT NULL, + "updated_by" UUID, + + CONSTRAINT "inventory_checks_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "inventory_check_items" ( + "id" UUID NOT NULL, + "inventory_check_id" UUID NOT NULL, + "asset_id" UUID NOT NULL, + "result" "InventoryCheckResult" NOT NULL, + "note" TEXT, + + CONSTRAINT "inventory_check_items_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "idx_asset_assignments_asset_open" ON "asset_assignments"("asset_id", "returned_at"); + +-- CreateIndex +CREATE INDEX "idx_asset_assignments_employee_open" ON "asset_assignments"("employee_id", "returned_at"); + +-- CreateIndex +CREATE INDEX "idx_asset_transfers_asset_time" ON "asset_transfers"("asset_id", "transferred_at"); + +-- CreateIndex +CREATE INDEX "idx_asset_transfers_from_employee" ON "asset_transfers"("from_employee_id"); + +-- CreateIndex +CREATE INDEX "idx_asset_transfers_to_employee" ON "asset_transfers"("to_employee_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "inventory_checks_check_no_key" ON "inventory_checks"("check_no"); + +-- CreateIndex +CREATE INDEX "idx_inventory_checks_status_created" ON "inventory_checks"("status", "created_at"); + +-- CreateIndex +CREATE INDEX "idx_inventory_checks_scope" ON "inventory_checks"("scope_type", "scope_ref_id"); + +-- CreateIndex +CREATE INDEX "idx_inventory_check_items_asset" ON "inventory_check_items"("asset_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "inventory_check_asset_ux" ON "inventory_check_items"("inventory_check_id", "asset_id"); + +-- AddForeignKey +ALTER TABLE "asset_assignments" ADD CONSTRAINT "asset_assignments_asset_id_fkey" FOREIGN KEY ("asset_id") REFERENCES "asset_cards"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "asset_assignments" ADD CONSTRAINT "asset_assignments_employee_id_fkey" FOREIGN KEY ("employee_id") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "asset_transfers" ADD CONSTRAINT "asset_transfers_asset_id_fkey" FOREIGN KEY ("asset_id") REFERENCES "asset_cards"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "asset_transfers" ADD CONSTRAINT "asset_transfers_from_employee_id_fkey" FOREIGN KEY ("from_employee_id") REFERENCES "employees"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "asset_transfers" ADD CONSTRAINT "asset_transfers_to_employee_id_fkey" FOREIGN KEY ("to_employee_id") REFERENCES "employees"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "inventory_check_items" ADD CONSTRAINT "inventory_check_items_inventory_check_id_fkey" FOREIGN KEY ("inventory_check_id") REFERENCES "inventory_checks"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "inventory_check_items" ADD CONSTRAINT "inventory_check_items_asset_id_fkey" FOREIGN KEY ("asset_id") REFERENCES "asset_cards"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index ba4b855e..f62e5678 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -60,6 +60,26 @@ enum AssetCardStatus { archived } +enum InventoryCheckScopeType { + all + legal_entity + cost_center + location +} + +enum InventoryCheckStatus { + draft + counting + completed +} + +enum InventoryCheckResult { + matched + missing + extra + damaged +} + enum LeaveDaySession { am pm @@ -242,6 +262,9 @@ model Employee { purchase_attachments_uploaded PurchaseAttachment[] @relation("PurchaseAttachmentUploader") purchase_order_archives PurchaseOrderArchive[] @relation("PurchaseOrderArchiveArchivedBy") assigned_asset_cards AssetCard[] @relation("AssetCardAssignee") + asset_assignments AssetAssignment[] + asset_transfers_from AssetTransfer[] @relation("AssetTransferFromEmployee") + asset_transfers_to AssetTransfer[] @relation("AssetTransferToEmployee") owned_invoices InvoicePool[] @relation("InvoiceOwner") uploaded_invoices InvoicePool[] @relation("InvoiceUploader") @@ -715,6 +738,9 @@ model AssetCard { cost_center CostCenter @relation(fields: [cost_center_id], references: [id], onDelete: Restrict) purchase_request PurchaseRequest? @relation(fields: [purchase_request_id], references: [id], onDelete: SetNull) assignee Employee? @relation("AssetCardAssignee", fields: [assignee_employee_id], references: [id], onDelete: SetNull) + assignments AssetAssignment[] + transfers AssetTransfer[] + inventory_items InventoryCheckItem[] @@index([category_id], map: "idx_asset_cards_category") @@index([legal_entity_id], map: "idx_asset_cards_legal_entity") @@ -725,6 +751,78 @@ model AssetCard { @@map("asset_cards") } +model AssetAssignment { + id String @id @default(uuid()) @db.Uuid + asset_id String @db.Uuid + employee_id String @db.Uuid + assigned_at DateTime @default(now()) + returned_at DateTime? + note String? + + asset AssetCard @relation(fields: [asset_id], references: [id], onDelete: Cascade) + employee Employee @relation(fields: [employee_id], references: [id], onDelete: Restrict) + + @@index([asset_id, returned_at], map: "idx_asset_assignments_asset_open") + @@index([employee_id, returned_at], map: "idx_asset_assignments_employee_open") + @@map("asset_assignments") +} + +model AssetTransfer { + id String @id @default(uuid()) @db.Uuid + asset_id String @db.Uuid + from_employee_id String? @db.Uuid + to_employee_id String? @db.Uuid + from_location String? + to_location String? + transferred_at DateTime @default(now()) + note String? + + asset AssetCard @relation(fields: [asset_id], references: [id], onDelete: Cascade) + from_employee Employee? @relation("AssetTransferFromEmployee", fields: [from_employee_id], references: [id], onDelete: SetNull) + to_employee Employee? @relation("AssetTransferToEmployee", fields: [to_employee_id], references: [id], onDelete: SetNull) + + @@index([asset_id, transferred_at], map: "idx_asset_transfers_asset_time") + @@index([from_employee_id], map: "idx_asset_transfers_from_employee") + @@index([to_employee_id], map: "idx_asset_transfers_to_employee") + @@map("asset_transfers") +} + +model InventoryCheck { + id String @id @default(uuid()) @db.Uuid + check_no String @unique + scope_type InventoryCheckScopeType + scope_ref_id String? @db.VarChar(200) + status InventoryCheckStatus @default(draft) + started_at DateTime? + completed_at DateTime? + + created_at DateTime @default(now()) + created_by String? @db.Uuid + updated_at DateTime @updatedAt + updated_by String? @db.Uuid + + items InventoryCheckItem[] + + @@index([status, created_at], map: "idx_inventory_checks_status_created") + @@index([scope_type, scope_ref_id], map: "idx_inventory_checks_scope") + @@map("inventory_checks") +} + +model InventoryCheckItem { + id String @id @default(uuid()) @db.Uuid + inventory_check_id String @db.Uuid + asset_id String @db.Uuid + result InventoryCheckResult + note String? + + inventory_check InventoryCheck @relation(fields: [inventory_check_id], references: [id], onDelete: Cascade) + asset AssetCard @relation(fields: [asset_id], references: [id], onDelete: Restrict) + + @@unique([inventory_check_id, asset_id], map: "inventory_check_asset_ux") + @@index([asset_id], map: "idx_inventory_check_items_asset") + @@map("inventory_check_items") +} + model ReimbursementForm { id String @id @default(uuid()) @db.Uuid form_number String? @unique diff --git a/server/src/app.js b/server/src/app.js index bdd4ca25..fde2ed24 100644 --- a/server/src/app.js +++ b/server/src/app.js @@ -40,6 +40,8 @@ const dashboardRoutes = require("./routes/dashboard"); const budgetsRoutes = require("./routes/budgets"); const purchaseRequestRoutes = require("./routes/purchaseRequests"); const assetsRoutes = require("./routes/assets"); +const assetMovementsRoutes = require("./routes/assetMovements"); +const inventoryChecksRoutes = require("./routes/inventoryChecks"); const integrationsRoutes = require("./routes/integrations"); const travelOrdersRoutes = require("./routes/travelOrders"); const { startJobs } = require("./jobs/jobSystem"); @@ -299,6 +301,8 @@ async function buildApp() { await app.register(budgetsRoutes, { prefix: "/api" }); await app.register(purchaseRequestRoutes, { prefix: "/api" }); await app.register(assetsRoutes, { prefix: "/api" }); + await app.register(assetMovementsRoutes, { prefix: "/api" }); + await app.register(inventoryChecksRoutes, { prefix: "/api" }); await app.register(integrationsRoutes, { prefix: "/api" }); await app.register(travelOrdersRoutes, { prefix: "/api" }); await app.register(exportsRoutes, { prefix: "/api" }); diff --git a/server/src/routes/assetMovements.js b/server/src/routes/assetMovements.js new file mode 100644 index 00000000..01a1632b --- /dev/null +++ b/server/src/routes/assetMovements.js @@ -0,0 +1,447 @@ +const { z } = require("zod"); + +const { AppError } = require("../utils/AppError"); +const { buildAuditLogData } = require("../utils/audit"); +const { sendData } = require("../utils/reply"); + +const UUID_SCHEMA = z.string().uuid(); +const NOTE_SCHEMA = z.string().trim().max(500).nullable().optional(); +const LOCATION_SCHEMA = z.string().trim().max(200).nullable().optional(); + +const ASSIGN_BODY_SCHEMA = z + .object({ + employeeId: UUID_SCHEMA, + note: NOTE_SCHEMA, + }) + .strict(); + +const RETURN_BODY_SCHEMA = z + .object({ + note: NOTE_SCHEMA, + }) + .strict(); + +const TRANSFER_BODY_SCHEMA = z + .object({ + toEmployeeId: UUID_SCHEMA.nullable().optional(), + toLocation: LOCATION_SCHEMA, + note: NOTE_SCHEMA, + }) + .strict(); + +const BLOCKED_MOVEMENT_STATUSES = new Set(["retired", "archived"]); + +function buildValidationError(details, statusCode = 400) { + return new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode, + details, + }); +} + +function parseUuid(fieldName, value) { + const parsed = UUID_SCHEMA.safeParse(value); + if (!parsed.success) { + throw buildValidationError({ [fieldName]: "invalid_uuid" }); + } + return parsed.data; +} + +function parseBody(schema, body) { + const parsed = schema.safeParse(body ?? {}); + if (!parsed.success) { + throw buildValidationError(parsed.error.flatten()); + } + return parsed.data; +} + +function normalizeNote(value) { + if (value == null) { + return null; + } + const trimmed = String(value).trim(); + return trimmed ? trimmed : null; +} + +function normalizeLocation(value) { + if (value == null) { + return null; + } + const trimmed = String(value).trim(); + return trimmed ? trimmed : null; +} + +function ensureMovementAllowed(asset) { + if (BLOCKED_MOVEMENT_STATUSES.has(asset.status)) { + throw buildValidationError({ status: "movement_not_allowed" }, 409); + } +} + +async function loadAsset(tx, assetId) { + return tx.assetCard.findUnique({ + where: { id: assetId }, + select: { + id: true, + asset_no: true, + status: true, + assignee_employee_id: true, + location: true, + activated_at: true, + created_at: true, + }, + }); +} + +async function ensureAssetExists(tx, assetId) { + const asset = await loadAsset(tx, assetId); + if (!asset) { + throw buildValidationError({ id: "not_found" }, 404); + } + ensureMovementAllowed(asset); + return asset; +} + +async function ensureActiveEmployee(tx, employeeId, fieldName = "employeeId") { + const employee = await tx.employee.findUnique({ + where: { id: employeeId }, + select: { + id: true, + employee_no: true, + name: true, + status: true, + }, + }); + if (!employee) { + throw buildValidationError({ [fieldName]: "not_found" }, 404); + } + if (employee.status !== "active") { + throw buildValidationError({ [fieldName]: "inactive" }, 409); + } + return employee; +} + +async function findOpenAssignment(tx, assetId) { + return tx.assetAssignment.findFirst({ + where: { + asset_id: assetId, + returned_at: null, + }, + orderBy: [{ assigned_at: "desc" }, { id: "desc" }], + select: { + id: true, + asset_id: true, + employee_id: true, + assigned_at: true, + returned_at: true, + }, + }); +} + +async function ensureOpenAssignment(tx, asset) { + const existing = await findOpenAssignment(tx, asset.id); + if (existing) { + return existing; + } + if (!asset.assignee_employee_id) { + return null; + } + return tx.assetAssignment.create({ + data: { + asset_id: asset.id, + employee_id: asset.assignee_employee_id, + assigned_at: asset.activated_at ?? asset.created_at, + note: null, + }, + select: { + id: true, + asset_id: true, + employee_id: true, + assigned_at: true, + returned_at: true, + }, + }); +} + +async function closeAssignmentIfAny(tx, asset, note, returnedAt) { + const openAssignment = await ensureOpenAssignment(tx, asset); + if (!openAssignment) { + return null; + } + return tx.assetAssignment.update({ + where: { id: openAssignment.id }, + data: { + returned_at: returnedAt, + note: note ?? undefined, + }, + select: { + id: true, + asset_id: true, + employee_id: true, + assigned_at: true, + returned_at: true, + }, + }); +} + +async function assetMovementsRoutes(fastify) { + fastify.post( + "/assets/:id/assign", + { + schema: { tags: ["Assets"], summary: "Assign Asset" }, + preHandler: fastify.requirePermission("asset.transfer"), + }, + async (request, reply) => { + const assetId = parseUuid("id", request.params?.id); + const body = parseBody(ASSIGN_BODY_SCHEMA, request.body); + const note = normalizeNote(body.note); + + const result = await fastify.prisma.$transaction(async (tx) => { + const asset = await ensureAssetExists(tx, assetId); + const openAssignment = await findOpenAssignment(tx, asset.id); + if (asset.assignee_employee_id || openAssignment) { + throw buildValidationError({ asset: "already_assigned" }, 409); + } + + const employee = await ensureActiveEmployee(tx, body.employeeId); + const assignedAt = new Date(); + const assignment = await tx.assetAssignment.create({ + data: { + asset_id: asset.id, + employee_id: employee.id, + assigned_at: assignedAt, + note, + }, + select: { + id: true, + employee_id: true, + assigned_at: true, + }, + }); + + const updatedAsset = await tx.assetCard.update({ + where: { id: asset.id }, + data: { + assignee_employee_id: employee.id, + status: "in_use", + updated_by: request.employee.id, + }, + select: { + id: true, + asset_no: true, + status: true, + assignee_employee_id: true, + location: true, + }, + }); + + await tx.auditLog.create({ + data: buildAuditLogData(request, { + actorId: request.employee.id, + action: "ASSET_ASSIGNED", + targetType: "asset_card", + targetId: asset.id, + extra: { + assetNo: asset.asset_no, + employeeId: employee.id, + assignmentId: assignment.id, + }, + }), + }); + + return { updatedAsset, assignmentId: assignment.id }; + }); + + return sendData(reply, request, { + assetId: result.updatedAsset.id, + assetNo: result.updatedAsset.asset_no, + status: result.updatedAsset.status, + assigneeEmployeeId: result.updatedAsset.assignee_employee_id, + location: result.updatedAsset.location ?? null, + assignmentId: result.assignmentId, + }); + }, + ); + + fastify.post( + "/assets/:id/return", + { + schema: { tags: ["Assets"], summary: "Return Asset" }, + preHandler: fastify.requirePermission("asset.transfer"), + }, + async (request, reply) => { + const assetId = parseUuid("id", request.params?.id); + const body = parseBody(RETURN_BODY_SCHEMA, request.body); + const note = normalizeNote(body.note); + + const result = await fastify.prisma.$transaction(async (tx) => { + const asset = await ensureAssetExists(tx, assetId); + if (!asset.assignee_employee_id) { + throw buildValidationError({ asset: "not_assigned" }, 409); + } + + const returnedAt = new Date(); + const closedAssignment = await closeAssignmentIfAny(tx, asset, note, returnedAt); + const updatedAsset = await tx.assetCard.update({ + where: { id: asset.id }, + data: { + assignee_employee_id: null, + status: "in_stock", + updated_by: request.employee.id, + }, + select: { + id: true, + asset_no: true, + status: true, + assignee_employee_id: true, + location: true, + }, + }); + + await tx.auditLog.create({ + data: buildAuditLogData(request, { + actorId: request.employee.id, + action: "ASSET_RETURNED", + targetType: "asset_card", + targetId: asset.id, + extra: { + assetNo: asset.asset_no, + previousEmployeeId: asset.assignee_employee_id, + assignmentId: closedAssignment?.id ?? null, + }, + }), + }); + + return { updatedAsset, assignmentId: closedAssignment?.id ?? null }; + }); + + return sendData(reply, request, { + assetId: result.updatedAsset.id, + assetNo: result.updatedAsset.asset_no, + status: result.updatedAsset.status, + assigneeEmployeeId: result.updatedAsset.assignee_employee_id, + location: result.updatedAsset.location ?? null, + assignmentId: result.assignmentId, + }); + }, + ); + + fastify.post( + "/assets/:id/transfer", + { + schema: { tags: ["Assets"], summary: "Transfer Asset" }, + preHandler: fastify.requirePermission("asset.transfer"), + }, + async (request, reply) => { + const assetId = parseUuid("id", request.params?.id); + const body = parseBody(TRANSFER_BODY_SCHEMA, request.body); + const note = normalizeNote(body.note); + const hasEmployeeField = Object.prototype.hasOwnProperty.call(body, "toEmployeeId"); + const hasLocationField = Object.prototype.hasOwnProperty.call(body, "toLocation"); + const nextLocation = hasLocationField ? normalizeLocation(body.toLocation) : undefined; + + const result = await fastify.prisma.$transaction(async (tx) => { + const asset = await ensureAssetExists(tx, assetId); + const currentEmployeeId = asset.assignee_employee_id ?? null; + const currentLocation = asset.location ?? null; + const requestedEmployeeId = hasEmployeeField ? body.toEmployeeId ?? null : currentEmployeeId; + + if (hasEmployeeField && body.toEmployeeId == null && currentEmployeeId) { + throw buildValidationError({ toEmployeeId: "use_return" }, 409); + } + + const employeeChange = hasEmployeeField && requestedEmployeeId !== currentEmployeeId; + const locationChange = hasLocationField && nextLocation !== currentLocation; + + if (!employeeChange && !locationChange) { + throw buildValidationError({ transfer: "no_change" }, 409); + } + + if (employeeChange) { + await ensureActiveEmployee(tx, requestedEmployeeId, "toEmployeeId"); + } + + const transferredAt = new Date(); + if (employeeChange && currentEmployeeId) { + await closeAssignmentIfAny(tx, asset, note, transferredAt); + } + + let assignmentId = null; + if (employeeChange && requestedEmployeeId) { + const newAssignment = await tx.assetAssignment.create({ + data: { + asset_id: asset.id, + employee_id: requestedEmployeeId, + assigned_at: transferredAt, + note, + }, + select: { id: true }, + }); + assignmentId = newAssignment.id; + } + + const updatedAsset = await tx.assetCard.update({ + where: { id: asset.id }, + data: { + ...(employeeChange ? { assignee_employee_id: requestedEmployeeId } : {}), + ...(locationChange ? { location: nextLocation } : {}), + ...(employeeChange && requestedEmployeeId ? { status: "in_use" } : {}), + updated_by: request.employee.id, + }, + select: { + id: true, + asset_no: true, + status: true, + assignee_employee_id: true, + location: true, + }, + }); + + const transfer = await tx.assetTransfer.create({ + data: { + asset_id: asset.id, + from_employee_id: currentEmployeeId, + to_employee_id: employeeChange ? requestedEmployeeId : currentEmployeeId, + from_location: currentLocation, + to_location: locationChange ? nextLocation : currentLocation, + transferred_at: transferredAt, + note, + }, + select: { id: true }, + }); + + await tx.auditLog.create({ + data: buildAuditLogData(request, { + actorId: request.employee.id, + action: "ASSET_TRANSFERRED", + targetType: "asset_card", + targetId: asset.id, + extra: { + assetNo: asset.asset_no, + fromEmployeeId: currentEmployeeId, + toEmployeeId: employeeChange ? requestedEmployeeId : currentEmployeeId, + fromLocation: currentLocation, + toLocation: locationChange ? nextLocation : currentLocation, + transferId: transfer.id, + assignmentId, + }, + }), + }); + + return { updatedAsset, transferId: transfer.id, assignmentId }; + }); + + return sendData(reply, request, { + assetId: result.updatedAsset.id, + assetNo: result.updatedAsset.asset_no, + status: result.updatedAsset.status, + assigneeEmployeeId: result.updatedAsset.assignee_employee_id, + location: result.updatedAsset.location ?? null, + transferId: result.transferId, + assignmentId: result.assignmentId, + }); + }, + ); +} + +module.exports = assetMovementsRoutes; diff --git a/server/src/routes/assets.js b/server/src/routes/assets.js index 73831565..5c83e6b4 100644 --- a/server/src/routes/assets.js +++ b/server/src/routes/assets.js @@ -56,7 +56,7 @@ const ASSET_CREATE_SCHEMA = z }) .strict(); -const ASSET_PATCH_SCHEMA = ASSET_CREATE_SCHEMA.omit({ purchaseRequestId: true }).partial(); +const ASSET_PATCH_SCHEMA = ASSET_CREATE_SCHEMA.omit({ purchaseRequestId: true, assigneeEmployeeId: true }).partial(); const ASSET_CATEGORY_SELECT = { id: true, @@ -796,6 +796,8 @@ async function assetsRoutes(fastify) { actorId: request.employee.id, }); + const resolvedStatus = payload.assigneeEmployeeId ? "in_use" : payload.status ?? "in_stock"; + const created = await tx.assetCard.create({ data: { asset_no: issued.documentNumber, @@ -806,7 +808,7 @@ async function assetsRoutes(fastify) { purchase_request_id: payload.purchaseRequestId ?? null, serial_no: payload.serialNo ?? null, location: payload.location ?? null, - status: payload.status ?? "in_stock", + status: resolvedStatus, assignee_employee_id: payload.assigneeEmployeeId ?? null, activated_at: payload.activatedAt ? parseDateOnlyString(payload.activatedAt) : null, retired_at: payload.retiredAt ? parseDateOnlyString(payload.retiredAt) : null, @@ -816,6 +818,17 @@ async function assetsRoutes(fastify) { select: ASSET_CARD_SELECT, }); + if (created.assignee_employee_id) { + await tx.assetAssignment.create({ + data: { + asset_id: created.id, + employee_id: created.assignee_employee_id, + assigned_at: created.activated_at ?? created.created_at, + note: null, + }, + }); + } + await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, @@ -894,7 +907,6 @@ async function assetsRoutes(fastify) { const nextCategoryId = payload.categoryId ?? current.category_id; const nextLegalEntityId = payload.legalEntityId ?? current.legal_entity_id; const nextCostCenterId = payload.costCenterId ?? current.cost_center_id; - const nextAssigneeId = payload.assigneeEmployeeId === undefined ? current.assignee_employee_id : payload.assigneeEmployeeId; if (payload.categoryId !== undefined) { await ensureAssetCategoryExists(tx, nextCategoryId, { requireActive: true }); @@ -905,9 +917,6 @@ async function assetsRoutes(fastify) { if (payload.costCenterId !== undefined || payload.legalEntityId !== undefined) { await ensureCostCenterAvailable(tx, nextCostCenterId, nextLegalEntityId); } - if (payload.assigneeEmployeeId !== undefined) { - await ensureAssigneeEmployee(tx, nextAssigneeId); - } const updated = await tx.assetCard.update({ where: { id: assetId }, @@ -919,7 +928,6 @@ async function assetsRoutes(fastify) { ...(payload.serialNo !== undefined ? { serial_no: payload.serialNo } : {}), ...(payload.location !== undefined ? { location: payload.location } : {}), ...(payload.status !== undefined ? { status: payload.status } : {}), - ...(payload.assigneeEmployeeId !== undefined ? { assignee_employee_id: payload.assigneeEmployeeId } : {}), ...(payload.activatedAt !== undefined ? { activated_at: payload.activatedAt ? parseDateOnlyString(payload.activatedAt) : null } : {}), diff --git a/server/src/routes/inventoryChecks.js b/server/src/routes/inventoryChecks.js new file mode 100644 index 00000000..1898a3bb --- /dev/null +++ b/server/src/routes/inventoryChecks.js @@ -0,0 +1,574 @@ +const { z } = require("zod"); + +const { AppError } = require("../utils/AppError"); +const { buildAuditLogData } = require("../utils/audit"); +const { issueDocumentNumber } = require("../services/documentSequence"); +const { sendData } = require("../utils/reply"); + +const UUID_SCHEMA = z.string().uuid(); +const INVENTORY_SCOPE_TYPES = ["all", "legal_entity", "cost_center", "location"]; +const INVENTORY_STATUS_VALUES = ["draft", "counting", "completed"]; +const INVENTORY_RESULT_VALUES = ["matched", "missing", "extra", "damaged"]; + +const LIST_QUERY_SCHEMA = z.object({ + status: z.enum(INVENTORY_STATUS_VALUES).optional(), + scopeType: z.enum(INVENTORY_SCOPE_TYPES).optional(), +}); + +const CREATE_BODY_SCHEMA = z + .object({ + scopeType: z.enum(INVENTORY_SCOPE_TYPES), + scopeRefId: z.string().trim().max(200).nullable().optional(), + }) + .strict(); + +const RECORD_ITEMS_BODY_SCHEMA = z + .object({ + items: z + .array( + z + .object({ + assetId: UUID_SCHEMA, + result: z.enum(INVENTORY_RESULT_VALUES), + note: z.string().trim().max(500).nullable().optional(), + }) + .strict(), + ) + .min(1), + }) + .strict(); + +function buildValidationError(details, statusCode = 400) { + return new AppError({ + code: "VALID_001", + message: "参数校验失败", + statusCode, + details, + }); +} + +function parseUuid(fieldName, value) { + const parsed = UUID_SCHEMA.safeParse(value); + if (!parsed.success) { + throw buildValidationError({ [fieldName]: "invalid_uuid" }); + } + return parsed.data; +} + +function parseBody(schema, body) { + const parsed = schema.safeParse(body ?? {}); + if (!parsed.success) { + throw buildValidationError(parsed.error.flatten()); + } + return parsed.data; +} + +function parseListQuery(query) { + const parsed = LIST_QUERY_SCHEMA.safeParse(query ?? {}); + if (!parsed.success) { + throw buildValidationError(parsed.error.flatten()); + } + return parsed.data; +} + +function normalizeOptionalText(value) { + if (value == null) { + return null; + } + const trimmed = String(value).trim(); + return trimmed ? trimmed : null; +} + +async function ensureScopeReference(tx, scopeType, scopeRefId) { + if (scopeType === "all") { + return null; + } + if (!scopeRefId) { + throw buildValidationError({ scopeRefId: "required" }); + } + if (scopeType === "location") { + return scopeRefId; + } + + const normalizedId = parseUuid("scopeRefId", scopeRefId); + if (scopeType === "legal_entity") { + const legalEntity = await tx.legalEntity.findUnique({ + where: { id: normalizedId }, + select: { id: true }, + }); + if (!legalEntity) { + throw buildValidationError({ scopeRefId: "not_found" }, 404); + } + return normalizedId; + } + + const costCenter = await tx.costCenter.findUnique({ + where: { id: normalizedId }, + select: { id: true }, + }); + if (!costCenter) { + throw buildValidationError({ scopeRefId: "not_found" }, 404); + } + return normalizedId; +} + +function mapInventoryItem(record) { + return { + id: record.id, + assetId: record.asset_id, + result: record.result, + note: record.note ?? null, + asset: record.asset + ? { + id: record.asset.id, + assetNo: record.asset.asset_no, + name: record.asset.name, + status: record.asset.status, + legalEntityId: record.asset.legal_entity_id, + costCenterId: record.asset.cost_center_id, + location: record.asset.location ?? null, + assigneeEmployeeId: record.asset.assignee_employee_id ?? null, + } + : null, + }; +} + +function mapInventoryCheck(record) { + return { + id: record.id, + checkNo: record.check_no, + scopeType: record.scope_type, + scopeRefId: record.scope_ref_id ?? null, + status: record.status, + startedAt: record.started_at?.toISOString?.() ?? null, + completedAt: record.completed_at?.toISOString?.() ?? null, + createdAt: record.created_at?.toISOString?.() ?? null, + updatedAt: record.updated_at?.toISOString?.() ?? null, + items: (record.items ?? []).map(mapInventoryItem), + }; +} + +async function ensureInventoryCheck(tx, inventoryCheckId) { + const record = await tx.inventoryCheck.findUnique({ + where: { id: inventoryCheckId }, + select: { + id: true, + check_no: true, + scope_type: true, + scope_ref_id: true, + status: true, + started_at: true, + completed_at: true, + created_at: true, + updated_at: true, + items: { + orderBy: [{ asset: { asset_no: "asc" } }], + select: { + id: true, + asset_id: true, + result: true, + note: true, + asset: { + select: { + id: true, + asset_no: true, + name: true, + status: true, + legal_entity_id: true, + cost_center_id: true, + location: true, + assignee_employee_id: true, + }, + }, + }, + }, + }, + }); + if (!record) { + throw buildValidationError({ id: "not_found" }, 404); + } + return record; +} + +function ensureCheckStatus(record, expectedStatus) { + if (record.status !== expectedStatus) { + throw buildValidationError({ status: `expected_${expectedStatus}` }, 409); + } +} + +function ensureAssetInScope(check, asset) { + if (check.scope_type === "all") { + return; + } + if (check.scope_type === "legal_entity" && asset.legal_entity_id !== check.scope_ref_id) { + throw buildValidationError({ assetId: "scope_mismatch" }, 409); + } + if (check.scope_type === "cost_center" && asset.cost_center_id !== check.scope_ref_id) { + throw buildValidationError({ assetId: "scope_mismatch" }, 409); + } + if (check.scope_type === "location" && (asset.location ?? null) !== (check.scope_ref_id ?? null)) { + throw buildValidationError({ assetId: "scope_mismatch" }, 409); + } +} + +async function inventoryChecksRoutes(fastify) { + fastify.get( + "/inventory-checks", + { + schema: { tags: ["Assets"], summary: "List Inventory Checks" }, + preHandler: fastify.requirePermission("asset.inventory"), + }, + async (request, reply) => { + const query = parseListQuery(request.query); + const where = {}; + if (query.status) { + where.status = query.status; + } + if (query.scopeType) { + where.scope_type = query.scopeType; + } + const items = await fastify.prisma.inventoryCheck.findMany({ + where, + orderBy: [{ created_at: "desc" }, { id: "desc" }], + select: { + id: true, + check_no: true, + scope_type: true, + scope_ref_id: true, + status: true, + started_at: true, + completed_at: true, + created_at: true, + updated_at: true, + items: { + orderBy: [{ asset: { asset_no: "asc" } }], + select: { + id: true, + asset_id: true, + result: true, + note: true, + asset: { + select: { + id: true, + asset_no: true, + name: true, + status: true, + legal_entity_id: true, + cost_center_id: true, + location: true, + assignee_employee_id: true, + }, + }, + }, + }, + }, + }); + return sendData(reply, request, { items: items.map(mapInventoryCheck) }); + }, + ); + + fastify.post( + "/inventory-checks", + { + schema: { tags: ["Assets"], summary: "Create Inventory Check" }, + preHandler: fastify.requirePermission("asset.inventory"), + }, + async (request, reply) => { + const body = parseBody(CREATE_BODY_SCHEMA, request.body); + const scopeRefId = normalizeOptionalText(body.scopeRefId); + + const inventoryCheck = await fastify.prisma.$transaction(async (tx) => { + const normalizedScopeRefId = await ensureScopeReference(tx, body.scopeType, scopeRefId); + const issued = await issueDocumentNumber(tx, { + documentType: "inventory_check", + actorId: request.employee.id, + }); + + const created = await tx.inventoryCheck.create({ + data: { + check_no: issued.documentNumber, + scope_type: body.scopeType, + scope_ref_id: normalizedScopeRefId, + created_by: request.employee.id, + updated_by: request.employee.id, + }, + select: { + id: true, + check_no: true, + scope_type: true, + scope_ref_id: true, + status: true, + started_at: true, + completed_at: true, + created_at: true, + updated_at: true, + items: { + select: { + id: true, + asset_id: true, + result: true, + note: true, + asset: { + select: { + id: true, + asset_no: true, + name: true, + status: true, + legal_entity_id: true, + cost_center_id: true, + location: true, + assignee_employee_id: true, + }, + }, + }, + }, + }, + }); + + await tx.auditLog.create({ + data: buildAuditLogData(request, { + actorId: request.employee.id, + action: "INVENTORY_CHECK_CREATED", + targetType: "inventory_check", + targetId: created.id, + extra: { + checkNo: created.check_no, + scopeType: created.scope_type, + scopeRefId: created.scope_ref_id ?? null, + }, + }), + }); + + return created; + }); + + return sendData(reply, request, { inventoryCheck: mapInventoryCheck(inventoryCheck) }); + }, + ); + + fastify.post( + "/inventory-checks/:id/start", + { + schema: { tags: ["Assets"], summary: "Start Inventory Check" }, + preHandler: fastify.requirePermission("asset.inventory"), + }, + async (request, reply) => { + const inventoryCheckId = parseUuid("id", request.params?.id); + const inventoryCheck = await fastify.prisma.$transaction(async (tx) => { + const current = await ensureInventoryCheck(tx, inventoryCheckId); + ensureCheckStatus(current, "draft"); + + const updated = await tx.inventoryCheck.update({ + where: { id: inventoryCheckId }, + data: { + status: "counting", + started_at: new Date(), + updated_by: request.employee.id, + }, + select: { + id: true, + check_no: true, + scope_type: true, + scope_ref_id: true, + status: true, + started_at: true, + completed_at: true, + created_at: true, + updated_at: true, + items: { + orderBy: [{ asset: { asset_no: "asc" } }], + select: { + id: true, + asset_id: true, + result: true, + note: true, + asset: { + select: { + id: true, + asset_no: true, + name: true, + status: true, + legal_entity_id: true, + cost_center_id: true, + location: true, + assignee_employee_id: true, + }, + }, + }, + }, + }, + }); + + await tx.auditLog.create({ + data: buildAuditLogData(request, { + actorId: request.employee.id, + action: "INVENTORY_CHECK_STARTED", + targetType: "inventory_check", + targetId: updated.id, + extra: { + checkNo: updated.check_no, + }, + }), + }); + + return updated; + }); + + return sendData(reply, request, { inventoryCheck: mapInventoryCheck(inventoryCheck) }); + }, + ); + + fastify.post( + "/inventory-checks/:id/items", + { + schema: { tags: ["Assets"], summary: "Record Inventory Check Items" }, + preHandler: fastify.requirePermission("asset.inventory"), + }, + async (request, reply) => { + const inventoryCheckId = parseUuid("id", request.params?.id); + const body = parseBody(RECORD_ITEMS_BODY_SCHEMA, request.body); + + const inventoryCheck = await fastify.prisma.$transaction(async (tx) => { + const current = await ensureInventoryCheck(tx, inventoryCheckId); + ensureCheckStatus(current, "counting"); + + const seen = new Set(); + for (const item of body.items) { + if (seen.has(item.assetId)) { + throw buildValidationError({ items: "duplicate_asset_id" }, 409); + } + seen.add(item.assetId); + + const asset = await tx.assetCard.findUnique({ + where: { id: item.assetId }, + select: { + id: true, + asset_no: true, + name: true, + status: true, + legal_entity_id: true, + cost_center_id: true, + location: true, + assignee_employee_id: true, + }, + }); + if (!asset) { + throw buildValidationError({ assetId: "not_found" }, 404); + } + ensureAssetInScope(current, asset); + + await tx.inventoryCheckItem.upsert({ + where: { + inventory_check_id_asset_id: { + inventory_check_id: inventoryCheckId, + asset_id: item.assetId, + }, + }, + update: { + result: item.result, + note: normalizeOptionalText(item.note), + }, + create: { + inventory_check_id: inventoryCheckId, + asset_id: item.assetId, + result: item.result, + note: normalizeOptionalText(item.note), + }, + }); + } + + await tx.auditLog.create({ + data: buildAuditLogData(request, { + actorId: request.employee.id, + action: "INVENTORY_ITEM_RECORDED", + targetType: "inventory_check", + targetId: inventoryCheckId, + extra: { + itemCount: body.items.length, + assetIds: body.items.map((item) => item.assetId), + }, + }), + }); + + return ensureInventoryCheck(tx, inventoryCheckId); + }); + + return sendData(reply, request, { inventoryCheck: mapInventoryCheck(inventoryCheck) }); + }, + ); + + fastify.post( + "/inventory-checks/:id/complete", + { + schema: { tags: ["Assets"], summary: "Complete Inventory Check" }, + preHandler: fastify.requirePermission("asset.inventory"), + }, + async (request, reply) => { + const inventoryCheckId = parseUuid("id", request.params?.id); + const inventoryCheck = await fastify.prisma.$transaction(async (tx) => { + const current = await ensureInventoryCheck(tx, inventoryCheckId); + ensureCheckStatus(current, "counting"); + + const updated = await tx.inventoryCheck.update({ + where: { id: inventoryCheckId }, + data: { + status: "completed", + completed_at: new Date(), + updated_by: request.employee.id, + }, + select: { + id: true, + check_no: true, + scope_type: true, + scope_ref_id: true, + status: true, + started_at: true, + completed_at: true, + created_at: true, + updated_at: true, + items: { + orderBy: [{ asset: { asset_no: "asc" } }], + select: { + id: true, + asset_id: true, + result: true, + note: true, + asset: { + select: { + id: true, + asset_no: true, + name: true, + status: true, + legal_entity_id: true, + cost_center_id: true, + location: true, + assignee_employee_id: true, + }, + }, + }, + }, + }, + }); + + await tx.auditLog.create({ + data: buildAuditLogData(request, { + actorId: request.employee.id, + action: "INVENTORY_CHECK_COMPLETED", + targetType: "inventory_check", + targetId: updated.id, + extra: { + checkNo: updated.check_no, + itemCount: updated.items.length, + }, + }), + }); + + return updated; + }); + + return sendData(reply, request, { inventoryCheck: mapInventoryCheck(inventoryCheck) }); + }, + ); +} + +module.exports = inventoryChecksRoutes; diff --git a/server/src/services/documentSequence.js b/server/src/services/documentSequence.js index 747b7174..f4844ec5 100644 --- a/server/src/services/documentSequence.js +++ b/server/src/services/documentSequence.js @@ -50,6 +50,17 @@ const DOCUMENT_TYPE_CONFIG = { resetPeriod: "yearly", }, }, + inventory_check: { + defaultCategoryCode: null, + preferLegacySequence: false, + fallback: { + prefix: "PD", + dateFormat: "YYYYMMDD", + separator: "-", + numberLength: 4, + resetPeriod: "yearly", + }, + }, general: { defaultCategoryCode: "general_record", preferLegacySequence: false, @@ -193,6 +204,10 @@ async function findDocumentCategory(prisma, { documentType, documentCategoryCode } } + if (normalizedDocumentType === "inventory_check") { + return null; + } + return prisma.documentCategory.findFirst({ where: { entity_type: normalizedDocumentType, diff --git a/server/tests/unit/documentSequence.service.test.js b/server/tests/unit/documentSequence.service.test.js index 0cef9b60..3e80ef07 100644 --- a/server/tests/unit/documentSequence.service.test.js +++ b/server/tests/unit/documentSequence.service.test.js @@ -131,6 +131,16 @@ describe("documentSequence service", () => { expect(issued.documentNumber).toMatch(/^CG-\d{8}-0001$/); }); + it("falls back to inventory check legacy-free config", async () => { + const prisma = buildPrisma(); + + const config = await resolveDocumentSequenceConfig(prisma, { documentType: "inventory_check" }); + + expect(config.source).toBe("fallback"); + expect(config.prefix).toBe("PD"); + expect(config.documentCategoryCode).toBeNull(); + }); + it("falls back to default asset category code for asset cards", async () => { const prisma = buildPrisma({