Перейти до змісту

AI-агент «Виписати рахунок»

Phase 1 — Chat-trigger ✅ shipped 2026-05-11

Кейс. Директор у чаті DOP пише: «Виписати рахунок ТОВ Ромашка на 10 годин консалтингу по 500 грн і відправити на ceo@romashka.test». Агент створює Invoice у статусі draft, показує summary для підтвердження, потім рендерить PDF через Print Framework і шле SMTP-листом. Результат фіксується в shop.EmailLog.

Архітектура

Реалізовано як два окремі tools поверх існуючої eswf_chat інфраструктури (OpenRouter + multi-turn function calling). Tools декларовані в ERP_TOOLS (backend/eswf_chat/tools.py) і дві функції-виконавці у тому ж файлі.

Tool Що робить Де
create_customer_invoice Резолвить buyer (existing або новий, set is_buyer=True), резолвить items+units, створює Invoice(state='draft') + InvoiceLine[]. Повертає id, total_gross, warnings, next_step-підказку для LLM. tools.py _create_customer_invoice
send_invoice_to_client_email Завантажує invoice (з фільтром tenant_id), рендерить PDF через essentials.services.invoice_pdf.render_invoice_pdf(), передає у shop.email_service.send_email() з attachments=[(filename, pdf_bytes, 'pdf')]. Логує в EmailLog. tools.py _send_invoice_to_client_email

Допоміжні зміни: - shop.email_service.send_email — додано параметр attachments: list[tuple[str, bytes, str]]. Коли список непорожній, обгорткою стає MIMEMultipart('mixed') з текстовою body всередині alternative-контейнера + окремі MIMEBase('application', subtype) парти для файлів. - essentials.services.invoice_pdf.render_invoice_pdf(invoice, lang='ua') — тонка обгортка над core.print (PrintRegistry → resolve_template → render_html → render_pdf). Повертає байти, нічого не зберігає на диск. - Резолвер _resolve_buyer — окремий від _resolve_supplier (який фільтрує is_supplier=True і створив би дубль для buyer-only клієнта). Шукає по обох ролях, флипає is_buyer=True на existing record при потребі. - SYSTEM_PROMPT — додано секцію INVOICE ISSUANCE AGENT з 3-крокової flow-інструкцією: parse → confirm → send. Hard-rule: ніколи не вигадувати email клієнта, завжди перепитати.

Потік (chat-trigger)

User → "Виписати рахунок ТОВ Ромашка на 10×500 грн і надіслати на ceo@romashka.test"
LLM розпарсує → tool_call: create_customer_invoice
draft Invoice + LLM показує summary
LLM (наступний раунд або в тому ж turn): tool_call: send_invoice_to_client_email
render_invoice_pdf() → bytes  ─┐
                                ├→ shop.email_service.send_email(..., attachments=[...])
EmailSmtpSettings (default)  ───┘                                ▼
                                                          shop.EmailLog
                                LLM: "Надіслав. Status: sent. Email-log #12."

Безпечнісні гарантії

  • Multi-tenantInvoice.objects.get(pk=invoice_id, tenant_id=tenant_id) у _send_invoice_to_client_email. Тест test_send_invoice_to_client_email_other_tenant_invoice_404 перевіряє.
  • Email — не вигадувати — у SYSTEM_PROMPT hard-rule «ASK before guessing». Підтримується LLM-prompt, не runtime-перевіркою.
  • Invoice стає draft, не posted — проводки і фінальне затвердження лишаються за бухгалтером. Tool лише створює документ і шле PDF.
  • SMTP errors → EmailLog.status='failed'send_email ловить exception і пише лог; tool повертає JSON з error_message, LLM відрапортує юзеру.
  • PDF render failurerender_pdf піднімає ImportError (playwright не встановлено) або інше; обертається в _fmt({"error": ...}).
  • Prompt injection через тілоJinja2 SandboxedEnvironment у print engine, autoescape=True. Ризик не повний (LLM сам інтерпретує payload), але render path безпечний.

Тести

backend/eswf_chat/tests/test_invoice_agent.py — 9 тестів, всі зелені:

  1. test_create_customer_invoice_with_existing_client — happy path, 2 лінії, total math.
  2. test_create_customer_invoice_creates_unknown_client — auto-create нового buyer + warning.
  3. test_create_customer_invoice_flips_is_buyer_on_existing_supplier — supplier-only client → стає dual role, без дубля.
  4. test_create_customer_invoice_validates_required_fieldsclient_name, lines обов'язкові.
  5. test_send_invoice_to_client_email_happy_path — mock render + mock smtplib, перевіряє що в SMTP-payload є Content-Disposition: attachment, ім'я файлу, EmailLog створено.
  6. test_send_invoice_to_client_email_rejects_bad_input — три гілки невалідного вводу.
  7. test_send_invoice_to_client_email_other_tenant_invoice_404 — multi-tenant safety.
  8. test_send_invoice_to_client_email_no_smtp_returns_actionable_error — actionable повідомлення замість 500.
  9. test_new_tools_are_registered_in_erp_tools_and_tool_map — schema + dispatcher in sync.

Залежності

Компонент Статус
eswf_chat LLM tool-use
Invoice API + serializer
Print Framework PDF (InvoiceContract + print_templates/invoice/)
SMTP outbox (shop.email_service + EmailSmtpSettings + EmailLog)

Що НЕ зроблено в Phase 1

  • Frontend preview-чіп — окремий компонент InvoiceAgentConfirmation з PDF-preview перед натисканням «Send». Зараз LLM показує текстовий summary, юзер підтверджує письмово. Достатньо для MVP, але preview підвищило би довіру.
  • payment_status after-send — не змінюємо. Документ лишається unpaid поки клієнт не заплатить (нормальний flow).
  • i18n шаблонів листа — defaults тільки ua/en, з простим текстом. Без HTML-брендування.

Phase 2 — Email-trigger (deferred)

Початковий кейс — директор пише email замість чат-команди. Потребує IMAP/Gmail polling + IncomingRequest модель + HMAC-approval лінк (спільна інфра з Agent Inbox Фаза 3). Trigger реактивації: перший реальний user-кейс, де директор не у DOP-чаті, а в пошті.

Email (директор → DOP inbox)
IMAP/Gmail API polling (NEW)
IncomingRequest record (NEW Django model)
Same agent (LLM + create_customer_invoice + send_invoice_to_client_email)
Approval email (HMAC-підписаний лінк) → директор (NEW)
        ├─ Approve → render PDF + SMTP send клієнту (вже є)
        └─ Reject  → Invoice.delete() (NEW)

Risk / constraints (для майбутніх Phase)

  • Point of no return — відправка email клієнту. У Phase 1 контроль через chat-confirm ("ASK before sending"). У Phase 2 — HMAC-approval гілка обов'язкова.
  • Помилка в розпізнаванні — неправильний buyer / ціна → потенційний фінансовий збиток. Phase 1 пом'якшує warning'ами і діалогом; Phase 2 потребує preview-PDF в approval email.
  • Item auto-create_resolve_item_and_unit створює нову номенклатуру коли не знайдено. Для services це норма, для goods може створювати дублі. Перевіряти у warning'ах.

Відкриті питання

  • Чи розширювати до SalesOrder / GoodsShipment, чи лишати тільки Invoice? — Поки тільки Invoice. SalesOrder доречний коли EssentialsModuleSettings.enable_sales_orders=True.
  • Чи додавати follow-up: клієнт відповів — агент сам постить оплату (IncomingPayment)? Відкладено до Phase 3.