Месечен счетоводен отчет с локален AI: от журнал към PDF
В края на всеки месец счетоводителят събира едни и същи справки: приходи и разходи, салда, ДДС. Тук подготвяме чернова на пакета автоматично: числата смята заявка към базата, локален модел пише само кратък коментар-чернова, програмата прави PDF и го заключва с контролна сума, а човек го одобрява. Нищо не се изпраща само.
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Какво ще научиш
- Как да подредиш журнал и таблица на съответствията, така че една заявка да върне приходите, разходите, салдата и ДДС.
- Защо салдата в баланса са натрупани, а не само „за месеца“, и как да направиш контрол „дебит = кредит“.
- Как да получиш от локален модел коментар по схема и да откриеш числа, които не са от данните.
- Как да направиш PDF с кирилица (WeasyPrint) и защо текстът на модела се екранира.
- Как да пазиш контролна сума във файл и как „одобрение“ да не е само дума.
02Преди да започнеш
- PostgreSQL — например този от урок 04-01 — и Ollama с езиков модел, например
ollama pull llama3.3:70b(по ollama.com — 43 GB; паметта на GB10 е 128 GB и е обща за процесора и видеокартата, така че се събира). По-малък модел — презREPORT_MODEL, при по-слабо качество. - Python 3.10 или по-нов и пакетите
pip install httpx psycopg2-binary jinja2 weasyprint(по PyPI към 03.10.2026: WeasyPrint 70.0, Jinja2 3.1.6, httpx 0.28.1, psycopg2-binary 2.9.13). - WeasyPrint иска Pango (по документацията му — версия 1.44 или по-нова). На Debian и Ubuntu при инсталиране с
pipдокументацията изброява пакетитеlibpango-1.0-0,libpangoft2-1.0-0иlibharfbuzz-subset0. ⚠️ На DGX OS не сме го инсталирали. Нужен е и шрифт с кирилица — в примера е „DejaVu Sans“; провери сfc-list | grep -i dejavu. - Адрес на Ollama: скриптът чете
OLLAMA_URL(по подразбиранеhttp://localhost:11434). В стека от урок 04-01 Ollama няма публикуван порт; за да го викаш от машината, добавиports: ["127.0.0.1:11434:11434"]— само към loopback. - Урокът следва същата подредба като „Месечен отчет с локален AI“ (04-150): числата от базата, текстът — чернова, проверка от код и човек. Ако си го правил, много неща ще са познати.
- Данните в урока са измислени. В истински журнал има имена на контрагенти и други лични данни — към модела отиват само обобщени числа.
03Стъпки
-
Какво ще построим
Какво Кой го прави Защо Приходи и разходи, салда, ДДС Една заявка към PostgreSQL Числата не бива да зависят от настроението на модела Контрол „дебит = кредит“ Код Небалансиран журнал → отчет не се прави Кратък коментар Локален модел — чернова Пише бързо и четимо, но може да греши PDF и контролна сума Код Файлът може да се провери по-късно Одобрение и изпращане Човек Отговорността за отчета е негова Редът е: заявка → контрол → модел → проверка на числата → PDF → запис в дневника → одобрение от човек → ръчно изпращане.
-
Журнал, съответствия и дневник
journalе двустранен журнал: всеки ред е дебит или кредит по сметка. Редовете с ДДС носят ощеvat_side(продажби или покупки), основа и ДДС.account_mapказва коя сметка в коя позиция отива — по префикс: печели най-дългият подходящ. Префиксите по-долу са примерни; истинската таблица попълва счетоводителят по твоя сметкоплан.report_logпази дневника на отчетите.sql · schema.sqlCREATE 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 ); -
Измислени данни
Девет месеца от 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); -
Една заявка — целият пакет
Заявката връща един 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. -
Числата, контролът и коментарът от модела
Първата част на
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 -
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() -
Създаване и одобрение
Третата част.
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) -
Пусни го
bashexport 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, командата отказва. -
Всеки месец
Най-простото е
cron: на 2-ро число в 08:00 скриптът готви чернова за предходния месец. В crontab знакът за процент се екранира с\%. Колкото по-рано е черновата, толкова повече време остава на счетоводителя преди сроковете (за ДДС вж. бележката в стъпка 4).bash · crontab -e0 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“, по документацията), с които можеш да планираш и да искаш отговор по имейл, но ⚠️ не сме ги свързвали с този скрипт.
-
Човекът и данните
✅Само обобщени числа към моделаВ заявката към модела отиват само агрегирани суми по позиции — без имена на контрагенти, фактури и документи. Така дори грешка в черновата не разкрива нищо за конкретен човек или фирма.💡Локално не значи „съответствие с GDPR“Локалната обработка помага (данните не напускат машината), но съответствието е друго: основание, срок за съхранение, достъп, договори с обработващи. Журналът съдържа лични данни — реши това със своя юрист или отговорник по защита на данните.
04Проверка
- Таблиците са създадени, измислените данни са заредени, а
pack.sqlвръща JSON сpnl,balance,vat,unmappedиcontrol. - Сметка без съответствие се вижда в
unmappedи в PDF-а. - Небалансиран журнал (пробвай с ред само с дебит) спира отчета.
- PDF-ът се създава, кирилицата се чете, а текст като „<b>“ от модела се показва като текст.
foreign_numbersхваща умишлено подправено число.approveотказва, ако файлът е променен след създаването.- Човек чете черновата и сам я изпраща; счетоводителят потвърждава всяко число.
Тест
1. Защо салдата в баланса се смятат от началото на журнала до края на месеца?
2. Защо шаблонът се пуска с autoescape=True?
3. Защо контролната сума е в дневника, а не в самия PDF?
4. Какво става, ако общият дебит не е равен на общия кредит?
05Какво следва
06Източници
- Ollama: API /api/chat · структурирани изходи 🔒 локално — полето
formatсъс схема. - llama3.3 в Ollama 🔒 локално — 43 GB, изброени езици (без български).
- WeasyPrint: първи стъпки и инсталиране · PyPI — версия 70.0, Python 3.10+, Pango 1.44+.
- Jinja2: API (автоматично екраниране) · Python: hashlib.
- PostgreSQL: SELECT (DISTINCT ON) · агрегатни функции (FILTER).
- Закон за данъка върху добавената стойност (консолидиран текст) — за срока и справката-декларация виж и урок 04-129.
- n8n: Schedule Trigger · Send Email — по избор.
- Jinja2 · httpx · psycopg2-binary — версии към 03.10.2026.