130 lines
5.1 KiB
Python
130 lines
5.1 KiB
Python
"""
|
||
Grouping service — match classified documents into contract groups.
|
||
|
||
Архитектурное решение (Opus, Q4 + Q6):
|
||
- Двухпроходный гибрид: LLM извлекает строки (classify.py), Python нормализует и группирует.
|
||
- Нормализация номеров: uppercase + только буквы/цифры.
|
||
"МЭС-123-2024" == "МЭС 123/2024" после нормализации.
|
||
- Группировка на бэкенде (не на фронте): Python regex/unicode надёжнее JS.
|
||
- apply_groups(): создаёт contracts + supplements с авто-порядком по дате.
|
||
"""
|
||
import re
|
||
from db import documents as db_docs
|
||
from db import contracts as db_contracts
|
||
from db import supplements as db_supplements
|
||
|
||
|
||
def normalize_number(num):
|
||
"""
|
||
Нормализация номера договора для сравнения.
|
||
Убирает всё кроме букв и цифр, приводит к uppercase.
|
||
Пример: "МЭС-123-2024" → "МЭС1232024", "МЭС 123/2024" → "МЭС1232024".
|
||
"""
|
||
if not num:
|
||
return ""
|
||
return re.sub(r"[^A-Z0-9А-Я]", "", num.upper())
|
||
|
||
|
||
def group_documents(batch_id):
|
||
"""
|
||
Сгруппировать классифицированные документы по контрактам.
|
||
|
||
Алгоритм:
|
||
1. Отделить contract от supplement/specification
|
||
2. Каждый contract → якорь группы
|
||
3. Для каждого supplement: найти contract по parent_number (нормализованный)
|
||
4. Несматченные → группа "__unresolved__"
|
||
5. Внутри группы сортировка по doc_date
|
||
|
||
Возвращает {ok, groups: [{contract_number, counterparty, documents: [...]}], total_docs}.
|
||
"""
|
||
docs = db_docs.list_by_batch(batch_id)
|
||
classified = [d for d in docs if d.get("classify_status") == "classified"]
|
||
|
||
# Separate contracts and supplements
|
||
contracts_list = []
|
||
supplements_list = []
|
||
|
||
for d in classified:
|
||
if d.get("doc_type") == "contract":
|
||
contracts_list.append(d)
|
||
else:
|
||
supplements_list.append(d)
|
||
|
||
groups = []
|
||
|
||
# Each contract becomes a group
|
||
for c in contracts_list:
|
||
group = {
|
||
"contract_number": c.get("own_number") or c.get("filename", ""),
|
||
"counterparty": c.get("counterparty") or "",
|
||
"documents": [c],
|
||
}
|
||
# Find supplements matching this contract
|
||
c_norm = normalize_number(c.get("own_number"))
|
||
for s in supplements_list:
|
||
parent = normalize_number(s.get("parent_number") or "")
|
||
own = normalize_number(s.get("own_number") or "")
|
||
if parent == c_norm or own == c_norm:
|
||
if s not in group["documents"]:
|
||
group["documents"].append(s)
|
||
|
||
# Sort by date
|
||
group["documents"].sort(key=lambda x: x.get("doc_date") or "")
|
||
|
||
# Remove matched supplements from the pool
|
||
for s in group["documents"]:
|
||
if s in supplements_list:
|
||
supplements_list.remove(s)
|
||
|
||
groups.append(group)
|
||
|
||
# Unmatched documents → "unresolved" group (including failed/pending)
|
||
unmatched = [s for s in supplements_list if s.get("doc_type") != "contract"]
|
||
unmatched += [d for d in docs if d.get("classify_status") != "classified"]
|
||
|
||
if unmatched or len(contracts_list) == 0:
|
||
# Put unmatched into a separate group
|
||
if unmatched:
|
||
groups.append({
|
||
"contract_number": "__unresolved__",
|
||
"counterparty": "",
|
||
"documents": unmatched,
|
||
})
|
||
|
||
return {"ok": True, "groups": groups, "total_docs": len(docs)}
|
||
|
||
|
||
def apply_groups(batch_id, groups_data):
|
||
"""
|
||
Применить подтверждённые группы: создать contracts + supplements.
|
||
|
||
Вызывается из POST /api/apply-groups.
|
||
Для каждой группы (кроме __unresolved__):
|
||
1. Создать запись в contracts (number, client)
|
||
2. Для каждого документа создать supplement (type='initial' для первого, 'additional' для остальных)
|
||
3. Порядок supplements соответствует порядку документов в группе (сортировка по дате уже сделана)
|
||
|
||
Возвращает {ok, created: количество созданных supplements}.
|
||
"""
|
||
created = 0
|
||
for g in groups_data:
|
||
contract_number = g.get("contract_number", "")
|
||
if contract_number == "__unresolved__":
|
||
continue
|
||
counterparty = g.get("counterparty", "")
|
||
docs = g.get("documents", [])
|
||
|
||
# Create contract
|
||
c = db_contracts.insert(contract_number, counterparty)
|
||
contract_id = c["id"]
|
||
|
||
# Create supplements in order
|
||
for i, d in enumerate(docs):
|
||
doc_id = d.get("id")
|
||
supp_type = "initial" if i == 0 else "additional"
|
||
db_supplements.insert(contract_id, doc_id, supp_type)
|
||
created += 1
|
||
|
||
return {"ok": True, "created": created}
|