文件
hyb-oa/server/src/routes/inventoryChecks.js
T

638 行
19 KiB
JavaScript

const { z } = require("zod");
const { AppError } = require("../utils/AppError");
const { buildAuditLogData } = require("../utils/audit");
const { issueDocumentNumber } = require("../services/documentSequence");
const { createNotificationsWithRecords, resolveRecipientsForAssignee } = require("../services/notifications");
const { sendNotificationEmailSafe } = require("../services/email");
const { NOTIFICATION_TYPES } = require("../constants/notificationTypes");
const { sendData } = require("../utils/reply");
const { flushNotificationDispatchTasks } = require("../services/notificationDispatcher");
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;
}
function uniq(values) {
return Array.from(new Set((values ?? []).filter(Boolean)));
}
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,
created_by: 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,
created_by: 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,
created_by: 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 emailTasks = [];
const dispatchTasks = [];
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,
created_by: 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,
},
}),
});
const financeMgrRecipients = await resolveRecipientsForAssignee(tx, { assigneeRole: "finance_mgr" });
const recipients = uniq([updated.created_by, ...financeMgrRecipients]);
const title = `【盘点完成】盘点单 ${updated.check_no}`;
const bodyText = `盘点单 ${updated.check_no} 已完成,请在资产盘点中查看结果。`;
const notifications = await createNotificationsWithRecords(tx, {
recipientEmployeeIds: recipients,
type: NOTIFICATION_TYPES.inventoryCheckCompleted,
title,
body: bodyText,
targetType: "inventory_check",
targetId: updated.id,
});
dispatchTasks.push({
notifications,
type: NOTIFICATION_TYPES.inventoryCheckCompleted,
title,
body: bodyText,
targetType: "inventory_check",
targetId: updated.id,
targetPath: "/inventory-checks",
actorId: request.employee.id,
});
emailTasks.push({
type: NOTIFICATION_TYPES.inventoryCheckCompleted,
recipientEmployeeIds: recipients,
subject: title,
body: bodyText,
targetPath: "/inventory-checks",
});
return updated;
});
for (const task of emailTasks) {
void sendNotificationEmailSafe({
prisma: fastify.prisma,
config: fastify.config,
logger: fastify.log,
requestId: request.id,
...task,
});
}
await flushNotificationDispatchTasks({
prisma: fastify.prisma,
config: fastify.config,
logger: fastify.log,
requestId: request.id,
tasks: dispatchTasks,
});
return sendData(reply, request, { inventoryCheck: mapInventoryCheck(inventoryCheck) });
},
);
}
module.exports = inventoryChecksRoutes;