# Business Rules

## Processing order

1. Parse the fixed `RES_DETAIL` XML and require exactly one XML business date.
2. Create one in-memory source record for every reservation in XML order; no later stage may discard that audit record.
3. Require every reservation to contain `RATE_CODE` so whitelist membership is knowable.
4. Classify rows whose trimmed, uppercased `RATE_CODE` is not in the whitelist as `excluded_rate_code`.
5. Validate required fields on the remaining rows and classify invalid rows as `validation_failed`.
6. Deduplicate remaining valid rows by `DISP_ROOM_NO + ARRIVAL`; retain the first XML occurrence and classify later occurrences as `duplicate` with a pointer to the first source sequence.
7. Preserve the relative XML order of all retained rows and compute static non-negative integer `NIGHTS = DEPARTURE - ARRIVAL`. Same-day arrival and departure is a valid zero-night record; a departure before arrival is invalid.
8. Validate the entire bundled price table, then apply the approved zero-price exception or match and write static daily `REAL PRICE`; calculate static daily `TOTAL PRICE = REAL PRICE * NO_OF_ROOMS * NIGHTS`. Classify an unmatched row as `price_unmatched`.
9. Assign deterministic channel keys and conditional KB values, then generate the daily workbook. Only legacy `daily-monthly` mode also routes the same in-memory rows into a monthly workbook.
10. Write `result.json` and the all-source-record `structured-result.json`, then independently validate every requested XLSX output, artifact hash, derived key, outcome reconciliation, and structured payload before reporting success.

Any row-level validation or price error fails the formal invocation. Rows that were valid candidates but could not reach a fully validated formal result become `validation_failed` with `BATCH_NOT_VALIDATED`; filtered, duplicate, and price-unmatched conclusions remain unchanged in the failed structured payload.

## Rate-code whitelist

`GRPA1`, `GRPA2`, `GRPA3`, `GRPA4`, `GRP1`, `WHO1`, `WHO2`, `WHO3`, `WHO4`, `LTLT`, `LBLT`, `LBSM`, `LBMS`, `LBW1`, `LBKB`, `LBLS`, `WHKR2100B`, `GL2100B`, `GL2200KR`, `GLSPCB`.

## Multi-value text

For reservation-level `RES_COMMENT` and `TRACE_TEXT`, take the first non-empty value in XML order. Leave blank when every candidate is empty. Never concatenate values or expand one reservation into multiple rows.

## Price matching

Use the bundled latest `references/价格对照.xlsx` as a fixed, non-user-editable rule source.

- Normalize company identity on both the price-table side and the XML/output side before price matching or standard-channel routing:
  1. trim, uppercase, and remove every space and punctuation character, retaining only `A-Z` and `0-9`;
  2. use fixed substring keywords, not edit distance, to recognize `LIANTAI → LIAN TAI`, `QBD → QBD`, `RAINBOW → RAINBOW/AI`, `FENGRUN → FENGRUN`, `HANATOUR` or `HANA → HANA TOUR`, `HONGTAI → HONGTAI`, and `GUANGZHOUGOEASY` or `GOEASY → GUANGZHOU GO EASY`;
  3. if no keyword group matches, use the compact normalized name as an exact identity;
  4. if more than one keyword group matches, fail rather than guessing.
- Match normalized `COMPANY_NAME + RATE_CODE + EFFECTIVE_RATE_AMOUNT` to normalized price-table `COMPANY'S NAME + RATE CODE + Opera展示的价格`.
- Trim and uppercase rate codes.
- Compare price amounts numerically, so `900` equals `900.00`.
- Reject negative Opera display amounts or total prices in the fixed price table.
- Before the three-key lookup, write numeric `REAL PRICE = 0` when and only when both conditions hold:
  - normalized company identity is `RAINBOW/AI` or `GUANGZHOU GO EASY`; and
  - normalized `RATE_CODE` is `LBMS` or `LBSM`.
