# PRD — kb-online (Kohler Box)

Evidence date: 2026-10-11.

Source: `git@bitbucket.org:overdrive/kb-online.git`, `main` at `5aa7141` (2026-09-17).

There is no prior PRD in the source repository or in Silicon Ark. Requirements below are recovered from the implementation. Provenance is labelled. Secrets from `.env` are not in the repository and are not reproduced.

## Product purpose

Staff at Kohler Box use this site to take a box job from a customer enquiry to a factory production order, and to keep the price of that box.

**Inferred** from the screens and documents the code generates. No requirements document states this purpose.

The public hostname implemented in code is `www.kb-online.co.za`.

## Users and roles

People sign in as rows in `sysUser`. The session stores user id, display name, email, and `refSecurityGroupID`. A login succeeds only when the security group is active (`sysSecurityGroup.blnActive = 1`) and the user is active (`sysUser.blnActive = 1`), and when `md5` of the typed password equals `strPasswordMD5`.

Menu items and page rights come from `sysMenuLevel1`, `sysMenuLevel2`, and `sysSecurity`. Each right is `blnView`, `blnDelete`, `blnSave`, `blnNew`, or `blnSpecial`. The menu labels themselves are database rows and are not in git.

| Actor | Evidence | Status |
|---|---|---|
| Signed-in staff member | `login.php` session. Every `Nemo` page requires `$_SESSION["USER"]` | **Implemented** |
| Security group 4, called SalesPerson in comments | `enquiry.php`, `pricing.estimate.php`. Order list defaults the status filter to `Order: New` | **Implemented**. The group name is a comment, not a seed row |
| Security groups 1 and 2 | May reprint an existing factory spreadsheet. A 2019 comment says "Only allow Admin Sys to reprint factory cards" | **Implemented**. Which id is "Admin" and which is "Sys" is **Unknown / Requires Confirmation** |
| Named Kohler Box mailboxes | `marsilee@`, `kayla@`, `andriah@`, `abigail@`, `gerrie@`, `accounts@kohlerbox.com` may reprint an existing factory spreadsheet even when they are not group 1 or 2 | **Implemented** allowlist in `_nemo.details.order.cls.php` |
| Security group 6 | Order list defaults the status filter to `Order: In Production` | **Implemented**. Role name is **Unknown / Requires Confirmation** |
| Security group 7 | Order list checks "To Be Invoiced" on first load | **Implemented**. Role name is **Unknown / Requires Confirmation** |
| Estimator | `vieEstimator`, selected on the order. Receives the "New Enquiry" email | **Implemented** as a data role. Whether estimators are a security group is **Unknown / Requires Confirmation** |
| Salesperson on the order | `vieSalesPerson`. Receives estimation-complete mail and is the Reply-To on pipeline emails | **Implemented** |
| Customer and contact | `tblCustomer`, `tblContact`. They receive quote and order-confirmation mail. No customer login was found | **Implemented** as records, not as user accounts |
| Deploy operator | Not represented. No pipeline, Docker, or CodeDeploy files | **Unknown** |

`includes/user.cls.php` still contains methods whose comments talk about course coordinators. Nothing in `user.php` calls them. Provenance: leftover code. Not a current user role.

## Systems the product talks to

1. The staff browser.
2. MySQL, through `mysqli`, using `DB_HOST`, `DB_USER`, `DB_PASS`, and `DB_NAME` from `.env`.
3. PHP `mail()`, with From and BCC taken from settings `SMTP Send As` and `SMTP BCC`.
4. The Google Ajax CDN for jQuery 1.8.2.
5. The filesystem paths in settings for quotes, orders, signed estimates, and supporting documents.
6. `http://www.mymobileapi.com/api5/http5.aspx` only if `SendSMS` is called. No caller exists in this tree.
7. Mailchimp only as a CSV the user downloads. No API call.
8. Pastel only as `strPastelAccountCode` stored on the customer. No API call.

## Information the product manages

One commercial job is one `tblOrder` row. The status string decides which screen lists it:

| View | Filter in the comment that defines the view | Screen |
|---|---|---|
| `vieEnquiry` | `strStatus` like `Enquiry:%` and not archived | `enquiry.php`, and the list inside `pricing.estimate.php` |
| `vieEstimation` | `strStatus` like `Estimation:%` and not archived | `estimation.php` |
| `vieQuote` | `strStatus` like `Quote:%` and not archived | `quote.php` |
| `vieOrder` | `strStatus` like `Order:%` and not archived | `order.php` |

