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
|
archived
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum InventoryCheckScopeType {
|
||||||
|
all
|
||||||
|
legal_entity
|
||||||
|
cost_center
|
||||||
|
location
|
||||||
|
}
|
||||||
|
|
||||||
|
enum InventoryCheckStatus {
|
||||||
|
draft
|
||||||
|
counting
|
||||||
|
completed
|
||||||
|
}
|
||||||
|
|
||||||
|
enum InventoryCheckResult {
|
||||||
|
matched
|
||||||
|
missing
|
||||||
|
extra
|
||||||
|
damaged
|
||||||
|
}
|
||||||
|
|
||||||
enum LeaveDaySession {
|
enum LeaveDaySession {
|
||||||
am
|
am
|
||||||
pm
|
pm
|
||||||
@@ -242,6 +262,9 @@ model Employee {
|
|||||||
purchase_attachments_uploaded PurchaseAttachment[] @relation("PurchaseAttachmentUploader")
|
purchase_attachments_uploaded PurchaseAttachment[] @relation("PurchaseAttachmentUploader")
|
||||||
purchase_order_archives PurchaseOrderArchive[] @relation("PurchaseOrderArchiveArchivedBy")
|
purchase_order_archives PurchaseOrderArchive[] @relation("PurchaseOrderArchiveArchivedBy")
|
||||||
assigned_asset_cards AssetCard[] @relation("AssetCardAssignee")
|
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")
|
owned_invoices InvoicePool[] @relation("InvoiceOwner")
|
||||||
uploaded_invoices InvoicePool[] @relation("InvoiceUploader")
|
uploaded_invoices InvoicePool[] @relation("InvoiceUploader")
|
||||||
@@ -715,6 +738,9 @@ model AssetCard {
|
|||||||
cost_center CostCenter @relation(fields: [cost_center_id], references: [id], onDelete: Restrict)
|
cost_center CostCenter @relation(fields: [cost_center_id], references: [id], onDelete: Restrict)
|
||||||
purchase_request PurchaseRequest? @relation(fields: [purchase_request_id], references: [id], onDelete: SetNull)
|
purchase_request PurchaseRequest? @relation(fields: [purchase_request_id], references: [id], onDelete: SetNull)
|
||||||
assignee Employee? @relation("AssetCardAssignee", fields: [assignee_employee_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([category_id], map: "idx_asset_cards_category")
|
||||||
@@index([legal_entity_id], map: "idx_asset_cards_legal_entity")
|
@@index([legal_entity_id], map: "idx_asset_cards_legal_entity")
|
||||||
@@ -725,6 +751,78 @@ model AssetCard {
|
|||||||
@@map("asset_cards")
|
@@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 {
|
model ReimbursementForm {
|
||||||
id String @id @default(uuid()) @db.Uuid
|
id String @id @default(uuid()) @db.Uuid
|
||||||
form_number String? @unique
|
form_number String? @unique
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ const dashboardRoutes = require("./routes/dashboard");
|
|||||||
const budgetsRoutes = require("./routes/budgets");
|
const budgetsRoutes = require("./routes/budgets");
|
||||||
const purchaseRequestRoutes = require("./routes/purchaseRequests");
|
const purchaseRequestRoutes = require("./routes/purchaseRequests");
|
||||||
const assetsRoutes = require("./routes/assets");
|
const assetsRoutes = require("./routes/assets");
|
||||||
|
const assetMovementsRoutes = require("./routes/assetMovements");
|
||||||
|
const inventoryChecksRoutes = require("./routes/inventoryChecks");
|
||||||
const integrationsRoutes = require("./routes/integrations");
|
const integrationsRoutes = require("./routes/integrations");
|
||||||
const travelOrdersRoutes = require("./routes/travelOrders");
|
const travelOrdersRoutes = require("./routes/travelOrders");
|
||||||
const { startJobs } = require("./jobs/jobSystem");
|
const { startJobs } = require("./jobs/jobSystem");
|
||||||
@@ -299,6 +301,8 @@ async function buildApp() {
|
|||||||
await app.register(budgetsRoutes, { prefix: "/api" });
|
await app.register(budgetsRoutes, { prefix: "/api" });
|
||||||
await app.register(purchaseRequestRoutes, { prefix: "/api" });
|
await app.register(purchaseRequestRoutes, { prefix: "/api" });
|
||||||
await app.register(assetsRoutes, { 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(integrationsRoutes, { prefix: "/api" });
|
||||||
await app.register(travelOrdersRoutes, { prefix: "/api" });
|
await app.register(travelOrdersRoutes, { prefix: "/api" });
|
||||||
await app.register(exportsRoutes, { 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();
|
.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 = {
|
const ASSET_CATEGORY_SELECT = {
|
||||||
id: true,
|
id: true,
|
||||||
@@ -796,6 +796,8 @@ async function assetsRoutes(fastify) {
|
|||||||
actorId: request.employee.id,
|
actorId: request.employee.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const resolvedStatus = payload.assigneeEmployeeId ? "in_use" : payload.status ?? "in_stock";
|
||||||
|
|
||||||
const created = await tx.assetCard.create({
|
const created = await tx.assetCard.create({
|
||||||
data: {
|
data: {
|
||||||
asset_no: issued.documentNumber,
|
asset_no: issued.documentNumber,
|
||||||
@@ -806,7 +808,7 @@ async function assetsRoutes(fastify) {
|
|||||||
purchase_request_id: payload.purchaseRequestId ?? null,
|
purchase_request_id: payload.purchaseRequestId ?? null,
|
||||||
serial_no: payload.serialNo ?? null,
|
serial_no: payload.serialNo ?? null,
|
||||||
location: payload.location ?? null,
|
location: payload.location ?? null,
|
||||||
status: payload.status ?? "in_stock",
|
status: resolvedStatus,
|
||||||
assignee_employee_id: payload.assigneeEmployeeId ?? null,
|
assignee_employee_id: payload.assigneeEmployeeId ?? null,
|
||||||
activated_at: payload.activatedAt ? parseDateOnlyString(payload.activatedAt) : null,
|
activated_at: payload.activatedAt ? parseDateOnlyString(payload.activatedAt) : null,
|
||||||
retired_at: payload.retiredAt ? parseDateOnlyString(payload.retiredAt) : null,
|
retired_at: payload.retiredAt ? parseDateOnlyString(payload.retiredAt) : null,
|
||||||
@@ -816,6 +818,17 @@ async function assetsRoutes(fastify) {
|
|||||||
select: ASSET_CARD_SELECT,
|
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({
|
await tx.auditLog.create({
|
||||||
data: buildAuditLogData(request, {
|
data: buildAuditLogData(request, {
|
||||||
actorId: request.employee.id,
|
actorId: request.employee.id,
|
||||||
@@ -894,7 +907,6 @@ async function assetsRoutes(fastify) {
|
|||||||
const nextCategoryId = payload.categoryId ?? current.category_id;
|
const nextCategoryId = payload.categoryId ?? current.category_id;
|
||||||
const nextLegalEntityId = payload.legalEntityId ?? current.legal_entity_id;
|
const nextLegalEntityId = payload.legalEntityId ?? current.legal_entity_id;
|
||||||
const nextCostCenterId = payload.costCenterId ?? current.cost_center_id;
|
const nextCostCenterId = payload.costCenterId ?? current.cost_center_id;
|
||||||
const nextAssigneeId = payload.assigneeEmployeeId === undefined ? current.assignee_employee_id : payload.assigneeEmployeeId;
|
|
||||||
|
|
||||||
if (payload.categoryId !== undefined) {
|
if (payload.categoryId !== undefined) {
|
||||||
await ensureAssetCategoryExists(tx, nextCategoryId, { requireActive: true });
|
await ensureAssetCategoryExists(tx, nextCategoryId, { requireActive: true });
|
||||||
@@ -905,9 +917,6 @@ async function assetsRoutes(fastify) {
|
|||||||
if (payload.costCenterId !== undefined || payload.legalEntityId !== undefined) {
|
if (payload.costCenterId !== undefined || payload.legalEntityId !== undefined) {
|
||||||
await ensureCostCenterAvailable(tx, nextCostCenterId, nextLegalEntityId);
|
await ensureCostCenterAvailable(tx, nextCostCenterId, nextLegalEntityId);
|
||||||
}
|
}
|
||||||
if (payload.assigneeEmployeeId !== undefined) {
|
|
||||||
await ensureAssigneeEmployee(tx, nextAssigneeId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated = await tx.assetCard.update({
|
const updated = await tx.assetCard.update({
|
||||||
where: { id: assetId },
|
where: { id: assetId },
|
||||||
@@ -919,7 +928,6 @@ async function assetsRoutes(fastify) {
|
|||||||
...(payload.serialNo !== undefined ? { serial_no: payload.serialNo } : {}),
|
...(payload.serialNo !== undefined ? { serial_no: payload.serialNo } : {}),
|
||||||
...(payload.location !== undefined ? { location: payload.location } : {}),
|
...(payload.location !== undefined ? { location: payload.location } : {}),
|
||||||
...(payload.status !== undefined ? { status: payload.status } : {}),
|
...(payload.status !== undefined ? { status: payload.status } : {}),
|
||||||
...(payload.assigneeEmployeeId !== undefined ? { assignee_employee_id: payload.assigneeEmployeeId } : {}),
|
|
||||||
...(payload.activatedAt !== undefined
|
...(payload.activatedAt !== undefined
|
||||||
? { activated_at: payload.activatedAt ? parseDateOnlyString(payload.activatedAt) : null }
|
? { 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",
|
resetPeriod: "yearly",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
inventory_check: {
|
||||||
|
defaultCategoryCode: null,
|
||||||
|
preferLegacySequence: false,
|
||||||
|
fallback: {
|
||||||
|
prefix: "PD",
|
||||||
|
dateFormat: "YYYYMMDD",
|
||||||
|
separator: "-",
|
||||||
|
numberLength: 4,
|
||||||
|
resetPeriod: "yearly",
|
||||||
|
},
|
||||||
|
},
|
||||||
general: {
|
general: {
|
||||||
defaultCategoryCode: "general_record",
|
defaultCategoryCode: "general_record",
|
||||||
preferLegacySequence: false,
|
preferLegacySequence: false,
|
||||||
@@ -193,6 +204,10 @@ async function findDocumentCategory(prisma, { documentType, documentCategoryCode
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (normalizedDocumentType === "inventory_check") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return prisma.documentCategory.findFirst({
|
return prisma.documentCategory.findFirst({
|
||||||
where: {
|
where: {
|
||||||
entity_type: normalizedDocumentType,
|
entity_type: normalizedDocumentType,
|
||||||
|
|||||||
@@ -131,6 +131,16 @@ describe("documentSequence service", () => {
|
|||||||
expect(issued.documentNumber).toMatch(/^CG-\d{8}-0001$/);
|
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 () => {
|
it("falls back to default asset category code for asset cards", async () => {
|
||||||
const prisma = buildPrisma({
|
const prisma = buildPrisma({
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户