638 行
19 KiB
JavaScript
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;
|