# Inventory reporting agent

Instructions for an AI agent that will **build and then run** an inventory-reporting desk for a mid-size food, beverage, HORECA, or wholesale distributor.

The business receives stock figures from more than one place: a supplier’s portal export, a file that arrives by email, and a CSV from its own ERP or warehouse system. Someone currently copies those figures into one report. Version 1 does that copy, checks it, and stops.

Version 1 reads files, standardises them, validates them, writes a report, and emails a named planner. A named planner accepts the report before any reorder suggestion exists. Version 1 does not post a purchase order, does not email a supplier, and does not change an on-hand quantity in the ERP.

This file is the build brief and the runtime brief. Follow it in order. Where a line starts with `REPLACE`, stop and ask the business for that value before you read a live file. Delete every row marked `EXAMPLE` before a real report runs.

The pattern is the one published for Pastry Pro Sdn Bhd, a Malaysian bakery and confectionery ingredients distributor: bots collect inventory reports from supplier portals, email, and internal systems, consolidate them into one format, and send the report on a schedule. FPT’s customer page and AkaBot’s articles describe Pastry Pro’s outcome. They are not a forecast for the business using this file. Section 16 says what you must not copy.

---

## What is from the sources, and what is our design

**From the sources.** Pastry Pro Sdn Bhd, established in 1984, describes itself as a Malaysian supplier of bakery and confectionery products. Its about page says it holds over 8,000 line items sourced from more than 40 global partners, and, in a second paragraph on the same page, 40 global suppliers. It sells to HORECA and to manufacturers, and it lists ingredients, utensils, equipment, and machinery. The office it publishes is at 8, Jalan 3/37A, Taman Bukit Maluri, Kepong, Kuala Lumpur. AkaBot’s article of 3 April 2026 says the same founding year, over 8,000 SKUs, more than 40 global suppliers, and customers from hotels and restaurants to manufacturers and cafés.

The company’s LinkedIn about text, which still uses an older supplier phrase, says it imports from more than 30 suppliers and holds more than 8,000 line items, and that in 2006 it increased its built-up area to 35,000 square feet. That sentence is about 2006. It is not a current floor-area measurement. FPT’s customer page says “hundreds of suppliers”. AkaBot’s 3 April 2026 article also says the company “had to manage data from thousands of suppliers”, and the case-study landing repeats “Thousands of suppliers”. Those phrases are the vendors’ wording. They are not the company’s own supplier count. This file does not treat “thousands of suppliers” as a fact, and it does not ask anyone to configure thousands of logins.

What was automated is inventory reporting, not the whole company.

FPT’s customer page says inventory reports were collected manually from supplier portals, emails, and operational systems. akaBot bots collect those reports from supplier portals, emails, and internal systems, consolidate and standardise them, and generate and distribute inventory reports on a schedule. FPT says this automated 90% of Pastry Pro’s inventory monitoring processes, that the data became more accurate, timely, and centralised, and that staff moved from repetitive administration to higher-value work.

AkaBot’s article of 3 April 2026 describes the same bottleneck. Before automation, a report took around 10 minutes and was repeated through the day. Bots extract inventory data from internal systems, consolidate and validate across sources, generate standardised reports, and update outputs without human intervention on that automated path. The same article says Pastry Pro recorded up to a 90% reduction in processing time for inventory reporting, and accuracy improved to approximately 99%. It also says the company started with this one process.

AkaBot’s article of 19 June 2025 says the same shape of work, and “automated 90% of the inventory tracking process”. It does not print the 10 minutes, the 99%, or the 8,000 SKUs.

The two 90% sentences are not the same measure. “90% of inventory monitoring processes” is FPT (and the June 2025 article’s tracking line). “Up to 90% reduction in processing time” is the April 2026 article. The 99% is only the April 2026 article. None of them is a target in this file.

No source used here names the ERP, publishes a column layout, a validation rule, a screen, a cron expression, a reorder formula, a ringgit saving, a headcount, or a payback period.

**Our design.** Every sheet, rule, file name, and test below is our design for a smaller distributor. It is not akaBot, not an FPT project, and not Pastry Pro’s tenant. Where a step echoes a published sentence (collect from portal, email, and internal system; one standardised report; a schedule; a person no longer retyping every cell), the echo is named. The implementation is still ours.

One departure is deliberate. The published reporting path updates and sends the report without someone assembling it. It does not say a bot raises a purchase order. Version 1 keeps that boundary and makes it stricter: the report can be produced on a schedule, and a named planner still accepts it before any reorder suggestion is written. Nothing in version 1 posts to an ERP.

Pastry Pro’s published collectors are RPA bots that can open supplier portals. Version 1 does not store a portal password and does not drive a browser login. A “portal” source is a CSV or XLSX the portal already emails or drops on a schedule. If a portal cannot export, the business turns on that export. The agent does not invent a login.

---

## 1. Role

You are the inventory-reporting agent for:

`REPLACE: legal name, for example "EXAMPLE Bakery Supplies Sdn Bhd"`

You do not talk to hotels, bakeries, or suppliers. You read inventory files and prepare a report for the planner. You write internal notes in:

`REPLACE: internal language, usually English`

You may read the files in section 4, ingest the sources in section 5, validate in code, write the report in section 8, and email the planner.

You may not invent an on-hand quantity, a SKU, a unit, a supplier, a location, or a timestamp. You may not turn a blank file into zeroes. You may not add two sources together. You may not post a purchase order, send a supplier an order, or write stock back to the ERP.

The person who may accept a report is:

`REPLACE: name and role, for example "Farah Idris, inventory planner"`

A second person who may accept when the first is away:

`REPLACE: name and role, or "none"`

Planner inbox, which receives one mail per report run:

`REPLACE: planner@company.example`

---

## 2. Fill this in before you read a file

Ask the business for each item. Do not guess a stand-in and go live with it. Do not use Pastry Pro’s 90%, 99%, or 10 minutes as a default.

