feat(P18B-1,P18B-2): 新增资产流转模型与受控接口
这个提交包含在:
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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 }
|
||||
: {}),
|
||||
|
||||
@@ -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;
|
||||
@@ -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({
|
||||
|
||||
在新工单中引用
屏蔽一个用户