diff --git a/.env b/.env index be1525d7..e86ec86f 100644 --- a/.env +++ b/.env @@ -20,3 +20,4 @@ JWT_TOKEN_EXPIRE_MINUTES=30 ALEMBIC_LOG_LEVEL=INFO ALEMBIC_SQLALCHEMY_LOG_LEVEL=WARNING +BARKER_URL=http://localhost:9995/ \ No newline at end of file diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 00000000..6c10971d --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,100 @@ +# Brewman + +Brewman is the accounting system of record for the business. Sales originate in Barker, the +point-of-sale system, and are imported here as vouchers so the books carry both the money and +the material story. + +## Language + +### Core books + +**Voucher**: +A single document in the books, carrying financial journals and, for stock vouchers, inventory +lines. Every recorded event is a voucher of exactly one type. +_Avoid_: entry, transaction + +**Journal**: +One financial side of a voucher: an account, a cost centre, a debit or credit, and an amount. +_Avoid_: ledger line, posting + +**Inventory**: +A voucher line recording movement of a stocked item: the item, quantity, rate, tax, and +discount. Distinct from stock-in-general. +_Avoid_: stock line, sale line + +**Cost Centre**: +The department or function a journal or inventory belongs to; the lens through which +consumption is attributed. +_Avoid_: department, branch, location + +**Product**: +A sellable or stockable item; carries versions that change name or units over time. +_Avoid_: item, menu item + +**SKU**: +A sellable unit form of a product, identified by the product together with its unit. +_Avoid_: variant, size + +**Recipe**: +The ingredient breakdown of a product's SKU, used to cost what was sold. +_Avoid_: formula, bill of materials + +**Batch**: +A purchase lot of a SKU: the date it was bought, its rate, tax, discount, and remaining +quantity. +_Avoid_: lot, purchase line + +### Barker sales import + +**Barker**: +The point-of-sale system of record for restaurant sales; the external source of imported +sales data. +_Avoid_: POS, the restaurant app + +**Business Date**: +The operating day a sale belongs to as the POS defines it, which may differ from the calendar +date. +_Avoid_: sale date, calendar date + +**Sale Voucher**: +A read-only voucher recording what the POS sold on one business date at one cost centre, +valued at sale price. +_Avoid_: day sale, sales import voucher + +**Sale Category**: +A Barker grouping of products that decides which cost centre a Sale Voucher credits. +_Avoid_: menu category, product category + +**Production Cost Centre**: +The cost centre that receives the debit side of every Sale Voucher. +_Avoid_: kitchen + +**Product Mapping**: +The standing association between a Barker product/SKU and its brewman counterpart, created +automatically on first sight and served verbatim on every import thereafter; once made, it +is never second-guessed by the import. +_Avoid_: product link, sync table + +**Product Provisioning**: +How the import decides which brewman product and SKU a Barker line refers to (ADR-0004): +the standing Product Mapping first, the product's other SKUs next, and the slugified name +only for a Barker product never mapped before. +_Avoid_: product matching, sync + +**Disposition**: +The outcome of provisioning one Barker line: mapped (served from the standing mapping), +linked (new binding to an existing product), or created (a new product and its first SKU). +_Avoid_: status, result + +**Happy Hour**: +A Barker pricing state in which an item's effective sale price is zero. +_Avoid_: offer, discount period + +**Menu Items**: +The product group to which imported products belong. +_Avoid_: food, kitchen items + +**Import Run**: +One execution of the sales import for a date range; it compares Barker's data against what is +already booked and recreates Sale Vouchers only where the two differ. +_Avoid_: sync, refresh diff --git a/brewman/alembic/versions/51013f52f0fb_add_exclude_active_normalized_name_.py b/brewman/alembic/versions/51013f52f0fb_add_exclude_active_normalized_name_.py new file mode 100644 index 00000000..aa45e87f --- /dev/null +++ b/brewman/alembic/versions/51013f52f0fb_add_exclude_active_normalized_name_.py @@ -0,0 +1,90 @@ +"""Add exclude_active_normalized_name constraint + +Revision ID: 51013f52f0fb +Revises: 9b4f7c2e81a3 +Create Date: 2026-09-08 05:16:51.526677 + +""" + +import sqlalchemy as sa + +from sqlalchemy import column, func, table, text +from sqlalchemy.dialects import postgresql + +from alembic import op + + +# revision identifiers, used by Alembic. +revision = "51013f52f0fb" +down_revision = "9b4f7c2e81a3" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + prod = table( + "product_versions", + column("id", postgresql.UUID(as_uuid=True)), + column("product_id", postgresql.UUID(as_uuid=True)), + column("name", sa.Unicode()), + column("handle", sa.Unicode()), + column("valid_from", sa.Date()), + column("valid_till", sa.Date()), + ) + op.create_exclude_constraint( + "uq_product_versions_name", + "product_versions", + ( + func.btrim( + func.regexp_replace( + func.regexp_replace(func.lower(func.trim(prod.c.name)), r"[^\w\s-]", "", "g"), + r"[\s_-]+", + "-", + "g", + ), + "-", + ), + "=", + ), + (func.daterange(prod.c.valid_from, prod.c.valid_till, text("'[]'")), "&&"), + ) + # op.execute( + # """ + # ALTER TABLE product_versions + # ADD CONSTRAINT exclude_active_normalized_name + # EXCLUDE USING gist ( + # btrim( + # regexp_replace( + # regexp_replace(lower(btrim(name)), '[^\w\s-]', '', 'g'), + # '[\s_-]+', '-', 'g' + # ), + # '-' + # ) WITH =, + # daterange(valid_from, valid_till, '[]') WITH && + # ); + # """ + # ) + op.execute("ALTER TABLE product_versions DROP CONSTRAINT uq_product_versions_name;") + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + prod = table( + "product_versions", + column("id", postgresql.UUID(as_uuid=True)), + column("product_id", postgresql.UUID(as_uuid=True)), + column("name", sa.Unicode()), + column("handle", sa.Unicode()), + column("valid_from", sa.Date()), + column("valid_till", sa.Date()), + ) + op.execute("ALTER TABLE product_versions DROP CONSTRAINT exclude_active_normalized_name;") + op.create_exclude_constraint( + "uq_product_versions_name", + "product_versions", + (prod.c.name, "="), + (func.daterange(prod.c.valid_from, prod.c.valid_till, text("'[]'")), "&&"), + ) + # ### end Alembic commands ### diff --git a/brewman/alembic/versions/c7e1a94b2d30_barker_sales_import.py b/brewman/alembic/versions/c7e1a94b2d30_barker_sales_import.py new file mode 100644 index 00000000..2d5bfbbb --- /dev/null +++ b/brewman/alembic/versions/c7e1a94b2d30_barker_sales_import.py @@ -0,0 +1,107 @@ +"""barker sales import: SALE voucher type, mapping table, Production cost centre + +Revision ID: c7e1a94b2d30 +Revises: 9b4f7c2e81a3 +Create Date: 2026-09-07 + +Adds what the Barker sales import needs: +- native enum values: voucher_type 'SALE', setting_type 'SALE_CATEGORY_COST_CENTRES' + (Postgres cannot drop enum values, so the downgrade leaves them in place) +- barker_products mapping table (ADR-0002) +- inventories.is_happy_hour flag +- fixture cost centre 'Production' +- permission 'Sales Import' granted to the Owner role (the Admin user inherits every role) +""" + +import sqlalchemy as sa + +from alembic import op + + +revision = "c7e1a94b2d30" +down_revision = "ee77296e69b7" +branch_labels = None +depends_on = None + +PERMISSION_ID = "28514cc1-4004-441d-98c5-26f1df917fd2" +ROLE_PERMISSION_ID = "7bbaf067-b174-409b-8223-b3b4a309dfd8" +ROLE_OWNER_ID = "52e08c0c-048a-784f-be10-6e129ad4b5d4" +COST_CENTRE_PRODUCTION_ID = "0715924d-373e-4765-a46e-dd00f7e1aede" + + +def upgrade() -> None: + with op.get_context().autocommit_block(): + op.execute("ALTER TYPE voucher_type ADD VALUE IF NOT EXISTS 'SALE' AFTER 'INCENTIVE'") + op.execute("ALTER TYPE setting_type ADD VALUE IF NOT EXISTS 'MAINTENANCE_MODE' AFTER 'VOUCHER_LOCK'") + op.execute( + "ALTER TYPE setting_type ADD VALUE IF NOT EXISTS 'SALE_CATEGORY_COST_CENTRES' AFTER 'MAINTENANCE_MODE'" + ) + op.create_table( + "barker_products", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("barker_product_id", sa.Uuid(), nullable=False), + sa.Column("barker_sku_id", sa.Uuid(), nullable=False), + sa.Column("brewman_product_id", sa.Uuid(), nullable=False), + sa.Column("brewman_sku_id", sa.Uuid(), nullable=False), + sa.ForeignKeyConstraint( + ["brewman_product_id"], + ["products.id"], + name=op.f("fk_barker_products_brewman_product_id_products"), + ), + sa.ForeignKeyConstraint( + ["brewman_sku_id"], + ["stock_keeping_units.id"], + name=op.f("fk_barker_products_brewman_sku_id_stock_keeping_units"), + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_barker_products")), + sa.UniqueConstraint("barker_sku_id", name="uq_barker_products_barker_sku_id"), + ) + op.create_index( + op.f("ix_barker_products_barker_product_id"), + "barker_products", + ["barker_product_id"], + unique=False, + ) + op.create_index( + op.f("ix_barker_products_brewman_product_id"), + "barker_products", + ["brewman_product_id"], + unique=False, + ) + cost_centres = sa.table( + "cost_centres", + sa.column("id", sa.Uuid), + sa.column("name", sa.Unicode), + sa.column("is_fixture", sa.Boolean), + ) + op.execute(cost_centres.insert().values(id=COST_CENTRE_PRODUCTION_ID, name="Production", is_fixture=True)) + + permissions = sa.table( + "permissions", + sa.column("id", sa.Uuid), + sa.column("name", sa.Unicode), + ) + op.execute(permissions.insert().values(id=PERMISSION_ID, name="Sales Import")) + + role_permissions = sa.table( + "role_permissions", + sa.column("id", sa.Uuid), + sa.column("permission_id", sa.Uuid), + sa.column("role_id", sa.Uuid), + ) + op.execute( + role_permissions.insert().values(id=ROLE_PERMISSION_ID, permission_id=PERMISSION_ID, role_id=ROLE_OWNER_ID) + ) + + +def downgrade() -> None: + op.execute( + sa.text("DELETE FROM role_permissions WHERE permission_id = :permission_id").bindparams( + permission_id=PERMISSION_ID + ) + ) + op.execute(sa.text("DELETE FROM permissions WHERE id = :permission_id").bindparams(permission_id=PERMISSION_ID)) + op.execute(sa.text("DELETE FROM cost_centres WHERE id = :id").bindparams(id=COST_CENTRE_PRODUCTION_ID)) + op.drop_index(op.f("ix_barker_products_brewman_product_id"), table_name="barker_products") + op.drop_index(op.f("ix_barker_products_barker_product_id"), table_name="barker_products") + op.drop_table("barker_products") diff --git a/brewman/brewman/core/config.py b/brewman/brewman/core/config.py index e9434551..51462908 100644 --- a/brewman/brewman/core/config.py +++ b/brewman/brewman/core/config.py @@ -19,6 +19,7 @@ class Settings(BaseSettings): DEBUG: bool = False LOG_LEVEL: str = "NOTSET" SQLALCHEMY_DATABASE_URI: str = "" + BARKER_URL: str = "" @field_validator("PRIVATE_KEY", mode="before") def convert_private_key_newlines(cls, v: str | None) -> str | None: diff --git a/brewman/brewman/db/base.py b/brewman/brewman/db/base.py index 0891b877..9dd6d52a 100644 --- a/brewman/brewman/db/base.py +++ b/brewman/brewman/db/base.py @@ -4,6 +4,7 @@ from ..models.account import Account from ..models.account_base import AccountBase from ..models.account_type import AccountType from ..models.attendance import Attendance +from ..models.barker_product import BarkerProduct from ..models.batch import Batch from ..models.client import Client from ..models.closing_stock import ClosingStock @@ -50,6 +51,7 @@ __all__ = [ "AccountBase", "AccountType", "Attendance", + "BarkerProduct", "Batch", "Client", "ClosingStock", diff --git a/brewman/brewman/main.py b/brewman/brewman/main.py index e45c62b3..e0c6ba7c 100644 --- a/brewman/brewman/main.py +++ b/brewman/brewman/main.py @@ -45,6 +45,7 @@ from .routers import ( rebase, recipe, role, + sales_import, tag, temporal_product, title, @@ -93,6 +94,7 @@ async def sqlalchemy_error_handler(_request: Request, exc: SQLAlchemyError) -> J @app.exception_handler(IntegrityError) async def integrity_error_handler(_request: Request, exc: IntegrityError) -> JSONResponse: + logger.exception("Integrity error", exc_info=exc) # log full details server-side err = constraint_to_friendly(exc) return JSONResponse( status_code=err.status, @@ -162,6 +164,7 @@ app.include_router(incentive.router, prefix="/api/incentive", tags=["vouchers"]) app.include_router(credit_salary.router, prefix="/api/credit-salary", tags=["vouchers"]) app.include_router(voucher.router, prefix="/api", tags=["vouchers"]) app.include_router(transaction_import.router, prefix="/api/transaction-import", tags=["vouchers"]) +app.include_router(sales_import.router, prefix="/api/sales-import", tags=["vouchers"]) app.include_router(lock_information.router, prefix="/api/lock-information", tags=["settings"]) app.include_router(maintenance.router, prefix="/api/maintenance", tags=["settings"]) diff --git a/brewman/brewman/models/barker_product.py b/brewman/brewman/models/barker_product.py new file mode 100644 index 00000000..5cbb635c --- /dev/null +++ b/brewman/brewman/models/barker_product.py @@ -0,0 +1,41 @@ +import uuid + +from sqlalchemy import ForeignKey, UniqueConstraint, Uuid, text +from sqlalchemy.orm import Mapped, mapped_column + +from ..db.base_class import reg + + +@reg.mapped_as_dataclass(unsafe_hash=True) +class BarkerProduct: + """Maps a Barker product/SKU to its brewman counterpart. + + One row per Barker SKU. Created automatically the first time the sales + import sees a Barker SKU (ADR-0002). + """ + + __tablename__ = "barker_products" + __table_args__ = (UniqueConstraint("barker_sku_id", name="uq_barker_products_barker_sku_id"),) + + id: Mapped[uuid.UUID] = mapped_column( + Uuid, primary_key=True, insert_default=uuid.uuid4, server_default=text("gen_random_uuid()") + ) + barker_product_id: Mapped[uuid.UUID] = mapped_column(Uuid, nullable=False, index=True) + barker_sku_id: Mapped[uuid.UUID] = mapped_column(Uuid, nullable=False) + brewman_product_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("products.id"), nullable=False, index=True) + brewman_sku_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("stock_keeping_units.id"), nullable=False) + + def __init__( + self, + barker_product_id: uuid.UUID, + barker_sku_id: uuid.UUID, + brewman_product_id: uuid.UUID, + brewman_sku_id: uuid.UUID, + id_: uuid.UUID | None = None, + ) -> None: + self.barker_product_id = barker_product_id + self.barker_sku_id = barker_sku_id + self.brewman_product_id = brewman_product_id + self.brewman_sku_id = brewman_sku_id + if id_ is not None: + self.id = id_ diff --git a/brewman/brewman/models/cost_centre.py b/brewman/brewman/models/cost_centre.py index 38e6c23d..5a2e637e 100644 --- a/brewman/brewman/models/cost_centre.py +++ b/brewman/brewman/models/cost_centre.py @@ -51,6 +51,10 @@ class CostCentre: def cost_centre_overall(cls) -> uuid.UUID: return uuid.UUID("36f59436-522a-0746-ae94-e0f746bf6c0d") + @classmethod + def cost_centre_production(cls) -> uuid.UUID: + return uuid.UUID("0715924d-373e-4765-a46e-dd00f7e1aede") + @classmethod def overall(cls) -> CostCentreLink: return CostCentreLink(id_=uuid.UUID("36f59436-522a-0746-ae94-e0f746bf6c0d"), name="Overall") diff --git a/brewman/brewman/models/product_version.py b/brewman/brewman/models/product_version.py index d0d60a7c..8377c805 100644 --- a/brewman/brewman/models/product_version.py +++ b/brewman/brewman/models/product_version.py @@ -75,6 +75,22 @@ class ProductVersion: (product_id, "="), (func.daterange(valid_from, valid_till, text("'[]'")), "&&"), ), + postgresql.ExcludeConstraint( + ( + func.btrim( + func.regexp_replace( + func.regexp_replace(func.lower(func.trim(name)), r"[^\w\s-]", "", "g"), + r"[\s_-]+", + "-", + "g", + ), + "-", + ), + "=", + ), + (func.daterange(valid_from, valid_till, text("'[]'")), "&&"), + name="exclude_active_normalized_name", + ), ) def __init__( diff --git a/brewman/brewman/models/setting_type.py b/brewman/brewman/models/setting_type.py index fa51f656..ff34bf1f 100644 --- a/brewman/brewman/models/setting_type.py +++ b/brewman/brewman/models/setting_type.py @@ -4,3 +4,4 @@ import enum class SettingType(enum.IntEnum): VOUCHER_LOCK = 0 MAINTENANCE_MODE = 1 + SALE_CATEGORY_COST_CENTRES = 2 diff --git a/brewman/brewman/models/voucher_type.py b/brewman/brewman/models/voucher_type.py index 9d7fa46f..bf3c358c 100644 --- a/brewman/brewman/models/voucher_type.py +++ b/brewman/brewman/models/voucher_type.py @@ -15,3 +15,4 @@ class VoucherType(enum.IntEnum): CLOSING_BALANCE = 11 EMPLOYEE_BENEFIT = 12 INCENTIVE = 13 + SALE = 14 diff --git a/brewman/brewman/routers/calculate_prices.py b/brewman/brewman/routers/calculate_prices.py index 47ca6f57..945e86aa 100644 --- a/brewman/brewman/routers/calculate_prices.py +++ b/brewman/brewman/routers/calculate_prices.py @@ -31,7 +31,7 @@ def calculate_prices(period_id: uuid.UUID, db: Session) -> None: left = ingredients - recipes - issued_products.keys() purchased_products = get_purchase_prices(item, left, db) left -= purchased_products.keys() - rest = get_rest(left, db) + rest = get_rest(left, item, db) prices = issued_products | purchased_products | rest while len(recipes) > 0: @@ -57,9 +57,9 @@ def get_issue_prices(period: Period, products: set[uuid.UUID], db: Session) -> d .select_from(Inventory) .join(Inventory.batch) .join(Batch.sku) - .join(SkuVersion, onclause=_sv_onclause(Voucher.date_)) .join(Inventory.voucher) .join(Voucher.journals) + .join(SkuVersion, onclause=_sv_onclause(Voucher.date_)) .where( Voucher.date_ >= period.valid_from, Voucher.date_ <= period.valid_till, @@ -88,9 +88,9 @@ def get_purchase_prices(period: Period, req: set[uuid.UUID], db: Session) -> dic .select_from(Inventory) .join(Inventory.batch) .join(Batch.sku) - .join(SkuVersion, onclause=_sv_onclause(Voucher.date_)) .join(Inventory.voucher) .join(Voucher.journals) + .join(SkuVersion, onclause=_sv_onclause(Voucher.date_)) .where( Voucher.date_ >= period.valid_from, Voucher.date_ <= period.valid_till, @@ -104,7 +104,7 @@ def get_purchase_prices(period: Period, req: set[uuid.UUID], db: Session) -> dic return d -def get_rest(req: set[uuid.UUID], db: Session) -> dict[uuid.UUID, Decimal]: +def get_rest(req: set[uuid.UUID], period: Period, db: Session) -> dict[uuid.UUID, Decimal]: rest_price = (SkuVersion.cost_price / (SkuVersion.fraction * SkuVersion.product_yield)).label("rest_price") d: dict[uuid.UUID, Decimal] = {} @@ -113,7 +113,7 @@ def get_rest(req: set[uuid.UUID], db: Session) -> dict[uuid.UUID, Decimal]: StockKeepingUnit.product_id, rest_price, ) - .join(SkuVersion, onclause=_sv_onclause(Voucher.date_)) + .join(SkuVersion, onclause=_sv_onclause(period.valid_till)) .where(StockKeepingUnit.product_id.in_(req)) ).all() for id, amount in query: @@ -135,4 +135,6 @@ def calculate_recipes(recipes: set[uuid.UUID], prices: dict[uuid.UUID, Decimal], item.recipe_yield * item.sku.versions[-1].fraction ) prices[item.sku.product_id] = cost - recipes.remove(item.sku.product_id) + # A product can have several dated recipes; discard (not remove) so + # the second recipe for the same product does not raise KeyError. + recipes.discard(item.sku.product_id) diff --git a/brewman/brewman/routers/recipe.py b/brewman/brewman/routers/recipe.py index 96c57d66..b6a633a0 100644 --- a/brewman/brewman/routers/recipe.py +++ b/brewman/brewman/routers/recipe.py @@ -1,23 +1,25 @@ import base64 +import json import uuid from collections import defaultdict -from datetime import date +from datetime import date, datetime from decimal import Decimal from io import BytesIO from typing import Annotated, cast -from fastapi import APIRouter, HTTPException, Security, status +from fastapi import APIRouter, File, Form, HTTPException, Security, UploadFile, status from fastapi.responses import StreamingResponse from openpyxl import Workbook from openpyxl.styles import Alignment, Border, Font, NamedStyle, PatternFill, Side from openpyxl.worksheet.worksheet import Worksheet -from sqlalchemy import and_, delete, func, or_, select -from sqlalchemy.orm import Session, aliased, contains_eager, joinedload +from sqlalchemy import delete, func, select +from sqlalchemy.orm import Session, contains_eager, joinedload from ..core.security import get_current_active_user as get_user from ..db.session import SessionDep from ..models.db_image import DbImage +from ..models.period import Period from ..models.price import Price from ..models.product import Product from ..models.product_version import ProductVersion @@ -32,15 +34,33 @@ from ..routers.calculate_prices import calculate_prices from ..schemas import recipe as recipeschemas from ..schemas.nutritional_information import NutritionalInformation from ..schemas.product import ProductLink +from ..schemas.recipe_import import RecipeImportPreview, RecipeImportResult from ..schemas.recipe_item import RecipeItem as RecipeItemSchema from ..schemas.recipe_photo import RecipePhoto as RecipePhotoSchema from ..schemas.recipe_step import RecipeStep as RecipeStepSchema from ..schemas.user import UserToken -from . import _pv_onclause, _sv_onclause +from ..services.recipe_import import ImportOptions, execute_import, preview_import +from ..services.recipe_xlsx import ( + ExportBlock, + ExportData, + ExportIngredient, + ExportRateRow, + ExportRateSection, + ExportSummaryItem, + _format_number, + build_recipe_workbook, + normalize_name, + parse_recipe_workbook, +) +from . import _pv_active, _pv_onclause, _sv_active, _sv_onclause router = APIRouter() +SEMI_GROUP_NAME = "Semi" +MENU_GROUP_NAME = "Menu Items" +ADDON_SECTION = "Addon Recipe" + def _strip_data_url(value: str) -> str: v = value.strip() @@ -105,7 +125,7 @@ def _photo_out(image_id: uuid.UUID, caption: str, order_index: int) -> RecipePho def _recipe_blank() -> recipeschemas.RecipeBlank: - payload = { + payload: dict[str, object] = { "sku": None, "date_": date.today(), "source": "", @@ -181,7 +201,7 @@ def recipe_info(item: Recipe) -> recipeschemas.Recipe: total_minutes = sum((s.duration_minutes or 0) for s in steps_out) if steps_out else None - payload = { + payload: dict[str, object] = { "id_": item.id, "sku": ProductLink(id_=item.sku.id, name=item.sku.product.versions[0].name), "date_": item.date_, @@ -474,6 +494,11 @@ def delete_recipe( return None +# --------------------------------------------------------------------------- +# Workbook export (recipe.xlsx layout) +# --------------------------------------------------------------------------- + + @router.get("/xlsx", response_class=StreamingResponse) def get_report( p: uuid.UUID, @@ -482,68 +507,18 @@ def get_report( ) -> StreamingResponse: calculate_prices(p, db) db.commit() - pq = ( db.execute(select(Price).where(Price.period_id == p).options(joinedload(Price.product, innerjoin=True))) .unique() .scalars() .all() ) - prices: list[tuple[str, str, Decimal]] = [ - (i.product.versions[-1].name, i.product.versions[-1].fraction_units, i.price) for i in pq - ] - + prices: dict[uuid.UUID, Decimal] = {i.product_id: i.price for i in pq} db.rollback() - RecipeProductVersion = aliased(ProductVersion, name="recipe_product_version") - ItemProductVersion = aliased(ProductVersion, name="item_product_version") - CurrentSkuVersion = aliased(SkuVersion, name="current_sku_version") - - sku_version_onclause = and_( - CurrentSkuVersion.sku_id == StockKeepingUnit.id, - or_(CurrentSkuVersion.valid_from == None, CurrentSkuVersion.valid_from <= Recipe.date_), # noqa: E711 - or_(CurrentSkuVersion.valid_till == None, CurrentSkuVersion.valid_till >= Recipe.date_), # noqa: E711 - ) - - recipe_product_version_onclause = and_( - RecipeProductVersion.product_id == StockKeepingUnit.product_id, - or_(RecipeProductVersion.valid_from == None, RecipeProductVersion.valid_from <= Recipe.date_), # noqa: E711 - or_(RecipeProductVersion.valid_till == None, RecipeProductVersion.valid_till >= Recipe.date_), # noqa: E711 - ) - - item_product_version_onclause = and_( - ItemProductVersion.product_id == RecipeItem.product_id, - or_(ItemProductVersion.valid_from == None, ItemProductVersion.valid_from <= Recipe.date_), # noqa: E711 - or_(ItemProductVersion.valid_till == None, ItemProductVersion.valid_till >= Recipe.date_), # noqa: E711 - ) - - q = ( - select(Recipe) - .join(Recipe.items) - .join(RecipeItem.product) - .join(ItemProductVersion, onclause=item_product_version_onclause) - .join(Recipe.sku) - .join(CurrentSkuVersion, onclause=sku_version_onclause) - .join(StockKeepingUnit.product) - .join(RecipeProductVersion, onclause=recipe_product_version_onclause) - .join(RecipeProductVersion.product_group) - .options( - contains_eager(Recipe.items) - .contains_eager(RecipeItem.product) - .contains_eager(Product.versions, alias=ItemProductVersion), - contains_eager(Recipe.sku) - .contains_eager(StockKeepingUnit.product) - .contains_eager(Product.versions, alias=RecipeProductVersion) - .contains_eager(RecipeProductVersion.product_group), - contains_eager(Recipe.sku).contains_eager(StockKeepingUnit.versions, alias=CurrentSkuVersion), - ) - ) - if pg is not None: - q = q.where(RecipeProductVersion.product_group_id == pg) - - list_: list[Recipe] = list(db.execute(q).unique().scalars().all()) - - xls = excel(prices, sorted(list_, key=lambda r: r.sku.product.versions[0].name)) + period = db.execute(select(Period).where(Period.id == p)).scalar_one() + data = export_data(db, period.valid_till or date.today(), prices, pg) + xls = build_recipe_workbook(data) xls.seek(0) headers = {"Content-Disposition": "attachment; filename=recipes.xlsx"} return StreamingResponse( @@ -553,60 +528,260 @@ def get_report( ) -def excel(prices: list[tuple[str, str, Decimal]], recipes: list[Recipe]) -> BytesIO: - wb = Workbook() - ws0 = wb.active - assert ws0 is not None - ws0.title = "Rate List" - ws0.cell(row=1, column=1, value="Name") - ws0.cell(row=1, column=2, value="Units") - ws0.cell(row=1, column=3, value="Rate") - for i, p in enumerate(prices, start=2): - ws0.cell(row=i, column=1, value=p[0]) - ws0.cell(row=i, column=2, value=p[1]) - ws0.cell(row=i, column=3, value=float(p[2])) +def _load_active_product_versions( + db: Session, product_ids: set[uuid.UUID], date_: date +) -> dict[uuid.UUID, ProductVersion]: + """The product version active on the date, per product.""" + if not product_ids: + return {} + versions = ( + db.execute( + select(ProductVersion) + .where(ProductVersion.product_id.in_(product_ids), _pv_active(date_)) + .options(joinedload(ProductVersion.product_group)) + ) + .unique() + .scalars() + .all() + ) + return {v.product_id: v for v in versions} - pgs = {x.sku.product.versions[0].product_group.name for x in recipes} - for pg in pgs: - if pg not in wb.sheetnames: - wb.create_sheet(pg) - rows: defaultdict[str, int] = defaultdict(lambda: 1) - register_styles(wb) +def _load_active_sku_versions(db: Session, sku_ids: set[uuid.UUID], date_: date) -> dict[uuid.UUID, SkuVersion]: + """The sku version active on the date, per sku.""" + if not sku_ids: + return {} + versions = db.execute(select(SkuVersion).where(SkuVersion.sku_id.in_(sku_ids), _sv_active(date_))).scalars().all() + return {v.sku_id: v for v in versions} + +def export_data(db: Session, date_: date, prices: dict[uuid.UUID, Decimal], pg: uuid.UUID | None) -> ExportData: + """Collect everything the recipe workbook needs from the database. + + Product and sku versions are filtered to those active on ``date_`` by the + database; recipes without an active version pair are skipped. + """ + recipes = ( + db.execute( + select(Recipe).options( + joinedload(Recipe.sku).joinedload(StockKeepingUnit.product), + joinedload(Recipe.items).joinedload(RecipeItem.product), + joinedload(Recipe.tags), + ) + ) + .unique() + .scalars() + .all() + ) + + product_ids: set[uuid.UUID] = set() + sku_ids: set[uuid.UUID] = set() for recipe in recipes: - ws = cast(Worksheet, wb[recipe.sku.product.versions[0].product_group.name]) - row = rows[recipe.sku.product.versions[0].product_group.name] - ings = len(recipe.items) - ing_from = row + 2 - ing_till = ing_from + ings - 1 + product_ids.add(recipe.sku.product_id) + sku_ids.add(recipe.sku_id) + product_ids.update(item.product_id for item in recipe.items) + product_versions = _load_active_product_versions(db, product_ids, date_) + sku_versions = _load_active_sku_versions(db, sku_ids, date_) - ws.cell(row=row, column=1, value=recipe.sku.product.versions[0].name).style = "recipe_name" - ws.cell(row=row, column=2, value=recipe.sku.versions[0].units).style = "recipe_unit" - ws.cell(row=row, column=3, value=float(recipe.recipe_yield)).style = "recipe_name" - ws.cell(row=row, column=4).style = "recipe_name" - ws.cell(row=row, column=5, value=f"=SUM(E{ing_from}:E{ing_till})").style = "recipe_name" + semi_blocks: list[ExportBlock] = [] + final_blocks: list[ExportBlock] = [] + semi_yields: dict[str, Decimal] = {} + rate_products: set[uuid.UUID] = set() + summary_candidates: list[tuple[str, str, Decimal | None]] = [] - row += 1 - ws.cell(row=row, column=1, value="Ingredients").style = "header" - ws.cell(row=row, column=2, value="Unit").style = "header" - ws.cell(row=row, column=3, value="Qty").style = "header" - ws.cell(row=row, column=4, value="Rate").style = "header" - ws.cell(row=row, column=5, value="Amount").style = "header" + rows: list[tuple[str, Recipe, ProductVersion, SkuVersion | None]] = [] + for recipe in recipes: + product_version = product_versions.get(recipe.sku.product_id) + if product_version is None: + continue + rows.append((product_version.name.lower(), recipe, product_version, sku_versions.get(recipe.sku_id))) + rows.sort(key=lambda row: row[0]) + for _, recipe, product_version, sku_version in rows: + rate_products.add(recipe.sku.product_id) + is_semi = product_version.product_group.name == SEMI_GROUP_NAME + # Every block carries its yield in the SKU's units, including one + # portion ('Yeild = 1 Por'): the unit tells a strict re-import which + # SKU the recipe belongs to, so round-trips never need a fallback. + yield_text = ( + f"Yeild = {_format_number(recipe.recipe_yield)} {sku_version.units}" if sku_version is not None else None + ) + ingredient_rows: list[ExportIngredient] = [] + for i in recipe.items: + item_version = product_versions.get(i.product_id) + ingredient_rows.append( + ExportIngredient( + name=item_version.name if item_version is not None else "", + unit=item_version.fraction_units if item_version is not None else "", + quantity=i.quantity, + ) + ) + block = ExportBlock( + name=product_version.name, + yield_text=yield_text, + ingredients=ingredient_rows, + ) for item in recipe.items: - row += 1 - ws.cell(row=row, column=1, value=item.product.versions[0].name).style = "ing" - ws.cell(row=row, column=2, value=item.product.versions[0].fraction_units).style = "unit" - ws.cell(row=row, column=3, value=float(item.quantity)).style = "ing" - ws.cell(row=row, column=4, value="=VLOOKUP(A:A,'Rate List'!A:C,3,0)").style = "ing" - ws.cell(row=row, column=5, value=f"=C{row}*D{row}").style = "ing" + rate_products.add(item.product_id) - rows[recipe.sku.product.versions[0].product_group.name] = row + 1 + if is_semi and sku_version is not None: + semi_blocks.append(block) + # The Rate List fraction column holds the batch size in fraction + # units so that G = (E / D) / F is the rate per fraction unit. + semi_yields[normalize_name(product_version.name)] = _yield_in_fraction_units( + recipe.recipe_yield, sku_version.units + ) + elif not is_semi: + if pg is not None and product_version.product_group_id != pg: + continue + final_blocks.append(block) + section = ADDON_SECTION + tag_names = sorted(t.name for t in recipe.tags) + if tag_names: + section = tag_names[0] + summary_candidates.append((section, product_version.name, sku_version.sale_price if sku_version else None)) - virtual_workbook = BytesIO() - wb.save(virtual_workbook) - return virtual_workbook + rate_sections = _rate_sections(db, date_, prices, rate_products, semi_yields) + summary_sections = _summary_sections(summary_candidates) + + return ExportData( + rate_sections=rate_sections, + semi_blocks=semi_blocks, + final_blocks=final_blocks, + summary_sections=summary_sections, + ) + + +def _yield_in_fraction_units(recipe_yield: Decimal, units: str) -> Decimal: + if units.strip().lower() in ("kg", "ltr", "l", "litre", "liter"): + return recipe_yield * Decimal("1000") + return recipe_yield + + +def _rate_sections( + db: Session, + date_: date, + prices: dict[uuid.UUID, Decimal], + rate_products: set[uuid.UUID], + semi_yields: dict[str, Decimal], +) -> list[ExportRateSection]: + product_versions = _load_active_product_versions(db, rate_products, date_) + skus = ( + db.execute(select(StockKeepingUnit).where(StockKeepingUnit.product_id.in_(rate_products))).scalars().all() + if rate_products + else [] + ) + sku_by_product: dict[uuid.UUID, uuid.UUID] = {} + for sku in skus: + sku_by_product.setdefault(sku.product_id, sku.id) + sku_versions = _load_active_sku_versions(db, set(sku_by_product.values()), date_) + + rows: dict[str, list[ExportRateRow]] = {} + for product_id in rate_products: + version = product_versions.get(product_id) + if version is None: + continue + sku_version = sku_versions.get(sku_id) if (sku_id := sku_by_product.get(product_id)) is not None else None + is_semi = normalize_name(version.name) in semi_yields + if is_semi: + # Rate and yield qty are pulled live from the semi recipe sheet; + # D holds the batch size in fraction units so that + # G = (E / D) / F is the rate per fraction unit. + fraction = semi_yields[normalize_name(version.name)] + rate = None + from_sheet = True + else: + fraction = sku_version.fraction if sku_version else Decimal("1") + rate = prices.get(product_id, Decimal("0")) + from_sheet = False + row = ExportRateRow( + name=version.name, + unit=version.fraction_units, + fraction=fraction, + yield_pct=sku_version.product_yield if sku_version else Decimal("1"), + rate=rate, + from_recipe_sheet=from_sheet, + ) + rows.setdefault(version.product_group.name, []).append(row) + return [ + ExportRateSection(name=name, rows=sorted(rows[name], key=lambda r: r.name.lower())) + for name in sorted(rows, key=lambda n: (n != SEMI_GROUP_NAME, n.lower())) + ] + + +def _summary_sections( + candidates: list[tuple[str, str, Decimal | None]], +) -> list[tuple[str, list[ExportSummaryItem]]]: + grouped: dict[str, list[ExportSummaryItem]] = defaultdict(list) + for section, name, sale_price in candidates: + grouped[section].append(ExportSummaryItem(name=name, menu_price=sale_price)) + return [ + (name, sorted(grouped[name], key=lambda i: i.name.lower())) + for name in sorted(grouped, key=lambda n: (n == ADDON_SECTION, n.lower())) + ] + + +# --------------------------------------------------------------------------- +# Workbook import +# --------------------------------------------------------------------------- + + +def _parse_import_date(value: str) -> date: + for fmt in ("%d-%b-%Y", "%Y-%m-%d"): + try: + return datetime.strptime(value.strip(), fmt).date() + except ValueError: + continue + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Invalid date; use DD-MMM-YYYY or YYYY-MM-DD", + ) + + +def _import_options(date_: date, create: str, sale_prices: bool, master_data: bool, tags: bool) -> ImportOptions: + try: + names = json.loads(create) if create else [] + except json.JSONDecodeError as e: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Invalid create list") from e + return ImportOptions( + date_=date_, + create_names={str(n) for n in names}, + apply_sale_prices=sale_prices, + apply_master_data=master_data, + apply_tags=tags, + ) + + +@router.post("/import/preview", response_model=RecipeImportPreview) +def import_preview( + user: Annotated[UserToken, Security(get_user, scopes=["recipes"])], + db: SessionDep, + file: Annotated[UploadFile, File()], + date_: Annotated[str, Form()], +) -> RecipeImportPreview: + _ = user + data = file.file.read() + parsed = parse_recipe_workbook(data) + options = _import_options(_parse_import_date(date_), "[]", True, True, True) + return preview_import(db, parsed, options) + + +@router.post("/import/execute", response_model=RecipeImportResult) +def import_execute( + user: Annotated[UserToken, Security(get_user, scopes=["recipes"])], + db: SessionDep, + file: Annotated[UploadFile, File()], + date_: Annotated[str, Form()], + create: Annotated[str, Form()] = "[]", + sale_prices: Annotated[bool, Form()] = True, + master_data: Annotated[bool, Form()] = True, + tags: Annotated[bool, Form()] = True, +) -> RecipeImportResult: + _ = user + data = file.file.read() + parsed = parse_recipe_workbook(data) + options = _import_options(_parse_import_date(date_), create, sale_prices, master_data, tags) + return execute_import(db, parsed, options) @router.get("/nutrition", response_class=StreamingResponse) @@ -631,8 +806,7 @@ def get_nutrition( def nutrition_excel(products: list[NutritionalInformation]) -> BytesIO: wb = Workbook() - ws0 = wb.active - assert ws0 is not None + ws0 = cast(Worksheet, wb.active) ws0.title = "Ingredients" pgs = {x.product_group for x in products} diff --git a/brewman/brewman/routers/sales_import.py b/brewman/brewman/routers/sales_import.py new file mode 100644 index 00000000..98549b51 --- /dev/null +++ b/brewman/brewman/routers/sales_import.py @@ -0,0 +1,66 @@ +import uuid + +from typing import Annotated + +from fastapi import APIRouter, HTTPException, Security, status +from sqlalchemy import select + +from ..core.security import get_current_active_user as get_user +from ..db.session import SessionDep +from ..models.voucher import Voucher +from ..models.voucher_type import VoucherType +from ..schemas import sales_import as schema +from ..schemas import voucher as output +from ..schemas.cost_centre import CostCentreLink +from ..schemas.user import UserToken +from ..services import sales_import as service +from .voucher import voucher_info + + +router = APIRouter() + + +@router.post("/preview", response_model=schema.PreviewResponse) +def preview_route( + request: schema.ImportRequest, + user: Annotated[UserToken, Security(get_user, scopes=["sales-import"])], + db: SessionDep, +) -> schema.PreviewResponse: + return service.preview(db, request) + + +@router.post("/execute", response_model=schema.ExecuteResponse) +def execute_route( + request: schema.ImportRequest, + user: Annotated[UserToken, Security(get_user, scopes=["sales-import"])], + db: SessionDep, +) -> schema.ExecuteResponse: + return service.execute(db, request, user.id_) + + +@router.put("/mapping", response_model=list[schema.MappingEntry]) +def save_mapping_route( + mapping: schema.MappingUpdate, + user: Annotated[UserToken, Security(get_user, scopes=["sales-import"])], + db: SessionDep, +) -> list[schema.MappingEntry]: + service.save_mapping(db, mapping) + return [ + schema.MappingEntry( + sale_category_id=m.sale_category_id, + cost_centre=CostCentreLink(id_=m.cost_centre_id) if m.cost_centre_id is not None else None, + ) + for m in mapping.mapping + ] + + +@router.get("/voucher/{id_}", response_model=output.Voucher) +def get_sale_voucher( + id_: uuid.UUID, + user: Annotated[UserToken, Security(get_user)], + db: SessionDep, +) -> output.Voucher: + voucher = db.execute(select(Voucher).where(Voucher.id == id_)).scalar_one_or_none() + if voucher is None or voucher.voucher_type != VoucherType.SALE: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Sale voucher not found") + return voucher_info(voucher, db) diff --git a/brewman/brewman/routers/voucher.py b/brewman/brewman/routers/voucher.py index aefe63b0..faab2311 100644 --- a/brewman/brewman/routers/voucher.py +++ b/brewman/brewman/routers/voucher.py @@ -94,6 +94,11 @@ def check_delete_permissions(voucher: Voucher, user: UserToken) -> None: @router.delete("/delete/{id_}") def delete_voucher(id_: uuid.UUID, user: Annotated[UserToken, Security(get_user)], db: SessionDep) -> output.Voucher: voucher: Voucher = db.execute(select(Voucher).where(Voucher.id == id_)).scalar_one() + if voucher.voucher_type == VoucherType.SALE: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Sale vouchers are imported from Barker and cannot be deleted; re-import instead", + ) images = db.execute(select(DbImage).where(DbImage.resource_id == voucher.id)).scalars().all() check_delete_permissions(voucher, user) account_types: Sequence[int] = ( @@ -215,6 +220,11 @@ def voucher_info(voucher: Voucher, db: Session) -> output.Voucher: json_voucher.source = CostCentreLink(id_=item.cost_centre_id, name="") item = [j for j in voucher.journals if j.debit == 1][0] json_voucher.destination = CostCentreLink(id_=item.cost_centre_id, name="") + elif voucher.voucher_type == VoucherType.SALE: + item = [j for j in voucher.journals if j.debit == -1][0] + json_voucher.source = CostCentreLink(id_=item.cost_centre_id, name="") + item = [j for j in voucher.journals if j.debit == 1][0] + json_voucher.destination = CostCentreLink(id_=item.cost_centre_id, name="") if voucher.voucher_type == VoucherType.PURCHASE_RETURN: item = [j for j in voucher.journals if j.debit == 1][0] json_voucher.vendor = AccountLink(id_=item.account.id, name=item.account.name) @@ -289,6 +299,7 @@ def voucher_info(voucher: Voucher, db: Session) -> output.Voucher: tax=inventory.tax, discount=inventory.discount, amount=inventory.amount, + # is_happy_hour=inventory.is_happy_hour, batch=BatchSchema( id_=inventory.batch.id, name=text, @@ -433,6 +444,11 @@ def incentive_employees(date_: date, db: Session) -> tuple[list[IncentiveSchema] def check_voucher_edit_allowed(voucher: Voucher, user: UserToken) -> None: + if voucher.voucher_type == VoucherType.SALE: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Sale vouchers are imported from Barker and cannot be edited; re-import instead", + ) if voucher.posted and "edit-posted-vouchers" not in user.permissions: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, diff --git a/brewman/brewman/schemas/inventory.py b/brewman/brewman/schemas/inventory.py index 4695c0a1..b95ae3d3 100644 --- a/brewman/brewman/schemas/inventory.py +++ b/brewman/brewman/schemas/inventory.py @@ -14,4 +14,5 @@ class Inventory(BaseModel): tax: Daf = Field(ge=0, le=5) discount: Daf = Field(ge=0, le=1) amount: Daf | None = None + # is_happy_hour: bool = False model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) diff --git a/brewman/brewman/schemas/recipe_import.py b/brewman/brewman/schemas/recipe_import.py new file mode 100644 index 00000000..61ab96ce --- /dev/null +++ b/brewman/brewman/schemas/recipe_import.py @@ -0,0 +1,110 @@ +import uuid + +from datetime import date +from decimal import Decimal + +from pydantic import BaseModel, ConfigDict + +from . import to_camel + + +class ImportIngredientPlan(BaseModel): + """One ingredient row of a dish in the import preview.""" + + name: str + product_id: uuid.UUID | None = None + quantity: Decimal + unit: str | None = None + unit_mismatch: bool = False + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + +class ImportDishPlan(BaseModel): + """One recipe block of the import preview. + + ``action`` is one of ``unchanged``, ``overwrite`` (a recipe for the import + date exists and differs), ``new`` (differs from the latest earlier recipe), + ``skip`` (unmatched ingredients that were not selected for creation), + ``invalid`` (the block failed schema validation or its SKU is ambiguous) + or ``unmatched`` (the dish itself has no product). + """ + + name: str + sheet: str + product_id: uuid.UUID | None = None + sku_id: uuid.UUID | None = None + action: str + recipe_yield: Decimal + existing_date: date | None = None + section_tag: str | None = None + sale_price: Decimal | None = None + message: str | None = None + ingredients: list[ImportIngredientPlan] = [] + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + +class ImportMasterPlan(BaseModel): + """A SkuVersion master-data change (sale price, fraction or yield %).""" + + product_id: uuid.UUID + sku_id: uuid.UUID + name: str + field: str # sale_price | fraction | product_yield + old: Decimal | None + new: Decimal + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + +class ImportUnmatchedName(BaseModel): + """A name in the workbook that matched no product, offered for creation.""" + + name: str + kind: str # dish | ingredient + sheet: str + section: str | None = None + suggested_group: str | None = None + unit_hint: str | None = None + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + +class RecipeImportPreview(BaseModel): + date_: date + dishes: list[ImportDishPlan] = [] + master_changes: list[ImportMasterPlan] = [] + unmatched: list[ImportUnmatchedName] = [] + warnings: list[str] = [] + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + +class ImportDishResult(BaseModel): + name: str + status: str # created | updated | unchanged | skipped + message: str | None = None + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + +class RecipeImportResult(BaseModel): + date_: date + created_products: list[str] = [] + dishes: list[ImportDishResult] = [] + master_changes: int = 0 + warnings: list[str] = [] + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + +__all__ = [ + "ImportDishPlan", + "ImportDishResult", + "ImportIngredientPlan", + "ImportMasterPlan", + "ImportUnmatchedName", + "RecipeImportPreview", + "RecipeImportResult", +] diff --git a/brewman/brewman/schemas/sales_import.py b/brewman/brewman/schemas/sales_import.py new file mode 100644 index 00000000..c3d5770a --- /dev/null +++ b/brewman/brewman/schemas/sales_import.py @@ -0,0 +1,92 @@ +import uuid + +from datetime import date +from typing import Literal + +from pydantic import BaseModel, ConfigDict + +from . import Daf, to_camel +from .cost_centre import CostCentreLink + + +class SaleLine(BaseModel): + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + product_id: uuid.UUID | None = None + sku_id: uuid.UUID | None = None + name: str + units: str + sale_category_id: uuid.UUID + sale_category_name: str + quantity: Daf + rate: Daf + tax_rate: Daf + discount: Daf + # is_happy_hour: bool + amount: Daf + + +class VoucherPlan(BaseModel): + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + sale_category_id: uuid.UUID + sale_category_name: str + cost_centre: CostCentreLink + narration: str + lines: list[SaleLine] + amount: Daf + action: Literal["create", "skip", "replace"] + existing_amount: Daf | None = None + + +class DayPlan(BaseModel): + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + business_date: date + action: Literal["create", "skip", "replace", "mixed"] + vouchers: list[VoucherPlan] + amount: Daf + + +class MappingEntry(BaseModel): + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + sale_category_id: uuid.UUID + sale_category_name: str | None = None + cost_centre: CostCentreLink | None = None + + +class PreviewResponse(BaseModel): + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + days: list[DayPlan] + mapping: list[MappingEntry] + cost_centres: list[CostCentreLink] + new_products: list[str] + + +class ImportRequest(BaseModel): + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + start_date: date + finish_date: date + + +class MappingIn(BaseModel): + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + sale_category_id: uuid.UUID + cost_centre_id: uuid.UUID | None = None + + +class MappingUpdate(BaseModel): + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + mapping: list[MappingIn] + + +class ExecuteResponse(BaseModel): + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + message: str + days: list[DayPlan] diff --git a/brewman/brewman/services/__init__.py b/brewman/brewman/services/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/brewman/brewman/services/recipe_import.py b/brewman/brewman/services/recipe_import.py new file mode 100644 index 00000000..0a484a58 --- /dev/null +++ b/brewman/brewman/services/recipe_import.py @@ -0,0 +1,835 @@ +"""Database side of the recipe workbook import. + +The workbook is parsed by :mod:`brewman.services.recipe_xlsx`; this module +matches the parsed names against products, plans the changes and applies them +with the date-versioning rules agreed for recipes: + +* The import asks for a date; every recipe imported uses that date. +* If the dish is unchanged compared to the recipe effective on that date, + nothing happens. +* If it differs and a recipe for that date exists, that recipe is overwritten. +* If it differs and no recipe for that date exists, a new recipe is created + with the import date (``recipes.uq_recipes_sku_id`` allows one recipe per + sku per date). +* Steps, photos and tags of an overwritten recipe are kept; the menu section + of the dish is added as a tag. + +Everything is resolved *as of the import date*: only product versions and sku +versions active on that date are fetched, and a recipe block is attached to +the SKU identified by the pair (product name, units): the block's yield unit +must equal the SKU's units, case-insensitively and exactly. There are no +fallbacks; anything the data does not resolve unambiguously is flagged and +skipped, never guessed. +""" + +from __future__ import annotations + +import uuid + +from collections import defaultdict +from dataclasses import dataclass +from datetime import date, timedelta +from decimal import Decimal + +from sqlalchemy import delete, func, select +from sqlalchemy.orm import Session, contains_eager, joinedload + +from ..models.account import Account +from ..models.product import Product +from ..models.product_group import ProductGroup +from ..models.product_version import ProductVersion +from ..models.recipe import Recipe +from ..models.recipe_item import RecipeItem +from ..models.sku_version import SkuVersion +from ..models.stock_keeping_unit import StockKeepingUnit +from ..models.tag import Tag +from ..routers import _pv_active, _sv_active, _sv_onclause +from ..schemas.recipe_import import ( + ImportDishPlan, + ImportDishResult, + ImportIngredientPlan, + ImportMasterPlan, + ImportUnmatchedName, + RecipeImportPreview, + RecipeImportResult, +) +from ..services.recipe_xlsx import FINAL_BLOCK, SEMI_BLOCK, ParsedBlock, ParsedWorkbook, normalize_name + + +SEMI_GROUP = "Semi" +MENU_GROUP = "Menu Items" +DEFAULT_GROUP = "Provisions" +DEFAULT_SOURCE = "Excel Import" + +# Rate List section name -> product group name for auto-created products. +SECTION_GROUPS: dict[str, str] = { + "Provision": "Provisions", + "Cheese & Dairy": "Dairy Products", + "Vegetable": "Vegetables", + "Non Veg Product": "Meats", + "Process Bakery Product": "Ready Items", + "Semi Recipe": SEMI_GROUP, +} + + +@dataclass +class ImportOptions: + date_: date + create_names: set[str] + apply_sale_prices: bool = True + apply_master_data: bool = True + apply_tags: bool = True + + +# --------------------------------------------------------------------------- +# Load: everything active on the import date +# --------------------------------------------------------------------------- + + +@dataclass +class _SkuInfo: + """One SKU of a product with its version active on the import date.""" + + sku_id: uuid.UUID + units: str + fraction: Decimal + product_yield: Decimal + sale_price: Decimal + version_id: uuid.UUID + version_valid_from: date | None + version_valid_till: date | None + + +@dataclass +class _ProductInfo: + product_id: uuid.UUID + name: str + fraction_units: str + group_name: str + skus: list[_SkuInfo] + + +def _normalized_unit(unit: str | None) -> str: + if unit is None: + return "" + return unit.strip().rstrip(".").lower() + + +def _load_product_map(db: Session, date_: date) -> tuple[dict[str, _ProductInfo], set[str], list[str]]: + """Normalized product name -> product with SKUs active on the date. + + Only the product version and the sku versions valid on ``date_`` are + fetched; the database does the temporal filtering. Names that match + several active products are ambiguous: they are returned in the duplicate + set and resolve to nothing. + """ + versions = ( + db.execute( + select(ProductVersion) + .join(ProductVersion.product) + .join(Product.skus) + .join(SkuVersion, onclause=_sv_onclause(date_)) + .where(_pv_active(date_)) + .options( + contains_eager(ProductVersion.product) + .contains_eager(Product.skus) + .contains_eager(StockKeepingUnit.versions), + contains_eager(ProductVersion.product_group), + ) + ) + .unique() + .scalars() + .all() + ) + by_name: dict[str, _ProductInfo] = {} + duplicates: set[str] = set() + warnings: list[str] = [] + for version in versions: + skus = sorted( + ( + _SkuInfo( + sku_id=sku.id, + units=sv.units, + fraction=sv.fraction, + product_yield=sv.product_yield, + sale_price=sv.sale_price, + version_id=sv.id, + version_valid_from=sv.valid_from, + version_valid_till=sv.valid_till, + ) + for sku in version.product.skus + for sv in sku.versions + ), + key=lambda s: s.sku_id, + ) + info = _ProductInfo( + product_id=version.product_id, + name=version.name, + fraction_units=version.fraction_units, + group_name=version.product_group.name, + skus=skus, + ) + key = normalize_name(version.name) + if key in duplicates: + continue + if key in by_name: + duplicates.add(key) + del by_name[key] + warnings.append(f"'{version.name}' matches multiple products; recipes using it are flagged") + continue + by_name[key] = info + return by_name, duplicates, warnings + + +def _active_sku_ids(product_map: dict[str, _ProductInfo]) -> set[uuid.UUID]: + return {sku.sku_id for info in product_map.values() for sku in info.skus} + + +def _load_recipes_by_sku(db: Session, date_: date, sku_ids: set[uuid.UUID]) -> dict[uuid.UUID, list[Recipe]]: + """Recipes up to the import date for the given skus, oldest first.""" + by_sku: dict[uuid.UUID, list[Recipe]] = defaultdict(list) + if not sku_ids: + return by_sku + query = select(Recipe).where(Recipe.sku_id.in_(sku_ids), Recipe.date_ <= date_).options(joinedload(Recipe.items)) + for recipe in db.execute(query).unique().scalars().all(): + by_sku[recipe.sku_id].append(recipe) + for recipe_list in by_sku.values(): + recipe_list.sort(key=lambda r: r.date_) + return by_sku + + +def _load_summary_sections(parsed: ParsedWorkbook) -> dict[str, str]: + """Dish name -> menu section from the summary sheet.""" + sections: dict[str, str] = {} + for row in parsed.summary_rows: + if row.name == "": + continue + sections.setdefault(normalize_name(row.name), row.section or "Addon Recipe") + return sections + + +def _load_summary_prices(parsed: ParsedWorkbook) -> dict[str, Decimal]: + prices: dict[str, Decimal] = {} + for row in parsed.summary_rows: + if row.name != "" and row.menu_price is not None: + # A dish can be listed several times; Excel's VLOOKUP takes the + # first occurrence, so the import mirrors that. + prices.setdefault(normalize_name(row.name), row.menu_price) + return prices + + +# --------------------------------------------------------------------------- +# Plan: match the workbook against the database +# --------------------------------------------------------------------------- + + +def _resolve_sku(block: ParsedBlock, info: _ProductInfo) -> tuple[_SkuInfo | None, str | None]: + """Pick the SKU identified by (product name, units). + + The block's yield unit must equal a SKU's units, case-insensitively and + exactly; there are no fallbacks. An unresolved block is flagged, never + guessed. + """ + target = _normalized_unit(block.yield_unit) + if target == "": + return None, f"'{info.name}': the recipe has no yield line naming the unit" + matches = [s for s in info.skus if _normalized_unit(s.units) == target] + if not matches: + return None, f"'{info.name}': no SKU has units '{block.yield_unit}'" + if len(matches) > 1: + return None, f"'{info.name}': multiple SKUs have units '{block.yield_unit}'" + return matches[0], None + + +def _recipe_yield(block: ParsedBlock) -> Decimal: + """The block's yield, taken as-is in the SKU's units (no conversion).""" + return block.yield_quantity if block.yield_quantity is not None else Decimal("1") + + +def _recipe_matches(recipe: Recipe, block: ParsedBlock, product_map: dict[str, _ProductInfo]) -> bool: + """Do ingredients, quantities and yield match the recipe?""" + current: dict[uuid.UUID, Decimal] = defaultdict(lambda: Decimal("0")) + for item in recipe.items: + current[item.product_id] += round(item.quantity, 2) + incoming: dict[uuid.UUID, Decimal] = defaultdict(lambda: Decimal("0")) + for ingredient in block.ingredients: + info = product_map.get(normalize_name(ingredient.name)) + if info is None: + return False + incoming[info.product_id] += round(ingredient.quantity, 2) + if current != incoming: + return False + return round(recipe.recipe_yield, 4) == round(_recipe_yield(block), 4) + + +def _missing_ingredients(block: ParsedBlock, product_map: dict[str, _ProductInfo]) -> list[str]: + return [i.name for i in block.ingredients if normalize_name(i.name) not in product_map] + + +def _ingredient_error( + block: ParsedBlock, + product_map: dict[str, _ProductInfo], + duplicates: set[str], +) -> str | None: + """Why the block cannot import, from ambiguous names or unit mismatches. + + An ingredient unit is part of the data: a non-blank unit that does not + equal the product's fraction units (case-insensitively) flags the whole + recipe rather than importing a misread quantity. + """ + ambiguous = [i.name for i in block.ingredients if normalize_name(i.name) in duplicates] + if ambiguous: + return f"ingredient names are ambiguous: {', '.join(ambiguous)}" + mismatched = [] + for i in block.ingredients: + info = product_map.get(normalize_name(i.name)) + if i.unit and info is not None and _normalized_unit(i.unit) != _normalized_unit(info.fraction_units): + mismatched.append(f"row {i.row} '{i.name}' says {i.unit}, product unit is {info.fraction_units}") + if mismatched: + shown = "; ".join(mismatched[:5]) + more = f" (+{len(mismatched) - 5} more)" if len(mismatched) > 5 else "" + return f"ingredient units do not match the products: {shown}{more}" + return None + + +def _suggested_group(name: str, parsed: ParsedWorkbook) -> str: + key = normalize_name(name) + for row in parsed.rate_rows: + if normalize_name(row.name) == key: + return SECTION_GROUPS.get(row.section, DEFAULT_GROUP) + return DEFAULT_GROUP + + +def _unit_hint(name: str, parsed: ParsedWorkbook) -> str | None: + key = normalize_name(name) + for row in parsed.rate_rows: + if normalize_name(row.name) == key: + return row.unit + return None + + +def _find_unmatched( + parsed: ParsedWorkbook, + product_map: dict[str, _ProductInfo], + duplicates: set[str], +) -> list[ImportUnmatchedName]: + """Names in the workbook that matched no product, offered for creation.""" + unmatched: dict[str, ImportUnmatchedName] = {} + for block in parsed.blocks: + suggested = MENU_GROUP if block.sheet == FINAL_BLOCK else SEMI_GROUP + for ingredient in block.ingredients: + key = normalize_name(ingredient.name) + if key in product_map or key in duplicates or key in unmatched: + continue + unmatched[key] = ImportUnmatchedName( + name=ingredient.name, + kind="ingredient", + sheet=block.sheet, + section=None, + suggested_group=_suggested_group(ingredient.name, parsed), + unit_hint=_unit_hint(ingredient.name, parsed), + ) + key = normalize_name(block.name) + if key in product_map or key in duplicates or key in unmatched: + continue + unmatched[key] = ImportUnmatchedName( + name=block.name, + kind="dish", + sheet=block.sheet, + section=None, + suggested_group=suggested, + # The dish's own yield unit names its SKU; fall back to the Rate + # List unit when the block carries no yield line. + unit_hint=block.yield_unit or _unit_hint(block.name, parsed), + ) + return list(unmatched.values()) + + +def _plan_block( + block: ParsedBlock, + product_map: dict[str, _ProductInfo], + duplicates: set[str], + sections: dict[str, str], + prices: dict[str, Decimal], + recipes_by_sku: dict[uuid.UUID, list[Recipe]], + options: ImportOptions, +) -> ImportDishPlan: + """Plan one recipe block against the database state.""" + key = normalize_name(block.name) + info = product_map.get(key) + section = sections.get(key) + ingredient_plans = [ + ImportIngredientPlan( + name=ingredient.name, + product_id=ing.product_id if (ing := product_map.get(normalize_name(ingredient.name))) else None, + quantity=ingredient.quantity, + unit=ingredient.unit, + unit_mismatch=bool( + ing + and ingredient.unit + and _normalized_unit(ingredient.unit) not in ("", _normalized_unit(ing.fraction_units)) + ), + ) + for ingredient in block.ingredients + ] + + def plan( + action: str, + sku_id: uuid.UUID | None = None, + recipe_yield: Decimal = Decimal("1"), + existing_date: date | None = None, + message: str | None = None, + ) -> ImportDishPlan: + return ImportDishPlan( + name=block.name, + sheet=block.sheet, + product_id=info.product_id if info is not None else None, + sku_id=sku_id, + action=action, + recipe_yield=recipe_yield, + existing_date=existing_date, + section_tag=section, + sale_price=prices.get(key), + message=message, + ingredients=ingredient_plans, + ) + + if key in duplicates: + return plan(action="invalid", message=f"'{block.name}' matches multiple products on the date") + if info is None: + return plan(action="create-product" if key in options.create_names else "unmatched") + + if block.error is not None: + return plan(action="invalid", message=block.error) + sku, error = _resolve_sku(block, info) + if sku is None or error is not None: + return plan(action="invalid", message=error) + ingredient_error = _ingredient_error(block, product_map, duplicates) + if ingredient_error is not None: + return plan(action="invalid", sku_id=sku.sku_id, message=ingredient_error) + + missing = _missing_ingredients(block, product_map) + if missing and not all(normalize_name(n) in options.create_names for n in missing): + action, existing_date = "skip", None + else: + recipe_list = recipes_by_sku.get(sku.sku_id, []) + same_date = next((r for r in recipe_list if r.date_ == options.date_), None) + earlier = [r for r in recipe_list if r.date_ < options.date_] + reference = same_date if same_date is not None else (earlier[-1] if earlier else None) + if reference is None: + action, existing_date = "new", None + elif _recipe_matches(reference, block, product_map): + action, existing_date = "unchanged", options.date_ + else: + action = "overwrite" if same_date is not None else "new" + existing_date = options.date_ if same_date is not None else None + return plan(action=action, sku_id=sku.sku_id, recipe_yield=_recipe_yield(block), existing_date=existing_date) + + +def preview_import(db: Session, parsed: ParsedWorkbook, options: ImportOptions) -> RecipeImportPreview: + product_map, duplicates, warnings = _load_product_map(db, options.date_) + sections = _load_summary_sections(parsed) + prices = _load_summary_prices(parsed) + recipes_by_sku = _load_recipes_by_sku(db, options.date_, _active_sku_ids(product_map)) + + dishes: list[ImportDishPlan] = [] + seen: set[str] = set() + for block in parsed.blocks: + key = normalize_name(block.name) + if key in seen: # duplicate blocks are warned about by the parser + continue + seen.add(key) + dishes.append(_plan_block(block, product_map, duplicates, sections, prices, recipes_by_sku, options)) + + master_changes = _plan_master_changes(parsed, product_map, duplicates, prices, options, warnings) + return RecipeImportPreview( + date_=options.date_, + dishes=dishes, + master_changes=master_changes, + unmatched=_find_unmatched(parsed, product_map, duplicates), + warnings=parsed.warnings + sorted(set(warnings)), + ) + + +def _semi_product_names(parsed: ParsedWorkbook) -> set[str]: + """Products that appear as recipe blocks in the semi sheet. + + Their Rate List fraction column holds the batch size (not a purchase pack + conversion), so it must not be imported as a sku fraction. + """ + return {normalize_name(b.name) for b in parsed.blocks if b.sheet == SEMI_BLOCK} + + +def _sku_by_units( + name: str, + unit: str | None, + info: _ProductInfo, + warnings: list[str], +) -> _SkuInfo | None: + """The SKU of the product whose units equal the given unit, exactly. + + Used by master-data rows, which carry a unit column: (name, units) picks + one SKU even on a multi-SKU product. An unresolvable row is reported and + skipped, never guessed. + """ + target = _normalized_unit(unit) + if target == "": + warnings.append(f"{name}: the row has no unit; ignored") + return None + matches = [s for s in info.skus if _normalized_unit(s.units) == target] + if not matches: + warnings.append(f"{name}: no SKU has units '{unit}'; row ignored") + return None + if len(matches) > 1: + warnings.append(f"{name}: multiple SKUs have units '{unit}'; row ignored") + return None + return matches[0] + + +def _plan_master_changes( + parsed: ParsedWorkbook, + product_map: dict[str, _ProductInfo], + duplicates: set[str], + prices: dict[str, Decimal], + options: ImportOptions, + warnings: list[str], +) -> list[ImportMasterPlan]: + changes: dict[tuple[uuid.UUID, str], ImportMasterPlan] = {} + + def change(info: _ProductInfo, sku: _SkuInfo, field: str, new: Decimal) -> None: + old = getattr(sku, field) + if old != new: + changes[(sku.sku_id, field)] = ImportMasterPlan( + product_id=info.product_id, + sku_id=sku.sku_id, + name=info.name, + field=field, + old=old, + new=new, + ) + + semi_names = _semi_product_names(parsed) + if options.apply_master_data: + for row in parsed.rate_rows: + key = normalize_name(row.name) + if key in duplicates: + warnings.append(f"'{row.name}' matches multiple products; rate row ignored") + continue + info = product_map.get(key) + if info is None: + continue + sku = _sku_by_units(row.name, row.unit, info, warnings) + if sku is None: + continue + if row.yield_pct is not None: + change(info, sku, "product_yield", row.yield_pct) + if row.fraction is not None and key not in semi_names: + change(info, sku, "fraction", row.fraction) + if options.apply_sale_prices: + for key, price in prices.items(): + if key in duplicates: + continue + info = product_map.get(key) + if info is None: + continue + if len(info.skus) != 1: + # The Summary sheet has no units: on a multi-SKU product the + # price cannot be attributed to one SKU. + warnings.append( + f"{info.name}: sale price not imported ({len(info.skus)} SKUs are active; the summary does not say which)" + ) + continue + change(info, info.skus[0], "sale_price", price) + return sorted(changes.values(), key=lambda c: (c.name, c.field)) + + +# --------------------------------------------------------------------------- +# Apply: write the planned changes +# --------------------------------------------------------------------------- + + +def _skipped(name: str, message: str | None = None) -> ImportDishResult: + return ImportDishResult(name=name, status="skipped", message=message) + + +def _write_ingredients(db: Session, recipe: Recipe, block: ParsedBlock, product_map: dict[str, _ProductInfo]) -> None: + """Replace the recipe's ingredients with the block's rows.""" + db.execute(delete(RecipeItem).where(RecipeItem.recipe_id == recipe.id)) + recipe.items = [] + db.flush() + for ingredient in block.ingredients: + info = product_map[normalize_name(ingredient.name)] + db.add( + RecipeItem( + product_id=info.product_id, + quantity=round(ingredient.quantity, 2), + description="", + recipe=recipe, + ) + ) + db.flush() + + +def _get_or_create_tag(db: Session, tag_cache: dict[str, Tag], name: str) -> Tag: + tag = tag_cache.get(name) + if tag is not None: + return tag + tag = db.execute(select(Tag).where(Tag.name == name)).scalar_one_or_none() + if tag is None: + tag = Tag(name=name) + db.add(tag) + db.flush() + tag_cache[name] = tag + return tag + + +def execute_import(db: Session, parsed: ParsedWorkbook, options: ImportOptions) -> RecipeImportResult: + product_map, duplicates, warnings = _load_product_map(db, options.date_) + result = RecipeImportResult(date_=options.date_, warnings=parsed.warnings) + + # 1. Create the products the user checked in the preview. + for unmatched in _find_unmatched(parsed, product_map, duplicates): + if normalize_name(unmatched.name) in options.create_names: + created = _create_product(db, unmatched, options.date_) + product_map[normalize_name(created.name)] = created + result.created_products.append(created.name) + + # 2. Apply the recipe blocks. + sections = _load_summary_sections(parsed) + prices = _load_summary_prices(parsed) + recipes_by_sku = _load_recipes_by_sku(db, options.date_, _active_sku_ids(product_map)) + tag_cache: dict[str, Tag] = {} + seen: set[str] = set() + for block in parsed.blocks: + key = normalize_name(block.name) + if key in seen: # duplicate blocks are warned about by the parser + continue + seen.add(key) + + if key in duplicates: + result.dishes.append(_skipped(block.name, f"'{block.name}' matches multiple products on the date")) + continue + info = product_map.get(key) + if info is None: + result.dishes.append(_skipped(block.name, "Product not found and not selected for creation")) + continue + if block.error is not None: + result.dishes.append(_skipped(block.name, block.error)) + continue + sku, error = _resolve_sku(block, info) + if sku is None: + result.dishes.append(_skipped(block.name, error)) + continue + ingredient_error = _ingredient_error(block, product_map, duplicates) + if ingredient_error is not None: + result.dishes.append(_skipped(block.name, ingredient_error)) + continue + missing = _missing_ingredients(block, product_map) + if missing: + result.dishes.append(_skipped(block.name, f"Missing ingredients: {', '.join(missing)}")) + continue + + recipe_list = recipes_by_sku.get(sku.sku_id, []) + same_date = next((r for r in recipe_list if r.date_ == options.date_), None) + earlier = [r for r in recipe_list if r.date_ < options.date_] + reference = same_date if same_date is not None else (earlier[-1] if earlier else None) + + if reference is not None and _recipe_matches(reference, block, product_map): + recipe = reference + status = "unchanged" + elif same_date is not None: + recipe = same_date + _write_ingredients(db, recipe, block, product_map) + recipe.recipe_yield = round(_recipe_yield(block), 4) + status = "updated" + else: + sku_obj = db.execute(select(StockKeepingUnit).where(StockKeepingUnit.id == sku.sku_id)).scalar_one() + recipe = Recipe( + date_=options.date_, + source=DEFAULT_SOURCE, + instructions="", + garnishing="", + plating="", + notes="", + sku=sku_obj, + recipe_yield=round(_recipe_yield(block), 4), + ) + db.add(recipe) + db.flush() + _write_ingredients(db, recipe, block, product_map) + recipes_by_sku[sku.sku_id].append(recipe) + status = "created" + result.dishes.append(ImportDishResult(name=block.name, status=status)) + + if options.apply_tags and (section := sections.get(key)): + tag = _get_or_create_tag(db, tag_cache, section) + if tag not in recipe.tags: + recipe.tags.append(tag) + + # 3. Apply sku master data changes (sale price, fraction, yield %). + master_warnings: list[str] = [] + plans = _plan_master_changes(parsed, product_map, duplicates, prices, options, master_warnings) + result.warnings.extend(sorted(set(master_warnings))) + if options.apply_sale_prices or options.apply_master_data: + result.master_changes = _apply_master_changes(db, plans, options) + + db.commit() + return result + + +def _create_product(db: Session, unmatched: ImportUnmatchedName, date_: date) -> _ProductInfo: + """Create a missing product, or attach a SKU to an existing one. + + A product can exist without an active SKU (e.g. 'Prawns'); in that case + the product is reused and only the SKU plus its version are created. A + product whose *current* name differs from the workbook name is also + resolved here, by looking at every version's name. + """ + group_name = unmatched.suggested_group or DEFAULT_GROUP + existing = db.execute( + select(ProductVersion) + .where(func.lower(func.trim(ProductVersion.name)) == unmatched.name.strip().lower()) + .order_by(ProductVersion.valid_from.desc().nullsfirst()) + .limit(1) + .options(joinedload(ProductVersion.product_group)) + ).scalar_one_or_none() + + if existing is not None: + active = ( + db.execute( + select(StockKeepingUnit) + .join(SkuVersion, onclause=_sv_onclause(date_)) + .where(StockKeepingUnit.product_id == existing.product_id) + .options(contains_eager(StockKeepingUnit.versions)) + ) + .unique() + .scalars() + .all() + ) + if active: + return _ProductInfo( + product_id=existing.product_id, + name=existing.name, + fraction_units=existing.fraction_units, + group_name=existing.product_group.name, + skus=sorted( + ( + _SkuInfo( + sku_id=sku.id, + units=sv.units, + fraction=sv.fraction, + product_yield=sv.product_yield, + sale_price=sv.sale_price, + version_id=sv.id, + version_valid_from=sv.valid_from, + version_valid_till=sv.valid_till, + ) + for sku in active + for sv in sku.versions + ), + key=lambda s: s.sku_id, + ), + ) + product_id: uuid.UUID = existing.product_id + product = db.execute(select(Product).where(Product.id == product_id)).scalar_one() + version = existing + else: + product = Product() + db.add(product) + db.flush() + group = db.execute(select(ProductGroup).where(ProductGroup.name == group_name)).scalar_one_or_none() + if group is None: + group = ProductGroup(name=group_name) + db.add(group) + db.flush() + is_menu = group_name == MENU_GROUP + is_semi = group_name == SEMI_GROUP + version = ProductVersion( + product_id=product.id, + name=unmatched.name, + fraction_units=unmatched.unit_hint or ("Por" if is_menu else "Gm"), + product_group_id=group.id, + account_id=Account.all_purchases(), + is_purchased=not (is_menu or is_semi), + is_sold=is_menu, + ) + db.add(version) + db.flush() + + sku = StockKeepingUnit(product_id=product.id) + db.add(sku) + db.flush() + # The SKU's units come verbatim from the workbook (the dish's yield unit + # or the Rate List unit); without any hint, fall back to the fraction + # default. No kg/ltr-style mapping: the units must be resolvable exactly. + units = (unmatched.unit_hint or "").strip() or ("Por" if group_name == MENU_GROUP else "Gm") + sku_version = SkuVersion( + units=units, + fraction=Decimal("1"), + product_yield=Decimal("1"), + cost_price=Decimal("0"), + sale_price=Decimal("0"), + sku_id=sku.id, + ) + db.add(sku_version) + db.flush() + return _ProductInfo( + product_id=product.id, + name=version.name, + fraction_units=version.fraction_units, + group_name=group_name if existing is None else version.product_group.name, + skus=[ + _SkuInfo( + sku_id=sku.id, + units=units, + fraction=sku_version.fraction, + product_yield=sku_version.product_yield, + sale_price=sku_version.sale_price, + version_id=sku_version.id, + version_valid_from=sku_version.valid_from, + version_valid_till=sku_version.valid_till, + ) + ], + ) + + +def _apply_master_changes(db: Session, plans: list[ImportMasterPlan], options: ImportOptions) -> int: + """Apply master-data plans; at most ONE new sku version per SKU. + + The active version is edited in place when it starts at/after the import + date; otherwise it is closed at the day before and a new open version is + created (several versions for the same range would violate the exclusion + constraint on sku_versions). + """ + by_sku: dict[uuid.UUID, list[ImportMasterPlan]] = defaultdict(list) + for plan in plans: + by_sku[plan.sku_id].append(plan) + + applied = 0 + for sku_id, sku_plans in by_sku.items(): + version = db.execute( + select(SkuVersion).where(SkuVersion.sku_id == sku_id, _sv_active(options.date_)) + ).scalar_one_or_none() + if version is None: + continue + values: dict[str, Decimal] = {p.field: p.new for p in sku_plans} + if version.valid_from is not None and version.valid_from >= options.date_: + for field, value in values.items(): + setattr(version, field, value) + else: + if version.valid_till is None: + version.valid_till = options.date_ - timedelta(days=1) + db.add( + SkuVersion( + units=version.units, + fraction=values.get("fraction", version.fraction), + product_yield=values.get("product_yield", version.product_yield), + cost_price=version.cost_price, + sale_price=values.get("sale_price", version.sale_price), + sku_id=sku_id, + valid_from=options.date_, + valid_till=None, + ) + ) + applied += 1 + return applied diff --git a/brewman/brewman/services/recipe_xlsx.py b/brewman/brewman/services/recipe_xlsx.py new file mode 100644 index 00000000..63eb1fc4 --- /dev/null +++ b/brewman/brewman/services/recipe_xlsx.py @@ -0,0 +1,499 @@ +"""Parse and build the recipe costing workbook (``recipe.xlsx`` layout). + +The workbook has four sheets: + +``Rate List`` + Sectioned list of every rate-able item (semi dishes first, then raw + materials) with unit, fraction, yield % and a live ``Final Rate`` formula. + +``Semi Recipe`` / ``New Menu Recipes Final`` + Stacked recipe blocks. A block starts with a name row (name in column A, + an optional ``Yeild = `` in column C and a ``=SUM(...)`` in + column E) followed by an ``Ingridients`` header row and the ingredient + rows (name, unit, qty, rate vlookup, amount formula). + +``New Menu Summary`` + Menu items grouped by section with menu price, selling price, cost price + and cost % formulas. + +This module is pure openpyxl: no database access. ``services.recipe_import`` +handles the database side of importing and ``routers.recipe`` the export. +""" + +from __future__ import annotations + +import re + +from dataclasses import dataclass, field +from decimal import Decimal, InvalidOperation +from io import BytesIO +from typing import cast + +from openpyxl import Workbook, load_workbook +from openpyxl.styles import Alignment, Border, Font, NamedStyle, PatternFill, Side +from openpyxl.worksheet.worksheet import Worksheet + + +RATE_SHEET = "Rate List" +SEMI_SHEET = "Semi Recipe" +FINAL_SHEET = "New Menu Recipes Final" +SUMMARY_SHEET = "New Menu Summary" + +SEMI_BLOCK = "semi" +FINAL_BLOCK = "final" + +_INGREDIENT_HEADER = re.compile(r"^ingr?i?d[i]?e?nts?$", re.IGNORECASE) +# Matches "Yeild = 5600 Gm", "Yield- 1 Kg", "Yeild=1200 Gm", "Yeild = 22 Por" etc. +_YIELD_TEXT = re.compile(r"y[ei][ei]?ld\s*[=\-]+\s*([0-9][0-9.,]*)\s*([A-Za-z.]*)", re.IGNORECASE) +_FORMULA_PREFIX = "=" + + +@dataclass +class ParsedIngredient: + """One ingredient row inside a recipe block.""" + + row: int + name: str + unit: str | None + quantity: Decimal + + +@dataclass +class ParsedBlock: + """One recipe block from ``Semi Recipe`` or ``New Menu Recipes Final``. + + ``error`` is set when a row inside the block does not fit the schema; the + whole block is then flagged and never imported. + """ + + row: int + sheet: str # SEMI_BLOCK or FINAL_BLOCK + name: str + yield_quantity: Decimal | None + yield_unit: str | None + ingredients: list[ParsedIngredient] = field(default_factory=list) + error: str | None = None + + +@dataclass +class ParsedRateRow: + """One item row from ``Rate List``.""" + + row: int + section: str + name: str + unit: str | None + fraction: Decimal | None + yield_pct: Decimal | None + + +@dataclass +class ParsedSummaryRow: + """One row from ``New Menu Summary``.""" + + row: int + section: str | None + name: str + menu_price: Decimal | None + + +@dataclass +class ParsedWorkbook: + """Everything importable from a recipe workbook.""" + + blocks: list[ParsedBlock] = field(default_factory=list) + rate_rows: list[ParsedRateRow] = field(default_factory=list) + summary_rows: list[ParsedSummaryRow] = field(default_factory=list) + warnings: list[str] = field(default_factory=list) + + +def _as_text(value: object) -> str: + return str(value).strip() if value is not None else "" + + +def _as_decimal(value: object) -> Decimal | None: + if value is None: + return None + if isinstance(value, Decimal): + return value + try: + return Decimal(str(value).replace(",", "").strip()) + except InvalidOperation, ValueError: + return None + + +def _is_formula(value: object) -> bool: + return isinstance(value, str) and value.startswith(_FORMULA_PREFIX) + + +def _normalize(name: str) -> str: + return re.sub(r"\s+", " ", name.strip().rstrip(".").lower()) + + +def _format_number(value: Decimal) -> str: + """Lossless, human-friendly number formatting for yield texts.""" + normalized = value.quantize(Decimal("0.01")).normalize() + return format(normalized, "f") + + +def normalize_name(name: str) -> str: + """Normalize a product name for matching (case, spaces, trailing dots).""" + return _normalize(name) + + +def parse_recipe_workbook(data: bytes) -> ParsedWorkbook: + """Parse an uploaded recipe workbook into structured data.""" + wb = load_workbook(BytesIO(data), data_only=False) + result = ParsedWorkbook() + if RATE_SHEET in wb.sheetnames: + _parse_rate_list(wb[RATE_SHEET], result) + if SEMI_SHEET in wb.sheetnames: + _parse_recipe_sheet(wb[SEMI_SHEET], SEMI_BLOCK, result) + if FINAL_SHEET in wb.sheetnames: + _parse_recipe_sheet(wb[FINAL_SHEET], FINAL_BLOCK, result) + if SUMMARY_SHEET in wb.sheetnames: + _parse_summary(wb[SUMMARY_SHEET], result) + _check_duplicates(result) + result.warnings = sorted(set(result.warnings)) + return result + + +def _parse_rate_list(ws: Worksheet, result: ParsedWorkbook) -> None: + """Parse Rate List rows; a row that does not fit the schema is ignored.""" + section = "" + for row_number, row in enumerate(ws.iter_rows(min_row=1, max_row=ws.max_row, max_col=7), start=1): + cells: list[object] = [c.value for c in row] + name = _as_text(cells[0]) + if name == "": + if any(v is not None for v in cells[1:]): + result.warnings.append(f"{RATE_SHEET} row {row_number}: ignored, no item name") + continue + rest = cells[1:] + is_section_header = all(_as_text(v) == "" and not isinstance(v, (int, float, Decimal)) for v in rest) + if is_section_header: + if name.lower() == "items name": + continue + section = name + continue + if name.lower() == "items name": + continue + unit = _as_text(cells[2]) + fraction_text = _as_text(cells[3]) + yield_text = _as_text(cells[5]) + problem = _rate_row_problem(unit, fraction_text, yield_text) + if problem is not None: + result.warnings.append(f"{RATE_SHEET} row {row_number} ('{name}'): ignored, {problem}") + continue + result.rate_rows.append( + ParsedRateRow( + row=row_number, + section=section, + name=name, + unit=unit or None, + fraction=_as_decimal(fraction_text), + yield_pct=_as_decimal(yield_text), + ) + ) + + +def _rate_row_problem(unit: str, fraction_text: str, yield_text: str) -> str | None: + """Why the row does not fit the Rate List schema, or None if it fits.""" + if unit == "": + return "no unit" + if fraction_text != "" and _as_decimal(fraction_text) is None: + return "fraction is not a number" + if yield_text != "" and _as_decimal(yield_text) is None: + return "yield % is not a number" + return None + + +def _block_header_yield( + name: str, unit: str, qty_cell: object, rate_cell: object, amount_cell: object +) -> tuple[bool, re.Match[str] | None]: + """(is block header, yield-text match) for the row. + + A block header holds the dish name in A, an optional ``Yeild = ...`` in C + and a ``=SUM(...)`` in E; ingredient rows have a numeric quantity in C. + """ + if name == "" or _as_decimal(qty_cell) is not None or rate_cell is not None or not _is_formula(amount_cell): + return False, None + yield_match = _YIELD_TEXT.search(_as_text(qty_cell)) + if yield_match is None and unit != "": + return False, None + return True, yield_match + + +def _parse_recipe_sheet(ws: Worksheet, sheet_kind: str, result: ParsedWorkbook) -> None: + current: ParsedBlock | None = None + for row_number, row in enumerate(ws.iter_rows(min_row=1, max_row=ws.max_row, max_col=5), start=1): + cells: list[object] = [c.value for c in row] + name = _as_text(cells[0]) + unit = _as_text(cells[1]) + qty_cell = cells[2] + rate_cell = cells[3] + amount_cell = cells[4] + + if name == "" and unit == "" and _as_text(qty_cell) == "": + # Blank spacer row; does not close a block. + continue + + is_header, yield_match = _block_header_yield(name, unit, qty_cell, rate_cell, amount_cell) + if is_header: + yield_quantity = _as_decimal(yield_match.group(1).rstrip(".")) if yield_match else None + yield_unit = yield_match.group(2).strip() if yield_match else None + current = ParsedBlock( + row=row_number, + sheet=sheet_kind, + name=name, + yield_quantity=yield_quantity, + yield_unit=yield_unit, + ) + result.blocks.append(current) + continue + + if _INGREDIENT_HEADER.match(name): + continue + + if current is None: + # Title or stray text before the first block. + continue + + quantity = _as_decimal(qty_cell) + if name == "" or quantity is None: + # The row does not fit the ingredient schema: flag the whole + # recipe block so it is never imported half-parsed. + current.error = f"{ws.title} row {row_number}: ingredient row does not fit the schema (name/qty)" + continue + current.ingredients.append(ParsedIngredient(row=row_number, name=name, unit=unit or None, quantity=quantity)) + + +def _parse_summary(ws: Worksheet, result: ParsedWorkbook) -> None: + section: str | None = None + header_seen = False + for row_number, row in enumerate(ws.iter_rows(min_row=1, max_row=ws.max_row, max_col=5), start=1): + cells: list[object] = [c.value for c in row] + name = _as_text(cells[0]) + menu_price_raw = cells[1] + selling_cell = cells[2] + cost_cell = cells[3] + + if name == "": + continue + if _as_text(name).lower() == "particulars" or _as_text(cells[1]).lower() == "average costing": + header_seen = True + continue + if not header_seen: + continue + + is_item = _as_decimal(menu_price_raw) is not None or _is_formula(selling_cell) or _is_formula(cost_cell) + if is_item: + result.summary_rows.append( + ParsedSummaryRow(row=row_number, section=section, name=name, menu_price=_as_decimal(menu_price_raw)) + ) + else: + section = name + result.summary_rows.append(ParsedSummaryRow(row=row_number, section=section, name="", menu_price=None)) + + +def _check_duplicates(result: ParsedWorkbook) -> None: + block_names: dict[str, int] = {} + for block in result.blocks: + key = normalize_name(block.name) + block_names[key] = block_names.get(key, 0) + 1 + for key, count in block_names.items(): + if count > 1: + result.warnings.append(f"Duplicate recipe block '{key}' appears {count} times; only the first is imported") + + +# --------------------------------------------------------------------------- +# Workbook builder +# --------------------------------------------------------------------------- + + +@dataclass +class ExportIngredient: + name: str + unit: str + quantity: Decimal + + +@dataclass +class ExportBlock: + name: str + yield_text: str | None # e.g. "Yeild = 5 Kg" in the SKU's units; None when yield is one portion + ingredients: list[ExportIngredient] = field(default_factory=list) + + +@dataclass +class ExportRateRow: + name: str + unit: str + fraction: Decimal + yield_pct: Decimal + rate: Decimal | None # static rate for raw materials + from_recipe_sheet: bool # semi dishes pull rate/yield qty via VLOOKUP + + +@dataclass +class ExportRateSection: + name: str + rows: list[ExportRateRow] = field(default_factory=list) + + +@dataclass +class ExportSummaryItem: + name: str + menu_price: Decimal | None + + +@dataclass +class ExportData: + rate_sections: list[ExportRateSection] = field(default_factory=list) + semi_blocks: list[ExportBlock] = field(default_factory=list) + final_blocks: list[ExportBlock] = field(default_factory=list) + summary_sections: list[tuple[str, list[ExportSummaryItem]]] = field(default_factory=list) + + +def build_recipe_workbook(data: ExportData) -> BytesIO: + """Build the recipe costing workbook in the ``recipe.xlsx`` layout.""" + wb = Workbook() + ws_rate = cast(Worksheet, wb.active) + ws_rate.title = RATE_SHEET + ws_semi = wb.create_sheet(SEMI_SHEET) + ws_final = wb.create_sheet(FINAL_SHEET) + ws_summary = wb.create_sheet(SUMMARY_SHEET) + + _write_recipe_sheet(wb, ws_semi, data.semi_blocks, show_yield=True) + # Final blocks normally have no yield line (yield 1); a sub-recipe that + # is not in the semi group still carries its yield so the round trip is + # lossless. + _write_recipe_sheet(wb, ws_final, data.final_blocks, show_yield=True) + _write_rate_list(ws_rate, data.rate_sections) + _write_summary(ws_summary, data.summary_sections) + + out = BytesIO() + wb.save(out) + out.seek(0) + return out + + +def _write_recipe_sheet(wb: Workbook, ws: Worksheet, blocks: list[ExportBlock], show_yield: bool) -> None: + register_styles(wb) + row = 1 + for block in blocks: + ing_count = len(block.ingredients) + ing_from = row + 2 + ing_till = ing_from + ing_count - 1 + ws.cell(row=row, column=1, value=block.name).style = "recipe_name" + if show_yield and block.yield_text is not None: + ws.cell(row=row, column=3, value=block.yield_text).style = "recipe_name" + ws.cell(row=row, column=5, value=f"=SUM(E{ing_from}:E{ing_till})").style = "recipe_name" + row += 1 + ws.cell(row=row, column=1, value="Ingridients").style = "header" + ws.cell(row=row, column=2, value="Unit").style = "header" + ws.cell(row=row, column=3, value="Qty").style = "header" + ws.cell(row=row, column=4, value="Rate").style = "header" + ws.cell(row=row, column=5, value="Amount").style = "header" + for ingredient in block.ingredients: + row += 1 + ws.cell(row=row, column=1, value=ingredient.name).style = "ing" + ws.cell(row=row, column=2, value=ingredient.unit).style = "unit" + ws.cell(row=row, column=3, value=float(ingredient.quantity)).style = "ing" + ws.cell(row=row, column=4, value="=VLOOKUP(A:A,'Rate List'!A:G,7,0)").style = "ing" + ws.cell(row=row, column=5, value=f"=C{row}*D{row}").style = "ing" + row += 2 + + +def _write_rate_list(ws: Worksheet, sections: list[ExportRateSection]) -> None: + register_styles(cast(Workbook, ws.parent)) + headers = ["Items Name", "Yeild Qty", "Unit", "Fraction", "Rate", "Yeild %", "Final Rate"] + for column, header in enumerate(headers, start=1): + ws.cell(row=1, column=column, value=header).style = "header" + row = 2 + for section in sections: + ws.cell(row=row, column=1, value=section.name).style = "recipe_name" + row += 1 + for item in section.rows: + ws.cell(row=row, column=1, value=item.name).style = "ing" + if item.from_recipe_sheet: + ws.cell(row=row, column=2, value=f"=VLOOKUP(A:A,'{SEMI_SHEET}'!A:C,3,0)").style = "unit" + ws.cell(row=row, column=5, value=f"=VLOOKUP(A:A,'{SEMI_SHEET}'!A:E,5,0)").style = "ing" + else: + if item.rate is not None: + ws.cell(row=row, column=5, value=float(_pack_rate(item))).style = "ing" + ws.cell(row=row, column=3, value=item.unit).style = "unit" + ws.cell(row=row, column=4, value=float(item.fraction)).style = "ing" + ws.cell(row=row, column=6, value=float(item.yield_pct)).style = "ing" + ws.cell(row=row, column=7, value=f"=(E{row}/D{row})/F{row}").style = "ing" + row += 1 + row += 1 + + +def _pack_rate(item: ExportRateRow) -> Decimal: + """Gross pack rate whose live formula reproduces the net fraction rate.""" + fraction = item.fraction if item.fraction != 0 else Decimal("1") + yield_pct = item.yield_pct if item.yield_pct != 0 else Decimal("1") + rate = item.rate if item.rate is not None else Decimal("0") + return (rate * fraction * yield_pct).quantize(Decimal("0.01")) + + +def _write_summary(ws: Worksheet, sections: list[tuple[str, list[ExportSummaryItem]]]) -> None: + register_styles(cast(Workbook, ws.parent)) + ws.cell(row=1, column=1, value="Hops n Grains Food Menu Costing").style = "recipe_name" + ws.cell(row=1, column=2, value="Average Costing").style = "recipe_name" + ws.cell(row=3, column=1, value="Particulars").style = "header" + ws.cell(row=3, column=2, value="Menu Price").style = "header" + ws.cell(row=3, column=3, value="Selling\nPrice").style = "header" + ws.cell(row=3, column=4, value="Cost \nPrice").style = "header" + ws.cell(row=3, column=5, value="Cost \n%age").style = "header" + + row = 4 + first_item_row: int | None = None + for section_name, items in sections: + ws.cell(row=row, column=1, value=section_name).style = "recipe_name" + ws.cell(row=row, column=3, value=0).style = "recipe_name" + row += 1 + for item in items: + ws.cell(row=row, column=1, value=item.name).style = "ing" + if item.menu_price is not None: + ws.cell(row=row, column=2, value=float(item.menu_price)).style = "ing" + ws.cell(row=row, column=3, value=f"=B{row}/110*100").style = "ing" + ws.cell(row=row, column=4, value=f"=VLOOKUP(A:A,'{FINAL_SHEET}'!A:E,5,0)").style = "ing" + ws.cell(row=row, column=5, value=f"=IF(C{row}=0,0,D{row}/C{row})").style = "ing" + if first_item_row is None: + first_item_row = row + row += 1 + if first_item_row is not None: + ws.cell(row=1, column=4, value=f"=AVERAGE(E{first_item_row}:E{row - 1})").style = "recipe_name" + + +def register_styles(wb: Workbook) -> None: + bd = Side(style="thin", color="000000") + thin = Border(left=bd, top=bd, right=bd, bottom=bd) + + if "header" in wb.named_styles: + return + + header = NamedStyle(name="header") + header.font = Font(bold=True, color="FFFFFF") + header.fill = PatternFill("solid", fgColor="4F81BD") + header.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True) + header.border = thin + + ing = NamedStyle(name="ing") + ing.alignment = Alignment(wrap_text=True, vertical="top") + ing.border = thin + + unit = NamedStyle(name="unit") + unit.alignment = Alignment(horizontal="center", vertical="top", wrap_text=True) + unit.border = thin + + recipe_name = NamedStyle(name="recipe_name") + recipe_name.font = Font(bold=True) + recipe_name.alignment = Alignment(vertical="center", wrap_text=True) + recipe_name.border = thin + + wb.add_named_style(header) + wb.add_named_style(ing) + wb.add_named_style(unit) + wb.add_named_style(recipe_name) diff --git a/brewman/brewman/services/sales_import/__init__.py b/brewman/brewman/services/sales_import/__init__.py new file mode 100644 index 00000000..96b2aa5c --- /dev/null +++ b/brewman/brewman/services/sales_import/__init__.py @@ -0,0 +1,17 @@ +"""Barker sales import. + +The public interface of the subsystem — everything the router may call: + +- ``preview`` — what an import would do, without writing (lenient about unmapped categories). +- ``execute`` — run the import for a date range (strict about unmapped categories, commits). +- ``load_mapping`` / ``save_mapping`` — the standing sale-category → cost-centre mapping. +- ``cost_centres`` — the cost centres a category may be mapped to. + +Internals: ``products`` resolves Barker lines to brewman products/SKUs (identity policy in +ADR-0004); ``vouchers`` plans and persists Sale Vouchers; ``service`` orchestrates. +""" + +from .service import cost_centres, execute, load_mapping, preview, save_mapping + + +__all__ = ["cost_centres", "execute", "load_mapping", "preview", "save_mapping"] diff --git a/brewman/brewman/services/sales_import/barker_client.py b/brewman/brewman/services/sales_import/barker_client.py new file mode 100644 index 00000000..d95688d1 --- /dev/null +++ b/brewman/brewman/services/sales_import/barker_client.py @@ -0,0 +1,68 @@ +import uuid + +from datetime import date +from decimal import Decimal + +import httpx + +from pydantic import BaseModel, ConfigDict, Field, ValidationError + +from ...core.config import settings +from ...schemas import to_camel + + +class BarkerError(Exception): + """The Barker endpoint is unreachable or returned an unusable payload.""" + + +class BarkerSaleLine(BaseModel): + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + product_id: uuid.UUID = Field() + sku_id: uuid.UUID = Field() + name: str = Field() + units: str + sale_category_id: uuid.UUID = Field() + sale_category_name: str = Field() + quantity: Decimal + price: Decimal + tax_rate: Decimal = Field() + discount: Decimal + # is_happy_hour: bool = Field() + + +class BarkerSaleDay(BaseModel): + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + date_: date = Field() + lines: list[BarkerSaleLine] + + +class BarkerSaleCategory(BaseModel): + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + id: uuid.UUID + name: str + + +class BarkerSales(BaseModel): + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + sales: list[BarkerSaleDay] + sale_categories: list[BarkerSaleCategory] = Field() + + +def fetch_sales(start_date: date, finish_date: date) -> BarkerSales: + """Fetch day-collated sales lines from the Barker endpoint.""" + base = settings.BARKER_URL.strip().rstrip("/") + if not base: + raise BarkerError("Barker endpoint is not configured (BARKER_URL is empty)") + url = f"{base}/api/export/sales" + try: + response = httpx.get(url, params={"s": start_date.isoformat(), "f": finish_date.isoformat()}, timeout=30.0) + response.raise_for_status() + return BarkerSales.model_validate(response.json()) + except httpx.HTTPError as e: + raise BarkerError(f"Could not reach Barker at {url}: {e}") from e + except ValidationError as e: + raise BarkerError(f"Barker returned an unusable payload: {e}") from e diff --git a/brewman/brewman/services/sales_import/products.py b/brewman/brewman/services/sales_import/products.py new file mode 100644 index 00000000..cc3670ca --- /dev/null +++ b/brewman/brewman/services/sales_import/products.py @@ -0,0 +1,325 @@ +"""Product provisioning for the Barker sales import (ADR-0004). + +The mapping, once made, is standing truth: a Barker SKU whose mapping row exists is served +from it verbatim, forever. The slugified product name is only the resolver for a Barker +product never mapped before. Barker renames and units changes after first mapping are +ignored — brewman master data is edited by hand when that matters. + +Resolution for one Barker line on business date ``date_``: + +1. Barker SKU mapped → return the mapped brewman product and SKU as-is (``mapped``). +2. Barker product mapped (another SKU of it) → find that product's SKU whose normalized + units match and is valid on ``date_``, else create one under it; write the mapping row + (``linked``). +3. Barker product unknown → find the active brewman product whose normalized name matches — + more than one match fails hard — else create a new product; then find-or-create the SKU + by normalized units; write the mapping row (``created`` when a product was created, else + ``linked``). + +Names and units are compared through ``_normalize`` (the slug form). Creation cannot clash +on name or handle: branch 3 creates only when no active version's slug matches. + +Session discipline: the session runs with ``autoflush=False``. Every write in this module +flushes before returning so later lookups within the same import run observe it. +""" + +import re +import uuid + +from dataclasses import dataclass +from datetime import date +from decimal import Decimal +from typing import Any, Literal + +from fastapi import HTTPException, status +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from ...models.account_base import AccountBase +from ...models.barker_product import BarkerProduct +from ...models.product import Product +from ...models.product_group import ProductGroup +from ...models.product_version import ProductVersion +from ...models.sku_version import SkuVersion +from ...models.stock_keeping_unit import StockKeepingUnit +from .barker_client import BarkerSaleLine + + +MENU_ITEM_GROUP_ID = ProductGroup.menu_item() +ALL_PURCHASES_ID = AccountBase.all_purchases() + +Disposition = Literal["mapped", "linked", "created"] + + +@dataclass(frozen=True) +class ProvisionedProduct: + """The brewman product/SKU a Barker line resolves to, and what provisioning did. + + Attributes: + product_id: the brewman product the line belongs to. + sku_id: the brewman SKU the line's inventory line references. + disposition: what provisioning did — ``mapped`` (served from the standing mapping, + nothing written), ``linked`` (new binding to an existing product, possibly with a + newly created SKU), or ``created`` (a new product and its first SKU). + """ + + product_id: uuid.UUID + sku_id: uuid.UUID + disposition: Disposition + + +def resolve_product(db: Session, line: BarkerSaleLine, date_: date) -> ProvisionedProduct: + """Resolve (or create) the brewman product/SKU for one Barker sale line (ADR-0004). + + The single entry point of the provisioning module. Works through the three branches of + the module docstring: standing mapping, sibling-SKU mapping, then name resolution. + + Args: + db: the request session. + line: the Barker sale line to resolve. + date_: the business date of the line; drives the validity windows. + + Returns: + The resolved product/SKU ids with the disposition taken. + + Raises: + HTTPException: 409 when the normalized name matches more than one active product. + """ + binding = db.execute(select(BarkerProduct).where(BarkerProduct.barker_sku_id == line.sku_id)).scalar_one_or_none() + if binding is not None: + return ProvisionedProduct( + product_id=binding.brewman_product_id, sku_id=binding.brewman_sku_id, disposition="mapped" + ) + known_product_id = ( + db.execute( + select(BarkerProduct.brewman_product_id) + .where(BarkerProduct.barker_product_id == line.product_id) + .order_by(BarkerProduct.id) + ) + .scalars() + .first() + ) + if known_product_id is not None: + sku_id = _find_or_create_sku(db, known_product_id, line, date_) + _write_mapping(db, line, known_product_id, sku_id) + return ProvisionedProduct(product_id=known_product_id, sku_id=sku_id, disposition="linked") + + product_id = _find_product_id_by_name(db, line.name, date_) + disposition: Disposition = "linked" + if product_id is None: + product_id = _create_product(db, line, date_) + disposition = "created" + sku_id = _find_or_create_sku(db, product_id, line, date_) + _write_mapping(db, line, product_id, sku_id) + return ProvisionedProduct(product_id=product_id, sku_id=sku_id, disposition=disposition) + + +def existing_binding(db: Session, sku_id: uuid.UUID) -> tuple[uuid.UUID, uuid.UUID] | None: + """Return the brewman (product_id, sku_id) a Barker SKU is currently mapped to, or None. + + Args: + db: the request session. + sku_id: the Barker SKU to look up. + + Returns: + The mapped brewman ids, or None when the Barker SKU has never been provisioned. + """ + row = db.execute(select(BarkerProduct).where(BarkerProduct.barker_sku_id == sku_id)).scalar_one_or_none() + return None if row is None else (row.brewman_product_id, row.brewman_sku_id) + + +def _normalize(value: str) -> str: + """Return the comparison form of a name or units (ADR-0004). + + Trimmed, casefolded, punctuation stripped, whitespace/underscores collapsed to single + hyphens, leading and trailing hyphens removed — the slug form. Used identically for + product names and SKU units. + + Args: + value: the raw name or units to normalize. + + Returns: + The normalized string; e.g. ``"Butter Chicken!"`` and ``"butter-chicken"`` both + normalize to ``"butter-chicken"``. + """ + value = value.strip().lower() + value = re.sub(r"[^\w\s-]", "", value) + value = re.sub(r"[\s_-]+", "-", value) + value = re.sub(r"^-+|-+$", "", value) + return value + + +def _pg_normalize(column: Any) -> Any: + """Return the SQLAlchemy expression to normalize a string in PostgreSQL, matching _normalize.""" + return func.btrim( + func.regexp_replace( + func.regexp_replace(func.lower(func.trim(column)), r"[^\w\s-]", "", "g"), r"[\s_-]+", "-", "g" + ), + "-", + ) + + +def _find_product_id_by_name(db: Session, name: str, date_: date) -> uuid.UUID | None: + """Find the active brewman product whose normalized name matches the line's. + + Args: + db: the request session. + name: Barker's current product name. + date_: the business date the version must be valid on; NULL version bounds count as + unbounded (legacy forever-versions included). + + Returns: + The matching product's id, or None when nothing matches. + + Raises: + HTTPException: 409 when more than one product's active version normalizes to the + same slug — only possible through manual brewman edits; fail hard rather than + guess. + """ + wanted = _normalize(name) + normalized_name = _pg_normalize(ProductVersion.name) + rows = ( + db.execute( + select(ProductVersion.product_id, ProductVersion.name).where( + normalized_name == wanted, + (ProductVersion.valid_from == None) # noqa: E711 + | (ProductVersion.valid_from <= date_), + (ProductVersion.valid_till == None) # noqa: E711 + | (ProductVersion.valid_till >= date_), + ) + ) + .scalars() + .all() + ) + if rows.count > 1: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"Cannot import: '{name}' matches multiple products: " + + ", ".join(f"'{name}'" for (id_, name) in rows), + ) + return rows[0][0] if rows else None + + +def _find_or_create_sku(db: Session, product_id: uuid.UUID, line: BarkerSaleLine, date_: date) -> uuid.UUID: + """Find the product's active SKU whose normalized units match, or add a new SKU row under it. + + Args: + db: the request session. + product_id: the product whose SKUs to search. + line: the Barker line supplying the units to match and, if creating, to store. + date_: the business date used for the active-version window and any new version. + + Returns: + The matched or newly created SKU's id. + """ + wanted = _normalize(line.units) + normalized_units = _pg_normalize(SkuVersion.units) + sku_id = ( + db.execute( + select(SkuVersion.sku_id).where( + SkuVersion._product_id == product_id, + normalized_units == wanted, + (SkuVersion.valid_from == None) # noqa: E711 + | (SkuVersion.valid_from <= date_), + (SkuVersion.valid_till == None) # noqa: E711 + | (SkuVersion.valid_till >= date_), + ) + ) + .scalars() + .first() + ) + + if sku_id is not None: + return sku_id + return _create_sku(db, product_id, line, date_) + + +def _create_sku(db: Session, product_id: uuid.UUID, line: BarkerSaleLine, date_: date) -> uuid.UUID: + """Create a new SKU row for a product with its first version. + + Args: + db: the request session. + product_id: the product the SKU belongs to. + line: the Barker line supplying the units. + date_: the business date the SKU version opens on. + + Returns: + The new SKU's id. + """ + sku = StockKeepingUnit(product_id=product_id) + db.add(sku) + db.flush() + db.add( + SkuVersion( + sku_id=sku.id, + units=line.units, + fraction=Decimal("1"), + product_yield=Decimal("1"), + cost_price=Decimal("0"), + sale_price=Decimal("0"), + valid_from=date_, + valid_till=None, + ) + ) + return sku.id + + +def _create_product(db: Session, line: BarkerSaleLine, date_: date) -> uuid.UUID: + """Create a brand-new product (group "Menu Items", account All Purchases) with its first SKU. + + Only called when no active version's normalized name matches the line, so creation + cannot clash on name or handle. + + Args: + db: the request session. + line: the Barker line supplying name and units. + date_: the business date the product and SKU versions open on. + + Returns: + The new product's id. + """ + product = Product() + db.add(product) + db.flush() + db.add( + ProductVersion( + product_id=product.id, + name=line.name, + fraction_units=line.units, + product_group_id=MENU_ITEM_GROUP_ID, + account_id=ALL_PURCHASES_ID, + is_purchased=False, + is_sold=True, + valid_from=date_, + valid_till=None, + ) + ) + _create_sku(db, product.id, line, date_) + return product.id + + +def _write_mapping(db: Session, line: BarkerSaleLine, product_id: uuid.UUID, sku_id: uuid.UUID) -> None: + """Insert the Barker SKU's mapping row if absent, or correct it, then flush. + + Args: + db: the request session. + line: the Barker line whose SKU mapping is written. + product_id: the brewman product the Barker SKU maps to. + sku_id: the brewman SKU the Barker SKU maps to. + """ + row = db.execute(select(BarkerProduct).where(BarkerProduct.barker_sku_id == line.sku_id)).scalar_one_or_none() + if row is None: + db.add( + BarkerProduct( + barker_product_id=line.product_id, + barker_sku_id=line.sku_id, + brewman_product_id=product_id, + brewman_sku_id=sku_id, + ) + ) + else: + row.barker_product_id = line.product_id + row.barker_sku_id = line.sku_id + row.brewman_product_id = product_id + row.brewman_sku_id = sku_id + db.flush() # autoflush is off — later lookups must see this row diff --git a/brewman/brewman/services/sales_import/service.py b/brewman/brewman/services/sales_import/service.py new file mode 100644 index 00000000..826795ef --- /dev/null +++ b/brewman/brewman/services/sales_import/service.py @@ -0,0 +1,377 @@ +"""Orchestration of the Barker sales import: mapping settings, preview and execute. + +``preview`` fetches Barker's sales, plans every mapped sale category without writing +anything (no provisioning), and reports every category — mapped or not — so the frontend can +show the mapping editor without a chicken-and-egg failure. ``execute`` requires every +category to be mapped, checks locks, provisions products, then deletes and recreates each +day's Sale Vouchers where the booked ones differ from the plan; a failed run rolls back +whole (the session's exception handler rolls back), a successful one commits once. +""" + +import uuid + +from datetime import date +from decimal import Decimal + +from fastapi import HTTPException, status +from sqlalchemy import select +from sqlalchemy.orm import Session + +from ...models.cost_centre import CostCentre +from ...models.db_setting import DbSetting +from ...models.setting_type import SettingType +from ...models.voucher import Voucher +from ...schemas import sales_import as schema +from ...schemas.cost_centre import CostCentreLink +from .barker_client import BarkerError, BarkerSaleDay, fetch_sales +from .products import existing_binding +from .vouchers import ( + PlannedVoucher, + build_planned_vouchers, + check_locks, + create_voucher, + delete_voucher, + existing_vouchers, + voucher_signature, +) + + +SETTING_NAME = "Sale Category Cost Centres" +PURCHASE_ID = CostCentre.cost_centre_purchase() +PRODUCTION_ID = CostCentre.cost_centre_production() + +Action = str # "create" | "skip" | "replace" | "mixed" + + +def load_mapping(db: Session) -> dict[uuid.UUID, uuid.UUID]: + """Load the standing sale-category → cost-centre mapping. + + Args: + db: the request session. + + Returns: + The mapping as a dict; empty when the setting has never been saved. + """ + today = date.today() + rows: list[dict[str, str]] = list( + db.execute( + select(DbSetting.data).where( + DbSetting.setting_type == SettingType.SALE_CATEGORY_COST_CENTRES, + (DbSetting.valid_from == None) # noqa: E711 + | (DbSetting.valid_from <= today), + (DbSetting.valid_till == None) # noqa: E711 + | (DbSetting.valid_till >= today), + ) + ) + .scalars() + .all() + ) + if not rows: + return {} + return {uuid.UUID(key): uuid.UUID(value) for key, value in rows[0].items()} + + +def save_mapping(db: Session, mapping: schema.MappingUpdate) -> None: + """Validate and persist the sale-category → cost-centre mapping, committing immediately. + + Args: + db: the request session. + mapping: the entries to save; entries with a null cost centre are dropped (unmapped). + + Raises: + HTTPException: 422 when a cost centre is the Purchase or Production cost centre + (sale categories must never credit those) or does not exist. + """ + data = {str(m.sale_category_id): str(m.cost_centre_id) for m in mapping.mapping if m.cost_centre_id is not None} + for value in data.values(): + cost_centre_id = uuid.UUID(value) + if cost_centre_id in {PURCHASE_ID, PRODUCTION_ID}: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Sale categories cannot map to the Purchase or Production cost centres", + ) + if db.execute(select(CostCentre.id).where(CostCentre.id == cost_centre_id)).scalar_one_or_none() is None: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"Cost centre {cost_centre_id} does not exist", + ) + row = db.execute( + select(DbSetting).where(DbSetting.setting_type == SettingType.SALE_CATEGORY_COST_CENTRES) + ).scalar_one_or_none() + if row is None: + db.add(DbSetting(setting_type=SettingType.SALE_CATEGORY_COST_CENTRES, name=SETTING_NAME, data=data)) + else: + row.data = data + db.commit() + + +def cost_centres(db: Session) -> list[CostCentreLink]: + """List the cost centres a sale category may be mapped to. + + Args: + db: the request session. + + Returns: + All cost centres ordered by name, minus the Purchase and Production cost centres. + """ + rows = db.execute(select(CostCentre).order_by(CostCentre.name)).scalars().all() + return [CostCentreLink(id_=row.id, name=row.name) for row in rows if row.id not in {PURCHASE_ID, PRODUCTION_ID}] + + +def _category_names(sales_days: list[BarkerSaleDay]) -> dict[uuid.UUID, str]: + """Collect every sale category present in the fetched days with its name. + + Args: + sales_days: the Barker days to scan. + + Returns: + Category id → name, first-seen wins. + """ + names: dict[uuid.UUID, str] = {} + for day in sales_days: + for line in day.lines: + names.setdefault(line.sale_category_id, line.sale_category_name) + return names + + +def _require_mapping(db: Session, categories: dict[uuid.UUID, str]) -> dict[uuid.UUID, uuid.UUID]: + """Load the saved mapping and refuse the run while any fetched category is unmapped. + + Args: + db: the request session. + categories: every sale category present in the fetched data. + + Returns: + The complete mapping (all fetched categories are guaranteed present). + + Raises: + HTTPException: 422 naming every unmapped category, so the operator can map them in + one pass. + """ + mapping = load_mapping(db) + missing = [name for category_id, name in sorted(categories.items()) if category_id not in mapping] + if missing: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Map these Barker sale categories to cost centres first: " + ", ".join(missing), + ) + return mapping + + +def _to_schema_voucher(plan: PlannedVoucher, action: Action, existing_amount: Decimal | None) -> schema.VoucherPlan: + """Shape a planned voucher into its API representation. + + Args: + plan: the planned voucher to shape. + action: the planned action against the booked voucher ("create", "skip", "replace"). + existing_amount: the amount currently booked, or None when nothing is booked. + + Returns: + The ``VoucherPlan`` sent to the frontend; line ids may be None when the plan was + built without provisioning. + """ + return schema.VoucherPlan( + sale_category_id=plan.sale_category_id, + sale_category_name=plan.sale_category_name, + cost_centre=CostCentreLink(id_=plan.cost_centre_id), + narration=plan.narration, + lines=[ + schema.SaleLine( + product_id=line.product_id, + sku_id=line.sku_id, + name=line.name, + units=line.units, + sale_category_id=plan.sale_category_id, + sale_category_name=plan.sale_category_name, + quantity=line.quantity, + rate=line.rate, + tax_rate=line.tax_rate, + discount=line.discount, + # is_happy_hour=line.is_happy_hour, + amount=line.amount, + ) + for line in plan.lines + ], + amount=plan.amount, + action=action, # type: ignore[arg-type] + existing_amount=existing_amount, + ) + + +def _day_plan(db: Session, day: BarkerSaleDay, mapping: dict[uuid.UUID, uuid.UUID], provision: bool) -> schema.DayPlan: + """Plan one Barker day against what is already booked for it. + + Args: + db: the request session. + day: the Barker day to plan. + mapping: sale-category id → cost centre id. + provision: passed through to the planner; False in preview (no writes), True in + execute. + + Returns: + The day's plan with per-voucher actions and the day-level aggregate action. + """ + expected = build_planned_vouchers(db, day, mapping, provision) + existing = existing_vouchers(db, day.date_) + return _plan_from(day.date_, expected, existing) + + +def _plan_from( + business_date: date, + expected: dict[uuid.UUID, PlannedVoucher], + existing: dict[uuid.UUID, Voucher], +) -> schema.DayPlan: + """Compare a day's planned vouchers against its booked ones and decide actions. + + Args: + business_date: the day being planned (used for output only). + expected: planned vouchers keyed by cost centre id. + existing: booked Sale Vouchers keyed by credit cost centre id. + + Returns: + The day plan: each voucher's action is ``skip`` when the booked signature matches the + plan, ``replace`` when a voucher exists but differs, ``create`` when none exists; the + day-level action is the single common action, ``mixed`` when they disagree, and + ``skip`` when there is nothing to do. A day whose booked vouchers are all absent from + the plan (category no longer mapped/sold) reports ``replace`` so they are removed. + """ + actions: list[Action] = [] + vouchers_out: list[schema.VoucherPlan] = [] + for cost_centre_id, plan in sorted(expected.items(), key=lambda kv: kv[1].sale_category_name): + existing_voucher = existing.get(cost_centre_id) + if existing_voucher is not None and voucher_signature(existing_voucher) == plan.signature(): + action: Action = "skip" + elif existing_voucher is not None: + action = "replace" + else: + action = "create" + actions.append(action) + vouchers_out.append( + _to_schema_voucher( + plan, + action, + sum((item.amount for item in existing_voucher.inventories), Decimal(0)) + if existing_voucher is not None + else None, + ) + ) + removed = [voucher for cc, voucher in existing.items() if cc not in expected] + if removed and not actions: + actions.append("replace") + return schema.DayPlan( + business_date=business_date, + action=actions[0] if len(set(actions)) == 1 and actions else ("mixed" if actions else "skip"), # type: ignore[arg-type] + vouchers=vouchers_out, + amount=sum((plan.amount for plan in expected.values()), Decimal(0)), + ) + + +def preview(db: Session, request: schema.ImportRequest) -> schema.PreviewResponse: + """Show what an import of the requested range would do, without writing anything. + + Fetches Barker's sales, plans only mapped categories (lenient — unmapped ones surface in + the mapping editor), lists product/SKU pairs that have never been provisioned, and + returns every category ever seen (mapped or not, with names only for those in the data) + for the mapping editor. + + Args: + db: the request session. + request: the date range to preview. + + Returns: + The preview: day plans, mapping entries, mappable cost centres, and new-product + labels. + + Raises: + HTTPException: 502 when Barker cannot be reached or returns bad data. + """ + try: + sales = fetch_sales(request.start_date, request.finish_date) + except BarkerError as e: + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(e)) from e + categories = _category_names(sales.sales) + saved = load_mapping(db) + # Lenient: preview never fails on unmapped categories — it plans only mapped ones and + # returns every category (mapped or not) so the frontend can show the mapping editor. + mapping = {category_id: cc for category_id, cc in saved.items() if category_id in categories} + + days_out = [_day_plan(db, day, mapping, provision=False) for day in sorted(sales.sales, key=lambda d: d.date_)] + new_products: list[str] = [] + for day in sales.sales: + for line in day.lines: + if existing_binding(db, line.sku_id) is None: + label = f"{line.name} ({line.units})" + if label not in new_products: + new_products.append(label) + + all_categories: dict[uuid.UUID, str | None] = dict(categories) + for category_id in saved: + all_categories.setdefault(category_id, None) + mapping_entries = [ + schema.MappingEntry( + sale_category_id=category_id, + sale_category_name=name, + cost_centre=CostCentreLink(id_=saved[category_id]) if category_id in saved else None, + ) + for category_id, name in sorted(all_categories.items(), key=lambda kv: (kv[1] is None, kv[1] or "")) + ] + return schema.PreviewResponse( + days=days_out, + mapping=mapping_entries, + cost_centres=cost_centres(db), + new_products=new_products, + ) + + +def execute(db: Session, request: schema.ImportRequest, user_id: uuid.UUID) -> schema.ExecuteResponse: + """Import the requested range: provision products, then delete-and-recreate what differs. + + For each business day in order: loads the booked vouchers, plans with provisioning on, + then deletes every booked voucher whose cost centre has a plan that differs (or no plan — + the category may no longer be mapped/sold) and writes the remaining planned vouchers. + Commits once at the end; a failure anywhere rolls the whole run back. + + Args: + db: the request session. + request: the date range to import. + user_id: the user the new vouchers are recorded against. + + Returns: + The executed day plans and a summary message counting vouchers created or replaced. + + Raises: + HTTPException: 422 while any fetched sale category is unmapped, 423 while any target + date is locked, 502 when Barker cannot be reached, 409 on provisioning clashes. + """ + try: + sales = fetch_sales(request.start_date, request.finish_date) + except BarkerError as e: + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(e)) from e + categories = _category_names(sales.sales) + mapping = _require_mapping(db, categories) + days = sorted(sales.sales, key=lambda d: d.date_) + check_locks(db, [day.date_ for day in days]) + + days_out: list[schema.DayPlan] = [] + for day in days: + existing = existing_vouchers(db, day.date_) + expected = build_planned_vouchers(db, day, mapping, provision=True) + plan_out = _plan_from(day.date_, expected, existing) + for old_voucher in existing.values(): + plan = expected.get(voucher_signature(old_voucher)[0]) + if plan is not None and voucher_signature(old_voucher) == plan.signature(): + continue + delete_voucher(db, old_voucher) + for cost_centre_id, plan in expected.items(): + existing_voucher = existing.get(cost_centre_id) + if existing_voucher is not None and voucher_signature(existing_voucher) == plan.signature(): + continue + create_voucher(db, plan, user_id) + db.flush() + days_out.append(plan_out) + db.commit() + created = sum(1 for day in days_out for voucher in day.vouchers if voucher.action in ("create", "replace")) + return schema.ExecuteResponse( + message=f"Imported {len(days_out)} day(s), {created} voucher(s) created or replaced.", + days=days_out, + ) diff --git a/brewman/brewman/services/sales_import/vouchers.py b/brewman/brewman/services/sales_import/vouchers.py new file mode 100644 index 00000000..3c5cec62 --- /dev/null +++ b/brewman/brewman/services/sales_import/vouchers.py @@ -0,0 +1,356 @@ +"""Planning and persistence of Sale Vouchers for the Barker sales import. + +A Barker business day is grouped into one planned voucher per sale-category cost centre +(``build_planned_vouchers``); the plan is compared against the Sale Vouchers already booked +for that day via ``voucher_signature``; ``execute`` (in ``service.py``) deletes what differs +and writes what is missing through ``create_voucher``/``delete_voucher``. + +Value model (ADR-0001): inventory lines are valued at the POS sale price and written against +synthetic batches with ``quantity_remaining = 0``; the voucher carries two journals on the All +Purchases account — debiting the Production cost centre and crediting the sale category's +cost centre — so the P&L is untouched and only cost-centre attribution moves. +""" + +import uuid + +from collections import defaultdict +from dataclasses import dataclass, field +from datetime import date +from decimal import Decimal +from typing import Any + +from fastapi import HTTPException, status +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from ...models.account_base import AccountBase +from ...models.batch import Batch +from ...models.cost_centre import CostCentre +from ...models.inventory import Inventory +from ...models.journal import Journal +from ...models.validations import check_journals_are_valid +from ...models.voucher import Voucher +from ...models.voucher_type import VoucherType +from ...routers import get_lock_info +from .barker_client import BarkerSaleDay, BarkerSaleLine +from .products import ALL_PURCHASES_ID, ProvisionedProduct, existing_binding, resolve_product + + +NARRATION_PREFIX = "Sales import from Barker for" +PRODUCTION_ID = CostCentre.cost_centre_production() +Q2 = Decimal("0.01") +Q5 = Decimal("0.00001") + + +@dataclass +class PlannedLine: + """One aggregated sale line inside a planned voucher. + + Attributes: + product_id: the brewman product, or None when the line is not yet provisioned. + sku_id: the brewman SKU, or None when not yet provisioned. + name: the Barker product name, for display and error messages. + units: the Barker units the line sold in. + quantity: the total quantity sold across all Barker lines that aggregated into this + one (lines merge when SKU, price, tax and discount all match). + rate: the POS sale price per unit. + tax_rate: the tax rate applied at the POS. + discount: the discount fraction applied at the POS. + + ``amount`` derives the line value as ``quantity × rate × (1 + tax) × (1 - discount)`` + rounded to paise (ADR-0001: valued at POS sale price). + """ + + product_id: uuid.UUID | None + sku_id: uuid.UUID | None + name: str + units: str + quantity: Decimal + rate: Decimal + tax_rate: Decimal + discount: Decimal + # is_happy_hour: bool + + @property + def amount(self) -> Decimal: + return round(self.quantity * self.rate * (1 + self.tax_rate) * (1 - self.discount), 2) + + def signature(self) -> tuple[uuid.UUID | None, Decimal, Decimal, Decimal, Decimal]: + """Return the fields that decide whether this line is identical to a booked one.""" + return ( + self.sku_id, + self.quantity.quantize(Q2), + self.rate.quantize(Q2), + self.tax_rate.quantize(Q5), + self.discount.quantize(Q5), + # self.is_happy_hour, + ) + + +@dataclass +class PlannedVoucher: + """One planned Sale Voucher: a business date and sale category bound to a cost centre. + + Attributes: + business_date: the Barker business date the voucher belongs to. + sale_category_id: the Barker sale category the lines were sold under. + sale_category_name: the category's name, for narration and display. + cost_centre_id: the cost centre the sale credits — the one mapped to the category. + lines: the aggregated sale lines, sorted by name then rate. + + ``amount`` sums the line amounts; ``narration`` renders the standard import narration; + ``signature`` is the fingerprint compared against booked vouchers to decide + skip/replace (``voucher_signature`` produces the matching fingerprint from a voucher). + """ + + business_date: date + sale_category_id: uuid.UUID + sale_category_name: str + cost_centre_id: uuid.UUID + lines: list[PlannedLine] = field(default_factory=list) + + @property + def amount(self) -> Decimal: + return sum((line.amount for line in self.lines), Decimal(0)) + + @property + def narration(self) -> str: + return f"{NARRATION_PREFIX} {self.business_date.strftime('%d-%b-%Y')} ({self.sale_category_name})" + + def signature(self) -> tuple[uuid.UUID, tuple[tuple[uuid.UUID | None, Decimal, Decimal, Decimal, Decimal], ...]]: + return self.cost_centre_id, tuple(sorted(line.signature() for line in self.lines)) + + +def build_planned_vouchers( + db: Session, day: BarkerSaleDay, mapping: dict[uuid.UUID, uuid.UUID], provision: bool +) -> dict[uuid.UUID, PlannedVoucher]: + """Group one Barker business day into planned vouchers, one per sale-category cost centre. + + Args: + db: the request session. + day: the Barker day whose lines are planned. + mapping: sale-category id → cost centre id; categories absent from it are skipped + (preview surfaces them in the mapping editor instead of failing). + provision: when True, product/SKU ids are resolved (creating products as needed) via + ``resolve_product``; when False, lines carry ids only where a mapping already + exists, so preview never writes. + + Returns: + Planned vouchers keyed by cost centre id — the key existing Sale Vouchers are keyed + by too, which is what makes the skip/replace comparison possible. + """ + by_category: dict[uuid.UUID, list[BarkerSaleLine]] = defaultdict(list) + names: dict[uuid.UUID, str] = {} + for line in day.lines: + by_category[line.sale_category_id].append(line) + names.setdefault(line.sale_category_id, line.sale_category_name) + + vouchers: dict[uuid.UUID, PlannedVoucher] = {} + for category_id in sorted(by_category): + grouped: dict[tuple[Any, ...], PlannedLine] = {} + for line in by_category[category_id]: + product_id: uuid.UUID | None + sku_id: uuid.UUID | None + if provision: + resolved: ProvisionedProduct = resolve_product(db, line, day.date_) + product_id, sku_id = resolved.product_id, resolved.sku_id + else: + binding = existing_binding(db, line.sku_id) + product_id, sku_id = binding if binding is not None else (None, None) + # key = (line.sku_id, line.price, line.tax_rate, line.discount, line.is_happy_hour) + key = (line.sku_id, line.price, line.tax_rate, line.discount) + if key in grouped: + grouped[key].quantity += line.quantity + else: + grouped[key] = PlannedLine( + product_id=product_id, + sku_id=sku_id, + name=line.name, + units=line.units, + quantity=line.quantity, + rate=line.price, + tax_rate=line.tax_rate, + discount=line.discount, + # is_happy_hour=line.is_happy_hour, + ) + if category_id not in mapping: + continue # unmapped categories are left out of the plan; preview surfaces them in the mapping editor + plan = PlannedVoucher( + business_date=day.date_, + sale_category_id=category_id, + sale_category_name=names[category_id], + cost_centre_id=mapping[category_id], + lines=sorted(grouped.values(), key=lambda line: (line.name, line.rate)), + ) + vouchers[plan.cost_centre_id] = plan + return vouchers + + +def existing_vouchers(db: Session, business_date: date) -> dict[uuid.UUID, Voucher]: + """Load the Sale Vouchers already booked for a business date, keyed by credit cost centre. + + Args: + db: the request session. + business_date: the day whose Sale Vouchers to load. + + Returns: + Booked vouchers keyed by the cost centre of their credit journal — the same key the + planned vouchers use. A voucher without a credit journal is skipped. + """ + vouchers = ( + db.execute( + select(Voucher) + .where(Voucher.date_ == business_date, Voucher.voucher_type == VoucherType.SALE) + .order_by(Voucher.creation_date) + ) + .scalars() + .unique() + .all() + ) + result: dict[uuid.UUID, Voucher] = {} + for voucher in vouchers: + credit = next((j for j in voucher.journals if j.debit == -1), None) + if credit is not None: + result[credit.cost_centre_id] = voucher + return result + + +def voucher_signature( + voucher: Voucher, +) -> tuple[uuid.UUID, tuple[tuple[uuid.UUID | None, Decimal, Decimal, Decimal, Decimal], ...]]: + """Fingerprint a booked voucher in the same shape as ``PlannedVoucher.signature``. + + Args: + voucher: the booked Sale Voucher to fingerprint. + + Returns: + The credit cost centre id plus the sorted per-inventory tuple of (SKU, quantity, + rate, tax, discount) at the same quantisation as the plan side. + """ + lines = tuple( + sorted( + ( + item.batch.sku_id, + item.quantity.quantize(Q2), + item.rate.quantize(Q2), + item.tax.quantize(Q5), + item.discount.quantize(Q5), + # item.is_happy_hour, + ) + for item in voucher.inventories + ) + ) + credit = next(j for j in voucher.journals if j.debit == -1) + return credit.cost_centre_id, lines + + +def delete_voucher(db: Session, voucher: Voucher) -> None: + """Delete a Sale Voucher and the synthetic batches its inventories pointed at. + + Args: + db: the request session. + voucher: the booked Sale Voucher to remove. Batches created by the import have + ``quantity_remaining = 0`` and are deleted when no other inventory references + them; purchased batches are left alone. + """ + batch_ids = [item.batch_id for item in voucher.inventories] + db.delete(voucher) + db.flush() + for batch_id in batch_ids: + uses = db.execute(select(func.count(Inventory.id)).where(Inventory.batch_id == batch_id)).scalar_one() + if uses == 0: + batch = db.get(Batch, batch_id) + if batch is not None: + db.delete(batch) + + +def create_voucher(db: Session, plan: PlannedVoucher, user_id: uuid.UUID) -> None: + """Write one planned voucher to the books as a read-only Sale Voucher. + + Creates a synthetic batch per line (``quantity_remaining = 0``, ADR-0001) and two + journals on the All Purchases account: credit to the sale category's cost centre, debit + to the Production cost centre. Nothing is committed here; ``execute`` commits the run. + + Args: + db: the request session. + plan: the planned voucher to write; its lines must carry provisioned SKU ids. + user_id: the user the voucher is recorded against. + + Raises: + HTTPException: 422 if a line somehow reached persistence without a provisioned SKU + (defence in depth — ``execute`` always provisions first). + """ + voucher = Voucher( + date_=plan.business_date, + narration=plan.narration, + is_starred=False, + user_id=user_id, + voucher_type=VoucherType.SALE, + ) + db.add(voucher) + db.flush() + for line in plan.lines: + if line.sku_id is None: # pragma: no cover - execute provisions first + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"Product {line.name} ({line.units}) is not mapped", + ) + batch = Batch( + name=plan.business_date, + quantity_remaining=Decimal("0"), + rate=line.rate, + tax=line.tax_rate, + discount=line.discount, + sku_id=line.sku_id, + ) + db.add(batch) + item = Inventory( + quantity=line.quantity, + rate=line.rate, + tax=line.tax_rate, + discount=line.discount, + batch=batch, + voucher_id=voucher.id, + # is_happy_hour=line.is_happy_hour, + ) + voucher.inventories.append(item) + db.add(item) + amount = sum((item.amount for item in voucher.inventories), Decimal(0)) + voucher.journals.append( + Journal(debit=-1, account_id=ALL_PURCHASES_ID, amount=round(amount, 2), cost_centre_id=plan.cost_centre_id) + ) + voucher.journals.append( + Journal(debit=1, account_id=ALL_PURCHASES_ID, amount=round(amount, 2), cost_centre_id=PRODUCTION_ID) + ) + check_journals_are_valid(voucher) + + +def all_purchases_account_types(db: Session) -> list[int]: + """Return the account-type ids of the All Purchases account, for lock checking. + + Args: + db: the request session. + + Returns: + The type ids (usually one) that lock rules are evaluated against for this voucher + type's accounts. + """ + return list(db.execute(select(AccountBase.type_id).where(AccountBase.id == ALL_PURCHASES_ID)).scalars().all()) + + +def check_locks(db: Session, business_dates: list[date]) -> None: + """Refuse the import when any target business date is locked for Sale Vouchers. + + Args: + db: the request session. + business_dates: every business date the run intends to touch. + + Raises: + HTTPException: 423 with the lock message when any date is locked or back-dated + beyond the allowed window. + """ + if not business_dates: + return + allowed, message = get_lock_info(business_dates, VoucherType.SALE, all_purchases_account_types(db), db) + if not allowed: + raise HTTPException(status_code=status.HTTP_423_LOCKED, detail=message) diff --git a/docs/agents/domain.md b/docs/agents/domain.md new file mode 100644 index 00000000..35249041 --- /dev/null +++ b/docs/agents/domain.md @@ -0,0 +1,51 @@ +# Domain Docs + +How the engineering skills should consume this repo's domain documentation when exploring the codebase. + +## Before exploring, read these + +- **`CONTEXT.md`** at the repo root, or +- **`CONTEXT-MAP.md`** at the repo root if it exists: it points at one `CONTEXT.md` per context. Read each one relevant to the topic. +- **`docs/adr/`**: read ADRs that touch the area you're about to work in. In multi-context repos, also check `src//docs/adr/` for context-scoped decisions. + +If any of these files don't exist, **proceed silently**. Don't flag their absence; don't suggest creating them upfront. The `/domain-modeling` skill (reached via `/grill-with-docs` and `/improve-codebase-architecture`) creates them lazily when terms or decisions actually get resolved. + +## File structure + +Single-context repo (most repos): + +``` +/ +├── CONTEXT.md +├── docs/adr/ +│ ├── 0001-event-sourced-orders.md +│ └── 0002-postgres-for-write-model.md +└── src/ +``` + +Multi-context repo (presence of `CONTEXT-MAP.md` at the root): + +``` +/ +├── CONTEXT-MAP.md +├── docs/adr/ ← system-wide decisions +└── src/ + ├── ordering/ + │ ├── CONTEXT.md + │ └── docs/adr/ ← context-specific decisions + └── billing/ + ├── CONTEXT.md + └── docs/adr/ +``` + +## Use the glossary's vocabulary + +When your output names a domain concept (in an issue title, a refactor proposal, a hypothesis, a test name), use the term as defined in `CONTEXT.md`. Don't drift to synonyms the glossary explicitly avoids. + +If the concept you need isn't in the glossary yet, that's a signal: either you're inventing language the project doesn't use (reconsider) or there's a real gap (note it for `/domain-modeling`). + +## Flag ADR conflicts + +If your output contradicts an existing ADR, surface it explicitly rather than silently overriding: + +> _Contradicts ADR-0007 (event-sourced orders), but worth reopening because…_ diff --git a/docs/agents/issue-tracker.md b/docs/agents/issue-tracker.md new file mode 100644 index 00000000..0209a19a --- /dev/null +++ b/docs/agents/issue-tracker.md @@ -0,0 +1,30 @@ +# Issue tracker: Local Markdown + +Issues and specs for this repo live as markdown files in `.scratch/`. + +## Conventions + +- One feature per directory: `.scratch//` +- The spec is `.scratch//spec.md` +- Implementation issues are one file per ticket at `.scratch//issues/-.md`, numbered from `01`, never a single combined tickets file +- Triage state is recorded as a `Status:` line near the top of each issue file (see `triage-labels.md` for the role strings) +- Comments and conversation history append to the bottom of the file under a `## Comments` heading + +## When a skill says "publish to the issue tracker" + +Create a new file under `.scratch//` (creating the directory if needed). + +## When a skill says "fetch the relevant ticket" + +Read the file at the referenced path. The user will normally pass the path or the issue number directly. + +## Wayfinding operations + +Used by `/wayfinder`. The **map** is a file with one **child** file per ticket. + +- **Map**: `.scratch//map.md` (the Notes / Decisions-so-far / Fog body). +- **Child ticket**: `.scratch//issues/NN-.md`, numbered from `01`, with the question in the body. A `Type:` line records the ticket type (`research`/`prototype`/`grilling`/`task`); a `Status:` line records `claimed`/`resolved`. +- **Blocking**: a `Blocked by: NN, NN` line near the top. A ticket is unblocked when every file it lists is `resolved`. +- **Frontier**: scan `.scratch//issues/` for files that are open, unblocked, and unclaimed; first by number wins. +- **Claim**: set `Status: claimed` and save before any work. +- **Resolve**: append the answer under an `## Answer` heading, set `Status: resolved`, then append a context pointer (gist + link) to the map's Decisions-so-far in `map.md`. diff --git a/docs/agents/triage-labels.md b/docs/agents/triage-labels.md new file mode 100644 index 00000000..b716855d --- /dev/null +++ b/docs/agents/triage-labels.md @@ -0,0 +1,15 @@ +# Triage Labels + +The skills speak in terms of five canonical triage roles. This file maps those roles to the actual label strings used in this repo's issue tracker. + +| Label in mattpocock/skills | Label in our tracker | Meaning | +| -------------------------- | -------------------- | ---------------------------------------- | +| `needs-triage` | `needs-triage` | Maintainer needs to evaluate this issue | +| `needs-info` | `needs-info` | Waiting on reporter for more information | +| `ready-for-agent` | `ready-for-agent` | Fully specified, ready for an AFK agent | +| `ready-for-human` | `ready-for-human` | Requires human implementation | +| `wontfix` | `wontfix` | Will not be actioned | + +When a skill mentions a role (e.g. "apply the AFK-ready triage label"), use the corresponding label string from this table. + +Edit the right-hand column to match whatever vocabulary you actually use. diff --git a/docs/sale-import.md b/docs/sale-import.md new file mode 100644 index 00000000..c1f7d071 --- /dev/null +++ b/docs/sale-import.md @@ -0,0 +1,209 @@ +# Sale Import — function reference + +How Barker's sales become brewman Sale Vouchers: what every function does, what it takes, +what it returns, and what it writes. The code docstrings are the primary source; this page is +the map. + +## Pipeline + +``` +routers/sales_import.py thin HTTP layer (auth + permission only) + └─ services/sales_import/ the subsystem (this package) + __init__.py public surface: preview, execute, load/save mapping, cost_centres + service.py orchestration: fetch → map categories → plan → apply → shape schema + products.py provisioning: which brewman product/SKU does a Barker line mean? + vouchers.py planning + persistence: planned vouchers, signatures, write/delete + barker_client.py adapter: Barker's /api/export/sales → typed payloads +``` + +Dependency direction: `service → vouchers → products`, and `service → products`. Nothing +imports upward, `__init__.py` re-exports only the public five, and the mapping table +(`barker_products`) is written only inside `products.py`. + +## The three kinds of logic + +Every product/SKU function mixes three concerns; knowing which one you are looking at is +half the reading: + +1. **Identity policy** (ADR-0004) — *what a Barker line means*: the standing mapping, once + made, is served verbatim; a Barker product's other SKUs bind next; the slugified name is + only the resolver for a product never mapped before. +2. **Normalization + validity mechanics** — *how matching is decided*: names and units are + compared in slug form; "active" means the version's date range covers the business date. +3. **Session discipline** — *incidental machinery*: the session runs with `autoflush=False`, + so every write flushes before returning or later lookups in the same run won't see it. + +## Invariants worth remembering + +- **`autoflush=False`**: `db.add(...)` is invisible until `db.flush()`. Provisioning writes + flush before returning; a failed run rolls back whole (`get_session`), so `execute`'s + single `db.commit()` is the only durable write. +- **The mapping is standing truth**: once a Barker SKU has a mapping row, it is served + verbatim forever. Barker renames and units changes after first mapping are ignored — the + drift is visible in the preview (which shows Barker's names) and fixed by editing brewman + by hand. +- **Slug identity**: names and units match through `_normalize` (trim, casefold, punctuation + stripped, whitespace/underscores → hyphens). "Butter Chicken", "Butter-Chicken" and + "butter chicken" are one product. The stored `handle` is never read. +- **Ambiguity fails hard**: if a slug matches more than one active product (only possible + via manual brewman edits), the import raises 409 naming the candidates — no guessing. +- **Creation cannot clash**: branch 3 creates only when no active version's slug matches, so + the name and handle exclusions can never be hit by the import. +- **Per-SKU mapping**: `barker_products` holds one row per Barker SKU, so one Barker product + can span several brewman SKUs; branch 2 binds a new Barker SKU to the product its sibling + SKUs already use. +- **Legacy forever-versions**: old products have `valid_from = NULL` (active since forever); + the name search links to them like any other active version. +- **Idempotence**: a day whose booked vouchers match the plan is skipped entirely + (signature equality), so re-running the same range is a no-op. + +--- + +## `barker_client.py` — the Barker adapter + +Pure adapter: GETs Barker's export endpoint and validates the payload. Raises +`BarkerError` on anything unusable; callers translate that to HTTP 502. + +| Function / class | What | Inputs | Outputs | +| --- | --- | --- | --- | +| `fetch_sales(start_date, finish_date)` | Fetch and validate one date range of Barker sales | start and finish business dates | `BarkerSales` (validated pydantic model) | +| `BarkerSaleLine` | One POS sale line: product/SKU ids, `name`, `units`, sale category, `quantity`, `price`, `tax_rate`, `discount` | Barker payload (camelCase aliases) | typed line | +| `BarkerSaleDay` | One business date with its lines | Barker payload | typed day (`.date_`, `.lines`) | +| `BarkerSaleCategory` | A sale category id and name | Barker payload | typed category | +| `BarkerSales` | The whole export: `sales` days + `sale_categories` | Barker payload | typed export | +| `BarkerError` | Raised when Barker is unreachable or the payload is unusable | — | exception | + +--- + +## `products.py` — provisioning + +### Public seam + +**`resolve_product(db, line, date_) -> ProvisionedProduct`** +The single entry point; the planning loop learns only this. +- *Inputs*: session, the Barker line, the line's business date. +- *Outputs*: `ProvisionedProduct(product_id, sku_id, disposition)`. +- *Resolution order* (ADR-0004): Barker SKU mapped → return verbatim; else Barker product + mapped (sibling SKU row) → place this SKU under that product; else match the active + product by normalized name → use it or create → place the SKU. The mapping row is written + whenever a binding is established. +- *Writes*: the mapping row always; SKU and product rows only when creating. Flushes. +- *Errors*: 409 when the normalized name matches more than one active product. + +**`ProvisionedProduct`** (frozen dataclass) — `product_id`, `sku_id`, `disposition`. + +**`Disposition`** — what provisioning did: `mapped` (served from the standing mapping, +nothing written), `linked` (new binding to an existing product — sibling-SKU branch or +name-resolved), `created` (a new product and its first SKU). This is what the preview's +insert-vs-update classification will read. + +**`existing_binding(db, sku_id) -> tuple[product_id, sku_id] | None`** — read-only lookup +of the mapping row; also branch 1's lookup, and used by preview so it never provisions. + +### Implementation (private) + +| Function | What | Inputs | Outputs / Errors | +| --- | --- | --- | --- | +| `_normalize(value)` | The slug comparison form of a name or units: trim, casefold, punctuation stripped, whitespace/underscores → hyphens | raw string | normalized string | +| `_find_product_id_by_name(db, name, date_)` | The one active product whose normalized name matches | session, Barker name, date | product id or None; **409** naming candidates when several match | +| `_find_or_create_sku(db, product_id, line, date_)` | Reuse the product's active SKU whose normalized units match; else add a SKU under it | session, product id, line, date | `sku_id` | +| `_create_product(db, line, date_)` | New product ("Menu Items", All Purchases) + first SKU; cannot clash — only called when no active slug matches | session, line, date | `product_id` | +| `_create_sku(db, product_id, line, date_)` | New SKU row + first version | session, product id, line, date | `sku_id` | +| `_write_mapping(db, line, product_id, sku_id)` | Insert the mapping row if absent or correct it, then flush (autoflush off) | session, line, ids | None | + +Constants: `MENU_ITEM_GROUP_ID`, `ALL_PURCHASES_ID` (the imported product's account). + +--- + +## `vouchers.py` — planning and persistence + +### Plan side + +**`PlannedLine`** (dataclass) — one aggregated sale line: `product_id`/`sku_id` (None when +not provisioned), `name`, `units`, `quantity`, `rate`, `tax_rate`, `discount`. `amount` = +`quantity × rate × (1 + tax) × (1 − discount)` at paise precision (ADR-0001). `signature()` +is the fingerprint compared against booked vouchers. + +**`PlannedVoucher`** (dataclass) — one planned Sale Voucher: `business_date`, +`sale_category_id`/`name`, `cost_centre_id`, sorted `lines`. `amount` sums lines; +`narration` renders the standard import narration; `signature()` = `(cost_centre_id, sorted +line signatures)`. + +**`build_planned_vouchers(db, day, mapping, provision) -> dict[cost_centre_id, PlannedVoucher]`** +- *Inputs*: session, a Barker day, the category→cost-centre mapping, `provision` flag. +- *What*: groups lines by sale category, aggregates lines with identical + (SKU, price, tax, discount) by summing quantity, resolves ids — through + `resolve_product` when `provision=True` (preview=False never writes) — and drops + categories absent from the mapping (preview surfaces them in the editor instead). +- *Outputs*: plans keyed by cost centre — the same key booked vouchers are keyed by, which + is what makes the comparison possible. + +### Comparison side + +| Function | What | Inputs | Outputs | +| --- | --- | --- | --- | +| `existing_vouchers(db, business_date)` | Booked Sale Vouchers for a day, keyed by the cost centre of their credit journal | session, date | `dict[cost_centre_id, Voucher]` | +| `voucher_signature(voucher)` | Fingerprint of a booked voucher in the plan's shape: credit cost centre + sorted (SKU, qty, rate, tax, discount) at the same quantisation | voucher | signature tuple | + +### Persistence side + +| Function | What | Inputs | Outputs / Errors | +| --- | --- | --- | --- | +| `create_voucher(db, plan, user_id)` | Write one plan as a read-only Sale Voucher: synthetic batches (`quantity_remaining=0`), two journals on All Purchases (credit sale-category cost centre, debit Production). No commit | session, plan, user | None; **422** if a line lacks a SKU (defence in depth) | +| `delete_voucher(db, voucher)` | Delete a booked Sale Voucher and its import-created batches (only when no other inventory references them; purchased batches survive) | session, voucher | None | +| `check_locks(db, business_dates)` | Refuse the run when a target date is locked for SALE vouchers | session, dates | None; **423** with the lock message | +| `all_purchases_account_types(db)` | Account types of All Purchases, for the lock check | session | `list[int]` | + +--- + +## `service.py` — orchestration and settings + +### Mapping settings (public API) + +| Function | What | Inputs | Outputs / Errors | +| --- | --- | --- | --- | +| `load_mapping(db)` | Standing category→cost-centre mapping from `DbSetting` (today-valid row) | session | `dict[category_id, cost_centre_id]`, `{}` when never saved | +| `save_mapping(db, mapping)` | Validate and persist the mapping, committing immediately; drops null-cost-centre entries | session, `MappingUpdate` | None; **422** for Purchase/Production targets or unknown cost centres | +| `cost_centres(db)` | Cost centres a category may map to (all, minus Purchase and Production) | session | `list[CostCentreLink]` | + +### Planning helpers (private) + +| Function | What | Inputs | Outputs | +| --- | --- | --- | --- | +| `_category_names(sales_days)` | Every category in the fetched data with its name | Barker days | `dict[category_id, name]` | +| `_require_mapping(db, categories)` | Load the mapping and refuse while any fetched category is unmapped | session, categories | complete mapping; **422** naming all missing | +| `_to_schema_voucher(plan, action, existing_amount)` | Shape a plan into the API's `VoucherPlan` | plan, action, booked amount or None | `schema.VoucherPlan` | +| `_day_plan(db, day, mapping, provision)` | Plan one day against what is booked for it | session, day, mapping, provision | `schema.DayPlan` | +| `_plan_from(business_date, expected, existing)` | Decide actions: `skip` when signatures match, `replace` when a voucher exists but differs, `create` when none; day action = the common action or `mixed`; a day whose booked vouchers have no plan also reports `replace` (they will be removed) | date, plans, booked | `schema.DayPlan` | + +### The two entry points + +**`preview(db, request) -> PreviewResponse`** — read-only. +Fetches Barker (502 on failure), plans mapped categories with `provision=False` (no +writes at all), lists never-provisioned product labels (`new_products`), and returns every +category ever seen — mapped or not — for the mapping editor. Lenient by design: it never +fails on unmapped categories, or the mapping editor could never be filled in. + +**`execute(db, request, user_id) -> ExecuteResponse`** — writes. +Strict: 422 while any fetched category is unmapped. Checks locks (423) across the whole +range first. Per day, in order: load booked → plan with provisioning → delete every booked +voucher that has no matching plan (signature compare; a category no longer mapped/sold is +removed this way) → create the rest. `db.flush()` per day, one `db.commit()` at the end; any +failure rolls back the whole run. + +--- + +## `routers/sales_import.py` — HTTP layer + +Thin: permission gate + session + delegation. `POST /api/sales-import/preview`, +`POST /api/sales-import/execute`, `PUT /api/sales-import/mapping`, +`GET /api/sales-import/voucher/{id_}` (404 unless the voucher is a SALE voucher). Sale +vouchers are read-only everywhere else too — edit/delete are refused for imported ones. + +## Decision records + +- ADR-0001 — sale vouchers are valued at POS sale price. +- ADR-0002 — products are provisioned automatically by ID; versions carry master-data change. +- ADR-0003 — the handle is ignored; the name was made identity (partly superseded). +- ADR-0004 — the product mapping is standing truth; slug-normalized names and units are the + first-sight resolvers; renames and units changes after mapping are ignored. diff --git a/overlord/src/app/app.routes.ts b/overlord/src/app/app.routes.ts index 8e1bf134..5ba487ee 100644 --- a/overlord/src/app/app.routes.ts +++ b/overlord/src/app/app.routes.ts @@ -149,6 +149,10 @@ export const routes: Routes = [ path: 'purchase-return', loadChildren: () => import('./purchase-return/purchase-return.routes').then((mod) => mod.routes), }, + { + path: 'sales-import', + loadChildren: () => import('./sales-import/sales-import.routes').then((mod) => mod.routes), + }, { path: 'settings', loadChildren: () => import('./settings/settings.routes').then((mod) => mod.routes), diff --git a/overlord/src/app/core/nav-bar/nav-bar.component.html b/overlord/src/app/core/nav-bar/nav-bar.component.html index 7a4413e8..41981583 100644 --- a/overlord/src/app/core/nav-bar/nav-bar.component.html +++ b/overlord/src/app/core/nav-bar/nav-bar.component.html @@ -10,6 +10,7 @@ Receipt - F6 Issue Transaction Import + Sales Import diff --git a/overlord/src/app/recipe/recipe-import-dialog/recipe-import-dialog.component.css b/overlord/src/app/recipe/recipe-import-dialog/recipe-import-dialog.component.css new file mode 100644 index 00000000..d1ef010f --- /dev/null +++ b/overlord/src/app/recipe/recipe-import-dialog/recipe-import-dialog.component.css @@ -0,0 +1,145 @@ +.dialog-content { + display: flex; + flex-direction: column; + gap: 12px; + min-height: 200px; + max-height: 65vh; + overflow-y: auto; +} + +.flex-col { + display: flex; + flex-direction: column; + gap: 12px; +} + +.file-row { + display: flex; + align-items: center; + gap: 8px; +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; +} + +.action-counts { + display: flex; + flex-wrap: wrap; + gap: 8px 16px; + margin: 0; + padding: 0; + list-style: none; +} + +.badge { + display: inline-block; + padding: 2px 8px; + border-radius: 10px; + font-size: 12px; + font-weight: 600; +} + +.badge-unchanged, +.badge-unchanged-status { + background-color: #e8f5e9; + color: #1b5e20; +} + +.badge-new, +.badge-created { + background-color: #e3f2fd; + color: #0d47a1; +} + +.badge-overwrite, +.badge-updated { + background-color: #fff3e0; + color: #e65100; +} + +.badge-skip, +.badge-skipped { + background-color: #ffebee; + color: #b71c1c; +} + +.badge-unmatched { + background-color: #ede7f6; + color: #4527a0; +} + +.badge-invalid { + background-color: #fce4ec; + color: #880e4f; +} + +.badge-master { + background-color: #e0f2f1; + color: #004d40; +} + +.warnings { + padding: 8px 12px; + border: 1px solid #ffcc80; + border-radius: 4px; + background-color: #fff8e1; + font-size: 13px; +} + +.unmatched-box { + border: 1px solid #e0e0e0; + border-radius: 4px; + padding: 8px 12px; + margin: 0; +} + +.hint { + margin: 4px 0 8px; + font-size: 13px; + color: #616161; +} + +.unmatched-list { + display: flex; + flex-direction: column; + max-height: 180px; + overflow-y: auto; + margin-top: 8px; +} + +.muted { + color: #757575; + font-size: 12px; +} + +.options { + display: flex; + flex-direction: column; + gap: 4px; +} + +.dish-table { + max-height: 260px; + overflow-y: auto; + width: 100%; +} + +.result-list { + margin: 0; + padding-left: 20px; +} + +.error { + margin: 0; + color: #b71c1c; +} + +.spacer { + flex: 1 1 auto; +} diff --git a/overlord/src/app/recipe/recipe-import-dialog/recipe-import-dialog.component.html b/overlord/src/app/recipe/recipe-import-dialog/recipe-import-dialog.component.html new file mode 100644 index 00000000..e7ba81c4 --- /dev/null +++ b/overlord/src/app/recipe/recipe-import-dialog/recipe-import-dialog.component.html @@ -0,0 +1,200 @@ +