- The zero-price exception ignores `EFFECTIVE_RATE_AMOUNT`. It writes daily `REAL PRICE = 0` and therefore daily `TOTAL PRICE = 0`; the monthly row receives the same two numeric zeros. Other rate codes for those companies still require an exact normalized three-key price match.
- Write the matched price-table `总价` directly as daily `REAL PRICE`, then calculate daily `TOTAL PRICE = REAL PRICE * NO_OF_ROOMS * NIGHTS`.
- Validate the complete normalized three-field price key before processing. Any duplicate, including same-total duplicates unused by the current XML, fails the entire run.
- Any unmatched retained reservation fails the entire run.

## Channel routing

Standard routing uses the same normalized company identity described above. The original XML `COMPANY_NAME` remains unchanged in output cells.

| Condition | Worksheet |
|---|---|
| Normalized identity `QBD` | `QBD` |
| Normalized identity `LIAN TAI` and `RATE_CODE` in `{LBLT, LTLT}` | `LIANTAI-FIT` |
| Normalized identity `LIAN TAI` and other whitelisted `RATE_CODE` | `LIANTAI-GROUP` |
| Normalized identity `RAINBOW/AI` or `GUANGZHOU GO EASY` (all rate codes) | `DY-AI-Easy-KB` |
| Normalized identity `FENGRUN` | `FENGRUN` |
| Any other company | Sanitized actual company name |

For other companies, trim the name, remove `: \\ / ? * [ ]`, and truncate to 31 characters. Reuse the existing sheet for the same actual company. If a different company collides after sanitizing/truncation, append `-2`, `-3`, and so on while shortening the base to keep the result at most 31 characters.

## Business date and filenames

- Derive the date from the XML arrival/group date, not the runtime clock or upload filename.
- Daily filename and sheet name: `M.D.xlsx` and `M.D` (for example `7.20.xlsx`).
- Month key: XML `ARRIVAL` year and month.
- Monthly filename: `各渠道情况-YYYY年MM月-更新至M.D.xlsx`.
- `更新至` is the maximum `ARRIVAL` present anywhere in the monthly workbook, so an older backfill cannot move the filename backwards.

## Monthly update

These rules apply only to legacy `daily-monthly` mode. The `daily` database-ingestion mode never reads or writes a monthly workbook.

- When no monthly base is supplied, start from the bundled blank monthly template.
- A supplied monthly base must already follow the current 19/20-column contract. Legacy 18-column monthly workbooks and the transitional 19-column KB layout are rejected rather than upgraded.
- Keep different natural months in different workbooks.
- Before inserting the current date, delete that `ARRIVAL` from every worksheet.
- Insert the newly routed rows, then stably sort each sheet by `ARRIVAL` ascending.
- Preserve original daily/XML order within the same date.
- Preserve all other dates and their within-day order.
- Existing sheets retain their formatting. New company sheets copy the standard channel-page header and style.
- In every monthly row, write static `REAL PRICE = daily REAL PRICE` and static `TOTAL PRICE = daily TOTAL PRICE`. Independently enforce `TOTAL PRICE = REAL PRICE * NO_OF_ROOMS * NIGHTS` in both reports.
- Only `DY-AI-Easy-KB` has `KB（100/晚/间）`; write it as static `NO_OF_ROOMS * 100`. Do not multiply KB by `NIGHTS`, and do not include KB in `TOTAL PRICE`.

## Output types and formatting

- Use the daily and monthly templates independently; never copy columns by position between them.
- Headers are in row 1 and data starts in row 2.
- Store `ARRIVAL` and `DEPARTURE` as real Excel dates with display format `DD-MMM-YY`.
- Store adults, children, room count, nights, rates, real prices, totals, and KB as numbers.
- Store confirmation numbers, room numbers, codes, names, comments, and other identifiers as text.
- Write `NIGHTS`, `REAL PRICE`, `TOTAL PRICE`, and `KB（100/晚/间）` as static values, never formulas.
- Require `ADULTS >= 0`, `CHILDREN >= 0`, `NO_OF_ROOMS > 0`, `EFFECTIVE_RATE_AMOUNT >= 0`, and `DEPARTURE >= ARRIVAL` on every whitelist candidate. `DEPARTURE = ARRIVAL` produces `NIGHTS = 0`; `DEPARTURE < ARRIVAL` is invalid.
- Preserve complete comment/trace text and leave allowed empty values blank.
- Do not add filters, freezes, colors, or decorations absent from the templates.