The full set of status strings is the setting `ORDER_STATUS`, split on commas. It is not hardcoded. Status strings that the PHP itself writes or compares:

- `Enquiry: Estimate for Pricing` (default on the pricing-estimate screen)
- `Estimation: New`, `Estimation: In Progress`
- `Quote: New`, `Quote: In Progress`, `Quote: Sent to Customer`
- `Order: New`, `Order: In Production`, `Order: Completed`, `Order: XNew` (the last two appear in report SQL)

Saving a row whose status is `Estimation: New` or `Quote: New` rewrites it to `Estimation: In Progress` or `Quote: In Progress`.

Related records: customer, contact, product, board, style, descriptor, product price, quote-to-price links, delivery lines, sales rep, salesperson, estimator, signed-estimate files, supporting documents, email templates, sent mail, spreadsheet import rows (`impSheet`), users, security, settings, login attempts.

## Major workflows

### WF-1 — Sign in

`login.php` shows Username and Password. Action `Login`:

1. Load `sysUser` joined to an active security group, matched on email.
2. If no row: log "Login failed: User not found or SG not active". Message: "Login Details are invalid."
3. If `blnActive` is not 1: log "Login failed: Inactive User". Message: "User account is inactive."
4. If `strPasswordMD5` equals `md5` of the typed password: store the session, log "Login successful", redirect to `index.php`.
5. Otherwise: log "Login failed: Incorrect password", message "Username or Password is incorrect.", then `CheckSecurityLock`.

`CheckSecurityLock` counts the last five login rows that are either a bad password or a success. Five failures without a newer success send a warning email and tell the user the account is suspended. The line that would set `blnLogin = 0` is followed by a commented-out `Save()`. `login.php` never reads `blnLogin`. The suspension message is shown. The account is not persisted as locked. Provenance: **Implemented**.

Action `Redirect` looks up a user by email and `strPasswordMD5` equal to `$strPasswordMD5`. That variable is not set in this file. Provenance: **Implemented** branch whose input is **Unknown**.

After five failed page-access attempts in the session, `Nemo` sends the user to `message.php?MID=e10`. Fewer failures go to `MID=r09` ("You do not have sufficient access to view this page").

`index.php` shows `$SystemSettings["Home Screen Message"]`. A daily-diary block on that page is commented out.

Provenance: **Implemented**.

### WF-2 — Enquiry to estimator

On-screen instructions in `OrderDetails` tell the user to pick or add a customer, pick or add a contact, pick a product or `*New`, add quantity variations on the price matrix, choose an estimator, and send the enquiry. The instructions say the enquiry will not be editable after that step. No later PHP check was found that blocks edits because the status left `Enquiry`. That sentence is **Implemented** as help text. Enforcement is **Unknown / Requires Confirmation**.

`Order::SendEnquiryToEstimator` loads the enquiry, sends the template "New Enquiry" to the estimator, CCs an unset variable, BCCs `SMTP BCC`, and sets Reply-To to the salesperson email. From is `SMTP Send As`. The link in the body points at `estimation.php?Action=Edit&OrderID=`. On success the status becomes `Estimation: New`.

A salesperson (group 4) opening a new enquiry gets extra behaviour in `enquiry.php`. The comment labels that branch "if SalesPerson".

### WF-3 — Estimation back to the salesperson

Instructions: if the product is still `*New`, capture it; check unit prices; send the estimation back to the salesperson.

`Order::SendEstimationToSalesPerson` uses template "Estimation completed", mails the salesperson, CCs the estimator, Reply-To the salesperson, and on success sets status `Quote: New`.

While the status is `Estimation: New`, the next save changes it to `Estimation: In Progress`.

`blnSpecial` on `estimation.php` controls whether the price-matrix unit prices are hidden and read-only. Provenance: **Implemented**.

### WF-4 — Pricing estimate PDF

`pricing.estimate.php` lists `vieEnquiry` and defaults status to `Enquiry: Estimate for Pricing`.

Actions:

- `Generate Kohler Box Estimate PDF` calls `Order::GenerateKohlerBoxEstimatePDF`.
- `Email Estimator` and `Email Yourself` call `Order::EmailEstimate`. The mail template is "KB Estimate". The PDF must already exist under `documents/Estimates/`. Reply-To is the salesperson.
- `Convert To Quote` sets status `Quote: New` and redirects to `quote.php?Action=Edit`.

