feat(P18B-1,P18B-2): 新增资产流转模型与受控接口
这个提交包含在:
@@ -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;
|
||||
在新工单中引用
屏蔽一个用户