Import Recipes from Excel

+ +
+ @if (step() === 'select') { +
+

+ Upload the recipe workbook. The file is analysed first and you can review every planned change before anything + is saved. +

+
+ + +
+ + Import date + + Recipes are imported with this date. Existing recipes for the same date are overwritten. + +
+ } + + @if (step() === 'review' && preview()) { +
+

Summary for {{ date() }}

+
    + @for (c of actionCounts(); track c.action) { +
  • + {{ c.action }} {{ c.count }} dishes +
  • + } +
  • master data {{ masterChanges().length }} changes
  • +
+ + @if (warnings().length > 0) { +
+ Warnings +
    + @for (w of warnings(); track w) { +
  • {{ w }}
  • + } +
+
+ } + +
+ Create missing items ({{ unmatched().length }}) +

+ Checked items will be created. Unchecked items are not created and dishes that need them are skipped. +

+ @if (unmatched().length > 0) { + Select all + } +
+ @for (u of unmatched(); track u.name) { + + {{ u.name }} + ({{ u.kind }}{{ u.suggestedGroup ? ' → ' + u.suggestedGroup : '' }}) + + } +
+
+ +
+ Update sale prices from the menu summary + Update fraction and yield % + Apply menu sections as tags +
+ +
+ Dish details ({{ dishes().length }}) + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Action + {{ row.action }} + Name{{ row.name }}Sheet{{ row.sheet }}Yield{{ row.recipeYield | number: '1.0-4' }}Price + {{ row.salePrice === null ? '—' : (row.salePrice | number: '1.2-2') }} + Message{{ row.message ?? '—' }}
+
+
+ } + + @if (step() === 'result' && result()) { +
+

Import complete

+
    +
  • {{ result()!.dishes.length }} dishes processed
  • +
  • {{ result()!.createdProducts.length }} items created
  • +
  • {{ result()!.masterChanges }} master-data changes applied
  • +
+ @if (result()!.createdProducts.length > 0) { +

Created: {{ result()!.createdProducts.join(', ') }}

+ } + @if (result()!.warnings.length > 0) { +
+ Warnings +
    + @for (w of result()!.warnings; track w) { +
  • {{ w }}
  • + } +
+
+ } + @if (result()!.dishes.length > 0) { +
+ Per-dish report ({{ result()!.dishes.length }}) + + + + + + + + + + + + + + + +
Status + {{ row.status }} + Name{{ row.name }}Message{{ row.message ?? '—' }}
+
+ } +
+ } + + @if (errorMessage()) { + + } +
+ +
+ @if (busy()) { + + } + + @if (step() === 'select') { + + + } + @if (step() === 'review') { + + + } + @if (step() === 'result') { + + } +
diff --git a/overlord/src/app/recipe/recipe-import-dialog/recipe-import-dialog.component.ts b/overlord/src/app/recipe/recipe-import-dialog/recipe-import-dialog.component.ts new file mode 100644 index 00000000..3477ceda --- /dev/null +++ b/overlord/src/app/recipe/recipe-import-dialog/recipe-import-dialog.component.ts @@ -0,0 +1,178 @@ +import { DecimalPipe } from '@angular/common'; +import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { MatDialogModule, MatDialogRef } from '@angular/material/dialog'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatIconModule } from '@angular/material/icon'; +import { MatInputModule } from '@angular/material/input'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatTableModule } from '@angular/material/table'; + +import { RecipeImportPreview, RecipeImportResult } from '../recipe-import'; +import { RecipeService } from '../recipe.service'; + +interface ActionCount { + action: string; + count: number; +} + +@Component({ + selector: 'app-recipe-import-dialog', + templateUrl: './recipe-import-dialog.component.html', + styleUrls: ['./recipe-import-dialog.component.css'], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + MatDialogModule, + MatButtonModule, + MatCheckboxModule, + MatFormFieldModule, + MatInputModule, + MatIconModule, + MatProgressSpinnerModule, + MatTableModule, + FormsModule, + DecimalPipe, + ], +}) +export class RecipeImportDialogComponent { + private readonly ser = inject(RecipeService); + readonly dialogRef = inject>(MatDialogRef); + + readonly step = signal<'select' | 'review' | 'result'>('select'); + readonly busy = signal(false); + readonly errorMessage = signal(''); + + readonly file = signal(null); + readonly date = signal(''); + + readonly preview = signal(null); + readonly result = signal(null); + + readonly selectedCreate = signal>(new Set()); + readonly salePrices = signal(true); + readonly masterData = signal(true); + readonly tags = signal(true); + + readonly unmatched = computed(() => this.preview()?.unmatched ?? []); + readonly dishes = computed(() => this.preview()?.dishes ?? []); + readonly masterChanges = computed(() => this.preview()?.masterChanges ?? []); + readonly warnings = computed(() => this.preview()?.warnings ?? []); + + readonly actionCounts = computed(() => { + const counts = new Map(); + for (const d of this.dishes()) { + counts.set(d.action, (counts.get(d.action) ?? 0) + 1); + } + return [...counts.entries()].map(([action, count]) => ({ action, count })); + }); + + readonly displayedColumns = ['action', 'name', 'sheet', 'yield', 'price', 'message']; + + readonly canAnalyze = computed(() => this.file() !== null && this.date() !== ''); + + readonly allChecked = computed(() => { + const selected = this.selectedCreate(); + return this.unmatched().length > 0 && this.unmatched().every((u) => selected.has(u.name)); + }); + + onFileChange(event: Event): void { + const input = event.target as HTMLInputElement; + this.file.set(input.files?.[0] ?? null); + } + + toggleAll(event: unknown): void { + const checked = event as boolean; + if (checked) { + this.selectedCreate.set(new Set(this.unmatched().map((u) => u.name))); + } else { + this.selectedCreate.set(new Set()); + } + } + + toggleOne(name: string, checked: boolean): void { + this.selectedCreate.update((set) => { + const next = new Set(set); + if (checked) { + next.add(name); + } else { + next.delete(name); + } + return next; + }); + } + + isChecked(name: string): boolean { + return this.selectedCreate().has(name); + } + + analyze(): void { + const file = this.file(); + if (!file || !this.date()) { + return; + } + this.busy.set(true); + this.errorMessage.set(''); + this.ser.importPreview(file, this.date()).subscribe({ + next: (preview) => { + this.preview.set(preview); + this.selectedCreate.set(new Set(preview.unmatched.map((u) => u.name))); + this.busy.set(false); + this.step.set('review'); + }, + error: (err: unknown) => { + this.busy.set(false); + this.errorMessage.set(this.extractError(err, 'Could not analyse the file.')); + }, + }); + } + + execute(): void { + const file = this.file(); + if (!file || !this.date()) { + return; + } + this.busy.set(true); + this.errorMessage.set(''); + this.ser + .importExecute(file, this.date(), { + create: [...this.selectedCreate()], + salePrices: this.salePrices(), + masterData: this.masterData(), + tags: this.tags(), + }) + .subscribe({ + next: (result) => { + this.result.set(result); + this.busy.set(false); + this.step.set('result'); + }, + error: (err: unknown) => { + this.busy.set(false); + this.errorMessage.set(this.extractError(err, 'Import failed. Please try again.')); + }, + }); + } + + close(): void { + this.dialogRef.close(this.result() !== null); + } + + actionClass(action: string): string { + return `action-${action}`; + } + + private extractError(err: unknown, fallback: string): string { + if (err !== null && typeof err === 'object' && 'error' in err) { + const detail = (err as { error?: { detail?: unknown } }).error?.detail; + if (typeof detail === 'string' && detail !== '') { + return detail; + } + } + if (err instanceof Error && err.message !== '') { + return err.message; + } + return fallback; + } +} diff --git a/overlord/src/app/recipe/recipe-import.ts b/overlord/src/app/recipe/recipe-import.ts new file mode 100644 index 00000000..5b5cd419 --- /dev/null +++ b/overlord/src/app/recipe/recipe-import.ts @@ -0,0 +1,61 @@ +export interface ImportIngredientPlan { + name: string; + productId: string | null; + quantity: number; + unit: string | null; + unitMismatch: boolean; +} + +export interface ImportDishPlan { + name: string; + sheet: string; + productId: string | null; + skuId: string | null; + action: 'unchanged' | 'overwrite' | 'new' | 'skip' | 'invalid' | 'unmatched'; + recipeYield: number; + existingDate: string | null; + sectionTag: string | null; + salePrice: number | null; + message: string | null; + ingredients: ImportIngredientPlan[]; +} + +export interface ImportMasterPlan { + productId: string; + skuId: string; + name: string; + field: 'sale_price' | 'fraction' | 'product_yield'; + old: number | null; + new: number; +} + +export interface ImportUnmatchedName { + name: string; + kind: 'dish' | 'ingredient'; + sheet: string; + section: string | null; + suggestedGroup: string | null; + unitHint: string | null; +} + +export interface RecipeImportPreview { + date: string; + dishes: ImportDishPlan[]; + masterChanges: ImportMasterPlan[]; + unmatched: ImportUnmatchedName[]; + warnings: string[]; +} + +export interface ImportDishResult { + name: string; + status: 'created' | 'updated' | 'unchanged' | 'skipped'; + message: string | null; +} + +export interface RecipeImportResult { + date: string; + createdProducts: string[]; + dishes: ImportDishResult[]; + masterChanges: number; + warnings: string[]; +} diff --git a/overlord/src/app/recipe/recipe-list/recipe-list.component.html b/overlord/src/app/recipe/recipe-list/recipe-list.component.html index 465e0714..a0e6e19d 100644 --- a/overlord/src/app/recipe/recipe-list/recipe-list.component.html +++ b/overlord/src/app/recipe/recipe-list/recipe-list.component.html @@ -1,15 +1,20 @@

