fix(number-fields): избегаем потери точности длинных чисел

- number-поля теперь рендерятся как text + inputMode=numeric,
  чтобы браузер не округлял значения через input type=number
- пробелы при вставке в number-поля удаляются
- бэкенд нормализует значения number-полей в строку перед сохранением
- добавлен хелпер normalizeFieldValueForStorage

Closes: искажение расчётного счёта и других длинных числовых полей
This commit is contained in:
2026-07-07 21:03:40 +03:00
commit 1f5ecb6da4
1089 changed files with 237546 additions and 0 deletions

View 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;
}
}