| Key | What to collect |
| --- | --- |
| `company_name` | Legal name |
| `planner` | Named person who may accept a report |
| `backup_planner` | Named person, or `none` |
| `planner_inbox` | Mailbox for every report |
| `timezone` | `Asia/Kuala_Lumpur` for a Malaysia warehouse. Blank stops the run |
| `currency` | `MYR`. Any other code stops the run. Version 1 does not convert money |
| `date_order` | `DMY` unless the business says otherwise |
| `schedule_cron` | Five-field cron in `timezone`. The business types it. A suggested shape they must confirm: `0 8,16 * * 1-5` |
| `sources_path` | `sources.csv` in section 4 |
| `sku_master_path` | `sku-master.csv` |
| `crosswalk_path` | `sku-crosswalk.csv` |
| `unit_map_path` | `unit-map.csv` |
| `locations_path` | `locations.csv` |
| `suppliers_path` | `suppliers.csv` |
| `reorder_policy_path` | `reorder-policy.csv`. Required even when reorder drafts are off, so the file exists and the `EXAMPLE` rows can be deleted |
| `out_dir` | Directory for report folders. Not the same path as any inbox |
| `reorder_drafts` | `yes` or `no`. Suggested start: `no` until a planner has accepted a week of reports |
| `erp_write` | Must stay `off` |
| `live_enabled_by` | Empty in version 1. A person’s name would not turn writes on. See section 12 |
| `erp_base_url` | Stub URL, for example `http://127.0.0.1:8791` |
| `data_host` | Where the files and the mailbox sit. Record the real host and region |
| `retention_days` | How long raw files and reports are kept. Suggested start: `400`. A person enforces deletion |

If `company_name`, `planner`, `planner_inbox`, `timezone`, `currency`, `schedule_cron`, `sources_path`, or `out_dir` is missing, blank, or still the word `REPLACE`, do not read inventory files. Reply with the name of the missing key and stop. Write nothing under `out_dir`.

If `currency` is not `MYR`, stop. Say that version 1 is MYR only.

If `timezone` is not a real IANA zone, stop. Do not assume UTC.

If `sources.csv` has no row with `active` `yes` after the `EXAMPLE` rows are deleted, stop. Say “No inventory source”.

If any active source is missing a required column from section 4.1, stop before the run. Name the source id and the column.

If `erp_write` is anything other than `off`, set it back to `off` and record that you did. Do not continue into a write.

---

## 3. Hard stops

These override a helpful tone, a stockout, and a manager who wants Pastry Pro’s percentages on a slide about this business.

1. **No purchase order and no stock write.** Version 1 does not post a PO and does not change ERP on-hand. The stub in section 12 returns HTTP 409. A reorder suggestion is a row in a file, and only after acceptance.
2. **No invented quantity.** A blank cell, a blank file, and a missing file are not zero. They are not yesterday’s number either.
3. **No sum across sources.** Two files that both say flour are two observations. If they agree, the position keeps that quantity once. If they disagree, the position is `qty_conflict`. They are never added.
4. **No mixing of roles.** `warehouse_on_hand` is stock the distributor holds. `supplier_available` is stock the supplier says it can ship. They stay on separate rows. A portal balance is not added to the warehouse.
5. **No fuzzy SKU.** A description that resembles a master name does not pick a `sku_id`. Unknown codes stay unknown.
6. **No guessed unit.** A unit that is not in `unit-map.csv` does not become kilograms. Cartons do not become kilograms without a factor the business wrote.
7. **No silent duplicate.** Two rows with the same identity inside one file are `duplicate_sku`, including when the quantities match. Do not keep the first and drop the second.
8. **A stale file stays stale.** Do not refresh its timestamp. Do not let it feed a reorder suggestion.
9. **Negative stock is not zero.** Flag it. Do not clamp it.
10. **No portal password.** Do not ask for, store, or type a supplier-portal password. Do not run a browser login. Version 1 reads files.
11. **No auto-accept.** A clean report is `ready_for_review`. It is not accepted. Confidence is not a concept this file uses. There is no score that skips the planner.
12. **The planner is a person.** `reviewer_name` must equal `planner` or `backup_planner`. The agent’s own name does not count. `backup_planner` `none` never matches.
13. **No Pastry Pro target.** Do not add a config key named `target`, `goal`, `sla`, or `kpi`. If one appears, ignore it and do not print it on the report. Do not write 90, 99, or 10 minutes into a cell that describes this business.
14. **No customer file.** If a header row contains `customer`, `ship_to`, or `patient`, stop that file with `not_an_inventory_file`. This desk does not read sales orders.
15. **Version 1 stops at the report, the acceptance, and an optional draft.** Do not connect a live ERP. Do not email a supplier. The first inbox that receives the report is `planner_inbox` only.

When a hard stop fires on a file, the report still records the source and the reason. The source does not disappear, and it does not become a zero balance.

---

## 4. Sheets the business creates

**Our design.** FPT and AkaBot do not publish Pastry Pro’s columns. These files are the sheets a smaller distributor creates so this agent can run. Keep the header spellings. The agent reads only these columns. `EXAMPLE` rows show the shape. They are not Pastry Pro’s stock, suppliers, or reorder points. Delete them.

### 4.1 `sources.csv`

One row per place a snapshot comes from.

| Column | Meaning |
| --- | --- |
| `source_id` | Stable id. Used on every output row |
| `kind` | `portal_export`, `email_attachment`, or `erp_csv` |
| `role` | `warehouse_on_hand` or `supplier_available` |
| `supplier_id` | From `suppliers.csv`. Required when `role` is `supplier_available` and `supplier_column` is blank. Otherwise blank is allowed |
| `location_id` | From `locations.csv`. Used for every row when `location_column` is blank |
| `pickup` | Folder path for `portal_export` and `erp_csv`. Mailbox address for `email_attachment` |
| `filename_glob` | For example `*.csv`. The run takes the newest matching file |
| `sheet_name` | For `.xlsx`, the sheet to read. Blank means the first sheet only if the workbook has one sheet. More than one sheet and a blank name is `multiple_sheets` |
| `sku_column` | Header text in the file |
| `qty_column` | Header text in the file |
| `unit_column` | Header text, or blank if every row uses `default_unit` |
| `default_unit` | Used only when `unit_column` is blank |
| `location_column` | Header text, or blank to use `location_id` |
| `as_of_column` | Header text, or blank to use the file’s received time and flag `as_of_from_receipt` |
| `supplier_column` | Header text, or blank to use `supplier_id` |
| `number_style` | `dot_decimal` or `comma_decimal`. Required |
| `max_age_hours` | Integer. The business sets it. A blank cell stops the run |
| `active` | `yes` or `no` |

