Sales import Working

This commit is contained in:
tanshu committed 2026-09-15 04:10:38 +00:00
1 parent 7be26ad9da
commit 7656c7e877
11 files changed
+190 -77

No files matched your search

+3 -1
View File
@@ -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,
+1 -1
View File
@@ -4,7 +4,7 @@
"tasks": [
{
"type": "npm",
"script": "start",
"script": "start -- --port 4200",
"isBackground": true,
"options": {
"cwd": "${workspaceFolder}/overlord"
@@ -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")
+4
View File
@@ -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")
+3 -8
View File
@@ -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
@@ -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,
@@ -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()
@@ -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)
@@ -16,7 +16,7 @@
<div class="two-col">
<div class="col">
<h3>Permissions</h3>
@for (p of item().permissions; track p; let i = $index) {
@for (p of item().permissions; track p.id; let i = $index) {
<div class="row-container">
<mat-checkbox
[formField]="form.permissions[i].permission"
@@ -30,7 +30,7 @@
<div class="col">
<h3>Includes Roles</h3>
@for (r of item().includedRoles; track r; let i = $index) {
@for (r of item().includedRoles; track r.id; let i = $index) {
<div class="row-container">
<mat-checkbox [formField]="form.includedRoles[i].role" class="flex-auto">{{ r.name }}</mat-checkbox>
</div>
@@ -1,4 +1,13 @@
import { Component, inject, afterNextRender, effect, input, computed, linkedSignal } from '@angular/core';
import {
Component,
computed,
effect,
inject,
input,
linkedSignal,
untracked,
afterNextRender,
} from '@angular/core';
import { form as createForm, FormField, FormRoot } from '@angular/forms/signals';
import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
@@ -12,14 +21,31 @@ import { Router } from '@angular/router';
import { ConfirmDialogComponent } from '../../shared/confirm-dialog/confirm-dialog.component';
import { ErrorStateComponent } from '../../shared/error-state/error-state.component';
import { SkeletonLoaderComponent } from '../../shared/skeleton-loader/skeleton-loader.component';
import { Role } from '../role';
import { Permission } from '../permission';
import { Role, RoleItem } from '../role';
import { RoleService } from '../role.service';
export interface RoleDetailFormData {
name: string;
permissions: {
id: string;
name: string;
permission: boolean;
direct: boolean;
disabled: boolean;
}[];
includedRoles: {
id: string;
name: string;
role: boolean;
permissionIds: string[];
}[];
}
@Component({
selector: 'app-role-detail',
templateUrl: './role-detail.component.html',
styleUrls: ['./role-detail.component.css'],
imports: [
FormField,
FormRoot,
@@ -44,63 +70,93 @@ export class RoleDetailComponent {
model = linkedSignal({
source: this.item,
computation: (itemVal) => ({
name: itemVal.name ?? '',
permissions: itemVal.permissions.map((x) => ({
permission: x.enabled,
disabled: false,
})),
includedRoles: itemVal.includedRoles.map((x) => ({
role: x.enabled,
})),
}),
computation: (itemVal): RoleDetailFormData => {
const implied = new Set<string>();
(itemVal.includedRoles || []).forEach((r) => {
if (r.enabled) {
(r.permissionIds || []).forEach((pid) => implied.add(pid));
}
});
return {
name: itemVal.name ?? '',
permissions: (itemVal.permissions || []).map((p) => {
const isImplied = implied.has(p.id as string);
const isDirect = p.enabled === true;
return {
id: p.id ?? '',
name: p.name,
permission: isImplied || isDirect,
direct: isDirect,
disabled: isImplied,
};
}),
includedRoles: (itemVal.includedRoles || []).map((r) => ({
id: r.id ?? '',
name: r.name,
role: r.enabled === true,
permissionIds: r.permissionIds || [],
})),
};
},
});
form = createForm(this.model);
private enabledIncludedRoleIds = computed(() => {
return this.model()
.includedRoles.filter((r) => r.role)
.map((r) => r.id)
.sort()
.join(',');
});
constructor() {
afterNextRender(() => {
this.form.name().focusBoundControl();
});
effect(() => {
// Track changes to includedRoles explicitly
this.model().includedRoles.map((x) => x.role);
this.applyIncludedRolePermissionLocks();
// Recompute permission locks whenever the set of enabled included roles changes
this.enabledIncludedRoleIds();
untracked(() => {
this.applyIncludedRolePermissionLocks();
});
});
}
private applyIncludedRolePermissionLocks(): void {
this.model.update((currentModel) => {
const implied = new Set<string>();
const currentItem = this.item();
currentItem.includedRoles.forEach((roleOpt, idx) => {
const checked = currentModel.includedRoles[idx]?.role;
if (checked) {
(roleOpt.permissionIds || []).forEach((pid) => implied.add(pid));
currentModel.includedRoles.forEach((role) => {
if (role.role) {
(role.permissionIds || []).forEach((pid) => implied.add(pid));
}
});
const newPerms = [...currentModel.permissions];
let changed = false;
const newPerms = currentModel.permissions.map((p) => {
// If not disabled, direct state tracks the user's checkbox value
const currentDirect = p.disabled ? p.direct : p.permission;
const isImplied = implied.has(p.id);
currentItem.permissions.forEach((perm, idx) => {
const isImplied = implied.has(perm.id as string);
const isDirect = perm.enabled === true;
const newPermission = isImplied ? true : currentDirect;
const newDisabled = isImplied;
if (!newPerms[idx]) return;
if (isImplied) {
if (!newPerms[idx].permission || !newPerms[idx].disabled) {
newPerms[idx] = { permission: true, disabled: true };
changed = true;
}
} else {
if (newPerms[idx].permission !== isDirect || newPerms[idx].disabled) {
newPerms[idx] = { permission: isDirect, disabled: false };
changed = true;
}
if (
p.direct !== currentDirect ||
p.permission !== newPermission ||
p.disabled !== newDisabled
) {
changed = true;
return {
...p,
direct: currentDirect,
permission: newPermission,
disabled: newDisabled,
};
}
return p;
});
if (changed) {
@@ -149,21 +205,26 @@ export class RoleDetailComponent {
getItem(): Role {
const formModel = this.model();
const currentItem = this.item();
currentItem.name = formModel.name ?? '';
const permArray = formModel.permissions;
currentItem.permissions.forEach((p, index) => {
if (permArray[index]?.disabled) {
return;
}
p.enabled = permArray[index]?.permission ?? false;
return new Role({
...this.item(),
name: formModel.name.trim(),
permissions: formModel.permissions.map(
(p) =>
new Permission({
id: p.id,
name: p.name,
enabled: p.disabled ? p.direct : p.permission,
}),
),
includedRoles: formModel.includedRoles.map(
(r) =>
new RoleItem({
id: r.id,
name: r.name,
enabled: r.role,
permissionIds: r.permissionIds,
}),
),
});
const includeArray = formModel.includedRoles;
currentItem.includedRoles.forEach((r, index) => {
r.enabled = includeArray[index]?.role ?? false;
});
return currentItem;
}
}
@@ -164,6 +164,6 @@ export class SalesImportComponent {
}
trackLine(_index: number, line: SaleLine): string {
return `${line.skuId ?? line.productName}-${line.rate}-${line.isHappyHour}`;
return `${line.skuId ?? line.name}-${line.rate}-${line.isHappyHour}`;
}
}