Changing a checked price row while saving from `quote.php` regenerates that PDF and tells the user the estimate file was recreated.

`includes/pricing.estimate.cls.php` is a second copy of `class Order`. No page includes it. It is not on the request path.

### WF-5 — Quote to the customer, then to an order

Instructions: finalise the quote, optionally create the PDF, send it, and convert to an order after the customer accepts. Amend quantity or cost before converting, and resend if needed.

Quote actions rendered on the status field:

- `Create Quote` (`Order::GenerateQuote`).
- View Quote PDF when `dir_quotes` plus `{order number}.quote.pdf` exists.
- `Send Quote`. Confirms in the browser. `Order::SendQuote` uses template "Quote", mails the contact, CCs the salesperson and the customer email, attaches the quote PDF, Reply-To the salesperson. On success status becomes `Quote: Sent to Customer`.
- `Convert to Order`.

While status is `Quote: New`, the next save changes it to `Quote: In Progress`.

`quote.php` action `Save` calls `Order::Save($OrderID, "Order: New")`, which forces the saved status to `Order: New`. Provenance: **Implemented**. Whether every quote save is meant to become an order is **Requires Confirmation**. The case label is `Save`, and the status override is unconditional in that case.

### WF-6 — Production order and confirmation

Instructions for an order:

1. Select the quantity variation on the price matrix. After save, a green tick means a database match.
2. Complete the remaining order fields. Order date is required. Empty due date is stored as SQL `NULL`.
3. Set up the delivery schedule.
4. Create the production-order spreadsheet.
5. Marking a delivery complete also marks it to be invoiced. If the invoice number is empty, the order appears when the "To Be Invoiced" filter is on.

`Create Production Order` calls `Order::GenerateOrder`. The workbook has a sheet "Inspection Tracking Form" and a sheet "Kohlerbox Production Order". It is written to `documents/Orders/{order number}.order.xls` with the PhpSpreadsheet Xlsx writer. The file extension and the writer do not match. Provenance: **Implemented**.

If that file already exists, the create button and the view link are hidden unless the user is security group 1 or 2, or their email is on the allowlist (including `kayla@kohlerbox.com` as of `5aa7141`). A 2019 comment says reprint was limited to Admin Sys. The allowlist is the current rule.

Opening the spreadsheet link calls `ajaxfunctions.php` `jsUpdatePrintedOrder`. A 2019 comment says `blnOrderPrinted` must change only when the order is viewed, and `Order::Save` copies the previous flag back so a normal save does not clear it.

`Confirm Order` calls `Order::ConfirmOrder`. It sends template "Order Confirmation" to the contact, CC the customer email, Reply-To the salesperson. Substitutions include contact, customer, customer reference, PO number, unit cost, quantity, internal dimension, descriptor, and style. Since `6a901e3` (2026-06-05) this method does not set status back to `Order: New`. The previous update is commented out.

### WF-7 — Deliveries and invoicing

Delivery rows are saved with the order by `Order::SaveOrderDelivery` when the order id is already non-zero. The browser script `jsDeliveryComplete` checks the invoice flag when the completed flag is checked, and enables delivery date, units delivered, and invoice number.

The order-list filter "To Be Invoiced" keeps rows where the delivery date is not null or not `0000-00-00`, and the invoice number is null or empty. The SQL uses `OR` between the two date tests, so a null date still matches the second comparison when the invoice number is empty. Provenance: **Implemented**. Whether that `OR` is intentional is **Requires Confirmation**.

Group 7 has this filter checked when the action is not `Filter`.

### WF-8 — Customers, products, and prices

Customer list filters by search text and industry. Export can download an Excel file or a Mailchimp CSV (`customer.php` actions `Export` and `Export Mailchimp CSV`).

A customer can be created from the enquiry UI through `jsSaveNewCustomer`, including Pastel account code, price-increase contact, agent flag, personalised price-list flag, no-overs, and no-unders.

Product list filters by customer, board, style, and dimensions. Height, width, and length match the value or a band of 2 percent either side.

`jsGetCalculatedProductInfo` builds the internal dimension as length x width x height. When the style's `blnManualCalculations` is not 1, crease panel and flap specs from `tblStyle` have `L`, `W`, and `H` replaced and each segment is `eval`'d to a rounded number. Sheet length is the sum of panel segments. Sheet width is the sum of flap segments, except when the stored panel spec is `L` and the flap spec is `H`, in which case sheet width is the plain width. A second pair of crease specs is calculated in the same function.

