Commit graph

117 commits

Author SHA1 Message Date
5501fe3094 Tech cards: remove photo without replacement, header spacing, order link
- 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>
2026-09-14 00:19:32 +03:00
3697b4eb89 Add tech cards (kitchen prep instructions + plating photo) section
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>
2026-09-14 00:04:52 +03:00
47db28ccb0 Fix DB restore 500 on old backups, add photo archive restore
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>
2026-09-06 04:25:35 +03:00
73d4add7ac Add one-click cloud backup restore for disaster recovery
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>
2026-09-06 03:40:49 +03:00
9a30d84bf4 Fix estimate-badge checkbox alignment
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>
2026-09-06 03:32:40 +03:00
71210b6bdb Add independent on/off toggles for the "≈" estimate badge
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>
2026-09-06 03:28:05 +03:00
6a0579f24d Allow manually overriding the estimated price for per_100g products
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>
2026-09-06 02:59:44 +03:00
d27cbb9bf0 Fix "directory not found" on first-ever connection test
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>
2026-09-05 23:33:05 +03:00
e773b19508 OAuth Google Drive: use a plain folder path instead of a folder ID
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>
2026-09-05 23:26:38 +03:00
0cea565fd5 Move the shared Drive folder-id field above both auth methods
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>
2026-09-05 23:16:49 +03:00
601411451a Add OAuth auth option for the Google Drive backup remote
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>
2026-09-05 23:10:59 +03:00
375022e739 Add backup_remote.py and wire up the rest of the rclone rework
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>
2026-09-05 22:57:59 +03:00
f53a142cf4 Rework backups to use rclone, configurable entirely in the admin panel
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>
2026-09-05 22:57:23 +03:00
699a73c8a0 Add per-100g product pricing and editable order line prices
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>
2026-09-03 20:33:04 +03:00
5cfcf71154 Fix desktop layout and cart badge position; redirect / to the webapp
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>
2026-08-27 20:32:54 +03:00
7fe8ce9030 Add guest ordering for customers without Telegram
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>
2026-08-27 20:22:31 +03:00
0e09344245 Fix admin product form layout; add long-press product detail modal
- 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.
2026-08-27 17:32:29 +03:00
96737e4f3f Add optional English translation for product weight/volume
Product.weight_en (e.g. "300 г" -> "300 g"), same opt-in-per-item fallback
as name_en/description_en. Editable from the product admin form.
2026-08-27 17:14:36 +03:00
a1bda220df Add optional English translation for the cafe's address
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.
2026-08-27 16:52:27 +03:00
ff232d1a0f Bilingual menu content + finish EN coverage across bot and Mini App
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.
2026-08-27 16:38:10 +03:00
25988b3ef1 Make the collapsible sidebar itself an optional setting
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.
2026-08-27 15:47:58 +03:00
7643f43a43 Animate sidebar group collapse/expand smoothly
max-height transition (0.2s) instead of an instant display:none swap.
Respects prefers-reduced-motion.
2026-08-27 15:38:57 +03:00
2e1c7a8819 Make sidebar nav groups optionally collapsible
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.
2026-08-27 15:29:10 +03:00
52eec01477 Merge pluggable payment-provider layer; add LiqPay alongside Monobank 2026-08-27 13:41:27 +03:00
27f1d5f149 Add pluggable payment-provider layer; implement LiqPay alongside Monobank
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>
2026-08-27 13:38:24 +03:00
399e6a48e9 Merge i18n scaffolding: bilingual bot notifications + cart/checkout 2026-08-27 13:37:00 +03:00
3e8314925c Add i18n scaffolding: bot/notification locale support + bilingual cart/checkout
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>
2026-08-27 13:35:47 +03:00
1ede4b9692 Add opt-in PostgreSQL support alongside default SQLite
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>
2026-08-27 13:31:12 +03:00
b636d0584a Rate-limit /admin/login and alert on insecure default credentials
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.
2026-08-27 13:07:09 +03:00
759a590022 Alert owner on fiscalization failure; close payment-status race condition
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.
2026-08-27 12:49:17 +03:00
f5e46ef648 Fix two audit findings: fiscalization gap in checkout polling, path traversal in supplier import
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.
2026-08-27 00:33:23 +03:00
dcf5a9205a Fix TypeError crashing restock-from-ingredient-page on production
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.
2026-08-26 23:57:01 +03:00
d6f6efb270 Stop stuffing supplier name into stock-movement notes; break out cash/bank totals in statements
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.
2026-08-26 21:20:57 +03:00
662f0dcbc7 Track payment method (cash/bank) on supplier deliveries, surface it in statements
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.
2026-08-26 21:02:56 +03:00
2621c2395d Add downloadable movement history for a single ingredient
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.
2026-08-26 20:53:10 +03:00
6f361bc166 Add downloadable Excel/PDF stock reports (snapshot + movement history)
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.
2026-08-26 20:41:57 +03:00
a3c29095c8 Edit supplier deliveries after the fact; downloadable Excel/PDF statements
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.
2026-08-26 20:17:48 +03:00
1481332d3f Sync inventory restocks with the supplier ledger in both directions
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.
2026-08-26 19:40:35 +03:00
4a5621d912 Link suppliers to inventory ingredients (main supplier per ingredient)
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.
2026-08-26 18:56:47 +03:00
38376a06ca Allow multiple products in one manual supplier delivery entry
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).
2026-08-26 18:43:58 +03:00
c1e3e83d55 Add owner-alerting on unhandled errors, backup integrity checks, and a client-provisioning script
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.
2026-08-25 18:11:15 +03:00
922d9c5172 Make brand identity reusable for redeploying to other clients
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.
2026-08-25 17:54:29 +03:00
23e931d777 Fix duplicate-supplier creation and blind duplicate-ledger on re-import
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.
2026-08-25 17:17:53 +03:00
024f5349ff Remember per-supplier import column mapping; skip duplicate накладна on re-import
- 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.
2026-08-25 17:09:20 +03:00
001509f2b1 Fix production 500 on /admin/suppliers: missing reference column migration
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.
2026-08-25 17:06:30 +03:00
dc01316bed Add SKU/article field and currency-symbol stripping to supplier import
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.
2026-08-25 17:01:01 +03:00
31e78f19ba Recognize 'опт'/wholesale price column headers for price_per_unit 2026-08-25 16:50:11 +03:00
d3c77e98e1 Make supplier import adaptive: .xls/.csv support, multi-sheet, wider field detection
- 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.
2026-08-25 16:44:55 +03:00
674d52fe05 Add supplier management with flexible Excel import
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.
2026-08-25 16:29:50 +03:00
551b0d3afc Exclude cancelled orders from revenue/count stats
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.
2026-08-25 16:10:34 +03:00