Fix: estimates geo v2

This commit is contained in:
Arsen
2026-02-04 00:11:19 +05:00
commit 3f0086f88e
22567 changed files with 4348823 additions and 0 deletions

69
backend/dist/services/ai.service.d.ts vendored Executable file
View File

@@ -0,0 +1,69 @@
interface ChatMessage {
role: 'user' | 'assistant' | 'system';
content: string;
}
interface AIResponse {
content: string;
usage?: {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
};
}
export declare class AIService {
private provider;
private iieasyUrl;
private iieasyKey;
private iieasyModel;
private lmstudioUrl;
private lmstudioModel;
constructor();
setProvider(provider: 'iieasy' | 'lmstudio'): void;
chat(messages: ChatMessage[], systemPrompt?: string): Promise<AIResponse>;
private chatIIEasy;
private chatLMStudio;
extractEstimateData(text: string, previousData?: Partial<{
direction: string;
customer: string;
objectName: string;
executor: string;
works: Array<{
name: string;
volume: number;
unit: string;
}>;
}>, estimateContext?: {
objectName: string;
customer: string;
executor?: string;
direction?: string;
items?: Array<{
workName: string;
quantity: number;
unit?: string;
}>;
}): Promise<any>;
discussEstimate(userMessage: string, history: Array<{
role: string;
content: string;
}>, estimateJson: {
objectName: string;
customer: string;
executor?: string;
direction?: string;
items?: Array<{
workName: string;
quantity: number;
unit?: string;
basePrice?: number;
totalPrice?: number;
}>;
totals?: Array<{
label: string;
resultValue: number;
}>;
}): Promise<string>;
findPriceItems(workDescription: string, priceItems: any[]): Promise<any[]>;
}
export {};
//# sourceMappingURL=ai.service.d.ts.map

1
backend/dist/services/ai.service.d.ts.map vendored Executable file
View File

@@ -0,0 +1 @@
{"version":3,"file":"ai.service.d.ts","sourceRoot":"","sources":["../../src/services/ai.service.ts"],"names":[],"mappings":"AAGA,UAAU,WAAW;IACnB,IAAI,EAAE,MAAM,GAAG,WAAW,GAAG,QAAQ,CAAC;IACtC,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,UAAU,UAAU;IAClB,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,CAAC,EAAE;QACN,aAAa,EAAE,MAAM,CAAC;QACtB,iBAAiB,EAAE,MAAM,CAAC;QAC1B,YAAY,EAAE,MAAM,CAAC;KACtB,CAAC;CACH;AAED,qBAAa,SAAS;IACpB,OAAO,CAAC,QAAQ,CAAS;IACzB,OAAO,CAAC,SAAS,CAAS;IAC1B,OAAO,CAAC,SAAS,CAAS;IAC1B,OAAO,CAAC,WAAW,CAAS;IAC5B,OAAO,CAAC,WAAW,CAAS;IAC5B,OAAO,CAAC,aAAa,CAAS;;IAW9B,WAAW,CAAC,QAAQ,EAAE,QAAQ,GAAG,UAAU;IAIrC,IAAI,CAAC,QAAQ,EAAE,WAAW,EAAE,EAAE,YAAY,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC;YAgBjE,UAAU;YAiCV,YAAY;IAgCpB,mBAAmB,CACvB,IAAI,EAAE,MAAM,EACZ,YAAY,CAAC,EAAE,OAAO,CAAC;QACrB,SAAS,EAAE,MAAM,CAAC;QAClB,QAAQ,EAAE,MAAM,CAAC;QACjB,UAAU,EAAE,MAAM,CAAC;QACnB,QAAQ,EAAE,MAAM,CAAC;QACjB,KAAK,EAAE,KAAK,CAAC;YAAE,IAAI,EAAE,MAAM,CAAC;YAAC,MAAM,EAAE,MAAM,CAAC;YAAC,IAAI,EAAE,MAAM,CAAA;SAAE,CAAC,CAAC;KAC9D,CAAC,EACF,eAAe,CAAC,EAAE;QAChB,UAAU,EAAE,MAAM,CAAC;QACnB,QAAQ,EAAE,MAAM,CAAC;QACjB,QAAQ,CAAC,EAAE,MAAM,CAAC;QAClB,SAAS,CAAC,EAAE,MAAM,CAAC;QACnB,KAAK,CAAC,EAAE,KAAK,CAAC;YAAE,QAAQ,EAAE,MAAM,CAAC;YAAC,QAAQ,EAAE,MAAM,CAAC;YAAC,IAAI,CAAC,EAAE,MAAM,CAAA;SAAE,CAAC,CAAC;KACtE,GACA,OAAO,CAAC,GAAG,CAAC;IAkET,eAAe,CACnB,WAAW,EAAE,MAAM,EACnB,OAAO,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC,EACjD,YAAY,EAAE;QACZ,UAAU,EAAE,MAAM,CAAC;QACnB,QAAQ,EAAE,MAAM,CAAC;QACjB,QAAQ,CAAC,EAAE,MAAM,CAAC;QAClB,SAAS,CAAC,EAAE,MAAM,CAAC;QACnB,KAAK,CAAC,EAAE,KAAK,CAAC;YAAE,QAAQ,EAAE,MAAM,CAAC;YAAC,QAAQ,EAAE,MAAM,CAAC;YAAC,IAAI,CAAC,EAAE,MAAM,CAAC;YAAC,SAAS,CAAC,EAAE,MAAM,CAAC;YAAC,UAAU,CAAC,EAAE,MAAM,CAAA;SAAE,CAAC,CAAC;QAC9G,MAAM,CAAC,EAAE,KAAK,CAAC;YAAE,KAAK,EAAE,MAAM,CAAC;YAAC,WAAW,EAAE,MAAM,CAAA;SAAE,CAAC,CAAC;KACxD,GACA,OAAO,CAAC,MAAM,CAAC;IAkCZ,cAAc,CAAC,eAAe,EAAE,MAAM,EAAE,UAAU,EAAE,GAAG,EAAE,GAAG,OAAO,CAAC,GAAG,EAAE,CAAC;CA8BjF"}

205
backend/dist/services/ai.service.js vendored Executable file
View File

