452 lines
20 KiB
Python
452 lines
20 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
|
||
from db import dossiers as db_dossiers
|
||
from db import exceptions as db_exceptions
|
||
from db import extractions as db_extractions
|
||
|
||
|
||
_HOMOGLYPHS = str.maketrans({
|
||
"A": "А", "B": "В", "C": "С", "E": "Е", "H": "Н", "K": "К",
|
||
"M": "М", "O": "О", "P": "Р", "T": "Т", "X": "Х", "Y": "У",
|
||
})
|
||
|
||
|
||
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 canonicalize_contract_number(number):
|
||
"""Нормализовать полный номер, включая визуальные латинско-кириллические пары.
|
||
|
||
Функция намеренно не отбрасывает префиксы и не строит ключ только по
|
||
суффиксу: одинаковый короткий хвост у разных контрагентов не является
|
||
достаточным доказательством тождества договоров.
|
||
"""
|
||
if not number:
|
||
return ""
|
||
normalized = str(number).upper().translate(_HOMOGLYPHS)
|
||
return re.sub(r"[^0-9А-ЯЁ]", "", normalized)
|
||
|
||
|
||
def canonicalize_counterparty(name, inn):
|
||
"""Вернуть ключи контрагента для сравнения с жёстким ограничением по ИНН."""
|
||
inn_key = re.sub(r"\D", "", str(inn or ""))
|
||
if inn_key and len(inn_key) != 10 and len(inn_key) != 12:
|
||
inn_key = ""
|
||
normalized_name = str(name or "").upper().translate(_HOMOGLYPHS)
|
||
normalized_name = re.sub(r"[«»\"'`.,()\s]", "", normalized_name)
|
||
normalized_name = re.sub(r"^(ООО|ЗАО|АО|ПАО|ИП)", "", normalized_name)
|
||
return {"name": normalized_name, "inn": inn_key}
|
||
|
||
|
||
def _counterparties_compatible(left, right):
|
||
"""Разрешить связь при совпадении идентичности или отсутствующих данных."""
|
||
left_id = canonicalize_counterparty(left.get("name"), left.get("inn"))
|
||
right_id = canonicalize_counterparty(right.get("name"), right.get("inn"))
|
||
if left_id["inn"] and right_id["inn"]:
|
||
return left_id["inn"] == right_id["inn"]
|
||
left_has_identity = bool(left_id["inn"] or left_id["name"])
|
||
right_has_identity = bool(right_id["inn"] or right_id["name"])
|
||
if not left_has_identity or not right_has_identity:
|
||
return True
|
||
return bool(left_id["name"] and left_id["name"] == right_id["name"])
|
||
|
||
|
||
def _passport_keys(passport):
|
||
"""Собрать полные ключи договора, исключая номер самого допсоглашения."""
|
||
doc_class = passport.get("doc_class")
|
||
values = []
|
||
if doc_class in {"contract_frame", "specification"} and passport.get("own_number"):
|
||
values.append((passport["own_number"], "own_number"))
|
||
for reference in passport.get("referenced_numbers") or []:
|
||
if isinstance(reference, dict) and reference.get("number"):
|
||
values.append((reference["number"], reference.get("source") or "referenced_contract"))
|
||
unique = {}
|
||
for raw, source in values:
|
||
key = canonicalize_contract_number(raw)
|
||
if key:
|
||
unique.setdefault(key, {"raw": str(raw), "source": str(source)})
|
||
return unique
|
||
|
||
|
||
def _number_sort_key(number):
|
||
"""Разбить номер на текстовые и числовые части для сортировки ДС по номеру."""
|
||
return tuple((0, int(part)) if part.isdigit() else (1, part.casefold())
|
||
for part in re.split(r"(\d+)", str(number or "")))
|
||
|
||
|
||
def _dossier_exception(batch_id, document_id, title, source_quote):
|
||
"""Создать устойчивую запись исключения, не дублируя её при пересборке batch."""
|
||
existing = db_exceptions.list_exceptions(batch_id=batch_id)
|
||
if any(
|
||
item.get("document_id") == document_id
|
||
and item.get("stage") == "dossier_linking"
|
||
and item.get("title") == title
|
||
for item in existing
|
||
):
|
||
return
|
||
db_exceptions.add_exception(
|
||
batch_id=batch_id, dossier_id=None, document_id=document_id,
|
||
service_item_id=None, severity="AMBIGUOUS", stage="dossier_linking",
|
||
title=title, source_quote=source_quote,
|
||
)
|
||
|
||
|
||
def build_dossiers_for_batch(batch_id):
|
||
"""Построить и сохранить dossier graph из паспортов документов одного batch.
|
||
|
||
Полные номера и ссылки образуют рёбра только при совместимом контрагенте.
|
||
Компоненты дополнительно защищены от транзитивного слияния двух разных ИНН;
|
||
документ, который остаётся кандидатом сразу для нескольких таких компонент,
|
||
исключается из автоматических связей и передаётся в очередь исключений.
|
||
"""
|
||
documents = db_docs.list_by_batch(batch_id)
|
||
passports = db_extractions.list_by_batch(batch_id)
|
||
passport_by_id = {row["document_id"]: row for row in passports}
|
||
records = []
|
||
for document in documents:
|
||
passport = passport_by_id.get(document["id"])
|
||
if not passport:
|
||
passport = {"document_id": document["id"], "doc_class": "unknown"}
|
||
if passport.get("doc_class") == "ignored_accounting":
|
||
continue
|
||
counterparty = {
|
||
"name": passport.get("counterparty_name"),
|
||
"inn": passport.get("counterparty_inn"),
|
||
}
|
||
records.append({
|
||
"document": document,
|
||
"passport": passport,
|
||
"counterparty": counterparty,
|
||
"keys": _passport_keys(passport),
|
||
})
|
||
|
||
parent = list(range(len(records)))
|
||
component_inns = []
|
||
for record in records:
|
||
counterparty_id = canonicalize_counterparty(
|
||
record["counterparty"].get("name"), record["counterparty"].get("inn"),
|
||
)
|
||
component_inns.append({counterparty_id["inn"]} if counterparty_id["inn"] else set())
|
||
|
||
def find(index):
|
||
while parent[index] != index:
|
||
parent[index] = parent[parent[index]]
|
||
index = parent[index]
|
||
return index
|
||
|
||
candidates = [[] for _ in records]
|
||
for left_index, left in enumerate(records):
|
||
for right_index in range(left_index + 1, len(records)):
|
||
right = records[right_index]
|
||
shared = set(left["keys"]) & set(right["keys"])
|
||
if not shared or not _counterparties_compatible(left["counterparty"], right["counterparty"]):
|
||
continue
|
||
shared_key = sorted(shared)[0]
|
||
candidates[left_index].append((right_index, shared_key))
|
||
candidates[right_index].append((left_index, shared_key))
|
||
left_root, right_root = find(left_index), find(right_index)
|
||
if left_root == right_root:
|
||
continue
|
||
merged_inns = component_inns[left_root] | component_inns[right_root]
|
||
if len(merged_inns) > 1:
|
||
continue
|
||
parent[right_root] = left_root
|
||
component_inns[left_root] = merged_inns
|
||
|
||
ambiguous_indices = set()
|
||
for index, edges in enumerate(candidates):
|
||
key_targets = {}
|
||
for other, shared_key in edges:
|
||
key_targets.setdefault(shared_key, set()).add(find(other))
|
||
candidate_roots = set().union(*key_targets.values()) if key_targets else set()
|
||
if len(candidate_roots) > 1:
|
||
ambiguous_indices.add(index)
|
||
matching_numbers = sorted({
|
||
records[index]["keys"].get(key, {}).get("raw", key)
|
||
for _other, key in edges
|
||
})
|
||
_dossier_exception(
|
||
batch_id, records[index]["document"]["id"],
|
||
"AMBIGUOUS_DOSSIER_LINK", ", ".join(matching_numbers),
|
||
)
|
||
|
||
component_members = {}
|
||
for index, record in enumerate(records):
|
||
if index in ambiguous_indices:
|
||
continue
|
||
if not record["keys"]:
|
||
_dossier_exception(
|
||
batch_id, record["document"]["id"], "NO_RELIABLE_CONTRACT_NUMBER",
|
||
record["document"].get("filename") or "",
|
||
)
|
||
continue
|
||
component_members.setdefault(find(index), []).append(record)
|
||
|
||
db_dossiers.clear_batch_dossiers(batch_id)
|
||
dossier_results = []
|
||
for members in component_members.values():
|
||
frames = [r for r in members if r["passport"].get("doc_class") == "contract_frame"]
|
||
specifications = [r for r in members if r["passport"].get("doc_class") == "specification"]
|
||
specifications.sort(key=lambda r: (
|
||
r["passport"].get("own_date") or r["document"].get("doc_date") or "9999",
|
||
r["document"].get("id", ""),
|
||
))
|
||
identifiers = []
|
||
for record in members:
|
||
for key, value in record["keys"].items():
|
||
identifiers.append((key, value["raw"], value["source"]))
|
||
canonical_candidates = [
|
||
(r["passport"].get("own_number"), "frame") for r in frames
|
||
if r["passport"].get("own_number")
|
||
] + [
|
||
(r["passport"].get("own_number"), "specification") for r in specifications
|
||
if r["passport"].get("own_number")
|
||
] + [(raw, source) for _key, raw, source in identifiers]
|
||
canonical_number = canonical_candidates[0][0] if canonical_candidates else ""
|
||
canonical_key = canonicalize_contract_number(canonical_number)
|
||
aliases = list(dict.fromkeys(
|
||
raw for key, raw, _source in identifiers
|
||
if key != canonical_key and raw != canonical_number
|
||
))
|
||
counterparty_record = next(
|
||
(r["counterparty"] for r in members
|
||
if r["counterparty"].get("inn") or r["counterparty"].get("name")),
|
||
{},
|
||
)
|
||
dossier = db_dossiers.create_dossier(
|
||
batch_id=batch_id,
|
||
canonical_number=canonical_number,
|
||
alias_numbers=aliases,
|
||
counterparty_name=counterparty_record.get("name") or "",
|
||
counterparty_inn=counterparty_record.get("inn") or "",
|
||
has_frame_contract=bool(frames),
|
||
)
|
||
base_spec_id = specifications[0]["document"]["id"] if specifications else None
|
||
members.sort(key=lambda r: _dossier_order_key(r, base_spec_id))
|
||
linked_documents = []
|
||
for sequence, record in enumerate(members, start=1):
|
||
document = record["document"]
|
||
passport = record["passport"]
|
||
doc_class = passport.get("doc_class")
|
||
if doc_class == "contract_frame":
|
||
role = "frame_contract"
|
||
elif doc_class == "specification" and document["id"] == base_spec_id:
|
||
role = "base_spec"
|
||
elif doc_class == "specification":
|
||
role = "specification"
|
||
elif doc_class == "supplement":
|
||
role = "supplement"
|
||
else:
|
||
role = "document"
|
||
reason = _link_reason(record, canonical_key, canonical_number)
|
||
link = db_dossiers.link_document(
|
||
dossier_id=dossier["id"], document_id=document["id"], role=role,
|
||
link_status="CONFIRMED", link_reason=reason, seq_order=sequence,
|
||
)
|
||
linked_documents.append({**document, "role": role, "link_reason": reason, "link": link})
|
||
dossier_results.append({**dossier, "documents": linked_documents})
|
||
|
||
return {
|
||
"ok": True,
|
||
"batch_id": batch_id,
|
||
"total_docs": len(documents),
|
||
"dossiers": dossier_results,
|
||
"unlinked": sorted(records[i]["document"]["id"] for i in ambiguous_indices)
|
||
+ [r["document"]["id"] for r in records if not r["keys"]],
|
||
"ambiguous": len(ambiguous_indices),
|
||
}
|
||
|
||
|
||
def _dossier_order_key(record, base_spec_id):
|
||
"""Упорядочить документы так, чтобы рамка и базовая спека шли первыми."""
|
||
document = record["document"]
|
||
passport = record["passport"]
|
||
doc_class = passport.get("doc_class")
|
||
if doc_class == "contract_frame":
|
||
rank = 0
|
||
elif doc_class == "specification" and document["id"] == base_spec_id:
|
||
rank = 1
|
||
elif doc_class == "specification":
|
||
rank = 2
|
||
elif doc_class == "supplement":
|
||
rank = 3
|
||
else:
|
||
rank = 4
|
||
if doc_class == "supplement":
|
||
secondary = (_number_sort_key(passport.get("own_number")),
|
||
passport.get("own_date") or document.get("doc_date") or "9999")
|
||
else:
|
||
secondary = (passport.get("own_date") or document.get("doc_date") or "9999",)
|
||
return rank, secondary, document.get("id", "")
|
||
|
||
|
||
def _link_reason(record, canonical_key, canonical_number):
|
||
"""Сформировать аудируемое основание связи из совпавшей ссылки документа."""
|
||
for reference in record["passport"].get("referenced_numbers") or []:
|
||
if not isinstance(reference, dict) or not reference.get("number"):
|
||
continue
|
||
if canonicalize_contract_number(reference["number"]) == canonical_key:
|
||
source = reference.get("source")
|
||
if source in {"header", "preamble", "appendix"}:
|
||
return f"Связано по номеру {reference['number']} из {source}"
|
||
return f"Связано по номеру {reference['number']}"
|
||
own_number = record["passport"].get("own_number")
|
||
if own_number and canonicalize_contract_number(own_number) == canonical_key:
|
||
return f"Собственный номер документа совпадает с {canonical_number}"
|
||
return f"Связано в dossier по номеру {canonical_number}"
|
||
|
||
|
||
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}
|