SSE streaming: прогресс по каждому файлу во время LLM (v1.0.53)

This commit is contained in:
2026-06-19 09:40:31 +04:00
parent f4a6ceb38b
commit c6108ead6d
2 changed files with 319 additions and 271 deletions
+64 -31
View File
@@ -61,7 +61,7 @@
<body> <body>
<div class="topbar"> <div class="topbar">
<img src="/nubes-logo.svg" alt="Nubes"> <img src="/nubes-logo.svg" alt="Nubes">
<span class="title">Сверка договоров — LLM <span style="font-weight:400;color:var(--muted);font-size:12px;">v1.0.52 — Lucee</span></span> <span class="title">Сверка договоров — LLM <span style="font-weight:400;color:var(--muted);font-size:12px;">v1.0.53 — Lucee</span></span>
</div> </div>
<div class="content"> <div class="content">
@@ -332,7 +332,7 @@ document.getElementById('llmBtn').addEventListener('click', async function() {
var diffBody = document.getElementById('diffBody'); var diffBody = document.getElementById('diffBody');
var diffStatus = document.getElementById('diffStatus'); var diffStatus = document.getElementById('diffStatus');
diffCard.style.display = 'block'; diffCard.style.display = 'block';
diffBody.innerHTML = '⏳ LLM-извлечение...'; diffBody.innerHTML = '';
diffStatus.textContent = '⏳ 0.0с'; diffStatus.textContent = '⏳ 0.0с';
var llmStart = Date.now(); var llmStart = Date.now();
@@ -340,28 +340,37 @@ document.getElementById('llmBtn').addEventListener('click', async function() {
diffStatus.textContent = '⏳ ' + ((Date.now() - llmStart) / 1000).toFixed(1) + 'с'; diffStatus.textContent = '⏳ ' + ((Date.now() - llmStart) / 1000).toFixed(1) + 'с';
}, 200); }, 200);
// ОДИН запрос — всё внутри: экстракция + сравнение // SSE — прогресс по каждому файлу
try { var es = new EventSource('/process.cfm?contract_id=' + contractId);
var resp = await fetch('/process.cfm?contract_id=' + contractId);
var data = await resp.json();
if (data.OK) { es.onmessage = function(e) {
// Показать результаты экстракции var d = JSON.parse(e.data);
var extr = data.EXTRACTED || [];
diffBody.innerHTML = ''; if (d.type === 'extract_start') {
extr.forEach(function(e) { diffBody.innerHTML += '<div style="font-size:12px;color:var(--muted);margin-top:2px;" id="ext-' + d.supplement_id + '">⏳ ' + d.filename + ' → LLM... <span class="file-timer" data-start="' + Date.now() + '">0.0с</span></div>';
if (e.ERROR) { }
diffBody.innerHTML += '<div style="font-size:12px;color:var(--destructive);">✗ ' + (e.FILENAME||e.SUPPLEMENT_ID) + ': ' + e.ERROR + '</div>'; else if (d.type === 'extract_done') {
} else if (e.SKIPPED) { var el = document.getElementById('ext-' + d.supplement_id);
diffBody.innerHTML += '<div style="font-size:12px;color:var(--muted);">' + (e.FILENAME||e.SUPPLEMENT_ID) + ' (уже извлечено, ' + e.ROWS_SAVED + ' строк)</div>'; if (el) el.innerHTML = '✓ <span style="color:var(--green);">' + d.filename + ': ' + d.count + ' строк (' + d.time_s + 'с)</span>';
} else { }
diffBody.innerHTML += '<div style="font-size:12px;color:var(--green);">✓ ' + (e.FILENAME||e.SUPPLEMENT_ID) + ': ' + e.ROWS_SAVED + ' строк</div>'; else if (d.type === 'extract_skip') {
} diffBody.innerHTML += '<div style="font-size:12px;color:var(--muted);margin-top:2px;">⏭ ' + d.filename + ' (уже извлечено, ' + d.count + ' строк)</div>';
}); }
else if (d.type === 'extract_error') {
var el = document.getElementById('ext-' + d.supplement_id);
if (el) el.innerHTML = '✗ <span style="color:var(--destructive);">' + d.filename + ': ' + d.error + '</span>';
else diffBody.innerHTML += '<div style="font-size:12px;color:var(--destructive);margin-top:2px;">✗ ' + d.filename + ': ' + d.error + '</div>';
}
else if (d.type === 'done') {
clearInterval(timerInterval);
es.close();
diffStatus.textContent = '✓ ' + d.total_time_s + 'с';
btn.innerHTML = '<i data-lucide="check" style="width:16px;height:16px;"></i> ✓ Готово';
document.getElementById('chatCard').style.display = 'block';
// Сравнение // Сравнение
diffBody.innerHTML += '<div style="margin-top:8px;font-weight:600;">📊 Сравнение:</div>'; diffBody.innerHTML += '<div style="margin-top:8px;font-weight:600;">📊 Сравнение:</div>';
var all = data.ALL_CHANGES || []; var all = d.all_changes || [];
if (all.length === 0) { if (all.length === 0) {
diffBody.innerHTML += '<div style="color:var(--muted);font-size:12px;">Нет допников для сравнения.</div>'; diffBody.innerHTML += '<div style="color:var(--muted);font-size:12px;">Нет допников для сравнения.</div>';
} else { } else {
@@ -369,7 +378,6 @@ document.getElementById('llmBtn').addEventListener('click', async function() {
var s = ch.SUMMARY || {}; var s = ch.SUMMARY || {};
diffBody.innerHTML += '<div style="font-weight:600;margin-top:12px;">📄 Допник (' + ch.TYPE + ')</div>'; diffBody.innerHTML += '<div style="font-weight:600;margin-top:12px;">📄 Допник (' + ch.TYPE + ')</div>';
diffBody.innerHTML += '<div style="font-size:12px;margin-left:8px;"><span style="color:var(--green);">+' + (s.ADDED||0) + '</span> <span style="color:#eab308;">~' + (s.CHANGED||0) + '</span> <span style="color:var(--destructive);">-' + (s.DELETED||0) + '</span></div>'; diffBody.innerHTML += '<div style="font-size:12px;margin-left:8px;"><span style="color:var(--green);">+' + (s.ADDED||0) + '</span> <span style="color:#eab308;">~' + (s.CHANGED||0) + '</span> <span style="color:var(--destructive);">-' + (s.DELETED||0) + '</span></div>';
diffStatus.textContent = 'изм:' + (s.CHANGED||0) + ' +' + (s.ADDED||0) + ' -' + (s.DELETED||0);
var changes = ch.CHANGES || []; var changes = ch.CHANGES || [];
if (changes.length === 0) { if (changes.length === 0) {
@@ -386,19 +394,44 @@ document.getElementById('llmBtn').addEventListener('click', async function() {
} }
}); });
} }
} else { lucide.createIcons();
diffBody.innerHTML = '<div style="color:var(--destructive);">✗ ' + (data.ERROR||'Неизвестная ошибка') + '</div>';
} }
} catch(e) { else if (d.type === 'error') {
diffBody.innerHTML = '<div style="color:var(--destructive);">✗ ' + e.message + '</div>'; clearInterval(timerInterval);
} es.close();
diffBody.innerHTML += '<div style="color:var(--destructive);margin-top:8px;">✗ ' + d.message + '</div>';
btn.innerHTML = '<i data-lucide="scale" style="width:16px;height:16px;"></i> Сравнить (LLM)';
btn.disabled = false;
}
};
clearInterval(timerInterval); es.onerror = function() {
diffStatus.textContent = '✓ ' + ((Date.now() - llmStart) / 1000).toFixed(1) + 'с'; clearInterval(timerInterval);
es.close();
if (diffBody.innerHTML === '') {
diffBody.innerHTML = '<div style="color:var(--destructive);">✗ Ошибка соединения</div>';
}
btn.innerHTML = '<i data-lucide="scale" style="width:16px;height:16px;"></i> Сравнить (LLM)';
btn.disabled = false;
lucide.createIcons();
};
btn.innerHTML = '<i data-lucide="check" style="width:16px;height:16px;"></i> ✓ Готово'; // Таймер для файловых строк
document.getElementById('chatCard').style.display = 'block'; var fileTimerInterval = setInterval(function() {
lucide.createIcons(); var now = Date.now();
document.querySelectorAll('.file-timer').forEach(function(el) {
var start = parseInt(el.dataset.start);
if (start) el.textContent = ((now - start) / 1000).toFixed(1) + 'с';
});
}, 200);
// Сохранить интервалы для очистки
es._fileTimer = fileTimerInterval;
var origClose = es.close;
es.close = function() {
clearInterval(fileTimerInterval);
origClose.call(es);
};
}); });
function _dc(c, field) { function _dc(c, field) {
+255 -240
View File
@@ -1,40 +1,50 @@
<cfsetting showdebugoutput="no" enablecfoutputonly="true" requesttimeout="600"> <cfsetting showdebugoutput="no" enablecfoutputonly="true" requesttimeout="600">
<cfheader name="Content-Type" value="application/json; charset=utf-8"> <cfheader name="Content-Type" value="text/event-stream; charset=utf-8">
<cfheader name="Cache-Control" value="no-cache">
<cfheader name="Connection" value="keep-alive">
<cfheader name="X-Accel-Buffering" value="no">
<cfparam name="url.contract_id" default=""> <cfparam name="url.contract_id" default="">
<cfset result = {OK: false, ERROR: ""}>
<cfoutput>: ok
</cfoutput><cfflush>
<cftry> <cftry>
<cfif NOT len(url.contract_id)> <cfif NOT len(url.contract_id)>
<cfset result.ERROR = "contract_id required"> <cfoutput>data: #serializeJSON({type:"error",message:"contract_id required"})#
<cfelse>
<!--- Все допники договора с parsed_text --->
<cfquery name="supps" datasource="baza">
SELECT s.id as supp_id, s.type, d.id as doc_id, d.filename, d.elements_json, d.parsed_text
FROM supplements s
JOIN documents d ON s.document_id = d.id
WHERE s.contract_id = <cfqueryparam value="#url.contract_id#" cfsqltype="cf_sql_varchar">
AND d.elements_json IS NOT NULL
ORDER BY s.created_at
</cfquery>
<cfif supps.recordCount EQ 0> </cfoutput><cfflush>
<cfset result.ERROR = "Нет распарсенных файлов. Сначала нажмите Парсинг."> <cfabort>
<cfelse> </cfif>
<cfset extractedInfo = []>
<cfset extractedRows = {}> <!--- supp_id → [rows] --->
<!--- Промпт из БД ---> <cfquery name="supps" datasource="baza">
<cfset promptText = ""> SELECT s.id as supp_id, s.type, d.id as doc_id, d.filename, d.elements_json
<cfquery name="pq" datasource="baza"> FROM supplements s
SELECT prompt_text FROM prompts WHERE contract_id = <cfqueryparam value="#url.contract_id#" cfsqltype="cf_sql_varchar"> JOIN documents d ON s.document_id = d.id
</cfquery> WHERE s.contract_id = <cfqueryparam value="#url.contract_id#" cfsqltype="cf_sql_varchar">
<cfif pq.recordCount GT 0 AND len(pq.prompt_text)> AND d.elements_json IS NOT NULL
<cfset promptText = pq.prompt_text> ORDER BY s.created_at
</cfif> </cfquery>
<!--- DEFAULT_PROMPT ---> <cfif supps.recordCount EQ 0>
<cfset defaultPrompt = 'Ты — анализатор договоров. Ниже текст спецификации услуг из договора облачного провайдера. <cfoutput>data: #serializeJSON({type:"error",message:"Нет распарсенных файлов. Сначала нажмите Парсинг."})#
</cfoutput><cfflush>
<cfabort>
</cfif>
<cfset extractedRows = {}>
<cfset promptText = "">
<cfquery name="pq" datasource="baza">
SELECT prompt_text FROM prompts WHERE contract_id = <cfqueryparam value="#url.contract_id#" cfsqltype="cf_sql_varchar">
</cfquery>
<cfif pq.recordCount GT 0 AND len(pq.prompt_text)>
<cfset promptText = pq.prompt_text>
</cfif>
<cfset defaultPrompt = 'Ты — анализатор договоров. Ниже текст спецификации услуг из договора облачного провайдера.
Найди ВСЕ таблицы со списком услуг. Извлеки каждую строку в JSON-массив. Найди ВСЕ таблицы со списком услуг. Извлеки каждую строку в JSON-массив.
@@ -60,230 +70,235 @@
{parsed_text} {parsed_text}
---'> ---'>
<!--- LLM API ---> <cfset apiKey = "sk-ucI5YvOticoOQ9Kuj5K9mQ">
<cfset apiKey = "sk-ucI5YvOticoOQ9Kuj5K9mQ"> <cfset llmStart = getTickCount()>
<!--- Шаг 1: LLM-извлечение для каждого допника ---> <cfloop query="supps">
<cfloop query="supps"> <cfset sid = supps.supp_id>
<cfset sid = supps.supp_id>
<!--- Проверить: уже извлечено? ---> <cfquery name="ex" datasource="baza">
<cfquery name="ex" datasource="baza"> SELECT row_num, name, price, qty, sum, date_start
SELECT row_num, name, price, qty, sum, date_start FROM spec_rows WHERE supplement_id = <cfqueryparam value="#sid#" cfsqltype="cf_sql_varchar">
FROM spec_rows WHERE supplement_id = <cfqueryparam value="#sid#" cfsqltype="cf_sql_varchar"> ORDER BY row_num
ORDER BY row_num </cfquery>
</cfquery>
<cfif ex.recordCount GT 0> <cfif ex.recordCount GT 0>
<!--- Уже извлечено — загрузить существующие ---> <cfset rows = []>
<cfset rows = []> <cfloop query="ex">
<cfloop query="ex"> <cfset arrayAppend(rows, {ROW_NUM: ex.row_num, NAME: ex.name, PRICE: ex.price, QTY: ex.qty, SUM: ex.sum, DATE_START: ex.date_start})>
<cfset arrayAppend(rows, {ROW_NUM: ex.row_num, NAME: ex.name, PRICE: ex.price, QTY: ex.qty, SUM: ex.sum, DATE_START: ex.date_start})> </cfloop>
</cfloop> <cfset extractedRows[sid] = rows>
<cfset extractedRows[sid] = rows> <cfoutput>data: #serializeJSON({type:"extract_skip",supplement_id:sid,filename:supps.filename,count:ex.recordCount})#
<cfset arrayAppend(extractedInfo, {SUPPLEMENT_ID: sid, FILENAME: supps.filename, ROWS_SAVED: ex.recordCount, SKIPPED: true})>
<cfcontinue>
</cfif>
<!--- textify: elements → текст ---> </cfoutput><cfflush>
<cfset elements = deserializeJSON(supps.elements_json)> <cfcontinue>
<cfset textLines = []> </cfif>
<cfloop array="#elements#" index="el">
<cfif el.type EQ "paragraph">
<cfset prefix = len(el.style) ? "[#el.style#] " : "">
<cfset arrayAppend(textLines, prefix & el.text)>
<cfelseif el.type EQ "table">
<cfset rows = el.rows>
<cfif arrayLen(rows) GT 0>
<cfset ncols = arrayLen(rows[1])>
<cfset arrayAppend(textLines, "--- Таблица (" & arrayLen(rows) & "×" & ncols & ") ---")>
<cfloop array="#rows#" index="row">
<cfset cells = []>
<cfloop from="1" to="#ncols#" index="ci">
<cfset val = "">
<cfif ci LTE arrayLen(row)>
<cfset val = replace(replace(toString(row[ci]), chr(10), " ", "ALL"), "|", "\|", "ALL")>
</cfif>
<cfset arrayAppend(cells, val)>
</cfloop>
<cfset arrayAppend(textLines, "| " & arrayToList(cells, " | ") & " |")>
</cfloop>
<cfset arrayAppend(textLines, "")>
</cfif>
</cfif>
</cfloop>
<cfset parsedText = arrayToList(textLines, chr(10))>
<!--- Промпт ---> <cfset elements = deserializeJSON(supps.elements_json)>
<cfset prompt = replace(len(promptText) ? promptText : defaultPrompt, "{parsed_text}", parsedText, "ALL")> <cfset textLines = []>
<cfloop array="#elements#" index="el">
<!--- LLM ---> <cfif el.type EQ "paragraph">
<cfset llmPayload = { <cfset prefix = len(el.style) ? "[#el.style#] " : "">
"model": "gpt-oss-120b", <cfset arrayAppend(textLines, prefix & el.text)>
"messages": [{"role": "user", "content": prompt}], <cfelseif el.type EQ "table">
"max_tokens": 8000, <cfset rows = el.rows>
"temperature": 0.1 <cfif arrayLen(rows) GT 0>
}> <cfset ncols = arrayLen(rows[1])>
<cfset arrayAppend(textLines, "--- Таблица (" & arrayLen(rows) & "×" & ncols & ") ---")>
<cfhttp url="https://api.aillm.ru/v1/chat/completions" method="POST" timeout="120"> <cfloop array="#rows#" index="row">
<cfhttpparam type="header" name="Authorization" value="Bearer #apiKey#"> <cfset cells = []>
<cfhttpparam type="header" name="Content-Type" value="application/json"> <cfloop from="1" to="#ncols#" index="ci">
<cfhttpparam type="body" value="#serializeJSON(llmPayload)#"> <cfset val = "">
</cfhttp> <cfif ci LTE arrayLen(row)>
<cfset val = replace(replace(toString(row[ci]), chr(10), " ", "ALL"), "|", "\|", "ALL")>
<cfif NOT (cfhttp.statusCode EQ 200 OR left(cfhttp.statusCode, 3) EQ "200")> </cfif>
<cfset arrayAppend(extractedInfo, {SUPPLEMENT_ID: sid, FILENAME: supps.filename, ERROR: "LLM HTTP #cfhttp.statusCode#"})> <cfset arrayAppend(cells, val)>
<cfcontinue>
</cfif>
<cfset llmResp = deserializeJSON(cfhttp.fileContent)>
<cfset llmText = llmResp.choices[1].message.content>
<!--- Вытащить JSON из ответа --->
<cfset jsonText = llmText>
<cfset p1 = find("```json", jsonText)>
<cfif p1 GT 0>
<cfset jsonText = mid(jsonText, p1 + 7, len(jsonText))>
<cfset p2 = find("```", jsonText)>
<cfif p2 GT 0>
<cfset jsonText = left(jsonText, p2 - 1)>
</cfif>
<cfelse>
<cfset p1 = find("{", jsonText)>
<cfset p2 = find("}", reverse(jsonText))>
<cfif p1 GT 0 AND p2 GT 0>
<cfset jsonText = mid(jsonText, p1, len(jsonText) - p2 - p1 + 2)>
</cfif>
</cfif>
<cfset jsonText = trim(jsonText)>
<cftry>
<cfset extracted = deserializeJSON(jsonText)>
<cfset rows = []>
<cfif structKeyExists(extracted, "rows") AND isArray(extracted.rows)>
<cfset rows = extracted.rows>
</cfif>
<!--- Сохранить spec_rows (DELETE + INSERT) --->
<cftransaction>
<cfquery datasource="baza">
DELETE FROM spec_rows WHERE supplement_id = <cfqueryparam value="#sid#" cfsqltype="cf_sql_varchar">
</cfquery>
<cfloop array="#rows#" index="row">
<cfset rn = structKeyExists(row, 'row_num') ? row.row_num : 0>
<cfset nm = structKeyExists(row, 'name') ? row.name : ''>
<cfset pr = (structKeyExists(row, 'price') AND len(row.price)) ? row.price : ''>
<cfset qt = (structKeyExists(row, 'qty') AND len(row.qty)) ? row.qty : ''>
<cfset sm = (structKeyExists(row, 'sum') AND len(row.sum)) ? row.sum : ''>
<cfset ds = (structKeyExists(row, 'date_start') AND len(row.date_start)) ? row.date_start : ''>
<cfquery datasource="baza">
INSERT INTO spec_rows (supplement_id, row_num, name, price, qty, sum, date_start)
VALUES (
<cfqueryparam value="#sid#" cfsqltype="cf_sql_varchar">,
<cfqueryparam value="#rn#" cfsqltype="cf_sql_integer">,
<cfqueryparam value="#nm#" cfsqltype="cf_sql_varchar">,
<cfqueryparam value="#pr#" cfsqltype="cf_sql_float" null="#len(pr) EQ 0#">,
<cfqueryparam value="#qt#" cfsqltype="cf_sql_float" null="#len(qt) EQ 0#">,
<cfqueryparam value="#sm#" cfsqltype="cf_sql_float" null="#len(sm) EQ 0#">,
<cfqueryparam value="#ds#" cfsqltype="cf_sql_varchar" null="#len(ds) EQ 0#">
)
</cfquery>
</cfloop> </cfloop>
</cftransaction> <cfset arrayAppend(textLines, "| " & arrayToList(cells, " | ") & " |")>
<cfset savedRows = []>
<cfloop array="#rows#" index="r">
<cfset arrayAppend(savedRows, {ROW_NUM: structKeyExists(r,'row_num')?r.row_num:0, NAME: structKeyExists(r,'name')?r.name:'', PRICE: structKeyExists(r,'price')?r.price:'', QTY: structKeyExists(r,'qty')?r.qty:'', SUM: structKeyExists(r,'sum')?r.sum:'', DATE_START: structKeyExists(r,'date_start')?r.date_start:''})>
</cfloop> </cfloop>
<cfset extractedRows[sid] = savedRows> <cfset arrayAppend(textLines, "")>
<cfset arrayAppend(extractedInfo, {SUPPLEMENT_ID: sid, FILENAME: supps.filename, ROWS_SAVED: arrayLen(savedRows), SKIPPED: false})> </cfif>
</cfif>
</cfloop>
<cfset parsedText = arrayToList(textLines, chr(10))>
<cfcatch> <cfset prompt = replace(len(promptText) ? promptText : defaultPrompt, "{parsed_text}", parsedText, "ALL")>
<cfset arrayAppend(extractedInfo, {SUPPLEMENT_ID: sid, FILENAME: supps.filename, ERROR: "JSON parse: " & cfcatch.message})>
</cfcatch> <cfoutput>data: #serializeJSON({type:"extract_start",supplement_id:sid,filename:supps.filename})#
</cftry>
</cfoutput><cfflush>
<cfset t0 = getTickCount()>
<cfset llmPayload = {
"model": "gpt-oss-120b",
"messages": [{"role": "user", "content": prompt}],
"max_tokens": 8000,
"temperature": 0.1
}>
<cfhttp url="https://api.aillm.ru/v1/chat/completions" method="POST" timeout="120">
<cfhttpparam type="header" name="Authorization" value="Bearer #apiKey#">
<cfhttpparam type="header" name="Content-Type" value="application/json">
<cfhttpparam type="body" value="#serializeJSON(llmPayload)#">
</cfhttp>
<cfset elapsed = int((getTickCount() - t0) / 1000 * 10 + 0.5) / 10>
<cfif NOT (cfhttp.statusCode EQ 200 OR left(cfhttp.statusCode, 3) EQ "200")>
<cfoutput>data: #serializeJSON({type:"extract_error",supplement_id:sid,filename:supps.filename,error:"LLM HTTP #cfhttp.statusCode#",time_s:elapsed})#
</cfoutput><cfflush>
<cfcontinue>
</cfif>
<cfset llmResp = deserializeJSON(cfhttp.fileContent)>
<cfset llmText = llmResp.choices[1].message.content>
<cfset jsonText = llmText>
<cfset p1 = find("```json", jsonText)>
<cfif p1 GT 0>
<cfset jsonText = mid(jsonText, p1 + 7, len(jsonText))>
<cfset p2 = find("```", jsonText)>
<cfif p2 GT 0><cfset jsonText = left(jsonText, p2 - 1)></cfif>
<cfelse>
<cfset p1 = find("{", jsonText)>
<cfset p2 = find("}", reverse(jsonText))>
<cfif p1 GT 0 AND p2 GT 0>
<cfset jsonText = mid(jsonText, p1, len(jsonText) - p2 - p1 + 2)>
</cfif>
</cfif>
<cfset jsonText = trim(jsonText)>
<cftry>
<cfset extracted = deserializeJSON(jsonText)>
<cfset rows = []>
<cfif structKeyExists(extracted, "rows") AND isArray(extracted.rows)>
<cfset rows = extracted.rows>
</cfif>
<cftransaction>
<cfquery datasource="baza">
DELETE FROM spec_rows WHERE supplement_id = <cfqueryparam value="#sid#" cfsqltype="cf_sql_varchar">
</cfquery>
<cfloop array="#rows#" index="row">
<cfset rn = structKeyExists(row, 'row_num') ? row.row_num : 0>
<cfset nm = structKeyExists(row, 'name') ? row.name : ''>
<cfset pr = (structKeyExists(row, 'price') AND len(row.price)) ? row.price : ''>
<cfset qt = (structKeyExists(row, 'qty') AND len(row.qty)) ? row.qty : ''>
<cfset sm = (structKeyExists(row, 'sum') AND len(row.sum)) ? row.sum : ''>
<cfset ds = (structKeyExists(row, 'date_start') AND len(row.date_start)) ? row.date_start : ''>
<cfquery datasource="baza">
INSERT INTO spec_rows (supplement_id, row_num, name, price, qty, sum, date_start)
VALUES (
<cfqueryparam value="#sid#" cfsqltype="cf_sql_varchar">,
<cfqueryparam value="#rn#" cfsqltype="cf_sql_integer">,
<cfqueryparam value="#nm#" cfsqltype="cf_sql_varchar">,
<cfqueryparam value="#pr#" cfsqltype="cf_sql_float" null="#len(pr) EQ 0#">,
<cfqueryparam value="#qt#" cfsqltype="cf_sql_float" null="#len(qt) EQ 0#">,
<cfqueryparam value="#sm#" cfsqltype="cf_sql_float" null="#len(sm) EQ 0#">,
<cfqueryparam value="#ds#" cfsqltype="cf_sql_varchar" null="#len(ds) EQ 0#">
)
</cfquery>
</cfloop>
</cftransaction>
<cfset savedRows = []>
<cfloop array="#rows#" index="r">
<cfset arrayAppend(savedRows, {ROW_NUM: structKeyExists(r,'row_num')?r.row_num:0, NAME: structKeyExists(r,'name')?r.name:'', PRICE: structKeyExists(r,'price')?r.price:'', QTY: structKeyExists(r,'qty')?r.qty:'', SUM: structKeyExists(r,'sum')?r.sum:'', DATE_START: structKeyExists(r,'date_start')?r.date_start:''})>
</cfloop>
<cfset extractedRows[sid] = savedRows>
<cfoutput>data: #serializeJSON({type:"extract_done",supplement_id:sid,filename:supps.filename,count:arrayLen(savedRows),time_s:elapsed})#
</cfoutput><cfflush>
<cfcatch>
<cfoutput>data: #serializeJSON({type:"extract_error",supplement_id:sid,filename:supps.filename,error:"JSON parse: " & cfcatch.message,time_s:elapsed})#
</cfoutput><cfflush>
</cfcatch>
</cftry>
</cfloop>
<!--- Differ --->
<cfset allChanges = []>
<cfquery name="initSup" datasource="baza">
SELECT id FROM supplements WHERE contract_id=<cfqueryparam value="#url.contract_id#" cfsqltype="cf_sql_varchar"> AND type='initial' ORDER BY created_at LIMIT 1
</cfquery>
<cfif initSup.recordCount EQ 0>
<cfquery name="initSup" datasource="baza">
SELECT id FROM supplements WHERE contract_id=<cfqueryparam value="#url.contract_id#" cfsqltype="cf_sql_varchar"> ORDER BY created_at LIMIT 1
</cfquery>
</cfif>
<cfif initSup.recordCount GT 0 AND structKeyExists(extractedRows, initSup.id)>
<cfset initByNum = {}>
<cfloop array="#extractedRows[initSup.id]#" index="r">
<cfset initByNum[r.ROW_NUM] = r>
</cfloop>
<cfquery name="amendments" datasource="baza">
SELECT id, type FROM supplements WHERE contract_id=<cfqueryparam value="#url.contract_id#" cfsqltype="cf_sql_varchar"> AND id!=<cfqueryparam value="#initSup.id#" cfsqltype="cf_sql_varchar"> ORDER BY created_at
</cfquery>
<cfloop query="amendments">
<cfif NOT structKeyExists(extractedRows, amendments.id)>
<cfcontinue>
</cfif>
<cfset amdRows = extractedRows[amendments.id]>
<cfset amdByNum = {}>
<cfloop array="#amdRows#" index="r">
<cfset amdByNum[r.ROW_NUM] = r>
</cfloop> </cfloop>
<!--- Шаг 2: Differ (логика из differ.cfm, но на свежих данных) ---> <cfset allNums = structKeyArray(initByNum)>
<cfset allChanges = []> <cfloop array="#structKeyArray(amdByNum)#" index="n">
<cfif NOT structKeyExists(initByNum, n)><cfset arrayAppend(allNums, n)></cfif>
</cfloop>
<cfset arraySort(allNums, "numeric")>
<!--- Найти initial ---> <cfset changes = []>
<cfquery name="initSup" datasource="baza"> <cfset fields = ["NAME","PRICE","QTY","SUM","DATE_START"]>
SELECT id FROM supplements WHERE contract_id=<cfqueryparam value="#url.contract_id#" cfsqltype="cf_sql_varchar"> AND type='initial' ORDER BY created_at LIMIT 1 <cfloop array="#allNums#" index="num">
</cfquery> <cfset inOld = structKeyExists(initByNum, num)>
<cfif initSup.recordCount EQ 0> <cfset inNew = structKeyExists(amdByNum, num)>
<cfquery name="initSup" datasource="baza"> <cfif inOld AND NOT inNew>
SELECT id FROM supplements WHERE contract_id=<cfqueryparam value="#url.contract_id#" cfsqltype="cf_sql_varchar"> ORDER BY created_at LIMIT 1 <cfset arrayAppend(changes, {ROW_NUM:num, CHANGE_TYPE:"deleted", OLD_VALUES:initByNum[num], NEW_VALUES:{}})>
</cfquery> <cfelseif NOT inOld AND inNew>
</cfif> <cfset arrayAppend(changes, {ROW_NUM:num, CHANGE_TYPE:"added", OLD_VALUES:{}, NEW_VALUES:amdByNum[num]})>
<cfelse>
<cfif initSup.recordCount GT 0 AND structKeyExists(extractedRows, initSup.id)> <cfset ov = initByNum[num]>
<cfset initByNum = {}> <cfset nv = amdByNum[num]>
<cfloop array="#extractedRows[initSup.id]#" index="r"> <cfset same = true>
<cfset initByNum[r.ROW_NUM] = r> <cfset cf = {}>
</cfloop> <cfloop array="#fields#" index="f">
<cfif structKeyExists(ov,f) AND structKeyExists(nv,f)>
<cfquery name="amendments" datasource="baza"> <cfif toString(ov[f]) NEQ toString(nv[f])>
SELECT id, type FROM supplements WHERE contract_id=<cfqueryparam value="#url.contract_id#" cfsqltype="cf_sql_varchar"> AND id!=<cfqueryparam value="#initSup.id#" cfsqltype="cf_sql_varchar"> ORDER BY created_at <cfset same = false><cfset cf[f] = {OLD:ov[f], NEW:nv[f]}>
</cfquery> </cfif>
<cfelseif structKeyExists(ov,f) OR structKeyExists(nv,f)>
<cfloop query="amendments"> <cfset same = false><cfset cf[f] = {OLD: structKeyExists(ov,f)?ov[f]:"", NEW: structKeyExists(nv,f)?nv[f]:""}>
<cfif NOT structKeyExists(extractedRows, amendments.id)>
<cfcontinue>
</cfif>
<cfset amdRows = extractedRows[amendments.id]>
<cfset amdByNum = {}>
<cfloop array="#amdRows#" index="r">
<cfset amdByNum[r.ROW_NUM] = r>
</cfloop>
<cfset allNums = structKeyArray(initByNum)>
<cfloop array="#structKeyArray(amdByNum)#" index="n">
<cfif NOT structKeyExists(initByNum, n)><cfset arrayAppend(allNums, n)></cfif>
</cfloop>
<cfset arraySort(allNums, "numeric")>
<cfset changes = []>
<cfset fields = ["NAME","PRICE","QTY","SUM","DATE_START"]>
<cfloop array="#allNums#" index="num">
<cfset inOld = structKeyExists(initByNum, num)>
<cfset inNew = structKeyExists(amdByNum, num)>
<cfif inOld AND NOT inNew>
<cfset arrayAppend(changes, {ROW_NUM:num, CHANGE_TYPE:"deleted", OLD_VALUES:initByNum[num], NEW_VALUES:{}})>
<cfelseif NOT inOld AND inNew>
<cfset arrayAppend(changes, {ROW_NUM:num, CHANGE_TYPE:"added", OLD_VALUES:{}, NEW_VALUES:amdByNum[num]})>
<cfelse>
<cfset ov = initByNum[num]>
<cfset nv = amdByNum[num]>
<cfset same = true>
<cfset cf = {}>
<cfloop array="#fields#" index="f">
<cfif structKeyExists(ov,f) AND structKeyExists(nv,f)>
<cfif toString(ov[f]) NEQ toString(nv[f])>
<cfset same = false><cfset cf[f] = {OLD:ov[f], NEW:nv[f]}>
</cfif>
<cfelseif structKeyExists(ov,f) OR structKeyExists(nv,f)>
<cfset same = false><cfset cf[f] = {OLD: structKeyExists(ov,f)?ov[f]:"", NEW: structKeyExists(nv,f)?nv[f]:""}>
</cfif>
</cfloop>
<cfset arrayAppend(changes, {ROW_NUM:num, CHANGE_TYPE:same?"unchanged":"changed", OLD_VALUES:ov, NEW_VALUES:nv, CHANGED_FIELDS:cf})>
</cfif> </cfif>
</cfloop> </cfloop>
<cfset sm = {ADDED:0, DELETED:0, CHANGED:0, UNCHANGED:0}> <cfset arrayAppend(changes, {ROW_NUM:num, CHANGE_TYPE:same?"unchanged":"changed", OLD_VALUES:ov, NEW_VALUES:nv, CHANGED_FIELDS:cf})>
<cfloop array="#changes#" index="ch"><cfset sm[ch.CHANGE_TYPE] = sm[ch.CHANGE_TYPE] + 1></cfloop> </cfif>
<cfset arrayAppend(allChanges, {SUPPLEMENT_ID:amendments.id, TYPE:amendments.type, CHANGES:changes, SUMMARY:sm})> </cfloop>
</cfloop> <cfset sm = {ADDED:0, DELETED:0, CHANGED:0, UNCHANGED:0}>
</cfif> <cfloop array="#changes#" index="ch"><cfset sm[ch.CHANGE_TYPE] = sm[ch.CHANGE_TYPE] + 1></cfloop>
<cfset arrayAppend(allChanges, {SUPPLEMENT_ID:amendments.id, TYPE:amendments.type, CHANGES:changes, SUMMARY:sm})>
<cfset result = { </cfloop>
OK: true,
CONTRACT_ID: url.contract_id,
EXTRACTED: extractedInfo,
ALL_CHANGES: allChanges
}>
</cfif>
</cfif> </cfif>
<cfcatch><cfset result = {OK: false, ERROR: cfcatch.message, DETAIL: cfcatch.detail}></cfcatch>
<cfset totalTime = int((getTickCount() - llmStart) / 1000 * 10 + 0.5) / 10>
<cfoutput>data: #serializeJSON({type:"done",total_time_s:totalTime,all_changes:allChanges})#
</cfoutput><cfflush>
<cfcatch>
<cfoutput>data: #serializeJSON({type:"error",message:cfcatch.message,detail:cfcatch.detail})#
</cfoutput><cfflush>
</cfcatch>
</cftry> </cftry>
<cfoutput>#serializeJSON(result)#</cfoutput>