# Business rules — printafoil (Printafoil Portal)

Evidence date: 2026-10-06. Source: `master` at `058841e9`.

Rules below are taken from PHP, SQL inside `calculated_fields()`, and config flags. They are not a signed business policy. Where the code only shows a formula, the business intent is marked **Inferred** or left as **Unknown / Requires Confirmation**.

Policies that exist only as uploaded PDFs under `images/` (HR policy filenames, ISO procedures, a QMS manual) were not treated as system rules. The application stores those files. It does not execute them.

## Access

| ID | Rule | Provenance |
|---|---|---|
| BR-001 | A member can insert, view, edit, or delete rows in a table only when that flag is set on their membership group. A row in `membership_userpermissions` for that member replaces the group flags for that table | **Implemented** — `getTablePermissions()` |
| BR-002 | If none of insert, view, edit, or delete is set, the member has no access to the table | **Implemented** — same function, `access` flag |
| BR-003 | Visitors cannot create their own login. `defaultSignUp` is `0` | **Implemented** — `config.php` |
| BR-004 | Guests, if any, are the anonymous group named `anonymous` and the member id `guest` | **Implemented** as names. What they may open is **Unknown** (database) |
| BR-005 | There is a single super-admin concept. `MULTIPLE_SUPER_ADMINS` is false. The configured admin username is `admin` | **Implemented** |
| BR-006 | After login, only membership group `client_Charlotte_Rhyse` is sent to `finish_stock_client_view.php` with filters: client equal to `Charlotte Rhys`, two fields like `Yes`, and one field greater than `0` | **Implemented** — `login_ok()`. The filter field numbers are 4, 26, 25, and 15. Which columns those numbers are on the finished-stock screen was not mapped in this pass |
| BR-007 | Every other member lands on the normal homepage | **Implemented** — `login_ok()` returns an empty string |
| BR-008 | Homepage hides tables named exactly `Management` or `Reports`. No catalogue table uses those names, so this hide list currently matches nothing | **Implemented** code, no matching table |
| BR-009 | Record ownership (owner group and owner member) can be assigned and batch-transferred from the admin area | **Implemented** AppGini admin. Which records have owners is database state |

The spelling in the redirect group is `client_Charlotte_Rhyse`. The stock filter value is `Charlotte Rhys`. The empty menu group is `Charlotte Rhys`. These three strings are not the same. **Requires Confirmation** which one is the live client name.

## Purchase orders

| ID | Rule | Provenance |
|---|---|---|
| BR-010 | A PO line amount (`po_items.price`) is unit price times quantity, rounded with MySQL `ROUND(..., 0.00)`, which is 0 decimal places | **Implemented** |
| BR-011 | PO subtotal is the sum of unit price times quantity on the lines (`po_items.purchase_order_Nr` = PO id), rounded to 2 decimal places. If the sum is null, subtotal is 0.00 | **Implemented** |
| BR-012 | PO total is `Subtotal * (1 - Discount/100) * (1 + Tax/100)`, rounded to 2 decimal places. Discount and tax are percentages | **Implemented** |
| BR-013 | The PO "approved by" value is the `Person_Approving` on a `po_approval` row for that PO. The query does not order or limit the rows | **Implemented**. If more than one approval exists, which name is stored is **Unknown / Requires Confirmation** |
| BR-014 | A supplier can show an evaluation id and an approver, both calculated from `supplierevaluation` / `suppliedapprovedby` | **Implemented** as lookups. The pass/fail rule for a supplier is **Unknown** |

Line prices at 0 decimals and a header subtotal at 2 decimals can disagree by rounding. That follows from the two SQL expressions. Whether the business wants it is **Unknown / Requires Confirmation**.

## Finished stock

Quantities on `finish_stock` are calculated, not typed as the source of truth. Provenance: **Implemented** in `calculated_fields()`.

| ID | Rule |
|---|---|
| BR-015 | Finished, excess, and spoilage quantities (`calc_total_qty_finished`, `calc_total_qty_excess`, `calc_total_qty_spoilage`) each sum `production_finishing.fulltotalqty` |
| BR-016 | Adjustments (`calc_adjust_in_finished`, `calc_adjust_in_excess`, `calc_adjust_in_spoilage`) each sum `finish_stock_adjusting.qty_in - qty_out` |
| BR-017 | Dispatched quantity (`calc_dispatch_qty`) sums `dispatch_pick.pick_qty_all` |
| BR-018 | On-hand finished quantity is finished production minus dispatched quantity plus finished adjustments |
| BR-019 | Excess on hand is excess production plus excess adjustments. Spoilage on hand is spoilage production plus spoilage adjustments |
| BR-020 | `total_qty_new` is finished on hand plus excess on hand |
| BR-021 | Unit price comes from `fin_stock_costing.price`. Finished value and excess value multiply those quantities by that price. `total_value_all` adds the two values |
| BR-022 | Parallel "old system" columns sum `finish_stock_move` quantity in and out for finished and excess, then subtract out from in |

The SQL joins that decide which production or adjustment rows belong to which stock row are in `calculated_fields()`. They were not rewritten here. If a join is missing a predicate, totals could include the wrong rows. That was not verified row by row. **Requires Confirmation** against a known job.

`finish_stock_move.calc_stk_value` is the sum of `selling_price * (Qty_in - Qty_out)`.

## Materials

| ID | Rule | Provenance |
|---|---|---|
| BR-023 | `new_stk_item` and `stk_item` keep separate quantities for Printafoil, Peter Paper, Jak Paper, Gerber Paper, and Paper Smith. `Qty_Stock_Total` is calculated | **Implemented** field names |
| BR-024 | `new_stk_item` also keeps paid-in and paid-out quantities for those four merchants | **Implemented** |
| BR-025 | Laminate on `new_stk_item` has quantity, price per square metre, and rand value (`lam_total_qty`, `lam_pricepersqm`, `lam_totalvalue_rand`). `bp_totalvalue_rand` is a separate value field | **Implemented** names. The price formula is the SQL in `calculated_fields()` |

