314 lines
14 KiB
JavaScript
Executable File
314 lines
14 KiB
JavaScript
Executable File
"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
|