Files
iistwin/server/documents/package.service.ts
Ильяс Султанов 1f5ecb6da4 fix(number-fields): избегаем потери точности длинных чисел
- number-поля теперь рендерятся как text + inputMode=numeric,
  чтобы браузер не округлял значения через input type=number
- пробелы при вставке в number-поля удаляются
- бэкенд нормализует значения number-полей в строку перед сохранением
- добавлен хелпер normalizeFieldValueForStorage

Closes: искажение расчётного счёта и других длинных числовых полей
2026-07-07 21:03:40 +03:00

221 lines
7.6 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { ZipArchive } from "archiver";
import * as storage from "../storage/documents.storage";
import { DocumentGenerationService } from "./generation.service";
import { uploadToS3 } from "../utils/s3";
import { db } from "../db";
import { fileUploads } from "@shared/schema";
import type { DocumentPackage, DocumentPackageItem } from "@shared/schema";
export interface GeneratePackageRequest {
packageId: number;
taskId: number;
organizationId: number;
userId: number;
}
export interface GeneratePackageResult {
packageId: number;
zipUrl: string;
generatedFiles: number;
skippedItems: number;
}
export class PackageService {
constructor(private generationService: DocumentGenerationService) {}
async list(organizationId: number): Promise<Array<DocumentPackage & { items: DocumentPackageItem[] }>> {
const packages = await storage.listDocumentPackages(organizationId);
const result: Array<DocumentPackage & { items: DocumentPackageItem[] }> = [];
for (const pkg of packages) {
const items = await storage.listDocumentPackageItems(pkg.id);
result.push({ ...pkg, items });
}
return result;
}
async getById(packageId: number, organizationId: number): Promise<(DocumentPackage & { items: DocumentPackageItem[] }) | undefined> {
const pkg = await storage.getDocumentPackage(packageId, organizationId);
if (!pkg) return undefined;
const items = await storage.listDocumentPackageItems(packageId);
return { ...pkg, items };
}
async create(
data: { name: string; description?: string; category?: string; items: Array<{ templateId: number; sortOrder?: number; customName?: string; condition?: any }> },
organizationId: number,
): Promise<DocumentPackage & { items: DocumentPackageItem[] }> {
const pkg = await storage.createDocumentPackage({
organizationId,
name: data.name,
description: data.description,
category: data.category,
});
const items: DocumentPackageItem[] = [];
for (let i = 0; i < data.items.length; i++) {
const item = data.items[i];
items.push(
await storage.createDocumentPackageItem({
packageId: pkg.id,
templateId: item.templateId,
sortOrder: item.sortOrder ?? i,
customName: item.customName,
condition: item.condition,
}),
);
}
return { ...pkg, items };
}
async update(
packageId: number,
organizationId: number,
data: Partial<DocumentPackage> & { items?: Array<{ id?: number; templateId: number; sortOrder?: number; customName?: string; condition?: any }> },
): Promise<(DocumentPackage & { items: DocumentPackageItem[] }) | undefined> {
const pkg = await storage.updateDocumentPackage(packageId, organizationId, data);
if (!pkg) return undefined;
if (data.items) {
const existing = await storage.listDocumentPackageItems(packageId);
const incomingIds = data.items.filter((i) => i.id && i.id > 0).map((i) => i.id!);
const toDelete = existing.filter((e) => !incomingIds.includes(e.id));
for (const item of toDelete) {
await storage.deleteDocumentPackageItem(item.id, packageId);
}
for (let i = 0; i < data.items.length; i++) {
const item = data.items[i];
const itemData = {
packageId,
templateId: item.templateId,
sortOrder: item.sortOrder ?? i,
customName: item.customName,
condition: item.condition,
};
if (item.id && item.id > 0) {
await storage.updateDocumentPackageItem(item.id, packageId, itemData);
} else {
await storage.createDocumentPackageItem(itemData);
}
}
}
return this.getById(packageId, organizationId);
}
async delete(packageId: number, organizationId: number): Promise<void> {
const items = await storage.listDocumentPackageItems(packageId);
for (const item of items) {
await storage.deleteDocumentPackageItem(item.id, packageId);
}
await storage.deleteDocumentPackage(packageId, organizationId);
}
async generatePackage(request: GeneratePackageRequest): Promise<GeneratePackageResult> {
const pkg = await this.getById(request.packageId, request.organizationId);
if (!pkg) throw new Error("Пакет не найден");
if (!pkg.items.length) throw new Error("В пакете нет шаблонов");
const files: Array<{ name: string; buffer: Buffer; format: 'pdf' | 'docx' }> = [];
let skippedItems = 0;
for (const item of pkg.items) {
// TODO: evaluate condition
try {
const result = await this.generationService.generate({
templateId: item.templateId,
taskId: request.taskId,
organizationId: request.organizationId,
userId: request.userId,
outputFormat: "both",
});
if (result.pdfUrl) {
const fileKey = result.pdfUrl.replace("/api/files/", "");
const buffer = await this.downloadFile(fileKey);
files.push({
name: `${item.customName || `document_${item.templateId}`}.pdf`,
buffer,
format: "pdf",
});
}
if (result.docxUrl) {
const fileKey = result.docxUrl.replace("/api/files/", "");
const buffer = await this.downloadFile(fileKey);
files.push({
name: `${item.customName || `document_${item.templateId}`}.docx`,
buffer,
format: "docx",
});
}
} catch {
skippedItems++;
}
}
if (files.length === 0) throw new Error("Не удалось сгенерировать ни одного документа");
const zipBuffer = await this.createZip(files);
const zipName = `${pkg.name}-${Date.now()}.zip`;
const { key } = await uploadToS3(zipBuffer, zipName, "application/zip");
try {
await db.insert(fileUploads).values({
organizationId: request.organizationId,
uploadedBy: request.userId,
fileKey: key,
originalName: zipName,
sizeBytes: zipBuffer.length,
taskId: request.taskId,
fieldId: null,
}).onConflictDoNothing();
} catch (err) {
console.warn('[Documents] Failed to track package zip:', err);
}
return {
packageId: pkg.id,
zipUrl: `/api/files/${key}`,
generatedFiles: files.length,
skippedItems,
};
}
private async downloadFile(fileKey: string): Promise<Buffer> {
const { isS3Enabled, streamFromS3 } = await import("../utils/s3.js");
const fs = await import("fs/promises");
const path = await import("path");
if (isS3Enabled) {
const result = await streamFromS3(fileKey);
if (!result?.body) throw new Error("Не удалось загрузить файл");
const chunks: Buffer[] = [];
for await (const chunk of result.body as any) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
return Buffer.concat(chunks);
}
const filePath = path.join(process.env.UPLOADS_DIR || "/app/data/uploads", path.basename(fileKey));
return fs.readFile(filePath);
}
private async createZip(files: Array<{ name: string; buffer: Buffer; format: 'pdf' | 'docx' }>): Promise<Buffer> {
return new Promise((resolve, reject) => {
const archive = new ZipArchive({ zlib: { level: 9 } });
const chunks: Buffer[] = [];
archive.on("data", (chunk: Buffer) => chunks.push(chunk));
archive.on("end", () => resolve(Buffer.concat(chunks)));
archive.on("error", (err: Error) => reject(err));
for (const file of files) {
archive.append(file.buffer, { name: file.name });
}
archive.finalize();
});
}
}