// MDM SORP — Relationship Map screen // ER-like diagram with FOUR semantic view modes: // Business — бизнес-объекты (Company, Contact, Deal, Invoice, Payment, Task, Document, User) // Technical — реальные таблицы из БД // Integration — откуда данные приходят (AmoCRM/Asana/Sheets/Bank/Email → tables) // AI — где работают AI-агенты (extraction/matching/dedup/scoring/...) // // SVG-based, pan/zoom, node selection, side panel with details. (function () { const { useState, useEffect, useMemo, useRef, useCallback } = React; // ───────────────────────────────────────────────────────────── // Modes — node lists + edges + colors per mode // ───────────────────────────────────────────────────────────── // Compact node spec: id, x, y, color, group, label, sub, fields? const M_BUSINESS = { label: "Business view", desc: "Бизнес-объекты — то, чем оперирует бизнес. Без технических деталей.", nodeColor: "#2C5C8C", nodes: [ { id: "company", x: 80, y: 80, label: "Company", sub: "Контрагенты", icon: "parties" }, { id: "contact", x: 80, y: 220, label: "Contact", sub: "Физ. лица", icon: "parties" }, { id: "user", x: 80, y: 360, label: "User", sub: "Наши сотрудники", icon: "parties" }, { id: "deal", x: 360, y: 80, label: "Deal", sub: "Сделки SORP-XXXX", icon: "deals" }, { id: "task", x: 360, y: 220, label: "Task", sub: "Задачи проджекта", icon: "queue" }, { id: "invoice", x: 640, y: 80, label: "Invoice", sub: "Счета", icon: "docs" }, { id: "payment", x: 640, y: 220, label: "Payment", sub: "Платежи", icon: "bar" }, { id: "document", x: 640, y: 360, label: "Document", sub: "Файлы и сканы", icon: "docs" }, ], edges: [ { from: "company", to: "deal", label: "owns" }, { from: "contact", to: "company", label: "works_at" }, { from: "user", to: "deal", label: "manages" }, { from: "user", to: "task", label: "assignee" }, { from: "deal", to: "task", label: "spawns" }, { from: "deal", to: "invoice", label: "produces" }, { from: "invoice", to: "payment", label: "paid_by" }, { from: "deal", to: "document", label: "has" }, ], legend: [ { color: "#2C5C8C", label: "Бизнес-сущности" }, { color: "#7A7468", label: "Связи (semantic)" }, ], }; const M_TECHNICAL = { label: "Technical view", desc: "Реальные таблицы Postgres. Поля, типы, FK — то, что видит разработчик.", nodeColor: "#1E2329", nodes: [ { id: "counterparties", x: 80, y: 60, label: "counterparties", sub: "table", fields: [ { k: "id", t: "uuid", pk: true }, { k: "type", t: "enum" }, { k: "short_name", t: "text" }, { k: "inn", t: "text" }, ] }, { id: "deals", x: 360, y: 60, label: "deals", sub: "table", fields: [ { k: "id", t: "uuid", pk: true }, { k: "sorp_number", t: "text" }, { k: "customer_id", t: "uuid → cp", fk: true }, { k: "supplier_id", t: "uuid → cp", fk: true }, { k: "current_stage", t: "enum" }, { k: "margin_amount", t: "numeric (gen)" }, ] }, { id: "deal_items", x: 660, y: 60, label: "deal_items", sub: "table", fields: [ { k: "id", t: "uuid", pk: true }, { k: "deal_id", t: "uuid → deals", fk: true }, { k: "name", t: "text" }, { k: "quantity", t: "numeric" }, ] }, { id: "contracts", x: 360, y: 280, label: "contracts", sub: "table", fields: [ { k: "id", t: "uuid", pk: true }, { k: "deal_id", t: "uuid → deals", fk: true }, { k: "party_type", t: "enum" }, { k: "amount_rub", t: "numeric" }, ] }, { id: "payments", x: 660, y: 280, label: "payments", sub: "table", fields: [ { k: "id", t: "uuid", pk: true }, { k: "contract_id", t: "uuid → ctr", fk: true }, { k: "amount", t: "numeric" }, { k: "direction", t: "enum" }, ] }, { id: "transitions_log", x: 80, y: 280, label: "transitions_log", sub: "append-only", fields: [ { k: "id", t: "uuid", pk: true }, { k: "deal_id", t: "uuid → deals", fk: true }, { k: "stage_from", t: "enum" }, { k: "stage_to", t: "enum" }, ] }, { id: "employees", x: 80, y: 500, label: "employees", sub: "table", fields: [ { k: "id", t: "uuid", pk: true }, { k: "full_name", t: "text" }, { k: "role", t: "enum" }, ] }, { id: "documents", x: 660, y: 500, label: "documents", sub: "table", fields: [ { k: "id", t: "uuid", pk: true }, { k: "deal_id", t: "uuid → deals", fk: true }, { k: "doc_type", t: "enum" }, { k: "url", t: "text" }, ] }, { id: "shipments", x: 360, y: 500, label: "shipments", sub: "table", fields: [ { k: "id", t: "uuid", pk: true }, { k: "contract_id", t: "uuid → ctr", fk: true }, { k: "status", t: "enum" }, ] }, ], edges: [ { from: "deals", to: "counterparties", label: "customer_id" }, { from: "deals", to: "counterparties", label: "supplier_id", curveOffset: 30 }, { from: "deal_items", to: "deals", label: "FK" }, { from: "contracts", to: "deals", label: "FK" }, { from: "payments", to: "contracts", label: "FK" }, { from: "transitions_log", to: "deals", label: "FK" }, { from: "documents", to: "deals", label: "FK" }, { from: "shipments", to: "contracts", label: "FK" }, { from: "deals", to: "employees", label: "pm_id" }, ], legend: [ { color: "#1E2329", label: "table" }, { color: "#D63D4A", label: "PK column" }, { color: "#2C5C8C", label: "FK column" }, ], }; const M_INTEGRATION = { label: "Integration view", desc: "Откуда данные приходят. Стрелка = поток данных от источника к таблице.", nodeColor: "#1E5E40", nodes: [ // Sources { id: "amocrm", x: 60, y: 80, label: "AmoCRM", sub: "source · OAuth", icon: "plug", kind: "source", srcColor: "#FF8A0B" }, { id: "asana", x: 60, y: 200, label: "Asana", sub: "source · PAT", icon: "plug", kind: "source", srcColor: "#F06A6A" }, { id: "sheets", x: 60, y: 320, label: "Google Sheets", sub: "source · OAuth", icon: "plug", kind: "source", srcColor: "#0F9D58" }, { id: "bank", x: 60, y: 440, label: "Bank statements",sub: "source · CSV", icon: "plug", kind: "source", srcColor: "#2C5C8C" }, { id: "email", x: 60, y: 560, label: "Email", sub: "source · IMAP", icon: "plug", kind: "source", srcColor: "#7A7468" }, // Tables { id: "deals", x: 440, y: 60, label: "deals", sub: "table" }, { id: "tasks", x: 440, y: 200, label: "tasks", sub: "table" }, { id: "fin_staging",x: 440, y: 320, label: "fin_staging", sub: "staging table" }, { id: "bank_txn", x: 440, y: 440, label: "bank_transactions", sub: "table" }, { id: "documents", x: 440, y: 560, label: "documents", sub: "table" }, ], edges: [ { from: "amocrm", to: "deals", label: "leads → deals", kind: "integration" }, { from: "asana", to: "tasks", label: "tasks → tasks", kind: "integration" }, { from: "asana", to: "deals", label: "GID → deal.asana_task_gid", kind: "integration", curveOffset: -40 }, { from: "sheets", to: "fin_staging", label: "sheet → staging", kind: "integration" }, { from: "bank", to: "bank_txn", label: "csv → txn", kind: "integration" }, { from: "email", to: "documents", label: "attachment → doc", kind: "integration" }, ], legend: [ { color: "#FF8A0B", label: "AmoCRM source" }, { color: "#F06A6A", label: "Asana source" }, { color: "#0F9D58", label: "Sheets source" }, { color: "#2C5C8C", label: "Bank source" }, { color: "#7A7468", label: "Email source" }, { color: "#1E5E40", label: "Destination table" }, ], }; const M_AI = { label: "AI view", desc: "Где работают AI-агенты. Стрелка = вход и выход агента.", nodeColor: "#4A2BA8", nodes: [ // Inputs { id: "raw_text", x: 60, y: 80, label: "raw text", sub: "Asana comments, emails", icon: "list", kind: "data" }, { id: "raw_pdf", x: 60, y: 220, label: "PDF / scan", sub: "spec, invoice, КП", icon: "file", kind: "data" }, { id: "csv_data", x: 60, y: 360, label: "CSV / sheet", sub: "bank, finance", icon: "table", kind: "data" }, { id: "duplicates", x: 60, y: 500, label: "duplicate candidates", sub: "fuzzy match input", icon: "merge", kind: "data" }, // Agents { id: "extraction", x: 380, y: 80, label: "Extraction agent", sub: "NER + regex", kind: "agent" }, { id: "matching", x: 380, y: 220, label: "Matching agent", sub: "amo↔asana↔drive", kind: "agent" }, { id: "dedup", x: 380, y: 360, label: "Dedup agent", sub: "fuzzy + INN + name", kind: "agent" }, { id: "scoring", x: 380, y: 500, label: "Scoring agent", sub: "confidence + risk", kind: "agent" }, { id: "reconciliation",x: 380, y: 640, label: "Reconciliation", sub: "amount match cross-source", kind: "agent" }, { id: "summarization", x: 700, y: 80, label: "Summarization", sub: "deal digest, weekly", kind: "agent" }, // Outputs { id: "deals_out", x: 700, y: 220, label: "deals", sub: "table" }, { id: "provenance", x: 700, y: 360, label: "field_provenance", sub: "src + conf" }, { id: "merged_party", x: 700, y: 500, label: "counterparties", sub: "merged record" }, ], edges: [ { from: "raw_text", to: "extraction", label: "input", kind: "ai" }, { from: "raw_pdf", to: "extraction", label: "input", kind: "ai" }, { from: "csv_data", to: "matching", label: "input", kind: "ai" }, { from: "duplicates", to: "dedup", label: "input", kind: "ai" }, { from: "extraction", to: "deals_out", label: "fields", kind: "ai" }, { from: "extraction", to: "provenance", label: "conf+raw", kind: "ai" }, { from: "matching", to: "provenance", label: "match score", kind: "ai" }, { from: "dedup", to: "merged_party", label: "merged uuid", kind: "ai" }, { from: "scoring", to: "provenance", label: "writes conf", kind: "ai" }, { from: "reconciliation", to: "deals_out", label: "amount_rub", kind: "ai", curveOffset: -50 }, { from: "extraction", to: "summarization", label: "digest input", kind: "ai" }, ], legend: [ { color: "#4A2BA8", label: "AI agent" }, { color: "#7A7468", label: "Input data" }, { color: "#1E5E40", label: "Destination" }, { color: "#6E45E2", label: "Data flow", dash: true }, ], }; const MODES = { business: M_BUSINESS, technical: M_TECHNICAL, integration: M_INTEGRATION, ai: M_AI }; // ───────────────────────────────────────────────────────────── // Node sizing — width fixed, height depends on fields count // ───────────────────────────────────────────────────────────── const NODE_W = 220; const HEADER_H = 36; const FIELD_H = 22; function nodeHeight(node) { if (node.fields && node.fields.length) return HEADER_H + node.fields.length * FIELD_H + 8; return HEADER_H + 32; // sub line } // Compute attachment point on the side of a node closest to target center function attachPoint(node, targetCx, targetCy) { const w = NODE_W; const h = nodeHeight(node); const cx = node.x + w / 2; const cy = node.y + h / 2; const dx = targetCx - cx; const dy = targetCy - cy; // Decide if attach is on left/right or top/bottom side if (Math.abs(dx) / w > Math.abs(dy) / h) { // attach on left or right return { x: dx > 0 ? node.x + w : node.x, y: cy }; } else { return { x: cx, y: dy > 0 ? node.y + h : node.y }; } } function curvePath(from, to, offset = 0) { const dx = to.x - from.x; const dy = to.y - from.y; const cx1 = from.x + dx * 0.5; const cy1 = from.y + dy * 0.1 + offset; const cx2 = from.x + dx * 0.5; const cy2 = to.y - dy * 0.1 + offset; return `M ${from.x} ${from.y} C ${cx1} ${cy1}, ${cx2} ${cy2}, ${to.x} ${to.y}`; } // ───────────────────────────────────────────────────────────── // Main screen // ───────────────────────────────────────────────────────────── function RelationshipMapScreen() { const [mode, setMode] = useState("business"); const [selected, setSelected] = useState(null); const [transform, setTransform] = useState({ x: 0, y: 0, k: 1 }); const vpRef = useRef(null); const data = MODES[mode]; const nodes = data.nodes; const edges = data.edges; // Pan: drag background const dragRef = useRef(null); const onMouseDown = (e) => { if (e.target.closest(".rm-node")) return; dragRef.current = { startX: e.clientX, startY: e.clientY, base: { x: transform.x, y: transform.y } }; }; useEffect(() => { const move = (e) => { if (!dragRef.current) return; setTransform(t => ({ ...t, x: dragRef.current.base.x + (e.clientX - dragRef.current.startX), y: dragRef.current.base.y + (e.clientY - dragRef.current.startY) })); }; const up = () => { dragRef.current = null; }; window.addEventListener("mousemove", move); window.addEventListener("mouseup", up); return () => { window.removeEventListener("mousemove", move); window.removeEventListener("mouseup", up); }; }, []); // Zoom: ctrl/cmd + wheel const onWheel = (e) => { if (!(e.ctrlKey || e.metaKey)) return; e.preventDefault(); const factor = Math.exp(-e.deltaY * 0.002); setTransform(t => ({ ...t, k: Math.max(0.4, Math.min(2.5, t.k * factor)) })); }; const zoomIn = () => setTransform(t => ({ ...t, k: Math.min(2.5, t.k * 1.2) })); const zoomOut = () => setTransform(t => ({ ...t, k: Math.max(0.4, t.k / 1.2) })); const zoomReset = () => setTransform({ x: 0, y: 0, k: 1 }); // Reset selection when mode changes useEffect(() => { setSelected(null); }, [mode]); const selectedNode = selected ? nodes.find(n => n.id === selected) : null; const incoming = selected ? edges.filter(e => e.to === selected) : []; const outgoing = selected ? edges.filter(e => e.from === selected) : []; // ───────────────────────────────────────────────────── // Render // ───────────────────────────────────────────────────── return (

