# Business rules — snell-freestock (Freestock Requisition)

Evidence date: 2026-10-02. Source: `master` at `477f559` (2026-09-18).

No business-rules document exists in the repository. Rules below are taken from PHP conditions, SQL predicates, and comments. Each rule is marked **Implemented** or **Inferred**. A comment that states intent is quoted as a comment, not as a signed-off policy.

Thresholds and status names are whatever the code enforces. Whether the business still wants them is **Requires Confirmation** where noted.

## Identity and access

### BR-001 — Active directory login text, database match

**Implemented.** The login page says access is controlled through Active Directory. The code admits `LOGON_USER` only when it equals `mstUser.strDomainLogin` for a row with `blnActive = 1` that joins to `mstUserGroupFreeStock`.

Evidence: `login.php`, `LogginUser`.

**Requires Confirmation:** whether IIS Windows authentication is the only directory check operators rely on. adLDAP is not called.

### BR-002 — Inactive users cannot sign in

**Implemented.** `blnActive = 1` is part of the login query (commit `552bae2`, 2025-06-03). Make Inactive sets `blnActive = 0` and leaves the row in `mstUser`.

Evidence: `LogginUser`, `User::Delete`.

### BR-003 — Internet Explorer 6 through 11 may not log in

**Implemented.** Those versions clear `Action` on `login.php` and show an instruction to install Firefox or Chrome. The check is not repeated on other pages.

Evidence: `login.php`, comment "Yusiry, 20151013".

### BR-004 — Authenticated pages require a session user

**Implemented.** `layout.full.inc.php` redirects to `login.php` when `$_SESSION[USER]` is unset. That layout wraps the requisition list, the requisition form, and user administration.

### BR-005 — AJAX and the test page do not apply BR-004

**Implemented.** `ajax.functions.php` answers XML for account and product lists without reading the session. `test.php` uses the login layout.

**Inferred:** this is a gap rather than a product rule. **Requires Confirmation** before treating it as accepted behaviour.

### BR-006 — Users menu is for special users

**Implemented.** The menu link Users is rendered only when `blnSpecial = 1`. The comment says "Dev||Admin". `user.php` does not repeat that check.

**Requires Confirmation:** whether a non-special user who opens `user.php` directly should be refused.

### BR-007 — Dev host impersonates one Windows user

**Implemented.** When `SERVER_NAME` is `sql-server`, `LOGON_USER` is set to `silicon\pj`.

**Requires Confirmation:** whether that host is still used.

### BR-008 — Session IP check is not in force

**Implemented** as unused code. `checkLogin` would redirect when `$_SESSION['IP']` differs from `REMOTE_ADDR`. No page calls it, and login does not set `$_SESSION['IP']`.

## Who may edit a requisition

The permission switch matches the session user id to ids on the requisition, in this order: executive, then manager or alternate, then requester. Executive also sets the manager and requester flags. Anyone else gets `isUser = false`.

### BR-009 — Draft is editable by the requester

**Implemented.** In the non-special branch, status Draft (and any status that falls through `default`) enables the edit controls only when `isUser` is true. Approve is disabled in that branch. Submit is disabled when status is not Draft.

### BR-010 — Pending under R10 000 is editable by a manager on that requisition

**Implemented.** `$SALESMANAGERMAXVALUE = 10000`. If the summed item total, cast to int, is lower than 10000 and `isManager` is true, Pending controls are enabled. `isManager` is true for the requisition's manager, alternate, and executive (because the executive case sets it).

Evidence: `requisitionForm.php`. Comment 2016-05-05: "fixed bug with approval > limit". Comment 2020-10-29: "fixed bug where user/manager/alt can approve over limit".

### BR-011 — Pending at or above R10 000 is editable by the executive on that requisition

**Implemented.** The comparison is `>= $SALESMANAGERMAXVALUE` and `isExec`. `isExec` is set only when the session user id equals `refExecutiveManagerID`.

**Requires Confirmation:** the rand amount is a source constant. No document in the repo authorises the figure.

### BR-012 — Approved, Completed, Declined, and Cancelled are locked for ordinary users

**Implemented.** Those statuses set the edit controls disabled and hide the remove-line style. Comment on Approved: "No more edits" (2023-11-02).

Nothing in this repository writes status Completed. **Unknown** which system does.

### BR-013 — Special users bypass the status lock, with two exceptions

**Implemented.**

- The R10 000 band, the Draft rule that disables Approve, and the rule that disables Submit when status is not Draft are assigned only in the non-special branch. A special user does not get those limits.
- Any `blnSpecial = 1` user is locked when status is Approved. That shared lock disables Save, Submit, Approve, Decline, and Add Item together.
- If that user is also `refUserGroupFreeStockID == 6`, Completed, Declined, and Cancelled are locked as well. The comment calls group 6 the Executive security group, "not user.blnExec".
- The cancel button stays `display: none` for every user.