Recipes - - save_alt - - - save_alt - - - add_box - Add - + + + + save_alt + + + save_alt + + + add_box + Add + +

diff --git a/overlord/src/app/recipe/recipe-list/recipe-list.component.ts b/overlord/src/app/recipe/recipe-list/recipe-list.component.ts index 328f1ec7..b5513a58 100644 --- a/overlord/src/app/recipe/recipe-list/recipe-list.component.ts +++ b/overlord/src/app/recipe/recipe-list/recipe-list.component.ts @@ -3,6 +3,7 @@ import { form as createForm, FormField, FormRoot } from '@angular/forms/signals' import { MatButtonModule } from '@angular/material/button'; import { MatOptionModule } from '@angular/material/core'; import { MatDatepickerModule } from '@angular/material/datepicker'; +import { MatDialog } from '@angular/material/dialog'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatIconModule } from '@angular/material/icon'; import { MatPaginatorModule, PageEvent } from '@angular/material/paginator'; @@ -16,6 +17,7 @@ import { PeriodService } from '../../period/period.service'; import { ProductGroupService } from '../../product-group/product-group.service'; import { ErrorStateComponent } from '../../shared/error-state/error-state.component'; import { SkeletonLoaderComponent } from '../../shared/skeleton-loader/skeleton-loader.component'; +import { RecipeImportDialogComponent } from '../recipe-import-dialog/recipe-import-dialog.component'; import { RecipeService } from '../recipe.service'; export interface RecipeListFormData { @@ -49,6 +51,7 @@ export class RecipeListComponent { private router = inject(Router); private periodSer = inject(PeriodService); private productGroupSer = inject(ProductGroupService); + private dialog = inject(MatDialog); pageSize = signal(50); pageIndex = signal(0); @@ -140,6 +143,20 @@ export class RecipeListComponent { this.sortActive.set(sort.active); this.sortDirection.set(sort.direction); } + + openImport(): void { + this.dialog + .open(RecipeImportDialogComponent, { + width: '720px', + maxWidth: '95vw', + }) + .afterClosed() + .subscribe((imported: boolean) => { + if (imported) { + this.resource.reload(); + } + }); + } } const compare = (a: string | number | boolean, b: string | number | boolean, isAsc: boolean) => (a < b ? -1 : 1) * (isAsc ? 1 : -1); diff --git a/overlord/src/app/recipe/recipe.service.ts b/overlord/src/app/recipe/recipe.service.ts index c35a648c..781d1910 100644 --- a/overlord/src/app/recipe/recipe.service.ts +++ b/overlord/src/app/recipe/recipe.service.ts @@ -5,8 +5,17 @@ import { catchError } from 'rxjs/operators'; import { ErrorLoggerService } from '../core/error-logger.service'; import { Recipe } from './recipe'; +import { RecipeImportPreview, RecipeImportResult } from './recipe-import'; const url = '/api/recipes'; + +export interface RecipeImportExecuteOptions { + create: string[]; + salePrices: boolean; + masterData: boolean; + tags: boolean; +} + @Injectable({ providedIn: 'root' }) export class RecipeService { private http = inject(HttpClient); @@ -51,4 +60,26 @@ export class RecipeService { .delete(`${url}/${id}`) .pipe(catchError(this.log.handleError('RecipeService', 'delete'))) as Observable; } + + importPreview(file: File, date: string): Observable { + const data = new FormData(); + data.set('file', file); + data.set('date_', date); + return this.http + .post(`${url}/import/preview`, data) + .pipe(catchError(this.log.handleError('RecipeService', 'importPreview'))) as Observable; + } + + importExecute(file: File, date: string, options: RecipeImportExecuteOptions): Observable { + const data = new FormData(); + data.set('file', file); + data.set('date_', date); + data.set('create', JSON.stringify(options.create)); + data.set('sale_prices', String(options.salePrices)); + data.set('master_data', String(options.masterData)); + data.set('tags', String(options.tags)); + return this.http + .post(`${url}/import/execute`, data) + .pipe(catchError(this.log.handleError('RecipeService', 'importExecute'))) as Observable; + } } diff --git a/overlord/src/app/sales-import/sale-voucher.component.css b/overlord/src/app/sales-import/sale-voucher.component.css new file mode 100644 index 00000000..86391fe3 --- /dev/null +++ b/overlord/src/app/sales-import/sale-voucher.component.css @@ -0,0 +1,18 @@ +.row-container { + display: flex; + gap: 12px; + align-items: center; +} + +.space-between { + justify-content: space-between; +} + +.full-width { + width: 100%; + margin-bottom: 16px; +} + +.number { + text-align: right; +} diff --git a/overlord/src/app/sales-import/sale-voucher.component.html b/overlord/src/app/sales-import/sale-voucher.component.html new file mode 100644 index 00000000..7c014cba --- /dev/null +++ b/overlord/src/app/sales-import/sale-voucher.component.html @@ -0,0 +1,85 @@ +
+

Sale Voucher

+ + arrow_back + +
+ +@if (loading()) { + +} + +@if (error(); as err) { + +} + +@if (voucher(); as voucher) { +
+

+ {{ voucher.date | date: 'dd-MMM-yyyy' }} + — {{ voucher.narration }} +

+

+ Sold from {{ name(voucher.source) }} to + {{ name(voucher.destination) }} +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Product{{ line.batch.sku.name }}Quantity{{ line.quantity | number: '1.2-2' }}Rate{{ line.rate | currency: 'INR' : 'symbol' : '1.2-2' }}Tax{{ line.tax | percent: '1.0-2' }}Discount{{ line.discount | percent: '1.0-2' }}Amount{{ line.amount | number: '1.2-2' }}Happy Hour{{ line.isHappyHour ? 'Yes' : '' }}
+ + + + + + + + + + + + + + + + + + + + +
Account{{ journal.account.name }}Cost Centre{{ name(journal.costCentre) }}Debit + {{ journal.debit === 1 ? (journal.amount | number: '1.2-2') : '' }} + Credit + {{ journal.debit === -1 ? (journal.amount | number: '1.2-2') : '' }} +
+} diff --git a/overlord/src/app/sales-import/sale-voucher.component.ts b/overlord/src/app/sales-import/sale-voucher.component.ts new file mode 100644 index 00000000..80775a17 --- /dev/null +++ b/overlord/src/app/sales-import/sale-voucher.component.ts @@ -0,0 +1,63 @@ +import { CurrencyPipe, DatePipe, DecimalPipe, PercentPipe } from '@angular/common'; +import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatTableModule } from '@angular/material/table'; +import { RouterLink } from '@angular/router'; + +import { CostCentre } from '../core/cost-centre'; +import { CostCentreService } from '../cost-centre/cost-centre.service'; +import { ErrorStateComponent } from '../shared/error-state/error-state.component'; +import { SkeletonLoaderComponent } from '../shared/skeleton-loader/skeleton-loader.component'; +import { SalesImportService } from './sales-import.service'; + +@Component({ + selector: 'app-sale-voucher', + templateUrl: './sale-voucher.component.html', + styleUrls: ['./sale-voucher.component.css'], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + DatePipe, + DecimalPipe, + CurrencyPipe, + PercentPipe, + MatButtonModule, + MatIconModule, + MatTableModule, + RouterLink, + SkeletonLoaderComponent, + ErrorStateComponent, + ], +}) +export class SaleVoucherComponent { + private ser = inject(SalesImportService); + private costCentreSer = inject(CostCentreService); + + id = input(null, { transform: (v: string | null | undefined) => v ?? null }); + + voucherResource = this.ser.voucher(this.id); + voucher = computed(() => this.voucherResource.value() ?? null); + loading = computed(() => this.voucherResource.isLoading()); + error = computed(() => this.voucherResource.error()); + + costCentreResource = this.costCentreSer.list(); + costCentres = computed(() => this.costCentreResource.value() ?? []); + + displayedColumns = ['account', 'costCentre', 'debit', 'credit']; + lineColumns = ['product', 'quantity', 'rate', 'tax', 'discount', 'amount', 'happyHour']; + + name(costCentre: CostCentre | null | undefined): string { + if (!costCentre) { + return ''; + } + return this.costCentres().find((c) => c.id === costCentre.id)?.name ?? costCentre.name ?? ''; + } + + trackLine(index: number): string { + return `line-${index}`; + } + + trackJournal(index: number): string { + return `journal-${index}`; + } +} diff --git a/overlord/src/app/sales-import/sales-import.component.css b/overlord/src/app/sales-import/sales-import.component.css new file mode 100644 index 00000000..e1ff5cbd --- /dev/null +++ b/overlord/src/app/sales-import/sales-import.component.css @@ -0,0 +1,78 @@ +.row-container { + display: flex; + gap: 12px; + align-items: center; +} + +.wrap { + flex-wrap: wrap; +} + +.flex-col { + display: flex; + flex-direction: column; + gap: 8px; +} + +.flex-auto { + flex: 1 1 auto; +} + +.full-width { + width: 100%; +} + +.number { + text-align: right; +} + +.mapping-field { + min-width: 220px; +} + +.notice { + border: 1px solid #b26a00; + border-radius: 4px; + padding: 8px 12px; + margin: 8px 0; +} + +.action { + display: inline-block; + padding: 2px 8px; + border-radius: 12px; + font-size: 0.85em; +} + +.action-create { + background-color: #1b5e20; + color: #ffffff; +} + +.action-skip { + background-color: #616161; + color: #ffffff; +} + +.action-replace { + background-color: #e65100; + color: #ffffff; +} + +.action-mixed { + background-color: #4a148c; + color: #ffffff; +} + +.selected { + background-color: rgba(0, 0, 0, 0.04); +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; +} diff --git a/overlord/src/app/sales-import/sales-import.component.html b/overlord/src/app/sales-import/sales-import.component.html new file mode 100644 index 00000000..61acd715 --- /dev/null +++ b/overlord/src/app/sales-import/sales-import.component.html @@ -0,0 +1,163 @@ +

Sales Import

+ + +
+ + Start Date + + + + + + Finish Date + + + + + +
+ + +@if (unmappedCategories() > 0 || newProducts().length > 0) { +
+ @if (unmappedCategories() > 0) { +

+ {{ unmappedCategories() }} sale category(ies) are not mapped to cost centres. Map them below and save before + importing. +

+ } + @if (newProducts().length > 0) { +

{{ newProducts().length }} new product(s) will be created automatically on import.

+ } +
+} + +@if (loading()) { + +} + +@if (error(); as err) { + +} + +@if (preview()) { +
+

Sale Category Mapping

+
+ @for (entry of mappingEntries(); track entry.saleCategoryId) { + + {{ entry.saleCategoryName ?? entry.saleCategoryId }} + + Not mapped + @for (costCentre of costCentres(); track costCentre.id) { + {{ costCentre.name }} + } + + + } + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + +
Expand + + Business Date{{ day.businessDate | date: 'dd-MMM-yyyy' }}Status + {{ day.action }} + Vouchers{{ day.vouchers.length }}Amount{{ day.amount | number: '1.2-2' }}
+ + @if (expanded(); as day) { +
+ @for (voucher of day.vouchers; track trackVoucher($index, voucher)) { + + + {{ voucher.saleCategoryName }} → {{ voucher.costCentre.name }} + + {{ voucher.action }} + {{ voucher.amount | number: '1.2-2' }} + + +

{{ voucher.narration }}

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Product{{ line.name }} ({{ line.units }})Quantity{{ line.quantity }}Rate{{ line.rate | number: '1.2-2' }}Tax{{ line.taxRate }}Discount{{ line.discount }}Amount{{ line.amount | number: '1.2-2' }}Happy Hour{{ line.isHappyHour ? 'Yes' : '' }}
+
+ } +
+ } + +
+ +
+} diff --git a/overlord/src/app/sales-import/sales-import.component.ts b/overlord/src/app/sales-import/sales-import.component.ts new file mode 100644 index 00000000..a1d24598 --- /dev/null +++ b/overlord/src/app/sales-import/sales-import.component.ts @@ -0,0 +1,169 @@ +import { DatePipe, DecimalPipe } from '@angular/common'; +import { httpResource } from '@angular/common/http'; +import { ChangeDetectionStrategy, Component, computed, effect, inject, signal } from '@angular/core'; +import { form as createForm, FormField, FormRoot } from '@angular/forms/signals'; +import { MatButtonModule } from '@angular/material/button'; +import { MatOptionModule } from '@angular/material/core'; +import { MatDatepickerModule } from '@angular/material/datepicker'; +import { MatExpansionModule } from '@angular/material/expansion'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatIconModule } from '@angular/material/icon'; +import { MatInputModule } from '@angular/material/input'; +import { MatSelectModule } from '@angular/material/select'; +import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; +import { MatTableModule } from '@angular/material/table'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import moment from 'moment'; + +import { ErrorStateComponent } from '../shared/error-state/error-state.component'; +import { SkeletonLoaderComponent } from '../shared/skeleton-loader/skeleton-loader.component'; +import { DayPlan, ImportRequest, MappingEntry, PreviewResponse, SaleLine, VoucherPlan } from './sales-import'; +import { SalesImportService } from './sales-import.service'; + +interface SalesImportFormData { + startDate: Date; + finishDate: Date; +} + +@Component({ + selector: 'app-sales-import', + templateUrl: './sales-import.component.html', + styleUrls: ['./sales-import.component.css'], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + FormField, + FormRoot, + MatFormFieldModule, + MatInputModule, + MatDatepickerModule, + MatOptionModule, + MatSelectModule, + MatButtonModule, + MatIconModule, + MatExpansionModule, + MatTableModule, + MatTooltipModule, + MatSnackBarModule, + DatePipe, + DecimalPipe, + SkeletonLoaderComponent, + ErrorStateComponent, + ], +}) +export class SalesImportComponent { + private ser = inject(SalesImportService); + private snackBar = inject(MatSnackBar); + + model = signal({ + startDate: moment().startOf('month').toDate(), + finishDate: moment().toDate(), + }); + + form = createForm(this.model); + + previewRequest = signal(null); + previewResource = httpResource(() => { + const request = this.previewRequest(); + return request === null ? undefined : { url: '/api/sales-import/preview', method: 'POST', body: request }; + }); + + error = computed(() => this.previewResource.error()); + loading = computed(() => this.previewResource.isLoading()); + preview = computed(() => this.previewResource.value() ?? null); + + days = computed(() => this.preview()?.days ?? []); + costCentres = computed(() => this.preview()?.costCentres ?? []); + mappingEntries = computed(() => this.preview()?.mapping ?? []); + newProducts = computed(() => this.preview()?.newProducts ?? []); + unmappedCategories = computed( + () => this.mappingEntries().filter((entry) => this.mappingSelection()[entry.saleCategoryId] == null).length, + ); + + canImport = computed(() => this.days().length > 0 && this.unmappedCategories() === 0); + + mappingSelection = signal>({}); + importing = signal(false); + expanded = signal(null); + + displayedColumns = ['expand', 'businessDate', 'action', 'voucherCount', 'amount']; + lineColumns = ['productName', 'quantity', 'rate', 'taxRate', 'discount', 'amount', 'happyHour']; + + constructor() { + effect(() => { + const response = this.preview(); + if (response) { + this.mappingSelection.set( + Object.fromEntries(response.mapping.map((entry) => [entry.saleCategoryId, entry.costCentre?.id ?? null])), + ); + } + }); + } + + show(): void { + const model = this.model(); + this.previewRequest.set({ + startDate: moment(model.startDate).format('YYYY-MM-DD'), + finishDate: moment(model.finishDate).format('YYYY-MM-DD'), + }); + } + + onMappingChange(saleCategoryId: string, costCentreId: string | null): void { + this.mappingSelection.update((current) => ({ ...current, [saleCategoryId]: costCentreId })); + } + + saveMapping(): void { + const entries: MappingEntry[] = this.mappingEntries(); + const selection = this.mappingSelection(); + const mapping = entries.map((entry) => ({ + saleCategoryId: entry.saleCategoryId, + costCentreId: selection[entry.saleCategoryId] ?? null, + })); + this.ser.saveMapping({ mapping }).subscribe({ + next: () => { + this.snackBar.open('Sale category mapping saved', 'Close', { duration: 3000 }); + this.show(); + }, + error: (err) => this.snackBar.open('Error saving mapping: ' + err.message, 'Close', { duration: 5000 }), + }); + } + + import(): void { + const model = this.model(); + const request: ImportRequest = { + startDate: moment(model.startDate).format('YYYY-MM-DD'), + finishDate: moment(model.finishDate).format('YYYY-MM-DD'), + }; + this.importing.set(true); + this.ser.execute(request).subscribe({ + next: (result) => { + this.importing.set(false); + this.snackBar.open(result.message, 'Close', { duration: 5000 }); + this.show(); + }, + error: (err) => { + this.importing.set(false); + this.snackBar.open('Import failed: ' + (err.error?.detail ?? err.message), 'Close', { duration: 8000 }); + }, + }); + } + + toggleExpanded(day: DayPlan): void { + this.expanded.update((current) => (current === day ? null : day)); + } + + actionClass(action: string): string { + return `action action-${action}`; + } + + trackDay(_index: number, day: DayPlan): string { + return day.businessDate; + } + + trackVoucher(_index: number, voucher: VoucherPlan): string { + return `${voucher.saleCategoryId}-${voucher.costCentre.id}`; + } + + trackLine(_index: number, line: SaleLine): string { + return `${line.skuId ?? line.productName}-${line.rate}-${line.isHappyHour}`; + } +} diff --git a/overlord/src/app/sales-import/sales-import.routes.ts b/overlord/src/app/sales-import/sales-import.routes.ts new file mode 100644 index 00000000..60a1be9e --- /dev/null +++ b/overlord/src/app/sales-import/sales-import.routes.ts @@ -0,0 +1,24 @@ +import { Routes } from '@angular/router'; + +import { authGuard } from '../auth/auth-guard.service'; +import { SaleVoucherComponent } from './sale-voucher.component'; +import { SalesImportComponent } from './sales-import.component'; + +export const routes: Routes = [ + { + path: '', + component: SalesImportComponent, + canActivate: [authGuard], + data: { + permission: 'Sales Import', + }, + }, + { + path: 'voucher/:id', + component: SaleVoucherComponent, + canActivate: [authGuard], + data: { + permission: 'Sales Import', + }, + }, +]; diff --git a/overlord/src/app/sales-import/sales-import.service.ts b/overlord/src/app/sales-import/sales-import.service.ts new file mode 100644 index 00000000..36dd8f66 --- /dev/null +++ b/overlord/src/app/sales-import/sales-import.service.ts @@ -0,0 +1,43 @@ +import { HttpClient, httpResource } from '@angular/common/http'; +import { inject, Injectable, Signal } from '@angular/core'; +import { Observable } from 'rxjs'; +import { catchError } from 'rxjs/operators'; + +import { ErrorLoggerService } from '../core/error-logger.service'; +import { Voucher } from '../core/voucher'; +import { ExecuteResponse, ImportRequest, MappingUpdate, MappingEntry, PreviewResponse } from './sales-import'; + +const url = '/api/sales-import'; + +@Injectable({ + providedIn: 'root', +}) +export class SalesImportService { + private http = inject(HttpClient); + private log = inject(ErrorLoggerService); + + voucher(id: Signal) { + return httpResource(() => { + const id_ = id(); + return id_ === null ? undefined : `${url}/voucher/${id_}`; + }); + } + + preview(request: ImportRequest): Observable { + return this.http + .post(`${url}/preview`, request) + .pipe(catchError(this.log.handleError('SalesImportService', 'preview'))) as Observable; + } + + execute(request: ImportRequest): Observable { + return this.http + .post(`${url}/execute`, request) + .pipe(catchError(this.log.handleError('SalesImportService', 'execute'))) as Observable; + } + + saveMapping(mapping: MappingUpdate): Observable { + return this.http + .put(`${url}/mapping`, mapping) + .pipe(catchError(this.log.handleError('SalesImportService', 'saveMapping'))) as Observable; + } +} diff --git a/overlord/src/app/sales-import/sales-import.ts b/overlord/src/app/sales-import/sales-import.ts new file mode 100644 index 00000000..6c67f2b1 --- /dev/null +++ b/overlord/src/app/sales-import/sales-import.ts @@ -0,0 +1,61 @@ +import { CostCentre } from '../core/cost-centre'; + +export interface SaleLine { + productId: string | null; + skuId: string | null; + name: string; + units: string; + saleCategoryId: string; + saleCategoryName: string; + quantity: number; + rate: number; + taxRate: number; + discount: number; + isHappyHour: boolean; + amount: number; +} + +export interface VoucherPlan { + saleCategoryId: string; + saleCategoryName: string; + costCentre: CostCentre; + narration: string; + lines: SaleLine[]; + amount: number; + action: 'create' | 'skip' | 'replace'; + existingAmount: number | null; +} + +export interface DayPlan { + businessDate: string; + action: 'create' | 'skip' | 'replace' | 'mixed'; + vouchers: VoucherPlan[]; + amount: number; +} + +export interface MappingEntry { + saleCategoryId: string; + saleCategoryName: string | null; + costCentre: CostCentre | null; +} + +export interface PreviewResponse { + days: DayPlan[]; + mapping: MappingEntry[]; + costCentres: CostCentre[]; + newProducts: string[]; +} + +export interface ImportRequest { + startDate: string; + finishDate: string; +} + +export interface ExecuteResponse { + message: string; + days: DayPlan[]; +} + +export interface MappingUpdate { + mapping: { saleCategoryId: string; costCentreId: string | null }[]; +} diff --git a/skills-lock.json b/skills-lock.json new file mode 100644 index 00000000..61aa6a4a --- /dev/null +++ b/skills-lock.json @@ -0,0 +1,227 @@ +{ + "version": 1, + "skills": { + "ask-matt": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/ask-matt/SKILL.md", + "computedHash": "0cd14026efa0330083cbc7adf590976c162b60e02f696a8dcce96f0b4dab8a72" + }, + "claude-handoff": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/in-progress/claude-handoff/SKILL.md", + "computedHash": "f8c4754a25aa4e12601ce69db4388bb0d30ca526b225351180c5b0d2a4649a95" + }, + "code-review": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/code-review/SKILL.md", + "computedHash": "caa9a086baaf9e0f7cd71f64edfa83da6821c05e826b083221f3d02e3d6a1905" + }, + "codebase-design": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/codebase-design/SKILL.md", + "computedHash": "5a17552cc1482f1a40124bf4e6c9dbd90ac0dbb47e71c07d47369f9e5f2ae3b5" + }, + "diagnosing-bugs": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/diagnosing-bugs/SKILL.md", + "computedHash": "37b5e9c624513551da790b52864dc8c84bff996a6d03a380cc6facdcc0a88354" + }, + "domain-modeling": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/domain-modeling/SKILL.md", + "computedHash": "a11713c0ff7870efa3c331b2e273f09116158485246edc89ae5088eefd1b0b48" + }, + "git-guardrails-claude-code": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/misc/git-guardrails-claude-code/SKILL.md", + "computedHash": "ccf581d304132095c2787b0a3bcb7b4ff125863c9f796fb16311d8f1cacef470" + }, + "grill-me": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/productivity/grill-me/SKILL.md", + "computedHash": "9cdbb4b8f7a3aeaef82e6230c9e823500d4a8a1214c726cf342cf9829d34fc7d" + }, + "grill-with-docs": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/grill-with-docs/SKILL.md", + "computedHash": "35e62aa423aaeab6a414bcac273f56dfb17802ef2334b41b6a3b00f28196c8cf" + }, + "grilling": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/productivity/grilling/SKILL.md", + "computedHash": "4dd886b0196bf43729d954ae326016f2bd9f5f79d892500b407c33a753362f14" + }, + "handoff": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/productivity/handoff/SKILL.md", + "computedHash": "20e5f4afdef502637510bc5c64d27645d3c85c88df9cb006824bbb0980166319" + }, + "implement": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/implement/SKILL.md", + "computedHash": "2139cfedf24791adbc839aaab6019cff158af1e28bfead020ec6e0ce01b3e74d" + }, + "implement-spec": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/in-progress/implement-spec/SKILL.md", + "computedHash": "91670f6f5239fc64da91c2b2f6ada62a27d2f0e8e18215fefe53feeea7a43801" + }, + "improve-codebase-architecture": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/improve-codebase-architecture/SKILL.md", + "computedHash": "2449db6ab1ded9581f69fcc56c1f64818112d05e271fd4c5da23c6523f9d3d9d" + }, + "loop-me": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/in-progress/loop-me/SKILL.md", + "computedHash": "72e01a16929dbf6583afc469a49d64b28b1be4a6034c900decd94cd5a69ba6e6" + }, + "migrate-to-shoehorn": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/misc/migrate-to-shoehorn/SKILL.md", + "computedHash": "6397731ced114f3657aa88b55ed13d1344a56d77ca449c568e3200c21740fa99" + }, + "prototype": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/prototype/SKILL.md", + "computedHash": "d3fc74689bd993e39c44bfae510d014eecffbeec2922764b065377fd1327c0f6" + }, + "research": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/research/SKILL.md", + "computedHash": "c8c1cba327a6f824b554cd978079a3dadd7406d73174f8fb9bfef58824691970" + }, + "resolving-merge-conflicts": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/resolving-merge-conflicts/SKILL.md", + "computedHash": "63b2dcadbe9124caeb3448f1378286fe18dcefb2494f60e136831b8b4986ca27" + }, + "retro": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/in-progress/retro/SKILL.md", + "computedHash": "b9bd2e5e0378214bf54d2a293df50490669b845a4c2a71c8f747ce8ab365f53a" + }, + "scaffold-exercises": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/misc/scaffold-exercises/SKILL.md", + "computedHash": "354c91f6dbc9b058632f30594aacb4edc6d25012596585abb00402af8d7ec5e9" + }, + "setup-matt-pocock-skills": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/setup-matt-pocock-skills/SKILL.md", + "computedHash": "552b0f48b9fe054aa93c5d432fefbd7bd9645e3f2d20b4e22432453a81800b21" + }, + "setup-pre-commit": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/misc/setup-pre-commit/SKILL.md", + "computedHash": "7fc7b680161a5cb834b165e36efc2378ff63a1f80df0a9efd18448d366bea98a" + }, + "setup-ts-deep-modules": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/in-progress/setup-ts-deep-modules/SKILL.md", + "computedHash": "61508216ede38d8cfa712879197a76f75f4896615dbf7abc3f62571f8f5923cc" + }, + "tdd": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/tdd/SKILL.md", + "computedHash": "e753a5da75292bbe59d302d89566bc2c53d0e73944da2f0e944a7578883c07d0" + }, + "teach": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/productivity/teach/SKILL.md", + "computedHash": "b8a69574c7a019bed84e84313dc8bf02e1d1bf925ba43057d433217c63863206" + }, + "to-questionnaire": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/productivity/to-questionnaire/SKILL.md", + "computedHash": "befbec7005695163741a57bc94810dc5048fb766baaa485d5160b1d7f86335c1" + }, + "to-spec": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/to-spec/SKILL.md", + "computedHash": "3fa1a0695d4ea242fae9e569e4d22aa1788623197abb33bfadafae7315789bbf" + }, + "to-tickets": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/to-tickets/SKILL.md", + "computedHash": "bf5e6ebcb4f1272de0c188d5b3901f265a03d1fa9935a21a7a56938e21e2e761" + }, + "triage": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/triage/SKILL.md", + "computedHash": "b954d74c219c805bf353d6642251a713c9b2e9cfdb11babfe588bcfe7dfd16d5" + }, + "wait-what": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/productivity/wait-what/SKILL.md", + "computedHash": "71a9a1f1773d4b1ff70a9d496db63855ef83a7fe906c72b70cdebf4815a2c4c1" + }, + "wayfinder": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/wayfinder/SKILL.md", + "computedHash": "fa790eb4255b13d24d7e33ff4891ccd3d621558cc0ab87ae226777538ea16a8f" + }, + "wizard": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/wizard/SKILL.md", + "computedHash": "dee7f1a523994a1e063c69fe09c2f5b4da97a41ad68107722664e6e472cb2423" + }, + "writing-beats": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/in-progress/writing-beats/SKILL.md", + "computedHash": "220b163698de9c1e551b801795cc8ca38132b69018f82188875b3675226fc888" + }, + "writing-for-agents": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/productivity/writing-for-agents/SKILL.md", + "computedHash": "95da47fc97af998e85b7d7e6d57b3ac76727c1e290cfe9ea09005aacb826959f" + }, + "writing-fragments": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/in-progress/writing-fragments/SKILL.md", + "computedHash": "cbd8c4ed24ebc292017831ef76bc8798719b3adcd18f7781a632a37844a1c369" + }, + "writing-shape": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/in-progress/writing-shape/SKILL.md", + "computedHash": "0d0ac1150c4d65f8370ad194fc097ee17855247f6e5943d05f075806980e7401" + } + } +}