`updateProductPriceCheck` sets `tblProductPrice.dblPriceCheck` to `round(tblBoard.dblCost * tblProduct.dblWeight, 4)` when it differs.

`product.price.increase.php` uploads an annual increase, confirms it, processes the queue, and can send a test or live "General Price Increase" email. It also uploads personalised price-list detail and sends "Personalised Price Increase". `customer.price.list.php` sends the personalised template to selected customers or to a typed address.

### WF-9 — Spreadsheet import

`data.import.php` pages through an import sheet and supports Previous, Next, Save, Process, Delete, Add Project, Delete Project, and Run Process Approved. List statuses handled in the page include Old, New, Edited, Approved, and Processed. Rows live in `impSheet`. The status enum is read from the database column.

### WF-10 — Reports

`report.php` offers:

- Daily Diary (`includes/daily.diary.rpt.php`), filtered by order date unless "all dates" is checked. KB number links to `order.php`.
- Outstanding Production Orders, statuses `Order: New` and `Order: In Production`, quantity not zero.
- Dormant Customers, default interval 6 weeks, or 3, 6, or 12 months.

Commented out, not offered: Outstanding Orders, Orders Due Today, an IIF export.

Export renders a landscape PDF. The filename includes the date. Commit `5f3b4a6` added export date and time.

### WF-11 — Profile, users, and mail administration

`own.profile.php` edits the signed-in `sysUser` row. Display name and email are read-only. Password change asks for the old password.

`user.php`, `security.group.php`, and `settings.php` are Nemo detail screens on their tables. What each group may do is data in `sysSecurity`, not code.

`email.template.php` maintains templates. `email.php` can save, send, and resend a message. `NemoEmail` stores outbound mail when `blnInsert` is left on.

`my.order.php` lists orders and opens the matching pipeline screen on Edit. New redirects to `enquiry.php?Action=New`. It is not a separate customer portal.

`archive.php` lists archived orders. Delete on pipeline lists sets `blnArchived = 1`. It does not remove the row. The views used by the live lists exclude archived rows.

## Functional requirements

### FR-001 — Staff authentication

The system accepts an email and password, checks an active user in an active security group, compares an MD5 digest, and opens a session.

- Trigger: `Login` on `login.php`.
- Output: redirect to `index.php`, or a warning message.
- Evidence: `login.php`, `tblLoginInsert`.
- Implementation: **Implemented**.
- Confidence: High.

### FR-002 — Page authorisation

A signed-in user can open a script only when their security group has `blnView` for that URL in `sysSecurity`.

- Evidence: `Nemo::iniSecurity`.
- Implementation: **Implemented**.
- Confidence: High.
- Unknown: the menu labels and the matrix of groups.

### FR-003 — One job, four stages

The system stores enquiry, estimation, quote, and production order as one `tblOrder` row distinguished by `strStatus`.

- Evidence: view comments in `includes/order.cls.php`, `OrderDetails`.
- Implementation: **Implemented**.
- Confidence: High.

### FR-004 — Order number

On first save, if `strOrderNumber` is empty, the system sets it to the current `ym` plus the last six digits of the order id padded to eight digits.

A comment states a different rule: `date("ym")` plus the order id padded to six digits, example `1406000001`.

- Evidence: `Order::Save` and the comment marked `BR:` in `includes/order.cls.php`.
- Implementation: the eight-digit pad is **Implemented**. The comment is an older statement.
- Confidence: High on the code path. **Requires Confirmation** if production still uses the comment's formula from before this snapshot.

### FR-005 — Pipeline hand-off mail

Sending to the estimator, back to the salesperson, to the customer, and confirming the order each send a named template and, on success, update status as described in WF-2, WF-3, WF-5, and WF-6.

Reply-To is the salesperson address when that address is present. From is `SMTP Send As`.

- Evidence: `Order::SendEnquiryToEstimator`, `SendEstimationToSalesPerson`, `SendQuote`, `ConfirmOrder`, `EmailEstimate`. Commit `75ca321`.
- Implementation: **Implemented**.
- Confidence: High.

### FR-006 — Estimate and factory documents

The system can build a Kohler Box estimate PDF and a two-sheet production workbook, and can store signed estimates and supporting documents against the order.

- Evidence: `Order::GenerateKohlerBoxEstimatePDF`, `Order::GenerateOrder`, `impSignedEstimates`, `impSupportingDocuments`.
- Implementation: **Implemented**.
- Confidence: High.

