feat: add TallyNote local reimbursement ledger
TallyNote release / linux-x64 (push) Failing after 2m41s

This commit is contained in:
Qiufeng
2026-08-29 01:02:29 +08:00
commit 9719429f4a
62 changed files with 29200 additions and 0 deletions
+188
View File
@@ -0,0 +1,188 @@
import { describe, expect, it, beforeEach, afterEach } from "vitest";
import { createHash, randomUUID } from "node:crypto";
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import ExcelJS from "exceljs";
import yauzl from "yauzl";
import { loadConfig, prepareDataDirectories } from "../server/config.js";
import { openDatabase } from "../server/db/index.js";
import { buildExportJob, insertExportJob, type ExportSnapshot } from "../server/exporter.js";
const proofBytes = Buffer.from("export-proof-bytes");
function zipEntries(buffer: Buffer): Promise<Map<string, Buffer>> {
return new Promise((resolve, reject) => {
yauzl.fromBuffer(buffer, { lazyEntries: true, validateEntrySizes: true }, (error, zip) => {
if (error || !zip) {
reject(error ?? new Error("无法读取导出 ZIP"));
return;
}
const entries = new Map<string, Buffer>();
let settled = false;
const fail = (reason: Error) => {
if (settled) return;
settled = true;
zip.close();
reject(reason);
};
zip.on("error", fail);
zip.on("end", () => {
if (settled) return;
settled = true;
resolve(entries);
});
zip.on("entry", (entry) => {
zip.openReadStream(entry, (streamError, stream) => {
if (streamError || !stream) {
fail(streamError ?? new Error("无法读取 ZIP 条目"));
return;
}
const chunks: Buffer[] = [];
stream.on("data", (chunk: Buffer | string) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)));
stream.on("error", fail);
stream.on("end", () => {
entries.set(entry.fileName, Buffer.concat(chunks));
if (!settled) zip.readEntry();
});
});
});
zip.readEntry();
});
});
}
describe("导出 ZIP 产物", () => {
let dataDir: string;
let config: ReturnType<typeof loadConfig>;
let database: ReturnType<typeof openDatabase>;
let adminId: string;
beforeEach(async () => {
dataDir = mkdtempSync(path.join(tmpdir(), "tallynote-export-"));
process.env.TALLYNOTE_DATA_DIR = dataDir;
process.env.TALLYNOTE_COOKIE_SECURE = "false";
process.env.TALLYNOTE_PUBLIC_ORIGIN = "http://127.0.0.1:3998";
config = loadConfig();
prepareDataDirectories(config);
database = openDatabase(config);
adminId = randomUUID();
database.sqlite.prepare(`
INSERT INTO admins(id, username, username_norm, display_name, password_hash, status,
must_change_password, auth_version, version, created_at)
VALUES (?, ?, ?, ?, ?, 'active', 0, 1, 1, ?)
`).run(adminId, "export-admin", "export-admin", "导出测试管理员", "not-a-password-hash", Date.now());
});
afterEach(async () => {
database.sqlite.close();
await rm(dataDir, { recursive: true, force: true });
});
async function createJob(includeManifest: boolean): Promise<{ jobId: string; expenseId: string; reason: string }> {
const expenseId = randomUUID();
const attachmentId = randomUUID();
const paidAt = Date.parse("2026-08-27T04:00:00.000Z");
const reason = "供应商仅提供收据,无法补开发票";
const storagePath = "aa/payment.png";
await mkdir(path.join(config.filesDir, "aa"), { recursive: true });
await writeFile(path.join(config.filesDir, storagePath), proofBytes, { mode: 0o600 });
const sha256 = createHash("sha256").update(proofBytes).digest("hex");
database.sqlite.prepare(`
INSERT INTO expenses(id, paid_at, amount_cents, note, invoice_missing_reason, status, version,
created_at, created_by, updated_at, updated_by)
VALUES (?, ?, ?, ?, ?, 'unreimbursed', 1, ?, ?, ?, ?)
`).run(expenseId, paidAt, 1234, "导出无发票测试", reason, Date.now(), adminId, Date.now(), adminId);
database.sqlite.prepare(`
INSERT INTO attachments(id, expense_id, kind, storage_path, original_name, mime_type,
size_bytes, sha256, created_at, created_by)
VALUES (?, ?, 'payment_proof', ?, ?, 'image/png', ?, ?, ?, ?)
`).run(attachmentId, expenseId, storagePath, "付款截图.png", proofBytes.length, sha256, Date.now(), adminId);
const snapshot: ExportSnapshot = {
includeManifest,
expenses: [{
id: expenseId,
paidAt,
amountCents: 1234,
note: "导出无发票测试",
invoiceMissingReason: reason,
status: "unreimbursed",
attachments: [{
id: attachmentId,
kind: "payment_proof",
originalName: "付款截图.png",
mimeType: "image/png",
storagePath,
sizeBytes: proofBytes.length,
sha256,
}],
}],
};
const jobId = insertExportJob(database.sqlite, config, {
adminId,
sessionHash: "session-hash",
selection: { ids: [expenseId], includeManifest },
snapshot,
});
await buildExportJob(database.sqlite, config, jobId);
return { jobId, expenseId, reason };
}
it("Excel 包含无发票原因列和合计,默认不生成 manifest", async () => {
const { jobId, reason } = await createJob(false);
const job = database.sqlite.prepare("SELECT status, file_path AS filePath FROM export_jobs WHERE id=?").get(jobId) as { status: string; filePath: string };
expect(job.status).toBe("ready");
const archive = await zipEntries(await readFile(path.join(config.exportsDir, job.filePath)));
expect([...archive.keys()]).toContain("报销清单.xlsx");
expect(archive.has("manifest.json")).toBe(false);
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.load(archive.get("报销清单.xlsx")!);
const sheet = workbook.getWorksheet("报销清单")!;
expect(sheet.getCell("I1").value).toBe("无发票原因");
expect(sheet.getCell("I2").value).toBe(reason);
expect(sheet.getCell("C2").value).toBe(12.34);
expect(sheet.getCell("C3").value).toBe(12.34);
expect([...archive.keys()].some((name) => name.endsWith("/付款凭证/付款截图.png"))).toBe(true);
});
it("开启 manifest 时包含原因和附件元数据,重复构建不会破坏 ZIP", async () => {
const { jobId, expenseId, reason } = await createJob(true);
await Promise.all([buildExportJob(database.sqlite, config, jobId), buildExportJob(database.sqlite, config, jobId)]);
const job = database.sqlite.prepare("SELECT status, file_path AS filePath FROM export_jobs WHERE id=?").get(jobId) as { status: string; filePath: string };
expect(job.status).toBe("ready");
const archive = await zipEntries(await readFile(path.join(config.exportsDir, job.filePath)));
const manifest = JSON.parse(archive.get("manifest.json")!.toString("utf8")) as { records: Array<{ id: string; invoiceMissingReason: string; attachments: Array<{ originalName: string }> }> };
expect(manifest.records).toHaveLength(1);
expect(manifest.records[0]).toMatchObject({ id: expenseId, invoiceMissingReason: reason });
expect(manifest.records[0]!.attachments[0]!.originalName).toBe("付款截图.png");
});
it("导出错误不泄露本地路径或内部附件标识", async () => {
const expenseId = randomUUID();
const missingId = randomUUID();
const now = Date.now();
database.sqlite.prepare(`
INSERT INTO expenses(id, paid_at, amount_cents, note, status, version, created_at, created_by, updated_at, updated_by)
VALUES (?, ?, 100, '审计下载', 'unreimbursed', 1, ?, ?, ?, ?)
`).run(expenseId, now, now, adminId, now, adminId);
const storagePath = "bb/proof.png";
await mkdir(path.join(config.filesDir, "bb"), { recursive: true });
await writeFile(path.join(config.filesDir, storagePath), proofBytes, { mode: 0o600 });
const digest = createHash("sha256").update(proofBytes).digest("hex");
database.sqlite.prepare(`
INSERT INTO attachments(id, expense_id, kind, storage_path, original_name, mime_type, size_bytes, sha256, created_at, created_by)
VALUES (?, ?, 'payment_proof', ?, 'proof.png', 'image/png', ?, ?, ?, ?)
`).run(randomUUID(), expenseId, storagePath, proofBytes.length, digest, now, adminId);
const brokenSnapshot: ExportSnapshot = {
includeManifest: false,
expenses: [{ id: missingId, paidAt: now, amountCents: 100, note: "broken", invoiceMissingReason: null, status: "unreimbursed", attachments: [{ id: randomUUID(), kind: "payment_proof", originalName: "missing.png", mimeType: "image/png", storagePath: "cc/does-not-exist.png", sizeBytes: 12, sha256: "d".repeat(64) }] }],
};
database.sqlite.prepare("INSERT INTO expenses(id, paid_at, amount_cents, note, status, version, created_at, created_by, updated_at, updated_by) VALUES (?, ?, 100, 'broken', 'unreimbursed', 1, ?, ?, ?, ?)").run(missingId, now, now, adminId, now, adminId);
const brokenJob = insertExportJob(database.sqlite, config, { adminId, sessionHash: "audit-session", selection: { ids: [missingId] }, snapshot: brokenSnapshot });
await buildExportJob(database.sqlite, config, brokenJob);
const failed = database.sqlite.prepare("SELECT error_message AS errorMessage FROM export_jobs WHERE id=?").get(brokenJob) as { errorMessage: string };
expect(failed.errorMessage).toBe("导出失败:附件文件缺失或校验不通过");
expect(failed.errorMessage).not.toContain("does-not-exist");
});
});