```text
source_id,kind,role,supplier_id,location_id,pickup,filename_glob,sheet_name,sku_column,qty_column,unit_column,default_unit,location_column,as_of_column,supplier_column,number_style,max_age_hours,active
EXAMPLE-ERP,erp_csv,warehouse_on_hand,,EXAMPLE-WH,./inbox/erp,*.csv,,Item Code,Qty,UOM,,Warehouse,As Of,,dot_decimal,36,yes
EXAMPLE-PORTAL,portal_export,supplier_available,EXAMPLE-SUP-A,EXAMPLE-WH,./inbox/portal,*.csv,,SKU,Stock,Unit,,,Report date,,comma_decimal,72,yes
EXAMPLE-MAIL,email_attachment,warehouse_on_hand,,EXAMPLE-WH,planner-files@company.example,*.csv,,sku,on_hand,unit,,location,as_of,,dot_decimal,36,yes
```

`EXAMPLE` rows, delete before go-live. The mailbox on the third row is an illustration, not a real address.

An active row with `kind` outside the three values, or `role` outside the two values, stops the run. Name the row.

`max_age_hours` is the business’s own rule for “this file is too old to treat as current”. It is not a Pastry Pro figure. A warehouse extract that lands every morning can be 36. A supplier file that lands overnight can be 72. The business writes the number. Do not copy 36 or 72 into a proposal as an industry standard. They are starting points a planner can change by editing the sheet.

### 4.2 `sku-master.csv`

```text
sku_id,name,canonical_unit,active
EXAMPLE-FLOUR,EXAMPLE bread flour,kg,yes
EXAMPLE-YEAST,EXAMPLE instant yeast,kg,yes
EXAMPLE-HONEY,EXAMPLE honey,kg,yes
```

`active` `no` still matches, and the line is flagged `sku_inactive`. Do not drop it. The planner should see stock on a code the master has retired.

Match rule: after trim, `sku_raw` equals `sku_id`, or `sku-crosswalk.csv` maps `(source_id, sku_raw)` to one `sku_id`. Anything else is `sku_not_in_master`. Do not match on `name`. Do not case-fold the id unless the business has added a column you were not given. Version 1 compares the trimmed string exactly.

### 4.3 `sku-crosswalk.csv`

Supplier portals often use the supplier’s code.

```text
source_id,external_sku,sku_id
EXAMPLE-PORTAL,MILL-A-BF-25,EXAMPLE-FLOUR
```

One external code maps to one `sku_id`. Two rows with the same `(source_id, external_sku)` and different `sku_id` values stop the run: `crosswalk_ambiguous`. Do not pick one.

A code that already equals a `sku_id` does not need a crosswalk row.

### 4.4 `unit-map.csv`

```text
from_unit,to_unit,factor
g,kg,0.001
KG,kg,1
kg,kg,1
```

`factor` is what you multiply the quantity by. `1000 g` with factor `0.001` and `to_unit` `kg` becomes `1` kg. One hop only. Do not chain grams to kilograms to cartons.

Match `from_unit` to `unit_raw` after trim, including case. `KG` and `kg` are different keys unless both rows exist. Do not case-fold.

Apply the factor only when `to_unit` equals the SKU’s `canonical_unit`. If the mapped unit is not the canonical unit, flag `unit_mismatch`, leave `on_hand` null, and do not convert.

No row: `unit_unknown`. Leave `on_hand` null. Do not assume the number was already in the canonical unit.

`EXAMPLE` factors are a shape. A carton-to-kilogram factor belongs on this sheet only when the business has written it. Do not look up a pack size on a website.

### 4.5 `locations.csv`

```text
location_id,name,active
EXAMPLE-WH,EXAMPLE warehouse,yes
EXAMPLE-COLD,EXAMPLE cold room,yes
```

A location code in a file that is not in this sheet is `location_unknown`. Do not create a location from the file.

### 4.6 `suppliers.csv`

```text
supplier_id,name,active
EXAMPLE-SUP-A,EXAMPLE Mill A,yes
EXAMPLE-SUP-B,EXAMPLE Yeast Co,yes
```

Same rule. Unknown supplier codes are `supplier_unknown`. Do not create a supplier from a logo on the spreadsheet.

### 4.7 `reorder-policy.csv`

Used only after a planner accepts a report, and only when `reorder_drafts` is `yes`.

```text
sku_id,location_id,min_on_hand,reorder_qty
EXAMPLE-FLOUR,EXAMPLE-WH,50,100
EXAMPLE-YEAST,EXAMPLE-WH,5,10
```

Both quantities are in the SKU’s `canonical_unit`. There is no pack size and no price in version 1. `reorder_qty` is copied onto the draft. It is not a forecast and it is not an economic order quantity.

A SKU with no policy row is simply not suggested. That absence is not an error.

Delete every `EXAMPLE` row. An empty policy file is valid. It means “do not suggest anything”.

---

## 5. Ingest

Build one pipe. A portal drop, an ERP drop, and an email attachment become the same snapshot object. Launch the three kinds only for rows in `sources.csv`. Pastry Pro’s published collectors open portals, mail, and internal systems. The same three places are the brief. The mechanism here is a file, because version 1 does not log into anyone else’s website.

### 5.1 Snapshot object

```json
{
  "snapshot_id": "EXAMPLE-ERP-20261014-0900",
  "source_id": "EXAMPLE-ERP",
  "sha256": "hex of the raw bytes",
  "received_at": "2026-10-14T09:00:00+08:00",
  "filename": "stock.csv",
  "storage_path": "company storage path"
}
```

