# PRD — snell-freestock (Freestock Requisition)

Evidence date: 2026-10-02. Source: `git@bitbucket.org:overdrive/snell-freestock.git`, `master` at `477f559` (2026-09-18).

There is no prior PRD in the repository. Silicon Ark has a separate set for `snell-freestock-5.6`, which is a different remote. Every requirement below was recovered from this tree's PHP, SQL strings, and `web.config`. Provenance is marked on each item.

## Product purpose

Snell staff raise and authorise freestock requisitions from a browser. A requisition names a region, a bill-to account, a ship-to account, a request type, whether the cost is Marketing or Sales, and one or more products with case and bottle quantities. A manager or executive manager approves or declines it. The screen title is "Freestock Requisition".

Provenance: **Implemented**. A written product statement was not found.

## Users and roles

Roles are rows, not code enums. Group names live in `mstUserGroupFreeStock.strUserGroup` and were not in the repository. Only group id **6** is named in a comment, as the Executive security group.

| Role | What they do | Evidence | Provenance |
|---|---|---|---|
| Windows-authenticated staff user | IIS supplies `LOGON_USER`. The app loads `mstUser` where `strDomainLogin` matches and `blnActive = 1` | `LogginUser` | Implemented |
| Requester | `fsrRequisition.refUserID` equals the session user. Can edit a Draft | `requisitionForm.php` permission switch | Implemented |
| Manager or alternate manager | Session user id equals `refManagerID` or `refAlternateManagerID`. Can edit a Pending requisition when the item total is under R10 000 | Same switch; `$SALESMANAGERMAXVALUE = 10000` | Implemented |
| Executive manager on the requisition | Session user id equals `refExecutiveManagerID`. Can edit a Pending requisition at or above R10 000. The same case also sets the manager and requester flags | Same switch | Implemented |
| Special user (`blnSpecial = 1`) | Menu shows Users. Comment says "Dev\|\|Admin". Bypasses the status lock except when status is Approved. Group 6 inside this branch also locks Completed, Declined, and Cancelled | `layout.menu.inc.php`, `requisitionForm.php` | Implemented. Which table owns `blnSpecial` is **Unknown** because the login query is `SELECT *` |
| User group 6 | Comment: Executive security group, distinct from `blnManagerExecutive` | `requisitionForm.php` | Implemented. The group name in the database was not verified |
| Caller of `ajax.functions.php` or `test.php` | Those scripts do not check `$_SESSION[USER]` | `ajax.functions.php`; `test.php` uses the login layout | Implemented |

Login copy says "Login is controlled through the Active Directory." The code does not call adLDAP. It matches `LOGON_USER` to `mstUser.strDomainLogin`. Provenance of the AD sentence: **Documented on the login page**. Provenance of the lookup: **Implemented**. Whether IIS authentication is the whole AD integration is **Requires Confirmation**.

On host name `sql-server`, `system.inc.php` sets `LOGON_USER` to `silicon\pj` before login. Provenance: **Implemented** dev override.

`LogginUser` will replace the supplied name when `$OVERRIDE_USERNAME` is not null. The assignment `$OVERRIDE_USERNAME = 'AzureAD\UrielBalela'` is commented out, marked `notforprod`. Provenance: **Dormant** on this revision.

## Main workflows

### W1 — Sign in

Trigger: `index.php` redirects to `login.php`. `login.php` destroys any existing session, then includes the system bootstrap.

Inputs: `$_SERVER["LOGON_USER"]`. There is no username or password field. The Login button posts `Action=Login`. `Action` is also set to `Login` whenever `LOGON_USER` is not null, before the button is used.

Behaviour:

1. If the user-agent matches Internet Explorer 6, 7, 8, 9, 10, or 11 (MSIE or Trident), `Action` is cleared and a red message tells the user to install Firefox or Chrome.
2. Otherwise `LogginUser` runs.
3. Lookup: active user joined to `mstUserGroupFreeStock` on `strDomainLogin`.
4. On success, `$_SESSION["USER"]` is that row, the script sleeps one second, a row is inserted into `mstUsageLog` (`strSystem = 'FreeStock'`), and the browser goes to `requisition.php`.
5. On failure the message is "Insufficient Access for {LOGON_USER}" plus the Active Directory sentence.

