374 lines
12 KiB
Markdown
374 lines
12 KiB
Markdown
# Database Design
|
||
|
||
## Purpose
|
||
|
||
This app uses a relational database to support five main concerns:
|
||
|
||
1. Raw material pricing and unit conversion.
|
||
2. Mix definitions and mix costing.
|
||
3. Product-level formulas and product costing.
|
||
4. Mix calculator session history.
|
||
5. Access control for both internal users and client users.
|
||
|
||
The backend is written with SQLAlchemy models in `backend/app/models`. The schema is created automatically at startup, and lightweight migration/patch logic lives in `backend/app/db/migrations.py`.
|
||
|
||
## Design Principles
|
||
|
||
- `tenant_id` is the tenancy boundary for most business tables.
|
||
- Reference/master data is stored separately from transactional/session data.
|
||
- Product costing is built from raw materials -> formulas -> products -> outputs.
|
||
- The mix calculator now prefers product-specific ingredient formulas over the shared mix master.
|
||
- The database is designed to run on both SQLite locally and Postgres in production.
|
||
|
||
## High-Level Domains
|
||
|
||
### 1. Raw Materials
|
||
|
||
These tables store ingredients and their price history.
|
||
|
||
- `raw_materials`
|
||
- One row per ingredient/raw material.
|
||
- Stores name, supplier, unit of measure, `kg_per_unit`, status, and notes.
|
||
- Example: `Hulled Oats`, `White French Millet`, `Pano`.
|
||
|
||
- `raw_material_price_versions`
|
||
- One-to-many from `raw_materials`.
|
||
- Stores `market_value`, `waste_percentage`, `effective_date`, and status.
|
||
- Lets the system keep historical prices instead of overwriting one current value.
|
||
|
||
Relationship:
|
||
|
||
- `raw_materials.id` -> `raw_material_price_versions.raw_material_id`
|
||
|
||
### 2. Mix Master
|
||
|
||
These tables store shared mix definitions.
|
||
|
||
- `mixes`
|
||
- One row per named mix.
|
||
- Stores client name, mix name, version, status, and notes.
|
||
- This is the shared mix/master-recipe layer.
|
||
|
||
- `mix_ingredients`
|
||
- One-to-many from `mixes`.
|
||
- One row per raw material inside a mix.
|
||
- Stores `quantity_kg` for that mix.
|
||
|
||
Relationships:
|
||
|
||
- `mixes.id` -> `mix_ingredients.mix_id`
|
||
- `raw_materials.id` -> `mix_ingredients.raw_material_id`
|
||
|
||
Important note:
|
||
|
||
- This table is still used by mix master pages and as a fallback.
|
||
- It is no longer the primary source for mix calculator formulas when product-specific formulas exist.
|
||
|
||
### 3. Products
|
||
|
||
These tables describe saleable products and their formula rows.
|
||
|
||
- `products`
|
||
- One row per sellable product/SKU.
|
||
- Stores client name, product name, optional `item_id`, packaging/unit info, margins, and linked mix.
|
||
- `mix_id` links the product to the shared mix master entry.
|
||
|
||
- `product_ingredients`
|
||
- One-to-many from `products`.
|
||
- One row per raw material required for that product’s formula.
|
||
- Stores `quantity_kg`, `sort_order`, and optional notes.
|
||
- This is now the key table for the mix calculator.
|
||
|
||
Relationships:
|
||
|
||
- `products.mix_id` -> `mixes.id`
|
||
- `products.id` -> `product_ingredients.product_id`
|
||
- `raw_materials.id` -> `product_ingredients.raw_material_id`
|
||
|
||
Why both `mix_ingredients` and `product_ingredients` exist:
|
||
|
||
- `mix_ingredients` represents a shared recipe.
|
||
- `product_ingredients` represents the actual formula used for a specific product.
|
||
- Multiple products can point at the same mix name but still require product-specific formula rows.
|
||
- This solves the workbook case where product labels like `Budgie Mix 20kg` map to a formula/mix name like `Hunter - Budgie Mix`.
|
||
|
||
### 4. Costing Assumptions
|
||
|
||
These tables hold non-ingredient costs used in product costing.
|
||
|
||
- `process_cost_rules`
|
||
- Holds grading, bagging, and cracking costs by `process_name`.
|
||
|
||
- `packaging_cost_rules`
|
||
- Holds bag cost by `sale_type`, `unit_of_measure`, and `own_bag`.
|
||
|
||
- `freight_cost_rules`
|
||
- Holds freight cost by `sale_type` and `unit_of_measure`.
|
||
|
||
These tables are read during product cost calculation after ingredient cost has been resolved.
|
||
|
||
### 5. Scenarios and Stored Outputs
|
||
|
||
- `scenarios`
|
||
- Named pricing/costing scenarios.
|
||
- Stores `overrides` as JSON.
|
||
|
||
- `costing_results`
|
||
- One-to-many from `scenarios`.
|
||
- Stores calculated output per product for a scenario.
|
||
- Includes prices, warnings, and calculation details as JSON.
|
||
|
||
Relationships:
|
||
|
||
- `scenarios.id` -> `costing_results.scenario_id`
|
||
- `products.id` -> `costing_results.product_id`
|
||
|
||
### 6. Mix Calculator Sessions
|
||
|
||
These tables store saved calculator runs.
|
||
|
||
- `mix_calculator_sessions`
|
||
- Header row for a calculator run.
|
||
- Stores product, mix, batch size, total bags, total kg, prepared by, and timestamps.
|
||
|
||
- `mix_calculator_session_lines`
|
||
- One-to-many from `mix_calculator_sessions`.
|
||
- Snapshot of the scaled ingredient rows shown to the user at save time.
|
||
- Stores `required_kg`, `mix_percentage`, unit, and display name.
|
||
|
||
Relationships:
|
||
|
||
- `mix_calculator_sessions.id` -> `mix_calculator_session_lines.session_id`
|
||
- `products.id` -> `mix_calculator_sessions.product_id`
|
||
- `mixes.id` -> `mix_calculator_sessions.mix_id`
|
||
|
||
Important note:
|
||
|
||
- Session lines are denormalized snapshots.
|
||
- They are intentionally stored separately so historical saved runs do not change if product formulas are updated later.
|
||
|
||
### 7. Client Access / Tenant Administration
|
||
|
||
These tables manage customer-facing users and feature/module access.
|
||
|
||
- `client_accounts`
|
||
- One row per client/tenant account.
|
||
|
||
- `client_users`
|
||
- One-to-many from `client_accounts`.
|
||
- Customer-side users tied to a client account.
|
||
|
||
- `client_feature_access`
|
||
- One-to-many from `client_accounts`.
|
||
- Feature flags per client account.
|
||
|
||
- `client_user_module_permissions`
|
||
- One-to-many from `client_users`.
|
||
- Module-level access levels per client user.
|
||
|
||
- `client_access_audit_events`
|
||
- One-to-many from `client_accounts`.
|
||
- Audit log for client-access changes.
|
||
|
||
Relationships:
|
||
|
||
- `client_accounts.id` -> `client_users.client_account_id`
|
||
- `client_accounts.id` -> `client_feature_access.client_account_id`
|
||
- `client_accounts.id` -> `client_access_audit_events.client_account_id`
|
||
- `client_users.id` -> `client_user_module_permissions.client_user_id`
|
||
|
||
### 8. Internal Access Control
|
||
|
||
These tables are for internal staff login and permissions.
|
||
|
||
- `users`
|
||
- Internal users.
|
||
- Stores per-user `password_hash`, role link, and active flag.
|
||
|
||
- `roles`
|
||
- Named roles like `Admin`, `Operations`, `Full Access`.
|
||
|
||
- `permissions`
|
||
- Atomic permission keys like `view_mix_calculator`.
|
||
|
||
- `role_permissions`
|
||
- Many-to-many join table between roles and permissions.
|
||
|
||
Relationships:
|
||
|
||
- `roles.id` -> `users.role_id`
|
||
- `roles.id` <-> `permissions.id` through `role_permissions`
|
||
|
||
## Core Costing Flow
|
||
|
||
### Raw Material Cost
|
||
|
||
The system calculates ingredient cost from:
|
||
|
||
- `market_value`
|
||
- `waste_percentage`
|
||
- `kg_per_unit`
|
||
|
||
This produces:
|
||
|
||
- loss cost
|
||
- adjusted cost per unit
|
||
- cost per kg
|
||
|
||
### Mix Cost
|
||
|
||
There are now two formula sources:
|
||
|
||
1. Preferred: `product_ingredients`
|
||
2. Fallback: `mix_ingredients`
|
||
|
||
For mix calculator and product costing:
|
||
|
||
- if a product has rows in `product_ingredients`, use them
|
||
- otherwise use the linked shared mix from `mix_ingredients`
|
||
|
||
### Product Cost
|
||
|
||
Product cost is built from:
|
||
|
||
1. ingredient formula cost
|
||
2. process costs
|
||
3. packaging cost
|
||
4. freight cost
|
||
5. optional distributor / wholesale margin
|
||
|
||
## Workbook Import Design
|
||
|
||
The seed/import logic is in `backend/app/seed.py`.
|
||
|
||
There are now two workbook roles:
|
||
|
||
- Legacy costing workbook:
|
||
- `C- Raw Products Costs`
|
||
- `M - All`
|
||
- `Product Cost - Price`
|
||
- Product-formula workbook:
|
||
- `input_data/1.xlsx`
|
||
- sheet `mix_quantites_per_client_per_pr`
|
||
|
||
### Current Import Behaviour
|
||
|
||
- Raw materials are seeded from the legacy costing workbook.
|
||
- Shared mixes are seeded from the legacy costing workbook.
|
||
- Products are seeded from the legacy costing workbook.
|
||
- Product-specific formulas are seeded from `mix_quantites_per_client_per_pr`.
|
||
|
||
### Formula Matching Rule
|
||
|
||
Workbook formula rows are attached to products using:
|
||
|
||
1. `(client_name, product.name)` if it matches directly.
|
||
2. `(client_name, product.mix.name)` if the workbook row uses the mix/formula name instead of the sellable product label.
|
||
|
||
This is important for cases like:
|
||
|
||
- workbook formula: `HunterBird / Hunter - Budgie Mix`
|
||
- product row: `HunterBird / Budgie Mix 20kg`
|
||
|
||
Both product SKUs can inherit the same formula through the linked mix name.
|
||
|
||
## Tenancy
|
||
|
||
Most business tables include `tenant_id`.
|
||
|
||
This includes:
|
||
|
||
- raw materials
|
||
- price versions
|
||
- mixes
|
||
- mix ingredients
|
||
- product ingredients
|
||
- products
|
||
- scenarios
|
||
- costing results
|
||
- mix calculator sessions and lines
|
||
- client-access tables
|
||
- assumption tables
|
||
|
||
Startup migration logic backfills `tenant_id` where possible by deriving it from related parent tables.
|
||
|
||
## Visibility Rules
|
||
|
||
The `products.visible` flag is used to hide client/product rows from normal UI paths.
|
||
|
||
Startup migration logic also auto-hides products for a configured list of client names in `backend/app/db/migrations.py`.
|
||
|
||
This means:
|
||
|
||
- rows can exist in the database
|
||
- but not be offered in normal mix calculator/product selection flows
|
||
|
||
## Transaction vs Reference Data
|
||
|
||
Reference/master data:
|
||
|
||
- `raw_materials`
|
||
- `raw_material_price_versions`
|
||
- `mixes`
|
||
- `mix_ingredients`
|
||
- `products`
|
||
- `product_ingredients`
|
||
- `process_cost_rules`
|
||
- `packaging_cost_rules`
|
||
- `freight_cost_rules`
|
||
- access-control tables
|
||
|
||
Transactional/snapshot data:
|
||
|
||
- `mix_calculator_sessions`
|
||
- `mix_calculator_session_lines`
|
||
- `scenarios`
|
||
- `costing_results`
|
||
- `client_access_audit_events`
|
||
|
||
## Important Constraints
|
||
|
||
- `mix_ingredients` is unique on `(mix_id, raw_material_id)`.
|
||
- `product_ingredients` is unique on `(product_id, raw_material_id)`.
|
||
- `client_users` is unique on `(client_account_id, email)`.
|
||
- `client_feature_access` is unique on `(client_account_id, feature_key)`.
|
||
- `client_user_module_permissions` is unique on `(client_user_id, module_key)`.
|
||
- `mix_calculator_sessions` is unique on `(tenant_id, session_number)`.
|
||
|
||
These constraints prevent duplicate ingredient or access rows within the same parent scope.
|
||
|
||
## Known Tradeoffs
|
||
|
||
- `RawMaterial.name` is globally unique, not tenant-scoped. That is simple for now, but stricter than a multi-tenant design usually wants.
|
||
- `Product.mix_id` is still required even though product-specific formulas now exist. That is useful for compatibility and navigation, but it means a product currently has both a shared mix link and potentially its own formula rows.
|
||
- Some calculation outputs are denormalized into session/result tables for stability and history.
|
||
- Migration logic is startup-driven and pragmatic rather than using a full migration framework like Alembic.
|
||
|
||
## Recommended Mental Model
|
||
|
||
Use this as the working model of the schema:
|
||
|
||
- `raw_materials` = ingredients
|
||
- `raw_material_price_versions` = ingredient pricing history
|
||
- `mixes` = shared recipe labels
|
||
- `mix_ingredients` = shared recipe lines
|
||
- `products` = saleable SKUs
|
||
- `product_ingredients` = actual formula for a SKU
|
||
- `mix_calculator_sessions` + `lines` = saved production calculations
|
||
- `scenarios` + `costing_results` = stored what-if pricing outputs
|
||
- `client_*` tables = client account access
|
||
- `users / roles / permissions` = internal staff access
|
||
|
||
## Files To Read Alongside This Document
|
||
|
||
- [backend/app/models/raw_material.py](/abs/path/C:/Users/mattc/data-entry-app/data-entry-app/backend/app/models/raw_material.py:1)
|
||
- [backend/app/models/mix.py](/abs/path/C:/Users/mattc/data-entry-app/data-entry-app/backend/app/models/mix.py:1)
|
||
- [backend/app/models/product.py](/abs/path/C:/Users/mattc/data-entry-app/data-entry-app/backend/app/models/product.py:1)
|
||
- [backend/app/models/mix_calculator.py](/abs/path/C:/Users/mattc/data-entry-app/data-entry-app/backend/app/models/mix_calculator.py:1)
|
||
- [backend/app/models/scenario.py](/abs/path/C:/Users/mattc/data-entry-app/data-entry-app/backend/app/models/scenario.py:1)
|
||
- [backend/app/models/assumption.py](/abs/path/C:/Users/mattc/data-entry-app/data-entry-app/backend/app/models/assumption.py:1)
|
||
- [backend/app/models/client_access.py](/abs/path/C:/Users/mattc/data-entry-app/data-entry-app/backend/app/models/client_access.py:1)
|
||
- [backend/app/models/access.py](/abs/path/C:/Users/mattc/data-entry-app/data-entry-app/backend/app/models/access.py:1)
|
||
- [backend/app/db/migrations.py](/abs/path/C:/Users/mattc/data-entry-app/data-entry-app/backend/app/db/migrations.py:1)
|
||
- [backend/app/seed.py](/abs/path/C:/Users/mattc/data-entry-app/data-entry-app/backend/app/seed.py:1)
|