- New "Видалити фото (без заміни)" checkbox on the tech card form —
previously the only way to clear a plating photo was uploading a
replacement.
- Fixed missing top margin between the "← До тех карт" back-link and the
product header on the tech card form.
- Order detail page now links each item to its tech card (when the viewer
has tech-card access) so kitchen staff can jump straight to prep
instructions while working an order.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
New admin-only "Тех карти" menu section: one tech card per product with
cooking instructions, cook time, and a plating photo for kitchen staff —
separate from the customer-facing menu (never exposed via the webapp API).
Access follows the same admin/manager-toggle pattern already used for
Suppliers: admins always have full access, managers only if an admin
explicitly flips on "tech_cards_manager_access" in Settings.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Restoring a DB backup taken before a schema change (e.g. an older cloud
backup) left the live app crashing with "no such column" on every request
touching the new column, because restore only copied the file over without
re-running the app's own db.create_all()/migrate() startup sequence. Restore
now re-applies both immediately after the copy, exactly like a fresh deploy
would.
Also fills the other half of the disaster-recovery story the user flagged:
until now only the DB could be restored, never the product photos zipped
alongside it. Adds a local "photo archives" card plus a new
restore-uploads route (same confirm-word + auto-snapshot safety as the DB
restore), and broadens the cloud listing to show both DB and photo backups
so both can be pulled down and restored after redeploying on a new server.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Lets an admin browse the cloud-backed remote (?show_remote=1) and
download any listed backup file back into the local backups/ folder
with one click, reusing the existing local restore/confirm/auto-snapshot
flow instead of duplicating it.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The two checkboxes were placed as direct <label> children of a .row,
but .row's flex-sizing rule (.row > div { flex: 1 }) only targets div
children — so the pair didn't line up the way every other .row-based
field pair in this form does. Wrapped each in its own div to match.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Previously the badge always showed next to the price for any per_100g
product and never next to the weight at all. Adds two per-product
checkboxes (show_estimate_badge_price, default on; show_estimate_badge_weight,
default off) so the admin can turn either one on or off independently —
covers a shashlik-style product where the weight itself is also just an
estimate, not only the price.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Previously the customer-facing estimate for a weight-priced product was
always computed from price-per-100g * reference_weight_g / 100, with no
way to just type a round number directly. Adds Product.estimated_price_override
(nullable) — effective_unit_price prefers it when set, falling back to the
computed estimate otherwise. The admin form's price field auto-fills from
the price/weight inputs until the admin types their own value, at which
point it's protected from being silently recalculated (same "touched"
pattern used for the order-line price override). Weight stays its own
always-editable field regardless of which pricing path is used.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
test_connection() used `lsjson` on the target path, which fails if the
folder hasn't been created yet — true for any brand-new destination, since
rclone only creates it lazily on an actual `copy`. Switched to `mkdir`
(idempotent, no-op if it already exists), which also verifies write
access rather than just read access.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A real OAuth-authorized account has its own "My Drive" with actual
storage, so unlike the service-account path (which has none of its own
and can only write into an explicitly shared-by-ID folder), there's no
need to hunt down a numeric folder ID — a plain path segment works and
rclone creates it automatically on first upload if it doesn't exist yet.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
It only appeared inside the service-account block, so it wasn't visible
when scrolled to the OAuth section — moved to the top of the Google Drive
group since the same field applies to whichever auth method is filled in.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The admin form only supported rclone's service-account auth for Google
Drive. rclone's drive backend also supports OAuth (client_id/client_secret/
scope/token, as produced by running `rclone config`/`rclone authorize` on
your own machine) — added as a second, independent set of fields so
someone who already has OAuth credentials doesn't need to set up a service
account instead. root_folder_id is required for the service-account path
(a service account has no Drive storage of its own) but optional for
OAuth (a real account's own My Drive is fully accessible without one).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Continuation of the previous commit (gdrive_backup.py removal landed
separately by accident) — adds the new module itself, the admin
routes/template using it, generalized Setting keys, updated docs/
provisioning notes, and tests.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The backup system was hard-wired to Google Drive via googleapiclient and a
service account — replaced with rclone (invoked as an external binary via
subprocess, credentials passed as RCLONE_CONFIG_BACKUP_* env vars, never
written to a config file on disk) so the destination isn't locked to one
provider. The admin panel now has a storage-type picker (Google Drive / S3-
compatible / custom) with real form fields for each — no rclone.conf to
paste in, matching the same non-technical-friendly spirit as the previous
Google-only settings form, just generalized.
bober_bbq/utils/gdrive_backup.py -> bober_bbq/utils/backup_remote.py;
Setting keys generalized from gdrive_* to backup_*/backup_remote_* (clean
replacement, no migration needed — confirmed via production that Google
Drive backup was never enabled there). Local backup create/list/download/
delete/restore is untouched. Removes the now-unused google-api-python-
client/google-auth/google-auth-httplib2 dependencies.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Some items (grilled shashlik) can't have a flat price since the cooked
weight varies. Product gains pricing_mode ("fixed"/"per_100g") and
reference_weight_g; Product.effective_unit_price computes an estimated
per-unit charge (price-per-100g * reference_weight_g / 100) that every
cart/order call site now reads instead of raw Product.price, so the
customer sees a ready "≈" estimate everywhere (menu, cart, checkout) with
zero changes to the actual cart/checkout math.
Also adds a general per-line price override to the admin order create/edit
forms (unit_price), so staff can correct the charged amount after weighing
an item — for any product, not just weight-priced ones. This incidentally
fixes an existing bug where editing an order for an unrelated reason (e.g.
a phone number) silently re-snapshotted every line item to today's catalog
price instead of preserving what was actually charged.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The Mini App was designed only ever to render inside Telegram's phone-width
WebView, so it stretched edge-to-edge with no width cap — fine there, but
now that guests without Telegram open it directly in an ordinary desktop
browser (see the guest-ordering feature) it looked broken on wide screens.
Caps .app/.bottom-nav at 560px, centered, matching the width already used
for the product detail modal's sheet.
That same unbounded width was also the root cause of the cart badge
floating away from its icon on wide screens: it was positioned at
`right: 18%` of the whole nav button, which drifts far from the icon once
the button itself grows wide — fixed by anchoring the badge to the icon's
own wrapper instead of a percentage of the button.
Also makes the bare domain (webapp.boberbbq.com) redirect straight into
/webapp/ instead of showing a JSON route dump — guests are now expected to
land there directly.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The Mini App required a valid Telegram WebApp signature for every
cart/checkout/order call, so anyone without Telegram couldn't order at all.
require_customer_auth now falls back to a cookie-bound guest identity
(a fresh negative telegram_id per guest, see allocate_guest_telegram_id)
instead of rejecting the request, reusing the existing webapp bundle with
no new frontend needed. Orders also get a random tracking_token so a guest
who loses their session cookie can still check status via a /track/<token>
link. Staff/admin surfaces (notifications, customer stats, broadcast) are
updated to treat guests the same as the existing walk-in sentinel user.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- product_form.html: weight/weight_en/price were squeezed into one
3-column flex row after weight_en was added, overflowing on the form's
520px width. Split into weight+weight_en (2 columns) and price (own row).
- Mini App: long-press (450ms hold) on a product's thumbnail opens a
bottom-sheet modal with a large photo, full description, weight,
allergens, and its own quantity/add-to-cart controls — for browsing a
dish in more detail than the compact card allows. A quick tap still does
nothing (matches existing behavior), and movement beyond 10px during the
hold (a scroll starting on the thumbnail) cancels it so scrolling past a
product never accidentally opens its modal. A small 🔍 badge on every
thumbnail hints the interaction exists; it grows and the thumbnail dims
slightly while holding, both animated over the same 450ms as the hold
itself for tactile feedback.
Setting["address_en"] (Адмінка → Налаштування → Заклад), opt-in fallback
like Product.name_en — used in place of "address" for English-locale
customers in /api/settings (Mini App contacts + closed screen), the bot's
/contacts command, and the pickup-ready notification's address line.
Untranslated (blank) address_en falls back to the Ukrainian address as before.
Two gaps in the earlier i18n scaffolding: the Mini App's UA/EN switcher
existed but most screens and all menu content stayed hardcoded Ukrainian.
Menu content (business data, not UI chrome):
- Category.name_en, Product.name_en/description_en (nullable, opt-in per
item — untranslated items fall back to Ukrainian rather than showing
blank). Editable from the existing category/product admin forms.
- Allergen labels (a small fixed EU-mandated set) get an English label
directly in ALLERGENS, no DB column needed.
- /api/categories and /api/products take an optional ?locale= param (no
session on these unauthenticated routes to read a stored preference from).
Full EN coverage for everything else:
- Every remaining webapp screen: MenuPage/ProductCard, CartItemRow (fixes a
pre-existing gap in the "already translated" cart screen), OrdersPage,
ContactsPage, ClosedScreen, BottomNav, AnnouncementPopup, plus app-level
status/error text.
- Every reachable bot message: /menu, /contacts (including its
locale-dependent reply-keyboard button, matched by text across all
locales since the label itself varies), and the rating thank-you flow.
- Every customer-facing checkout/order error message (empty cart, closed,
min order amount, payment/promo validation, cancel/pay errors) via
locale params threaded through workhours.closed_message() and
promo.validate_promo_code().
New: PATCH /api/me/locale persists an explicit in-app language choice back
to TelegramUser.locale (fire-and-forget from the webapp switcher), so it
also applies to the bot's own push notifications, not just the Mini App
session that made the choice.
Fixed a real bug caught while testing: product_form.html's allergen
checklist unpacked ALLERGENS values as 2-tuples, which broke (ValueError)
once they became 3-tuples (uk_label, emoji, en_label) — admin's "add
product" page 500'd until this was caught in browser testing.
The collapsible nav groups added earlier were always-on for every admin.
Added Налаштування → Вигляд → "Дозволити згортати розділи бічного меню"
(default on) — when turned off, groups render exactly as before this
feature existed: plain non-interactive labels, everything always visible.
Each group (Замовлення, Меню, Аналітика, Система, ...) is now a clickable
header that collapses/expands its links, remembered per-browser via
localStorage — nothing forced, nothing synced across staff. A group
containing the currently active page always shows expanded on load even
if it was previously collapsed, so navigating there never hides the
active link.
Introduces bober_bbq/payments/base.py as the shared provider contract
(ProviderNotConfigured/ProviderError, the atomic claim_order_paid race
guard, and a documented function-shape convention) and
bober_bbq/payments/registry.py for provider selection/dispatch, so
checkout.py, orders.py, and reconciliation.py no longer hardcode
Monobank. monobank.py's public names/behavior are unchanged -- its
exceptions now subclass the generic ones and claim_order_paid is
re-exported from base.py, but every existing import keeps working.
Adds liqpay.py (Checkout/CNB API: base64(JSON)+sha1 signing, form-encoded
webhook, status polling, a local checkout-redirect bridge page since
LiqPay has no server-side "create invoice" call) and
api/payments_liqpay.py for its webhook + redirect routes. Wires a
"payment_provider" setting (admin-configurable, env fallback) and adds
matching LiqPay admin settings fields/test button.
No live LiqPay credentials were available to test against; verification
is a re-derived payment-race regression test (webhook/poll/reconciliation
racing to mark an order paid, for both providers) plus mocked-HTTP
structural checks. Uncertain LiqPay details (verify_token's error-code
heuristic) are flagged in liqpay.py and docs/PAYMENTS.md, which also
documents adding a third provider (Fondy, not implemented).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Backend: dependency-free t(key, locale) helper (bober_bbq/utils/i18n.py) in
the spirit of Setting.get(), a new TelegramUser.locale column (migrated via
the existing _add_column_if_missing pattern) populated from Telegram's
language_code on first contact, and order-status notification messages
(notify_customer.py) plus the bot's /start welcome translated to en as proof.
Webapp: a ~30-line I18nProvider/useI18n context (webapp/src/i18n/) with a
per-locale translations dict, auto-detecting locale from a saved choice,
Telegram's language_code, or the browser, with a manual UA/EN switcher in
the header. CartPage and CheckoutPage are fully bilingual end-to-end as the
representative slice; the rest of the app is unchanged Ukrainian-only.
Documented in docs/I18N.md: how to add strings/languages, and the explicit
boundary of what's translated today vs. left for later (admin panel is
intentionally untouched).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
SQLite stays the default (fine for a small single-cafe deployment); this
makes Postgres a supported alternative via DATABASE_URL for clients who
outgrow SQLite's single-writer lock (multiple concurrent admin users,
high write volume).
- requirements.txt: add psycopg2-binary (Postgres driver for SQLAlchemy).
- bober_bbq/migrate.py: the admin_users.created_at/last_login_at ALTER
TABLE DDL used the type name DATETIME, which SQLite accepts (it only
has type affinity) but Postgres doesn't recognize as a real type — use
TIMESTAMP instead, valid on both.
- bober_bbq/admin/inventory.py: the ingredient-name duplicate check used
db.func.lower() in SQL, which is ASCII-only on SQLite and silently
fails to match Cyrillic names (same bug already fixed this session in
admin/suppliers.py's supplier-name dedup) — compare in Python instead.
Not a Postgres crash risk (Postgres's LOWER() is Unicode-aware), but
worth fixing for consistency since moving to Postgres would otherwise
silently change this check's behavior.
- bober_bbq/utils/gdrive_backup.py and admin/system.py were audited for
SQLite-specific behavior (PRAGMA integrity_check, direct file
copy/restore) but already guard every such path behind
`uri.startswith("sqlite:///")` / `_sqlite_path()` checks that
gracefully no-op with a clear message on Postgres — no changes needed.
- scripts/migrate_sqlite_to_postgres.py (new): standalone, manually-run,
one-off tool for actually moving a specific client's data off SQLite.
Reuses the app's own SQLAlchemy models (db.metadata) rather than raw
table introspection, copies rows in FK-safe order, refuses to run
against a non-empty target unless --truncate is passed, and resets
Postgres auto-increment sequences after copying explicit ids. Not
wired into run_web.py or any automatic startup path.
- docs/DEPLOYMENT.md: new section on when/how to use Postgres instead of
SQLite, standing up a Postgres instance, the DATABASE_URL format, and
when/how to run the migration script.
Verified: create_app() + db.create_all() + migrate() + seed() (including
the ALTER-TABLE-ADD-COLUMN code path exercised against a DB missing
those columns) still work cleanly against a fresh temp SQLite DB. The
migration script's row-copy/FK-ordering/truncate/non-empty-guard logic
was verified end-to-end using SQLite as a stand-in target, since no
Postgres server is available in this environment — the Postgres-specific
pieces (actual connection, pg_get_serial_sequence/setval) are
unverified beyond code review.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
No brute-force protection existed on the admin login form. Added an
in-memory per-IP lockout (5 failed attempts / 15 min -> 15 min lockout),
alerting the owner via Telegram when a lockout triggers.
Also warn loudly at startup (log + owner Telegram alert, not a hard
refusal) if SECRET_KEY or the admin password are still the config.py
defaults — previously a skipped .env line would silently run production
with session-forgeable SECRET_KEY or an admin/admin login.
Fiscal errors previously only wrote fiscal_status/fiscal_error and logged,
so an order could sit fiscally unissued indefinitely unless someone opened
its detail page — now routed through the existing error_alerts.notify().
Three independent code paths (Monobank webhook, the Mini App's own
"Перевірити оплату" poll, and the periodic reconciliation job) could all
observe the same "just paid" transition via a read-then-write check and
double up notifications/fiscalization. Added claim_order_paid() as a single
atomic UPDATE ... WHERE payment_status != 'paid', with side effects gated
on whichever caller actually won the transition.
1. GET /api/checkout/payment-status/<id> (the Mini App's "Перевірити
оплату" poll, used before the Monobank webhook arrives) marked an
order paid but skipped everything the webhook and reconciliation
paths do afterward — no fiscal receipt, no staff notification.
Confirmed by cross-checking every other payment_status="paid" call
site in the codebase (api/payments.py, admin/routes.py x3,
utils/reconciliation.py) — all of them call fiscalize_in_background;
this was the only one that didn't. Added the missing staff-chat
notification and fiscalization call, matching the webhook's pattern.
(Skipped the customer Telegram message the webhook also sends — the
customer is already looking at the Mini App screen that just told
them "paid", a duplicate ping there would be noise, not signal.)
2. bober_bbq/utils/supplier_import.py's discard_upload/load_preview/
parse_rows all built a path as `TMP_DIR / token`, where `token` is a
client-submitted hidden form field round-tripped across the
preview/confirm steps — not re-validated as one of save_upload()'s
own uuid4-named files. Path("/some/dir") / "/etc/passwd" evaluates
to "/etc/passwd" (an absolute right-hand side silently discards the
left), so a crafted token let any authenticated suppliers-access
user delete (via discard_upload's error-path cleanup) or, if the
extension happened to match, read the contents of an arbitrary file
on the server. Added _resolve_upload_path() — validates the token
against the exact <uuid4 hex><supported extension> shape and that
the resolved path's parent is still TMP_DIR — used by all three
functions instead of the raw join.
Verified with two smoke tests: 22 checks confirming 6 different attack
payloads (absolute paths both slash styles, relative traversal both
separator styles, a malformed-but-real-extension token, an empty
token) are all rejected without touching a planted victim file, while
a real save_upload()/discard_upload() round trip still works exactly
as before; and 8 checks confirming the checkout endpoint now fiscalizes
and notifies staff on the first paid poll, and does neither again on a
second poll (no double-fiscalization/double-notification). Plus a full
regression rerun of the session's other smoke suites.
Missed this call site when _apply_stock_topup's signature dropped its
unused supplier_name parameter (previous commit) — my grep search was
scoped to suppliers.py only and missed inventory.py's own call in
inventory_adjust's "restock" path. This 500'd in production every time
someone tried to restock directly from an ingredient's own page: the
SupplierDelivery record got created and committed, but the crash
happened before the stock top-up / ingredient-supplier auto-link ever
ran, leaving orphaned delivery rows with no matching stock movement.
Added a smoke test exercising this exact route (POST .../adjust with
reason=restock) so a future signature change here gets caught locally
instead of in production.
Two follow-ups from real usage:
1. Inventory reports were showing "Поставка від {supplier}" in their
Примітка column because _apply_stock_topup always wrote that literal
string as the StockMovement note, regardless of what the admin
actually typed. Now it passes through the delivery's own note (or
nothing) — the supplier is identified properly via a new
"Постачальник" column in both inventory movement reports (all-
ingredients and per-ingredient, Excel + PDF), sourced from the
ingredient's own supplier link rather than free text. Also dropped
the now-unused supplier_name parameter from _apply_stock_topup.
2. A supplier is routinely paid via a mix of cash and bank transfer
across different deliveries — each delivery already carries its own
payment_method, so this needed to show up in aggregate reporting
rather than being requested as a schema change. Both
supplier_statement_rows() and all_suppliers_summary_rows() now
return paid_cash/paid_bank/paid_unspecified alongside the existing
totals, surfaced as extra columns (summary) or a breakdown line
(per-supplier detail) in both Excel and PDF. The two PDFs that grew
more columns (per-supplier statement was already landscape; the
summary now is too) to avoid cramming.
Verified with a 22-check smoke test (note no longer auto-stuffed,
inventory reports carry a proper supplier column, payment totals split
and add back up to the combined total, both statement shapes show the
breakdown) plus a full regression rerun of every smoke suite from this
session — fixed two of them (smoke25) whose old assertions did exact
dict equality against a totals dict that legitimately grew new keys.
New SupplierDelivery.payment_method ("cash"/"bank", optional) — settable
from both the manual multi-add form (one накладна = one payment method,
shared like reference/dates) and the delivery edit form. Shown as a new
column in the deliveries table and on the per-supplier statement.
Also widened the detailed statement (Excel + PDF) with columns it was
missing: Постачальник (repeated per row, not just in the header —
useful once a row is copied out of context), Спосіб оплати, and
Примітка. The PDF version switched to landscape to fit the extra
columns without cramming.
Verified with a 17-check smoke test (payment_method round-trips through
add/edit, invalid values rejected rather than stored raw, new statement
columns present with correct content, landscape PDF still renders)
plus a full regression rerun of every supplier/inventory smoke suite
from this session.
Completes the inventory reporting set: the snapshot (all ingredients,
now) and the movements log (everything, date-range) already existed —
this adds the per-ingredient detail, the direct analog of the
per-supplier statement vs. the all-suppliers summary.
inventory_movements_rows()/render_movements_xlsx()/render_movements_pdf()
gained an optional `ingredient` parameter: when given, the report scopes
to that ingredient only and drops the now-redundant "Інгредієнт" column;
omitted, behavior is byte-for-byte unchanged from before (verified). New
route GET /inventory/<id>/report/movements/<fmt>, with a download block
on the ingredient detail page next to its movement history table.
Verified with a 16-check smoke test (correct per-ingredient scoping,
date-range filtering on top of it, column-shape difference between the
single- and multi-ingredient report, 404 on an unknown id) plus a full
regression rerun of the supplier/inventory smoke suites from this
session.
Mirrors the supplier statements added this session: a current-stock
snapshot (name, supplier, levels, cost, stock value, status) and a
date-range-filterable log of every StockMovement, both in Excel and PDF.
Reuses supplier_reports.py's PDF font-loading plumbing rather than
duplicating it. Download buttons added to the inventory list page.
Verified with a 20-check smoke test (snapshot/movement data correctness,
date-range filtering, real route responses reopened/parsed, missing-font
fallback) plus a full regression rerun of the supplier/inventory smoke
suites from this session.
Two gaps: an unpaid delivery had no way to later be marked paid (or
corrected at all) short of delete-and-recreate, losing history; and
there was no way to hand a supplier or an accountant a statement of
account.
- New GET+POST /suppliers/<id>/deliveries/<id>/edit
(supplier_delivery_edit.html) — edits every field including
paid_amount/payment_date. Deliberately a pure ledger correction: like
the existing delete button already warns, this never touches
current_stock/StockMovement even if quantity or the ingredient link
changes — stock only moves through the ingredient's own actions.
- New bober_bbq/utils/supplier_reports.py generates both a per-supplier
detailed statement and an all-suppliers summary, each in Excel
(openpyxl, already a dependency) and PDF (new: fpdf2), optionally
scoped to a delivery-date range. Download buttons added to the
supplier detail page and the suppliers list.
- PDF needs a real Cyrillic TTF; added fpdf2 to requirements.txt and
documented (and will install) fonts-dejavu-core on the server rather
than bundling a font in the repo — DejaVu is freely embeddable,
Windows fonts are not. A missing font produces a clear flash message,
not a 500.
Verified with a 33-check smoke test across both features (edit
correctness + stock isolation, date-range filtering on both statement
shapes, real route responses reopened/parsed, and the missing-font
fallback path) plus a full regression rerun of every supplier/inventory
smoke suite from this session.
Suppliers→inventory (top up stock from a delivery) already worked, but
the reverse didn't: restocking directly from the ingredient's own page
just moved current_stock via a generic "adjustment" — no supplier
ledger entry, no debt, no traceability of who it came from. Professional
inventory systems (1C, Torgsoft) never let a stock increase appear from
nowhere when the item has a known supplier — it's always a receipt
document against that supplier.
The ingredient page's "Прихід/списання" now offers a real "🚚 Поставка
від <supplier>" operation (only when the ingredient has a main supplier)
alongside the existing adjustment/write-off. Submitting it creates a
SupplierDelivery row (same model, same ledger, same debt math as a
delivery entered from the supplier's own card) and reuses the existing
_apply_stock_topup (bober_bbq/admin/suppliers.py) to move stock — one
shared code path for both directions, not a parallel implementation.
Verified with a 24-check smoke test (no-supplier ingredients keep the
old behavior unchanged, a restock creates the exact right ledger row
and updates the supplier's debt, a bypass attempt without a supplier is
rejected) plus full regression of the prior supplier/inventory suites,
and end-to-end in a live browser: the reason-select JS toggle in both
directions, a real submission, and confirming the delivery appears
correctly on the supplier's own page afterward.
Suppliers and inventory were previously two disconnected subsystems —
the only bridge was a one-off, per-delivery, optional ingredient link
that just topped up stock. Nothing persisted "this ingredient is
normally bought from supplier X", and the restock-request page showed
low-stock quantities with no indication of who to order from.
- Ingredient.supplier_id (nullable FK) — one main supplier per
ingredient; a supplier can naturally be the main one for many
ingredients (many-to-one), just not the reverse.
- Ingredient create/edit forms and the inventory list/detail pages now
show and let the admin set this; the ingredient detail page also
shows the last delivery's price/date, computed on the fly from
SupplierDelivery rather than a duplicated stored field.
- Supplier detail page gained a "Товари цього постачальника" section
(reverse lookup).
- The open restock request page now groups its low-stock items by each
ingredient's main supplier — named suppliers first, unassigned last —
so the admin sees at a glance who to call for what. Pure display
grouping; RestockRequest/RestockRequestItem stay unchanged.
- _apply_stock_topup now auto-adopts the delivering supplier as an
ingredient's main one the first time they're linked (never overwrites
one already set), so normal delivery-entry usage grows these links
without extra manual bookkeeping in the common case.
Found and fixed a real Jinja bug along the way: a dict key named "items"
resolves via dot-access to the builtin dict.items() method instead of
its value — renamed to "rows" in the grouping helper.
Verified with a 20-check smoke test (manual link/edit/clear, auto-assign
on first delivery without overwriting on a later one, grouping logic,
and full page renders via the real Flask test client) plus a full
regression rerun of every other smoke suite from this session.
A real накладна rarely has just one line item — the manual "add
delivery" form previously required one form submission per product.
Now one submission carries a dynamic list of product rows (vanilla-JS
add/remove, no framework) sharing the накладна's common fields
(reference, delivery/payment dates, note); each row keeps its own
name/qty/price/sku/ingredient link. The entered total "Оплачено" splits
across the created rows proportionally to their sum, so per-row debt
tracking (delivery_sum - paid_amount) still adds up correctly without a
schema change.
Verified via a smoke test (single-row backward compatibility, 3-row
submission with proportional payment split, blank-row skipping,
restock-on-ingredient-link) and end-to-end in a live browser session
(add/remove rows, per-row sum-hint scoping, real form submission).
Reliability gaps found while investigating today's suppliers 500 (which I
only learned about because the user reported it — nothing in the app
itself said anything):
- New bober_bbq/utils/error_alerts.py: notifies OWNER_IDS via the existing
Telegram send_message pattern (already used by gdrive_backup/
reconciliation/checkbox_prro) on ANY unhandled request exception, via
Flask's got_request_exception signal — purely observational, doesn't
change the actual error response. Same helper now also wraps every
APScheduler job in run_web.py, closing the one job (reconcile_payments)
that had no failure handling at all and could die silently. A 15-minute
per-(exception type, source) cooldown keeps a repeating failure from
spamming the chat.
- gdrive_backup.py: verifies each local DB backup with PRAGMA
integrity_check before it's ever uploaded (a corrupt copy now fails
loudly instead of silently becoming an unusable "backup"), and now also
archives+uploads static/uploads/ (product photos) alongside the DB —
previously never backed up at all.
Also, per the earlier per-client template-readiness pass: provision_client.sh
automates docs/DEPLOYMENT.md's clone/.env/nginx/TLS/systemd steps for a
NEW client deployment (confirmation prompt before every irreversible
step; refuses to run against an existing install-dir). Not executed
anywhere this session — no target VPS yet, verified via `bash -n` and a
step-by-step review against the runbook it automates.
All of this is additive and was smoke-tested to confirm zero behavior
change for the live instance: same error responses, same backup content,
same scheduler behavior when nothing is actually broken.
Groundwork for reusing this codebase per-client (own VPS/DB/bot per
cafe): the operational config (bot token, DB URL, secrets, Checkbox PRRO
creds) was already .env/Setting-driven, but ~13 spots still hardcoded the
literal string "Bober BBQ" instead of reading the existing cafe_name
Setting, and the webapp's static shell (index.html title, PWA manifest)
had no templating at all since Settings only load after JS boots.
- Route the remaining hardcoded strings through Setting.get("cafe_name",
"Bober BBQ") — same pattern already used correctly in bot/handlers/
start.py. Every fallback stays "Bober BBQ", so production is byte-
identical with no cafe_name override.
- Template webapp/index.html via Vite's native %VITE_CAFE_NAME% HTML
replacement, backed by a committed webapp/.env (default "Bober BBQ",
no secrets) with a per-client override via gitignored .env.local.
- Add webapp/scripts/gen-manifest.mjs to generate manifest.json from a
new manifest.template.json the same way, since Vite doesn't process
public/ assets — wired into the build script.
- Parameterize deploy.sh's systemd unit names and the admin log-viewer's
log paths via optional SERVICE_WEB/SERVICE_BOT/LOG_FILE_WEB/
LOG_FILE_BOT env vars, defaulting to today's literal values.
- Rewrite docs/DEPLOYMENT.md into a repeatable new-client runbook (fixed
an existing /opt/bober-bbq vs /opt/bober-bbq-bot inconsistency, added
a rebranding checklist).
Verified via a smoke test that every changed string still renders
"Bober BBQ" with no overrides present (matching prod's actual .env/DB
state), and that webapp builds both with and without a VITE_CAFE_NAME
override produce the expected output — including a no-override rebuild
confirming byte-identical output to before this change.
Two real bugs found from a live incident: (1) importing without an
explicit supplier_id (the generic "Імпорт" entry point) always created a
new Supplier row, even when one with that exact name already existed —
so re-uploading the same file created a sibling supplier instead of
reusing it. (2) duplicate-накладна detection only compared the reference
field, which is empty for files that don't carry invoice numbers (like
the original sample file), so it never caught anything for those.
Fix: reuse an existing supplier by name (case-insensitive; done in
Python since SQLite's LOWER() isn't Unicode-aware and silently failed to
match Cyrillic), and fall back to a content match (name + quantity + sum
+ date) for duplicate-row detection when there's no reference to compare.
- Supplier.import_mapping stores the last confirmed column mapping (JSON),
applied as a trusted pre-fill on the next import for that supplier —
suppliers whose export tool produces the same layout every time no
longer require re-mapping columns each month.
- Import confirm now skips rows whose № накладної already exists among
that supplier's deliveries, reporting them as duplicates instead of
silently double-importing an accidentally re-uploaded file.
reference was added to the SupplierDelivery model mid-session after the
table already existed in production; db.create_all() only creates new
tables, so the column never got added via ALTER TABLE. Add the missing
migrate.py entry.
Researched Ukrainian накладна/price-list conventions (1C, Torgsoft,
Poster POS) — all key deliveries off an Артикул/SKU code in addition to
product name, since it's stable across renames/typos the name column
doesn't survive. Also strips currency symbols (грн, ₴, UAH) that some
exports leave in numeric cells before parsing.
- Reads .xlsx/.xlsm (openpyxl), legacy .xls (xlrd, with native Excel
date-cell conversion), and .csv (delimiter/encoding auto-detected —
handles ';'/',' delimiters and utf-8/cp1251).
- Multi-sheet workbooks: auto-picks the sheet that looks most like a
data table (scored the same way as header-row detection), with a
sheet picker to override and re-preview without re-uploading.
- Broader Ukrainian+Russian header synonyms per field, plus a per-word
fuzzy fallback so typo'd/unusual headers still get a starting guess
the admin can correct.
- Smarter number parsing (handles both "1.234,56" and "1,234.56"
thousands/decimal conventions).
- New optional "№ накладної/рахунку" field, mappable on import and
enterable on the manual-add form.
Verified against CSV, a deliberately multi-sheet workbook (data on a
non-first sheet with a decoy sheet in front), a synthetic legacy .xls,
and a regression pass confirming the original real file still imports
identically.
New Supplier/SupplierDelivery models track what was delivered, paid,
and owed per supplier. Add/edit/deactivate/delete suppliers manually,
or import an Excel ledger of any layout — the admin maps which column
is which (with an auto-guess, including a fuzzy fallback for typo'd
headers) rather than relying on a fixed format, since real supplier
files vary. Rows with no product name (empty template/totals rows)
are skipped automatically. A delivery can optionally be linked to an
existing Ingredient and top up its stock on entry, opt-in per action.
Admin always has access; managers only if "suppliers_manager_access"
is turned on in Settings — hard delete stays admin-only regardless,
matching the order-delete precedent. Verified end-to-end against a
real supplier ledger file, including its date-format quirk and a
typo'd column header.
Dashboard and Statistics both summed every order in the period
regardless of status, so a cancelled order's total still counted
toward revenue, average check, card/cash split, and top products.
Cancelled orders are now excluded from all of that on both pages —
the dashboard's status donut is the one exception, since showing
cancellations there is its actual purpose.