# Business rules — sawis_api

Evidence date: 2026-10-04.

Source: `git@bitbucket.org:silicon_bit_bucket/sawis_api.git`, `master` at `162260a`, with stated exceptions from `prod_api_2020` at `b0d4147`.

Rules below are what the PHP enforces. They are **Inferred** from implementation unless a comment states the rule in words. No separate business-rules document exists in the repository. Nothing here was taken from a contract or a SAWIS policy file.

Database passwords and bypass tokens are not recorded.

## Vendor access

### BR-001 — Only an active vendor key is accepted

**Inferred.** `apiVendor.VendorKey` must match and `blnActive` must be 1. Otherwise the call stops with `INVALID VENDOR KEY`.

### BR-002 — Returned columns are an include list

**Inferred,** and stated in the 2018-09-27 comment: "Inverted to include fields as opposed to excluding them."

`apiVendorField` rows for vendor `*` or this vendor, this `strCall`, and `strLastUser = 'INCLUDE'` name the fields copied into the response. `strField = *` copies all columns. No matching rows means the response rows have no fields.

### BR-003 — Vinpro member visibility

**Inferred** from the comment block in `setVendor_CallWHERE_Additional` for `VINPRO`, which the code then implements for member list, address, contact, and profile:

- Status `Active`, or status `Inactive` with `blnVinPro = 1`.
- `tblMemberType.refVinPro <> 9`.
- The comment also says closed members are excluded, and inactive members with Vinpro flag N are excluded. The SQL covers active, and inactive-with-flag. It does not mention a `Closed` literal. A status other than those two strings fails the predicate.

The comment says profile rows should be sorted by the `VP` field and the lowest `VP` number wins. `getMemberProfile` orders by member id, WOL code descending, entity, member type, status, and production cellar. It does not order by `refVinPro`. **Requires Confirmation:** the sort rule in the comment is not what the query does.

### BR-004 — Wine Online (`WOLINE`) member visibility

**Inferred** from the same function's comment and SQL, applied to member list, address, contact, and profile:

- `tblMember.strStatus` like `Active`.
- `tblMemberType.refWineOnline <> 9`.

The comment describes a unique key of SAWIS member number, WOL code, farm number, member type, status, and production cellar. `getMemberProfile` selects those concepts (`MemberID`, `strWOLCode`, `FarmID`, member type, status, `syncProductionCellarID`) and orders by them. It does not enforce uniqueness in PHP.

### BR-005 — WIETA member visibility

**Inferred.** For `WIETA1`, member list, address, contact, and profile require `tblMemberType.refWieta <> 9`. Member type list adds the same predicate. The comment says the `WT` field must not be 9.

`getMemberProfile` selects `tblMemberRelationship.strStatus` as `strRelationshipStatus` for every vendor, not only WIETA. A 2018-12-05 comment says relationship status was added for WIETA only. The column filter that would hide it from other vendors is the `apiVendorField` table, which is not in git.

### BR-006 — EZW1NE members are empty

**Inferred.** Vendor `EZW1NE` appends `AND 1=0` to member list, address, contact, and profile. Those calls return no member rows for that vendor. No comment explains why.

### BR-007 — LMS001 has no extra member predicate

**Inferred.** The `LMS001` branch of the vendor switch is empty. LMS calls use their own WHERE clauses instead.

## Member and farm filters that apply to every vendor

### BR-008 — Member id and member type id floors

**Inferred.**

- `getMemberList`, address, contact, and profile: `tblMember.MemberID > 0`.
- `getMemberTypeList`: `MemberTypeID > 0`.

### BR-009 — Farm list member types

**Inferred.** A farm is listed when a `tblMemberRelationship` row has `strType = 'Farm to Member'` and `strMemberTypeCode` in `FRM000`, `FRMEST`, `FRMOWN`. The query groups by `FarmID` and takes `Max(refMemberTypeID)` inside the derivation.

### BR-010 — Title deeds skip temporary entities

**Inferred** from the 2017-09-20 comment and the SQL. `EntityType NOT LIKE '%TMP%'`.

### BR-011 — Deleted harvest items are omitted from `getWS`

**Inferred.** `tblHarvestItem.blnDeleted = 0`. The same predicate is commented as `n/a` on `getWSB`.

### BR-012 — LMS profile status

**Inferred.**

A member is included when:

- status is `Inactive` or `Active`, and `tblMemberType.refLMS <> 9`, or
- status is `Active` and `refLMS` is null,

and `MemberID > 0`.

Optional `MemberID` argument narrows to that member.

### BR-013 — LMS member-id list types

**Inferred.** `strMemberTypeCode` in `FRMOWN`, `FRMTNT`, `FRMSCP`.

