# Module 6 — Day Close & Daily Sales Report (DSR)

> **KB version:** 2.0 · **Code baseline:** `b71c156` · **Last reviewed:** 2026-10-26
> **Epic:** EP-TBD · **Status:** Pending PO review
> **Pending PO review:**
> - Non-overridable "undelivered external order" day-close guard — confirmed to include `cancelled` orders with no exclusion or escape hatch; see BL-12 in `ISSUES_TRACKER.md` and V-01 in `KB_UPDATE_REPORT.md`.
> - Items DSR per-item value summed from each order line's own stored price.
> - Add-on lines are Items DSR item rows in their own right, listed under their own add-on menu category and never folded into the meal they were sold with.
> - A customised meal's Items DSR value includes its ingredient-replacement extra charges, so two lines of the same item in one window can carry different values.
> - `orders.count_of_items` counts add-on lines, so a meal plus two paid add-ons is stamped as three items.
> **Related flags:** TBD (requires As-Built Spec cross-reference, out of scope this pass) · **Open decisions:** TBD (requires As-Built Spec OD register, out of scope this pass)

---

## Module Overview
- **Purpose:** End-of-day closing: freeze the day's orders, produce printable sales & item reports, reset order numbering.
- **Business Goal:** Daily branch reconciliation (cash-up) by payment method, order type and item mix; establish the immutable "business day" used by all head-office reporting.
- **Main Actors:** Cashier (closes day, prints), Admin/Superadmin (may also access; role list includes them).
- **Dependencies:** Orders, Payment Types, Order Types, Items/Categories, Dashboard settings (company name), PDF engine.
- **Entry Points:**
  - `GET /v1/pos/daily-sales/close-day` (with `close_day=0` preview / `close_day=1` commit, optional `close_orders=1` override)
  - `GET /v1/pos/daily-sales/list`, `GET /v1/pos/daily-sales/print/{id}`

## Business Workflow
1. Cashier requests day close. Scope = the branch's orders with `day_closed=0`, `closed_at IS NULL`, `related_date IS NULL`.
2. **Guard:** if committing (`close_day=1`) and there are still `active` (unpaid) orders and no override flag, the close is refused: *"There is many active orders, please complete them first."* The cashier must complete/cancel them or pass `close_orders=1` to force.
2b. 🚩 **SPRINT 1 · 09-08-2026 (2026-07-15):** A **second, non-overridable guard**: committing is also refused while any order is still `inReview` (unapproved platform orders): *"There are pending external orders in review, please approve or reject them first."* Unlike the active-orders guard, **`close_orders=1` does not bypass this one** — sweeping an unapproved mobile order into a closed day used to lose the order and leave the customer's app without an outcome; that is no longer possible.
2c. 🚩 **Draft · 2026-09-16:** A **third guard**, also non-overridable: committing is refused while any in-scope order has an external-order row with `is_delivered = 0` — *"There are many mobile orders that have not been marked as delivered. Please mark them as delivered so you can close the day."* This checks ALL external orders in scope regardless of financial status, not just `inReview` ones — so an *approved* delivery order that Noon hasn't finished (or that the cashier hasn't manually marked delivered) also blocks day close. The automatic-deliver-on-webhook behavior (Module 8) reduces how often cashiers hit this manually.
3. If there are no open orders at all system-wide → "There is no open orders".
4. The report window is **opened-at = created time of the earliest open order** to **closed-at = now** (both Dubai time). `related_date` := the open date (Y-m-d) — this becomes the business-day key.
5. Only **completed** orders feed the sales figures; **cancelled** orders are shown as a separate count/value line; `active/returned/inReview` orders are excluded from figures but still get stamped closed.
6. Two PDFs (65×250 mm) are rendered and stored (`dsr_pdf/{Y}/{m}/branch_{id}/…`): the **Sales DSR** (totals, order types, payments) and the **Items DSR** (per-item qty/value grouped by category).
7. If committing: two `daily_sales_reports` rows are inserted (items report is a child via `parent_id`, `type='items'`), and **all in-scope orders (any status)** are updated `day_closed=1, closed_at=now, related_date=open-date`. **Added 2026-07-11:** each row also persists a `data` JSON snapshot of the computed figures — the parent (sales) row stores `header/dates/general/order_types/payment`, the child (items) row stores `header/dates/order_items` — so the numbers can be re-served without re-rendering or recomputing.
8. The DSR list shows past parent reports; `print/{id}` returns the stored PDF URL and stamps `last_printed_at`.