Merchant names in older movement tables use slightly different spellings (`stk_movement_l1` caption "Movement Peters Papers" versus field `Qty_Stock_PeterPaper`). **Inferred** that these are the same suppliers. **Requires Confirmation**.

## Consumables

| ID | Rule | Provenance |
|---|---|---|
| BR-026 | Available quantity at finishing is the sum of `consumdetails.Amount` | **Implemented** |
| BR-027 | Available quantity at the main building is the sum of `consumdetailsmainbld.Amount` | **Implemented** |
| BR-028 | Available quantity at 9 Glosderry is the sum of `consumdetailsno12.Amount` (caption: "Consumable at 9 Glosderry") | **Implemented** |
| BR-029 | `TotalAvailableALL` is the sum of those three totals | **Implemented** |
| BR-030 | `ReorderNotice` is the sum of `TotalAvailableALL - MinOrderQty + MinOrderQtyMainBld + MinOrderQtyNo12` | **Implemented** SQL. **Unknown / Requires Confirmation** that this expression is the reorder rule the business wants |

## Production, plates, dispatch, quality

| ID | Rule | Provenance |
|---|---|---|
| BR-031 | `production_finishing.fulltotalqty` (and `totalqty`, `totalqty_1`) are calculated | **Implemented** |
| BR-032 | `printingpflight` calculates finished, excess, spoilage, a production sum, and `qty_wip_percentage` | **Implemented** names |
| BR-033 | Plate counters (`QtyNEW`, `QtyDTPerror`, `QtyAdjArt`, `QtyRemadePress`, `QtyDamaged`, `QtyNoStanding`, `QtyTotalMade`) each sum `platesimaged.QtyMade` | **Implemented**. Which status filter each counter uses is in the SQL |
| BR-034 | `dispatch_pick.pick_qty_all` and `Qty_totalwithovers` are calculated. Pick-line fields include loose quantities, pallet totals, and box counts | **Implemented** names |
| BR-035 | `dispatch_sys` calculates total quantity, picked quantity, `btftotalqty`, and joined delivery numbers | **Implemented** names |
| BR-036 | A non-conformance carton percentage is `(ctn_spoil_qty / ctn_ordered_qty) * 100` | **Implemented**. Behaviour when ordered quantity is 0 is **Unknown** |
| BR-037 | Colour-standard approval status is read from `colourstdapproval.status` | **Implemented** |
| BR-038 | The WIP board stores, per finishing operation, queue / in / out / waste / complete / on-machine style counters. Operation prefixes in the column names match foiling, foil-and-emboss, embossing, a `ccem` step, CNC, stripping, gluing, sorting, handwork, covering, staying, windowing, store 9, and main building | **Implemented** columns. The words for `mb`, `fd`, `ccem`, and `store9` are **Inferred** from other captions (main building, finishing, 9 Glosderry). What `ccem` stands for is **Unknown / Requires Confirmation** |
| BR-039 | Sales order confirmation calculates ordered amount, cartons delivered, ordered-versus-delivery quantity, and `PercentageOU` | **Implemented** names. What OU means is **Unknown / Requires Confirmation** |

## Mail, dates, and session

| ID | Rule | Provenance |
|---|---|---|
| BR-040 | Dates are shown as day/month/year (`j/n/Y`, MySQL `%d/%m/%Y`) | **Implemented** |
| BR-041 | The business timezone in the portal is `Africa/Johannesburg` | **Implemented** |
| BR-042 | Membership mail uses SMTP. The approval message tells the member they can log in at `https://printafoil.bluolive.co.za` and is signed Manny. Sender address is `manny@printafoil.co.za`. Sender display name is "Membership management" | **Implemented** template. Whether that mailbox is still used is **Unknown** |
| BR-043 | New-member notification to the admin is off (`notifyAdminNewMembers` is `0`), which is consistent with sign-up being off | **Implemented** |
| BR-044 | Child record grids show 20 rows per page | **Implemented** — `child_records_config()` |
| BR-045 | A table view may request up to 2,000 rows per page, 20 filters, and a print of 100 rows | **Implemented** — `definitions.php` |

## Security-related behaviour (not proposed as desired rules)

| ID | What the code does | Provenance |
|---|---|---|
| BR-046 | A failed login inserts the attempted password in clear text into `log_failed_logins` | **Implemented** — `login_failed()`. Human review: this should be treated as a defect |
| BR-047 | Database passwords, the admin password hash, and the SMTP password are stored in `config.php` and `b2b/wp-config.php`, and a backup config file `config.bak.php` is also in git | **Implemented**. Values are intentionally not copied into this document. Human review: rotate them and stop committing them |
| BR-048 | The SQL dump begins with `DROP DATABASE IF EXISTS` for the databases it restores | **Implemented** in the dump file. Restoring it is destructive. It is evidence, not a runtime rule of the portal |

## Rules that are not in the software

No code was found that:

- enforces a credit limit, a quote validity date, or a minimum order value
- chooses a payment gateway for the portal
- syncs a WooCommerce order into `sales_enquiry_order` or `b2b_order_sys`
- applies FSC or ISO approval as a gate on dispatch
- calculates VAT at a named South African rate (tax is whatever percentage is stored on the PO)

If those are business rules, they are outside this repository, or they live only in staff practice and in the PDF procedures under `images/`. **Unknown / Requires Confirmation**.