### BR-014 — WineMS active records

**Inferred.**

- `getWineMSFarmList`: `FarmStatus` like `Active`.
- `getWineMSFarm` and `getWineMSMember`: `blnWineMS = 1`, farm and relationship status like `Active`, and `refWineMS` in `1` or `3`.
- `getWineMSSawis1`: `blnWineMS = 1` only.

`getWineMSFarm`: if the member type code is `CELPRD`, match `syncProductionCellarID` and `refWineMS = 1`. Otherwise match `refMemberID` and `refWineMS = 3`. Both paths also require `refEntityID` equal to `refFarmID`. Arguments `refMemberID` and `refFarmID` are mandatory.

`getWineMSMember`: arguments `CellarID` and `MemberID` are mandatory. `CELPRD` matches production cellar and `OpsID`, with `refWineMS = 1`. The other branch matches `refMemberID` to `CellarID` and `refWineMS = 3`, and does not add the `MemberID` argument to the SQL.

`getWineMSSawis1` requires arguments `refMemberID` and `refFarmID`, but the member-type lookup uses `$this->args["CellarID"]`. **Requires Confirmation:** whether callers send `CellarID` as well. If they do not, the type lookup does not see the cellar id the rest of the method uses.

### BR-015 — CGA blocks are the configured year

**Inferred.** `getCGABlockList` requires `FarmID` and `tblBlock.intYear` equal to `sysSettings` value `CURENT_BLOCK_YEAR`.

**On `prod_api_2020`,** `putCGABlockSpatial` uses `sysYear.intYear` instead of that setting. **Requires Confirmation:** whether those two year sources are the same number.

### BR-016 — Hectares and block copy (unused helpers)

**Inferred.** Defined in `project.functions.inc.php`. No caller in this repository.

- `calculateHectare` = round( ((narrow row spacing + wide row spacing) / 2) * vine spacing * vine count / 10000 , 4 ).
- `getBlockDensity` = round(vines / hectare, 4).
- `recordBlockMovement` writes `tblBlockMovement`. If hectare is omitted it uses vines / `tblBlock.dblVineDensity`.
- `GetNewFarmID` takes `MAX(FarmID)`, adds 1000, and forces the last three digits to `000` when no farm id is passed. When a farm id is passed, it increments the last match of that prefix.
- `bg1GetSmallestCommonLocation` walks harvest items for a harvest and returns the ward if all wards match, else district, else region, else geo, using the left-hand digits of `WOCode` (2, 5, 8, or the full code).

`setBlockDensity` calls `getBlockDensity` with `$intVine`, which is not a parameter (`$intVines` is). **Inferred** dead code.

## Users and passwords

### BR-017 — New external users get a fixed security group

**Inferred** from the 2017-09-28 comment and the code.

- Vendor `SAWIS3` and security group id 0: group named `Member Superuser [Unapproved]`.
- Every other vendor: group named `External Site User`, replacing whatever the XML sent.

### BR-018 — Email is unique

**Inferred.** A database unique-key violation on create becomes "Cannot add User, Email already in use". On update it becomes "Cannot change Email, Email already in use".

### BR-019 — Password shape for `setPassword`

**Inferred** from the code and the message dated 2018-08-23. The message text is slightly wider than the regular expression.

The check, after removing `&` and spaces from the submitted password:

- Length of the original password is 8 to 14 characters.
- Remaining string matches `^[A-Za-z0-9\d=!\-@#%._*]*$`.
- At least one `A-Z`.
- At least one digit.

Failure message: minimum 8 characters, one capital, one number, and only standard letters plus the characters in the message (`!@#$%^&*()_+=-`). The expression does not allow `^`, `&`, `(`, `)`, or `+`. `&` and spaces are stripped before the expression runs, so they do not fail the character class, but they still count toward length.

Restricted-word checking against `NemoPassword` is commented out.

`NemoPassword` can create `sysPassword` and these `sysSettings` keys if the table is missing: policy enabled `1`, minimum length `8`, max history `8`, restricted words `password|123`, reset interval `60 MINUTE`. `PASSWORD_MaxAge` default inserted as `false`.

Stored form: 32 random bytes as hex salt, SHA-256 of salt concatenated with the password.

`resetPassword` saves the generated password with date `2010-01-01`. The comment calls that a default aged date. **Requires Confirmation:** whether that date forces a reset on next login in the web app. This repository does not check `PASSWORD_MaxAge` during `loginUser`.

### BR-020 — Who may log in

**Inferred.**

1. `checkAccess`: user column `blnSite_{vendor.strSite}` must be 1.
2. `sysUser.blnActive` must be 1.
3. Latest `sysPassword` hash must match SHA-256(salt + password).