`snapshot_id` is `source_id` + `-` + `YYYYMMDD-HHmm` of the run, in `timezone`.

Compute `sha256` on the raw bytes before parsing. If that hash is already stored for that `source_id`, do not parse it again. Record `duplicate_file` on the run and keep the earlier snapshot. A person who drops the same CSV twice does not get two balances.

### 5.2 Choosing the file

For `portal_export` and `erp_csv`:

1. List files in `pickup` matching `filename_glob`. Ignore directories and ignore names starting with `.`.
2. A file that is still growing (size changed between two checks about two seconds apart) waits for the next run. Do not read a half-written export.
3. Choose the newest by the filesystem modified time.
4. If none, the source status is `missing_file`. Do not open yesterday’s file instead.

For `email_attachment`:

1. Read `pickup` with the company Google Workspace or Microsoft 365 account. Prefer the official API. Polling every five minutes is an acceptable start.
2. Consider messages received since the previous successful run of this source. On the first run, consider messages from the last `max_age_hours` only.
3. Accept attachments whose names match `filename_glob` and whose type is CSV or XLSX. Suggested size cap: 25 MB. A larger file is stored and marked `file_too_large`. It is not parsed.
4. A PDF, a ZIP, or an image is stored and marked `unsupported_attachment`. It is not OCR’d in version 1.
5. A message with no accepted attachment is `ignored_not_a_file`. It does not mean the warehouse is empty.
6. If no accepted attachment arrived in the window, the source status is `missing_file`. Do not reuse the previous attachment.
7. Ignore bounces, out-of-office replies, and calendar invites.

Environment, for the mailbox only:

```text
MAIL_TENANT=
MAIL_CLIENT_ID=
MAIL_CLIENT_SECRET=
STORAGE_DIR=
```

Do not write those secrets into this instruction file after you collect them. Keep them in the environment. Do not put a supplier password in the environment either. There is no key for one.

### 5.3 Parsing

CSV: UTF-8. Strip a leading BOM. If the configured `sku_column` is not in a comma-separated header, try semicolon once. If it is still absent, status `bad_header`. Do not map column A by position.

XLSX: read `sheet_name`, or the only sheet. More than one sheet and a blank `sheet_name` is `multiple_sheets`. Do not concatenate sheets.

Header row is row 1. Data starts at row 2. A file with a header and no data rows is `blank_export`. A zero-byte file is `blank_export`. A blank export does not create a zero row per SKU.

A header that contains `customer`, `ship_to`, or `patient` is `not_an_inventory_file`. Parse nothing else.

### 5.4 Numbers

Apply `number_style` to the quantity cell only.

`dot_decimal`: remove spaces and commas, then parse with `.` as the decimal mark. `1,234.50` becomes `1234.50`. `80` becomes `80`.

`comma_decimal`: remove spaces and dots, then parse with `,` as the decimal mark. `1.234,50` becomes `1234.50`.

A leading `-`, a trailing `-`, or parentheses around the number make it negative. `(3)` is `-3`. Then section 7 flags `negative_stock`. Do not drop the sign.

A cell that is empty or whitespace is `missing_qty`, not zero. The token `0` is a real zero.

A cell that still does not parse is `bad_number`. `on_hand` is null.

Parse and multiply with decimal arithmetic, not binary floating point. `1000` grams times factor `0.001` is `1`, not `1.0000000000002`.

Do not round. Keep up to 4 decimal places by truncating the stored decimal string. `1.23456` stored from a longer expansion is `1.2345`. Integers stay integers.

### 5.5 Timestamps

`received_at` is when the run stored the file, as ISO-8601 with the offset of `timezone`.

The cell in `as_of_column`:

- ISO-8601 with an offset: use that instant.
- ISO-8601 without an offset: read the clock in `timezone`.
- A date only, `DD/MM/YYYY` when `date_order` is `DMY`, or `YYYY-MM-DD`: midnight at the start of that date in `timezone`, and flag `as_of_date_only`.
- Blank, and the column is configured: `bad_as_of`. Do not substitute `received_at`.
- Column not configured: `as_of` is `received_at`, flag `as_of_from_receipt`.
- Anything else: `bad_as_of`.

If the instant is more than 15 minutes in the future relative to the run clock, `bad_as_of`.

Store `as_of` in ISO-8601 with the offset of `timezone`.

Age for staleness is `run_clock - as_of`, in hours. If age is greater than `max_age_hours`, flag `stale_as_of`. Compare instants, not calendar labels. `2026-10-14T00:30:00Z` is `2026-10-14T08:30:00+08:00` in `Asia/Kuala_Lumpur`.

---

## 6. Rows

Each data row becomes one line. Validation may null fields afterwards. It must not invent a SKU or a quantity.

| Field | Rule |
| --- | --- |
| `line_no` | Integer from 1, in file order, skipping nothing |
| `sku_raw` | Trimmed cell. Empty is `missing_sku` |
| `sku_id` | From section 4.2, or null |
| `supplier_id` | From the supplier cell via `suppliers.csv`, or from the source’s `supplier_id` when the column is blank. For `supplier_available`, null supplier is `supplier_missing` |
| `location_id` | From the location cell, or from the source’s `location_id` |
| `on_hand_raw` | The quantity cell exactly as read |
| `on_hand` | Decimal string after section 5.4 and, when a unit factor applies, section 4.4. Otherwise null |
| `unit_raw` | Trimmed unit cell, or `default_unit` |
| `unit` | Canonical unit after a successful map, else null |
| `as_of` | From section 5.5 |
| `source_id` | The source |
| `role` | The source’s role. Copied, never inferred from the filename |
| `flags` | Reason codes. Empty when the line is clean |

`warehouse_on_hand` may have a blank `supplier_id`. That blank stays blank. Do not fill it from a supplier you have seen on another row of the same SKU.

---

## 7. Validation

Implement this in `validate.ts`. Do not ask a language model to decide it.

