diff --git a/backend/app/services/throughput_service.py b/backend/app/services/throughput_service.py index 27c5455..bc8f582 100644 --- a/backend/app/services/throughput_service.py +++ b/backend/app/services/throughput_service.py @@ -101,6 +101,29 @@ def _coerce_bool(value: object) -> bool: return True +def _coerce_import_bool(value: object, *, default: bool = False) -> bool: + """Conservative boolean parsing for ad-hoc imports. + + Uploaded CSV/XLSX rows often leave destination columns blank, or use text + like "stock" / "order" elsewhere in the row. Those should not silently + become True. Only explicit truthy markers opt in. + """ + if isinstance(value, bool): + return value + if value is None: + return default + if isinstance(value, (int, float)): + return bool(value) + text = str(value).strip().lower() + if not text: + return default + if text in {"yes", "y", "true", "1", "pass", "ok", "x", "checked"}: + return True + if text in {"no", "n", "false", "0", "fail"}: + return False + return default + + def _coerce_float(value: object) -> float | None: if value is None or value == "": return None @@ -631,8 +654,8 @@ def import_entries_from_file( by_item[item_id] = product by_name[product_name.lower()] = product - for_order = _coerce_bool(cell(row, "for_order")) if field_index.get("for_order") is not None else False - for_stock = _coerce_bool(cell(row, "for_stock")) if field_index.get("for_stock") is not None else False + for_order = _coerce_import_bool(cell(row, "for_order")) if field_index.get("for_order") is not None else False + for_stock = _coerce_import_bool(cell(row, "for_stock")) if field_index.get("for_stock") is not None else False stock_quantity = _coerce_float(cell(row, "stock_quantity")) if for_stock else None calculated = calculate_kg(quantity, quantity_type, bag_size) diff --git a/backend/tests/test_throughput.py b/backend/tests/test_throughput.py index 5dcd095..1457a85 100644 --- a/backend/tests/test_throughput.py +++ b/backend/tests/test_throughput.py @@ -13,6 +13,7 @@ from app.models.throughput import ProductionThroughput, ThroughputProduct from app.seed import seed_throughput_products_from_costing from app.services.throughput_service import ( calculate_kg, + import_entries_from_file, import_names_sheet, import_production_sheet, normalise_staff_name, @@ -240,3 +241,48 @@ def test_seed_throughput_products_from_costing_updates_existing_by_item_id(): assert products[0].name == "Updated Wheat 25kg" assert products[0].default_bag_size == 25 assert products[0].active is True + + +def test_upload_import_keeps_blank_destination_flags_false(): + db = _session() + csv_bytes = ( + "Date,Product,Quantity,Type,Bag Size,For Order,For Stock,Job Number\n" + "2026-06-12,Specialty Pigeon Breeder,40,bags,20,,,\n" + ).encode("utf-8") + + result = import_entries_from_file( + db, + filename="throughput-import.csv", + content=csv_bytes, + tenant_id="test-tenant", + created_by="tester@example.com", + ) + + assert result["entries_imported"] == 1 + entry = db.scalar(select(ProductionThroughput)) + assert entry is not None + assert entry.for_order is False + assert entry.for_stock is False + assert entry.job_number is None + + +def test_upload_import_does_not_treat_unknown_destination_text_as_true(): + db = _session() + csv_bytes = ( + "Date,Product,Quantity,Type,Bag Size,For Order,For Stock\n" + "2026-06-12,Specialty Pigeon Breeder,40,bags,20,stock,\n" + ).encode("utf-8") + + result = import_entries_from_file( + db, + filename="throughput-import.csv", + content=csv_bytes, + tenant_id="test-tenant", + created_by="tester@example.com", + ) + + assert result["entries_imported"] == 1 + entry = db.scalar(select(ProductionThroughput)) + assert entry is not None + assert entry.for_order is False + assert entry.for_stock is False diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 45c7a3e..d7073f9 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "hunter-app", - "version": "0.1.18", + "version": "0.1.22", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "hunter-app", - "version": "0.1.18", + "version": "0.1.22", "dependencies": { "@fontsource/inter": "^5.2.8", "lucide-svelte": "^1.0.1" diff --git a/frontend/package.json b/frontend/package.json index 9a6ea71..e83bda2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "hunter-app", - "version": "0.1.21", + "version": "0.1.22", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/lib/changelog.ts b/frontend/src/lib/changelog.ts index 344c0b5..c1f9cc4 100644 --- a/frontend/src/lib/changelog.ts +++ b/frontend/src/lib/changelog.ts @@ -17,6 +17,13 @@ export type ChangelogEntry = { export const APP_VERSION: string = packageInfo.version; export const changelog: ChangelogEntry[] = [ + { + version: '0.1.22', + date: '2026-06-15', + highlights: [ + 'Web App - Throughput module is now live.' + ] + }, { version: '0.1.20', date: '2026-06-13', diff --git a/frontend/src/lib/navigation/client-navigation.ts b/frontend/src/lib/navigation/client-navigation.ts index 8e81c29..76519eb 100644 --- a/frontend/src/lib/navigation/client-navigation.ts +++ b/frontend/src/lib/navigation/client-navigation.ts @@ -146,8 +146,7 @@ export const throughputItem: NavItem = { label: 'Throughput', shortLabel: 'OT', icon: Gauge, - moduleKey: 'operations_throughput', - badge: 'test' + moduleKey: 'operations_throughput' }; export const orderingItem: NavItem = { diff --git a/frontend/src/routes/throughput/+page.svelte b/frontend/src/routes/throughput/+page.svelte index 84d5155..eb413b0 100644 --- a/frontend/src/routes/throughput/+page.svelte +++ b/frontend/src/routes/throughput/+page.svelte @@ -541,13 +541,19 @@ return (a ?? '').localeCompare(b ?? '', undefined, { sensitivity: 'base' }); } + function compareDate(a: string | null | undefined, b: string | null | undefined) { + const aTime = a ? Date.parse(a) : Number.NEGATIVE_INFINITY; + const bTime = b ? Date.parse(b) : Number.NEGATIVE_INFINITY; + return aTime - bTime; + } + const sortedEntries = $derived.by(() => { const direction = sortDirection === 'asc' ? 1 : -1; return [...entries].sort((a, b) => { let result = 0; if (sortKey === 'date') { - result = compareText(a.production_date, b.production_date); + result = compareDate(a.production_date, b.production_date); } else if (sortKey === 'product') { result = compareText(a.product_name_snapshot, b.product_name_snapshot); } else if (sortKey === 'packed') { @@ -565,6 +571,10 @@ result = compareText(a.notes, b.notes); } + if (result === 0) { + result = (a.id ?? 0) - (b.id ?? 0); + } + return result * direction; }); });