**Requires Confirmation:** the display name of group 6, whether `blnSpecial` lives on `mstUser` or `mstUserGroupFreeStock`, and whether special users are meant to approve a Draft or a Pending requisition outside the R10 000 band.

### BR-014 — The authorisation list is not filtered by the viewer's role

**Implemented.** `getList` returns every requisition that matches the screen filters. It does not add the session user id.

**Requires Confirmation:** whether that organisation-wide list is intended.

## Requisition content

### BR-015 — Header fields required in the browser

**Implemented** in `validateForm` on the requisition form:

- Request type
- M or S expense (dropdown values Marketing and Sales)
- Region
- Bill-to account
- Ship-to account
- Detail of request

Empty or `0` fails the check. The alert lists the missing labels. There is no second copy of this check in `Requisition::Save`.

### BR-016 — Contact name and contact number are optional

**Implemented.** The checks are commented out. Comment: "20151123 - not required - pj / snell demo 20151123".

### BR-017 — A new requisition starts as Draft, delivery, requester's region

**Implemented.** Insert forces `strStatus = Draft`. The unsaved form label is **New** and is not written. `blnDelivery` defaults to 1 (Delivery). Region defaults to `$_SESSION[USER]->strRegion`. Date on the new form is today. Later saves do not update `dtDate`.

### BR-018 — Pink-slip format

**Implemented.** `ES` plus the requisition id, left-padded with zeros to 7 digits. Example shape: `ES0000123`. Applied only when the stored value is null or blank.

**Inferred:** "ES" is a document prefix. The expansion of the letters is not in the code.

### BR-019 — Bill-to accounts are promotional accounts in the region

**Implemented.** Dropdown and AJAX: `dwdCustomer.blnActive = 1`, `strRegionDesc2` equals the selected region, and `blnPromotionalAccounts = 1`. The account already stored on the requisition is included even when those flags would exclude it.

### BR-020 — Ship-to accounts are active customers in the region

**Implemented.** Same active and region predicates, without `blnPromotionalAccounts`. Search also matches `strCustomerNo` or `strCustomerName` with `LIKE`. The current ship-to is always eligible to remain selected.

### BR-021 — Request types, brands, and products prefer active rows

**Implemented.**

- Request types: `fsrRequestType.blnActive = 1` or the id already on the requisition.
- Brands: `mstBrand.blnActive = 1`, or the brand already on the line. Apostrophes are stripped when the option value is built.
- Products for a brand: `dwdProduct.blnActive = 1` and brand match, or the product code already on the line (server-rendered dropdown). The AJAX reload uses only the active brand match.

### BR-022 — Line price comes from the product table at save

**Implemented.** `dblCostCase` and `dblCostBottle` are read from `dwdProduct` for `strProdCode`. The browser shows those prices and a whole-rand line total. The stored `dblTotal` is rounded to two decimal places: `(cases * case price) + (bottles * bottle price)`.

Quantities missing from the post are stored as 0. A product code that is not on `dwdProduct` does not write the line and marks the save failed.

### BR-023 — A line with no product code is ignored

**Implemented.** The save loop skips `strProdCode` when it is an empty string.

### BR-024 — At least one saved line is required to submit or approve

**Implemented.** `ItemCount` is `COUNT(*)` from `fsrItem` for the requisition. Zero blocks Submit and Approve and leaves status unchanged. The 2026-09-18 comment says an empty requisition would otherwise email a blank R0 pink slip.

Decline and Cancel do not use this count.

**Requires Confirmation:** the comment assumes an email is sent. The sender is not in this repository.

### BR-025 — First save creates the header only

**Implemented.** The insert branch does not write `fsrItem`. The product table is hidden until a header row loads. Items are added on a later save.

### BR-026 — Managers on the requisition are copied from the user, not typed on the form

**Implemented.** `UpdateManagers` sets:

- `refManagerID` from the requester's `mstUser.refManagerID`
- `refAlternateManagerID` from the requester's `refManagerAlternativeID`
- `refExecutiveManagerID` from that manager's `refManagerExecutiveID`

If the manager id is 0, the save message warns that no manager is set and tells the user to escalate to an administrator. The save still returns success for that warning alone (`$blnSaveOK` is not cleared by the warning).

### BR-027 — Status transitions this application writes

**Implemented.**

| From (as used) | Action | To |
|---|---|---|
| Unsaved label New | Save | Draft (insert) |
| Draft, when save succeeds and at least one item exists | Submit | Pending, plus cron type `FSR_NewRequisition` |
| Pending (or any status the button can post), when save succeeds and at least one item exists | Approve | Approved, authoriser = user name, cron type `FSR_RequisitionApproved` |
| Any posted form the button can reach | Decline | Declined, authoriser text `Declined [name]`, matching cron rows `Removed` |
| Hidden button | Cancel Requisition | Cancelled, authoriser text `Cancelled [name]`, matching cron rows `Removed` |