Output: session, or the login message.

`checkLogin()` compares `$_SESSION['IP']` to `REMOTE_ADDR` and redirects to login. Nothing in the tree calls it. Provenance: **Dormant**.

A request to `requisitionForm.php` with any `Link` query parameter also calls `LogginUser` before showing the form. The parameter value is not read. Comment: "logs in via email". Provenance: **Implemented**. The email template that would add `Link` is not in this repository.

### W2 — Authorisation list

Trigger: `requisition.php` after a session exists. The page action switch has no live cases. The default builds `Requisition::getFilters()` and `getList()`.

First visit in the session, when filters are unset: status **Pending**, manager **-1** (All). Start date becomes the first of the current month and end date becomes today when those filter values are empty.

Filters (posted as `Action=Filter`, stored on `$_SESSION[PAGES]->Page[script]->Filters` for keys starting `fr`):

- Requested by, region, manager (matches manager, alternate, or executive id), status, bill-to, ship-to, free-text search, start date, end date, ignore-dates checkbox.
- Search matches requisition id, request type, contact number, detail, bill-to, ship-to, or contact person, using `LIKE`.
- `Action=Clear` drops that page's filters. The Clear button also resets the date inputs in the browser to month-start and today.

The grid shows a row number, pink-slip number (link to the form), requester, request type, date, region, bill-to, ship-to, status, authorised by, rounded total, and last edit. Each row rounds its own total. The footer adds the unrounded totals and then rounds that sum. Sort links toggle ASC/DESC per column. Default order is date descending, then requester, request type, status, bill-to, ship-to.

Missing customer names display as `*MISSING*`.

The list does not restrict rows to the current user's region or to requisitions they manage. Provenance: **Implemented**. Whether every signed-in user is meant to see every requisition is **Requires Confirmation**.

### W3 — Create and save a requisition

Trigger: menu "New Requisition" (`requisitionForm.php?RequisitionID=0`) or Save on the form.

Inputs required by `validateForm()` before post: request type, M or S expense, region, bill-to, ship-to, detail of request. Contact name and contact number are collected. Their checks are commented out (2015-11-23).

New-form defaults before the first save: requester is the session user, status label **New**, date today, region from the user, delivery selected, product table hidden (`display:none`).

The requisition id is resolved by `Requisition::PostedRequisitionID`. A positive integer in `$_POST["RequisitionID"]` wins, then `$_GET`, then the function argument. Comment dated 2026-09-18: a POST clears `$_GET`, and a GET-only read used to fall back to 0 and insert a new empty header.

Save of id 0 (`Requisition::Save`):

1. Status is forced to **Draft**. Manager ids, pink-slip number, and authorised-by are not taken from the post.
2. The header insert rewrites today's date literal to `CONVERT(datetime, 'Ymd', 120)`.
3. If the insert fails, or no new id comes back, the user sees a red message and `$blnSaveOK` is false.
4. Pink-slip number is set to `ES` plus the id, zero-padded to 7 digits, only when the current number is null or blank (`AssignPinkSlipNumber`). A failed assignment is reported and `$blnSaveOK` is false. An existing number is not changed.
5. `UpdateManagers` copies the requester's manager, alternate manager, and that manager's executive onto the requisition. If the manager id is 0, the message includes a red warning to escalate to an administrator.
6. Line items are not written on this insert path. The product table is hidden until a header row exists, so the first save creates the header only.

Save of an existing id:

1. `dtDate` and `strPinkSlipNumber` are not updated from the post. A missing pink-slip number is assigned with the same `ES` rule.
2. Header update goes through `sqlObject` and the ODBC wrapper. A failed update sets `$blnSaveOK` false and does not continue into a status change.
3. For each posted `strProdCode` that is not empty: the row key must be a whole number; brand must be posted for that key; the product must exist on `dwdProduct`. Costs are read from `dwdProduct` (`dblPriceCase`, `dblPriceBottle`), not from the posted cost cells. Total is `round((cases * case price) + (bottles * bottle price), 2)`.
4. A negative item id inserts `fsrItem`. A non-negative id updates that item only when `refRequisitionID` matches. New lines in the browser start at `-1` and step downward (`jsAddItem`).
5. A bad key, unknown product, missing brand, or failed item statement sets `$blnSaveOK` false. The loop continues. The return text says some details could not be saved.
6. Managers are refreshed after the item loop.