## Business Rules (report figure definitions — the reconciliation contract)
For the set **C** = completed open orders of the branch:
- **Gross sales** = Σ(price_before_discount) + Σ(vat); qty = |C|.
- **Gross sales w/o tax** = Σ(price_before_discount).
- **Total cancelled** = count & Σ(final_price) of cancelled open orders.
- **Total discount** = Σ(before − after); qty = number of orders where before > after.
- **Total tax** = Σ(vat).
- **Net sales** = Σ(final_price) − Σ(vat).
- **Guests** = |C| (1 guest per order).
- **Average per order = average per guest** = (Σ final − Σ vat) / |C|.
- **Per order type:** count & Σ(final_price) of completed orders of that type.
- **Per payment method:** from `order_payments` of completed open orders — split payments are attributed to each method by actual amount; count = number of payment rows (not orders); plus `total_payment` = Σ amounts and `net_payment` = Σ amounts − Σ vat. Payment names localized (Arabic name when locale is `ar`).
- **Items DSR:** per item name — qty = Σ line qty, value = **Σ (each order line's own stored `order_items.item_price` × that line's qty)**, grouped by the item's (first non-custom) category. Because this sums every line individually, lines of the same item priced differently within the window (delivery-app price vs. normal price, different payment-surcharge rates, a mid-day price change) each contribute their own actual price, not one price applied to the combined quantity.
- **Items DSR scope — add-on lines are DSR items in their own right:** every `order_items` row of a completed open order feeds the Items DSR. The query joins `order_items` → `items` with **no filter on `order_items.type`**, so an add-on sold with a meal — written as its own `order_items` row with `type = addon` and `parent_order_item_id` pointing at the meal line — appears in the Items DSR as its own item row, grouped under its own menu category (the add-on category `ASP_CAT_1000` or a category below it), **not** folded into the meal it was sold with. `order_items.type` is stored on every line but no DSR figure reads it, so nothing in either report distinguishes a meal line from an add-on line.
- **A customised meal's Items DSR value includes its replacement extra charges.** Each line's stored `order_items.item_price` is the item's price for the selected price mode **plus the sum of the chosen replacements' `extra_charge` plus that line's payment-surcharge share**. Because the Items DSR sums `item_price × qty` per line, a line where the cashier swapped an ingredient contributes **more** than the item's menu price × qty, and two lines of the same item within one window can carry different values. Removing an ingredient never lowers the line price, so removals leave the Items DSR value unchanged.
- Report header shows company name (global setting), branch name, open/close datetimes.

## Hidden Rules
- **Order numbering resets as a consequence of day close** — next order after close finds no open orders and restarts from `order_number_start`+1.
- `related_date` is the **open** date: a shift that runs past midnight books all sales to the day it started.
- The open-at timestamp query originally relied on the **invisible request-bound `BranchScope`** for branch filtering (correct for authenticated cashier requests, but silently cross-branch in console/queued contexts such as a future automatic day close). **Hardened 2026-07-07:** the query now filters `branch_id` explicitly.
- The active-orders guard is buggy: condition `!$request->has('close_orders') && $request->close_orders != 1` means **merely sending `close_orders=0` also bypasses the guard** (any presence of the parameter skips the check).
- Preview mode (`close_day` ≠ 1) still generates and stores both PDFs — storage fills with unprinted previews.
- ~~Orders in `inReview` (unapproved platform orders) get force-closed silently — a pending mobile order can be swept into a closed day and disappear from the external tab without ever being approved or rejected.~~ **Fixed 🚩 SPRINT 1 · 09-08-2026 (2026-07-15)** — day close is now hard-blocked (non-overridable) while any `inReview` order exists; see Business Workflow 2b.
- The items DSR values use each line's stored `order_items.item_price` (Module 4's fee-inflated, delivery-app-price-aware snapshot), so items reports include payment surcharges and delivery-app pricing, while the sales DSR figures don't break fees out at all (the `total_additional_fees` block is commented out).
- `orders.count_of_items` — the per-order item count stamped on the order — **counts add-on lines**: every line priced above 0 is counted, add-on lines included, so a meal sold with two paid add-ons stamps 3. Free lines (price 0) are left out. The DSR's own figures never read this column (the Sales DSR counts orders; the Items DSR sums line quantities), so the stamped per-order count and the Items DSR quantities are computed independently.
- Settings `close_day_automatic` / `automatic_day_end_time` / `close_day_manual` exist in the dashboard **but no scheduler implements them** — day close is manual-only today (Console kernel schedule is empty).
- DSR permission (`dsr.read`) is granted to cashier but **not** chief; the extra role gate also admits admin/superadmin, but those users' branch drives the scope.