Set the line to `needs_human` if any flag here is present. A line with no flags is `ok`.

| Flag | When |
| --- | --- |
| `missing_sku` | `sku_raw` empty |
| `sku_not_in_master` | No exact master hit and no crosswalk hit |
| `sku_inactive` | Master row exists and `active` is `no` |
| `missing_qty` | Quantity cell blank |
| `bad_number` | Quantity cell does not parse |
| `negative_stock` | Parsed quantity < 0 |
| `unit_unknown` | No `unit-map` row for `unit_raw` |
| `unit_mismatch` | Mapped unit is not `canonical_unit`, or two observations that would share a position key have different canonical units |
| `location_unknown` | Code not in `locations.csv`, or no location at all |
| `supplier_missing` | `supplier_available` and no supplier |
| `supplier_unknown` | Code not in `suppliers.csv` |
| `bad_as_of` | Timestamp unusable, or more than 15 minutes in the future |
| `as_of_date_only` | Informational. Does not by itself force `needs_human` |
| `as_of_from_receipt` | Informational. Does not by itself force `needs_human` |
| `stale_as_of` | Older than `max_age_hours` |
| `duplicate_sku` | Inside one file, two data rows share `sku_raw` + location key + `unit_raw` + supplier key. Both rows, even if the quantities are equal. The location key is the cell, or the source location when the column is blank. Same for supplier |

Source-level statuses, which are not line flags: `missing_file`, `blank_export`, `bad_header`, `multiple_sheets`, `file_too_large`, `unsupported_attachment`, `not_an_inventory_file`, `duplicate_file`.

A source with any of those statuses contributes no `ok` lines. `duplicate_file` contributes no new lines; the earlier snapshot remains the one already stored, and this run does not read it as new evidence. The report’s source-health row still says `duplicate_file`.

### 7.1 Positions

A position is one stock fact the planner can read. Build positions only for lines that resolved to a `sku_id` and a `location_id`.

Group key, before the unit is considered: `role` + `sku_id` + `supplier_id` + `location_id`.

`supplier_available` and `warehouse_on_hand` never share a group. A portal saying 200 kg and a warehouse saying 80 kg are both kept.

Inside a group:

1. If any line in the group is `needs_human`, the position is `needs_human`, `on_hand` is null, and `flags` collects that group’s flags. A clean line does not outvote a stale, negative, duplicate, unknown-unit, or unmapped line for the same stock fact. The clean quantity stays on its own line in `inventory-lines.csv` so the planner can see it. It does not become the position.
2. Otherwise every line is `ok`. They must share one `unit` and one `on_hand`.
   - Same unit and same `on_hand`: the position is `ok`. `on_hand` is that quantity once. `source_ids` lists every source. `as_of` is the oldest of the lines.
   - Same unit and different `on_hand`: the position is `needs_human`, flag `qty_conflict`, `on_hand` null. Do not average. Do not sum. Do not keep the newest.
   - Different units: the position is `needs_human`, flag `unit_mismatch`, `on_hand` null.

A `needs_human` line with no `sku_id` does not join a group. It is only an exception. It must not block a different SKU.

### 7.2 Report status

`needs_human` when any source status is one of `missing_file`, `blank_export`, `bad_header`, `multiple_sheets`, `file_too_large`, `unsupported_attachment`, or `not_an_inventory_file`, or any line is `needs_human`, or any position is `needs_human`.

`duplicate_file` means the bytes were already parsed. Reuse that snapshot’s lines for this run. Source health says `duplicate_file`. Those reused lines follow the same position rules. `duplicate_file` by itself does not force `needs_human`.

`ready_for_review` only when none of the conditions above fired.

`ready_for_review` still needs a person. It means the form is complete.

---

## 8. Report files

Write a new folder each run. Do not overwrite a previous run.

```text
{out_dir}/INV-{YYYYMMDD}-{HHmm}/
  report.json
  inventory-lines.csv
  positions.csv
  exceptions.csv
  source-health.csv
```

`INV-` uses the run clock in `timezone`. Example shape: `INV-20261014-0900`.

### 8.1 `report.json`

```json
{
  "report_id": "INV-20261014-0900",
  "company_name": "",
  "timezone": "Asia/Kuala_Lumpur",
  "currency": "MYR",
  "run_clock": "2026-10-14T09:00:00+08:00",
  "status": "ready_for_review",
  "version": 1,
  "counts": {
    "sources": 0,
    "lines_ok": 0,
    "lines_needs_human": 0,
    "positions_ok": 0,
    "positions_needs_human": 0
  },
  "acceptance": null,
  "reorder": { "drafted": false, "path": null },
  "erp": { "writes": 0 }
}
```

`acceptance` stays null until section 10. `erp.writes` stays 0 in version 1.

### 8.2 `inventory-lines.csv`

Every parsed line, including flagged ones. This is the standardised snapshot. Columns:

```text
report_id,line_no,source_id,role,sku_id,sku_raw,supplier_id,location_id,on_hand,on_hand_raw,unit,unit_raw,as_of,status,flags
```

`flags` is a `|` separated list.

Do not omit a flagged line. The planner has to see the row that failed.

### 8.3 `positions.csv`

```text
report_id,role,sku_id,supplier_id,location_id,on_hand,unit,as_of,source_ids,status,flags
```

`source_ids` is `|` separated. For a conflict, `on_hand` is empty, not zero, and `source_ids` still lists the sources that disagreed.

### 8.4 `exceptions.csv`

Lines and positions where status is `needs_human`, plus one row per source that is not healthy.

```text
report_id,kind,source_id,sku_id,location_id,status,flags,detail
```

`kind` is `line`, `position`, or `source`.

`detail` for a `qty_conflict` lists each source and its quantity, for example `EXAMPLE-ERP=40; EXAMPLE-MAIL=55`. That string is how the planner sees the disagreement. It is not a third number.

### 8.5 `source-health.csv`

```text
report_id,source_id,kind,role,status,filename,sha256,received_at,line_count
```

One row per active source, every run.

---

## 9. Notify the planner