Output: green-path text "New Requisition saved." or "Requisition details saved.", plus any manager warning.

### W4 — Submit, approve, decline, cancel

Trigger: the matching submit button. Each path calls `Save` first. If `$blnSaveOK` is false, status is not changed and a red sentence says the action did not happen because the details could not be saved.

| Action | Extra rule | Status write | Queue |
|---|---|---|---|
| Submit | `ItemCount` must be greater than 0. Otherwise the requisition stays as it was and the message tells the user to add a product, Save, then Submit | `Pending` | Deletes then inserts `sysCronJob` type `FSR_NewRequisition`, status `New`, param `RequisitionID={id}` |
| Approve | Same item-count rule. Message tells the requester to add products and submit again | `Approved`, `strAuthorizedBy` = session user name | Same pattern, type `FSR_RequisitionApproved` |
| Decline | No item-count check | `Declined`, `strAuthorizedBy` = `Declined [{name}]` | Existing `sysCronJob` rows for that param set to status `Removed` |
| Cancel Requisition | No item-count check. The button is in the HTML with `display: none` (comment 2015-02-08, hiding the cancel button) | `Cancelled`, `strAuthorizedBy` = `Cancelled [{name}]` | Same `Removed` update |

Button visibility for a user who is not `blnSpecial`:

- **Completed** or **Approved**: inputs locked. Comment on the Approved branch: "No more edits" (2023-11-02).
- **Declined** or **Cancelled**: inputs locked.
- **Pending**: inputs locked, then unlocked when the total is under R10 000 and the user is a manager on the requisition, or the total is at least R10 000 and the user is the executive on the requisition. The total used here is the summed `fsrItem.dblTotal` cast to int.
- **Draft** (and any other status in the default branch): Approve is disabled. Submit is disabled when status is not Draft. The requester can edit. Anyone else cannot.

`blnSpecial = 1` skips that status switch, including the R10 000 band and the Draft rule that disables Approve. Submit, Approve, and Decline stay enabled for a special user except where the shared edit lock applies. Group 6 still cannot edit Completed, Approved, Declined, or Cancelled. Every special user is locked when status is Approved. The cancel button stays hidden for everyone.

Nothing in this repository sets status to **Completed**. The word is only a lock. Provenance: **Implemented** as a permission case. Who sets Completed is **Unknown**.

### W5 — Line items on an existing requisition

Trigger: Add Item clones a hidden template row. Brand change calls `ajax.functions.php` type `ajaxReloadProductsXML`. Product change copies `dblPriceCase` and `dblPriceBottle` onto the row and resets quantities. The on-screen line total is rounded to a whole number. The server recalculates to two decimal places on save.

Remove is a link: `Action=RemoveItem&ItemID=`. `RemoveItem` deletes `fsrItem` by item id only. It does not check the requisition id or the session user's role. The link is hidden when the radio permission style is `display:none`.

Ship-to search and region changes reload account lists from the same AJAX endpoint. Bill-to options are active promotional customers in the region (`blnPromotionalAccounts = 1`), plus the requisition's current bill-to even if it would otherwise drop out. Ship-to options are active customers in the region, plus the current ship-to. A 2026-09-18 comment says bill-to and ship-to are no longer re-fetched on every page load, because a slow or failed call was emptying the lists.

If the XML is not a `<rows>` document, or the request errors, the script alerts and leaves the existing options in place.

### W6 — Sign out

Menu "Logout: {name}" opens `logout.php?Action=Logout`, which destroys the session. The page always shows "You have successfully logged out." Provenance: **Implemented**.

### W7 — User administration

Trigger: menu Users, shown only when `blnSpecial = 1`. `user.php` itself checks the session (via the full layout) and does not check `blnSpecial`. A signed-in user who knows the URL can open it. Provenance: **Implemented**. Whether that is intended is **Requires Confirmation**.