## Decision Tree
```
close-day request:
IF close_day == 1:
    IF count(open inReview orders of branch) > 0 → refuse (🚩 SPRINT 1 · 09-08-2026 — NOT overridable by close_orders)
    IF any open order has external data with is_delivered=0 → refuse (🚩 draft · 2026-09-16 — NOT overridable by close_orders)
    IF close_orders param absent AND count(open ACTIVE orders of branch) > 0 → refuse with message
IF no open order exists anywhere → "There is no open orders"
compute window (first open order created_at → now)
C := completed open branch orders
IF C is empty → exception "There is no open orders"
build figures; render Sales PDF + Items PDF
IF close_day == 1:
    insert parent DSR row + child items DSR row (parent_id, type='items')
    UPDATE all open branch orders (any status):
        day_closed := 1, closed_at := now, related_date := open-date
        (each DSR row also stores a `data` JSON snapshot of its figures)
return both PDF URLs + the full figure structure (header/dates/general/order_types/payment/order_items)
```

## Database Understanding
- `daily_sales_reports` — a parent/child pair per close: parent = sales report, child (`parent_id`, `type='items'`) = item mix report; stores the PDF path, window dates, who closed, branch, and `last_printed_at` (reprint audit). **`data` (JSON, nullable, added 2026-07-11)** — snapshot of the computed figures (parent: `header/dates/general/order_types/payment`; child: `header/dates/order_items`). Nullable so **legacy rows predating the column read as `null`** — API resources fall back to the old shape when it is absent.
- `orders.day_closed/closed_at/related_date` — denormalized day-close stamps on each order; `related_date` is *the* reporting dimension joining orders to business days.

## State Machine
Business day (per branch, implicit): `OPEN (has orders with day_closed=0)` → *(cashier close-day)* → `CLOSED (all stamped)`. One-way; no reopen mechanism exists.
DSR row: `created → reprinted (last_printed_at bumped)`.

## APIs
| Endpoint | Purpose | Input | Output | Validation | Errors | Side effects |
|---|---|---|---|---|---|---|
| `GET /pos/daily-sales/close-day` | Preview or commit day close | `close_day` (0/1), `close_orders` (force) | dsr_url + items_dsr_url **+ full figures** (header/dates/general/order_types/payment/order_items), or refusal message | guard above | 500 / message | PDFs always stored; on commit: DSR rows (+ `data` snapshot) + orders stamped |
| `GET /pos/daily-sales/list` | Past reports | — | parent DSRs, newest first — each includes its `data` snapshot and a nested `items_dsr` (id, report_url, data); legacy rows return `data=null` and `items_dsr` as id only | `dsr.read` | 500 | — |
| `GET /pos/daily-sales/print/{id}` | Reprint | id | report URL | id exists | invalid-id msg | last_printed_at updated |

## Edge Cases
- Day with only cancelled orders → C empty → hard exception even though cancelled totals exist.
- Two cashiers closing simultaneously → double DSR rows and double stamping (no lock).
- Close with zero completed orders but active ones + `close_orders=1` → exception (C empty) — force flag can't close a day with no completed sales.
- A branch that never orders is invisible to closing (nothing to stamp).

## Assumptions
- One close per business day per branch; the operator closes before starting next day's sales.
- Thermal printer for DSRs (65 mm), same hardware as invoices.
- "Guests" ≈ orders is an accepted approximation (no covers tracking).

## TODOs
- Implement the automatic close (settings exist, no job).
- Branch-filter the open-at query (cross-branch window bug).
- Fix the `close_orders` guard condition.
- Don't persist preview PDFs, or garbage-collect them.
- ~~Consider blocking `inReview` orders from silent closing.~~ **Done 🚩 SPRINT 1 · 09-08-2026.**

## Open Questions
- Wanted behavior for shifts crossing midnight in reporting (currently: open date).
- Should `returned` orders reduce net sales in the DSR? (Currently excluded entirely.)
- Should the DSR split meals from add-ons? `order_items.type` is stored on every line but no DSR figure reads it, so add-on sales are reported as ordinary item rows under the add-on category. And should ingredient-replacement extra charges be reported as their own figure rather than inside the item's own value, where they are indistinguishable from the item's menu price? Module 4 (M04) raises the same question from the order side.
- 🚩 **Draft · 2026-09-16:** the new `is_delivered=0` close-day block is non-overridable — what's the operational escape hatch if a delivery order can never be marked delivered (e.g. Noon dispatch failed, branch has no linked outlet)? Today the branch simply cannot close its day.

---
