In progress Sales import and Recipe import.
First I will finish Sales Import. Then move on to recipe import
This commit is contained in:
1 parent
75ccbdc6f9
commit
b425b299bf
50 files changed
+5306
-123
No files matched your search
@@ -20,3 +20,4 @@ JWT_TOKEN_EXPIRE_MINUTES=30
|
||||
|
||||
ALEMBIC_LOG_LEVEL=INFO
|
||||
ALEMBIC_SQLALCHEMY_LOG_LEVEL=WARNING
|
||||
BARKER_URL=http://localhost:9995/
|
||||
+100
@@ -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")
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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_
|
||||
@@ -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")
|
||||
@@ -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__(
|
||||
|
||||
@@ -4,3 +4,4 @@ import enum
|
||||
class SettingType(enum.IntEnum):
|
||||
VOUCHER_LOCK = 0
|
||||
MAINTENANCE_MODE = 1
|
||||
SALE_CATEGORY_COST_CENTRES = 2
|
||||
@@ -15,3 +15,4 @@ class VoucherType(enum.IntEnum):
|
||||
CLOSING_BALANCE = 11
|
||||
EMPLOYEE_BENEFIT = 12
|
||||
INCENTIVE = 13
|
||||
SALE = 14
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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.
@@ -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
|
||||
@@ -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)
|
||||
@@ -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…_
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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 }[];
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user