Знакът на КАГАМИ КАГАМИ
kagami.bg/academy · lesson · machine-readable viewUPDATED 2026-10-03
IDENTITY
module
GX10-04-178 · Monthly accounting report with a local AI: from a journal to a PDF
series
GX10 (local AI server class: NVIDIA GB10, e.g. ASUS Ascent GX10 / DGX Spark)
level
Advanced
duration
1.5–2 h
prerequisites
PostgreSQL, Ollama with a chat model, Python 3.10+, WeasyPrint system libraries (Pango), a font with Cyrillic such as DejaVu Sans, basic SQL and basic accounting vocabulary; lesson 04-150 (monthly report to Word and Excel) uses the same pattern
trust_label
TESTED 2026-10-03 on a GB10-class machine (GX10) · Python 3.12.3, Docker 29.2.1 (PostgreSQL) · model gemma3:4b (llama3.3:70b NOT run: the model was not on the machine) · PDF in ~40 s, peak 72 °C · the foreign-number check flagged 570, 724, 1000 · Bulgarian quality, other fonts, cron and n8n not checked
versions
WeasyPrint 70.0 (needs Python 3.10+ and Pango 1.44+) · Jinja2 3.1.6 · httpx 0.28.1 · psycopg2-binary 2.9.13 · example model llama3.3:70b (43 GB per ollama.com; Bulgarian is not among its listed languages)
scope_limit
Not accounting, tax or legal advice. The VAT block is an indicative summary, not a VAT return. Account prefixes in the lesson are examples to be replaced by the accountant's chart of accounts
language
human view: bg · english edition: /en/academy/gx10/ (same file name)
previous / next
04-177_Document_RAG_Accountant.html / 04-179_Contract_Deadline_Tracker.html
PURPOSE

Build a monthly close pack: one SQL query turns a double-entry journal and a prefix mapping table into JSON (profit and loss for the month, previous month and year to date; cumulative balances at month end; indicative VAT totals; unmapped accounts; a debit-equals-credit control); a local model drafts a short commentary from those numbers only; code flags numbers not present in the data; Jinja2 (autoescape on) and WeasyPrint render a PDF marked as a draft; a file checksum is stored in a log table; a person approves with a separate command that refuses if the file changed. Nothing is sent automatically.

KEY CONCEPTS
COMMANDS / PATHS
CHECKLIST
NEXT MODULE

04-179 · Deadlines from documents into an .ics calendar (04-179_Contract_Deadline_Tracker.html) · related: 04-150 monthly report to Word and Excel (04-150_Client_Report_Generator.html), 04-129 VAT journals and return draft (04-129_VAT_Report_Generator.html), 04-134 annual financial report (04-134_Financial_Report_Generator.html) · series index: kagami.bg/academy/gx10/ · offer: Quick experiment (kagami.bg/stalbata/)

SOURCES
TAGS
gx10nvidia-gb10accountingmonthly-closepostgresqlollamaweasyprintpdfaudit-trailhuman-in-the-loop
ТЕСТВАНО · 03.10.2026 ОБНОВЕНО · 03.10.2026

Месечен счетоводен отчет с локален AI: от журнал към PDF

В края на всеки месец счетоводителят събира едни и същи справки: приходи и разходи, салда, ДДС. Тук подготвяме чернова на пакета автоматично: числата смята заявка към базата, локален модел пише само кратък коментар-чернова, програмата прави PDF и го заключва с контролна сума, а човек го одобрява. Нищо не се изпраща само.