### FR-007 — Factory-card reprint limit

Once the production spreadsheet exists, only groups 1 and 2, or the hardcoded mailbox list, see the control to generate it again.

- Evidence: `_nemo.details.order.cls.php`. Commit `5aa7141`.
- Implementation: **Implemented**.
- Confidence: High.
- **Requires Confirmation:** whether this allowlist is still approved.

### FR-008 — Confirm order keeps status

Confirming an order sends the confirmation email and does not overwrite `strStatus` with `Order: New`.

- Evidence: `Order::ConfirmOrder`, comment dated 2026-06-05, commit `6a901e3`.
- Implementation: **Implemented**.
- Confidence: High.

### FR-009 — Price maintenance

The system recalculates a price-check figure from board cost and product weight, and can upload and email annual and personalised price increases.

- Evidence: `project.functions.inc.php`, `product.price.increase.php`, `customer.price.list.php`.
- Implementation: **Implemented**.
- Confidence: High on the code. Template body text is in the database except where a SQL seed exists.

### FR-010 — Reports

A user who can open `report.php` can run Daily Diary, Outstanding Production Orders, and Dormant Customers, and export the result as a landscape PDF.

- Evidence: `report.php`.
- Implementation: **Implemented**.
- Confidence: High.

## Integrations

| System | Purpose | Trigger | Direction | Auth | Failure |
|---|---|---|---|---|---|
| MySQL | All persistent data | Every page via `system.php` | Read and write | Username and password from `.env` | Connection error is printed with `mysqli_connect_error` |
| PHP `mail()` | Pipeline, price-increase, lockout, and composed mail | Send actions | Outbound | Server `mail()` configuration. From header is a setting | Methods return an error string or a `Message` object. Exact `mail()` false handling varies by method |
| mPDF | Estimate PDF | Generate Kohler Box Estimate PDF | Local file | None | **Unknown** beyond PHP errors. `display_errors` is on |
| PhpSpreadsheet | Production workbook and some exports | Create Production Order, list export | Local file or download | None | PHP errors |
| `barcode.php` | Code 128, 39, 25, or Codabar PNG | Included by the order class. Query `text` | Image response | None. No session check in `barcode.php` | GD failure is **Unknown** |
| MyMobileAPI | SMS | No caller in this tree | Outbound HTTP POST | Settings `smsUsername`, `smsPassword`, `smsLive` | Function returns a formatted XML string or dies |
| Mailchimp | Customer list export | Export Mailchimp CSV | File download | None | Not an API |
| Pastel | Account code on the customer | Customer save | Stored string | None | Not an API |
| jQuery CDN | Date picker and page script | Every Nemo layout | Browser GET | None | Pages depend on that host |

## Constraints

- Configuration that changes behaviour without a deploy lives in `sysSettings` and in security tables. The repository cannot show the production values.
- Passwords are MD5. Plain `strPassword` is nulled by `database/passwordCleanup.sql`. Whether that script has been applied is **Unknown / Requires Confirmation**.
- `system.php` turns on `display_errors` for all errors except notice and warning.
- `info.php` is `phpinfo()` with no session check.
- There is no test suite and no CI configuration in the tree.
- Two-factor files exist. The login page does not call them after the revert commit.

## Discrepancies

1. `database/emailtemplate.sql` inserts a template named "One-Time Password" whose subject is `PRSA: One-Time Password (OTP)` and whose body signs off as "Pernod Ricard SA Supplier Portal". The application host and documents say Kohler Box. Provenance of the PRSA wording: **Implemented** in that seed file. It does not match the rest of this product. **Requires Confirmation.**
2. The SQL file is named `tblMailAuthCodes.sql` and creates `tblEmailAuthCodes`.
3. `login2fa.php` treats `dtCreatedAt` as an expiry (`dtCreatedAt > now`). `Security::saveOTPHistory` writes the expiry time into `dtCreatedAt`. The login page does not enter this flow.
4. Quote save forces `Order: New` (see WF-5). The on-screen quote instructions describe a separate "convert to order" step.
5. Order-number comment and `Order::Save` disagree on padding.
6. `includes/pricing.estimate.cls.php` and `includes/QAcustomer.cls.php` redeclare `Order` and `Customer`. The live pages include `includes/order.cls.php` and `includes/customer.cls.php` instead.
7. Lockout tells the user the account is suspended and does not save that suspension.