List: name, group, domain login, email, region, manager, alt manager, exec manager, last user, last edit, Active or InActive, and a select checkbox. Filter is Active, Inactive, or All. Default order is user name. Sort links toggle direction.

New and edit form fields: user group, user name, domain login, email, RepID (labelled optional), region, Is Exec Manager, Is Manager, manager, alt manager, exec manager, Is Active. Client-side `validateForm` requires group, name, domain login, email, and region. Disabled fields are skipped. There is no matching server-side required-field check in `User::Save`.

Save behaviour:

- Unchecked active, manager, and exec-manager posts are stored as 0.
- `strLastUser` becomes the session user's name.
- Create rejects a domain login that already equals an existing `mstUser.strDomainLogin`. The comparison does not run on update.
- After the insert or update, manager links are rewritten: a non-manager keeps the posted manager and alternate and clears executive; a manager points `refManagerID` at themselves, clears the alternate, and stores the posted executive; an executive manager also sets `blnManager = 1` and points manager and executive ids at themselves.

Make Inactive sets `blnActive = 0` for each checked id. It does not delete the row. The older list method `getList_old` still contains a Delete button and is not called.

## Functional requirements

### FR-001 — Windows logon gate

The system admits a person only when `LOGON_USER` matches `mstUser.strDomainLogin` and `blnActive = 1`, and the user has a freestock group row.

Actor: staff user. Trigger: login page with a non-null `LOGON_USER`, unless IE 6–11 blocked the action. Output: `$_SESSION["USER"]` and a `mstUsageLog` row, or the insufficient-access message.

Validation: inactive users fail the query. Error: the Active Directory message. No password is collected.

Provenance: **Implemented**. Evidence: `login.php`, `LogginUser`.

### FR-002 — Session required for the main pages

`requisition.php`, `requisitionForm.php`, and `user.php` render through `layout.full.inc.php`, which sends the browser to `login.php` when `$_SESSION[USER]` is unset.

Provenance: **Implemented**. The AJAX endpoint and `test.php` are outside this rule.

### FR-003 — List and filter requisitions

A signed-in user can filter and sort the requisition list as described in W2. Totals on the list are rounded to whole rands. The caption note says "Rounded Values".

Provenance: **Implemented**. Evidence: `include/requisition.cls.php` `getList`. Row cells use `round` on each total. The footer rounds the sum of the raw totals.

### FR-004 — Capture a requisition header and lines

A permitted user can save a header and, once the header exists, product lines. Required header fields are those in W3. Prices come from `dwdProduct` at save time.

Provenance: **Implemented**. Evidence: `requisitionForm.php`, `Requisition::Save`.

### FR-005 — Pink-slip number

A new requisition receives `ES` plus a 7-digit id. A later save does not replace a non-blank number. A blank number on an existing row is filled in.

Provenance: **Implemented**. Evidence: `AssignPinkSlipNumber`.

### FR-006 — Submit only with items

Submit leaves the requisition unsubmitted when `fsrItem` has no rows for that id. A successful submit sets **Pending** and queues `FSR_NewRequisition`.

Provenance: **Implemented** (comment 2026-09-18). The worker that sends or prints the pink slip is **Unknown**.

### FR-007 — Approve only with items, and only inside the value band

Approve refuses a requisition with no items. For everyone except `blnSpecial`, a Pending requisition is editable under R10 000 by its manager or alternate, and at or above R10 000 by its executive. A successful approve sets **Approved**, stores the approver's name, and queues `FSR_RequisitionApproved`.

The threshold is a PHP constant, not a database setting. Provenance: **Implemented**. Whether R10 000 is still the business threshold is **Requires Confirmation**.

### FR-008 — Decline

Decline sets **Declined**, prefixes the authoriser text with `Declined`, and marks matching cron rows **Removed**. It does not require line items.

Provenance: **Implemented**.

### FR-009 — Cancel control is hidden

The cancel button is not shown (`display: none`). The server action `Cancel Requisition` still exists.

Provenance: **Implemented** hide; the action is **Dormant** in the UI. Comment date 2015-02-08.