⏱ 1,5–2 ч Напреднали GX10 PostgreSQL · Ollama · Python WeasyPrint · Jinja2
PostgreSQL · Python (httpx, psycopg2, Jinja2, WeasyPrint)🔒 локално Ollama (моделът)🔒 локално
🔄
ОБНОВЕНО · 03.10.2026 — какво
Урокът е преработен и обобщен. Махнахме: името и ЕИК на клиента от примера; адреса на нашата машина; бележката за „базов модел“ с препратка към вътрешен документ и непроверени имена на модели; твърдението, че е „съвместимо с GDPR“ само защото работи локално; баланс, който се смяташе само за един месец (салдата са натрупани); позицията „Прочие“, която скриваше сметки без съответствие; клас „9“ в баланса и конкретни номера на ДДС сметки, които не сме сверили; „контролна сума: pending“ в самия PDF (хешът на файл не може да стои вътре в него); шаблон без екраниране (текст от модела можеше да вкара HTML). Добавихме: журнал и таблица на съответствията; една заявка, която връща JSON; контрол „дебит = кредит“; видим списък със сметки без съответствие; чернова по схема и проверка за чужди числа; PDF с WeasyPrint 70.0 и Jinja2 с автоматично екраниране; дневник с контролна сума SHA-256; одобрение с отделна команда, която отказва, ако файлът е променен.
🧪
Какво пуснахме на GB10
03.10.2026 · пуснато на GB10 (GX10) от Клодет · Python 3.12.3 · Docker 29.2.1 (PostgreSQL в контейнер). Модел, действително ползван: gemma3:4b. С llama3.3:70b не е пускано (моделът не беше на машината); тествано с gemma3:4b. Минаха схемата, данните, make (PDF ~18 KB) и approve. psql не беше на хоста — зареждане през docker exec. На нашата машина пазачът kagami-gate спира процеса със SIGSTOP — пускахме с nohup. Измерено: PDF за ~40 s, температура до 72 °C (паралелно ComfyUI държеше 16,6 GB от GPU). Български и проверката: моделът добави числа (570, 724, 1000), които ги няма в данните, и проверката за чужди числа ги маркира правилно; отчетът не дава оценка на езиковото качество. Остава непроверено: моделът llama3.3:70b, езиковото качество на коментара, PDF-ът с други шрифтове, cron и n8n.
⚠️
Български език и избор на модел
На страницата на llama3.3 в Ollama са изброени английски, немски, френски, италиански, португалски, хинди, испански и тайландски — български няма. Моделът може да пише на български, но качеството не е гарантирано: прочети коментара внимателно или смени модела през REPORT_MODEL и сравни.
⚖️
Не е счетоводен, данъчен или правен съвет
Урокът показва как се подрежда подготовка на отчет. Сметките и префиксите в примера са примерни — таблицата на съответствията я попълва счетоводителят по твоя сметкоплан. ДДС блокът е ориентир, не справка-декларация. Всяко число потвърждава счетоводителят, а при данъчен или правен въпрос — специалист.

01Какво ще научиш

02Преди да започнеш