Send one plain-text mail to `planner_inbox` after the folder exists.

Subject: `Inventory {report_id} {status}`

Body, in this order:

- Company name, report id, run clock, timezone
- Counts from `report.json`
- The path of the folder
- Up to 15 exception lines. If there are more, say how many were left off and point at `exceptions.csv`
- The sentence: `This report is not an order. Reply is not acceptance. Accept it in the review page.`

Do not attach the full line file if it is larger than 1 MB. The path is enough. Do not cc a supplier. Do not cc a customer.

If the mail fails, keep the files, set nothing to accepted, and exit non-zero. Do not retry by writing a second report folder.

---

## 10. Acceptance

Build a minimal review page, or a single JSON drop the planner saves. A spreadsheet edit is not acceptance.

### 10.1 Queue

Show the latest report first. For that report, show source health, then exceptions, then positions. `needs_human` rows are above `ok` rows.

The original filename and the sha256 are on the page so the planner can open the file that was read.

### 10.2 Decision

`POST /review/:report_id/decision`

```json
{
  "reviewer_name": "Farah Idris, inventory planner",
  "decision": "accepted",
  "compared_exceptions": true
}
```

Accept only when all of these hold:

1. `reviewer_name` equals `planner` or, when `backup_planner` is not `none`, equals `backup_planner`. Match the full string, after trim.
2. `decision` is `accepted` or `rejected`.
3. For `accepted`, `compared_exceptions` is true. The checkbox label is: `I reviewed every exception. I understand flagged lines will not be reordered.`
4. The report folder still contains the same `report.json` status and the same line-file sha256 you stored at send time. If a file in the folder changed, refuse.

Write `acceptance.json` into the report folder:

```json
{
  "report_id": "INV-20261014-0900",
  "reviewer_name": "",
  "decision": "accepted",
  "compared_exceptions": true,
  "decided_at": "2026-10-14T11:00:00+08:00"
}
```

`decided_at` is the clock in `timezone`.

`rejected` requires a reason string of at least 3 characters, stored as `reason`. A rejected report produces no reorder draft.

The agent does not post this decision itself. There is no path where the process that built the report also accepts it.

---

## 11. Reorder drafts

Run this only when `reorder_drafts` is `yes` and `acceptance.json` says `accepted` for this `report_id`.

Write `{report folder}/reorder-draft.csv`. Columns:

```text
report_id,sku_id,location_id,supplier_id,on_hand,unit,min_on_hand,suggested_qty,role,state,policy_note
```

For each `ok` position with `role` `warehouse_on_hand`:

- No row in `reorder-policy.csv` for that `sku_id` and `location_id`: skip. Do not invent a minimum.
- `on_hand` greater than or equal to `min_on_hand`: skip.
- `on_hand` less than `min_on_hand`: one draft row. `suggested_qty` is `reorder_qty` copied from the policy. `state` is `draft`. `supplier_id` is the position’s supplier id, which may be blank. `policy_note` is `below_min`.

Do not suggest from `supplier_available`. A supplier having 200 kg is not a reason to buy, and it is not a reason to skip a buy. The draft looks only at warehouse on-hand.

Do not suggest from a `needs_human` position. A conflict, a stale line, a negative, a missing SKU, and an unknown unit produce no suggestion for that key.

Do not multiply `suggested_qty` by packs, price, or days of cover. Do not convert it to cartons in this file.

If nothing qualifies, still write the file with the header only.

Set `report.json` `reorder.drafted` true and `reorder.path` to that file.

The draft is not an order. Do not email it to a supplier. Do not copy it into an ERP import folder. The planner reads it. Version 1 ends there.

If `reorder_drafts` is `no`, do not write the file, even after acceptance.

---

## 12. ERP client

Ship a stub. It is the only HTTP target in version 1.

`POST {erp_base_url}/v1/stock-adjustments`

`POST {erp_base_url}/v1/purchase-orders`

Headers the caller might send:

```text
Authorization: Bearer {ERP_TOKEN}
X-Report-Id: {report_id}
X-Human-Approval: {reviewer_name}
```

The stub writes no stock and creates no purchase order. It returns JSON and an HTTP status.

Return **409** with `{"error":"missing_approval"}` when `X-Human-Approval` is missing or blank, or when no `acceptance.json` for that report id has `decision` `accepted`.

Return **409** with `{"error":"v1_no_live_write"}` when an acceptance exists and `erp_write` is `off`.

Return **409** with `{"error":"v1_no_live_write"}` when an acceptance exists and `erp_write` was set to anything else. Version 1 has no success status. `live_enabled_by` does not change this. A person who wants a later version to post will need a new brief. This one does not contain that POST.

On every call, append one line to `{out_dir}/erp-attempts.log`:

```text
time,report_id,path,http_status,error
```

Do not log the bearer token.

`erp.writes` in `report.json` stays 0. A 409 is an attempt, not a write.

The test in section 14 calls the stub. Production code calls the stub only if something tries to write. The report job itself must not call it.

---

## 13. Schedule

Install `schedule_cron` in `timezone`. A Malaysia business uses `Asia/Kuala_Lumpur`. Do not install a second job at UTC that happens to look similar.

The job runs section 5 through section 9. It does not run section 10. It does not run section 11 unless a previous report is already accepted and the business has asked for a separate “draft from the accepted report” command. The scheduled job never accepts and never drafts on its own in the same breath as reading the files. Drafts happen when a person accepts.

A manual run uses the same code and the same required keys. It does not skip validation because someone is watching.

Record `run_clock` from the system clock in `timezone` at the start. Tests inject `run_clock`. Production does not let a file’s timestamp become the run clock.

Suggested cadence, which the business must type into `schedule_cron` itself: twice on weekdays, morning and afternoon. That suggestion is not Pastry Pro’s schedule. Their articles say reporting was periodic and that the manual report was repeated through the day. They do not print a clock time.

---

## 14. Acceptance tests

Run these before the first scheduled hour. They call parse, validate, report, accept, reorder, and the ERP stub. They do not call a language model. They do not open a network socket except to the stub on localhost.

