实现业务附件受控入库 V1(05-28-business-attachment-governance-v1)
这个提交包含在:
@@ -0,0 +1,46 @@
|
||||
ALTER TABLE "purchase_attachments"
|
||||
ADD COLUMN "file_sha256" TEXT,
|
||||
ADD COLUMN "governance_status" VARCHAR(32) NOT NULL DEFAULT 'approved',
|
||||
ADD COLUMN "sensitivity_level" VARCHAR(32) NOT NULL DEFAULT 'internal',
|
||||
ADD COLUMN "attachment_type" VARCHAR(64) NOT NULL DEFAULT 'other',
|
||||
ADD COLUMN "reviewed_by" UUID,
|
||||
ADD COLUMN "reviewed_at" TIMESTAMP(3),
|
||||
ADD COLUMN "review_note" TEXT,
|
||||
ADD COLUMN "archived_by" UUID,
|
||||
ADD COLUMN "archived_at" TIMESTAMP(3),
|
||||
ADD COLUMN "archive_note" TEXT,
|
||||
ADD COLUMN "download_policy" VARCHAR(32) NOT NULL DEFAULT 'standard';
|
||||
|
||||
ALTER TABLE "reimbursement_attachments"
|
||||
ADD COLUMN "governance_status" VARCHAR(32) NOT NULL DEFAULT 'approved',
|
||||
ADD COLUMN "sensitivity_level" VARCHAR(32) NOT NULL DEFAULT 'internal',
|
||||
ADD COLUMN "attachment_type" VARCHAR(64) NOT NULL DEFAULT 'supporting_material',
|
||||
ADD COLUMN "reviewed_by" UUID,
|
||||
ADD COLUMN "reviewed_at" TIMESTAMP(3),
|
||||
ADD COLUMN "review_note" TEXT,
|
||||
ADD COLUMN "archived_by" UUID,
|
||||
ADD COLUMN "archived_at" TIMESTAMP(3),
|
||||
ADD COLUMN "archive_note" TEXT,
|
||||
ADD COLUMN "download_policy" VARCHAR(32) NOT NULL DEFAULT 'standard';
|
||||
|
||||
ALTER TABLE "payment_batch_receipt_attachments"
|
||||
ADD COLUMN "governance_status" VARCHAR(32) NOT NULL DEFAULT 'approved',
|
||||
ADD COLUMN "sensitivity_level" VARCHAR(32) NOT NULL DEFAULT 'confidential',
|
||||
ADD COLUMN "attachment_type" VARCHAR(64) NOT NULL DEFAULT 'receipt',
|
||||
ADD COLUMN "reviewed_by" UUID,
|
||||
ADD COLUMN "reviewed_at" TIMESTAMP(3),
|
||||
ADD COLUMN "review_note" TEXT,
|
||||
ADD COLUMN "archived_by" UUID,
|
||||
ADD COLUMN "archived_at" TIMESTAMP(3),
|
||||
ADD COLUMN "archive_note" TEXT,
|
||||
ADD COLUMN "download_policy" VARCHAR(32) NOT NULL DEFAULT 'reason_required';
|
||||
|
||||
CREATE INDEX "idx_purchase_attachments_file_sha256" ON "purchase_attachments"("file_sha256");
|
||||
CREATE INDEX "idx_purchase_attachments_governance_status" ON "purchase_attachments"("governance_status");
|
||||
CREATE INDEX "idx_purchase_attachments_sensitivity_level" ON "purchase_attachments"("sensitivity_level");
|
||||
|
||||
CREATE INDEX "idx_reimbursement_attachments_governance_status" ON "reimbursement_attachments"("governance_status");
|
||||
CREATE INDEX "idx_reimbursement_attachments_sensitivity_level" ON "reimbursement_attachments"("sensitivity_level");
|
||||
|
||||
CREATE INDEX "idx_payment_batch_receipts_governance_status" ON "payment_batch_receipt_attachments"("governance_status");
|
||||
CREATE INDEX "idx_payment_batch_receipts_sensitivity_level" ON "payment_batch_receipt_attachments"("sensitivity_level");
|
||||
@@ -1183,14 +1183,28 @@ model PurchaseAttachment {
|
||||
original_name String
|
||||
mime_type String
|
||||
file_size Int
|
||||
file_sha256 String?
|
||||
uploaded_by String @db.Uuid
|
||||
created_at DateTime @default(now())
|
||||
governance_status String @default("approved") @db.VarChar(32)
|
||||
sensitivity_level String @default("internal") @db.VarChar(32)
|
||||
attachment_type String @default("other") @db.VarChar(64)
|
||||
reviewed_by String? @db.Uuid
|
||||
reviewed_at DateTime?
|
||||
review_note String?
|
||||
archived_by String? @db.Uuid
|
||||
archived_at DateTime?
|
||||
archive_note String?
|
||||
download_policy String @default("standard") @db.VarChar(32)
|
||||
|
||||
purchase_request PurchaseRequest @relation(fields: [purchase_request_id], references: [id], onDelete: Cascade)
|
||||
uploader Employee @relation("PurchaseAttachmentUploader", fields: [uploaded_by], references: [id], onDelete: Restrict)
|
||||
|
||||
@@index([purchase_request_id, created_at], map: "idx_purchase_attachments_request_created")
|
||||
@@index([uploaded_by], map: "idx_purchase_attachments_uploader")
|
||||
@@index([file_sha256], map: "idx_purchase_attachments_file_sha256")
|
||||
@@index([governance_status], map: "idx_purchase_attachments_governance_status")
|
||||
@@index([sensitivity_level], map: "idx_purchase_attachments_sensitivity_level")
|
||||
@@map("purchase_attachments")
|
||||
}
|
||||
|
||||
@@ -1446,6 +1460,16 @@ model ReimbursementAttachment {
|
||||
file_sha256 String
|
||||
uploaded_by String @db.Uuid
|
||||
created_at DateTime @default(now())
|
||||
governance_status String @default("approved") @db.VarChar(32)
|
||||
sensitivity_level String @default("internal") @db.VarChar(32)
|
||||
attachment_type String @default("supporting_material") @db.VarChar(64)
|
||||
reviewed_by String? @db.Uuid
|
||||
reviewed_at DateTime?
|
||||
review_note String?
|
||||
archived_by String? @db.Uuid
|
||||
archived_at DateTime?
|
||||
archive_note String?
|
||||
download_policy String @default("standard") @db.VarChar(32)
|
||||
|
||||
reimbursement_form ReimbursementForm @relation(fields: [reimbursement_id], references: [id], onDelete: Cascade)
|
||||
uploader Employee @relation("ReimbursementAttachmentUploader", fields: [uploaded_by], references: [id], onDelete: Restrict)
|
||||
@@ -1453,6 +1477,8 @@ model ReimbursementAttachment {
|
||||
@@index([reimbursement_id, created_at], map: "idx_reimbursement_attachments_form_created")
|
||||
@@index([uploaded_by], map: "idx_reimbursement_attachments_uploader")
|
||||
@@index([file_sha256], map: "idx_reimbursement_attachments_file_sha256")
|
||||
@@index([governance_status], map: "idx_reimbursement_attachments_governance_status")
|
||||
@@index([sensitivity_level], map: "idx_reimbursement_attachments_sensitivity_level")
|
||||
@@map("reimbursement_attachments")
|
||||
}
|
||||
|
||||
@@ -1867,6 +1893,16 @@ model PaymentBatchReceiptAttachment {
|
||||
file_sha256 String
|
||||
uploaded_by String @db.Uuid
|
||||
created_at DateTime @default(now())
|
||||
governance_status String @default("approved") @db.VarChar(32)
|
||||
sensitivity_level String @default("confidential") @db.VarChar(32)
|
||||
attachment_type String @default("receipt") @db.VarChar(64)
|
||||
reviewed_by String? @db.Uuid
|
||||
reviewed_at DateTime?
|
||||
review_note String?
|
||||
archived_by String? @db.Uuid
|
||||
archived_at DateTime?
|
||||
archive_note String?
|
||||
download_policy String @default("reason_required") @db.VarChar(32)
|
||||
|
||||
payment_batch PaymentBatch @relation(fields: [payment_batch_id], references: [id], onDelete: Cascade)
|
||||
uploader Employee @relation("PaymentBatchReceiptAttachmentUploader", fields: [uploaded_by], references: [id], onDelete: Restrict)
|
||||
@@ -1874,6 +1910,8 @@ model PaymentBatchReceiptAttachment {
|
||||
@@index([payment_batch_id, created_at], map: "idx_payment_batch_receipts_batch_created")
|
||||
@@index([uploaded_by], map: "idx_payment_batch_receipts_uploader")
|
||||
@@index([file_sha256], map: "idx_payment_batch_receipts_file_sha256")
|
||||
@@index([governance_status], map: "idx_payment_batch_receipts_governance_status")
|
||||
@@index([sensitivity_level], map: "idx_payment_batch_receipts_sensitivity_level")
|
||||
@@map("payment_batch_receipt_attachments")
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,16 @@ const { flushNotificationDispatchTasks } = require("../services/notificationDisp
|
||||
const { enqueueReimbursementJournalExport } = require("../services/integrations/erpJournal");
|
||||
const { ensureRiskFlag, buildPersistedRiskFlag, toMoneyNumber, toMoneyString } = require("../services/feeControl");
|
||||
const { openReadStream, StorageNotFoundError, writeBuffer } = require("../services/storage");
|
||||
const {
|
||||
ATTACHMENT_TYPE,
|
||||
DOWNLOAD_POLICY,
|
||||
SENSITIVITY_LEVEL,
|
||||
governanceCreateData,
|
||||
mapGovernanceFields,
|
||||
parseMultipartGovernanceField,
|
||||
requiresDownloadReason,
|
||||
sanitizeDownloadReason,
|
||||
} = require("../services/businessAttachmentGovernance");
|
||||
|
||||
const UUID_SCHEMA = z.string().uuid();
|
||||
const ENTITY_TYPE_SCHEMA = z.enum(["reimbursement", "travel_order"]);
|
||||
@@ -99,6 +109,12 @@ function mapReceiptAttachment(item) {
|
||||
mimeType: item.mime_type,
|
||||
fileSize: item.file_size,
|
||||
fileSha256: item.file_sha256,
|
||||
...mapGovernanceFields(item, {
|
||||
governanceStatus: "approved",
|
||||
sensitivityLevel: SENSITIVITY_LEVEL.confidential,
|
||||
attachmentType: ATTACHMENT_TYPE.receipt,
|
||||
downloadPolicy: DOWNLOAD_POLICY.reasonRequired,
|
||||
}),
|
||||
uploadedBy: item.uploader
|
||||
? {
|
||||
id: item.uploader.id,
|
||||
@@ -681,8 +697,13 @@ function buildNotFoundError() {
|
||||
|
||||
async function readReceiptUploadFiles(fastify, request) {
|
||||
const files = [];
|
||||
let governanceFields = {};
|
||||
try {
|
||||
for await (const part of request.parts()) {
|
||||
if (part.type === "field") {
|
||||
governanceFields = parseMultipartGovernanceField(part, governanceFields);
|
||||
continue;
|
||||
}
|
||||
if (part.type !== "file") {
|
||||
continue;
|
||||
}
|
||||
@@ -725,7 +746,7 @@ async function readReceiptUploadFiles(fastify, request) {
|
||||
throw buildValidationError({ file: "missing" });
|
||||
}
|
||||
|
||||
return files;
|
||||
return files.map((file) => ({ ...file, governanceFields }));
|
||||
}
|
||||
|
||||
async function paymentBatchesRoutes(fastify) {
|
||||
@@ -841,6 +862,16 @@ async function paymentBatchesRoutes(fastify) {
|
||||
mime_type: true,
|
||||
file_size: true,
|
||||
file_sha256: true,
|
||||
governance_status: true,
|
||||
sensitivity_level: true,
|
||||
attachment_type: true,
|
||||
reviewed_by: true,
|
||||
reviewed_at: true,
|
||||
review_note: true,
|
||||
archived_by: true,
|
||||
archived_at: true,
|
||||
archive_note: true,
|
||||
download_policy: true,
|
||||
created_at: true,
|
||||
uploader: {
|
||||
select: {
|
||||
@@ -888,6 +919,12 @@ async function paymentBatchesRoutes(fastify) {
|
||||
file_size: file.fileSize,
|
||||
file_sha256: fileSha256,
|
||||
uploaded_by: request.employee.id,
|
||||
...governanceCreateData(file.governanceFields, {
|
||||
governanceStatus: "approved",
|
||||
sensitivityLevel: SENSITIVITY_LEVEL.confidential,
|
||||
attachmentType: ATTACHMENT_TYPE.receipt,
|
||||
downloadPolicy: DOWNLOAD_POLICY.reasonRequired,
|
||||
}),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
@@ -895,6 +932,16 @@ async function paymentBatchesRoutes(fastify) {
|
||||
mime_type: true,
|
||||
file_size: true,
|
||||
file_sha256: true,
|
||||
governance_status: true,
|
||||
sensitivity_level: true,
|
||||
attachment_type: true,
|
||||
reviewed_by: true,
|
||||
reviewed_at: true,
|
||||
review_note: true,
|
||||
archived_by: true,
|
||||
archived_at: true,
|
||||
archive_note: true,
|
||||
download_policy: true,
|
||||
created_at: true,
|
||||
uploader: {
|
||||
select: {
|
||||
@@ -917,6 +964,7 @@ async function paymentBatchesRoutes(fastify) {
|
||||
extra: {
|
||||
attachmentIds: createdItems.map((item) => item.id),
|
||||
attachmentCount: createdItems.length,
|
||||
attachmentTypes: [...new Set(createdItems.map((item) => item.attachment_type))],
|
||||
},
|
||||
}),
|
||||
});
|
||||
@@ -940,11 +988,19 @@ async function paymentBatchesRoutes(fastify) {
|
||||
storage_path: true,
|
||||
original_name: true,
|
||||
mime_type: true,
|
||||
governance_status: true,
|
||||
sensitivity_level: true,
|
||||
attachment_type: true,
|
||||
download_policy: true,
|
||||
},
|
||||
});
|
||||
if (!attachment) {
|
||||
throw buildNotFoundError();
|
||||
}
|
||||
const downloadReason = sanitizeDownloadReason(request.query?.reason);
|
||||
if (requiresDownloadReason(attachment) && !downloadReason) {
|
||||
throw buildValidationError({ reason: "required" });
|
||||
}
|
||||
|
||||
let stream = null;
|
||||
try {
|
||||
@@ -966,7 +1022,13 @@ async function paymentBatchesRoutes(fastify) {
|
||||
action: "PAYMENT_BATCH_RECEIPT_DOWNLOADED",
|
||||
targetType: "payment_batch",
|
||||
targetId: batchId,
|
||||
extra: { attachmentId: attachment.id },
|
||||
extra: {
|
||||
attachmentId: attachment.id,
|
||||
attachmentType: attachment.attachment_type ?? ATTACHMENT_TYPE.receipt,
|
||||
sensitivityLevel: attachment.sensitivity_level ?? SENSITIVITY_LEVEL.confidential,
|
||||
governanceStatus: attachment.governance_status ?? "approved",
|
||||
reason: downloadReason,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -34,6 +34,16 @@ const {
|
||||
const { NOTIFICATION_TYPES } = require("../constants/notificationTypes");
|
||||
const { openReadStream, StorageNotFoundError, writeBuffer } = require("../services/storage");
|
||||
const { dispatchNotificationBatch } = require("../services/notificationDispatcher");
|
||||
const {
|
||||
ATTACHMENT_TYPE,
|
||||
DOWNLOAD_POLICY,
|
||||
SENSITIVITY_LEVEL,
|
||||
governanceCreateData,
|
||||
mapGovernanceFields,
|
||||
parseMultipartGovernanceField,
|
||||
requiresDownloadReason,
|
||||
sanitizeDownloadReason,
|
||||
} = require("../services/businessAttachmentGovernance");
|
||||
|
||||
const UUID_SCHEMA = z.string().uuid();
|
||||
const DATE_ONLY_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
|
||||
@@ -207,6 +217,17 @@ const PURCHASE_DETAIL_SELECT = {
|
||||
original_name: true,
|
||||
mime_type: true,
|
||||
file_size: true,
|
||||
file_sha256: true,
|
||||
governance_status: true,
|
||||
sensitivity_level: true,
|
||||
attachment_type: true,
|
||||
reviewed_by: true,
|
||||
reviewed_at: true,
|
||||
review_note: true,
|
||||
archived_by: true,
|
||||
archived_at: true,
|
||||
archive_note: true,
|
||||
download_policy: true,
|
||||
uploaded_by: true,
|
||||
created_at: true,
|
||||
uploader: {
|
||||
@@ -623,6 +644,13 @@ function mapPurchaseRequestDetail(item, request) {
|
||||
originalName: attachment.original_name,
|
||||
mimeType: attachment.mime_type,
|
||||
fileSize: attachment.file_size,
|
||||
fileSha256: attachment.file_sha256 ?? null,
|
||||
...mapGovernanceFields(attachment, {
|
||||
governanceStatus: "approved",
|
||||
sensitivityLevel: SENSITIVITY_LEVEL.internal,
|
||||
attachmentType: ATTACHMENT_TYPE.other,
|
||||
downloadPolicy: DOWNLOAD_POLICY.standard,
|
||||
}),
|
||||
uploadedBy: attachment.uploaded_by,
|
||||
createdAt: toIsoDateTime(attachment.created_at),
|
||||
uploader: mapEmployeeBasic(attachment.uploader),
|
||||
@@ -984,6 +1012,7 @@ async function persistAttachment({ config, purchaseRequestId, filePart }) {
|
||||
originalName: filePart.filename || safeName,
|
||||
mimeType: filePart.mimetype || "application/octet-stream",
|
||||
fileSize,
|
||||
fileSha256: crypto.createHash("sha256").update(buffer).digest("hex"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2199,8 +2228,13 @@ async function purchaseRequestRoutes(fastify) {
|
||||
}
|
||||
|
||||
let filePart = null;
|
||||
let governanceFields = {};
|
||||
try {
|
||||
for await (const part of request.parts()) {
|
||||
if (part.type === "field") {
|
||||
governanceFields = parseMultipartGovernanceField(part, governanceFields);
|
||||
continue;
|
||||
}
|
||||
if (part.type === "file") {
|
||||
if (filePart) {
|
||||
throw buildValidationError({ file: "only_single_file_supported" });
|
||||
@@ -2233,13 +2267,31 @@ async function purchaseRequestRoutes(fastify) {
|
||||
original_name: stored.originalName,
|
||||
mime_type: stored.mimeType,
|
||||
file_size: stored.fileSize,
|
||||
file_sha256: stored.fileSha256,
|
||||
uploaded_by: request.employee.id,
|
||||
...governanceCreateData(governanceFields, {
|
||||
governanceStatus: "approved",
|
||||
sensitivityLevel: SENSITIVITY_LEVEL.internal,
|
||||
attachmentType: ATTACHMENT_TYPE.quote,
|
||||
downloadPolicy: DOWNLOAD_POLICY.standard,
|
||||
}),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
original_name: true,
|
||||
mime_type: true,
|
||||
file_size: true,
|
||||
file_sha256: true,
|
||||
governance_status: true,
|
||||
sensitivity_level: true,
|
||||
attachment_type: true,
|
||||
reviewed_by: true,
|
||||
reviewed_at: true,
|
||||
review_note: true,
|
||||
archived_by: true,
|
||||
archived_at: true,
|
||||
archive_note: true,
|
||||
download_policy: true,
|
||||
uploaded_by: true,
|
||||
created_at: true,
|
||||
uploader: {
|
||||
@@ -2262,6 +2314,8 @@ async function purchaseRequestRoutes(fastify) {
|
||||
purchaseRequestId,
|
||||
originalName: stored.originalName,
|
||||
fileSize: stored.fileSize,
|
||||
attachmentType: created.attachment_type,
|
||||
sensitivityLevel: created.sensitivity_level,
|
||||
},
|
||||
}),
|
||||
});
|
||||
@@ -2275,6 +2329,13 @@ async function purchaseRequestRoutes(fastify) {
|
||||
originalName: attachment.original_name,
|
||||
mimeType: attachment.mime_type,
|
||||
fileSize: attachment.file_size,
|
||||
fileSha256: attachment.file_sha256 ?? null,
|
||||
...mapGovernanceFields(attachment, {
|
||||
governanceStatus: "approved",
|
||||
sensitivityLevel: SENSITIVITY_LEVEL.internal,
|
||||
attachmentType: ATTACHMENT_TYPE.quote,
|
||||
downloadPolicy: DOWNLOAD_POLICY.standard,
|
||||
}),
|
||||
uploadedBy: attachment.uploaded_by,
|
||||
createdAt: toIsoDateTime(attachment.created_at),
|
||||
uploader: mapEmployeeBasic(attachment.uploader),
|
||||
@@ -2373,6 +2434,10 @@ async function purchaseRequestRoutes(fastify) {
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
governance_status: true,
|
||||
sensitivity_level: true,
|
||||
attachment_type: true,
|
||||
download_policy: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -2383,6 +2448,10 @@ async function purchaseRequestRoutes(fastify) {
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
const downloadReason = sanitizeDownloadReason(request.body?.reason ?? request.query?.reason);
|
||||
if (requiresDownloadReason(attachment) && !downloadReason) {
|
||||
throw buildValidationError({ reason: "required" });
|
||||
}
|
||||
|
||||
const expiresAt = Date.now() + fastify.config.signedUrlTtlSeconds * 1000;
|
||||
const token = signPayload(
|
||||
@@ -2390,6 +2459,7 @@ async function purchaseRequestRoutes(fastify) {
|
||||
purchaseRequestId,
|
||||
attachmentId,
|
||||
actorId: request.employee.id,
|
||||
reason: downloadReason,
|
||||
exp: expiresAt,
|
||||
},
|
||||
fastify.config.jwtSecret,
|
||||
@@ -2408,6 +2478,10 @@ async function purchaseRequestRoutes(fastify) {
|
||||
extra: {
|
||||
purchaseRequestId,
|
||||
expiresAt,
|
||||
attachmentType: attachment.attachment_type ?? ATTACHMENT_TYPE.quote,
|
||||
sensitivityLevel: attachment.sensitivity_level ?? SENSITIVITY_LEVEL.internal,
|
||||
governanceStatus: attachment.governance_status ?? "approved",
|
||||
reason: downloadReason,
|
||||
},
|
||||
}),
|
||||
});
|
||||
@@ -2470,6 +2544,10 @@ async function purchaseRequestRoutes(fastify) {
|
||||
storage_path: true,
|
||||
mime_type: true,
|
||||
original_name: true,
|
||||
governance_status: true,
|
||||
sensitivity_level: true,
|
||||
attachment_type: true,
|
||||
download_policy: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -2480,6 +2558,10 @@ async function purchaseRequestRoutes(fastify) {
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
const downloadReason = sanitizeDownloadReason(request.query?.reason ?? verified.payload?.reason);
|
||||
if (requiresDownloadReason(attachment) && !downloadReason) {
|
||||
throw buildValidationError({ reason: "required" });
|
||||
}
|
||||
|
||||
let opened = null;
|
||||
try {
|
||||
@@ -2508,6 +2590,10 @@ async function purchaseRequestRoutes(fastify) {
|
||||
extra: {
|
||||
purchaseRequestId,
|
||||
exp: verified.payload.exp,
|
||||
attachmentType: attachment.attachment_type ?? ATTACHMENT_TYPE.quote,
|
||||
sensitivityLevel: attachment.sensitivity_level ?? SENSITIVITY_LEVEL.internal,
|
||||
governanceStatus: attachment.governance_status ?? "approved",
|
||||
reason: downloadReason,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -31,6 +31,16 @@ const {
|
||||
} = require("../services/feeControl");
|
||||
const { issueDocumentNumber } = require("../services/documentSequence");
|
||||
const { openReadStream, StorageNotFoundError, writeBuffer } = require("../services/storage");
|
||||
const {
|
||||
ATTACHMENT_TYPE,
|
||||
DOWNLOAD_POLICY,
|
||||
SENSITIVITY_LEVEL,
|
||||
governanceCreateData,
|
||||
mapGovernanceFields,
|
||||
parseMultipartGovernanceField,
|
||||
requiresDownloadReason,
|
||||
sanitizeDownloadReason,
|
||||
} = require("../services/businessAttachmentGovernance");
|
||||
const {
|
||||
buildApprovalWorkflowSnapshot,
|
||||
getWorkflowStepsSnapshot,
|
||||
@@ -289,6 +299,12 @@ function mapReimbursementAttachment(item) {
|
||||
mimeType: item.mime_type,
|
||||
fileSize: item.file_size,
|
||||
fileSha256: item.file_sha256,
|
||||
...mapGovernanceFields(item, {
|
||||
governanceStatus: "approved",
|
||||
sensitivityLevel: SENSITIVITY_LEVEL.internal,
|
||||
attachmentType: ATTACHMENT_TYPE.supportingMaterial,
|
||||
downloadPolicy: DOWNLOAD_POLICY.standard,
|
||||
}),
|
||||
uploadedBy: item.uploader
|
||||
? {
|
||||
id: item.uploader.id,
|
||||
@@ -2434,8 +2450,13 @@ async function reimbursementRoutes(fastify) {
|
||||
}
|
||||
|
||||
const files = [];
|
||||
let governanceFields = {};
|
||||
try {
|
||||
for await (const part of request.parts()) {
|
||||
if (part.type === "field") {
|
||||
governanceFields = parseMultipartGovernanceField(part, governanceFields);
|
||||
continue;
|
||||
}
|
||||
if (part.type !== "file") {
|
||||
continue;
|
||||
}
|
||||
@@ -2505,6 +2526,12 @@ async function reimbursementRoutes(fastify) {
|
||||
|
||||
const datePrefix = compactDateString(fastify.config.appTimezone);
|
||||
const createdItems = [];
|
||||
const governanceDefaults = {
|
||||
governanceStatus: "approved",
|
||||
sensitivityLevel: SENSITIVITY_LEVEL.internal,
|
||||
attachmentType: ATTACHMENT_TYPE.supportingMaterial,
|
||||
downloadPolicy: DOWNLOAD_POLICY.standard,
|
||||
};
|
||||
for (const file of files) {
|
||||
const fileSha256 = sha256Hex(file.buffer);
|
||||
const extension = path.extname(file.originalName ?? "").toLowerCase();
|
||||
@@ -2525,6 +2552,7 @@ async function reimbursementRoutes(fastify) {
|
||||
file_size: file.fileSize,
|
||||
file_sha256: fileSha256,
|
||||
uploaded_by: request.employee.id,
|
||||
...governanceCreateData(governanceFields, governanceDefaults),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
@@ -2532,6 +2560,16 @@ async function reimbursementRoutes(fastify) {
|
||||
mime_type: true,
|
||||
file_size: true,
|
||||
file_sha256: true,
|
||||
governance_status: true,
|
||||
sensitivity_level: true,
|
||||
attachment_type: true,
|
||||
reviewed_by: true,
|
||||
reviewed_at: true,
|
||||
review_note: true,
|
||||
archived_by: true,
|
||||
archived_at: true,
|
||||
archive_note: true,
|
||||
download_policy: true,
|
||||
created_at: true,
|
||||
uploader: {
|
||||
select: {
|
||||
@@ -2545,6 +2583,7 @@ async function reimbursementRoutes(fastify) {
|
||||
createdItems.push(created);
|
||||
}
|
||||
|
||||
const uploadedGovernance = governanceCreateData(governanceFields, governanceDefaults);
|
||||
await fastify.prisma.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: request.employee.id,
|
||||
@@ -2553,6 +2592,8 @@ async function reimbursementRoutes(fastify) {
|
||||
targetId: reimbursementId,
|
||||
extra: {
|
||||
attachmentIds: createdItems.map((item) => item.id),
|
||||
attachmentType: uploadedGovernance.attachment_type,
|
||||
sensitivityLevel: uploadedGovernance.sensitivity_level,
|
||||
},
|
||||
}),
|
||||
});
|
||||
@@ -2657,6 +2698,10 @@ async function reimbursementRoutes(fastify) {
|
||||
storage_path: true,
|
||||
original_name: true,
|
||||
mime_type: true,
|
||||
governance_status: true,
|
||||
sensitivity_level: true,
|
||||
attachment_type: true,
|
||||
download_policy: true,
|
||||
reimbursement_form: {
|
||||
select: {
|
||||
owner_id: true,
|
||||
@@ -2674,6 +2719,15 @@ async function reimbursementRoutes(fastify) {
|
||||
}
|
||||
|
||||
ensureOwnOrCanViewAll(fastify, request, attachment.reimbursement_form);
|
||||
const downloadReason = sanitizeDownloadReason(request.query?.reason);
|
||||
if (requiresDownloadReason(attachment) && !downloadReason) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { reason: "required" },
|
||||
});
|
||||
}
|
||||
|
||||
let stream = null;
|
||||
try {
|
||||
@@ -2701,6 +2755,10 @@ async function reimbursementRoutes(fastify) {
|
||||
targetId: reimbursementId,
|
||||
extra: {
|
||||
attachmentId: attachment.id,
|
||||
attachmentType: attachment.attachment_type ?? ATTACHMENT_TYPE.supportingMaterial,
|
||||
sensitivityLevel: attachment.sensitivity_level ?? SENSITIVITY_LEVEL.internal,
|
||||
governanceStatus: attachment.governance_status ?? "approved",
|
||||
reason: downloadReason,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
const GOVERNANCE_STATUS = Object.freeze({
|
||||
draft: "draft",
|
||||
underApproval: "under_approval",
|
||||
pendingReview: "pending_review",
|
||||
approved: "approved",
|
||||
archived: "archived",
|
||||
returned: "returned",
|
||||
rejected: "rejected",
|
||||
quarantined: "quarantined",
|
||||
});
|
||||
|
||||
const SENSITIVITY_LEVEL = Object.freeze({
|
||||
public: "public",
|
||||
internal: "internal",
|
||||
confidential: "confidential",
|
||||
restricted: "restricted",
|
||||
indexOnly: "index_only",
|
||||
});
|
||||
|
||||
const ATTACHMENT_TYPE = Object.freeze({
|
||||
contractDraft: "contract_draft",
|
||||
contractStamped: "contract_stamped",
|
||||
invoice: "invoice",
|
||||
receipt: "receipt",
|
||||
quote: "quote",
|
||||
acceptance: "acceptance",
|
||||
delivery: "delivery",
|
||||
policyDocument: "policy_document",
|
||||
supportingMaterial: "supporting_material",
|
||||
other: "other",
|
||||
});
|
||||
|
||||
const DOWNLOAD_POLICY = Object.freeze({
|
||||
standard: "standard",
|
||||
reasonRequired: "reason_required",
|
||||
restricted: "restricted",
|
||||
});
|
||||
|
||||
const GOVERNANCE_STATUS_VALUES = new Set(Object.values(GOVERNANCE_STATUS));
|
||||
const SENSITIVITY_LEVEL_VALUES = new Set(Object.values(SENSITIVITY_LEVEL));
|
||||
const ATTACHMENT_TYPE_VALUES = new Set(Object.values(ATTACHMENT_TYPE));
|
||||
const DOWNLOAD_POLICY_VALUES = new Set(Object.values(DOWNLOAD_POLICY));
|
||||
|
||||
const DEFAULT_GOVERNANCE = Object.freeze({
|
||||
governanceStatus: GOVERNANCE_STATUS.approved,
|
||||
sensitivityLevel: SENSITIVITY_LEVEL.internal,
|
||||
attachmentType: ATTACHMENT_TYPE.other,
|
||||
downloadPolicy: DOWNLOAD_POLICY.standard,
|
||||
});
|
||||
|
||||
function normalizeEnumValue(value, allowedValues, fallback) {
|
||||
if (typeof value !== "string") {
|
||||
return fallback;
|
||||
}
|
||||
const normalized = value.trim();
|
||||
return allowedValues.has(normalized) ? normalized : fallback;
|
||||
}
|
||||
|
||||
function normalizeGovernanceInput(input = {}, defaults = DEFAULT_GOVERNANCE) {
|
||||
return {
|
||||
governanceStatus: normalizeEnumValue(input.governanceStatus ?? input.governance_status, GOVERNANCE_STATUS_VALUES, defaults.governanceStatus),
|
||||
sensitivityLevel: normalizeEnumValue(input.sensitivityLevel ?? input.sensitivity_level, SENSITIVITY_LEVEL_VALUES, defaults.sensitivityLevel),
|
||||
attachmentType: normalizeEnumValue(input.attachmentType ?? input.attachment_type, ATTACHMENT_TYPE_VALUES, defaults.attachmentType),
|
||||
downloadPolicy: normalizeEnumValue(input.downloadPolicy ?? input.download_policy, DOWNLOAD_POLICY_VALUES, defaults.downloadPolicy),
|
||||
};
|
||||
}
|
||||
|
||||
function governanceCreateData(input = {}, defaults = DEFAULT_GOVERNANCE) {
|
||||
const normalized = normalizeGovernanceInput(input, defaults);
|
||||
return {
|
||||
governance_status: normalized.governanceStatus,
|
||||
sensitivity_level: normalized.sensitivityLevel,
|
||||
attachment_type: normalized.attachmentType,
|
||||
download_policy: normalized.downloadPolicy,
|
||||
};
|
||||
}
|
||||
|
||||
function mapGovernanceFields(item = {}, defaults = DEFAULT_GOVERNANCE) {
|
||||
const normalized = normalizeGovernanceInput({
|
||||
governance_status: item.governance_status,
|
||||
sensitivity_level: item.sensitivity_level,
|
||||
attachment_type: item.attachment_type,
|
||||
download_policy: item.download_policy,
|
||||
}, defaults);
|
||||
return {
|
||||
governanceStatus: normalized.governanceStatus,
|
||||
sensitivityLevel: normalized.sensitivityLevel,
|
||||
attachmentType: normalized.attachmentType,
|
||||
downloadPolicy: normalized.downloadPolicy,
|
||||
reviewedBy: item.reviewed_by ?? null,
|
||||
reviewedAt: item.reviewed_at?.toISOString?.() ?? null,
|
||||
reviewNote: item.review_note ?? null,
|
||||
archivedBy: item.archived_by ?? null,
|
||||
archivedAt: item.archived_at?.toISOString?.() ?? null,
|
||||
archiveNote: item.archive_note ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function parseMultipartGovernanceField(part, current = {}) {
|
||||
if (!part || part.type !== "field") {
|
||||
return current;
|
||||
}
|
||||
const fieldName = part.fieldname;
|
||||
if (!["attachmentType", "sensitivityLevel", "governanceStatus", "downloadPolicy"].includes(fieldName)) {
|
||||
return current;
|
||||
}
|
||||
return {
|
||||
...current,
|
||||
[fieldName]: typeof part.value === "string" ? part.value : "",
|
||||
};
|
||||
}
|
||||
|
||||
function requiresDownloadReason(attachment = {}) {
|
||||
const governance = normalizeGovernanceInput(attachment);
|
||||
return (
|
||||
governance.downloadPolicy === DOWNLOAD_POLICY.reasonRequired ||
|
||||
governance.downloadPolicy === DOWNLOAD_POLICY.restricted ||
|
||||
governance.sensitivityLevel === SENSITIVITY_LEVEL.confidential ||
|
||||
governance.sensitivityLevel === SENSITIVITY_LEVEL.restricted
|
||||
);
|
||||
}
|
||||
|
||||
function sanitizeDownloadReason(reason) {
|
||||
if (typeof reason !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = reason.trim();
|
||||
return trimmed ? trimmed.slice(0, 200) : null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ATTACHMENT_TYPE,
|
||||
DEFAULT_GOVERNANCE,
|
||||
DOWNLOAD_POLICY,
|
||||
GOVERNANCE_STATUS,
|
||||
SENSITIVITY_LEVEL,
|
||||
governanceCreateData,
|
||||
mapGovernanceFields,
|
||||
normalizeGovernanceInput,
|
||||
parseMultipartGovernanceField,
|
||||
requiresDownloadReason,
|
||||
sanitizeDownloadReason,
|
||||
};
|
||||
@@ -48,10 +48,18 @@ const OWNER_ID = "77777777-7777-4777-8777-777777777777";
|
||||
const SUBMITTER_ID = "88888888-8888-4888-8888-888888888888";
|
||||
const RECEIPT_ID = "99999999-9999-4999-8999-999999999999";
|
||||
|
||||
function buildMultipartRequest({ files }) {
|
||||
function buildMultipartRequest({ files, fields = {} }) {
|
||||
const boundary = "----codex-payment-batch-receipt-boundary";
|
||||
const segments = [];
|
||||
|
||||
for (const [name, value] of Object.entries(fields)) {
|
||||
segments.push(
|
||||
Buffer.from(
|
||||
`--${boundary}\r\nContent-Disposition: form-data; name="${name}"\r\n\r\n${value}\r\n`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
segments.push(
|
||||
Buffer.from(
|
||||
@@ -587,6 +595,10 @@ describe("payment batch reimbursement routes", () => {
|
||||
expect(uploadResponse.json().data.items[0]).toMatchObject({
|
||||
originalName: "bank-receipt.pdf",
|
||||
mimeType: "application/pdf",
|
||||
governanceStatus: "approved",
|
||||
sensitivityLevel: "confidential",
|
||||
attachmentType: "receipt",
|
||||
downloadPolicy: "reason_required",
|
||||
});
|
||||
const attachmentId = uploadResponse.json().data.items[0].id;
|
||||
expect(prisma.paymentBatchReceiptAttachment.create).toHaveBeenCalledTimes(1);
|
||||
@@ -610,14 +622,43 @@ describe("payment batch reimbursement routes", () => {
|
||||
});
|
||||
expect(listResponse.statusCode).toBe(200);
|
||||
expect(listResponse.json().data.items).toHaveLength(1);
|
||||
expect(listResponse.json().data.items[0]).toMatchObject({
|
||||
governanceStatus: "approved",
|
||||
sensitivityLevel: "confidential",
|
||||
attachmentType: "receipt",
|
||||
downloadPolicy: "reason_required",
|
||||
});
|
||||
|
||||
const downloadResponse = await app.inject({
|
||||
const missingReasonResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/payment-batches/${BATCH_ID}/receipt-attachments/${attachmentId}/file`,
|
||||
headers: { authorization: "Bearer test" },
|
||||
});
|
||||
expect(missingReasonResponse.statusCode).toBe(400);
|
||||
expect(missingReasonResponse.json().error.details).toEqual({ reason: "required" });
|
||||
|
||||
const downloadResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/payment-batches/${BATCH_ID}/receipt-attachments/${attachmentId}/file?reason=付款复核`,
|
||||
headers: { authorization: "Bearer test" },
|
||||
});
|
||||
expect(downloadResponse.statusCode).toBe(200);
|
||||
expect(downloadResponse.body).toBe("%PDF bank receipt");
|
||||
expect(prisma.auditLog.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
action: "PAYMENT_BATCH_RECEIPT_DOWNLOADED",
|
||||
target_id: BATCH_ID,
|
||||
extra: expect.objectContaining({
|
||||
attachmentId,
|
||||
attachmentType: "receipt",
|
||||
sensitivityLevel: "confidential",
|
||||
governanceStatus: "approved",
|
||||
reason: "付款复核",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const deleteResponse = await app.inject({
|
||||
method: "DELETE",
|
||||
|
||||
@@ -8,10 +8,18 @@ const multipart = require("@fastify/multipart");
|
||||
const { AppError } = require("../../../src/utils/AppError");
|
||||
const reimbursementRoutes = require("../../../src/routes/reimbursements");
|
||||
|
||||
function buildMultipartRequest({ files }) {
|
||||
function buildMultipartRequest({ files, fields = {} }) {
|
||||
const boundary = "----codex-reimbursement-attachment-boundary";
|
||||
const segments = [];
|
||||
|
||||
for (const [name, value] of Object.entries(fields)) {
|
||||
segments.push(
|
||||
Buffer.from(
|
||||
`--${boundary}\r\nContent-Disposition: form-data; name="${name}"\r\n\r\n${value}\r\n`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
segments.push(
|
||||
Buffer.from(
|
||||
@@ -55,6 +63,16 @@ async function buildApp({ reimbursementStatus = "draft" } = {}) {
|
||||
mime_type: data.mime_type,
|
||||
file_size: data.file_size,
|
||||
file_sha256: data.file_sha256,
|
||||
governance_status: data.governance_status,
|
||||
sensitivity_level: data.sensitivity_level,
|
||||
attachment_type: data.attachment_type,
|
||||
reviewed_by: data.reviewed_by ?? null,
|
||||
reviewed_at: data.reviewed_at ?? null,
|
||||
review_note: data.review_note ?? null,
|
||||
archived_by: data.archived_by ?? null,
|
||||
archived_at: data.archived_at ?? null,
|
||||
archive_note: data.archive_note ?? null,
|
||||
download_policy: data.download_policy,
|
||||
storage_path: data.storage_path,
|
||||
created_at: new Date("2026-05-08T08:00:00.000Z"),
|
||||
uploader: {
|
||||
@@ -91,6 +109,10 @@ async function buildApp({ reimbursementStatus = "draft" } = {}) {
|
||||
storage_path: item.storage_path,
|
||||
original_name: item.original_name,
|
||||
mime_type: item.mime_type,
|
||||
governance_status: item.governance_status,
|
||||
sensitivity_level: item.sensitivity_level,
|
||||
attachment_type: item.attachment_type,
|
||||
download_policy: item.download_policy,
|
||||
reimbursement_form: {
|
||||
owner_id: "actor-1",
|
||||
submitter_id: "actor-1",
|
||||
@@ -201,6 +223,10 @@ describe("reimbursement attachment routes", () => {
|
||||
expect.objectContaining({
|
||||
originalName: "contract.pdf",
|
||||
mimeType: "application/pdf",
|
||||
governanceStatus: "approved",
|
||||
sensitivityLevel: "internal",
|
||||
attachmentType: "supporting_material",
|
||||
downloadPolicy: "standard",
|
||||
uploadedBy: expect.objectContaining({ name: "测试用户" }),
|
||||
}),
|
||||
);
|
||||
@@ -210,6 +236,10 @@ describe("reimbursement attachment routes", () => {
|
||||
data: expect.objectContaining({
|
||||
action: "REIMBURSEMENT_ATTACHMENTS_UPLOADED",
|
||||
target_id: reimbursementId,
|
||||
extra: expect.objectContaining({
|
||||
attachmentType: "supporting_material",
|
||||
sensitivityLevel: "internal",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
在新工单中引用
屏蔽一个用户