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

这个提交包含在:
2026-03-07 23:20:34 +08:00
父节点 1b2527eee0
当前提交 946a357f8b
共修改 8 个文件,包含 1276 行新增和 7 行删除
+4
查看文件
@@ -40,6 +40,8 @@ const dashboardRoutes = require("./routes/dashboard");
const budgetsRoutes = require("./routes/budgets");
const purchaseRequestRoutes = require("./routes/purchaseRequests");
const assetsRoutes = require("./routes/assets");
const assetMovementsRoutes = require("./routes/assetMovements");
const inventoryChecksRoutes = require("./routes/inventoryChecks");
const integrationsRoutes = require("./routes/integrations");
const travelOrdersRoutes = require("./routes/travelOrders");
const { startJobs } = require("./jobs/jobSystem");
@@ -299,6 +301,8 @@ async function buildApp() {
await app.register(budgetsRoutes, { prefix: "/api" });
await app.register(purchaseRequestRoutes, { prefix: "/api" });
await app.register(assetsRoutes, { prefix: "/api" });
await app.register(assetMovementsRoutes, { prefix: "/api" });
await app.register(inventoryChecksRoutes, { prefix: "/api" });
await app.register(integrationsRoutes, { prefix: "/api" });
await app.register(travelOrdersRoutes, { prefix: "/api" });
await app.register(exportsRoutes, { prefix: "/api" });
+447
查看文件
@@ -0,0 +1,447 @@
const { z } = require("zod");
const { AppError } = require("../utils/AppError");
const { buildAuditLogData } = require("../utils/audit");
const { sendData } = require("../utils/reply");
const UUID_SCHEMA = z.string().uuid();
const NOTE_SCHEMA = z.string().trim().max(500).nullable().optional();
const LOCATION_SCHEMA = z.string().trim().max(200).nullable().optional();
const ASSIGN_BODY_SCHEMA = z
.object({
employeeId: UUID_SCHEMA,
note: NOTE_SCHEMA,
})
.strict();
const RETURN_BODY_SCHEMA = z
.object({
note: NOTE_SCHEMA,
})
.strict();
const TRANSFER_BODY_SCHEMA = z
.object({
toEmployeeId: UUID_SCHEMA.nullable().optional(),
toLocation: LOCATION_SCHEMA,
note: NOTE_SCHEMA,
})
.strict();
const BLOCKED_MOVEMENT_STATUSES = new Set(["retired", "archived"]);
function buildValidationError(details, statusCode = 400) {
return new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode,
details,
});
}
function parseUuid(fieldName, value) {
const parsed = UUID_SCHEMA.safeParse(value);
if (!parsed.success) {
throw buildValidationError({ [fieldName]: "invalid_uuid" });
}
return parsed.data;
}
function parseBody(schema, body) {
const parsed = schema.safeParse(body ?? {});
if (!parsed.success) {
throw buildValidationError(parsed.error.flatten());
}
return parsed.data;
}
function normalizeNote(value) {
if (value == null) {
return null;
}
const trimmed = String(value).trim();
return trimmed ? trimmed : null;
}
function normalizeLocation(value) {
if (value == null) {
return null;
}
const trimmed = String(value).trim();
return trimmed ? trimmed : null;
}
function ensureMovementAllowed(asset) {
if (BLOCKED_MOVEMENT_STATUSES.has(asset.status)) {
throw buildValidationError({ status: "movement_not_allowed" }, 409);
}
}
async function loadAsset(tx, assetId) {
return tx.assetCard.findUnique({
where: { id: assetId },
select: {
id: true,
asset_no: true,
status: true,
assignee_employee_id: true,
location: true,
activated_at: true,
created_at: true,
},
});
}
async function ensureAssetExists(tx, assetId) {
const asset = await loadAsset(tx, assetId);
if (!asset) {
throw buildValidationError({ id: "not_found" }, 404);
}
ensureMovementAllowed(asset);
return asset;
}
async function ensureActiveEmployee(tx, employeeId, fieldName = "employeeId") {
const employee = await tx.employee.findUnique({
where: { id: employeeId },
select: {
id: true,
employee_no: true,
name: true,
status: true,
},
});
if (!employee) {
throw buildValidationError({ [fieldName]: "not_found" }, 404);
}
if (employee.status !== "active") {
throw buildValidationError({ [fieldName]: "inactive" }, 409);
}
return employee;
}
async function findOpenAssignment(tx, assetId) {
return tx.assetAssignment.findFirst({
where: {
asset_id: assetId,
returned_at: null,
},
orderBy: [{ assigned_at: "desc" }, { id: "desc" }],
select: {
id: true,
asset_id: true,
employee_id: true,
assigned_at: true,
returned_at: true,
},
});
}
async function ensureOpenAssignment(tx, asset) {
const existing = await findOpenAssignment(tx, asset.id);
if (existing) {
return existing;
}
if (!asset.assignee_employee_id) {
return null;
}
return tx.assetAssignment.create({
data: {
asset_id: asset.id,
employee_id: asset.assignee_employee_id,
assigned_at: asset.activated_at ?? asset.created_at,
note: null,
},
select: {
id: true,
asset_id: true,
employee_id: true,
assigned_at: true,
returned_at: true,
},
});
}
async function closeAssignmentIfAny(tx, asset, note, returnedAt) {
const openAssignment = await ensureOpenAssignment(tx, asset);
if (!openAssignment) {
return null;
}
return tx.assetAssignment.update({
where: { id: openAssignment.id },
data: {
returned_at: returnedAt,
note: note ?? undefined,
},
select: {
id: true,
asset_id: true,
employee_id: true,
assigned_at: true,
returned_at: true,
},
});
}
async function assetMovementsRoutes(fastify) {
fastify.post(
"/assets/:id/assign",
{
schema: { tags: ["Assets"], summary: "Assign Asset" },
preHandler: fastify.requirePermission("asset.transfer"),
},
async (request, reply) => {
const assetId = parseUuid("id", request.params?.id);
const body = parseBody(ASSIGN_BODY_SCHEMA, request.body);
const note = normalizeNote(body.note);
const result = await fastify.prisma.$transaction(async (tx) => {
const asset = await ensureAssetExists(tx, assetId);
const openAssignment = await findOpenAssignment(tx, asset.id);
if (asset.assignee_employee_id || openAssignment) {
throw buildValidationError({ asset: "already_assigned" }, 409);
}
const employee = await ensureActiveEmployee(tx, body.employeeId);
const assignedAt = new Date();
const assignment = await tx.assetAssignment.create({
data: {
asset_id: asset.id,
employee_id: employee.id,
assigned_at: assignedAt,
note,
},
select: {
id: true,
employee_id: true,
assigned_at: true,
},
});
const updatedAsset = await tx.assetCard.update({
where: { id: asset.id },
data: {
assignee_employee_id: employee.id,
status: "in_use",
updated_by: request.employee.id,
},
select: {
id: true,
asset_no: true,
status: true,
assignee_employee_id: true,
location: true,
},
});
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "ASSET_ASSIGNED",
targetType: "asset_card",
targetId: asset.id,
extra: {
assetNo: asset.asset_no,
employeeId: employee.id,
assignmentId: assignment.id,
},
}),
});
return { updatedAsset, assignmentId: assignment.id };
});
return sendData(reply, request, {
assetId: result.updatedAsset.id,
assetNo: result.updatedAsset.asset_no,
status: result.updatedAsset.status,
assigneeEmployeeId: result.updatedAsset.assignee_employee_id,
location: result.updatedAsset.location ?? null,
assignmentId: result.assignmentId,
});
},
);
fastify.post(
"/assets/:id/return",
{
schema: { tags: ["Assets"], summary: "Return Asset" },
preHandler: fastify.requirePermission("asset.transfer"),
},
async (request, reply) => {
const assetId = parseUuid("id", request.params?.id);
const body = parseBody(RETURN_BODY_SCHEMA, request.body);
const note = normalizeNote(body.note);
const result = await fastify.prisma.$transaction(async (tx) => {
const asset = await ensureAssetExists(tx, assetId);
if (!asset.assignee_employee_id) {
throw buildValidationError({ asset: "not_assigned" }, 409);
}
const returnedAt = new Date();
const closedAssignment = await closeAssignmentIfAny(tx, asset, note, returnedAt);
const updatedAsset = await tx.assetCard.update({
where: { id: asset.id },
data: {
assignee_employee_id: null,
status: "in_stock",
updated_by: request.employee.id,
},
select: {
id: true,
asset_no: true,
status: true,
assignee_employee_id: true,
location: true,
},
});
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "ASSET_RETURNED",
targetType: "asset_card",
targetId: asset.id,
extra: {
assetNo: asset.asset_no,
previousEmployeeId: asset.assignee_employee_id,
assignmentId: closedAssignment?.id ?? null,
},
}),
});
return { updatedAsset, assignmentId: closedAssignment?.id ?? null };
});
return sendData(reply, request, {
assetId: result.updatedAsset.id,
assetNo: result.updatedAsset.asset_no,
status: result.updatedAsset.status,
assigneeEmployeeId: result.updatedAsset.assignee_employee_id,
location: result.updatedAsset.location ?? null,
assignmentId: result.assignmentId,
});
},
);
fastify.post(
"/assets/:id/transfer",
{
schema: { tags: ["Assets"], summary: "Transfer Asset" },
preHandler: fastify.requirePermission("asset.transfer"),
},
async (request, reply) => {
const assetId = parseUuid("id", request.params?.id);
const body = parseBody(TRANSFER_BODY_SCHEMA, request.body);
const note = normalizeNote(body.note);
const hasEmployeeField = Object.prototype.hasOwnProperty.call(body, "toEmployeeId");
const hasLocationField = Object.prototype.hasOwnProperty.call(body, "toLocation");
const nextLocation = hasLocationField ? normalizeLocation(body.toLocation) : undefined;
const result = await fastify.prisma.$transaction(async (tx) => {
const asset = await ensureAssetExists(tx, assetId);
const currentEmployeeId = asset.assignee_employee_id ?? null;
const currentLocation = asset.location ?? null;
const requestedEmployeeId = hasEmployeeField ? body.toEmployeeId ?? null : currentEmployeeId;
if (hasEmployeeField && body.toEmployeeId == null && currentEmployeeId) {
throw buildValidationError({ toEmployeeId: "use_return" }, 409);
}
const employeeChange = hasEmployeeField && requestedEmployeeId !== currentEmployeeId;
const locationChange = hasLocationField && nextLocation !== currentLocation;
if (!employeeChange && !locationChange) {
throw buildValidationError({ transfer: "no_change" }, 409);
}
if (employeeChange) {
await ensureActiveEmployee(tx, requestedEmployeeId, "toEmployeeId");
}
const transferredAt = new Date();
if (employeeChange && currentEmployeeId) {
await closeAssignmentIfAny(tx, asset, note, transferredAt);
}
let assignmentId = null;
if (employeeChange && requestedEmployeeId) {
const newAssignment = await tx.assetAssignment.create({
data: {
asset_id: asset.id,
employee_id: requestedEmployeeId,
assigned_at: transferredAt,
note,
},
select: { id: true },
});
assignmentId = newAssignment.id;
}
const updatedAsset = await tx.assetCard.update({
where: { id: asset.id },
data: {
...(employeeChange ? { assignee_employee_id: requestedEmployeeId } : {}),
...(locationChange ? { location: nextLocation } : {}),
...(employeeChange && requestedEmployeeId ? { status: "in_use" } : {}),
updated_by: request.employee.id,
},
select: {
id: true,
asset_no: true,
status: true,
assignee_employee_id: true,
location: true,
},
});
const transfer = await tx.assetTransfer.create({
data: {
asset_id: asset.id,
from_employee_id: currentEmployeeId,
to_employee_id: employeeChange ? requestedEmployeeId : currentEmployeeId,
from_location: currentLocation,
to_location: locationChange ? nextLocation : currentLocation,
transferred_at: transferredAt,
note,
},
select: { id: true },
});
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "ASSET_TRANSFERRED",
targetType: "asset_card",
targetId: asset.id,
extra: {
assetNo: asset.asset_no,
fromEmployeeId: currentEmployeeId,
toEmployeeId: employeeChange ? requestedEmployeeId : currentEmployeeId,
fromLocation: currentLocation,
toLocation: locationChange ? nextLocation : currentLocation,
transferId: transfer.id,
assignmentId,
},
}),
});
return { updatedAsset, transferId: transfer.id, assignmentId };
});
return sendData(reply, request, {
assetId: result.updatedAsset.id,
assetNo: result.updatedAsset.asset_no,
status: result.updatedAsset.status,
assigneeEmployeeId: result.updatedAsset.assignee_employee_id,
location: result.updatedAsset.location ?? null,
transferId: result.transferId,
assignmentId: result.assignmentId,
});
},
);
}
module.exports = assetMovementsRoutes;
+15 -7
查看文件
@@ -56,7 +56,7 @@ const ASSET_CREATE_SCHEMA = z
})
.strict();
const ASSET_PATCH_SCHEMA = ASSET_CREATE_SCHEMA.omit({ purchaseRequestId: true }).partial();
const ASSET_PATCH_SCHEMA = ASSET_CREATE_SCHEMA.omit({ purchaseRequestId: true, assigneeEmployeeId: true }).partial();
const ASSET_CATEGORY_SELECT = {
id: true,
@@ -796,6 +796,8 @@ async function assetsRoutes(fastify) {
actorId: request.employee.id,
});
const resolvedStatus = payload.assigneeEmployeeId ? "in_use" : payload.status ?? "in_stock";
const created = await tx.assetCard.create({
data: {
asset_no: issued.documentNumber,
@@ -806,7 +808,7 @@ async function assetsRoutes(fastify) {
purchase_request_id: payload.purchaseRequestId ?? null,
serial_no: payload.serialNo ?? null,
location: payload.location ?? null,
status: payload.status ?? "in_stock",
status: resolvedStatus,
assignee_employee_id: payload.assigneeEmployeeId ?? null,
activated_at: payload.activatedAt ? parseDateOnlyString(payload.activatedAt) : null,
retired_at: payload.retiredAt ? parseDateOnlyString(payload.retiredAt) : null,
@@ -816,6 +818,17 @@ async function assetsRoutes(fastify) {
select: ASSET_CARD_SELECT,
});
if (created.assignee_employee_id) {
await tx.assetAssignment.create({
data: {
asset_id: created.id,
employee_id: created.assignee_employee_id,
assigned_at: created.activated_at ?? created.created_at,
note: null,
},
});
}
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
@@ -894,7 +907,6 @@ async function assetsRoutes(fastify) {
const nextCategoryId = payload.categoryId ?? current.category_id;
const nextLegalEntityId = payload.legalEntityId ?? current.legal_entity_id;
const nextCostCenterId = payload.costCenterId ?? current.cost_center_id;
const nextAssigneeId = payload.assigneeEmployeeId === undefined ? current.assignee_employee_id : payload.assigneeEmployeeId;
if (payload.categoryId !== undefined) {
await ensureAssetCategoryExists(tx, nextCategoryId, { requireActive: true });
@@ -905,9 +917,6 @@ async function assetsRoutes(fastify) {
if (payload.costCenterId !== undefined || payload.legalEntityId !== undefined) {
await ensureCostCenterAvailable(tx, nextCostCenterId, nextLegalEntityId);
}
if (payload.assigneeEmployeeId !== undefined) {
await ensureAssigneeEmployee(tx, nextAssigneeId);
}
const updated = await tx.assetCard.update({
where: { id: assetId },
@@ -919,7 +928,6 @@ async function assetsRoutes(fastify) {
...(payload.serialNo !== undefined ? { serial_no: payload.serialNo } : {}),
...(payload.location !== undefined ? { location: payload.location } : {}),
...(payload.status !== undefined ? { status: payload.status } : {}),
...(payload.assigneeEmployeeId !== undefined ? { assignee_employee_id: payload.assigneeEmployeeId } : {}),
...(payload.activatedAt !== undefined
? { activated_at: payload.activatedAt ? parseDateOnlyString(payload.activatedAt) : null }
: {}),
+574
查看文件
@@ -0,0 +1,574 @@
const { z } = require("zod");
const { AppError } = require("../utils/AppError");
const { buildAuditLogData } = require("../utils/audit");
const { issueDocumentNumber } = require("../services/documentSequence");
const { sendData } = require("../utils/reply");
const UUID_SCHEMA = z.string().uuid();
const INVENTORY_SCOPE_TYPES = ["all", "legal_entity", "cost_center", "location"];
const INVENTORY_STATUS_VALUES = ["draft", "counting", "completed"];
const INVENTORY_RESULT_VALUES = ["matched", "missing", "extra", "damaged"];
const LIST_QUERY_SCHEMA = z.object({
status: z.enum(INVENTORY_STATUS_VALUES).optional(),
scopeType: z.enum(INVENTORY_SCOPE_TYPES).optional(),
});
const CREATE_BODY_SCHEMA = z
.object({
scopeType: z.enum(INVENTORY_SCOPE_TYPES),
scopeRefId: z.string().trim().max(200).nullable().optional(),
})
.strict();
const RECORD_ITEMS_BODY_SCHEMA = z
.object({
items: z
.array(
z
.object({
assetId: UUID_SCHEMA,
result: z.enum(INVENTORY_RESULT_VALUES),
note: z.string().trim().max(500).nullable().optional(),
})
.strict(),
)
.min(1),
})
.strict();
function buildValidationError(details, statusCode = 400) {
return new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode,
details,
});
}
function parseUuid(fieldName, value) {
const parsed = UUID_SCHEMA.safeParse(value);
if (!parsed.success) {
throw buildValidationError({ [fieldName]: "invalid_uuid" });
}
return parsed.data;
}
function parseBody(schema, body) {
const parsed = schema.safeParse(body ?? {});
if (!parsed.success) {
throw buildValidationError(parsed.error.flatten());
}
return parsed.data;
}
function parseListQuery(query) {
const parsed = LIST_QUERY_SCHEMA.safeParse(query ?? {});
if (!parsed.success) {
throw buildValidationError(parsed.error.flatten());
}
return parsed.data;
}
function normalizeOptionalText(value) {
if (value == null) {
return null;
}
const trimmed = String(value).trim();
return trimmed ? trimmed : null;
}
async function ensureScopeReference(tx, scopeType, scopeRefId) {
if (scopeType === "all") {
return null;
}
if (!scopeRefId) {
throw buildValidationError({ scopeRefId: "required" });
}
if (scopeType === "location") {
return scopeRefId;
}
const normalizedId = parseUuid("scopeRefId", scopeRefId);
if (scopeType === "legal_entity") {
const legalEntity = await tx.legalEntity.findUnique({
where: { id: normalizedId },
select: { id: true },
});
if (!legalEntity) {
throw buildValidationError({ scopeRefId: "not_found" }, 404);
}
return normalizedId;
}
const costCenter = await tx.costCenter.findUnique({
where: { id: normalizedId },
select: { id: true },
});
if (!costCenter) {
throw buildValidationError({ scopeRefId: "not_found" }, 404);
}
return normalizedId;
}
function mapInventoryItem(record) {
return {
id: record.id,
assetId: record.asset_id,
result: record.result,
note: record.note ?? null,
asset: record.asset
? {
id: record.asset.id,
assetNo: record.asset.asset_no,
name: record.asset.name,
status: record.asset.status,
legalEntityId: record.asset.legal_entity_id,
costCenterId: record.asset.cost_center_id,
location: record.asset.location ?? null,
assigneeEmployeeId: record.asset.assignee_employee_id ?? null,
}
: null,
};
}
function mapInventoryCheck(record) {
return {
id: record.id,
checkNo: record.check_no,
scopeType: record.scope_type,
scopeRefId: record.scope_ref_id ?? null,
status: record.status,
startedAt: record.started_at?.toISOString?.() ?? null,
completedAt: record.completed_at?.toISOString?.() ?? null,
createdAt: record.created_at?.toISOString?.() ?? null,
updatedAt: record.updated_at?.toISOString?.() ?? null,
items: (record.items ?? []).map(mapInventoryItem),
};
}
async function ensureInventoryCheck(tx, inventoryCheckId) {
const record = await tx.inventoryCheck.findUnique({
where: { id: inventoryCheckId },
select: {
id: true,
check_no: true,
scope_type: true,
scope_ref_id: true,
status: true,
started_at: true,
completed_at: true,
created_at: true,
updated_at: true,
items: {
orderBy: [{ asset: { asset_no: "asc" } }],
select: {
id: true,
asset_id: true,
result: true,
note: true,
asset: {
select: {
id: true,
asset_no: true,
name: true,
status: true,
legal_entity_id: true,
cost_center_id: true,
location: true,
assignee_employee_id: true,
},
},
},
},
},
});
if (!record) {
throw buildValidationError({ id: "not_found" }, 404);
}
return record;
}
function ensureCheckStatus(record, expectedStatus) {
if (record.status !== expectedStatus) {
throw buildValidationError({ status: `expected_${expectedStatus}` }, 409);
}
}
function ensureAssetInScope(check, asset) {
if (check.scope_type === "all") {
return;
}
if (check.scope_type === "legal_entity" && asset.legal_entity_id !== check.scope_ref_id) {
throw buildValidationError({ assetId: "scope_mismatch" }, 409);
}
if (check.scope_type === "cost_center" && asset.cost_center_id !== check.scope_ref_id) {
throw buildValidationError({ assetId: "scope_mismatch" }, 409);
}
if (check.scope_type === "location" && (asset.location ?? null) !== (check.scope_ref_id ?? null)) {
throw buildValidationError({ assetId: "scope_mismatch" }, 409);
}
}
async function inventoryChecksRoutes(fastify) {
fastify.get(
"/inventory-checks",
{
schema: { tags: ["Assets"], summary: "List Inventory Checks" },
preHandler: fastify.requirePermission("asset.inventory"),
},
async (request, reply) => {
const query = parseListQuery(request.query);
const where = {};
if (query.status) {
where.status = query.status;
}
if (query.scopeType) {
where.scope_type = query.scopeType;
}
const items = await fastify.prisma.inventoryCheck.findMany({
where,
orderBy: [{ created_at: "desc" }, { id: "desc" }],
select: {
id: true,
check_no: true,
scope_type: true,
scope_ref_id: true,
status: true,
started_at: true,
completed_at: true,
created_at: true,
updated_at: true,
items: {
orderBy: [{ asset: { asset_no: "asc" } }],
select: {
id: true,
asset_id: true,
result: true,
note: true,
asset: {
select: {
id: true,
asset_no: true,
name: true,
status: true,
legal_entity_id: true,
cost_center_id: true,
location: true,
assignee_employee_id: true,
},
},
},
},
},
});
return sendData(reply, request, { items: items.map(mapInventoryCheck) });
},
);
fastify.post(
"/inventory-checks",
{
schema: { tags: ["Assets"], summary: "Create Inventory Check" },
preHandler: fastify.requirePermission("asset.inventory"),
},
async (request, reply) => {
const body = parseBody(CREATE_BODY_SCHEMA, request.body);
const scopeRefId = normalizeOptionalText(body.scopeRefId);
const inventoryCheck = await fastify.prisma.$transaction(async (tx) => {
const normalizedScopeRefId = await ensureScopeReference(tx, body.scopeType, scopeRefId);
const issued = await issueDocumentNumber(tx, {
documentType: "inventory_check",
actorId: request.employee.id,
});
const created = await tx.inventoryCheck.create({
data: {
check_no: issued.documentNumber,
scope_type: body.scopeType,
scope_ref_id: normalizedScopeRefId,
created_by: request.employee.id,
updated_by: request.employee.id,
},
select: {
id: true,
check_no: true,
scope_type: true,
scope_ref_id: true,
status: true,
started_at: true,
completed_at: true,
created_at: true,
updated_at: true,
items: {
select: {
id: true,
asset_id: true,
result: true,
note: true,
asset: {
select: {
id: true,
asset_no: true,
name: true,
status: true,
legal_entity_id: true,
cost_center_id: true,
location: true,
assignee_employee_id: true,
},
},
},
},
},
});
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "INVENTORY_CHECK_CREATED",
targetType: "inventory_check",
targetId: created.id,
extra: {
checkNo: created.check_no,
scopeType: created.scope_type,
scopeRefId: created.scope_ref_id ?? null,
},
}),
});
return created;
});
return sendData(reply, request, { inventoryCheck: mapInventoryCheck(inventoryCheck) });
},
);
fastify.post(
"/inventory-checks/:id/start",
{
schema: { tags: ["Assets"], summary: "Start Inventory Check" },
preHandler: fastify.requirePermission("asset.inventory"),
},
async (request, reply) => {
const inventoryCheckId = parseUuid("id", request.params?.id);
const inventoryCheck = await fastify.prisma.$transaction(async (tx) => {
const current = await ensureInventoryCheck(tx, inventoryCheckId);
ensureCheckStatus(current, "draft");
const updated = await tx.inventoryCheck.update({
where: { id: inventoryCheckId },
data: {
status: "counting",
started_at: new Date(),
updated_by: request.employee.id,
},
select: {
id: true,
check_no: true,
scope_type: true,
scope_ref_id: true,
status: true,
started_at: true,
completed_at: true,
created_at: true,
updated_at: true,
items: {
orderBy: [{ asset: { asset_no: "asc" } }],
select: {
id: true,
asset_id: true,
result: true,
note: true,
asset: {
select: {
id: true,
asset_no: true,
name: true,
status: true,
legal_entity_id: true,
cost_center_id: true,
location: true,
assignee_employee_id: true,
},
},
},
},
},
});
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "INVENTORY_CHECK_STARTED",
targetType: "inventory_check",
targetId: updated.id,
extra: {
checkNo: updated.check_no,
},
}),
});
return updated;
});
return sendData(reply, request, { inventoryCheck: mapInventoryCheck(inventoryCheck) });
},
);
fastify.post(
"/inventory-checks/:id/items",
{
schema: { tags: ["Assets"], summary: "Record Inventory Check Items" },
preHandler: fastify.requirePermission("asset.inventory"),
},
async (request, reply) => {
const inventoryCheckId = parseUuid("id", request.params?.id);
const body = parseBody(RECORD_ITEMS_BODY_SCHEMA, request.body);
const inventoryCheck = await fastify.prisma.$transaction(async (tx) => {
const current = await ensureInventoryCheck(tx, inventoryCheckId);
ensureCheckStatus(current, "counting");
const seen = new Set();
for (const item of body.items) {
if (seen.has(item.assetId)) {
throw buildValidationError({ items: "duplicate_asset_id" }, 409);
}
seen.add(item.assetId);
const asset = await tx.assetCard.findUnique({
where: { id: item.assetId },
select: {
id: true,
asset_no: true,
name: true,
status: true,
legal_entity_id: true,
cost_center_id: true,
location: true,
assignee_employee_id: true,
},
});
if (!asset) {
throw buildValidationError({ assetId: "not_found" }, 404);
}
ensureAssetInScope(current, asset);
await tx.inventoryCheckItem.upsert({
where: {
inventory_check_id_asset_id: {
inventory_check_id: inventoryCheckId,
asset_id: item.assetId,
},
},
update: {
result: item.result,
note: normalizeOptionalText(item.note),
},
create: {
inventory_check_id: inventoryCheckId,
asset_id: item.assetId,
result: item.result,
note: normalizeOptionalText(item.note),
},
});
}
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "INVENTORY_ITEM_RECORDED",
targetType: "inventory_check",
targetId: inventoryCheckId,
extra: {
itemCount: body.items.length,
assetIds: body.items.map((item) => item.assetId),
},
}),
});
return ensureInventoryCheck(tx, inventoryCheckId);
});
return sendData(reply, request, { inventoryCheck: mapInventoryCheck(inventoryCheck) });
},
);
fastify.post(
"/inventory-checks/:id/complete",
{
schema: { tags: ["Assets"], summary: "Complete Inventory Check" },
preHandler: fastify.requirePermission("asset.inventory"),
},
async (request, reply) => {
const inventoryCheckId = parseUuid("id", request.params?.id);
const inventoryCheck = await fastify.prisma.$transaction(async (tx) => {
const current = await ensureInventoryCheck(tx, inventoryCheckId);
ensureCheckStatus(current, "counting");
const updated = await tx.inventoryCheck.update({
where: { id: inventoryCheckId },
data: {
status: "completed",
completed_at: new Date(),
updated_by: request.employee.id,
},
select: {
id: true,
check_no: true,
scope_type: true,
scope_ref_id: true,
status: true,
started_at: true,
completed_at: true,
created_at: true,
updated_at: true,
items: {
orderBy: [{ asset: { asset_no: "asc" } }],
select: {
id: true,
asset_id: true,
result: true,
note: true,
asset: {
select: {
id: true,
asset_no: true,
name: true,
status: true,
legal_entity_id: true,
cost_center_id: true,
location: true,
assignee_employee_id: true,
},
},
},
},
},
});
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "INVENTORY_CHECK_COMPLETED",
targetType: "inventory_check",
targetId: updated.id,
extra: {
checkNo: updated.check_no,
itemCount: updated.items.length,
},
}),
});
return updated;
});
return sendData(reply, request, { inventoryCheck: mapInventoryCheck(inventoryCheck) });
},
);
}
module.exports = inventoryChecksRoutes;
+15
查看文件
@@ -50,6 +50,17 @@ const DOCUMENT_TYPE_CONFIG = {
resetPeriod: "yearly",
},
},
inventory_check: {
defaultCategoryCode: null,
preferLegacySequence: false,
fallback: {
prefix: "PD",
dateFormat: "YYYYMMDD",
separator: "-",
numberLength: 4,
resetPeriod: "yearly",
},
},
general: {
defaultCategoryCode: "general_record",
preferLegacySequence: false,
@@ -193,6 +204,10 @@ async function findDocumentCategory(prisma, { documentType, documentCategoryCode
}
}
if (normalizedDocumentType === "inventory_check") {
return null;
}
return prisma.documentCategory.findFirst({
where: {
entity_type: normalizedDocumentType,