diff --git a/.vscode/settings.json b/.vscode/settings.json index dbc35fca..413e4568 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -8,7 +8,7 @@ ], "python.venvPath": "${workspaceFolder}/barker", "python.terminal.activateEnvironment": true, - // Quality-of-life + // Quality-of-life /* --- Pylance: keep IntelliSense, disable type checking --- */ // "python.analysis.typeCheckingMode": "off", // "python.analysis.diagnosticMode": "openFilesOnly", @@ -40,6 +40,7 @@ "**/.mypy_cache": true, "**/.ruff_cache": true, "**/.pytest_cache": true, + "**/.venv": true, ".idea": true, "**/node_modules": true, "typings": true, @@ -56,6 +57,7 @@ "**/.mypy_cache": true, "**/.ruff_cache": true, "**/.pytest_cache": true, + "**/.venv": true, ".idea": true, "**/node_modules": true, "**/poetry.lock": true, diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 00e6877b..f88bba6e 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -4,7 +4,7 @@ "tasks": [ { "type": "npm", - "script": "start", + "script": "start -- --port 4200", "isBackground": true, "options": { "cwd": "${workspaceFolder}/overlord" diff --git a/brewman/alembic/versions/c7e1a94b2d30_barker_sales_import.py b/brewman/alembic/versions/c7e1a94b2d30_barker_sales_import.py index 2d5bfbbb..6e6644d2 100644 --- a/brewman/alembic/versions/c7e1a94b2d30_barker_sales_import.py +++ b/brewman/alembic/versions/c7e1a94b2d30_barker_sales_import.py @@ -27,6 +27,7 @@ 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" +COST_CENTRE_SALE_ID = "a61fc66c-8ce1-4d1d-8dde-98e33e1afc4f" def upgrade() -> None: @@ -75,6 +76,7 @@ def upgrade() -> None: sa.column("is_fixture", sa.Boolean), ) op.execute(cost_centres.insert().values(id=COST_CENTRE_PRODUCTION_ID, name="Production", is_fixture=True)) + op.execute(cost_centres.insert().values(id=COST_CENTRE_SALE_ID, name="Sale", is_fixture=True)) permissions = sa.table( "permissions", @@ -102,6 +104,7 @@ def downgrade() -> None: ) 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.execute(sa.text("DELETE FROM cost_centres WHERE id = :id").bindparams(id=COST_CENTRE_SALE_ID)) op.drop_index(op.f("ix_barker_products_brewman_product_id"), table_name="barker_products") op.drop_index(op.f("ix_barker_products_barker_product_id"), table_name="barker_products") op.drop_table("barker_products") diff --git a/brewman/brewman/models/cost_centre.py b/brewman/brewman/models/cost_centre.py index 5a2e637e..f5a3b1d3 100644 --- a/brewman/brewman/models/cost_centre.py +++ b/brewman/brewman/models/cost_centre.py @@ -55,6 +55,10 @@ class CostCentre: def cost_centre_production(cls) -> uuid.UUID: return uuid.UUID("0715924d-373e-4765-a46e-dd00f7e1aede") + @classmethod + def cost_centre_sale(cls) -> uuid.UUID: + return uuid.UUID("a61fc66c-8ce1-4d1d-8dde-98e33e1afc4f") + @classmethod def overall(cls) -> CostCentreLink: return CostCentreLink(id_=uuid.UUID("36f59436-522a-0746-ae94-e0f746bf6c0d"), name="Overall") diff --git a/brewman/brewman/models/sku_version.py b/brewman/brewman/models/sku_version.py index 45d2e0dc..2073c5a2 100644 --- a/brewman/brewman/models/sku_version.py +++ b/brewman/brewman/models/sku_version.py @@ -6,7 +6,7 @@ from datetime import date from decimal import Decimal from typing import TYPE_CHECKING -from sqlalchemy import Date, ForeignKey, Numeric, Unicode, Uuid, func, text +from sqlalchemy import Date, FetchedValue, ForeignKey, Numeric, Unicode, Uuid, func, text from sqlalchemy.dialects import postgresql from sqlalchemy.orm import Mapped, mapped_column, relationship @@ -24,7 +24,6 @@ class SkuVersion: id: Mapped[uuid.UUID] = mapped_column( Uuid, primary_key=True, insert_default=uuid.uuid4, server_default=text("gen_random_uuid()") ) - # product_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("products.id"), nullable=False) # should i remove this? # DB column is "product_id", but ORM attribute is private: "_product_id" _product_id: Mapped[uuid.UUID] = mapped_column( "product_id", @@ -33,6 +32,8 @@ class SkuVersion: nullable=False, repr=False, # hides in dataclass repr init=False, # not part of __init__ signature (dataclass) + server_default=FetchedValue(), + server_onupdate=FetchedValue(), ) sku_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("stock_keeping_units.id"), nullable=False) units: Mapped[str] = mapped_column(Unicode, nullable=False) @@ -66,16 +67,12 @@ class SkuVersion: product_yield: Decimal = Decimal("1.0"), cost_price: Decimal = Decimal("0.0"), sale_price: Decimal = Decimal("0.0"), - # product_id: uuid.UUID | None = None, - # product: Product | None = None, sku_id: uuid.UUID | None = None, sku: StockKeepingUnit | None = None, id_: uuid.UUID | None = None, valid_from: date | None = None, valid_till: date | None = None, ) -> None: - # if product_id is not None: - # self.product_id = product_id if sku_id is not None: self.sku_id = sku_id self.units = units @@ -85,8 +82,6 @@ class SkuVersion: self.sale_price = sale_price if id_ is not None: self.id = id_ - # if product is not None: - # self.product = product if sku is not None: self.sku = sku self.valid_from = valid_from diff --git a/brewman/brewman/routers/reports/product_ledger.py b/brewman/brewman/routers/reports/product_ledger.py index 662855b3..75b18f12 100644 --- a/brewman/brewman/routers/reports/product_ledger.py +++ b/brewman/brewman/routers/reports/product_ledger.py @@ -91,6 +91,7 @@ def build_report( .where( StockKeepingUnit.product_id == product_id, Journal.cost_centre_id != CostCentre.cost_centre_purchase(), + Journal.cost_centre_id != CostCentre.cost_centre_sale(), Voucher.date_ >= start_date, Voucher.date_ <= finish_date, ) @@ -102,9 +103,13 @@ def build_report( for voucher, inventory, journal, stockKeepingUnit in query: journal_debit = journal.debit * -1 + if voucher.voucher_type == VoucherType.SALE: + print(journal_debit, " pre") + journal_debit = -1 * journal_debit + print(journal_debit) name = ( journal.cost_centre.name - if voucher.voucher_type in [VoucherType.ISSUE, VoucherType.CLOSING_STOCK] + if voucher.voucher_type in [VoucherType.ISSUE, VoucherType.CLOSING_STOCK, VoucherType.SALE] else journal.account.name ) debit_q = inventory.quantity if journal_debit == 1 else None @@ -131,7 +136,8 @@ def build_report( ], type_=voucher.voucher_type.name.replace("_", " ").title(), narration=voucher.narration, - posted=voucher.posted or voucher.voucher_type in [VoucherType.ISSUE, VoucherType.CLOSING_STOCK], + posted=voucher.posted + or voucher.voucher_type in [VoucherType.ISSUE, VoucherType.CLOSING_STOCK, VoucherType.SALE], debit_quantity=debit_q, debit_amount=debit_a, debit_unit=debit_u, @@ -162,8 +168,6 @@ def opening_balance( .join(Batch.sku) .join(SkuVersion, onclause=_sv_onclause(Voucher.date_)) .where( - Voucher.id == Inventory.voucher_id, - Voucher.id == Journal.voucher_id, StockKeepingUnit.product_id == product_id, Journal.cost_centre_id == CostCentre.cost_centre_purchase(), Voucher.date_ < start_date, diff --git a/brewman/brewman/services/sales_import/products.py b/brewman/brewman/services/sales_import/products.py index 2e01488f..beddd09b 100644 --- a/brewman/brewman/services/sales_import/products.py +++ b/brewman/brewman/services/sales_import/products.py @@ -160,7 +160,10 @@ 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" + func.regexp_replace(func.lower(func.trim(column)), r"[^[:alnum:]_[:space:]-]", "", "g"), + r"[[:space:]_-]+", + "-", + "g", ), "-", ) @@ -264,6 +267,7 @@ def _create_sku(db: Session, product_id: uuid.UUID, line: BarkerSaleLine, date_: valid_till=None, ) ) + db.flush() return sku.id @@ -297,6 +301,7 @@ def _create_product(db: Session, line: BarkerSaleLine, date_: date) -> uuid.UUID valid_till=None, ) ) + db.flush() _create_sku(db, product.id, line, date_) return product.id @@ -339,6 +344,21 @@ def _update_name_if_needed(db: Session, product_id: uuid.UUID, new_name: str, da ).scalar_one_or_none() if active_pv is not None and _normalize(active_pv.name) != wanted: + clashing_rows = db.execute( + select(ProductVersion.product_id, ProductVersion.name).where( + _pg_normalize(ProductVersion.name) == wanted, + ProductVersion.product_id != product_id, + (ProductVersion.valid_from == None) | (ProductVersion.valid_from <= date_), # noqa: E711 + (ProductVersion.valid_till == None) | (ProductVersion.valid_till >= date_), # noqa: E711 + ) + ).all() + if clashing_rows: + conflicting_names = ", ".join(f"'{p_name}'" for (_, p_name) in clashing_rows) + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"Cannot rename product '{active_pv.name}' to '{new_name}': an active product with that name already exists ({conflicting_names}).", + ) + if active_pv.valid_from == date_: active_pv.name = new_name active_pv.handle = ProductVersion.slugify(new_name) @@ -387,6 +407,26 @@ def _update_units_if_needed(db: Session, sku_id: uuid.UUID, new_units: str, date ).scalar_one_or_none() if active_sv is not None and _normalize(active_sv.units) != wanted: + clashing_sv = ( + db.execute( + select(SkuVersion.units).where( + SkuVersion._product_id == active_sv._product_id, + SkuVersion.sku_id != sku_id, + _pg_normalize(SkuVersion.units) == wanted, + (SkuVersion.valid_from == None) | (SkuVersion.valid_from <= date_), # noqa: E711 + (SkuVersion.valid_till == None) | (SkuVersion.valid_till >= date_), # noqa: E711 + ) + ) + .scalars() + .all() + ) + if clashing_sv: + conflicting_units = ", ".join(f"'{u}'" for u in clashing_sv) + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"Cannot rename SKU units '{active_sv.units}' to '{new_units}': an active SKU with those units already exists ({conflicting_units}).", + ) + if active_sv.valid_from == date_: active_sv.units = new_units db.flush() diff --git a/brewman/brewman/services/sales_import/vouchers.py b/brewman/brewman/services/sales_import/vouchers.py index 3c5cec62..9710824a 100644 --- a/brewman/brewman/services/sales_import/vouchers.py +++ b/brewman/brewman/services/sales_import/vouchers.py @@ -37,7 +37,8 @@ from .products import ALL_PURCHASES_ID, ProvisionedProduct, existing_binding, re NARRATION_PREFIX = "Sales import from Barker for" -PRODUCTION_ID = CostCentre.cost_centre_production() +# PRODUCTION_ID = CostCentre.cost_centre_production() +SALE_ID = CostCentre.cost_centre_sale() Q2 = Decimal("0.01") Q5 = Decimal("0.00001") @@ -316,12 +317,15 @@ def create_voucher(db: Session, plan: PlannedVoucher, user_id: uuid.UUID) -> Non 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) + credit_journal = Journal( + debit=-1, account_id=ALL_PURCHASES_ID, amount=round(amount, 2), cost_centre_id=plan.cost_centre_id ) + voucher.journals.append(credit_journal) + db.add(credit_journal) + debit_journal = Journal(debit=1, account_id=ALL_PURCHASES_ID, amount=round(amount, 2), cost_centre_id=SALE_ID) + voucher.journals.append(debit_journal) + db.add(debit_journal) + db.flush() check_journals_are_valid(voucher) diff --git a/overlord/src/app/role/role-detail/role-detail.component.html b/overlord/src/app/role/role-detail/role-detail.component.html index d970fa09..c2a4a97e 100644 --- a/overlord/src/app/role/role-detail/role-detail.component.html +++ b/overlord/src/app/role/role-detail/role-detail.component.html @@ -16,7 +16,7 @@