fix(number-fields): избегаем потери точности длинных чисел
- number-поля теперь рендерятся как text + inputMode=numeric, чтобы браузер не округлял значения через input type=number - пробелы при вставке в number-поля удаляются - бэкенд нормализует значения number-полей в строку перед сохранением - добавлен хелпер normalizeFieldValueForStorage Closes: искажение расчётного счёта и других длинных числовых полей
This commit is contained in:
184
server/documents/asset.service.ts
Normal file
184
server/documents/asset.service.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import fssync from 'fs';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import {
|
||||
organizationAssets,
|
||||
fileUploads,
|
||||
type OrganizationAsset,
|
||||
type InsertOrganizationAsset,
|
||||
} from "@shared/schema";
|
||||
import * as storage from "../storage/documents.storage";
|
||||
import { uploadToS3, streamFromS3, isS3Enabled, S3_BUCKET } from "../utils/s3";
|
||||
import { uploadsDir } from "../utils/upload";
|
||||
import { db } from "../db";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
|
||||
export interface AssetListFilters {
|
||||
organizationId: number;
|
||||
type?: string;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface MulterFile {
|
||||
path: string;
|
||||
originalname: string;
|
||||
mimetype: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
const MIME_BY_EXT: Record<string, string> = {
|
||||
png: 'image/png',
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
gif: 'image/gif',
|
||||
webp: 'image/webp',
|
||||
svg: 'image/svg+xml',
|
||||
};
|
||||
|
||||
function getMimeType(fileKeyOrName: string, fallback?: string): string {
|
||||
const ext = path.extname(fileKeyOrName).slice(1).toLowerCase();
|
||||
return fallback || MIME_BY_EXT[ext] || 'image/png';
|
||||
}
|
||||
|
||||
export class AssetService {
|
||||
// ── CRUD ───────────────────────────────────────────────────────────────────
|
||||
|
||||
async list(filters: AssetListFilters): Promise<OrganizationAsset[]> {
|
||||
return storage.listOrganizationAssets(filters);
|
||||
}
|
||||
|
||||
async getById(id: number, organizationId: number): Promise<OrganizationAsset | undefined> {
|
||||
return storage.getOrganizationAsset(id, organizationId);
|
||||
}
|
||||
|
||||
async create(data: InsertOrganizationAsset & { file?: MulterFile }): Promise<OrganizationAsset> {
|
||||
let fileKey = data.fileKey;
|
||||
|
||||
if (data.file) {
|
||||
const upload = await this.uploadAssetFile(data.file);
|
||||
fileKey = upload.key;
|
||||
}
|
||||
|
||||
if (!fileKey) {
|
||||
throw new Error('Не указан файл для актива');
|
||||
}
|
||||
|
||||
return storage.createOrganizationAsset({
|
||||
...data,
|
||||
fileKey,
|
||||
});
|
||||
}
|
||||
|
||||
async update(
|
||||
id: number,
|
||||
organizationId: number,
|
||||
updates: Partial<InsertOrganizationAsset> & { file?: MulterFile },
|
||||
): Promise<OrganizationAsset | undefined> {
|
||||
let fileKey = updates.fileKey;
|
||||
|
||||
if (updates.file) {
|
||||
const existing = await storage.getOrganizationAsset(id, organizationId);
|
||||
if (existing?.fileKey) {
|
||||
await this.deleteAssetFile(existing.fileKey);
|
||||
}
|
||||
const upload = await this.uploadAssetFile(updates.file);
|
||||
fileKey = upload.key;
|
||||
}
|
||||
|
||||
return storage.updateOrganizationAsset(id, organizationId, {
|
||||
...updates,
|
||||
...(fileKey ? { fileKey } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
async delete(id: number, organizationId: number): Promise<void> {
|
||||
const asset = await storage.getOrganizationAsset(id, organizationId);
|
||||
if (asset?.fileKey) {
|
||||
await this.deleteAssetFile(asset.fileKey);
|
||||
}
|
||||
await storage.deleteOrganizationAsset(id, organizationId);
|
||||
}
|
||||
|
||||
// ── File handling ──────────────────────────────────────────────────────────
|
||||
|
||||
private async uploadAssetFile(file: MulterFile): Promise<{ url: string; key: string }> {
|
||||
const buffer = await fs.readFile(file.path);
|
||||
const name = file.originalname;
|
||||
const mimeType = file.mimetype || getMimeType(name);
|
||||
|
||||
if (isS3Enabled) {
|
||||
const result = await uploadToS3(buffer, name, mimeType);
|
||||
await fs.unlink(file.path).catch(() => {});
|
||||
return result;
|
||||
}
|
||||
|
||||
// Локальный режим: копируем файл из tmp в uploads/
|
||||
const ext = path.extname(name);
|
||||
const key = `${Date.now()}-${Math.random().toString(36).slice(2)}${ext}`;
|
||||
const dest = path.join(uploadsDir, key);
|
||||
await fs.copyFile(file.path, dest);
|
||||
await fs.unlink(file.path).catch(() => {});
|
||||
return { url: `/api/files/${key}`, key };
|
||||
}
|
||||
|
||||
private async deleteAssetFile(fileKey: string): Promise<void> {
|
||||
if (isS3Enabled) {
|
||||
const { deleteFromS3 } = await import("../utils/s3.js");
|
||||
await deleteFromS3(fileKey);
|
||||
} else {
|
||||
const filePath = path.join(uploadsDir, path.basename(fileKey));
|
||||
await fs.unlink(filePath).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Base64 loading ─────────────────────────────────────────────────────────
|
||||
|
||||
async loadAsBase64(fileKey: string): Promise<string | null> {
|
||||
const mimeType = getMimeType(fileKey);
|
||||
|
||||
if (isS3Enabled) {
|
||||
const streamResult = await streamFromS3(fileKey);
|
||||
if (!streamResult?.body) return null;
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of streamResult.body as any) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
}
|
||||
const buffer = Buffer.concat(chunks);
|
||||
return `data:${mimeType};base64,${buffer.toString('base64')}`;
|
||||
}
|
||||
|
||||
const filePath = path.join(uploadsDir, path.basename(fileKey));
|
||||
try {
|
||||
const buffer = await fs.readFile(filePath);
|
||||
return `data:${mimeType};base64,${buffer.toString('base64')}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async loadByIdAsBase64(assetId: number, organizationId: number): Promise<string | null> {
|
||||
const asset = await storage.getOrganizationAsset(assetId, organizationId);
|
||||
if (!asset?.fileKey) return null;
|
||||
return this.loadAsBase64(asset.fileKey);
|
||||
}
|
||||
|
||||
async getAssetsForGeneration(
|
||||
placements: Array<{ assetId: number; position: string; width?: number; height?: number; opacity?: number }>,
|
||||
organizationId: number,
|
||||
): Promise<Array<{ assetId: number; base64: string; position: string; width?: number; height?: number; opacity?: number }>> {
|
||||
const result: Array<{ assetId: number; base64: string; position: string; width?: number; height?: number; opacity?: number }> = [];
|
||||
for (const placement of placements) {
|
||||
const base64 = await this.loadByIdAsBase64(placement.assetId, organizationId);
|
||||
if (!base64) continue;
|
||||
result.push({
|
||||
assetId: placement.assetId,
|
||||
base64,
|
||||
position: placement.position,
|
||||
width: placement.width,
|
||||
height: placement.height,
|
||||
opacity: placement.opacity,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user