Relationship Map

{data.desc}
{/* Edges */} {edges.map((e, i) => { const from = nodes.find(n => n.id === e.from); const to = nodes.find(n => n.id === e.to); if (!from || !to) return null; const fromCx = from.x + NODE_W / 2; const fromCy = from.y + nodeHeight(from) / 2; const toCx = to.x + NODE_W / 2; const toCy = to.y + nodeHeight(to) / 2; const fromAtt = attachPoint(from, toCx, toCy); const toAtt = attachPoint(to, fromCx, fromCy); const dimmed = selected && e.from !== selected && e.to !== selected; const arrowMarker = e.kind === "integration" ? "url(#rm-arrow-blue)" : e.kind === "ai" ? "url(#rm-arrow-purple)" : "url(#rm-arrow)"; return ( {e.label && ( {e.label} )} ); })} {/* Nodes */} {nodes.map(node => { const h = nodeHeight(node); const isSelected = selected === node.id; const dimmed = selected && !isSelected && !edges.some(e => (e.from === selected && e.to === node.id) || (e.to === selected && e.from === node.id)); const headerColor = node.srcColor || data.nodeColor; return ( { e.stopPropagation(); setSelected(node.id); }}> {/* Header band */} {node.label} {node.sub && ( {node.sub} )} {/* Body */} {node.fields ? ( <> {node.fields.map((f, i) => ( {f.pk ? "🔑 " : f.fk ? "↗ " : ""}{f.k} {f.t} ))} ) : ( {node.kind === "agent" ? "AI agent" : node.kind === "source" ? "data source" : node.kind === "data" ? "input" : "entity"} )} ); })} {/* Zoom controls */}
{/* Legend */}
Легенда · {data.label}
{data.legend.map((l, i) => (
{l.label}
))}
⌘+колесо — zoom · drag фон — pan
{/* Side panel — node details */} {selectedNode && (
{selectedNode.sub || data.label}

{selectedNode.label}

{mode === "business" && businessDesc(selectedNode.id)} {mode === "technical" && technicalDesc(selectedNode.id)} {mode === "integration"&& integrationDesc(selectedNode.id)} {mode === "ai" && aiDesc(selectedNode.id)}

{selectedNode.fields && selectedNode.fields.length > 0 && ( <>
Поля
{selectedNode.fields.map((f, i) => (
{f.pk && PK} {f.fk && FK} {f.k} {f.t}
))}
)} {(incoming.length > 0 || outgoing.length > 0) && ( <>
Связи
{incoming.map((e, i) => (
{e.from} {e.label || "→"}
))} {outgoing.map((e, i) => (
{e.to} {e.label || "→"}
))}
)}
)}
); } // ───────────────────────────────────────────────────────────── // Per-mode descriptions // ───────────────────────────────────────────────────────────── function businessDesc(id) { return { company: "Юридическое или физическое лицо, с которым у нас отношения. Может быть заказчиком, поставщиком или обоими.", contact: "Физическое лицо — представитель Company. Email, телефон, мессенджеры, правила общения.", user: "Сотрудник SORP с ролью (PM, закупщик, юрист, СБ, логист).", deal: "Сделка SORP-XXXX — ядро всей системы. Linked to Amo (lead), Asana (task), Drive (folder).", task: "Задача в Asana — у каждой сделки 0..N задач. ПМ и закупщик ведут диалог в её комментариях.", invoice: "Счёт на оплату — выставляется заказчику либо приходит от поставщика.", payment: "Денежная операция — связана со счётом, имеет direction (incoming / outgoing) и валюту.", document: "Файл (PDF/JPG/DOCX), привязанный к сделке или контракту. Тип распознан ИИ при загрузке.", }[id] || "—"; } function technicalDesc(id) { return { counterparties: "uuid-keyed таблица контрагентов. enum type ∈ customer/supplier/both. Триггер обновляет updated_at.", deals: "115 колонок, parent_deal_id для альтернативных закупок (Китай vs МИР). margin_amount/margin_pct — computed always stored. version авто-инкрементируется.", deal_items: "Позиции в сделке: name, quantity, unit, unit_price, currency. Несколько на одну deal.", contracts: "Договор с одной из сторон сделки (party_type customer/supplier). Содержит финансовые поля + контролирует статус.", payments: "Транзакции по contracts. payment_type, direction, status (not_paid/prepaid/...). Связь с contract.", transitions_log:"APPEND-ONLY. Каждый переход current_stage пишется триггером. Источник истины для реестров.", employees: "Наши сотрудники. Триггер на updated_at. Может быть FK во многих ролях (pm_id, buyer_id, ...).", documents: "URL + storage_path + doc_type enum. Привязка либо к deal_id, либо к contract_id (CHECK).", shipments: "Отгрузки с маршрутом и таможенной пошлиной. CHECK contract_id IS NOT NULL OR deal_id IS NOT NULL.", }[id] || "—"; } function integrationDesc(id) { return { amocrm: "Источник лидов и сделок. Webhook → leads_created/leads_status_changed → upsert в deals.", asana: "Operational hub: 40 custom fields + key info в комментариях. PAT-токен, polling каждые 5 мин + webhook.", sheets: "Google Sheets через Drive API. Импорт операционных таблиц (платежи, реестры, контракты) с предпросмотром в очереди.", bank: "Банковская выписка в CSV. Авто-разбор + reconciliation agent сопоставляет с invoices.", email: "IMAP. Письма от заказчиков и поставщиков; attachments извлекаются в documents.", deals: "Основная таблица — наполняется из AmoCRM (через leads) и Asana (через GID и custom fields).", tasks: "Каждая задача Asana = одна строка. SUB полей мапится в нашу нормальную схему.", fin_staging: "Промежуточная таблица перед нормализацией. Сюда падает результат парсинга Google Sheet до ручной верификации.", bank_txn: "Транзакции из выписки. Используется reconciliation-агентом для сопоставления с invoices.", documents: "Унифицированная таблица файлов — получает контент из Drive (через folder ID на deals) и Email (attachments).", }[id] || "—"; } function aiDesc(id) { return { raw_text: "Комментарии Asana, тело писем, заметки ПМ — всё, где может прятаться структурированная инфа (supplier, price, blocker, deadline).", raw_pdf: "Сканы спецификаций, КП, инвойсов, таможенных деклараций — извлекаются OCR + LLM.", csv_data: "Banking, finance — структурированные данные с лёгкой нормализацией.", duplicates: "Кандидаты на слияние от Dedup-агента — операторы подтверждают/отклоняют в очереди.", extraction: "NER + regex + LLM. Достаёт supplier, price, currency, deadline, brand из неструктурированного текста.", matching: "Связывает amo_lead ↔ asana_task ↔ drive_folder через GID, INN, email, phone. Confidence-score на пару.", dedup: "Fuzzy match по name (Levenshtein) + INN + phone. Возвращает группы кандидатов с confidence.", scoring: "Оценивает уверенность других агентов и risk-score (санкции, dual-use, parallel-import).", reconciliation: "Сопоставляет суммы между Amo, Asana, банком, нашим контрактом — flag если разъезд > tolerance.", summarization: "Готовит еженедельный digest для руководителя + per-deal summary для аналитики и onboarding нового ПМ.", deals_out: "Куда пишут extraction + reconciliation: fields сделки + audit-row в provenance.", provenance: "Каждое поле от ИИ имеет source/confidence/raw_value — это и есть field_provenance.", merged_party: "Результат работы Dedup-агента: одна counterparty с присоединёнными псевдонимами и историей.", }[id] || "—"; } window.RelationshipMapScreen = RelationshipMapScreen; })();