### FR-010 — No edit after approval for ordinary users

Approved and Completed requisitions disable the edit controls. The same lock applies to Declined and Cancelled. Special users are also locked on Approved. Group 6 special users are locked on Completed, Approved, Declined, and Cancelled.

Provenance: **Implemented**.

### FR-011 — Manager chain copied from the user record

On save, the requisition's manager, alternate, and executive are overwritten from the requester's `mstUser` links (executive comes from the manager's `refManagerExecutiveID`). The form shows mailto links built from `vieRequsitionManager`.

Provenance: **Implemented**. The view definition is not in the repository.

### FR-012 — User maintenance

A special user can list, filter by active flag, create, update, and mark users inactive, with the field rules in W7.

Provenance: **Implemented**. Evidence: `user.php`, `include/user.cls.php`. Comment on `system.inc.php`: user management added 2016-05-31.

### FR-013 — Usage log

Each successful login inserts `mstUsageLog` for system `FreeStock`.

Provenance: **Implemented**. How that log is read is **Unknown**.

## Integrations

| System | Direction | Trigger | Data | Auth | Failure |
|---|---|---|---|---|---|
| IIS / Windows logon | Inbound | Every login | `LOGON_USER` | IIS, not shown in PHP | Missing or unmatched user: insufficient-access message |
| SQL Server via ODBC | Both | Every page that includes `system.inc.php` | Users, requisitions, items, customers, products, cron rows | DSN `snell_fsr` with credentials in source | ODBC false results become red save messages on the 2026-09-18 paths. Older `sqlObject::doQuery` dies on failure. Connection failure behaviour is **Unknown** beyond that |
| `sysCronJob` consumer | Outbound from this app's point of view | Submit and approve | Type, param, status `New` | Database | Not in this repo. **Unknown** |
| Active Directory via adLDAP | Not called | `login.php` requires the file and comments out construction | None in the live path | n/a | n/a |
| Mail client | Outbound from the browser | Click a manager name | `mailto:` with subject `Freestock Requisition {pink slip}` | The user's mail client | No server send |

## Constraints

- Internet Explorer 6–11 cannot log in through `login.php`. Other pages do not repeat that check.
- Session garbage-collection max lifetime is set to 21600 seconds (6 hours) before `session_start`.
- Session files are stored under `DOCUMENT_ROOT\snell_freestock_PHP\Sessions` when the host is `sql-server` or `localhost`, otherwise `DOCUMENT_ROOT\snell_freestock\Sessions`.
- Bill-to accounts on the form are limited to promotional accounts in the region, plus the account already stored on the requisition.
- Request types in the dropdown are `blnActive = 1`, plus the type already stored.
- Brands and products in the dropdowns follow the same active-or-already-selected pattern.
- List and form money displays are rounded. Stored line totals use two decimal places.
- Contact name and contact number are not required. The 2015-11-23 comment cites a Snell demo.

## Behaviour that looks unintentional

These are observations, not approved requirements.

- `ajax.functions.php` and `test.php` do not require a session. `test.php` runs `SELECT COUNT(*) FROM sysQuery` and echoes encoding samples.
- `RemoveItem` deletes by item id with no requisition or role check.
- Many SQL fragments concatenate request values. `PostedRequisitionID` restricts the id used for save. List filters and several other statements do not.
- `user.php` is not limited to `blnSpecial` once the URL is known.
- PHP session files are committed under `Sessions/` although `.gitignore` lists that directory.
- Database credentials are hardcoded in `include/system.inc.php`.

## Unknown / requires confirmation

- Production URL, IIS site, and whether `477f559` is deployed.
- Relationship to repository `snell-freestock-5.6`.
- Group names for `mstUserGroupFreeStock`, including id 6.
- Which table column supplies `blnSpecial`.
- Process that sets status **Completed**, and the `sysCronJob` worker's actions (email, print, invoice).
- Whether every authenticated user should see every requisition.
- Whether the hidden cancel action should remain callable.
- PHP version on the server. Git history has commits titled as PHP 8 upgrades. No version file is in the repo.
- Whether the R10 000 constant is still the approved threshold.