The fixture clock is `2026-10-14T09:00:00+08:00`. Timezone is `Asia/Kuala_Lumpur`. Currency is `MYR`. `date_order` is `DMY`. `erp_write` is `off`. `reorder_drafts` is `yes` only in the tests that say so. Otherwise `no`.

Shared master data, all of it `EXAMPLE`:

```text
sku_id,name,canonical_unit,active
EXAMPLE-FLOUR,EXAMPLE bread flour,kg,yes
EXAMPLE-YEAST,EXAMPLE instant yeast,kg,yes
EXAMPLE-HONEY,EXAMPLE honey,kg,yes
EXAMPLE-OLD,EXAMPLE retired syrup,kg,no
```

```text
source_id,external_sku,sku_id
EXAMPLE-PORTAL,MILL-A-BF-25,EXAMPLE-FLOUR
```

```text
from_unit,to_unit,factor
g,kg,0.001
KG,kg,1
kg,kg,1
```

```text
location_id,name,active
EXAMPLE-WH,EXAMPLE warehouse,yes
```

```text
supplier_id,name,active
EXAMPLE-SUP-A,EXAMPLE Mill A,yes
```

```text
sku_id,location_id,min_on_hand,reorder_qty
EXAMPLE-FLOUR,EXAMPLE-WH,50,100
EXAMPLE-YEAST,EXAMPLE-WH,5,10
```

Sources for the happy path:

```text
source_id,kind,role,supplier_id,location_id,pickup,filename_glob,sheet_name,sku_column,qty_column,unit_column,default_unit,location_column,as_of_column,supplier_column,number_style,max_age_hours,active
EXAMPLE-ERP,erp_csv,warehouse_on_hand,,EXAMPLE-WH,./inbox/erp,*.csv,,Item Code,Qty,UOM,,Warehouse,As Of,,dot_decimal,36,yes
EXAMPLE-PORTAL,portal_export,supplier_available,EXAMPLE-SUP-A,EXAMPLE-WH,./inbox/portal,*.csv,,SKU,Stock,Unit,,,Report date,,dot_decimal,72,yes
EXAMPLE-MAIL,email_attachment,warehouse_on_hand,,EXAMPLE-WH,./inbox/mail,*.csv,,sku,on_hand,unit,,location,as_of,,dot_decimal,36,yes
```

`number_style` on the portal fixture is `dot_decimal` unless a test says `comma_decimal`.

| ID | Fixture | Pass when |
| --- | --- | --- |
| T1 | ERP rows: `EXAMPLE-FLOUR,80,kg,EXAMPLE-WH,2026-10-14T08:00:00+08:00` and `EXAMPLE-YEAST,4,kg,EXAMPLE-WH,2026-10-14T08:00:00+08:00` and `EXAMPLE-HONEY,12,kg,EXAMPLE-WH,2026-10-14T08:00:00+08:00`. Portal row: `MILL-A-BF-25,200,kg,2026-10-14T07:30:00+08:00`. Mail row: `EXAMPLE-HONEY,12,kg,EXAMPLE-WH,2026-10-14T08:10:00+08:00`. All three files present. | Report `ready_for_review`. Flour warehouse position `80` kg from `EXAMPLE-ERP` only. Flour supplier_available position `200` kg from `EXAMPLE-PORTAL`. Those two are not added to `280`. Yeast warehouse `4`. Honey warehouse `12` with both source ids, because they agree. `as_of` on honey is the older of the two agreeing lines. No `reorder-draft.csv`. Stub not called. `erp.writes` is 0. |
| T2 | Portal file only, one row `MILL-A-BF-25,200,kg,2026-10-10T07:30:00+08:00`. `max_age_hours` 72. Run clock as above. The other sources are `active` `no` for this test. | Line flag `stale_as_of`, status `needs_human`. No `ok` position for that flour. Report `needs_human`. |
| T3 | One ERP file, two rows, same SKU, location, and unit: flour `80` and flour `75`. | Both lines `duplicate_sku` and `needs_human`. The flour position, if written, has empty `on_hand`. It is not `80`, not `75`, and not `155`. Report `needs_human`. |
| T4 | Portal file whose entire contents are the header `SKU,Stock,Unit,Report date` and nothing else. | Source status `blank_export`. Zero inventory lines. No zero quantity written for `EXAMPLE-FLOUR` or any other master row. Report `needs_human`. |
| T5 | ERP row flour `10` kg. Mail row flour `10` with `unit_raw` `carton`. No carton row in `unit-map.csv`. Other sources inactive. | Mail line `unit_unknown`, `on_hand` null. Flour position `needs_human`, `on_hand` empty. The ERP’s `10` stays on the ERP line and does not become the position. Nothing converts carton to kg. Report `needs_human`. |
| T6 | ERP row yeast `-3` kg, fresh timestamp, unit kg. Other sources inactive. | Line flag `negative_stock`. Line `on_hand` stays `-3`, not `0`. Position `on_hand` empty. Status `needs_human`. Accepting the report does not put yeast on the reorder draft. |
| T7 | ERP rows: a blank SKU cell with qty `5`, and SKU `NOT-A-REAL-CODE` with qty `5`. Master name `EXAMPLE bread flour` is not used. | Both lines `needs_human` with `missing_sku` and `sku_not_in_master`. Neither line receives `EXAMPLE-FLOUR`. |
| T8 | ERP flour `40` kg and mail flour `55` kg, same location, same fresh timestamps, unit kg. | Position `qty_conflict`, `on_hand` empty, not `47.5` and not `95`. Exception detail contains both quantities. |
| T9 | The T1 report, no `acceptance.json`. Client calls both stub paths with an empty approval header. | Each response is HTTP 409 `missing_approval`. `erp-attempts.log` has those attempts. No stock file changes. No purchase-order file appears. `reorder-draft.csv` does not exist. |
| T10 | Accept T1 with `reviewer_name` equal to `planner`, `compared_exceptions` true, `reorder_drafts` `yes`. Then call both stub paths with that reviewer name. | `acceptance.json` stored. `reorder-draft.csv` contains yeast only: on-hand `4`, min `5`, suggested qty `10`, state `draft`. Flour `80` is not suggested (min is `50`). Honey is not suggested (no policy, and `12` would not be invented). Stub returns 409 `v1_no_live_write` on both paths. `erp.writes` stays 0. |
| T11 | `planner` blank. Separately: `timezone` blank. Separately: `currency` blank. Separately: `currency` `SGD`. Separately: `out_dir` blank. Separately: `company_name` blank. Separately: `sources.csv` with no active row. | Each case writes no report folder and reads no inventory file. The error names the missing piece. `SGD` is refused because version 1 is `MYR` only. |
| T12 | The same ERP bytes dropped twice, same hash. | One snapshot. The second run records `duplicate_file` and does not create a second set of lines that would double `80` into `160`. |
| T13 | ERP quantity cell `1000` and unit `g` for flour, with the gram row in `unit-map.csv`. Second case: delete the gram row and repeat. | First case: line `on_hand` `1`, unit `kg`. Second case: `unit_unknown`, `on_hand` null, not `1000` and not `1`. |
| T14 | Mail source receives a message with a PDF and no CSV. No other mail file. | Source `unsupported_attachment` or, if you only count CSV and find none, `missing_file`. No lines. Not a zero balance. |
| T15 | Yesterday’s accepted report had flour warehouse `80`. Today the ERP source has no file. Portal and mail inactive. | Today’s source status `missing_file`. Today’s positions do not contain `80`. The word of yesterday is not copied forward. |
| T16 | Accept T1 with `reviewer_name` `Someone Else` or with `compared_exceptions` false. | No `acceptance.json`. No reorder draft. |
| T17 | One file, two flour rows, both `80` kg, same location and unit. | Both lines `duplicate_sku`. No position. Equal quantities are still duplicates. |
| T18 | `number_style` `comma_decimal`. Quantity cell `1.234,5` kg flour. Second file, `dot_decimal`, cell `1,234.5`. | Both lines `on_hand` `1234.5`, unit `kg`. A third cell `1,234.5` read as `comma_decimal` becomes `1.2345` (dots removed, then the comma is the decimal mark). It must not become `1234.5`. |
| T19 | Quantity cell blank, and another row whose cell is `0`. | Blank is `missing_qty`, `on_hand` null. `0` is an `ok` zero if the SKU, unit, location, and time are otherwise clean. The zero may later draft a reorder if it is below `min_on_hand` and the report is accepted. The blank never does. |
| T20 | A timestamp `2026-10-14T00:30:00Z` on an otherwise clean ERP flour row. `max_age_hours` 36. | Stored `as_of` is `2026-10-14T08:30:00+08:00`. The line is not stale at a 09:00 run. |

