- number-поля теперь рендерятся как text + inputMode=numeric, чтобы браузер не округлял значения через input type=number - пробелы при вставке в number-поля удаляются - бэкенд нормализует значения number-полей в строку перед сохранением - добавлен хелпер normalizeFieldValueForStorage Closes: искажение расчётного счёта и других длинных числовых полей
221 lines
7.6 KiB
TypeScript
221 lines
7.6 KiB
TypeScript
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();
|
||
});
|
||
}
|
||
}
|