@@ -0,0 +1,205 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.AIService = void 0;
const dotenv_1 = __importDefault(require("dotenv"));
dotenv_1.default.config();
class AIService {
constructor() {
this.provider = process.env.AI_PROVIDER || 'iieasy';
this.iieasyUrl = process.env.IIEASY_API_URL || 'https://ai.iieasy.ru/v1';
this.iieasyKey = process.env.IIEASY_API_KEY || '';
this.iieasyModel = process.env.IIEASY_MODEL || 'google/gemma-3n-e4b';
this.lmstudioUrl = process.env.LMSTUDIO_API_URL || 'http://localhost:1234/v1';
this.lmstudioModel = process.env.LMSTUDIO_MODEL || 'local-model';
}
setProvider(provider) {
this.provider = provider;
}
async chat(messages, systemPrompt) {
const allMessages = [];
if (systemPrompt) {
allMessages.push({ role: 'system', content: systemPrompt });
}
allMessages.push(...messages);
if (this.provider === 'lmstudio') {
return this.chatLMStudio(allMessages);
}
else {
return this.chatIIEasy(allMessages);
}
}
async chatIIEasy(messages) {
try {
const response = await fetch(`${this.iieasyUrl}/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.iieasyKey}`,
},
body: JSON.stringify({
model: this.iieasyModel,
messages,
temperature: 0.7,
max_tokens: 4096,
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`IIEasy API error: ${response.status} - ${error}`);
}
const data = await response.json();
return {
content: data.choices[0]?.message?.content || '',
usage: data.usage,
};
}
catch (error) {
console.error('IIEasy API error:', error);
throw new Error(`AI service error: ${error.message}`);
}
}
async chatLMStudio(messages) {
try {
const response = await fetch(`${this.lmstudioUrl}/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: this.lmstudioModel,
messages,
temperature: 0.7,
max_tokens: 4096,
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`LM Studio API error: ${response.status} - ${error}`);
}
const data = await response.json();
return {
content: data.choices[0]?.message?.content || '',
usage: data.usage,
};
}
catch (error) {
console.error('LM Studio API error:', error);
throw new Error(`AI service error: ${error.message}`);
}
}
async extractEstimateData(text, previousData, estimateContext) {
const prevHints = [];
if (previousData?.customer?.trim())
prevHints.push(`Заказчик: "${previousData.customer}" — сохрани в customer, если в тексте не указан другой.`);
if (previousData?.direction?.trim())
prevHints.push(`Направление: "${previousData.direction}" — сохрани в direction, если пользователь не указал другое.`);
if (previousData?.objectName?.trim())
prevHints.push(`Объект: "${previousData.objectName}" — сохрани в objectName, если пользователь не указал другой.`);
if (previousData?.executor?.trim())
prevHints.push(`Исполнитель: "${previousData.executor}" — сохрани в executor, если не указан другой.`);
if (previousData?.works?.length)
prevHints.push(`Работы уже указаны — добавляй только новые, либо объединяй с ними.`);
const previousDataHint = prevHints.length
? `\nРанее в беседе указано:\n${prevHints.join('\n')}\nВ этих полях НЕ пиши "не указан" — используй ранее указанное значение или опусти поле.\n`
: '';
const estimateContextHint = estimateContext
? `\nТекущая смета (контекст беседы):\n- Объект: ${estimateContext.objectName}\n- Заказчик: ${estimateContext.customer}\n${estimateContext.executor ? `- Исполнитель: ${estimateContext.executor}\n` : ''}${estimateContext.direction ? `- Направление: ${estimateContext.direction}\n` : ''}${estimateContext.items && estimateContext.items.length > 0 ? `- Позиции сметы: ${estimateContext.items.map(i => `${i.workName} (${i.quantity} ${i.unit || 'шт.'})`).join(', ')}\n` : ''}\nОтвечай в контексте этой сметы. Пользователь может просить добавить работы, уточнить данные или задавать вопросы о смете.\n`
: '';
const systemPrompt = `Ты - ассистент для составления смет на изыскательские работы.
Проанализируй текст и извлеки следующую информацию в JSON:
- direction: направление изысканий — ОДНО из: geodesy, geology, ecology, hydrology (не перечень работ).
- customer: заказчик (организация или ФИО).
- objectName: полное наименование ОБЪЕКТА изысканий/строительства — это название объекта, НЕ перечень работ.
Примеры правильного objectName: «АО «Святогор». Месторождение «Волковское». Третья очередь. Комплекс объектов инфраструктуры обогатительной фабрики и открытого рудника», «Строительство школы в г. Москва».
НИКОГДА не подставляй в objectName: "работы", "перечень работ", названия видов работ (топосъёмка, нивелирование и т.п.) — это только для поля works.
- works: список работ с объёмами (name — краткое описание работы для подбора по СБЦ, volume — число, unit — единица: га, км, шт. и т.д.).
${previousDataHint}
${estimateContextHint}
Отвечай ТОЛЬКО в формате JSON, без дополнительного текста.
Пример ответа:
{
"direction": "geodesy",
"customer": "ООО Компания",
"objectName": "АО «Святогор». Месторождение «Волковское». Третья очередь. Комплекс объектов инфраструктуры обогатительной фабрики и открытого рудника",
"works": [
{"name": "Топографическая съемка", "volume": 10, "unit": "га"},
{"name": "Нивелирование", "volume": 5, "unit": "км"}
]
}`;
const response = await this.chat([{ role: 'user', content: text }], systemPrompt);
try {
// Try to parse JSON from response
const jsonMatch = response.content.match(/\{[\s\S]*\}/);
if (jsonMatch) {
return JSON.parse(jsonMatch[0]);
}
throw new Error('No JSON found in response');
}
catch (error) {
console.error('Failed to parse AI response:', response.content);
return {
error: true,
rawResponse: response.content,
};
}
}
async discussEstimate(userMessage, history, estimateJson) {
const itemsStr = estimateJson.items && estimateJson.items.length > 0
? estimateJson.items.map((i, idx) => `${idx + 1}. ${i.workName}: ${i.quantity} ${i.unit || 'шт.'}${i.totalPrice != null ? i.totalPrice + ' руб.' : ''}`).join('\n')
: 'Нет позиций';
const totalsStr = estimateJson.totals && estimateJson.totals.length > 0
? estimateJson.totals.map(t => `${t.label}: ${t.resultValue} руб.`).join('\n')
: '';
const systemPrompt = `Ты — эксперт-сметчик по изыскательским работам. Пользователь обсуждает смету. Отвечай на русском.
Текущая смета:
- Объект: ${estimateJson.objectName}
- Заказчик: ${estimateJson.customer}
${estimateJson.executor ? `- Исполнитель: ${estimateJson.executor}\n` : ''}${estimateJson.direction ? `- Направление: ${estimateJson.direction}\n` : ''}
Позиции:
${itemsStr}
${totalsStr ? `\nИтоги:\n${totalsStr}` : ''}
Помогай с рекомендациями, оптимизацией, корректировкой позиций и объёмов. Отвечай кратко и по делу.`;
const messages = [
...history.slice(-10).map(m => ({
role: m.role,
content: m.content,
})),
{ role: 'user', content: userMessage },
].filter(m => m.role !== 'system');
const response = await this.chat(messages, systemPrompt);
return response.content;
}
async findPriceItems(workDescription, priceItems) {
const systemPrompt = `Ты - эксперт по сметному делу в изысканиях.
Тебе даны: описание работы и список позиций из справочника базовых цен.
Найди наиболее подходящие позиции для данной работы.
Отвечай ТОЛЬКО в формате JSON - массив индексов подходящих позиций.
Пример: [0, 2, 5]
Если ничего не подходит, верни пустой массив: []`;
const itemsList = priceItems.map((item, idx) => `${idx}. ${item.paragraph}: ${item.workType}`).join('\n');
const response = await this.chat([{ role: 'user', content: `Работа: ${workDescription}\n\nПозиции справочника:\n${itemsList}` }], systemPrompt);
try {
const jsonMatch = response.content.match(/\[[\s\S]*\]/);
if (jsonMatch) {
const indices = JSON.parse(jsonMatch[0]);
return indices.map((idx) => priceItems[idx]).filter(Boolean);
}
return [];
}
catch (error) {
return [];
}
}
}
exports.AIService = AIService;
//# sourceMappingURL=ai.service.js.map

1
backend/dist/services/ai.service.js.map vendored Executable file

File diff suppressed because one or more lines are too long

258
backend/dist/services/estimate.service.d.ts vendored Executable file
View File

@@ -0,0 +1,258 @@
import { PrismaClient, Prisma } from '@prisma/client';
export declare class EstimateService {
private prisma;
constructor(prisma: PrismaClient);
createEstimate(data: {
ownerId: string;
directionCode: string;
objectName: string;
customer: string;
executor?: string;
vatRate?: number;
}): Promise<{
direction: {
name: string;
id: string;
code: string;
shortName: string;
isActive: boolean;
};
} & {
number: string;
id: string;
createdAt: Date;
updatedAt: Date;
ownerId: string;
objectName: string;
customer: string;
executor: string;
totalFieldWorks: Prisma.Decimal | null;
totalOfficeWorks: Prisma.Decimal | null;
totalLaboratory: Prisma.Decimal | null;
subtotal: Prisma.Decimal | null;
regionalCoef: Prisma.Decimal | null;
inflationIndex: Prisma.Decimal | null;
inflationDocRef: string | null;
companyCoef: Prisma.Decimal | null;
executorCoef: Prisma.Decimal | null;
totalWithoutVat: Prisma.Decimal | null;
vatRate: Prisma.Decimal | null;
vatAmount: Prisma.Decimal | null;
totalWithVat: Prisma.Decimal | null;
status: string;
directionId: string;
}>;
updateEstimate(id: string, data: Partial<{
objectName: string;
customer: string;
executor: string;
regionalCoef: number;
inflationIndex: number;
inflationDocRef: string;
companyCoef: number;
executorCoef: number;
vatRate: number;
status: string;
}>): Promise<{
direction: {
name: string;
id: string;
code: string;
shortName: string;
isActive: boolean;
};
items: {
id: string;
createdAt: Date;
updatedAt: Date;
orderNumber: number;
estimateId: string;
sectionType: string;
priceItemId: string | null;
workName: string;
justification: string | null;
basePrice: Prisma.Decimal;
quantity: Prisma.Decimal;
unit: string | null;
coef1: Prisma.Decimal | null;
coef1Desc: string | null;
coef2: Prisma.Decimal | null;
coef2Desc: string | null;
coef3: Prisma.Decimal | null;
coef3Desc: string | null;
totalPrice: Prisma.Decimal;
}[];
totals: {
id: string;
createdAt: Date;
updatedAt: Date;
label: string;
orderNumber: number;
estimateId: string;
description: string | null;
baseValue: Prisma.Decimal | null;
coefficient: Prisma.Decimal | null;
resultValue: Prisma.Decimal;
}[];
} & {
number: string;
id: string;
createdAt: Date;
updatedAt: Date;
ownerId: string;
objectName: string;
customer: string;
executor: string;
totalFieldWorks: Prisma.Decimal | null;
totalOfficeWorks: Prisma.Decimal | null;
totalLaboratory: Prisma.Decimal | null;
subtotal: Prisma.Decimal | null;
regionalCoef: Prisma.Decimal | null;
inflationIndex: Prisma.Decimal | null;
inflationDocRef: string | null;
companyCoef: Prisma.Decimal | null;
executorCoef: Prisma.Decimal | null;
totalWithoutVat: Prisma.Decimal | null;
vatRate: Prisma.Decimal | null;
vatAmount: Prisma.Decimal | null;
totalWithVat: Prisma.Decimal | null;
status: string;
directionId: string;
}>;
addEstimateItem(estimateId: string, data: {
sectionType: string;
priceItemId?: string;
workName: string;
justification?: string;
basePrice: number;
quantity: number;
unit?: string;
coef1?: number;
coef1Desc?: string;
coef2?: number;
coef2Desc?: string;
coef3?: number;
coef3Desc?: string;
}): Promise<{
id: string;
createdAt: Date;
updatedAt: Date;
orderNumber: number;
estimateId: string;
sectionType: string;
priceItemId: string | null;
workName: string;
justification: string | null;
basePrice: Prisma.Decimal;
quantity: Prisma.Decimal;
unit: string | null;
coef1: Prisma.Decimal | null;
coef1Desc: string | null;
coef2: Prisma.Decimal | null;
coef2Desc: string | null;
coef3: Prisma.Decimal | null;
coef3Desc: string | null;
totalPrice: Prisma.Decimal;
}>;
updateEstimateItem(itemId: string, data: Partial<{
workName: string;
justification: string;
basePrice: number;
quantity: number;
unit: string;
coef1: number;
coef1Desc: string;
coef2: number;
coef2Desc: string;
coef3: number;
coef3Desc: string;
}>): Promise<{
id: string;
createdAt: Date;
updatedAt: Date;
orderNumber: number;
estimateId: string;
sectionType: string;
priceItemId: string | null;
workName: string;
justification: string | null;
basePrice: Prisma.Decimal;
quantity: Prisma.Decimal;
unit: string | null;
coef1: Prisma.Decimal | null;
coef1Desc: string | null;
coef2: Prisma.Decimal | null;
coef2Desc: string | null;
coef3: Prisma.Decimal | null;
coef3Desc: string | null;
totalPrice: Prisma.Decimal;
}>;
recalculateTotals(estimateId: string): Promise<{
direction: {
name: string;
id: string;
code: string;
shortName: string;
isActive: boolean;
};
items: {
id: string;
createdAt: Date;
updatedAt: Date;
orderNumber: number;
estimateId: string;
sectionType: string;
priceItemId: string | null;
workName: string;
justification: string | null;
basePrice: Prisma.Decimal;
quantity: Prisma.Decimal;
unit: string | null;
coef1: Prisma.Decimal | null;
coef1Desc: string | null;
coef2: Prisma.Decimal | null;
coef2Desc: string | null;
coef3: Prisma.Decimal | null;
coef3Desc: string | null;
totalPrice: Prisma.Decimal;
}[];
totals: {
id: string;
createdAt: Date;
updatedAt: Date;
label: string;
orderNumber: number;
estimateId: string;
description: string | null;
baseValue: Prisma.Decimal | null;
coefficient: Prisma.Decimal | null;
resultValue: Prisma.Decimal;
}[];
} & {
number: string;
id: string;
createdAt: Date;
updatedAt: Date;
ownerId: string;
objectName: string;
customer: string;
executor: string;
totalFieldWorks: Prisma.Decimal | null;
totalOfficeWorks: Prisma.Decimal | null;
totalLaboratory: Prisma.Decimal | null;
subtotal: Prisma.Decimal | null;
regionalCoef: Prisma.Decimal | null;
inflationIndex: Prisma.Decimal | null;
inflationDocRef: string | null;
companyCoef: Prisma.Decimal | null;
executorCoef: Prisma.Decimal | null;
totalWithoutVat: Prisma.Decimal | null;
vatRate: Prisma.Decimal | null;
vatAmount: Prisma.Decimal | null;
totalWithVat: Prisma.Decimal | null;
status: string;
directionId: string;
}>;
private updateTotalsSection;
}
//# sourceMappingURL=estimate.service.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"estimate.service.d.ts","sourceRoot":"","sources":["../../src/services/estimate.service.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,EAAE,MAAM,gBAAgB,CAAC;AAEtD,qBAAa,eAAe;IACd,OAAO,CAAC,MAAM;gBAAN,MAAM,EAAE,YAAY;IAElC,cAAc,CAAC,IAAI,EAAE;QACzB,OAAO,EAAE,MAAM,CAAC;QAChB,aAAa,EAAE,MAAM,CAAC;QACtB,UAAU,EAAE,MAAM,CAAC;QACnB,QAAQ,EAAE,MAAM,CAAC;QACjB,QAAQ,CAAC,EAAE,MAAM,CAAC;QAClB,OAAO,CAAC,EAAE,MAAM,CAAC;KAClB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;IA4CK,cAAc,CAAC,EAAE,EAAE,MAAM,EAAE,IAAI,EAAE,OAAO,CAAC;QAC7C,UAAU,EAAE,MAAM,CAAC;QACnB,QAAQ,EAAE,MAAM,CAAC;QACjB,QAAQ,EAAE,MAAM,CAAC;QACjB,YAAY,EAAE,MAAM,CAAC;QACrB,cAAc,EAAE,MAAM,CAAC;QACvB,eAAe,EAAE,MAAM,CAAC;QACxB,WAAW,EAAE,MAAM,CAAC;QACpB,YAAY,EAAE,MAAM,CAAC;QACrB,OAAO,EAAE,MAAM,CAAC;QAChB,MAAM,EAAE,MAAM,CAAC;KAChB,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;IA2BI,eAAe,CAAC,UAAU,EAAE,MAAM,EAAE,IAAI,EAAE;QAC9C,WAAW,EAAE,MAAM,CAAC;QACpB,WAAW,CAAC,EAAE,MAAM,CAAC;QACrB,QAAQ,EAAE,MAAM,CAAC;QACjB,aAAa,CAAC,EAAE,MAAM,CAAC;QACvB,SAAS,EAAE,MAAM,CAAC;QAClB,QAAQ,EAAE,MAAM,CAAC;QACjB,IAAI,CAAC,EAAE,MAAM,CAAC;QACd,KAAK,CAAC,EAAE,MAAM,CAAC;QACf,SAAS,CAAC,EAAE,MAAM,CAAC;QACnB,KAAK,CAAC,EAAE,MAAM,CAAC;QACf,SAAS,CAAC,EAAE,MAAM,CAAC;QACnB,KAAK,CAAC,EAAE,MAAM,CAAC;QACf,SAAS,CAAC,EAAE,MAAM,CAAC;KACpB;;;;;;;;;;;;;;;;;;;;;IA0CK,kBAAkB,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,OAAO,CAAC;QACrD,QAAQ,EAAE,MAAM,CAAC;QACjB,aAAa,EAAE,MAAM,CAAC;QACtB,SAAS,EAAE,MAAM,CAAC;QAClB,QAAQ,EAAE,MAAM,CAAC;QACjB,IAAI,EAAE,MAAM,CAAC;QACb,KAAK,EAAE,MAAM,CAAC;QACd,SAAS,EAAE,MAAM,CAAC;QAClB,KAAK,EAAE,MAAM,CAAC;QACd,SAAS,EAAE,MAAM,CAAC;QAClB,KAAK,EAAE,MAAM,CAAC;QACd,SAAS,EAAE,MAAM,CAAC;KACnB,CAAC;;;;;;;;;;;;;;;;;;;;;IAyCI,iBAAiB,CAAC,UAAU,EAAE,MAAM;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;YAqE5B,mBAAmB;CAqGlC"}

317
backend/dist/services/estimate.service.js vendored Executable file
View File

@@ -0,0 +1,317 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.EstimateService = void 0;
const client_1 = require("@prisma/client");
class EstimateService {
constructor(prisma) {
this.prisma = prisma;
}
async createEstimate(data) {
// Get direction
const direction = await this.prisma.surveyDirection.findUnique({
where: { code: data.directionCode },
});
if (!direction) {
throw new Error(`Direction not found: ${data.directionCode}`);
}
// Get default executor from settings
let executor = data.executor;
if (!executor) {
const defaultExecutor = await this.prisma.setting.findUnique({
where: { key: 'default_executor' },
});
executor = defaultExecutor?.value || 'Не указан';
}
// Generate estimate number (per owner)
const count = await this.prisma.estimate.count({
where: { ownerId: data.ownerId },
});
const number = `${count + 1}`;
const estimate = await this.prisma.estimate.create({
data: {
number,
directionId: direction.id,
ownerId: data.ownerId,
objectName: data.objectName,
customer: data.customer,
executor,
vatRate: data.vatRate ? new client_1.Prisma.Decimal(data.vatRate) : new client_1.Prisma.Decimal(20),
status: 'draft',
},
include: {
direction: true,
},
});
return estimate;
}
async updateEstimate(id, data) {
const updateData = {};
if (data.objectName)
updateData.objectName = data.objectName;
if (data.customer)
updateData.customer = data.customer;
if (data.executor)
updateData.executor = data.executor;
if (data.regionalCoef !== undefined)
updateData.regionalCoef = new client_1.Prisma.Decimal(data.regionalCoef);
if (data.inflationIndex !== undefined)
updateData.inflationIndex = new client_1.Prisma.Decimal(data.inflationIndex);
if (data.inflationDocRef)
updateData.inflationDocRef = data.inflationDocRef;
if (data.companyCoef !== undefined)
updateData.companyCoef = new client_1.Prisma.Decimal(data.companyCoef);
if (data.executorCoef !== undefined)
updateData.executorCoef = new client_1.Prisma.Decimal(data.executorCoef);
if (data.vatRate !== undefined)
updateData.vatRate = new client_1.Prisma.Decimal(data.vatRate);
if (data.status)
updateData.status = data.status;
const estimate = await this.prisma.estimate.update({
where: { id },
data: updateData,
include: {
direction: true,
items: { orderBy: { orderNumber: 'asc' } },
totals: { orderBy: { orderNumber: 'asc' } },
},
});
return estimate;
}
async addEstimateItem(estimateId, data) {
// Get max order number
const maxOrder = await this.prisma.estimateItem.aggregate({
where: { estimateId },
_max: { orderNumber: true },
});
const orderNumber = (maxOrder._max.orderNumber || 0) + 1;
// Calculate total price
const basePrice = new client_1.Prisma.Decimal(data.basePrice);
const quantity = new client_1.Prisma.Decimal(data.quantity);
let totalPrice = basePrice.mul(quantity);
if (data.coef1)
totalPrice = totalPrice.mul(new client_1.Prisma.Decimal(data.coef1));
if (data.coef2)
totalPrice = totalPrice.mul(new client_1.Prisma.Decimal(data.coef2));
if (data.coef3)
totalPrice = totalPrice.mul(new client_1.Prisma.Decimal(data.coef3));
const item = await this.prisma.estimateItem.create({
data: {
estimateId,
orderNumber,
sectionType: data.sectionType,
priceItemId: data.priceItemId || null,
workName: data.workName,
justification: data.justification || null,
basePrice,
quantity,
unit: data.unit || null,
coef1: data.coef1 ? new client_1.Prisma.Decimal(data.coef1) : null,
coef1Desc: data.coef1Desc || null,
coef2: data.coef2 ? new client_1.Prisma.Decimal(data.coef2) : null,
coef2Desc: data.coef2Desc || null,
coef3: data.coef3 ? new client_1.Prisma.Decimal(data.coef3) : null,
coef3Desc: data.coef3Desc || null,
totalPrice,
},
});
return item;
}
async updateEstimateItem(itemId, data) {
const updateData = {};
if (data.workName)
updateData.workName = data.workName;
if (data.justification)
updateData.justification = data.justification;
if (data.basePrice !== undefined)
updateData.basePrice = new client_1.Prisma.Decimal(data.basePrice);
if (data.quantity !== undefined)
updateData.quantity = new client_1.Prisma.Decimal(data.quantity);
if (data.unit)
updateData.unit = data.unit;
if (data.coef1 !== undefined)
updateData.coef1 = data.coef1 ? new client_1.Prisma.Decimal(data.coef1) : null;
if (data.coef1Desc !== undefined)
updateData.coef1Desc = data.coef1Desc;
if (data.coef2 !== undefined)
updateData.coef2 = data.coef2 ? new client_1.Prisma.Decimal(data.coef2) : null;
if (data.coef2Desc !== undefined)
updateData.coef2Desc = data.coef2Desc;
if (data.coef3 !== undefined)
updateData.coef3 = data.coef3 ? new client_1.Prisma.Decimal(data.coef3) : null;
if (data.coef3Desc !== undefined)
updateData.coef3Desc = data.coef3Desc;
// Recalculate total if price/quantity/coefs changed
const currentItem = await this.prisma.estimateItem.findUnique({ where: { id: itemId } });
if (currentItem) {
const basePrice = updateData.basePrice || currentItem.basePrice;
const quantity = updateData.quantity || currentItem.quantity;
let totalPrice = basePrice.mul(quantity);
const coef1 = updateData.coef1 !== undefined ? updateData.coef1 : currentItem.coef1;
const coef2 = updateData.coef2 !== undefined ? updateData.coef2 : currentItem.coef2;
const coef3 = updateData.coef3 !== undefined ? updateData.coef3 : currentItem.coef3;
if (coef1)
totalPrice = totalPrice.mul(coef1);
if (coef2)
totalPrice = totalPrice.mul(coef2);
if (coef3)
totalPrice = totalPrice.mul(coef3);
updateData.totalPrice = totalPrice;
}
const item = await this.prisma.estimateItem.update({
where: { id: itemId },
data: updateData,
});
return item;
}
async recalculateTotals(estimateId) {
const estimate = await this.prisma.estimate.findUnique({
where: { id: estimateId },
include: {
items: true,
},
});
if (!estimate) {
throw new Error('Estimate not found');
}
// Calculate section totals
const fieldWorks = estimate.items
.filter(i => i.sectionType === 'field')
.reduce((sum, i) => sum.add(i.totalPrice), new client_1.Prisma.Decimal(0));
const officeWorks = estimate.items
.filter(i => i.sectionType === 'office')
.reduce((sum, i) => sum.add(i.totalPrice), new client_1.Prisma.Decimal(0));
const laboratory = estimate.items
.filter(i => i.sectionType === 'laboratory')
.reduce((sum, i) => sum.add(i.totalPrice), new client_1.Prisma.Decimal(0));
const subtotal = estimate.items
.reduce((sum, i) => sum.add(i.totalPrice), new client_1.Prisma.Decimal(0));
// Apply coefficients
const regionalCoef = estimate.regionalCoef || new client_1.Prisma.Decimal(1);
const inflationIndex = estimate.inflationIndex || new client_1.Prisma.Decimal(1);
const companyCoef = estimate.companyCoef || new client_1.Prisma.Decimal(1);
const executorCoef = estimate.executorCoef || new client_1.Prisma.Decimal(1);
let totalWithoutVat = subtotal
.mul(regionalCoef)
.mul(inflationIndex)
.mul(companyCoef)
.mul(executorCoef);
const vatRate = estimate.vatRate || new client_1.Prisma.Decimal(20);
const vatAmount = totalWithoutVat.mul(vatRate).div(100);
const totalWithVat = totalWithoutVat.add(vatAmount);
// Update estimate
const updated = await this.prisma.estimate.update({
where: { id: estimateId },
data: {
totalFieldWorks: fieldWorks,
totalOfficeWorks: officeWorks,
totalLaboratory: laboratory,
subtotal,
totalWithoutVat,
vatAmount,
totalWithVat,
},
include: {
direction: true,
items: { orderBy: { orderNumber: 'asc' } },
totals: { orderBy: { orderNumber: 'asc' } },
},
});
// Update totals section
await this.updateTotalsSection(estimateId, estimate, updated);
return updated;
}
async updateTotalsSection(estimateId, estimate, updated) {
// Delete existing totals
await this.prisma.estimateTotal.deleteMany({ where: { estimateId } });
const totals = [];
let order = 1;
// Add totals rows
if (updated.regionalCoef && Number(updated.regionalCoef) !== 1) {
totals.push({
estimateId,
orderNumber: order++,
label: 'С районным коэффициентом',
description: `К=${updated.regionalCoef}`,
baseValue: updated.subtotal,
coefficient: updated.regionalCoef,
resultValue: updated.subtotal.mul(updated.regionalCoef),
});
}
if (updated.inflationIndex && Number(updated.inflationIndex) !== 1) {
const prevValue = totals.length > 0
? totals[totals.length - 1].resultValue
: updated.subtotal;
totals.push({
estimateId,
orderNumber: order++,
label: 'Перевод в текущие цены',
description: updated.inflationDocRef || `Индекс: ${updated.inflationIndex}`,
baseValue: prevValue,
coefficient: updated.inflationIndex,
resultValue: prevValue.mul(updated.inflationIndex),
});
}
if (updated.companyCoef && Number(updated.companyCoef) !== 1) {
const prevValue = totals.length > 0
? totals[totals.length - 1].resultValue
: updated.subtotal;
totals.push({
estimateId,
orderNumber: order++,
label: 'Коэффициент компании',
description: null,
baseValue: prevValue,
coefficient: updated.companyCoef,
resultValue: prevValue.mul(updated.companyCoef),
});
}
if (updated.executorCoef && Number(updated.executorCoef) !== 1) {
const prevValue = totals.length > 0
? totals[totals.length - 1].resultValue
: updated.subtotal;
totals.push({
estimateId,
orderNumber: order++,
label: `Коэффициент ${estimate.executor}`,
description: null,
baseValue: prevValue,
coefficient: updated.executorCoef,
resultValue: prevValue.mul(updated.executorCoef),
});
}
// Final totals
totals.push({
estimateId,
orderNumber: order++,
label: 'Итого без НДС:',
description: null,
baseValue: null,
coefficient: null,
resultValue: updated.totalWithoutVat,
});
totals.push({
estimateId,
orderNumber: order++,
label: `НДС ${updated.vatRate}%:`,
description: null,
baseValue: updated.totalWithoutVat,
coefficient: updated.vatRate.div(100),
resultValue: updated.vatAmount,
});
totals.push({
estimateId,
orderNumber: order++,
label: 'Всего с НДС:',
description: null,
baseValue: null,
coefficient: null,
resultValue: updated.totalWithVat,
});
// Create totals
for (const total of totals) {
await this.prisma.estimateTotal.create({ data: total });
}
}
}
exports.EstimateService = EstimateService;
//# sourceMappingURL=estimate.service.js.map

File diff suppressed because one or more lines are too long

43
backend/dist/services/parser.service.d.ts vendored Executable file
View File

@@ -0,0 +1,43 @@
import { PrismaClient } from '@prisma/client';
import { AIService } from './ai.service';
interface ParsedData {
direction?: string;
customer?: string;
objectName?: string;
executor?: string;
works?: Array<{
name: string;
volume: number;
unit: string;
priceItemId?: string;
justification?: string;
}>;
vatIncluded?: boolean;
missingFields?: string[];
}
interface ProcessResult {
message: string;
extractedData?: ParsedData;
needsClarification: boolean;
clarificationQuestions?: string[];
}
export declare class ParserService {
private prisma;
private aiService;
constructor(prisma: PrismaClient, aiService: AIService);
/** Режим обсуждения сметы: свободный диалог о прикреплённой смете */
private processEstimateDiscussion;
/** Берём последние извлечённые данные из истории (ответ ассистента с metadata) */
private getPreviousExtractedData;
/** Значения-заполнители: не перезаписываем ими ранее указанные данные */
private static readonly PLACEHOLDER_VALUES;
private isPlaceholder;
/** Объединяем новые данные с предыдущими: не затираем поля, которые пользователь уже указал */
private mergeExtractedData;
processMessage(content: string, history: any[], estimateId?: string | null): Promise<ProcessResult>;
private matchWorksWithPriceItems;
private getRelevantPriceBookCode;
createEstimateFromParsedData(data: ParsedData): Promise<any>;
}
export {};
//# sourceMappingURL=parser.service.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"parser.service.d.ts","sourceRoot":"","sources":["../../src/services/parser.service.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAC9C,OAAO,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAEzC,UAAU,UAAU;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,KAAK,CAAC,EAAE,KAAK,CAAC;QACZ,IAAI,EAAE,MAAM,CAAC;QACb,MAAM,EAAE,MAAM,CAAC;QACf,IAAI,EAAE,MAAM,CAAC;QACb,WAAW,CAAC,EAAE,MAAM,CAAC;QACrB,aAAa,CAAC,EAAE,MAAM,CAAC;KACxB,CAAC,CAAC;IACH,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,aAAa,CAAC,EAAE,MAAM,EAAE,CAAC;CAC1B;AAED,UAAU,aAAa;IACrB,OAAO,EAAE,MAAM,CAAC;IAChB,aAAa,CAAC,EAAE,UAAU,CAAC;IAC3B,kBAAkB,EAAE,OAAO,CAAC;IAC5B,sBAAsB,CAAC,EAAE,MAAM,EAAE,CAAC;CACnC;AAED,qBAAa,aAAa;IAEtB,OAAO,CAAC,MAAM;IACd,OAAO,CAAC,SAAS;gBADT,MAAM,EAAE,YAAY,EACpB,SAAS,EAAE,SAAS;IAG9B,qEAAqE;YACvD,yBAAyB;IA6CvC,kFAAkF;IAClF,OAAO,CAAC,wBAAwB;IAahC,yEAAyE;IACzE,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,kBAAkB,CAGxC;IAEF,OAAO,CAAC,aAAa;IAMrB,+FAA+F;IAC/F,OAAO,CAAC,kBAAkB;IAgBpB,cAAc,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,GAAG,EAAE,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,GAAG,OAAO,CAAC,aAAa,CAAC;YAyI3F,wBAAwB;IA8BtC,OAAO,CAAC,wBAAwB;IAU1B,4BAA4B,CAAC,IAAI,EAAE,UAAU,GAAG,OAAO,CAAC,GAAG,CAAC;CA8EnE"}

314
backend/dist/services/parser.service.js vendored Executable file
View File

@@ -0,0 +1,314 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.ParserService = void 0;
class ParserService {
constructor(prisma, aiService) {
this.prisma = prisma;
this.aiService = aiService;
}
/** Режим обсуждения сметы: свободный диалог о прикреплённой смете */
async processEstimateDiscussion(content, history, estimateId) {
const estimate = await this.prisma.estimate.findUnique({
where: { id: estimateId },
include: {
direction: true,
items: { orderBy: { orderNumber: 'asc' } },
totals: { orderBy: { orderNumber: 'asc' } },
},
});
if (!estimate)
return null;
const estimateJson = {
objectName: estimate.objectName,
customer: estimate.customer,
executor: estimate.executor || undefined,
direction: estimate.direction?.code,
items: estimate.items.map(i => ({
workName: i.workName,
quantity: Number(i.quantity),
unit: i.unit || undefined,
basePrice: Number(i.basePrice),
totalPrice: Number(i.totalPrice),
})),
totals: estimate.totals?.map(t => ({
label: t.label,
resultValue: Number(t.resultValue),
})),
};
const historyForAi = history.map(m => ({
role: m.role,
content: m.content,
}));
const message = await this.aiService.discussEstimate(content, historyForAi, estimateJson);
return {
message,
needsClarification: false,
};
}
/** Берём последние извлечённые данные из истории (ответ ассистента с metadata) */
getPreviousExtractedData(history) {
for (let i = history.length - 1; i >= 0; i--) {
const msg = history[i];
if (msg.role === 'assistant' && msg.metadata && typeof msg.metadata === 'object') {
const data = msg.metadata;
if (data.direction || data.customer || data.objectName || (data.works && data.works.length > 0)) {
return data;
}
}
}
return null;
}
isPlaceholder(val) {
if (!val || typeof val !== 'string')
return true;
const t = val.trim().toLowerCase();
return ParserService.PLACEHOLDER_VALUES.some(p => t === p.toLowerCase() || t === '');
}
/** Объединяем новые данные с предыдущими: не затираем поля, которые пользователь уже указал */
mergeExtractedData(previous, fresh) {
const take = (freshVal, prevVal) => {
if (freshVal?.trim() && !this.isPlaceholder(freshVal))
return freshVal.trim();
if (prevVal?.trim())
return prevVal;
return undefined;
};
return {
direction: take(fresh.direction, previous?.direction),
customer: take(fresh.customer, previous?.customer),
objectName: take(fresh.objectName, previous?.objectName),
executor: take(fresh.executor, previous?.executor),
works: (fresh.works && fresh.works.length > 0) ? fresh.works : (previous?.works ?? fresh.works),
vatIncluded: fresh.vatIncluded ?? previous?.vatIncluded,
};
}
async processMessage(content, history, estimateId) {
// Режим обсуждения сметы: когда есть прикреплённая смета
if (estimateId) {
const discussionResult = await this.processEstimateDiscussion(content, history, estimateId);
if (discussionResult)
return discussionResult;
}
const previousData = this.getPreviousExtractedData(history);
let estimateContext;
if (estimateId) {
const estimate = await this.prisma.estimate.findUnique({
where: { id: estimateId },
include: {
direction: true,
items: { orderBy: { orderNumber: 'asc' } },
},
});
if (estimate) {
estimateContext = {
objectName: estimate.objectName,
customer: estimate.customer,
executor: estimate.executor || undefined,
direction: estimate.direction?.code,
items: estimate.items.map(i => ({
workName: i.workName,
quantity: Number(i.quantity),
unit: i.unit || undefined,
})),
};
}
}
const freshData = await this.aiService.extractEstimateData(content, previousData ?? undefined, estimateContext);
if (freshData.error) {
return {
message: 'Не удалось обработать сообщение. Пожалуйста, опишите подробнее: какой объект, какие работы нужно выполнить?',
needsClarification: true,
clarificationQuestions: [
'Какое направление изысканий? (геодезия, геология, экология, гидрометеорология)',
'Кто заказчик?',
'Как называется объект?',
'Какие работы и в каком объёме?',
],
};
}
const extractedData = this.mergeExtractedData(previousData, freshData);
// Check for missing fields
const missingFields = [];
const questions = [];
if (!extractedData.direction) {
missingFields.push('direction');
questions.push('Укажите направление изысканий: геодезия, геология, экология или гидрометеорология?');
}
if (!extractedData.customer) {
missingFields.push('customer');
questions.push('Кто заказчик работ?');
}
if (!extractedData.objectName) {
missingFields.push('objectName');
questions.push('Как называется объект? (полное наименование объекта изысканий/строительства)');
}
if (!extractedData.works || extractedData.works.length === 0) {
missingFields.push('works');
questions.push('Какие работы нужно выполнить и в каком объёме?');
}
// If we have enough data, try to match works with price items (СБЦ)
if (extractedData.works && extractedData.works.length > 0) {
await this.matchWorksWithPriceItems(extractedData);
}
// Build response message
let message = '';
if (extractedData.direction || extractedData.customer || extractedData.objectName) {
message += 'Извлечённые данные:\n';
if (extractedData.direction) {
const directionNames = {
geodesy: 'Инженерно-геодезические изыскания',
geology: 'Инженерно-геологические изыскания',
ecology: 'Инженерно-экологические изыскания',
hydrology: 'Инженерно-гидрометеорологические изыскания',
};
message += `- Направление: ${directionNames[extractedData.direction] || extractedData.direction}\n`;
}
if (extractedData.customer) {
message += `- Заказчик: ${extractedData.customer}\n`;
}
if (extractedData.objectName) {
message += `- Объект: ${extractedData.objectName}\n`;
}
if (extractedData.works && extractedData.works.length > 0) {
message += `- Работы (${extractedData.works.length}):\n`;
for (const work of extractedData.works) {
message += `${work.name}: ${work.volume} ${work.unit}`;
if (work.justification) {
message += ` (${work.justification})`;
}
message += '\n';
}
}
}
if (missingFields.length > 0) {
message += '\nДля продолжения уточните:\n';
questions.forEach((q, i) => {
message += `${i + 1}. ${q}\n`;
});
return {
message,
extractedData,
needsClarification: true,
clarificationQuestions: questions,
};
}
// All data is present
message += '\nВсе данные получены. Смета готова к формированию. Создать смету?';
return {
message,
extractedData,
needsClarification: false,
};
}
async matchWorksWithPriceItems(data) {
if (!data.works || !data.direction)
return;
// Get relevant price book
const priceBookCode = this.getRelevantPriceBookCode(data.direction);
const priceBook = await this.prisma.priceBook.findFirst({
where: { code: priceBookCode },
});
if (!priceBook)
return;
// Get all price items for this book
const priceItems = await this.prisma.priceItem.findMany({
where: { priceBookId: priceBook.id },
include: { priceTable: true },
});
// Match each work
for (const work of data.works) {
const matches = await this.aiService.findPriceItems(work.name, priceItems);
if (matches.length > 0) {
const bestMatch = matches[0];
work.priceItemId = bestMatch.id;
work.justification = `${bestMatch.priceTable.name} ${bestMatch.paragraph}`;
}
}
}
getRelevantPriceBookCode(direction) {
const mapping = {
geodesy: 'SBC-GEODESY-2004',
geology: 'SBC-GEOLOGY-1999',
ecology: 'SBC-GEOLOGY-1999',
hydrology: 'SBC-HYDROLOGY-2001',
};
return mapping[direction] || 'SBC-GEODESY-2004';
}
async createEstimateFromParsedData(data) {
if (!data.direction || !data.customer || !data.objectName) {
throw new Error('Missing required fields');
}
// Get direction
const direction = await this.prisma.surveyDirection.findUnique({
where: { code: data.direction },
});
if (!direction) {
throw new Error('Invalid direction');
}
// Get default settings
const executorSetting = await this.prisma.setting.findUnique({
where: { key: 'default_executor' },
});
const vatSetting = await this.prisma.setting.findUnique({
where: { key: 'default_vat_rate' },
});
// Generate estimate number
const count = await this.prisma.estimate.count();
// Create estimate
const estimate = await this.prisma.estimate.create({
data: {
number: `${count + 1}`,
directionId: direction.id,
objectName: data.objectName,
customer: data.customer,
executor: data.executor || executorSetting?.value || 'Не указан',
vatRate: vatSetting ? parseFloat(vatSetting.value) : 20,
status: 'draft',
},
});
// Add works as estimate items; наименование работ — из справочника СБЦ (workType), если есть совпадение
if (data.works && data.works.length > 0) {
for (let i = 0; i < data.works.length; i++) {
const work = data.works[i];
let basePrice = 0;
let workName = work.name;
let unit = work.unit;
if (work.priceItemId) {
const priceItem = await this.prisma.priceItem.findUnique({
where: { id: work.priceItemId },
include: { priceTable: true },
});
if (priceItem) {
if (priceItem.priceSimple)
basePrice = Number(priceItem.priceSimple);
workName = priceItem.workType;
if (priceItem.priceTable?.unit)
unit = priceItem.priceTable.unit;
}
}
await this.prisma.estimateItem.create({
data: {
estimateId: estimate.id,
orderNumber: i + 1,
sectionType: 'field',
priceItemId: work.priceItemId || null,
workName,
justification: work.justification || null,
basePrice,
quantity: work.volume,
unit,
totalPrice: basePrice * work.volume,
},
});
}
}
return estimate;
}
}
exports.ParserService = ParserService;
/** Значения-заполнители: не перезаписываем ими ранее указанные данные */
ParserService.PLACEHOLDER_VALUES = [
'не указан', 'не указано', 'не указана', 'не указаны',
'—', '', '-', 'н/д', 'н.д.', 'не задан', 'отсутствует', '',
];
//# sourceMappingURL=parser.service.js.map

1
backend/dist/services/parser.service.js.map vendored Executable file

File diff suppressed because one or more lines are too long

55
backend/dist/services/pdf.service.d.ts vendored Executable file
View File

@@ -0,0 +1,55 @@
interface EstimateItem {
orderNumber: number;
workName: string;
justification?: string | null;
basePrice: any;
quantity: any;
unit?: string | null;
coef1?: any;
coef2?: any;
coef3?: any;
totalPrice: any;
sectionType: string;
}
interface EstimateTotal {
orderNumber: number;
label: string;
description?: string | null;
resultValue: any;
}
interface Estimate {
number: string;
direction: {
name: string;
shortName: string;
};
objectName: string;
customer: string;
executor: string;
items: EstimateItem[];
totals: EstimateTotal[];
totalFieldWorks?: any;
totalOfficeWorks?: any;
totalLaboratory?: any;
subtotal?: any;
totalWithoutVat?: any;
vatRate?: any;
vatAmount?: any;
totalWithVat?: any;
}
export declare class PdfService {
/** Resolve path to @fontsource/pt-sans/files (tries require.resolve, then cwd). */
private getFontPaths;
/** Read font buffer from first path that exists. */
private readFontBuffer;
generateEstimatePdf(estimate: Estimate): Promise<Buffer>;
private addHeader;
private addItemsTable;
private addItemRow;
private addTotals;
private addFooter;
private formatNumber;
private formatCurrency;
}
export {};
//# sourceMappingURL=pdf.service.d.ts.map

1
backend/dist/services/pdf.service.d.ts.map vendored Executable file
View File

@@ -0,0 +1 @@
{"version":3,"file":"pdf.service.d.ts","sourceRoot":"","sources":["../../src/services/pdf.service.ts"],"names":[],"mappings":"AAIA,UAAU,YAAY;IACpB,WAAW,EAAE,MAAM,CAAC;IACpB,QAAQ,EAAE,MAAM,CAAC;IACjB,aAAa,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,SAAS,EAAE,GAAG,CAAC;IACf,QAAQ,EAAE,GAAG,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,KAAK,CAAC,EAAE,GAAG,CAAC;IACZ,KAAK,CAAC,EAAE,GAAG,CAAC;IACZ,KAAK,CAAC,EAAE,GAAG,CAAC;IACZ,UAAU,EAAE,GAAG,CAAC;IAChB,WAAW,EAAE,MAAM,CAAC;CACrB;AAED,UAAU,aAAa;IACrB,WAAW,EAAE,MAAM,CAAC;IACpB,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,WAAW,EAAE,GAAG,CAAC;CAClB;AAED,UAAU,QAAQ;IAChB,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KAAE,CAAC;IAC/C,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,YAAY,EAAE,CAAC;IACtB,MAAM,EAAE,aAAa,EAAE,CAAC;IACxB,eAAe,CAAC,EAAE,GAAG,CAAC;IACtB,gBAAgB,CAAC,EAAE,GAAG,CAAC;IACvB,eAAe,CAAC,EAAE,GAAG,CAAC;IACtB,QAAQ,CAAC,EAAE,GAAG,CAAC;IACf,eAAe,CAAC,EAAE,GAAG,CAAC;IACtB,OAAO,CAAC,EAAE,GAAG,CAAC;IACd,SAAS,CAAC,EAAE,GAAG,CAAC;IAChB,YAAY,CAAC,EAAE,GAAG,CAAC;CACpB;AAOD,qBAAa,UAAU;IACrB,mFAAmF;IACnF,OAAO,CAAC,YAAY;IAapB,oDAAoD;IACpD,OAAO,CAAC,cAAc;IAShB,mBAAmB,CAAC,QAAQ,EAAE,QAAQ,GAAG,OAAO,CAAC,MAAM,CAAC;IAmD9D,OAAO,CAAC,SAAS;IA6BjB,OAAO,CAAC,aAAa;IA4FrB,OAAO,CAAC,UAAU;IAkClB,OAAO,CAAC,SAAS;IAkBjB,OAAO,CAAC,SAAS;IAMjB,OAAO,CAAC,YAAY;IAIpB,OAAO,CAAC,cAAc;CAOvB"}

231
backend/dist/services/pdf.service.js vendored Executable file
View File

@@ -0,0 +1,231 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.PdfService = void 0;
const fs_1 = __importDefault(require("fs"));
const path_1 = __importDefault(require("path"));
const pdfkit_1 = __importDefault(require("pdfkit"));
const FONT_FILES = {
regular: 'pt-sans-cyrillic-400-normal.woff',
bold: 'pt-sans-cyrillic-700-normal.woff',
};
class PdfService {
/** Resolve path to @fontsource/pt-sans/files (tries require.resolve, then cwd). */
getFontPaths() {
let dir;
try {
dir = path_1.default.dirname(require.resolve('@fontsource/pt-sans/package.json'));
}
catch {
dir = path_1.default.join(process.cwd(), 'node_modules', '@fontsource', 'pt-sans');
}
return {
regular: path_1.default.join(dir, 'files', FONT_FILES.regular),
bold: path_1.default.join(dir, 'files', FONT_FILES.bold),
};
}
/** Read font buffer from first path that exists. */
readFontBuffer(paths) {
for (const p of paths) {
if (fs_1.default.existsSync(p))
return fs_1.default.readFileSync(p);
}
throw new Error(`Cyrillic font not found. Tried: ${paths.join(', ')}. Run "npm install" in backend and ensure @fontsource/pt-sans is installed.`);
}
async generateEstimatePdf(estimate) {
return new Promise((resolve, reject) => {
try {
const doc = new pdfkit_1.default({
size: 'A4',
margin: 40,
bufferPages: true,
});
const chunks = [];
doc.on('data', (chunk) => chunks.push(chunk));
doc.on('end', () => resolve(Buffer.concat(chunks)));
doc.on('error', reject);
// Register Cyrillic fonts (PT Sans) — pass Buffer so path/encoding issues are avoided
const { regular: pathRegular, bold: pathBold } = this.getFontPaths();
const cwd = process.cwd();
const cwdFiles = path_1.default.join(cwd, 'node_modules', '@fontsource', 'pt-sans', 'files');
const parentFiles = path_1.default.join(cwd, '..', 'node_modules', '@fontsource', 'pt-sans', 'files');
const fontRegularBuffer = this.readFontBuffer([
pathRegular,
path_1.default.join(cwdFiles, FONT_FILES.regular),
path_1.default.join(parentFiles, FONT_FILES.regular),
]);
const fontBoldBuffer = this.readFontBuffer([
pathBold,
path_1.default.join(cwdFiles, FONT_FILES.bold),
path_1.default.join(parentFiles, FONT_FILES.bold),
]);
doc.registerFont('PTSans', fontRegularBuffer);
doc.registerFont('PTSansBold', fontBoldBuffer);
// Header
this.addHeader(doc, estimate);
// Items table
this.addItemsTable(doc, estimate);
// Totals section
this.addTotals(doc, estimate);
// Footer
this.addFooter(doc, estimate);
doc.end();
}
catch (error) {
reject(error);
}
});
}
addHeader(doc, estimate) {
doc.fontSize(14).font('PTSansBold');
doc.text(`Исполнительная смета №${estimate.number}`, { align: 'center' });
doc.moveDown(0.5);
doc.fontSize(12).font('PTSans');
doc.text(`${estimate.direction.name} на объекте:`, { align: 'center' });
doc.moveDown(0.5);
doc.fontSize(11);
doc.text(`«${estimate.objectName}»`, { align: 'center' });
doc.moveDown(1);
// Customer and Executor
const leftX = 40;
const rightX = 300;
doc.fontSize(10);
doc.text('Наименование организации Заказчика:', leftX, doc.y);
doc.text(`«${estimate.customer}»`, rightX, doc.y - 10);
doc.moveDown(0.5);
doc.text('Наименование организации Исполнителя:', leftX, doc.y);
doc.text(`«${estimate.executor}»`, rightX, doc.y - 10);
doc.moveDown(1);
}
addItemsTable(doc, estimate) {
const tableTop = doc.y;
const tableLeft = 40;
const colWidths = [30, 200, 100, 50, 50, 85]; // №, Наименование, Обоснование, Цена, Объем, Стоимость
doc.fontSize(8).font('PTSansBold');
// Table header
let x = tableLeft;
const headers = ['№', 'Наименование работ', 'Обоснование', 'Цена', 'Объем', 'Стоимость'];
headers.forEach((header, i) => {
doc.text(header, x + 2, tableTop, { width: colWidths[i] - 4, align: 'center' });
x += colWidths[i];
});
doc.moveTo(tableLeft, tableTop - 5)
.lineTo(tableLeft + colWidths.reduce((a, b) => a + b, 0), tableTop - 5)
.stroke();
let y = tableTop + 15;
doc.moveTo(tableLeft, y)
.lineTo(tableLeft + colWidths.reduce((a, b) => a + b, 0), y)
.stroke();
y += 5;
doc.font('PTSans').fontSize(7);
// Group items by section
const sections = {
field: [],
office: [],
laboratory: [],
other: [],
};
estimate.items.forEach(item => {
const section = sections[item.sectionType] || sections.other;
section.push(item);
});
// Field works
if (sections.field.length > 0) {
doc.font('PTSansBold').fontSize(8);
doc.text('Полевые работы', tableLeft + 2, y);
y += 12;
doc.font('PTSans').fontSize(7);
sections.field.forEach(item => {
y = this.addItemRow(doc, item, y, tableLeft, colWidths);
});
}
// Office works
if (sections.office.length > 0) {
doc.font('PTSansBold').fontSize(8);
doc.text('Камеральные работы', tableLeft + 2, y);
y += 12;
doc.font('PTSans').fontSize(7);
sections.office.forEach(item => {
y = this.addItemRow(doc, item, y, tableLeft, colWidths);
});
}
// Laboratory works
if (sections.laboratory.length > 0) {
doc.font('PTSansBold').fontSize(8);
doc.text('Лабораторные работы', tableLeft + 2, y);
y += 12;
doc.font('PTSans').fontSize(7);
sections.laboratory.forEach(item => {
y = this.addItemRow(doc, item, y, tableLeft, colWidths);
});
}
// Other items
if (sections.other.length > 0) {
sections.other.forEach(item => {
y = this.addItemRow(doc, item, y, tableLeft, colWidths);
});
}
// Subtotal line
doc.moveTo(tableLeft, y)
.lineTo(tableLeft + colWidths.reduce((a, b) => a + b, 0), y)
.stroke();
doc.y = y + 10;
}
addItemRow(doc, item, y, tableLeft, colWidths) {
const rowHeight = 20;
let x = tableLeft;
// Check for page break
if (y > 750) {
doc.addPage();
y = 40;
}
// Row data
const cells = [
String(item.orderNumber),
item.workName.substring(0, 60),
(item.justification || '').substring(0, 30),
this.formatNumber(Number(item.basePrice)),
this.formatNumber(Number(item.quantity)),
this.formatNumber(Number(item.totalPrice)),
];
cells.forEach((cell, i) => {
doc.text(cell, x + 2, y, { width: colWidths[i] - 4, align: i > 2 ? 'right' : 'left' });
x += colWidths[i];
});
return y + rowHeight;
}
addTotals(doc, estimate) {
const leftX = 300;
const rightX = 480;
doc.moveDown(1);
doc.font('PTSans').fontSize(9);
estimate.totals.forEach(total => {
doc.text(total.label, leftX, doc.y, { continued: true });
if (total.description) {
doc.text(` (${total.description})`, { continued: true });
}
doc.text('');
doc.text(this.formatCurrency(Number(total.resultValue)), rightX, doc.y - 10, { align: 'right' });
doc.moveDown(0.3);
});
}
addFooter(doc, estimate) {
doc.moveDown(2);
doc.font('PTSans').fontSize(9);
doc.text('Выполнил: _____________________', 40);
}
formatNumber(num) {
return num.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
formatCurrency(num) {
return num.toLocaleString('ru-RU', {
style: 'currency',
currency: 'RUB',
minimumFractionDigits: 2,
});
}
}
exports.PdfService = PdfService;
//# sourceMappingURL=pdf.service.js.map

1
backend/dist/services/pdf.service.js.map vendored Executable file

File diff suppressed because one or more lines are too long