`loginUser` url-decodes username and password.

### BR-021 — Login token expiry comparison

The comment says reject an expired token. The code treats `dtTokenExpiration < now` as success and the other branch as "Login Token has expired". **Requires Confirmation.** This is not written up as an approved rule.

### BR-022 — Certification sent-flag

**Inferred** from the comment dated in `setCertificationRecord` and the SQL.

On update, if `strCertificationStatusCode` changes and the new code is `ST`, `CS`, `ES`, or `KS`, set `blnSent = 0` and `dtCertificateDate = NOW()`.

Empty bottle, taste, raad, and verified dates (`""` or `0000-00-00`) are stored as SQL `NULL`.

Required XML fields: `TransactionID` (the key), `ApplicationID`, `MemberID`, `intVintage`, `strOrigin`, `strCultivarCode`, `strCertificationStatusCode`.

### BR-023 — Lab results required fields

**Inferred.** `setCertificationResultsRecord` requires `TransactionID`, `LabID`, and `ApplicationID`.

`getCertificationResultsList` optionally filters on `TransactionID`, `LabID`, `dtLabAnalysis` (LIKE), and `strStatus`.

### BR-024 — WIETA certification dates

**Inferred** from the 2018-03-27 comment. If `dtCertificationDate` is missing or empty, use today's date. If `dtCertificationExpiryDate` is missing or empty, store `NULL`.

### BR-025 — WineMS cellar and activity required fields

**Inferred.**

`setWineMSCellar` requires query argument `refCellarID`. It deletes `stg_wmsBlock` for that cellar first. Each XML row requires `GUID`, `refCellarID`, `refMemberID`, `refFarmID`, `refBlockID`, `strBlock`, `intYearPlanted`, `CultivarCode`, `CultivarRootstockCode`, `dblRowSpacingWide`, `dblRowSpacingNarrow`, `dblVineSpacing`, `intVinesClosing`, `dblHectare`, `IrrigationCode`, `TrellisCode`, `intVinesOpening`, `intVinesAmmended`.

`setWineMSBlockActivity` requires query argument `refMemberID` and XML fields `GUID`, `refCellarID`, `refMemberID`, `refFarmID`, `refBlockID`, `strMember`, `strFarm`, `strBlock`, cultivar and rootstock codes and names, irrigation and trellis codes and names, `intYearPlanted`, `intPressYear`, `intChangeYear`, `strWMSActivity`, `intVines`, `dblHectare`.

`setWineMSBlockGUID` requires `GUID` plus `refCellarID`, `refMemberID`, `refFarmID`, `refBlockID`, `strStatus`.

### BR-026 — EzyWine consent (prod branch)

**On `prod_api_2020`. Inferred.**

`producer_num` matches `tblMember.strWOLCode`. If `blnEzyWine` is the string `0`, the body is JSON whose key is `403` and whose message is "Member did not give consent to share data." Missing arguments use the same shape: "Missing Parameters […]". `Harvest_year` of `all` does not filter `tblHarvest.intVintage`.

### BR-027 — Block coordinates (prod branch)

**On `prod_api_2020`. Inferred.**

`FarmID`, `BlockID`, and `Coordinates` are required. The block must already exist for `sysYear.intYear`. The update writes geometry with `ST_GeomFromText`. No insert of a new block.

## Logging, mail, and files

### BR-028 — API log retention

**Inferred.** Each `record()` deletes `apiLog` where `dtLog` is older than 3 months.

### BR-029 — Read receipt does not override an error status

**Inferred.** `tblEmail.strStatus` becomes `Read` and `strLastUser` becomes `Email Read Receipt` only when current status is not `Error`.

### BR-030 — FIM permissions

**Inferred.** Directories that are not `0755` are chmod'd to `0755`. Files that are not `0644` are chmod'd to `0644`. Names in `sysSettings` `FIM_EXCLUDE_PATH_LIKE` (split on `;`) are skipped. The static method calls `$this->arrExcludeWildcard` inside a static function. **Requires Confirmation** that this runs; a static call does not have `$this`.

### BR-031 — Session garbage collection versus API token

**Inferred.** PHP session `gc_maxlifetime` is 7200 seconds. API token retention is the SQL interval `3 DAY`. They are different clocks. The API methods do not read `$_SESSION` for authorisation. `session_start()` still runs from `system.php`.

## Rules that are comments only

| Comment | Code |
|---|---|
| Change token lifetime back after testing | Value left at `3 DAY` |
| Vinpro profile sorted by lowest `VP` | Not implemented in `getMemberProfile` ORDER BY |
| `setWineExportRecord` and `setWietaMembership` further validation | Marked TODO, not coded |
| LMS profile page size is one tenth | Commented out |
