# Business Rules

## Fixed processing order

1. Validate the invocation and parse one fixed `RES_DETAIL` XML.
2. Require exactly one XML business date and keep one audit record for every `G_RESERVATION` in XML order.
3. Require `RATE_CODE` so whitelist membership is knowable.
4. Classify a trimmed, uppercased rate code outside the whitelist as `excluded_rate_code`.
5. Validate every whitelist candidate; classify invalid rows as `validation_failed`.
6. Deduplicate valid candidates by `DISP_ROOM_NO + ARRIVAL`; retain the first XML occurrence and classify later occurrences as `duplicate` pointing to the first source sequence.
7. Compute integer `NIGHTS = DEPARTURE - ARRIVAL`. Zero is legal; negative is invalid.
8. Validate the complete fixed price table, then apply the approved zero-price exception or exact three-key match. Classify unmatched candidates as `price_unmatched`.
9. Assign `channel_key` and conditional `kb_amount` as row facts.
10. If all actionable errors are `PRICE_UNMATCHED`, emit `review_required`: exact fixed-price rows become controlled `candidate` rows, unmatched rows stay explicit, issues are grouped by normalized company, rate code and Opera price, and no XLSX is made.
11. Otherwise generate the 19-column daily XLSX, `result.json`, and path-free `structured-result.json` from the same in-memory records.
12. Independently replay XML, rules, prices, routing, workbook (when present), artifacts, outcomes, and structured records before reporting a final result.

Only a non-empty error set composed entirely of `PRICE_UNMATCHED` may become `review_required`. Any XML, fixed-table, routing, output, structural, or mixed error remains a formal failure. On failure, already excluded, duplicate, or price-unmatched outcomes remain explicit. Other candidates that did not reach a validated result become `validation_failed` with `BATCH_NOT_VALIDATED`.

## Rate-code whitelist

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

## Reservation 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.

## Company normalization

Normalize company identity for price matching and channel facts:

1. trim, uppercase, and keep only `A-Z` and `0-9`;
2. use fixed substring keywords:
   - `LIANTAI` → `LIAN TAI`
   - `QBD` → `QBD`
   - `RAINBOW` → `RAINBOW/AI`
   - `FENGRUN` → `FENGRUN`
   - `HANATOUR` or `HANA` → `HANA TOUR`
   - `HONGTAI` → `HONGTAI`
   - `GUANGZHOUGOEASY` or `GOEASY` → `GUANGZHOU GO EASY`
3. when no keyword matches, use the compact normalized name;
4. when more than one keyword group matches, fail instead of guessing.

This is deterministic keyword matching, not edit distance.

## Pricing

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

- Normalize `RATE_CODE` with `upper(trim(value))`.
- Compare amounts numerically, so `900` equals `900.00`.
- Reject negative reference amounts/totals, invalid rows, ambiguous companies, and every duplicate normalized `COMPANY + RATE_CODE + Opera amount` key.
- Before the normal lookup, set numeric `REAL PRICE = 0` only when:
  - company identity is `RAINBOW/AI` or `GUANGZHOU GO EASY`; and
  - rate code is `LBMS` or `LBSM`.
- The exception ignores `EFFECTIVE_RATE_AMOUNT`.
- Every other retained row must exactly match normalized `COMPANY_NAME + RATE_CODE + EFFECTIVE_RATE_AMOUNT`.
- Write reference `总价` as static `REAL PRICE`.
- Write static `TOTAL PRICE = REAL PRICE * NO_OF_ROOMS * NIGHTS`.
- An unmatched candidate normally requires review only when it is the sole actionable error family. The review artifact contains no price substitution.
- A final review replay may price only the exact unmatched keys listed in its frozen manifest. `real_price` is a non-negative two-decimal value (including `0.00`), uses `pricing_method = manual_review` and `MANUAL_PRICE_APPLIED`, and never changes the fixed table or any XML field.

## Channel facts

Keep original XML `COMPANY_NAME`; assign only the derived `channel_key`.

| Condition | `channel_key` |
|---|---|
| `QBD` | `QBD` |
| `LIAN TAI` and rate in `{LBLT, LTLT}` | `LIANTAI-FIT` |
| `LIAN TAI` and another whitelisted rate | `LIANTAI-GROUP` |
| `RAINBOW/AI` or `GUANGZHOU GO EASY`, any rate | `DY-AI-Easy-KB` |
| `FENGRUN` | `FENGRUN` |
| any other company | sanitized actual company name |

For other companies, trim, remove `: \ / ? * [ ]`, and truncate to 31 characters. Resolve different-company collisions with `-2`, `-3`, and so on. Set `kb_amount = NO_OF_ROOMS * 100` only for `DY-AI-Easy-KB`; do not multiply by nights and do not add it to `TOTAL PRICE`.

## Business date, XLSX, and types

- Derive the business date only from XML group dates, never runtime time or filename.
- Daily filename and sheet name: `M.D.xlsx` and `M.D`.
- Preserve retained XML order.
- Use the bundled daily template; do not add monthly or channel worksheets.
- Headers occupy row 1; data begins row 2.
- Write dates as real Excel dates with `DD-MMM-YY`.
- Write counts and prices as static numbers, never formulas.
- Write identifiers and descriptive fields as text.
- Require `ADULTS >= 0`, `CHILDREN >= 0`, `NO_OF_ROOMS > 0`, `EFFECTIVE_RATE_AMOUNT >= 0`, and `DEPARTURE >= ARRIVAL`.
- `DEPARTURE = ARRIVAL` yields `NIGHTS = 0` and `TOTAL PRICE = 0`.
- Preserve allowed blank fields and complete selected comment/trace text.

## Prohibited behavior

Do not access OSS, embed credentials, generate/update a monthly workbook, query/write a database, infer Group Code from `BLOCK_CODE`, or derive structured facts by reopening the generated XLSX.
