Files
geo/backend/dist/services/parser.service.js

314 lines
14 KiB
JavaScript
Raw Permalink Normal View History

2026-02-04 00:11:19 +05:00
"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