Cron `dtCronJob` is `GETDATE()` formatted as `YYYYMMDD` plus `HHMM`. Before insert, an existing row of the same type and `param` is deleted. Decline and cancel do not delete. They update `sysCronJob` rows whose `param` equals `RequisitionID={id}`, regardless of type.

**Unknown:** legal transitions enforced by the database, if any. The PHP status writes do not check the previous status.

### BR-028 — Cancel is hidden, not deleted

**Implemented.** The cancel button has `display: none`. Comment 2015-02-08. The server case remains.

### BR-029 — Authorised-by text

**Implemented.**

- Approve: the session user's `strUserName`, with single quotes doubled.
- Decline: `Declined [{name}]`. The status update concatenates the name before the quote-escape that is used for the cron result. The cron `Result` uses the escaped name.
- Cancel: `Cancelled [{name}]` with quotes doubled on both the status update and the cron result.

### BR-030 — List totals are rounded; the footer says so

**Implemented.** Each grid row prints `round(TotalValue, 0)` with thousands separators and a leading `R`. The footer adds the unrounded `TotalValue` figures and then rounds that sum. Caption note: "Rounded Values". A missing customer name is `*MISSING*`.

### BR-031 — Default list window is the current month through today

**Implemented.** When the start or end filter is empty, start is day 01 of the current month and end is today, format `d-m-Y`. Unless Ignore Dates is set, `dtDate` must fall between those dates after they are parsed with `strtotime` and formatted `Ymd`.

### BR-032 — Ignore Dates drops the date predicate

**Implemented.** If `frIgnoreDates` is set on the session filters, `getList` does not add the `dtDate` BETWEEN clause. Filter handling unsets that key when the posted value is 0.

## Users

### BR-033 — Required user fields in the browser

**Implemented** in `user.php` `validateForm`: user group, user name, domain login, email, region. A disabled control is not required. RepID is labelled optional and is not in the check.

`User::Save` does not repeat these checks.

### BR-034 — Domain login must be unique on create

**Implemented.** `checkExistingDomainLogin` selects `mstUser` by `strDomainLogin`. If the returned login equals the posted login, save returns "Domain login already exists. Please choose a different one." and does not insert. Updates do not call this check.

The SQL concatenates the posted login. **Inferred:** the uniqueness rule is real; the comparison is exact string equality on the loaded row.

### BR-035 — Manager flags rewrite reporting lines

**Implemented** after every user save:

| Posted flags | Stored links |
|---|---|
| Not a manager and not an executive | `refManagerID` and `refManagerAlternativeID` from the form; `refManagerExecutiveID = 0` |
| Manager | `refManagerID` = this user; alternate = 0; executive = the posted executive |
| Executive | `refManagerID` and `refManagerExecutiveID` = this user; alternate = 0; `blnManager` forced to 1 |

The executive branch is not `else if`. If both checkboxes are posted, the executive block runs after the manager block and overwrites it.

### BR-036 — Make Inactive is not a delete

**Implemented.** Checked users get `blnActive = 0`. The button label is "Make Inactive". The unused `getList_old` still says Delete and its delete statement is commented out in `User::Delete`.

### BR-037 — New user form defaults Active to checked

**Implemented.** `$chkActive` starts as checked. An existing inactive user clears it. A brand-new form (no row) keeps the checked default. Other checkboxes are set only when a row loads.

### BR-038 — Last user on a user save

**Implemented.** `strLastUser` is the session user's `strUserName`. A 2024-05-06 comment says the code stopped clearing `dtStamp` on update. Create still unsets `dtStamp` before insert.

## Money and display

### BR-039 — Expense is Marketing or Sales

**Implemented.** The dropdown is those two labels, plus a blank "- Select -". Stored column: `fsrRequisition.strExpense`.

### BR-040 — Delivery versus collect

**Implemented.** Radio `blnDelivery` 1 = Delivery, 0 = Collect. New requisitions select Delivery.

## Rules stated only as comments

These are not extra behaviour beyond the rules above. They record why a condition exists.

- 2015-02-08 — cancel button hidden.
- 2015-11-12 — group 6 is an executive security group, distinct from the user executive flag.
- 2015-11-23 — contact fields not required after a Snell demo.
- 2016-05-05 — approval over the limit was a bugfix.
- 2016-05-31 — user management added; version 2.0.1s.
- 2020-10-29 — requester, manager, and alternate must not approve over the limit.
- 2023-11-02 — no edits after approve.
- 2026-09-18 — do not submit or approve with no items; do not lose the requisition id on post; do not change status when save fails.

## Not found

No rule in this repository covers tax, credit limits, stock availability, partial approval, delegation beyond the stored alternate manager, or an expiry date on a Pending requisition. **Unknown** whether those rules exist in the cron worker or in SQL.
