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

603 行
18 KiB
JavaScript

const { z } = require("zod");
const { AppError } = require("../utils/AppError");
const { buildAuditLogData } = require("../utils/audit");
const { sendData } = require("../utils/reply");
const { createNotificationsWithRecords } = require("../services/notifications");
const { sendNotificationEmailSafe } = require("../services/email");
const { NOTIFICATION_TYPES } = require("../constants/notificationTypes");
const { flushNotificationDispatchTasks } = require("../services/notificationDispatcher");
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 uniq(values) {
return Array.from(new Set((values ?? []).filter(Boolean)));
}
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 emailTasks = [];
const dispatchTasks = [];
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,
},
}),
});
const recipients = uniq([employee.id]);
const title = `【资产领用】资产卡片 ${asset.asset_no}`;
const bodyText = `资产卡片 ${asset.asset_no} 已分配给你,请在资产台账中查看。`;
const notifications = await createNotificationsWithRecords(tx, {
recipientEmployeeIds: recipients,
type: NOTIFICATION_TYPES.assetAssigned,
title,
body: bodyText,
targetType: "asset_card",
targetId: asset.id,
});
dispatchTasks.push({
notifications,
type: NOTIFICATION_TYPES.assetAssigned,
title,
body: bodyText,
targetType: "asset_card",
targetId: asset.id,
targetPath: "/assets",
actorId: request.employee.id,
});
emailTasks.push({
type: NOTIFICATION_TYPES.assetAssigned,
recipientEmployeeIds: recipients,
subject: title,
body: bodyText,
targetPath: "/assets",
});
return { updatedAsset, assignmentId: assignment.id };
});
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, {
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 emailTasks = [];
const dispatchTasks = [];
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,
},
}),
});
const recipients = uniq([asset.assignee_employee_id]);
const title = `【资产归还】资产卡片 ${asset.asset_no}`;
const bodyText = `资产卡片 ${asset.asset_no} 已完成归还。`;
const notifications = await createNotificationsWithRecords(tx, {
recipientEmployeeIds: recipients,
type: NOTIFICATION_TYPES.assetReturned,
title,
body: bodyText,
targetType: "asset_card",
targetId: asset.id,
});
dispatchTasks.push({
notifications,
type: NOTIFICATION_TYPES.assetReturned,
title,
body: bodyText,
targetType: "asset_card",
targetId: asset.id,
targetPath: "/assets",
actorId: request.employee.id,
});
emailTasks.push({
type: NOTIFICATION_TYPES.assetReturned,
recipientEmployeeIds: recipients,
subject: title,
body: bodyText,
targetPath: "/assets",
});
return { updatedAsset, assignmentId: closedAssignment?.id ?? null };
});
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, {
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 emailTasks = [];
const dispatchTasks = [];
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,
},
}),
});
const recipients = uniq([
currentEmployeeId,
employeeChange ? requestedEmployeeId : currentEmployeeId,
]);
const title = `【资产调拨】资产卡片 ${asset.asset_no}`;
const bodyText = `资产卡片 ${asset.asset_no} 已完成调拨,请在资产台账中查看最新归属与位置。`;
const notifications = await createNotificationsWithRecords(tx, {
recipientEmployeeIds: recipients,
type: NOTIFICATION_TYPES.assetTransferred,
title,
body: bodyText,
targetType: "asset_card",
targetId: asset.id,
});
dispatchTasks.push({
notifications,
type: NOTIFICATION_TYPES.assetTransferred,
title,
body: bodyText,
targetType: "asset_card",
targetId: asset.id,
targetPath: "/assets",
actorId: request.employee.id,
});
emailTasks.push({
type: NOTIFICATION_TYPES.assetTransferred,
recipientEmployeeIds: recipients,
subject: title,
body: bodyText,
targetPath: "/assets",
});
return { updatedAsset, transferId: transfer.id, assignmentId };
});
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, {
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;