03Стъпки

  1. Какво ще построим

    КаквоКой го правиЗащо
    Приходи и разходи, салда, ДДСЕдна заявка към PostgreSQLЧислата не бива да зависят от настроението на модела
    Контрол „дебит = кредит“КодНебалансиран журнал → отчет не се прави
    Кратък коментарЛокален модел — черноваПише бързо и четимо, но може да греши
    PDF и контролна сумаКодФайлът може да се провери по-късно
    Одобрение и изпращанеЧовекОтговорността за отчета е негова

    Редът е: заявка → контрол → модел → проверка на числата → PDF → запис в дневника → одобрение от човек → ръчно изпращане.

  2. Журнал, съответствия и дневник

    journal е двустранен журнал: всеки ред е дебит или кредит по сметка. Редовете с ДДС носят още vat_side (продажби или покупки), основа и ДДС. account_map казва коя сметка в коя позиция отива — по префикс: печели най-дългият подходящ. Префиксите по-долу са примерни; истинската таблица попълва счетоводителят по твоя сметкоплан. report_log пази дневника на отчетите.

    sql · schema.sql
    CREATE TABLE journal (
        id           bigserial PRIMARY KEY,
        entry_date   date          NOT NULL,
        account_code text          NOT NULL,
        debit        numeric(14,2) NOT NULL DEFAULT 0 CHECK (debit  >= 0),
        credit       numeric(14,2) NOT NULL DEFAULT 0 CHECK (credit >= 0),
        vat_side     text          CHECK (vat_side IN ('sales', 'purchases')),  -- само за редове с ДДС
        vat_base     numeric(14,2),
        vat_amount   numeric(14,2)
    );
    CREATE INDEX ON journal (entry_date);
    
    -- Твоята таблица на съответствията: коя сметка в коя позиция отива.
    -- Префиксите по-долу са ПРИМЕРНИ — попълва ги счетоводителят по твоя сметкоплан.
    CREATE TABLE account_map (
        prefix    text PRIMARY KEY,
        statement text NOT NULL CHECK (statement IN ('pnl', 'balance')),
        kind      text NOT NULL CHECK (kind IN ('revenue', 'expense', 'asset', 'liability')),
        line      text NOT NULL
    );
    
    INSERT INTO account_map VALUES
        ('7',   'pnl',     'revenue',   'Приходи'),
        ('60',  'pnl',     'expense',   'Разходи за материали и услуги'),
        ('62',  'pnl',     'expense',   'Разходи за персонал'),
        ('50',  'balance', 'asset',     'Пари'),
        ('41',  'balance', 'asset',     'Вземания'),
        ('40',  'balance', 'liability', 'Задължения към доставчици'),
        ('450', 'balance', 'liability', 'Разчети по ДДС'),
        ('10',  'balance', 'liability', 'Капитал');
    
    CREATE TABLE report_log (
        id          bigserial PRIMARY KEY,
        period      date        NOT NULL,
        pdf_path    text        NOT NULL,
        sha256      text        NOT NULL,
        status      text        NOT NULL CHECK (status IN ('draft', 'approved')),
        created_at  timestamptz NOT NULL DEFAULT now(),
        approved_at timestamptz,
        approved_by text
    );
  3. Измислени данни

    Девет месеца от 2026 г.: продажби, покупки, заплати, плащания и първоначална вноска на капитал. ДДС е 20 % само за примера. Накрая има нарочна „клопка“ — сметка 999, която няма съответствие, за да видиш как се показват немаркираните. Числата при теб може да се различават (суми се извеждат от hashtext).

    sql · seed.sql
    -- ИЗМИСЛЕНИ записи за 2026-01 … 2026-09. Сметките са примерни, ДДС е 20 % само за пример.
    INSERT INTO journal (entry_date, account_code, debit, credit)
    VALUES ('2026-01-02', '501', 50000, 0), ('2026-01-02', '101', 0, 50000);
    
    WITH months AS (
        SELECT m::date AS m,
               (8000 + abs(hashtext(m::text || 's')) % 4000)::numeric AS sale,
               (2000 + abs(hashtext(m::text || 'p')) % 1500)::numeric AS purch,
               (3000 + abs(hashtext(m::text || 'w')) % 500)::numeric  AS wage
        FROM generate_series(date '2026-01-01', date '2026-09-01', interval '1 month') AS m
    )
    INSERT INTO journal (entry_date, account_code, debit, credit, vat_side, vat_base, vat_amount)
    SELECT m + 9,  '411', sale * 1.2, 0, NULL, NULL, NULL FROM months UNION ALL
    SELECT m + 9,  '701', 0, sale, 'sales', sale, sale * 0.2 FROM months UNION ALL
    SELECT m + 9,  '450', 0, sale * 0.2, NULL, NULL, NULL FROM months UNION ALL
    SELECT m + 14, '601', purch, 0, 'purchases', purch, purch * 0.2 FROM months UNION ALL
    SELECT m + 14, '450', purch * 0.2, 0, NULL, NULL, NULL FROM months UNION ALL
    SELECT m + 14, '401', 0, purch * 1.2, NULL, NULL, NULL FROM months UNION ALL
    SELECT m + 24, '621', wage, 0, NULL, NULL, NULL FROM months UNION ALL
    SELECT m + 24, '501', 0, wage, NULL, NULL, NULL FROM months UNION ALL
    SELECT m + 19, '501', sale * 1.2 * 0.8, 0, NULL, NULL, NULL FROM months UNION ALL
    SELECT m + 19, '411', 0, sale * 1.2 * 0.8, NULL, NULL, NULL FROM months;
    -- (сметка 999 без съответствие — за да видиш как се показват немаркираните)
    INSERT INTO journal (entry_date, account_code, debit, credit) VALUES
        ('2026-09-20', '999', 100, 0), ('2026-09-20', '501', 0, 100);
  4. Една заявка — целият пакет

    Заявката връща един JSON обект. Накратко: mapped прикачва всеки ред към позиция (най-дългият префикс); pnl дава месец, предходен месец и натрупано от началото на годината; bal дава салдата от началото на журнала до края на месеца — баланс „само за месеца“ би бил грешен; unmapped показва сметките без съответствие; vat сумира основата и ДДС по страни; ctl е контролът за дебит и кредит.

    sql · pack.sql
    -- Параметър: %(month)s = първо число на месеца (затворения месец)
    WITH b AS (
        SELECT %(month)s::date                                     AS m0,
               (%(month)s::date + interval '1 month')::date        AS m1,
               date_trunc('year', %(month)s::date)::date           AS y0
    ), mapped AS (
        SELECT DISTINCT ON (j.id) j.*, a.statement, a.kind, a.line
        FROM journal j
        LEFT JOIN account_map a ON j.account_code LIKE a.prefix || '%%'
        ORDER BY j.id, length(a.prefix) DESC NULLS LAST      -- печели най-дългият префикс
    ), pnl AS (
        SELECT line, kind,
               coalesce(sum(CASE kind WHEN 'revenue' THEN credit - debit ELSE debit - credit END)
                        FILTER (WHERE entry_date >= b.m0 AND entry_date < b.m1), 0)                         AS month_eur,
               coalesce(sum(CASE kind WHEN 'revenue' THEN credit - debit ELSE debit - credit END)
                        FILTER (WHERE entry_date >= (b.m0 - interval '1 month') AND entry_date < b.m0), 0) AS prev_month_eur,
               coalesce(sum(CASE kind WHEN 'revenue' THEN credit - debit ELSE debit - credit END)
                        FILTER (WHERE entry_date >= b.y0 AND entry_date < b.m1), 0)                         AS ytd_eur
        FROM mapped, b
        WHERE statement = 'pnl'
        GROUP BY line, kind
    ), bal AS (
        SELECT line, kind,
               sum(CASE kind WHEN 'asset' THEN debit - credit ELSE credit - debit END) AS balance_eur
        FROM mapped, b
        WHERE statement = 'balance' AND entry_date < b.m1      -- натрупано от началото, не само за месеца
        GROUP BY line, kind
    ), unmapped AS (
        SELECT account_code, sum(debit - credit) AS net_eur
        FROM mapped, b
        WHERE statement IS NULL AND entry_date < b.m1
        GROUP BY account_code
    ), vat AS (
        SELECT vat_side, sum(vat_base) AS base_eur, sum(vat_amount) AS vat_eur, count(*) AS docs
        FROM journal, b
        WHERE vat_side IS NOT NULL AND entry_date >= b.m0 AND entry_date < b.m1
        GROUP BY vat_side
    ), ctl AS (
        SELECT coalesce(sum(debit), 0) AS total_debit, coalesce(sum(credit), 0) AS total_credit
        FROM journal, b WHERE entry_date < b.m1
    )
    SELECT json_build_object(
        'month',     %(month)s::date,
        'currency',  'EUR',
        'pnl',       (SELECT coalesce(json_agg(row_to_json(pnl) ORDER BY kind DESC, line), '[]'::json) FROM pnl),
        'balance',   (SELECT coalesce(json_agg(row_to_json(bal) ORDER BY kind, line), '[]'::json) FROM bal),
        'vat',       (SELECT coalesce(json_agg(row_to_json(vat) ORDER BY vat_side), '[]'::json) FROM vat),
        'unmapped',  (SELECT coalesce(json_agg(row_to_json(unmapped) ORDER BY account_code), '[]'::json) FROM unmapped),
        'control',   (SELECT row_to_json(ctl) FROM ctl)
    ) AS pack;
    ⚠️
    Опростено — това не е цялото счетоводство
    Натрупаният финансов резултат не е прехвърлен в „Капитал“ — затова салдата не са пълен баланс. ДДС блокът не отчита право на данъчен кредит, специални режими и корекции. Ориентирът „продажби − покупки“ е само индикация; за законовия текст и срока виж урок 04-129 (там чл. 88, ал. 2 и 3 и чл. 125 от Закона за данъка върху добавената стойност са цитирани дословно и сверени към 01.10.2026; в този урок не сме ги сверявали наново — срокът за справката-декларация е до 14-о число на следващия месец).

    За септември 2026 на нашата проба заявката върна (твоите числа може да се различават): приходи 11 345,00 за месеца, 11 702,00 за предходния и 89 936,00 от началото на годината; разходи за материали и услуги 2 827,00 / 2 890,00 / 23 595,00; разходи за персонал 3 309,00 / 3 362,00 / 29 280,00; салда — пари 106 958,56, вземания 21 584,64, задължения към доставчици 28 314,00, капитал 50 000,00, разчети по ДДС 13 268,20; сметка 999 без съответствие със салдо 100,00; общ дебит = общ кредит = 301 955,76.

  5. Числата, контролът и коментарът от модела

    Първата част на monthly_close.py. fetch_pack пуска заявката и спира, ако дебитът не е равен на кредита — отчет върху небалансиран журнал е по-лош от липса на отчет. foreign_numbers сравнява числата в текста с числата в данните (като числа, не като низове). narrative пита модела по схема (commentary и checks) и му казва: само числата от данните, без причини и правни изводи. Паролата към базата не е в кода — идва от CLOSE_DSN.

    python · monthly_close.py (1/3)
    """Месечно приключване: числа (SQL) -> чернова на коментар (локален модел) -> PDF -> одобрение от човек."""
    import argparse
    import hashlib
    import json
    import os
    import re
    from datetime import date
    from pathlib import Path
    
    import httpx
    import psycopg2
    from jinja2 import Environment
    from weasyprint import CSS, HTML
    
    OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://localhost:11434")
    MODEL = os.environ.get("REPORT_MODEL", "llama3.3:70b")
    DSN = os.environ["CLOSE_DSN"]       # от средата, не в кода
    OUT = Path("output")
    
    SCHEMA = {
        "type": "object",
        "properties": {
            "commentary": {"type": "string"},
            "checks": {"type": "array", "items": {"type": "string"}},
        },
        "required": ["commentary", "checks"],
    }
    
    
    def fetch_pack(conn, period: str) -> dict:
        with conn.cursor() as cur:
            cur.execute(Path("pack.sql").read_text(encoding="utf-8"), {"month": period})
            pack = cur.fetchone()[0]
        c = pack["control"]
        if abs(float(c["total_debit"]) - float(c["total_credit"])) > 0.005:
            raise ValueError("Журналът не е балансиран (дебит ≠ кредит) — отчет не се прави.")
        return pack
    
    
    def numbers(s: str) -> set:
        return {float(n.replace(",", ".")) for n in re.findall(r"\d+(?:[.,]\d+)?", s)}
    
    
    def foreign_numbers(text: dict, pack: dict) -> list:
        blob = text["commentary"] + " " + " ".join(text["checks"])
        return sorted(numbers(blob) - numbers(json.dumps(pack)))
    
    
    def narrative(pack: dict) -> dict:
        prompt = (
            "Ти пишеш чернова на кратък управленски коментар към месечен счетоводен отчет. "
            "Ползвай САМО числата от данните; не измисляй числа, причини и правни изводи. "
            "Посочи до 3 значими промени спрямо предходния месец и какво да провери счетоводителят. "
            "Пиши на български, максимум 150 думи.\n\n"
            f"ДАННИ (JSON):\n{json.dumps(pack, ensure_ascii=False)}\n\n"
            "Върни JSON: commentary (текст), checks (1-3 кратки точки)."
        )
        r = httpx.post(f"{OLLAMA_URL}/api/chat", timeout=300, json={
            "model": MODEL, "messages": [{"role": "user", "content": prompt}],
            "format": SCHEMA, "stream": False,
            "options": {"temperature": 0.3, "num_predict": 800},
        })
        r.raise_for_status()
        data = json.loads(r.json()["message"]["content"])
        if any(k not in data for k in SCHEMA["required"]):
            raise ValueError("Моделът не върна всички полета")
        return data
  6. PDF с кирилица и без вмъкване на HTML

    Втората част: HTML шаблон, превръщане в PDF и контролна сума. Три неща заслужават внимание. Екраниране: Environment(autoescape=True) прави така, че ако моделът напише <b> или скрипт, в PDF-а ще се види като текст, не като форматиране. Шрифт: „DejaVu Sans“ има кирилица. Контролна сума: хешът на готовия файл не може да е вътре във файла; той отива в дневника (report_log). Пише „ЧЕРНОВА“ в заглавието, докато човек не одобри.

    python · monthly_close.py (2/3)
    TEMPLATE = """<!DOCTYPE html><html lang="bg"><head><meta charset="utf-8"><style>
    @page { size: A4; margin: 18mm; }
    body { font-family: "DejaVu Sans", sans-serif; font-size: 9.5pt; color: #1c1c1c; }
    h1 { font-size: 15pt; color: #0d1b3e; margin: 0 0 2mm; }
    h2 { font-size: 11pt; color: #0b7ea8; border-bottom: 1px solid #b8cade; margin: 7mm 0 2mm; }
    table { width: 100%; border-collapse: collapse; }
    th { background: #0d1b3e; color: #fff; text-align: left; padding: 1.5mm 2mm; }
    td { padding: 1.2mm 2mm; border-bottom: 1px solid #e3e8ef; }
    .r { text-align: right; } .draft { color: #b00020; font-weight: bold; }
    .box { background: #f6f7fa; border-left: 3px solid #0abfb0; padding: 3mm; }
    .warn { background: #fff4e5; border-left: 3px solid #e8a020; padding: 3mm; }
    </style></head><body>
    <h1>Месечен отчет — {{ label }}</h1>
    <p class="draft">ЧЕРНОВА · не е одобрена · не е правно или данъчно становище</p>
    
    <h2>Приходи и разходи (в евро)</h2>
    <table><tr><th>Позиция</th><th class="r">Месец</th><th class="r">Предх. месец</th><th class="r">От началото на годината</th></tr>
    {% for r in pack.pnl %}<tr><td>{{ r.line }}</td><td class="r">{{ r.month_eur|eur }}</td>
    <td class="r">{{ r.prev_month_eur|eur }}</td><td class="r">{{ r.ytd_eur|eur }}</td></tr>{% endfor %}</table>
    
    <h2>Салда в края на месеца (натрупано, в евро)</h2>
    <table><tr><th>Позиция</th><th class="r">Сума</th></tr>
    {% for r in pack.balance %}<tr><td>{{ r.line }}</td><td class="r">{{ r.balance_eur|eur }}</td></tr>{% endfor %}</table>
    
    <h2>ДДС за месеца — ориентир, не справка-декларация</h2>
    <table><tr><th>Вид</th><th class="r">Основа</th><th class="r">ДДС</th><th class="r">Документи</th></tr>
    {% for r in pack.vat %}<tr><td>{{ "Продажби" if r.vat_side == "sales" else "Покупки" }}</td>
    <td class="r">{{ r.base_eur|eur }}</td><td class="r">{{ r.vat_eur|eur }}</td><td class="r">{{ r.docs }}</td></tr>{% endfor %}
    <tr><td><b>Разлика (продажби − покупки)</b></td><td></td><td class="r"><b>{{ vat_diff|eur }}</b></td><td></td></tr></table>
    
    {% if pack.unmapped %}<div class="warn"><b>Сметки без съответствие в таблицата:</b>
    {% for u in pack.unmapped %}{{ u.account_code }} ({{ u.net_eur|eur }}){% if not loop.last %}, {% endif %}{% endfor %}.
    Те не са включени в таблиците по-горе.</div>{% endif %}
    
    <h2>Коментар (чернова от локален модел)</h2>
    <div class="box">{{ text.commentary }}</div>
    <ul>{% for c in text.checks %}<li>{{ c }}</li>{% endfor %}</ul>
    {% if flags %}<div class="warn"><b>За проверка:</b> в текста има числа, които ги няма в данните: {{ flags }}</div>{% endif %}
    <p style="font-size:8pt;color:#666">Създадено {{ today }}. Контролната сума на файла се пази отделно в дневника на отчетите.</p>
    </body></html>"""
    
    
    def eur(v) -> str:
        return f"{float(v):,.2f}".replace(",", " ")
    
    
    def render_pdf(pack: dict, text: dict, flags: list, period: str) -> Path:
        env = Environment(autoescape=True)          # текстът от модела не може да вкара HTML
        env.filters["eur"] = eur
        vat = {r["vat_side"]: float(r["vat_eur"]) for r in pack["vat"]}
        html = env.from_string(TEMPLATE).render(
            pack=pack, text=text, flags=flags, label=period[:7], today=date.today().strftime("%d.%m.%Y"),
            vat_diff=vat.get("sales", 0.0) - vat.get("purchases", 0.0))
        folder = OUT / period[:7]
        folder.mkdir(parents=True, exist_ok=True)
        path = folder / f"close_{period[:7]}.pdf"
        HTML(string=html).write_pdf(str(path), stylesheets=[CSS(string="@page { size: A4; }")])
        return path
    
    
    def sha256(path: Path) -> str:
        return hashlib.sha256(path.read_bytes()).hexdigest()
  7. Създаване и одобрение

    Третата част. make прави PDF и записва ред със статус „чернова“. approve смята контролната сума наново и отказва, ако файлът е променен след създаването — така „одобрено“ значи „този точно файл“. Одобрението записва кой и кога го е направил. Изпращането си остава ръчно.

    python · monthly_close.py (3/3)
    def cmd_make(conn, period: str) -> None:
        pack = fetch_pack(conn, period)
        text = narrative(pack)
        flags = foreign_numbers(text, pack)
        path = render_pdf(pack, text, flags, period)
        with conn.cursor() as cur:
            cur.execute("INSERT INTO report_log (period, pdf_path, sha256, status) VALUES (%s, %s, %s, 'draft')",
                        (period, str(path), sha256(path)))
        conn.commit()
        print("Чернова:", path, "· контролна сума:", sha256(path)[:16], "…")
        if flags:
            print("ЗА ПРОВЕРКА — числа, които ги няма в данните:", flags)
    
    
    def cmd_approve(conn, period: str, by: str) -> None:
        with conn.cursor() as cur:
            cur.execute("SELECT id, pdf_path, sha256 FROM report_log WHERE period = %s AND status = 'draft' "
                        "ORDER BY id DESC LIMIT 1", (period,))
            row = cur.fetchone()
            if not row:
                raise SystemExit("Няма чернова за този период.")
            log_id, path, digest = row
            if sha256(Path(path)) != digest:
                raise SystemExit("Файлът е променен след създаването — направи отчета наново.")
            cur.execute("UPDATE report_log SET status = 'approved', approved_at = now(), approved_by = %s WHERE id = %s",
                        (by, log_id))
        conn.commit()
        print("Одобрен:", path, "· от:", by, "· изпращането е ръчно.")
    
    
    if __name__ == "__main__":
        ap = argparse.ArgumentParser()
        sub = ap.add_subparsers(dest="cmd", required=True)
        m = sub.add_parser("make");    m.add_argument("--period", required=True, help="първо число, напр. 2026-09-01")
        p = sub.add_parser("approve"); p.add_argument("--period", required=True); p.add_argument("--by", required=True)
        a = ap.parse_args()
        with psycopg2.connect(DSN) as conn:
            cmd_make(conn, a.period) if a.cmd == "make" else cmd_approve(conn, a.period, a.by)
  8. Пусни го

    bash
    export CLOSE_DSN='postgresql://<потребител>:<парола>@localhost/<база>'
    psql "$CLOSE_DSN" -f schema.sql
    psql "$CLOSE_DSN" -f seed.sql
    python monthly_close.py make --period 2026-09-01
    # прочети output/2026-09/close_2026-09.pdf, после:
    python monthly_close.py approve --period 2026-09-01 --by "<име>"

    В PDF-а има таблица за приходите и разходите, салда, ДДС ориентир, предупреждение за несъпоставени сметки, коментарът на модела и — ако има — кутия „За проверка“ с чужди числа. Ако промениш файла и пуснеш approve, командата отказва.

  9. Всеки месец

    Най-простото е cron: на 2-ро число в 08:00 скриптът готви чернова за предходния месец. В crontab знакът за процент се екранира с \%. Колкото по-рано е черновата, толкова повече време остава на счетоводителя преди сроковете (за ДДС вж. бележката в стъпка 4).

    bash · crontab -e
    0 8 2 * * cd <папка-на-проекта> && .venv/bin/python monthly_close.py make --period "$(date -d "$(date +\%Y-\%m-01) -1 month" +\%Y-\%m-01)"

    В n8n има възли Schedule Trigger и Send Email (с операция „Send and Wait for Response“, по документацията), с които можеш да планираш и да искаш отговор по имейл, но ⚠️ не сме ги свързвали с този скрипт.

  10. Човекът и данните

    ✅
    Само обобщени числа към модела
    В заявката към модела отиват само агрегирани суми по позиции — без имена на контрагенти, фактури и документи. Така дори грешка в черновата не разкрива нищо за конкретен човек или фирма.
    💡
    Локално не значи „съответствие с GDPR“
    Локалната обработка помага (данните не напускат машината), но съответствието е друго: основание, срок за съхранение, достъп, договори с обработващи. Журналът съдържа лични данни — реши това със своя юрист или отговорник по защита на данните.

04Проверка

Тест

1. Защо салдата в баланса се смятат от началото на журнала до края на месеца?

2. Защо шаблонът се пуска с autoescape=True?

3. Защо контролната сума е в дневника, а не в самия PDF?

4. Какво става, ако общият дебит не е равен на общия кредит?

05Какво следва

06Източници

  1. Ollama: API /api/chat · структурирани изходи 🔒 локално — полето format със схема.
  2. llama3.3 в Ollama 🔒 локално — 43 GB, изброени езици (без български).
  3. WeasyPrint: първи стъпки и инсталиране · PyPI — версия 70.0, Python 3.10+, Pango 1.44+.
  4. Jinja2: API (автоматично екраниране) · Python: hashlib.
  5. PostgreSQL: SELECT (DISTINCT ON) · агрегатни функции (FILTER).
  6. Закон за данъка върху добавената стойност (консолидиран текст) — за срока и справката-декларация виж и урок 04-129.
  7. n8n: Schedule Trigger · Send Email — по избор.
  8. Jinja2 · httpx · psycopg2-binary — версии към 03.10.2026.