feat(P18B-1,P18B-2): 新增资产流转模型与受控接口

这个提交包含在:
2026-03-07 23:20:34 +08:00
父节点 1b2527eee0
当前提交 946a357f8b
共修改 8 个文件,包含 1276 行新增和 7 行删除
@@ -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;
+98
查看文件
@@ -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
+4
查看文件
@@ -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" });
+447
查看文件
@@ -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;
+15 -7
查看文件
@@ -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 }
: {}),
+574
查看文件
@@ -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;
+15
查看文件
@@ -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,
@@ -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({