fix: site/ → src/ (stdlib conflict — site is built-in Python module)
Deploy contracts-flask / validate (push) Successful in 0s

This commit is contained in:
2026-07-15 11:16:42 +04:00
parent a7b53fde19
commit 81aba97304
42 changed files with 53 additions and 53 deletions
-160
View File
@@ -1,160 +0,0 @@
"""
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 site.db import documents as db_docs
from site.db import contracts as db_contracts
from site.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. Оставшиеся supplement/spec → виртуальные группы по parent_number/own_number
5. Совсем без номеров → группа "__unresolved__"
6. Внутри группы сортировка по 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)
# ── Virtual groups: unmatched supplements grouped by number ──────────
# Group remaining supplements/specs by normalized parent_number (priority) or own_number
virtual = {}
for s in supplements_list:
num = normalize_number(s.get("parent_number") or s.get("own_number") or "")
if not num:
continue # no number → stays in supplements_list for unresolved
if num not in virtual:
# Use counterparty from first doc in group
cp = s.get("counterparty") or ""
virtual[num] = {
"contract_number": s.get("parent_number") or s.get("own_number") or "?",
"counterparty": cp,
"documents": [],
}
virtual[num]["documents"].append(s)
# Update counterparty if current doc has a better one
if not virtual[num]["counterparty"] and s.get("counterparty"):
virtual[num]["counterparty"] = s.get("counterparty")
for vnum, vgroup in virtual.items():
vgroup["documents"].sort(key=lambda x: x.get("doc_date") or "")
groups.append(vgroup)
# Remove grouped docs from supplements_list
for s in vgroup["documents"]:
supplements_list.remove(s)
# ── Unresolved: everything left (no number, failed, pending, other) ──
unmatched = [s for s in supplements_list] # remaining after virtual grouping
unmatched += [d for d in docs if d.get("classify_status") != "classified"]
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
contract_ids = []
for g in groups_data:
contract_number = g.get("contract_number", "")
if contract_number == "__unresolved__":
continue
counterparty = g.get("counterparty", "")
docs = g.get("documents", [])
try:
c = db_contracts.insert(contract_number, counterparty)
contract_id = c["id"]
contract_ids.append(contract_id)
for i, d in enumerate(docs):
doc_id = d.get("id")
if not doc_id:
continue
supp_type = "initial" if i == 0 else "additional"
db_supplements.insert(contract_id, doc_id, supp_type)
created += 1
except Exception as e:
return {"ok": False, "error": f"apply_groups failed at {contract_number}: {e}"}
return {"ok": True, "created": created, "contract_ids": contract_ids}