In progress Sales import and Recipe import.

First I will finish Sales Import. Then move on to recipe import
This commit is contained in:
tanshu committed 2026-09-11 10:43:23 +00:00
1 parent 75ccbdc6f9
commit b425b299bf
50 files changed
+5306 -123

No files matched your search

+1
View File
@@ -20,3 +20,4 @@ JWT_TOKEN_EXPIRE_MINUTES=30
ALEMBIC_LOG_LEVEL=INFO
ALEMBIC_SQLALCHEMY_LOG_LEVEL=WARNING
BARKER_URL=http://localhost:9995/
+100
View File
@@ -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
@@ -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 ###
@@ -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")
+1
View File
@@ -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:
+2
View File
@@ -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",
+3
View File
@@ -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"])
+41
View File
@@ -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_
+4
View File
@@ -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")
+16
View File
@@ -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__(
+1
View File
@@ -4,3 +4,4 @@ import enum
class SettingType(enum.IntEnum):
VOUCHER_LOCK = 0
MAINTENANCE_MODE = 1
SALE_CATEGORY_COST_CENTRES = 2
+1
View File
@@ -15,3 +15,4 @@ class VoucherType(enum.IntEnum):
CLOSING_BALANCE = 11
EMPLOYEE_BENEFIT = 12
INCENTIVE = 13
SALE = 14
+8 -6
View File
@@ -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)
+281 -107
View File
@@ -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}
+66
View File
@@ -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)
+16
View File
@@ -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,
+1
View File
@@ -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)
+110
View File
@@ -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",
]
+92
View File
@@ -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]
Whitespace-only changes.
+835
View File
@@ -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
+499
View File
@@ -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 = <qty> <unit>`` 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)
@@ -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"]
@@ -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
@@ -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
@@ -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,
)
@@ -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)
+51
View File
@@ -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/<context>/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…_
+30
View File
@@ -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/<feature-slug>/`
- The spec is `.scratch/<feature-slug>/spec.md`
- Implementation issues are one file per ticket at `.scratch/<feature-slug>/issues/<NN>-<slug>.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/<feature-slug>/` (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/<effort>/map.md` (the Notes / Decisions-so-far / Fog body).
- **Child ticket**: `.scratch/<effort>/issues/NN-<slug>.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/<effort>/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`.
+15
View File
@@ -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.
+209
View File
@@ -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.
+4
View File
@@ -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),
@@ -10,6 +10,7 @@
<a mat-menu-item routerLink="/receipt">Receipt - F6</a>
<a mat-menu-item routerLink="/issue">Issue</a>
<a mat-menu-item routerLink="/transaction-import">Transaction Import</a>
<a mat-menu-item routerLink="/sales-import">Sales Import</a>
</mat-menu>
<button mat-button [matMenuTriggerFor]="voucherMenu">Voucher Entry</button>
@@ -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;
}
@@ -0,0 +1,200 @@
<h2 mat-dialog-title>Import Recipes from Excel</h2>
<div mat-dialog-content class="dialog-content" cdkScrollable>
@if (step() === 'select') {
<div class="flex-col">
<p>
Upload the recipe workbook. The file is analysed first and you can review every planned change before anything
is saved.
</p>
<div class="file-row">
<button type="button" mat-stroked-button (click)="fileInput.click()">
<mat-icon>upload_file</mat-icon>
{{ file() ? file()!.name : 'Choose .xlsx file' }}
</button>
<input
#fileInput
type="file"
accept=".xlsx"
class="visually-hidden"
(change)="onFileChange($event)"
aria-label="Recipe workbook file"
/>
</div>
<mat-form-field appearance="outline">
<mat-label>Import date</mat-label>
<input
matInput
type="date"
[ngModel]="date()"
(ngModelChange)="date.set($event)"
required
aria-required="true"
/>
<mat-hint>Recipes are imported with this date. Existing recipes for the same date are overwritten.</mat-hint>
</mat-form-field>
</div>
}
@if (step() === 'review' && preview()) {
<div class="flex-col">
<h3>Summary for {{ date() }}</h3>
<ul class="action-counts" aria-label="Planned actions">
@for (c of actionCounts(); track c.action) {
<li>
<span [class]="'badge ' + actionClass(c.action)">{{ c.action }}</span> {{ c.count }} dishes
</li>
}
<li><span class="badge badge-master">master data</span> {{ masterChanges().length }} changes</li>
</ul>
@if (warnings().length > 0) {
<div class="warnings" role="note">
<strong>Warnings</strong>
<ul>
@for (w of warnings(); track w) {
<li>{{ w }}</li>
}
</ul>
</div>
}
<fieldset class="unmatched-box">
<legend>Create missing items ({{ unmatched().length }})</legend>
<p class="hint">
Checked items will be created. Unchecked items are not created and dishes that need them are skipped.
</p>
@if (unmatched().length > 0) {
<mat-checkbox [checked]="allChecked()" (change)="toggleAll($event.checked)">Select all</mat-checkbox>
}
<div class="unmatched-list" role="group" aria-label="Items to create">
@for (u of unmatched(); track u.name) {
<mat-checkbox [checked]="isChecked(u.name)" (change)="toggleOne(u.name, $event.checked)">
{{ u.name }}
<span class="muted">({{ u.kind }}{{ u.suggestedGroup ? ' → ' + u.suggestedGroup : '' }})</span>
</mat-checkbox>
}
</div>
</fieldset>
<div class="options" role="group" aria-label="Import options">
<mat-checkbox [ngModel]="salePrices()" (ngModelChange)="salePrices.set($event)"
>Update sale prices from the menu summary</mat-checkbox
>
<mat-checkbox [ngModel]="masterData()" (ngModelChange)="masterData.set($event)"
>Update fraction and yield %</mat-checkbox
>
<mat-checkbox [ngModel]="tags()" (ngModelChange)="tags.set($event)">Apply menu sections as tags</mat-checkbox>
</div>
<details>
<summary>Dish details ({{ dishes().length }})</summary>
<table mat-table [dataSource]="dishes()" class="dish-table">
<ng-container matColumnDef="action">
<th mat-header-cell *matHeaderCellDef scope="col">Action</th>
<td mat-cell *matCellDef="let row">
<span [class]="'badge ' + actionClass(row.action)">{{ row.action }}</span>
</td>
</ng-container>
<ng-container matColumnDef="name">
<th mat-header-cell *matHeaderCellDef scope="col">Name</th>
<td mat-cell *matCellDef="let row">{{ row.name }}</td>
</ng-container>
<ng-container matColumnDef="sheet">
<th mat-header-cell *matHeaderCellDef scope="col">Sheet</th>
<td mat-cell *matCellDef="let row">{{ row.sheet }}</td>
</ng-container>
<ng-container matColumnDef="yield">
<th mat-header-cell *matHeaderCellDef scope="col">Yield</th>
<td mat-cell *matCellDef="let row">{{ row.recipeYield | number: '1.0-4' }}</td>
</ng-container>
<ng-container matColumnDef="price">
<th mat-header-cell *matHeaderCellDef scope="col">Price</th>
<td mat-cell *matCellDef="let row">
{{ row.salePrice === null ? '—' : (row.salePrice | number: '1.2-2') }}
</td>
</ng-container>
<ng-container matColumnDef="message">
<th mat-header-cell *matHeaderCellDef scope="col">Message</th>
<td mat-cell *matCellDef="let row">{{ row.message ?? '—' }}</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
</table>
</details>
</div>
}
@if (step() === 'result' && result()) {
<div class="flex-col">
<h3>Import complete</h3>
<ul class="result-list">
<li>{{ result()!.dishes.length }} dishes processed</li>
<li>{{ result()!.createdProducts.length }} items created</li>
<li>{{ result()!.masterChanges }} master-data changes applied</li>
</ul>
@if (result()!.createdProducts.length > 0) {
<p>Created: {{ result()!.createdProducts.join(', ') }}</p>
}
@if (result()!.warnings.length > 0) {
<div class="warnings" role="note">
<strong>Warnings</strong>
<ul>
@for (w of result()!.warnings; track w) {
<li>{{ w }}</li>
}
</ul>
</div>
}
@if (result()!.dishes.length > 0) {
<details>
<summary>Per-dish report ({{ result()!.dishes.length }})</summary>
<table mat-table [dataSource]="result()!.dishes" class="dish-table">
<ng-container matColumnDef="action">
<th mat-header-cell *matHeaderCellDef scope="col">Status</th>
<td mat-cell *matCellDef="let row">
<span [class]="'badge badge-' + row.status">{{ row.status }}</span>
</td>
</ng-container>
<ng-container matColumnDef="name">
<th mat-header-cell *matHeaderCellDef scope="col">Name</th>
<td mat-cell *matCellDef="let row">{{ row.name }}</td>
</ng-container>
<ng-container matColumnDef="sheet">
<th mat-header-cell *matHeaderCellDef scope="col">Message</th>
<td mat-cell *matCellDef="let row">{{ row.message ?? '—' }}</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
</table>
</details>
}
</div>
}
@if (errorMessage()) {
<p class="error" role="alert">{{ errorMessage() }}</p>
}
</div>
<div mat-dialog-actions>
@if (busy()) {
<mat-spinner diameter="24" aria-label="Working"></mat-spinner>
}
<span class="spacer"></span>
@if (step() === 'select') {
<button mat-button (click)="close()">Cancel</button>
<button mat-raised-button color="primary" [disabled]="!canAnalyze() || busy()" (click)="analyze()">
Analyse file
</button>
}
@if (step() === 'review') {
<button mat-button [disabled]="busy()" (click)="step.set('select')">Back</button>
<button mat-raised-button color="primary" [disabled]="busy()" (click)="execute()">
Import {{ selectedCreate().size > 0 ? 'and create ' + selectedCreate().size + ' items' : '' }}
</button>
}
@if (step() === 'result') {
<button mat-raised-button color="primary" (click)="close()">Done</button>
}
</div>
@@ -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<RecipeImportDialogComponent>>(MatDialogRef);
readonly step = signal<'select' | 'review' | 'result'>('select');
readonly busy = signal(false);
readonly errorMessage = signal('');
readonly file = signal<File | null>(null);
readonly date = signal('');
readonly preview = signal<RecipeImportPreview | null>(null);
readonly result = signal<RecipeImportResult | null>(null);
readonly selectedCreate = signal<Set<string>>(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<ActionCount[]>(() => {
const counts = new Map<string, number>();
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;
}
}
+61
View File
@@ -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[];
}
@@ -1,15 +1,20 @@
<h2 class="row-container space-between">
<span>Recipes</span>
<a mat-icon-button [href]="'/api/recipes/xlsx?p=' + period.id">
<mat-icon>save_alt</mat-icon>
</a>
<a mat-icon-button href="/api/recipes/nutrition">
<mat-icon>save_alt</mat-icon>
</a>
<a mat-button [routerLink]="['/recipes', 'new']">
<mat-icon>add_box</mat-icon>
Add
</a>
<span class="row-container">
<button type="button" mat-icon-button (click)="openImport()" aria-label="Import recipes from Excel">
<mat-icon>upload</mat-icon>
</button>
<a mat-icon-button [href]="'/api/recipes/xlsx?p=' + period.id" aria-label="Export recipes to Excel">
<mat-icon>save_alt</mat-icon>
</a>
<a mat-icon-button href="/api/recipes/nutrition" aria-label="Export nutrition report">
<mat-icon>save_alt</mat-icon>
</a>
<a mat-button [routerLink]="['/recipes', 'new']">
<mat-icon>add_box</mat-icon>
Add
</a>
</span>
</h2>
<form class="flex-col" [formRoot]="form">
@@ -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);
+31
View File
@@ -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<Recipe>(`${url}/${id}`)
.pipe(catchError(this.log.handleError('RecipeService', 'delete'))) as Observable<Recipe>;
}
importPreview(file: File, date: string): Observable<RecipeImportPreview> {
const data = new FormData();
data.set('file', file);
data.set('date_', date);
return this.http
.post<RecipeImportPreview>(`${url}/import/preview`, data)
.pipe(catchError(this.log.handleError('RecipeService', 'importPreview'))) as Observable<RecipeImportPreview>;
}
importExecute(file: File, date: string, options: RecipeImportExecuteOptions): Observable<RecipeImportResult> {
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<RecipeImportResult>(`${url}/import/execute`, data)
.pipe(catchError(this.log.handleError('RecipeService', 'importExecute'))) as Observable<RecipeImportResult>;
}
}
@@ -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;
}
@@ -0,0 +1,85 @@
<header class="row-container space-between">
<h2>Sale Voucher</h2>
<a mat-icon-button routerLink="/sales-import" aria-label="Back to sales import">
<mat-icon>arrow_back</mat-icon>
</a>
</header>
@if (loading()) {
<app-skeleton-loader />
}
@if (error(); as err) {
<app-error-state [message]="err.message" />
}
@if (voucher(); as voucher) {
<section aria-label="Voucher details">
<p>
<strong>{{ voucher.date | date: 'dd-MMM-yyyy' }}</strong>
— {{ voucher.narration }}
</p>
<p>
Sold from <strong>{{ name(voucher.source) }}</strong> to
<strong>{{ name(voucher.destination) }}</strong>
</p>
</section>
<table mat-table [dataSource]="voucher.inventories" class="full-width">
<ng-container matColumnDef="product">
<th mat-header-cell *matHeaderCellDef scope="col">Product</th>
<td mat-cell *matCellDef="let line">{{ line.batch.sku.name }}</td>
</ng-container>
<ng-container matColumnDef="quantity">
<th mat-header-cell *matHeaderCellDef scope="col">Quantity</th>
<td mat-cell *matCellDef="let line" class="number">{{ line.quantity | number: '1.2-2' }}</td>
</ng-container>
<ng-container matColumnDef="rate">
<th mat-header-cell *matHeaderCellDef scope="col">Rate</th>
<td mat-cell *matCellDef="let line" class="number">{{ line.rate | currency: 'INR' : 'symbol' : '1.2-2' }}</td>
</ng-container>
<ng-container matColumnDef="tax">
<th mat-header-cell *matHeaderCellDef scope="col">Tax</th>
<td mat-cell *matCellDef="let line" class="number">{{ line.tax | percent: '1.0-2' }}</td>
</ng-container>
<ng-container matColumnDef="discount">
<th mat-header-cell *matHeaderCellDef scope="col">Discount</th>
<td mat-cell *matCellDef="let line" class="number">{{ line.discount | percent: '1.0-2' }}</td>
</ng-container>
<ng-container matColumnDef="amount">
<th mat-header-cell *matHeaderCellDef scope="col">Amount</th>
<td mat-cell *matCellDef="let line" class="number">{{ line.amount | number: '1.2-2' }}</td>
</ng-container>
<ng-container matColumnDef="happyHour">
<th mat-header-cell *matHeaderCellDef scope="col">Happy Hour</th>
<td mat-cell *matCellDef="let line">{{ line.isHappyHour ? 'Yes' : '' }}</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="lineColumns"></tr>
<tr mat-row *matRowDef="let row; columns: lineColumns"></tr>
</table>
<table mat-table [dataSource]="voucher.journals" class="full-width">
<ng-container matColumnDef="account">
<th mat-header-cell *matHeaderCellDef scope="col">Account</th>
<td mat-cell *matCellDef="let journal">{{ journal.account.name }}</td>
</ng-container>
<ng-container matColumnDef="costCentre">
<th mat-header-cell *matHeaderCellDef scope="col">Cost Centre</th>
<td mat-cell *matCellDef="let journal">{{ name(journal.costCentre) }}</td>
</ng-container>
<ng-container matColumnDef="debit">
<th mat-header-cell *matHeaderCellDef scope="col">Debit</th>
<td mat-cell *matCellDef="let journal" class="number">
{{ journal.debit === 1 ? (journal.amount | number: '1.2-2') : '' }}
</td>
</ng-container>
<ng-container matColumnDef="credit">
<th mat-header-cell *matHeaderCellDef scope="col">Credit</th>
<td mat-cell *matCellDef="let journal" class="number">
{{ journal.debit === -1 ? (journal.amount | number: '1.2-2') : '' }}
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
</table>
}
@@ -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}`;
}
}
@@ -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;
}
@@ -0,0 +1,163 @@
<h2>Sales Import</h2>
<form class="flex-col" [formRoot]="form" (submit)="$event.preventDefault(); show()">
<div class="row-container">
<mat-form-field class="flex-auto">
<mat-label>Start Date</mat-label>
<input matInput [matDatepicker]="startDate" [formField]="form.startDate" autocomplete="off" />
<mat-datepicker-toggle matSuffix [for]="startDate"></mat-datepicker-toggle>
<mat-datepicker #startDate></mat-datepicker>
</mat-form-field>
<mat-form-field class="flex-auto">
<mat-label>Finish Date</mat-label>
<input matInput [matDatepicker]="finishDate" [formField]="form.finishDate" autocomplete="off" />
<mat-datepicker-toggle matSuffix [for]="finishDate"></mat-datepicker-toggle>
<mat-datepicker #finishDate></mat-datepicker>
</mat-form-field>
<button mat-stroked-button type="submit" [disabled]="loading()">Preview</button>
</div>
</form>
@if (unmappedCategories() > 0 || newProducts().length > 0) {
<section class="notice" aria-label="Import prerequisites">
@if (unmappedCategories() > 0) {
<p>
{{ unmappedCategories() }} sale category(ies) are not mapped to cost centres. Map them below and save before
importing.
</p>
}
@if (newProducts().length > 0) {
<p>{{ newProducts().length }} new product(s) will be created automatically on import.</p>
}
</section>
}
@if (loading()) {
<app-skeleton-loader />
}
@if (error(); as err) {
<app-error-state [message]="err.message" />
}
@if (preview()) {
<section aria-label="Sale category mapping">
<h3>Sale Category Mapping</h3>
<div class="row-container wrap">
@for (entry of mappingEntries(); track entry.saleCategoryId) {
<mat-form-field class="mapping-field">
<mat-label>{{ entry.saleCategoryName ?? entry.saleCategoryId }}</mat-label>
<mat-select
[value]="mappingSelection()[entry.saleCategoryId] ?? null"
(selectionChange)="onMappingChange(entry.saleCategoryId, $event.value)"
>
<mat-option [value]="null">Not mapped</mat-option>
@for (costCentre of costCentres(); track costCentre.id) {
<mat-option [value]="costCentre.id">{{ costCentre.name }}</mat-option>
}
</mat-select>
</mat-form-field>
}
<button mat-stroked-button type="button" (click)="saveMapping()">Save Mapping</button>
</div>
</section>
<table mat-table [dataSource]="days()" class="full-width">
<ng-container matColumnDef="expand">
<th mat-header-cell *matHeaderCellDef scope="col"><span class="visually-hidden">Expand</span></th>
<td mat-cell *matCellDef="let day">
<button
mat-icon-button
type="button"
[attr.aria-label]="'Show vouchers for ' + day.businessDate"
[attr.aria-expanded]="expanded() === day"
(click)="toggleExpanded(day)"
>
<mat-icon>{{ expanded() === day ? 'expand_less' : 'expand_more' }}</mat-icon>
</button>
</td>
</ng-container>
<ng-container matColumnDef="businessDate">
<th mat-header-cell *matHeaderCellDef scope="col">Business Date</th>
<td mat-cell *matCellDef="let day">{{ day.businessDate | date: 'dd-MMM-yyyy' }}</td>
</ng-container>
<ng-container matColumnDef="action">
<th mat-header-cell *matHeaderCellDef scope="col">Status</th>
<td mat-cell *matCellDef="let day">
<span [class]="actionClass(day.action)">{{ day.action }}</span>
</td>
</ng-container>
<ng-container matColumnDef="voucherCount">
<th mat-header-cell *matHeaderCellDef scope="col">Vouchers</th>
<td mat-cell *matCellDef="let day">{{ day.vouchers.length }}</td>
</ng-container>
<ng-container matColumnDef="amount">
<th mat-header-cell *matHeaderCellDef scope="col">Amount</th>
<td mat-cell *matCellDef="let day" class="number">{{ day.amount | number: '1.2-2' }}</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns" [class.selected]="expanded() === row"></tr>
</table>
@if (expanded(); as day) {
<section aria-label="Vouchers for selected day">
@for (voucher of day.vouchers; track trackVoucher($index, voucher)) {
<mat-expansion-panel>
<mat-expansion-panel-header>
<mat-panel-title>{{ voucher.saleCategoryName }} → {{ voucher.costCentre.name }}</mat-panel-title>
<mat-panel-description>
<span [class]="actionClass(voucher.action)">{{ voucher.action }}</span>
<span class="number">{{ voucher.amount | number: '1.2-2' }}</span>
</mat-panel-description>
</mat-expansion-panel-header>
<p>{{ voucher.narration }}</p>
<table mat-table [dataSource]="voucher.lines" class="full-width">
<ng-container matColumnDef="productName">
<th mat-header-cell *matHeaderCellDef scope="col">Product</th>
<td mat-cell *matCellDef="let line">{{ line.name }} ({{ line.units }})</td>
</ng-container>
<ng-container matColumnDef="quantity">
<th mat-header-cell *matHeaderCellDef scope="col">Quantity</th>
<td mat-cell *matCellDef="let line" class="number">{{ line.quantity }}</td>
</ng-container>
<ng-container matColumnDef="rate">
<th mat-header-cell *matHeaderCellDef scope="col">Rate</th>
<td mat-cell *matCellDef="let line" class="number">{{ line.rate | number: '1.2-2' }}</td>
</ng-container>
<ng-container matColumnDef="taxRate">
<th mat-header-cell *matHeaderCellDef scope="col">Tax</th>
<td mat-cell *matCellDef="let line" class="number">{{ line.taxRate }}</td>
</ng-container>
<ng-container matColumnDef="discount">
<th mat-header-cell *matHeaderCellDef scope="col">Discount</th>
<td mat-cell *matCellDef="let line" class="number">{{ line.discount }}</td>
</ng-container>
<ng-container matColumnDef="amount">
<th mat-header-cell *matHeaderCellDef scope="col">Amount</th>
<td mat-cell *matCellDef="let line" class="number">{{ line.amount | number: '1.2-2' }}</td>
</ng-container>
<ng-container matColumnDef="happyHour">
<th mat-header-cell *matHeaderCellDef scope="col">Happy Hour</th>
<td mat-cell *matCellDef="let line">{{ line.isHappyHour ? 'Yes' : '' }}</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="lineColumns"></tr>
<tr mat-row *matRowDef="let row; columns: lineColumns"></tr>
</table>
</mat-expansion-panel>
}
</section>
}
<div class="row-container">
<button
mat-flat-button
type="button"
color="primary"
[disabled]="importing() || !canImport()"
[matTooltip]="canImport() ? '' : 'Map all sale categories to cost centres first'"
(click)="import()"
>
{{ importing() ? 'Importing…' : 'Import' }}
</button>
</div>
}
@@ -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<SalesImportFormData>({
startDate: moment().startOf('month').toDate(),
finishDate: moment().toDate(),
});
form = createForm(this.model);
previewRequest = signal<ImportRequest | null>(null);
previewResource = httpResource<PreviewResponse>(() => {
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<Record<string, string | null>>({});
importing = signal(false);
expanded = signal<DayPlan | null>(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}`;
}
}
@@ -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',
},
},
];
@@ -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<string | null>) {
return httpResource<Voucher>(() => {
const id_ = id();
return id_ === null ? undefined : `${url}/voucher/${id_}`;
});
}
preview(request: ImportRequest): Observable<PreviewResponse> {
return this.http
.post<PreviewResponse>(`${url}/preview`, request)
.pipe(catchError(this.log.handleError('SalesImportService', 'preview'))) as Observable<PreviewResponse>;
}
execute(request: ImportRequest): Observable<ExecuteResponse> {
return this.http
.post<ExecuteResponse>(`${url}/execute`, request)
.pipe(catchError(this.log.handleError('SalesImportService', 'execute'))) as Observable<ExecuteResponse>;
}
saveMapping(mapping: MappingUpdate): Observable<MappingEntry[]> {
return this.http
.put<MappingEntry[]>(`${url}/mapping`, mapping)
.pipe(catchError(this.log.handleError('SalesImportService', 'saveMapping'))) as Observable<MappingEntry[]>;
}
}
@@ -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 }[];
}
+227
View File
@@ -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"
}
}
}