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