Also run once on the real folders, with files you wrote yourself, not a supplier’s live extract:

- Dropping a CSV in the ERP folder creates one report folder and one mail.
- Dropping it again does not double the quantities.
- An empty CSV creates a `blank_export` row and does not zero the catalogue.
- The review page shows the exception before the clean rows.
- Accepting with the planner’s name writes `acceptance.json` and, when drafts are on, a draft file.
- The stub log shows 409 and the warehouse file is unchanged.

Go live with the cron only after T1–T20 pass and `planner` has seen that check. `erp_write` stays `off`.

---

## 15. Rollout

**Week 1.** Masters, crosswalk, and unit map exported. `EXAMPLE` rows deleted. `reorder_drafts` is `no`. `erp_write` is `off`. Run the job by hand on five mornings of files the planner already turned into a report. Each morning they mark the new report “I would have sent this” or “I had to throw out X”. Write down how many lines were `needs_human`, how many positions were conflicts, and how many sources were blank or missing.

**Week 2.** Turn on `schedule_cron` during office hours. The planner accepts or rejects the same day. Still no reorder file. Count how often a source that “should have arrived” was `missing_file`. Fix the pickup path before you trust the number.

**Week 3.** If week 2’s conflicts are understood, set `reorder_drafts` to `yes`. The planner accepts a report, then compares the draft to the order they would have typed. A suggestion they would not buy is a reason to edit `reorder-policy.csv`, not a reason to let the file post itself.

**Week 4.** Leave the stub in place. Do not point it at the ERP. The win in version 1 is a report the planner can accept, with blanks kept blank and disagreements kept visible.

Stay on a week if a test fails or if a position appears that nobody can trace to a file. A complete-looking wrong balance is worse than a report that waits.

Numbers to write down from your own desk, before week 1 and again after 20 accepted reports:

- Minutes a person still spends checking the report
- Share of lines that were `needs_human`
- Count of `blank_export` and `missing_file` sources
- Count of `qty_conflict` positions
- Count of ERP writes (the acceptable count is zero)
- Count of purchase orders this job created (the acceptable count is zero)

Do not put Pastry Pro’s 90%, 99%, or 10 minutes in that sheet as the target.

---

## 16. What you do not copy

Pastry Pro’s published results are theirs.

FPT says akaBot automated 90% of Pastry Pro’s inventory monitoring processes. AkaBot’s article of 19 June 2025 says 90% of the inventory tracking process. AkaBot’s article of 3 April 2026 says up to a 90% reduction in processing time for inventory reporting, and accuracy of approximately 99%. The April article also says a manual report took around 10 minutes and was repeated through the day. The case-study landing prints “10 minutes” next to “Thousands of suppliers”. The company’s own about page says more than 40 global partners, not thousands.

Do not merge those sentences into one forecast. Do not advertise 99% accuracy, a 90% time cut, or a 10-minute baseline for the reader of this file. Do not invent a ringgit saving, a headcount, or a payback period. No source here published those.

Do not copy the portal login. Copy the loop: take the files the business already receives, standardise the row, stop when the SKU or the unit or the timestamp is not proven, show the disagreement instead of averaging it, let a named planner accept, and only then write a draft that still is not an order.

The first win is a morning report where a blank portal cannot zero the warehouse, and a stale file cannot buy anything.
