""" 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. Оставшиеся 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}