Estimator Tools
TechNext · Casa Escondida, Anilao · Stage 1

Casa Escondida Estimator Tools: project docs

The whole project on one page: what the product is, who uses it, every user story built or planned, the flows F00–F07, the gap against the tool Casa uses today, open questions and how to try it.

Updated 25 Sep 2026 · source: the tn-casa-quotation-estimator repo · screenshots in fixture mode (sample data) · technical docs: /estimator-tech

Part 1Overview

What it is

  • Casa Escondida Estimator Tools (Estimator Tools for short) is the quoting web app for the Casa Escondida dive resort in Anilao: fill in a trip, get a quote, share a link, then send a reservation. The Odoo side calls the same product the Estimate Builder.
  • A seven-question form; Plan my trip opens the Result page with the guests table, dive grid, itinerary and price card.
  • Each Save trip freezes a version; Get final quote issues a /quote/:token link for family or an agent's clients.
  • The UI is English first with 中文 (Simplified) available, a night (navy) and a day theme, and works on a phone.
  • Every number comes straight from Odoo. The app keeps drafts, versions, links and reservations in its own store, so opening a link or a list never calls Odoo.

Who uses it

RoleWhoWhat they can do todayPriced by
GuestIndividual traveller, anonymous or with an accountFill a trip, edit on Result, Save, get a public link. With an account, send a reservation for their own tripRetail (guest service key)
AgentTravel agent, verified by the front desk in OdooAs guest, plus net rates, the FOC 5+1 column, links that need the viewer to sign in, the Agent View tabAgent rate card from their own api-key
InstructorDive instructor bringing a groupAs agent, without the Agent View tabInstructor rate card
StaffCasa front desk, dive centre, kitchen, managersChoose the guest type when quoting for someone, see every trip in the Ops Sheet, print daily sheets, change SettingsStaff key (cost and margin wait for a real key)

Trip workspace tabs by role

Trip workspace tabs by role Matrix of the five trip workspace tabs against four session roles: everyone sees Trip and Guest Estimates, agents also see Agent View, and only staff see Ops Sheet and Settings. TAB SHOWN PER SESSION ROLETrip & Guests/trip/:idGuest Estimates/estimatesAgent View/agentOps Sheet/opsSettings/settings · globalAnonymous · guestubg_sid or noneInstructorOdoo keyAgentOdoo keyStaffOdoo keyLEGENDtab visibletab hiddenAgent View: agents and staff only
After Plan my trip every screen of a trip sits in one tabbed workspace; tabs follow the session role.

The boundary: Odoo is the brain

  1. The app never computes a price, never discounts, never decides a role. Every number shown is from Odoo's response; the role comes from the Odoo-issued api-key, not from the payload.
  2. Odoo receives only two meaningful calls: compute on Plan my trip and Update price, and booking/submit exactly once per quote.
  3. Lists, links, versions and printing read the app's store. A link opened 100 times is 100 Postgres reads and 0 Odoo calls.
  4. Keys and passwords never reach the browser. The browser only holds signed HttpOnly cookies.
  5. Always send the full Trip. Six groups of fields, if missing, give a wrong price with no error from Odoo, so the app blocks them with a 422 first.

Architecture

System architecture

Estimator Tools system architecture The browser loads a static React app and calls a Hono BFF on the same Vercel project; only the BFF talks to Odoo, with an api-key header, and to the Postgres draft store; an AI channel is planned to call the same BFF. VERCEL · ONE PROJECTHTTPS/api/*HTTPS · api-keySQL · DATABASE_URLplanned · /api/*USERBrowserguest · agentinstructor · staffSPAReact 19 + Vite appstatic · app/dist/ · /trip/:id · /quoteAPIHono BFF/api/* · api/index.tsfill · validate · redactODOOOdoo estimate-api v1compute · rates · roomsboats · auth · booking/submitSTORESupabase Postgresdraft store · 7 tablesRLS on · no policyPLANNEDAI channel (P5)ai/ extractorLEGENDonly caller of Odooapp codeOdoo (source of prices)our storenetwork call to Odooplanned
The BFF is the only caller of Odoo and Postgres. Supabase runs locally in Docker for now; without DATABASE_URL the BFF falls back to an in-memory store.

Phase status

801tests passing, suite all (25 Sep)
0failing tests
8flows F00–F07
25original ASCII diagrams
15policy questions for Casa

Phases in delivery order

Phases in delivery order Ordinal timeline of the phases as they were delivered: P0, P1, P3, P4, P4b and P2 are code-complete, P2 booking still waits for Odoo to open live submit, the workspace tabs are in progress and P5 is parked. ORDER OF DELIVERY · NOT A CALENDARP0 FoundationdoneP1 Quotedone · fixtureP3 AccountsdoneP4 Planner + staffdone · fixtureP4b ValidationdoneP2 Bookingfixture · live closedWorkspace tabsin progressP5 AI channelparkedLEGENDdonewaiting on Odoo to open submitin progressparked
No dates: the axis is the order of work. P2 came last because it waits for Odoo to open submit.
PhaseDeliversStatusWaiting on
P0Fixture mode, the api-key header, local Supabase, Vercel config, test:recorddoneLinking the Vercel project (lead)
P1EN/中文 form in the Casa brand, Save as versions, /quote/:token links, My quotes, printdone · fixtureReal keys to compare numbers
P3Register, sign in, forgot password through Odoo; role from the key; gated links; Agent viewdoneQA agent and instructor accounts verified 25 Sep; staff account pending from Odoo
P4Flow A: form, Plan my trip, Result; Ops Sheet; Settings; day themedone · fixtureReal boats, drag-and-drop, tanks and DMs
P4bSeven rules block impossible trips before pricing; guest type follows the session roledoneQ-009..Q-012
P2Send reservation, Reserve, Booking; one Odoo submit per quotedone in fixtureLive stays closed until Odoo allows it and ODOO_SUBMIT_ENABLED=1 is set
P5Chat, email, WhatsApp → Trip → quote linkparkedContract for the AI team is ready (docs/integration/schema.md)

Part 2User stories by area

Each story reads "As <role>, I want … so that …", with acceptance criteria, the flows it relies on and screenshots. Built: works end to end. Fixture: works on sample data, waiting for real Odoo. Planned: not built. Screenshots are of the app running locally in fixture mode, anonymous flow only.

a. Quick quote: form, Plan my trip, Result

Epic: a stranger with no guidance gets a price for their trip in two screens.

US-A1 Built

As an individual guest, I want to answer seven questions about my trip without an account, so that I quickly know what it costs.

  • / is only a form: stay dates, guests, divers, dive from–to, airport transfer, full board. No numbers at all.
  • Typing sends no request; the date pickers only suggest min/max.
  • Anyone but staff sees "Quoting as …" instead of a guest-type question.
  • EN / 中文 switches every string; a "Sample data" band shows on sample data.

Relies on: F05 D1, F00 D1

Estimator home page with an empty form
Empty form with "Quoting as Guests".
Filled form: 20 to 22 November, 2 guests, 1 diver
Filled: 20–22 Nov 2026, 2 guests, 1 diver on 21 Nov.
The form in Chinese
中文 on: every label goes through the dictionary.
US-A2 Fixture

As an individual guest, I want Plan my trip to open a priced Result page straight away, so that I do not click through several steps.

  • Exactly one POST /api/estimates; /trip/:id opens already priced.
  • Summary chips: nights, guests, dive days, full board, transfer.
  • Price card: total, per guest or group, each line with a faint "why" line.
  • Odoo warnings are printed verbatim under "From the booking engine".
  • A missing price-relevant field shows under its input and the page does not move.

Relies on: F05 D1, D2, F03 D2

Result page: guests table, dive grid, itinerary, price card
Result after Plan my trip: ₱31,200 for two guests (a sample-file number).
US-A3 Built

As a returning guest, I want to reopen an unfinished trip, so that I do not start over.

  • / offers "Continue your last trip"; My quotes lists trips by cookie or by account.
  • Reopening /trip/:id reads only the app's store: 0 compute calls.
  • Signing in halfway moves the anonymous drafts into the account.

Relies on: F01 D2, F02 D4, F03 D1

My quotes page with one trip
My quotes: "Untitled trip · v1" with Open.

b. Editing the trip: guests, rooms, courses, dive grid, itinerary

Epic: the person quoting edits each guest as on the paper sheet, without waiting for Odoo on every keystroke.

US-B1 Built

As the person quoting, I want to edit each guest in one table, so that the quote matches the real group.

  • Columns: Name, Dives?, dive from–to, Full board, Transfer, Room, Course, Arrive / Depart, remove.
  • + Add guest adds a row from the form answers.
  • Courses come from Odoo's rate list, up to five.
  • The FOC column only shows when Odoo says the group qualifies (5+1) and the role is not guest.

Relies on: F05 D2

Guests table with two rows
The "Guests, rooms & diving" table.
US-B2 Fixture

As the person quoting, I want to put guests into rooms, so that room prices reflect occupancy.

  • The Room cell picks among the trip's rooms, with the head count of each.
  • + Add room… offers room types from roomAvailability.
  • The sample data has no full room yet, so the "room full" branch is untested.

Relies on: F05 D2 · Planned: real free rooms by date, a drag-and-drop room board (TG-05, TG-06)

US-B3 Fixture

As the person quoting, I want to tick who dives which day, third dive or night dive, so that diving is priced per person.

  • Dive schedule grid: rows are divers, columns are days; each cell has Dive, 3rd, Night; the last row counts divers.
  • The grid is the source of truth for dive days; changing "Dives until" drops the columns outside the window.
  • The boat select only shows when Odoo returns boats (the sample list is empty).

Relies on: F05 D2 · Planned: a dive planner by boat (TG-08)

Dive grid with one diver on one day
Dive schedule: one diver on 21 Nov.
US-B4 Built

As a guest, I want a day-by-day itinerary, so that I know when I arrive, dive, eat and leave.

  • The itinerary is read-only, built from Odoo's model: Date, Arrive, Dive, Meals, Depart.
  • No money in it; the departure day is appended.
  • While the price is out of date the itinerary is dimmed, as it belongs to the previous model.

Relies on: F05 D2 · Planned: an itinerary written as sentences (GE-03)

Two-day itinerary
Itinerary for 20–21 Nov.
US-B5 Built

As the person quoting, I want to make several edits and reprice once, so that I do not wait and Odoo is not loaded for nothing.

  • Any edit: 0 requests; the price card turns Outdated.
  • Update price sends exactly one PATCH, even on a double click.
  • Odoo busy or timed out: "Try again", the table stays editable and the draft is not overwritten.
  • Get final quote and Send reservation are locked while the price is stale.

Relies on: F05 D5

Price card in the Outdated state
After renaming guest 1: Outdated.
Price card
The price card when priced.

c. Saving versions and sharing links

Epic: a sent quote is a real, traceable thing, and opening its link costs no Odoo call.

US-C1 Built

As the person quoting, I want Save trip to create version 1, 2, 3, so that I know which number the client holds.

  • Save trip shows "Saved as version n"; the version and its snapshot can no longer change.
  • Save makes no Odoo call when the last model matches the stored trip.
  • Two tabs saving at once get two consecutive versions; nobody loses one.

Relies on: F02 D4, D3, F05 D1

Price card after saving
After Update price and Save trip.
US-C2 Built

As the quote owner, I want Get final quote to give me a link right away, so that the client and family see the same version.

  • Enabled only when the saved version is the trip on screen; otherwise "Save the trip first".
  • Goes straight to /quote/:token; the Share this quote block has the link, Copy link and Back to trip (owner only).
  • A random 32-byte token; the store keeps only sha256(token).

Relies on: F05 D1, F02 D5

Share this quote block
Share block (token masked in the image).
US-C3 Built

As a link recipient, I want to open the link on my phone without an account, so that I see what I will pay.

  • A guest's link is public: total, itinerary, "Still to sort out", Per guest / Group, Print / PDF.
  • 0 Odoo calls on open; the numbers are the ones frozen at Save.
  • A wrong token: "not valid or has expired"; an expired one returns 410 so the recipient knows to ask again.

Relies on: F01 D1, D2, F04 D4

Your quote page
The "Your quote" page, version 1.
US-C4 Planned

As the quote owner, I want to choose whether a link is frozen to the sent version or follows the latest, so that clients are not surprised by a new price.

  • Today a link points at the trip, so it shows the latest saved version (Q-005 pending).
  • Browsing old versions in the UI, naming trips and hiding prices on the client copy are not built yet.

d. Accounts and roles

Epic: Odoo owns accounts and prices by role; the app only keeps sessions.

US-D1 Built

As a travel agent, I want to register and upload my documents, so that Casa can verify me and I get agent rates.

  • /register: Guest, Travel agent or Dive instructor; agents add Agency, Contact person, Phone and documents (PDF/JPG/PNG, 5 MB, three files).
  • After creation the front desk verifies in Odoo.
  • A known email shows "This email is already registered".

Relies on: F03 D1

Register page with Travel agent selected
Registering as a Travel agent.
US-D2 Built

As an agent, I want to sign in without losing the trip I filled in anonymously, so that I continue with my rates.

  • The password goes to Odoo once, is never stored or logged; the response carries no key.
  • Wrong password and unknown email get the same message; more than five tries a minute are throttled.
  • Forgot password always answers with the same sentence.
  • Anonymous drafts merge into the account; after Sign out they stay with the account.

Relies on: F03 D1, D3, D5

Sign in page
Sign in, with Forgot password and Create account.
US-D3 Fixture

As a signed-in agent, I want prices from my rate card and to know when my account is not yet verified, so that I never pass retail prices on by mistake.

  • The form reads "Quoting as Travel agent · Your rate card is set by your account."; only staff pick a guest type.
  • If Odoo still prices as guest (not verified): a "Pending verification — retail rates shown" chip.
  • A payload guest type that differs from the session role is replaced, without an error, and logged.

Relies on: F03 D2

US-D4 Fixture

As an agent, I want my links to open only for signed-in viewers, and to compare retail with net, so that my net rates stay private.

  • An account's link: an anonymous viewer gets "Sign in to continue" and returns to the same link after signing in.
  • Agent View: Retail next to Your rate with a real key; now its own tab, hidden from instructors.
  • Planned: agent margin and FOC incentive tiles (waiting for Odoo to return commission).

Relies on: F04 D4, F03 D2

Needs sign-in — Anthony to addAgent View: retail next to the agent's net rate; needs a verified agent account and a real key.

e. Booking

Epic: the front desk gets a folio that matches the quote, and one quote never becomes two folios.

US-E1 Fixture

As a signed-in quote owner, I want Send reservation on the price card or the quote page, so that I can hold the booking once the client agrees.

  • Locked in order: already sent, stale price ("Update the price first"), current trip not saved ("Save the trip first").
  • Anonymous: the button opens "Sign in to continue" in place, with no request (Q-004).
  • A recipient who is not the owner: "Ask the person who sent this quote to confirm the booking." (Q-014).

Relies on: F07 D1, F04 D4

Sign in to continue card after Send reservation
Send reservation while anonymous: the Sign in to continue card.
US-E2 Fixture

As the quote owner, I want to review a summary and edit the contact before confirming, so that the front desk calls the right person.

  • /trip/:id/reserve: a summary of the latest saved version (dates, guests, Version n), no money.
  • Name and Email prefilled from the account, Phone optional (Q-013).
  • Unsaved edits show "You have unsaved changes…" and lock Confirm.

Relies on: F07 D1

Needs sign-in — Anthony to addReserve page: trip summary and a prefilled contact form.
US-E3 Fixture · live closed

As the front desk, I want each quote to create one folio however many times the sender clicks, so that I never cancel duplicates.

  • A submission row is written as pending before the Odoo call; one live row per quote, so a double click gives one 200 and one 409.
  • It sends the revision.trip of the latest saved version; a mismatched seq is 409 stale.
  • One POST /v1/booking/submit, no retry, 20-second timeout.
  • Live opens only with ODOO_SUBMIT_ENABLED=1; otherwise 503 closed. Fixture returns a spec-shaped sample labelled "Sample — no folio was created".

States of one submission

Booking submission states State machine of one reservation: a pending row is written before the single Odoo call, then becomes confirmed, failed (retry allowed with a new row) or unknown (locked for staff); a closed gate writes nothing. POST submitgate closed · 503200 successtimeout · networkHTTP error · breakerretry = new rowSTATEpendingrow written firstSend lockedSTATEconfirmedfolio_id · scenario submittedSend lockedSTATEunknownOdoo may have a foliolocked · staff handlesSTATEfailedno folioSend open againno rownothing writtenLEGENDno submission yethappy endretry creates a new row
confirmed and unknown lock Send; failed allows a resend with a new row.

Relies on: F07 D1, D2, D3

Needs sign-in — Anthony to addBooking page: "Reservation sent", folio number (fixture: "Folio number pending"), What happens next, Print / PDF.
US-E4 Fixture

As the quote owner, I want to see at once that a trip was already sent, so that I never send it twice.

  • A "Reservation sent" banner on the price card with View reservation; Send locks by state; it survives F5.

Relies on: F05 D1, F07 D2

US-E5 Planned

As a guest, I want an email or WhatsApp confirmation; as staff, I want to resolve "unknown" sends, amend or cancel after sending, and send on a client's behalf.

  • Waiting on Q-015, B-034, B-035, B-036. Deposits are not shown yet.

f. Staff: Ops Sheet and Settings

Epic: staff replace the paper sheet for the morning meeting and change policy without a deploy.

US-F1 Fixture

As staff, I want one printable sheet per day for front desk, housekeeping, dive centre, kitchen and transfers, so that nobody copies by hand before the meeting.

  • A list of every trip, newest first: Trip, Owner, Updated, Version, Open.
  • One sheet per day: Front desk, Housekeeping, Dive centre, Kitchen, Transfers; now the trip's Ops Sheet tab.
  • Prints in black and white, one page per day, with no money.
  • Non-staff see "This page is for Casa staff."; staff can read but not change others' trips.

Relies on: F06 D3 · Planned: handover notes, tanks and DMs, covers per meal (OS-02..OS-06)

Needs sign-in — Anthony to addOps Sheet: every trip and the daily sheets; needs a staff account.
US-F2 Built

As staff, I want to change draft retention, link expiry and display currency in the app, so that Casa policy applies without waiting for engineers.

  • Four rows: unsent trip retention, raw text retention, link expiry (with "Links never expire"), currency.
  • "Changes apply immediately."; one Save per row; invalid values show in the row.
  • Currency is only a label: Odoo returns PHP and the app does not convert (Q-003).

Relies on: F06 D4

Needs sign-in — Anthony to addSettings: four policy rows, each with its own Save; needs a staff account.

g. Validation: seven rules

Epic: an impossible trip never reaches Odoo, because Odoo would not complain; it would price it wrong.

US-G1 Built

As the person quoting, I want errors on the wrong field before anything is sent, so that I never get a silently wrong number.

  • The browser runs the same rules as the server and sends nothing when one fails.
  • The server checks again: 422 {error, code, fields, issues}, no Odoo call, nothing stored.
  • One root error, one message: a broken stay skips the rules that compare against it.
  • Before that fillTrip blocks six field groups; after compute, a sanity check flags unexpected zero revenue.
CodeMessage shownLevelInterim policy
checkout-not-after-checkinCheck-out must be after check-inblockAt least 1 night (Q-009)
checkin-in-pastCheck-in cannot be in the pastblockManila time; staff exempt (Q-012)
dive-window-reversedDiving 'from' must be on or before 'to'block—
dive-window-outside-stayDive dates must fall within the stayblockBoth ends inclusive (Q-010, Q-008)
dive-days-outside-windowA guest has dive days outside the dive windowblock—
arrive-depart-outside-stayArrival or departure is outside the stayblock—
room-emptyA room has no guests assignedwarnStill priced (Q-011)

Relies on: F05 D1, D5, F03 D2

Form showing Check-out must be after check-in
Check-in = check-out: flagged at once, with no request.

h. AI channel

Epic (parked, P5): a message becomes an editable quote link, through the same routes the form uses.

US-H1 Planned

As a guest messaging Casa, I want a quote link back from my own message, so that I never fill a form.

  • The extractor takes text and returns a Trip with per-field status; a missing required field is asked for, never defaulted.
  • Every call goes through /api/*: never Odoo directly, no key, no maths on money, never a reservation.
  • Still missing: POST /api/extract, the extraction table, a service identity for the bot.

Contract for the AI team: docs/integration/schema.md in the repo.

Part 3Flows F00–F07

Each flow is one file in docs/flows/, drawn before the code and amended in place when the flow changes. Five figures are redrawn in the site's design system; all 25 original ASCII diagrams sit in the collapsed "Original ASCII diagrams" under each flow.

F00 Fixture mode: the BFF runs on captured responses

built docs/flows/F00-fixture-mode.md

Development and demos run without Odoo: the BFF answers from captured responses while every check still runs as in production.

Original ASCII diagrams (3)

Copied verbatim from the repo; the text inside is Vietnamese, as in the source.

D1 — Business flow: dev and demo run without Odoo

 [Dev      ]         [UI React]            [BFF Hono]                 [Fixture files]        [Odoo]
     |                   |                     |                       (contracts/odoo/         |
     | FIXTURE_MODE=1    |                     |                        examples/*.json)         |
     | npm run dev:app   |                     |                             |                   |
     |------------------>|                     | boot: selectGateway(env)    |                   |
     |                   |                     |   -> createFixtureGateway   |                   |
     |                   |                     |   log "mode=fixture"        |                   |
     | mở trang          | GET /api/warmup ~~~>| gateway.rates() ----------->| rates.json        |
     |                   |                     |                             |                   |
     | điền form, bấm    | POST /api/estimates>| fillTrip (như thật)         |                   |
     | Get price         |                     | gateway.compute(trip) ----->| pickCompute(trip) |
     |                   |                     |   <- compute.<case>.json    |                   |
     |                   |                     | checkComputeSane, redact    |                   |
     |                   |<== 200 {model} =====|                             |                   |
     | thấy số thật đã   |                     |                             |      (0 lời gọi) |
     | chụp 17/09        |                     |                             |                   |

D2 — Data flow: picking the gateway at boot

                         process.env
                              |
                          loadEnv()  ----!---> throw "Thiếu ODOO_BASE_URL" (khi FIXTURE_MODE=0 mà thiếu)
                              |
                             Env { FIXTURE_MODE: boolean, ODOO_BASE_URL?: string,
                                   ODOO_API_KEY_HEADER: 'api-key', ODOO_KEY_GUEST?: string, ... }
                              |
                        selectGateway(env)
                        /              \
        FIXTURE_MODE=true               FIXTURE_MODE=false
              |                                |
   createFixtureGateway()            createEstimateGateway(env)
   compute -> pickCompute(trip)      compute ===> POST /v1/estimate/compute (header api-key: ODOO_KEY_GUEST)
   rates   -> rates.json             rates   ===> GET  /v1/estimate/rates
   rooms   -> rooms.json             rooms   ===> GET  /v1/estimate/rooms
              \                                /
               `------ EstimateGateway -------'   (một type, hai hiện thực)
                              |
                createApp({ env, gateway })  <- không biết đang dùng cái nào
                              |
              GET /api/health -> { ok: true, mode: 'fixture' | 'odoo' }

D3 — How pickCompute chooses a fixture

 trip
  |-- có ai diver=true VÀ thiếu diveFrom/diveTo? --yes--> compute.missing-divewindow.json
  |         (không xảy ra trong app vì fillTrip đã chặn; giữ để test regression L-001 chạy được)
  |-- guestType === 'agent'?                    --yes--> compute.agent-group.json
  |-- có guest nào courses.length > 0?          --yes--> compute.courses.json
  `-- còn lại (kể cả guestType === 'instructor', chưa có fixture riêng) --> compute.retail-couple.json

F01 Fill → Save → Get quote → open the link (P1)

built docs/flows/F01-quote-flow.md

The P1 flow: Save freezes a version, Get quote issues a link, and the recipient opens it without an account. P1's live pricing was replaced by Flow A (F05).

Original ASCII diagrams (3)

Copied verbatim from the repo; the text inside is Vietnamese, as in the source.

D1 — P1 flow: fill → price → Save → Get quote → recipient opens the link

 [Khách]              [UI React]                 [BFF Hono]                    (Draft store)        [Odoo]
    |                     |                          |                               |                  |
    | mở /                | GET /api/health ~~~~~~~~>| {ok, mode}                    |                  |
    |                     | GET /api/settings ~~~~~~>| {display_currency, ...}       |                  |
    |                     | GET /api/me ------------>| cookie? không -> {role:'guest', session:false}   |
    |                     |                          |                               |                  |
    | điền 7 câu (EN/中文) |                          |                               |                  |
    | đổi trường có giá   | debounce 400ms           |                               |                  |
    |-------------------->| POST /api/estimates ====>| set-cookie ubg_sid (nếu chưa) |                  |
    |                     |   {trip}                 | fillTrip ! 422                 |                  |
    |                     |                          | newScenario(owner, trip) ---->| scenario         |
    |                     |                          | compute ===================================>|
    |                     |                          |<========================== model ===========|
    |                     |<== 201 {id, model, issues, computedAt} ==|              |                  |
    | thấy tổng + dòng    |                          |                               |                  |
    | "vì sao" (lines.sub)|                          |                               |                  |
    |                     |                          |                               |                  |
    | sửa tiếp            | PATCH /api/estimates/:id | {trip, save:false} -> compute (cache) ---------->|
    |                     |   (mỗi 400ms)            | {trip, save:true}  -> + saveWorkingTrip ->| scenario.working_trip
    |                     |                          |                               |                  |
    | bấm SAVE            | POST /:id/commit ======>| compute (cache hit) ---------------------------->|
    |                     |                          | commitRevision(seq=n) ------->| revision         |
    |                     |                          | saveSnapshot(model,role) ---->| snapshot         |
    |                     |<== 200 {seq, snapshotId, computedAt} ==|                |                  |
    | "Saved as version 1"|                          |                               |                  |
    |                     |                          |                               |                  |
    | bấm GET QUOTE       | POST /:id/share ========>| token = random32; store sha256(token) ->| share_token
    |                     |<== 200 {url:/quote/<token>}                              |                  |
    | copy link, gửi      |                          |                               |                  |
    ...
 [Gia đình]  mở /quote/<token>   GET /api/share/<token> =>| sha256 -> share_token -> scenario -> snapshot mới nhất
             (không login)      <== 200 {trip, model, seq, computedAt, sample, currency}   (0 lời gọi Odoo)
             xem từng khách / cả nhóm, bấm Print -> print CSS

D2 — Server data flow: route ↔ function ↔ table

   request
     |-- middleware requestId (có)         -> c.get('requestId')
     |-- middleware resolveOwner (T5)      -> c.get('owner') = { role:'guest', ref: sid } | null
     |
     |-- POST /api/session/guest ---> signGuestCookie(secret) --> Set-Cookie ubg_sid
     |-- GET  /api/me --------------> owner ? {role, session:true} : {role:'guest', session:false}
     |-- GET  /api/settings --------> settings.public()  <- app_setting (cache boot, mặc định tạm)
     |
     |-- POST /api/estimates -------> ensureOwner -> fillTrip -> store.newScenario(owner, trip)
     |                                 -> gateway.compute(trip, role, today) -> sane -> redact
     |                                 -> 201 {id, model, issues, computedAt}
     |-- PATCH /api/estimates/:id --> ensureOwner -> store.getScenario(id, owner) ! 404
     |                                 -> fillTrip -> compute -> sane -> redact
     |                                 -> save ? store.saveWorkingTrip(id, trip, ui) : (không ghi)
     |-- POST  /:id/commit ---------> getScenario ! 404 -> compute(working_trip) -> redact(owner.role)
     |                                 -> store.commitRevision(id, trip) -> seq
     |                                 -> store.saveSnapshot(revisionId, {model, role, computedAt, odooMs})
     |-- GET   /api/estimates ------> store.listScenarios(owner)  (id, label, status, updated, latestSeq)
     |-- GET   /:id ----------------> getScenario ! 404 + latestRevision + snapshot
     |-- GET   /:id/revisions[/:seq]-> store.listRevisions / getRevision
     |-- POST  /:id/share ----------> getScenario ! 404 -> token=random(32) -> store.createShareToken(id, sha256(token), expiresAt?)
     |                                 -> 200 {url: '/quote/'+token}
     `-- GET  /api/share/:token ----> store.resolveShare(sha256(token)) ! 404 / 410 hết hạn
                                       -> latestSnapshot(scenario) ! 404 nếu chưa commit
                                       -> 200 {trip, model, seq, computedAt, sample: snapshot.sample, currency}

D4 — Version state machine

        POST /api/estimates                PATCH save:false          PATCH save:true
   () ----------------------> [draft, seq=0] ----(compute only)----> [draft, seq=0] ---(working_trip)--> [draft, seq=0]
                                   |                                                                          |
                                   | POST commit                                                              |
                                   v                                                                          v
                            [draft, seq=1] --- PATCH... --- commit ---> [draft, seq=2] ... (n lần Save = n bản)
                                   |
                                   | POST share  (không đổi seq; token -> scenario)
                                   v
                            /quote/<token> đọc snapshot(seq mới nhất tại lúc mở)
                                   |
                                   | (P2) submit -> [submitted]  (không xoá, không expire)
   TTL: draft quá draft_ttl_days -> [expired] (job P1 chỉ có hàm store.expireDrafts(now); lịch chạy là P2)

F02 Draft store: scenario → revision → snapshot → share_token

built docs/flows/F02-draft-store.md

Where quotes are kept. The rule: a draft can change, a version cannot.

Draft store data model (F02 D3 + F03 D3 + F07 D3)

Draft store data model Seven Postgres tables: a scenario owns revisions, each revision has one frozen snapshot, share tokens and submissions hang off the scenario, user sessions link to scenarios only by an owner string, and app settings stand alone. 1N111NGET QUOTE1NSENT VERSIONN1SEND RESERVATIONNO FKTABLEuser_session# id (16B b64url)roleodoo_loginodoo_key_enc bytearevoked_atTABLEscenario# id uuidowner_role · owner_refstatus draft|submitted|expiredworking_trip · working_uiworking_model jsonbexpires_atTABLErevision# id→ scenario_idseq unique per scenariotrip jsonbauthorTABLEsnapshot# id→ revision_id uniquemodel jsonb (redacted)retail_model jsonbrole · sampleTABLEshare_token# token_hash sha256→ scenario_idrequires_loginexpires_at · revoked_atTABLEsubmission# id→ scenario_id · snapshot_idstatecontact jsonb (PII)folio_id · order_idsone live row / scenarioTABLEapp_setting# keyvalue jsonbupdated_atLEGENDaggregate roottablestring reference, no foreign keyRLS on every table, no policy
Seven tables; RLS is on everywhere with no policy, so only the BFF can read. Tokens are stored as sha256 only.
Original ASCII diagrams (3)

Copied verbatim from the repo; the text inside is Vietnamese, as in the source.

D4 — Version state machine

        POST /api/estimates                PATCH save:false          PATCH save:true
   () ----------------------> [draft, seq=0] ----(compute only)----> [draft, seq=0] ---(working_trip)--> [draft, seq=0]
                                   |                                                                          |
                                   | POST commit                                                              |
                                   v                                                                          v
                            [draft, seq=1] --- PATCH... --- commit ---> [draft, seq=2] ... (n lần Save = n bản)
                                   |
                                   | POST share  (không đổi seq; token -> scenario)
                                   v
                            /quote/<token> đọc snapshot(seq mới nhất tại lúc mở)
                                   |
                                   | (P2) submit -> [submitted]  (không xoá, không expire)
   TTL: draft quá draft_ttl_days -> [expired] (job P1 chỉ có hàm store.expireDrafts(now); lịch chạy là P2)

D3 — Draft store ERD (P1: 5 tables)

 +----------------------+        +--------------------+        +----------------------+
 | scenario             |1     n | revision           |1     1 | snapshot             |
 |----------------------|--------|--------------------|--------|----------------------|
 | id uuid pk           |        | id uuid pk         |        | id uuid pk           |
 | owner_role text      |        | scenario_id fk     |        | revision_id fk uniq  |
 | owner_ref text       |        | seq int            |        | model jsonb          |
 | source text 'form'   |        | trip jsonb         |        | role text            |
 | status text 'draft'  |        | author text 'human'|        | sample bool          |
 | label text null      |        | created_at         |        | rates_version text ? |
 | working_trip jsonb   |        | uniq(scenario_id,  |        | computed_at          |
 | working_ui jsonb     |        |      seq)          |        | odoo_ms int          |
 | created_at,updated_at|        +--------------------+        +----------------------+
 | expires_at           |
 +----------------------+
          |1
          |n
 +----------------------+        +----------------------+
 | share_token          |        | app_setting          |
 |----------------------|        |----------------------|
 | token_hash text pk   |        | key text pk          |
 | scenario_id fk       |        | value jsonb          |
 | created_at           |        | updated_at           |
 | expires_at null      |        +----------------------+
 | revoked_at null      |
 +----------------------+
   idx: scenario(owner_role, owner_ref, updated_at desc); revision(scenario_id, seq); share_token(scenario_id)
   RLS: ENABLE ROW LEVEL SECURITY trên cả 5 bảng, KHÔNG có policy -> anon/authenticated đọc = 0 dòng.

D5 — Cookie and token security

  guest cookie   sid = base64url(random 16B)            token = base64url(random 32B)
                 sig = HMAC-SHA256(secret, sid)          DB lưu token_hash = sha256(token)
                 Set-Cookie: ubg_sid=<sid>.<sig>;        URL   /quote/<token>   (token thô chỉ có ở URL)
                             HttpOnly; SameSite=Lax;
                             Path=/; Max-Age=30d; Secure*
  verify:  tách '.', timingSafeEqual(sig, HMAC(sid))     lookup: sha256(token) -> hàng; không có -> 404
           sai -> coi như không có cookie (không 401)    hết hạn/revoked -> 410
  owner_ref = sid  (chưa bao giờ = token)                token không map ngược ra owner

F03 Auth through Odoo: register, sign in, role pricing

changed docs/flows/F03-auth.md

Odoo owns accounts; the app passes the password once, keeps a session, merges anonymous drafts into the account and lets Odoo decide the price by role.

Role resolution and pricing (F03 D2)

Auth and role resolution Flowchart: a signed-in cookie resolves to a stored session whose Odoo key is decrypted, otherwise the guest service key is used; the BFF forces guestType from the session role, Odoo prices with the key, and the model is redacted by the role Odoo actually used before it reaches the price card. YESNOapi-keymodel + rolepending if roles differRequest/api/*Signed in?ubg_authSESSIONuser_sessionHMAC id → live rowdecrypt key · AES-GCMGUESTGuest service keyubg_sid or no cookieODOO_KEY_GUESTDERIVEForce guestTypefrom session rolestaff keeps payloadODOOcomputerate card of the keyreturns response.roleREDACTredactForRoleby response.rolecost never to guestPrice cardrole rate cardLEGENDanonymous pathBFF stepOdoo decides the pricelast guard before the browser
Odoo prices by key; the app redacts by the role Odoo actually used and shows Pending verification when the roles differ.
Original ASCII diagrams (4)

Copied verbatim from the repo; the text inside is Vietnamese, as in the source.

D1 — Register → sign in → merge drafts → role pricing → sign out

 [Agent]              [UI React]                 [BFF Hono]                                  [Odoo]
    |                     |                          |                                           |
    | mở app ẩn danh      |                          | cookie ubg_sid                            |
    | điền chuyến (P1)    | POST /api/estimates ====>| scenario owner=guest:<sid> -> (scenario) insert
    |                     |                          |                                           |
    | bấm SIGN IN         | POST /api/auth/login ==>| {email,password}                          |
    |                     |                          | ========================================>| POST /v1/auth/login
    |                     |                          |<========================================| {api_key,role,name,expires_at}
    |                     |                          | encrypt(api_key) -> insert -> (user_session)
    |                     |                          | reassignOwner(guest:sid -> agent:odoo:login)
    |                     |                          |    -> (scenario) update owner_role,owner_ref
    |                     |                          | Set-Cookie ubg_auth (HttpOnly)             |
    |                     |<== 200 {role:'agent',name,pendingVerification} ==|                    |
    |                     |     (mật khẩu không lưu, không log)                                   |
    |                     |                          |                                           |
    | bấm UPDATE PRICE    | PATCH /api/estimates/:id| resolveOwner(ubg_auth) -> decrypt key      |
    |                     |   ====================>| ========================================>| compute (api-key: agent key)
    |                     |                          |<========================================| model (giá vai agent card,
    |                     |                          |                                           |  hoặc guest nếu chưa verify)
    |                     |<== 200 {model,...} + badge "Pending verification" nếu                |
    |                     |     response.role != session.role ======================|            |
    |                     |                          |                                           |
    | (ĐĂNG KÝ, trước 2)  | POST /api/auth/register|                                             |
    |                     |   ====================>| ========================================>| POST /v1/auth/register
    |                     |                          |<========================================| {login,role,verified:false}
    |                     |                          |                                           |  + To-Do lễ tân verify
    |                     |<== 200 "check email / pending verification" ==|                      |
    |                     |                          |                                           |
    | bấm LOGOUT          | POST /api/auth/logout ==>| ========================================>| POST /v1/auth/logout
    |                     |                          |                                           |  (Odoo revoke key)
    |                     |                          | set revoked_at -> (user_session) update    |
    |                     |                          | clear cookie ubg_auth (nháp giữ nguyên chủ)|
    |                     |<== 200 =================|                                             |
    !  sai mật khẩu -> Odoo 401 -> BFF trả 401 "Invalid email or password" (giống hệt email lạ)
    !  quá 5 lần/phút/IP -> 429 rate limit

D2 — Where a priced request goes (guest vs signed in)

  request
    |-- resolveOwner(secret, sessions):   (không đổi)
    |     đọc cookie ubg_auth trước
    |       -> verify HMAC ! sai chữ ký -> coi như không có, rơi xuống nhánh ubg_sid
    |       -> (user_session) lookup theo id: not revoked, key chưa hết hạn
    |       -> owner = { role: session.role, ref: 'odoo:' + session.odoo_login }; c.set('session', row)
    |     không có / hết hạn -> đọc cookie ubg_sid
    |       -> owner = { role: 'guest', ref: sid }
    |     không có cookie nào -> owner = null
    |
    |-- NEW deriveGuestType(owner, trip):
    |     owner null / role 'guest'      -> trip.guestType = 'retail'
    |     role 'agent'                   -> 'agent'
    |     role 'instructor'              -> 'instructor'
    |     role 'staff'                   -> giữ payload (staff báo giá thay người khác)
    |     ! payload khác giá trị ép (non-staff) -> thay, log guestTypeOverridden, không 4xx
    |     (cùng bước: bookedDaysAhead = max(0, checkIn - today), bỏ giá trị client)
    |
    |-- credentialFor(owner, session, env):   (không đổi)
    |     owner.role === 'guest'  -> { role:'guest', apiKey: env.ODOO_KEY_GUEST }
    |     owner đăng nhập         -> { role: session.role, apiKey: decrypt(session.odoo_key_enc) }
    |                                 AES-256-GCM, key = HKDF(SESSION_SECRET, 'odoo-key')
    |
    |-- gateway.compute(trip, credential, today)   (không đổi; trip = trip đã derive)
    |     ===> Odoo  header api-key: <apiKey>
    |     {cache} key = tripKey(trip) + ':' + sha256(apiKey ?? 'anon').slice(0,16), ttl 60s
    |     <=== { role, model, retail_model }
    |
    |-- checkComputeSane(trip, model)   (không đổi)
    |-- redactForRole(model, response.role)   -- redact theo vai Odoo THỰC SỰ dùng, không theo owner.role
    |
    `-- CHANGED 200 { role: response.role, model (kèm model.warnings của Odoo), retailModel?,
                      issues (sane + warn của validateTrip), computedAt, pendingVerification }

D5 — Fixture auth when FIXTURE_MODE=1

  selectAuth(env)
        |
        +-- FIXTURE_MODE=1 --> createFixtureAuth()
        |         |
        |         +-- 3 tài khoản cố định, password <ẩn>:
        |         |     <QA guest>               -> role guest,      key fx-guest
        |         |     <QA agent>               -> role agent,      key fx-agent
        |         |     <QA staff>                -> role staff,      key fx-staff
        |         |
        |         +-- login/register/forgot/logout: không có mạng, trả kết quả trong bộ nhớ
        |         |
        |         `-- compute: role in {agent,instructor,staff}
        |                 -> fixture chọn file agent-group.json
        |               role = guest -> fixture P1 cũ (retail/...)
        |
        `-- khác --------> createOdooAuth(env)
                  |
                  `-- login/register/forgot/logout ===> gọi thật /v1/auth/*  trên Odoo

  Cả hai implement cùng interface AuthGateway:
    register(input) · login(email, password) · logout(apiKey) · forgot(email)

D3 — ERD: user_session and moving scenario ownership

 +--------------------------+          +-----------------------------------------------+
 | user_session             |          | scenario (đã có, P1)                          |
 |--------------------------|          |-------------------------------------------------|
 | id text pk (16B b64url)  |          | id uuid pk                                     |
 | role text                |          | owner_role text  'guest' | 'agent' | ...        |
 |   (guest|agent|instructor|          | owner_ref  text  sid  |  'odoo:'+login          |
 |    |staff)                |          | ...                                             |
 | odoo_login text          |          +-----------------------------------------------+
 | display_name text        |                          ^
 | odoo_key_enc bytea        |                          | update owner_role, owner_ref
 | key_expires_at timestamptz|                          | where owner_role='guest'
 |   null                    |                          |   and owner_ref = sid
 | created_at                |          reassignOwner(from{guest,sid} -> to{agent,'odoo:'+login})
 | last_seen_at               |
 | revoked_at null            |         KHÔNG có khoá ngoại giữa user_session và scenario --
 +--------------------------+         owner_ref là chuỗi 'odoo:<login>', không phải id bảng này.

 +----------------------------+
 | share_token (đã có, P1)   |
 |----------------------------|
 | token_hash text pk        |
 | scenario_id fk            |
 | ...                       |
 | requires_login boolean    |  <-- cột mới: true khi scenario.owner không còn là guest
 +----------------------------+

 RLS: bật trên user_session, KHÔNG có policy (đóng, giống 5 bảng P1) -> chỉ BFF nối bằng DATABASE_URL.

F04 Gated quote links

changed docs/flows/F04-share-gate.md

A guest's link is public; a link from an account (agent, instructor, staff) needs the viewer to sign in.

Original ASCII diagrams (3)

Copied verbatim from the repo; the text inside is Vietnamese, as in the source.

D4 — Gated quote link (Q-002)

  GET /api/share/:token
        |
        v
  resolveShare(sha256(token))
        |
        +-- không thấy / hết hạn --------------------------------------! 404 / 410
        |
        v
  requires_login ?
        |
        +-- false (P1 path, không đổi) -----------------------------------> 200 snapshot
        |
        +-- true
              |
              v
        requester đã đăng nhập? (owner từ cookie ubg_auth, resolveOwner như D2)
              |
              +-- có -----------------------------------------------------> 200 snapshot
              |
              `-- không --------------------------------------------------! 401
                     { error: 'login required', reason: 'login' }
                     UI hiện thẻ gate: "Sign in · Create account · Back"
                     sau khi đăng nhập -> UI gọi lại đúng URL /api/share/:token

D4 (P2 change) — Send reservation on the quote page

  200 snapshot (cả hai nhánh trên)
        |
        +-- NEW: getScenario(row.scenarioId, viewer) khác null (người xem LÀ chủ) -> thêm `scenarioId`
        `-- không phải chủ / ẩn danh ---------------------------------------------> không có khoá đó

  /quote/:token  bấm SEND RESERVATION   (NEW, P2)
        |
        +-- chưa đăng nhập -----------------------------> AuthGate (Q-004) — như P3
        |
        +-- đã đăng nhập, share response có `scenarioId` (NEW: chỉ trả khi người xem LÀ chủ)
        |        -----------------------------------------> navigate /trip/:id/reserve
        |
        `-- đã đăng nhập, không phải chủ ----------------> t('quote.askOwner'): "Ask the person who
                                                            sent this quote to confirm the booking."
                                                            (Q-014 pending), không request

Data flow — issuing and reading a gated link

  POST /api/estimates/:id/share   (chủ nháp, riêng tư)
    getScenario(id, owner) ! 404
    latestSnapshot(scenario.id) ! 409 save first
    requiresLogin = scenario.owner.role !== 'guest'        <-- chốt LÚC PHÁT, không đọc lại sau
    createShareToken(id, owner, sha256(token), expiresAt, requiresLogin) -> (share_token)
    200 { url: '/quote/<token>', expiresAt }

  GET /api/share/:token   (công khai, không cookie mới, không Odoo)
    TOKEN_SHAPE ! 404 -> resolveShare ! 404 -> revoked/expired ! 410
    -> requiresLogin && session == null ! 401 { error:'login required', reason:'login' }
    -> latestSnapshot(scenarioId) ! 404 no-snapshot
    -> viewerRole = owner?.role ?? 'guest'   (vai NGƯỜI XEM, không phải vai chủ)
    -> model       = redactForRole(snapshot.model, viewerRole)
       retailModel = viewerRole === 'guest' ? null : redactForRole(snapshot.retailModel, viewerRole)
    200 { seq, trip, model, retailModel, computedAt, sample, currency, label: null,
          scenarioId?   <-- NEW (P2): chỉ khi getScenario(row.scenarioId, viewer) !== null }

F05 Flow A: form → Plan my trip → Result → Save → Get final quote

changed docs/flows/F05-flow-a-result.md

A form with no numbers, Plan my trip, an editable Result page, Update price, Save trip, Get final quote.

Flow A (F05 D1)

Flow A: from the form to a shareable quote Sequence showing Plan my trip and Update price as the only two Odoo compute calls, edits on the Result page making no request, Save trip and Get final quote reading and writing only the draft store, and Send reservation handing off to the booking flow. OPT[owner signed in]Edit guests, rooms, dive gridprice card → Outdated · 0 requestsPlan my tripPOST /api/estimatescompute (call 1)modelnewScenario + working model201 → /trip/:idUpdate price · PATCH save:truecompute (call 2)Save trip · POST commitrevision + snapshot · 0 OdooGet final quote → share_tokenSend reservation → F07Guest / agentbrowserAppReact SPABFFHono /api/*Draft storePostgresOdooestimate-apiLEGENDthe only two compute callscallreturnBFF decides what reaches Odoo
Only two compute calls in the whole life of a quote; edits on Result make no request; Save and reopen read the app's store.
Original ASCII diagrams (3)

Copied verbatim from the repo; the text inside is Vietnamese, as in the source.

D1 — Flow A: Form → Plan my trip → Result → Update price → Save → Get final quote

 [Khách/Agent]     [UI React]              [BFF Hono]                              [Odoo]
    |                  |                       |                                       |
    | mở "/" (chưa      | GET /api/me ---------> owner.role                              |
    |  có giá)          |   NEW: vai guest/agent/instructor (và ẩn danh) -> ẨN radio      |
    |                  |        Guests/Agent/Instructor, hiện "Quoting as <vai>";        |
    |                  |        staff -> radio giữ; /api/me chưa về -> không hiện cả hai |
    | điền câu hỏi       | DateField min/max, MultiPick cap 5 — gợi ý, không chặn         |
    |                  |   (form noValidate, L-022)                                     |
    | PLAN MY TRIP      | NEW guestType = forcedGuestType(role) (chưa biết vai -> retail)|
    |                  | NEW validateTrip(trip) thuần, không mạng                        |
    |                  |   ! error -> state 'invalid', ô aria-invalid, t('invalid.<code>')
    |                  | POST /api/estimates =>| TripSchema.parse (trong fillTrip)       |
    |                  |                       |   ! ZodError -> 422 {error, fields}  CHANGED
    |                  |                       |     (map issue.path, hết "Trip không hợp lệ" trống)
    |                  |                       | fillTrip (6 trường bắt buộc, giữ nguyên) |
    |                  |                       | NEW validateTrip(trip,{today,role})      |
    |                  |                       |   ! error -> 422 {error, code, fields, issues}
    |                  |                       | NEW deriveGuestType(owner.role, trip)    |
    |                  |                       | NEW bookedDaysAhead = days(checkIn-today)|
    |                  |                       | -> newScenario -> (draft) insert         |
    |                  |                       | ====================================>| compute
    |                  |                       |<======================================| model
    |                  |                       | checkComputeSane (giữ)                   |
    |                  |<== 201 {id, model,    |                                       |
    |                  |     issues} ==========|  CHANGED issues = sane + warn (room-empty)
    |                  | navigate -> /trip/:id  |                                       |
    |                  |                       |                                       |
    | Result render 4 khối: Trip summary · Guests table (sửa được) ·                   |
    |   Dive schedule grid (sửa được) · Itinerary (chỉ đọc: dayPlans/presence/           |
    |   vanRuns/covers) · Price card — CHANGED: Price card hiện cả `issues` (sane + warn)|
    |   VÀ `model.warnings` (của Odoo, ví dụ "no boat picked yet for Ana") dưới         |
    |   "From the booking engine", không chỉ ở /quote                                    |
    |                  |                       |                                       |
    | sửa Guests /      | (không gọi mạng)       |                                       |
    |  Dive schedule     | Price card -> "Outdated" + nút Update price hiện ra           |
    |                  |                       |                                       |
    | UPDATE PRICE      | NEW validateTrip trước |                                       |
    |                  |   ! error -> 'invalid', KHÔNG PATCH                            |
    |                  | PATCH /:id {trip,ui,  | cùng chuỗi: parse -> fillTrip ->        |
    |                  |   save:true} ========>|  validateTrip -> deriveGuestType ->     |
    |                  |                       |  bookedDaysAhead                        |
    |                  |                       | ====================================>| compute
    |                  |                       |<======================================| model
    |                  |                       | sane -> saveWorkingTrip -> (draft) update
    |                  |<== 200 {model,issues} |                                       |
    |                  | render lại cả 4 khối theo model mới                            |
    |                  |                       |                                       |
    | SAVE TRIP         | POST /:id/commit ====>| compute (cache hit, không gọi lại Odoo)|
    |                  |                       | -> (draft) insert revision + snapshot |
    |                  |<== "Saved as version n"|                                       |
    |                  |                       |                                       |
    | GET FINAL QUOTE   | CHANGED (P2): chỉ bật khi savedIsCurrent (bản Save = trip đang thấy)
    |                  | POST /:id/share =====>| -> (draft) insert share_token          |
    |                  |<== /quote/<token> ====|                                       |
    |                  | CHANGED (25/09, A8): navigate -> /quote/<token>; trang quote có khối
    |                  |   "Share this quote" (link đầy đủ + Copy + Back to trip) cho CHỦ  |
    |                  |                       |                                       |
    | (sau GET FINAL QUOTE, đã Save seq n)                                                |
    | SEND RESERVATION  | NEW: xem F07 D1 — chỉ chủ đã đăng nhập; gửi revision.trip seq n |
    |                  |   POST /:id/submit ==>| submission pending -> Odoo submit -> confirmed
    |                  |<== {state, folioId} ==|                                       |
    |                  |                       |                                       |
    | (mở lại /trip/:id  | GET /api/estimates/:id| -> (draft) select workingTrip +        |
    |  sau đó)           |   =================>|    latest snapshot                    |
    |                  |<== 200 {workingTrip,  |    — 0 lời gọi Odoo                    |
    |                  |     model} ===========|                                       |
    | (mở lại /trip/:id)| NEW: GET /:id/submission -> banner "Reservation sent", Send khoá |
    !  POST /api/estimates thiếu trường -> 422 -> hint ngay trên form, KHÔNG điều
    !     hướng sang /trip/:id
    !  compute lỗi 502/503/504 -> Price card "Odoo busy, Try again", bảng vẫn sửa được
    !  validateTrip client đỏ -> KHÔNG gọi mạng; server vẫn kiểm lại (script/API lách UI)
    !  non-staff gửi guestType khác vai phiên -> BỊ THAY, không 4xx, log guestTypeOverridden

D2 — Result data flow: from an edited cell to the model

  client state: { trip: TripShape, ui, model, dirty: boolean }

  Guests table (sửa 1 dòng) ---> trip.guests[i].{name,diver,meals,transport,
                                    roomId,courses,arrive,depart}
  From–to quick fill          ---> guests[i].days[date] = {dive:true}  (mọi ngày trong khoảng)
  Dive schedule cell          ---> guests[i].days[date].{dive,third,night,boatId}
                                    (grid = nguồn sự thật)
  + Add guest                 ---> guests.push(g<n>)
  + Add room                  ---> rooms.push({id:'r<n>', type})

  bất kỳ sửa nào -----------------------------------------------------> dirty = true

  bấm UPDATE PRICE
    |
    v
  PATCH /api/estimates/:id {trip, ui, save:true}
    |
    | ===================================================================> [Odoo] compute
    |<===================================================================|
    v
  response { model, issues, retailModel, pendingVerification } ---> dirty = false

  render map (client không tự tính tiền — chỉ đọc lại model):
    model.kpis.{nights,guests}, model.diveDates                ---> Trip summary
    model.roomAvailability[type] + rates.courseRates keys
      + GET /api/boats -----------------------------------> các ô select (Guests / Dive schedule)
    model.dayPlans[date].divers, presence[date],
      vanRuns, covers[date]                                 ---> Itinerary (chỉ đọc)
    model.quotes / model.kpis / model.foc / model.retail_model ---> Price card

D5 — Price card state machine on Result

                     sửa Guests/Dive schedule
        +--------------------------------------------+
        |                                              v
   +---------+                                    +-----------+
   | priced  |<---------------------------+        | outdated  |  (viền hổ phách,
   +---------+   success (model mới)      |        +-----------+   nút Update price)
        ^                                 |              |
        | seq+1 + toast                   |              | bấm UPDATE PRICE
        |                                 |              v
   +---------+   bấm SAVE TRIP     +-----------+   +-----------+
   | priced  |<--------------------|  priced   |   |  pricing  |  (skeleton)
   +---------+                     +-----------+   +-----------+
        ^                                             |     |
        |                                  thất bại   |     | 422
        |                            +-----------+     |     v
        +----------------------------|unavailable|<-----+   +----------+
              "Try again"            +-----------+           | invalid  |  (field hint,
                                    (Try again ->             +----------+   bảng vẫn
                                     pricing)                      |         sửa được)
                                                                   | sửa field -> outdated

  Get final quote: bật chỉ khi seq >= 1 và state === 'priced' (không phải outdated)
    -> đang outdated: nút mờ + hint "Update price first"

  Reopen /trip/:id: có snapshot/model -> bắt đầu ở 'priced'; không có -> 'outdated'

F06 Staff: Ops Sheet and Settings

changed docs/flows/F06-ops-settings.md

An Ops Sheet per day, printed for the morning meeting, and Settings that apply without a restart.

Trip workspace tabs by role

Trip workspace tabs by role Matrix of the five trip workspace tabs against four session roles: everyone sees Trip and Guest Estimates, agents also see Agent View, and only staff see Ops Sheet and Settings. TAB SHOWN PER SESSION ROLETrip & Guests/trip/:idGuest Estimates/estimatesAgent View/agentOps Sheet/opsSettings/settings · globalAnonymous · guestubg_sid or noneInstructorOdoo keyAgentOdoo keyStaffOdoo keyLEGENDtab visibletab hiddenAgent View: agents and staff only
Hiding a tab is cosmetic; the server still gates by the cookie role. Switching tabs makes no Odoo call.
Original ASCII diagrams (2)

Copied verbatim from the repo; the text inside is Vietnamese, as in the source.

D3 — Ops Sheet (staff) data flow

 [Staff]           [UI React]              [BFF Hono]                              [Odoo/Draft]
    |                  |                       |                                       |
    | mở /ops/:id       | GET /api/me =========>| resolveOwner                          |
    |                  |<== role != 'staff' ===| role != 'staff' -> UI: thẻ 404          |
    |                  |                       |                                       |
    |                  | GET /api/estimates/:id| owner.role==='staff' ?                 |
    |                  |   =================>|   getScenarioAsStaff(id)  (bỏ lọc chủ)  |
    |                  |                       |   : getScenario(id, owner)             |
    |                  |                       | -> (draft) select scenario ----------->|
    |                  |                       | -> latestSnapshot --------------------->|
    |                  |<== {workingTrip,      |                                       |
    |                  |     latest.model} ====|                                       |
    |                  |                       |                                       |
    | UI dựng theo stayDates[i]:                                                        |
    |   Front desk    <- gwin[g].{a,dep}  (arrivals / departures)                        |
    |   Housekeeping  <- trip.rooms x guest.roomId -> roomNames                          |
    |   Dive centre   <- dayPlans[date].divers, gộp theo boatId (đếm dive/third/night)   |
    |   Kitchen       <- covers[date]                                                    |
    |   Transfers     <- vanRuns (vans, vanCount, pax)                                   |
    | PRINT -> print CSS (không gọi mạng thêm)                                           |
    |                  |                       |                                       |
    | (danh sách)       | GET /api/estimates?  | owner.role==='staff' ->                |
    |                  |   scope=all ========>|   listAllScenarios()                    |
    |                  |<== toàn bộ scenario ==|                                       |
    !  không phải staff mà gọi ?scope=all -> BFF BỎ QUA scope, trả listOwn(owner) như
    !     thường (không phải 403, không phải {items:[]} rỗng)

D4 — Settings write path and reload (B-011)

 [Staff]        [UI React]           [BFF Hono]                settingsRef      [Draft store]
    |               |                    |                          |                 |
    | /settings      | GET /api/          | role != 'staff' ? 404    |                 |
    |               |  settings/all ===>|                          |                 |
    |               |                    | -------------------------------------------> getSettings()
    |               |                    |<---------------------------------------------|
    |               |<== 200 {settings} =|                          |                 |
    |               |                    |                          |                 |
    | sửa 1 field    | PUT /api/settings/ | zod theo key:            |                 |
    |               |  :key {value} ====>|  draft_ttl_days        int > 0             |
    |               |                    |  raw_text_ttl_days     int > 0             |
    |               |                    |  share_token_ttl_days  int > 0 | null       |
    |               |                    |  display_currency      3 chữ                |
    |               |                    | ! zod fail -> 422        |                 |
    |               |                    | -------------------------------------------> setSetting(key,value)
    |               |                    |                          |     upsert, updated_at=now
    |               |                    | settingsRef.reload() ---->|                 |
    |               |                    |   = mergeSettings( ------------------------> getSettings()
    |               |                    |       await getSettings())<-----------------|
    |               |<== 200 {settings} =|                          |                 |
    |               |                    |                          |                 |
    | (mọi request khác) --------------> đọc settingsRef.current (không phải bản đã   |
    |               |                    đóng băng)                 |                 |
    |               |                    |                          |                 |
    | GET /api/settings (public, không đổi) ------------------------------------------|

F07 Booking: Send reservation → Reserve → one booking/submit → Booking

built docs/flows/F07-booking.md

The submission row is written before the Odoo call, so a double click still sends once.

States of one reservation (F07 D2)

Booking submission states State machine of one reservation: a pending row is written before the single Odoo call, then becomes confirmed, failed (retry allowed with a new row) or unknown (locked for staff); a closed gate writes nothing. POST submitgate closed · 503200 successtimeout · networkHTTP error · breakerretry = new rowSTATEpendingrow written firstSend lockedSTATEconfirmedfolio_id · scenario submittedSend lockedSTATEunknownOdoo may have a foliolocked · staff handlesSTATEfailedno folioSend open againno rownothing writtenLEGENDno submission yethappy endretry creates a new row
One Odoo call, no retry. failed allows a resend with a new row; unknown stays locked for staff.
Original ASCII diagrams (4)

Copied verbatim from the repo; the text inside is Vietnamese, as in the source.

D1 — Send reservation → folio in Odoo

 [Chủ báo giá]        [UI React]                  [BFF Hono]                                 [Odoo]
    |                     |                           |                                          |
    | (đã Save trip,      |                           |                                          |
    |  revision seq n,    |                           |                                          |
    |  snapshot có sẵn)   |                           |                                          |
    |                     |                           |                                          |
    | bấm SEND            | reservationBlockedReason (thuần, không mạng), thứ tự:                |
    |  RESERVATION        |   'sent'   hàng sống (pending/confirmed/unknown) -> khoá,            |
    |                     |            title "Reservation already sent"                          |
    |                     |   'update' giá chưa khớp trip -> khoá "Update the price first"       |
    |                     |   'save'   seq = 0 HOẶC !savedIsCurrent (khác plan) -> khoá          |
    |                     |            "Save the trip first"                                      |
    |                     |   'login'  ẩn danh -> nút VẪN bấm được, mở AuthGate tại chỗ          |
    |                     |            (Q-004), KHÔNG gọi mạng                                    |
    |                     | navigate /trip/:id/reserve |                                          |
    |                     | GET /api/me -------------> name, login -> điền sẵn contact           |
    |                     | GET /api/estimates/:id +   |                                          |
    |                     |   GET /:id/revisions/:seq  | (DB mình, 0 Odoo) so JSON workingTrip   |
    |                     |   ! khác -> "You have unsaved changes…", Confirm khoá (khác plan)    |
    |                     |                           |                                          |
    | sửa contact, bấm    | cờ inFlight + disabled:   |                                          |
    |  CONFIRM            |   bấm đúp = một POST      |                                          |
    |                     | POST /api/estimates/:id/  |                                          |
    |                     |   submit {seq, contact} =>| 1. owner/phiên ubg_auth? không ->        |
    |                     |                           |    401 {reason:'login'}, KHÔNG cookie    |
    |                     |                           |    mới, TRƯỚC mọi thứ (khác plan:        |
    |                     |                           |    không ensureOwner)                     |
    |                     |                           | 2. zod body ! 422 {fields} (chỉ tên      |
    |                     |                           |    trường, không giá trị)                 |
    |                     |                           | 3. getScenario(id, owner) ! 404          |
    |                     |                           | 4. latestSnapshot ! 409 'no-snapshot'    |
    |                     |                           |    seq !== latest.seq ! 409 'stale'      |
    |                     |                           | 5. cửa: ODOO_SUBMIT_ENABLED=1 HOẶC       |
    |                     |                           |    (FIXTURE_MODE=1 VÀ mode fixture)      |
    |                     |                           |    ! đóng -> 503 'closed', KHÔNG ghi gì  |
    |                     |                           | 6. createSubmission -> INSERT pending    |
    |                     |                           |    TRƯỚC lời gọi Odoo -> (submission)    |
    |                     |                           |    ! 'already' (unique index) -> 409     |
    |                     |                           |      {reason:'already', state}           |
    |                     |                           | 7. gateway.submit({contact,              |
    |                     |                           |      trip: revision.trip})               |
    |                     |                           | =======================================>| POST /v1/booking/submit
    |                     |                           |   timeout 20s (ODOO_TIMEOUT_SUBMIT_MS),  |  header api-key
    |                     |                           |   retries 0, breaker chung với compute   |  sanitize trip,
    |                     |                           |<=======================================|  tạo folio + draft
    |                     |                           | {success, folio_id, order_ids}           |  quotations
    |                     |                           | 8. markSubmission (chỉ từ 'pending')     |
    |                     |                           |    -> 'confirmed' + folio_id/order_ids;  |
    |                     |                           |    setScenarioStatus('submitted')        |
    |                     |<== 200 {state:'confirmed', |                                          |
    |                     |     folioId, orderIds,    |                                          |
    |                     |     sample, seq} =========|                                          |
    |                     | navigate /trip/:id/booking (cả khi 409 'already' và 502 'unknown')   |
    |                     |   GET /:id/submission ===>| latestSubmission(id, owner) — không trả  |
    |                     |                           |   contact                                 |
    |                     |   "Reservation sent · Folio #123" (fixture: "Folio number pending"   |
    |                     |   + "Sample — no folio was created"), "What happens next…",         |
    |                     |   Print / PDF, Back to trip                                           |
    |                     |                           |                                          |
    | (mở lại /trip/:id)  | đã đăng nhập -> GET /:id/ |                                          |
    |                     |   submission (một lần) ==>| đọc hàng mới nhất theo chủ HIỆN TẠI     |
    |                     |<== state =================|                                          |
    |                     | banner trên thẻ giá "Reservation sent" + View reservation;           |
    |                     |   Send khoá theo state                                                |
    |                     |                           |                                          |
    !  Odoo trả HTTP lỗi (4xx/5xx, OdooHttpError) hoặc 200 {success:false} -> 'failed' (+error)     |
    !     -> 502 {reason:'rejected', status}; UI "did not accept… Try again" (hàng mới khi bấm lại)|
    !  breaker mở (BreakerOpenError, KHÔNG có lời gọi nào đi) -> 'failed' (error 'breaker-open')   |
    !     -> 503 {reason:'busy'}, cho bấm lại (khác plan, D-037)                                    |
    !  timeout / lỗi mạng -> 'unknown' (Odoo CÓ THỂ đã tạo folio) -> 502 {reason:'unknown'} ->     |
    !     Booking "We could not confirm your reservation… do not send it again"; Send khoá vĩnh    |
    !     viễn ở P2, staff xử (B-034, D-039)                                                       |
    !  markSubmission trả null (hàng không còn pending) -> 500 {reason:'unknown'}                  |
    !  fixture mode -> fixture twin trả {success:true, folio_id:null, order_ids:null}, sample:true |
    !     — hình dạng theo spec, không phải hành vi Odoo (B-033, D-038)                             |

D2 — Submission state machine

                      (none)
                        |
                        |  ! cửa đóng (503 'closed') -> KHÔNG có hàng, vẫn (none)
                        |
                        | POST /submit — INSERT trước khi gọi Odoo
                        v
                   +-----------+
                   |  pending  |  khoá nút Send
                   +-----------+
                    /    |    \
    200 success   /     |     \   timeout / network
     +-----------+      |      +-----------------+
     v                  |                         v
+-----------+           |  HTTP 4xx/5xx,    +-----------+
| confirmed |           |  success:false,   |  unknown  |
+-----------+           |  breaker mở       +-----------+
  khoá Send             v                     khoá Send
  (vĩnh viễn ở    +-----------+               (vĩnh viễn ở P2 —
   P2; amend      |  failed   |                Odoo CÓ THỂ đã tạo
   B-035)         +-----------+                folio; staff xử
  scenario.status   KHÔNG khoá Send —           B-034)
  ='submitted'      bấm lại -> hàng pending
                    MỚI, hàng cũ giữ 'failed'

D2 (cont.) — Where the data goes on submit

  Reserve form {name, email, phone?}  ---> POST /submit body.contact (trim; phone rỗng -> null)
       |                                     |
       |                                     +--> (submission).contact jsonb  — PII, không vào log,
       |                                     |      không vào submission.error, không trả ở GET
       |                                     +==> Odoo SubmitRequest.contact
  (revision).trip của seq mới nhất --------------> Odoo SubmitRequest.trip (toOdooTrip; không bao giờ
                                                    working trip hay trip trong payload)
  Odoo {folio_id, order_ids} ------------------> (submission).folio_id / order_ids
                                               -> 200 {folioId, orderIds} -> trang Booking
  gateway mode fixture ------------------------> (submission).sample = true -> "Sample — no folio was created"

D3 — ERD: the submission table

 +---------------------------------------------+
 | submission                                  |
 |---------------------------------------------|
 | id uuid pk default gen_random_uuid()        |
 | scenario_id uuid not null                   |
 |   fk -> scenario.id on delete cascade       |----+
 | revision_seq int not null                   |    |
 | snapshot_id uuid not null fk -> snapshot.id |----+---+
 | state text not null check in (              |    |   |
 |   pending, confirmed, failed, unknown)      |    |   |
 | contact jsonb not null (name,email,phone)   |    |   |
 |   -- PII, không log dạng thô                |    |   |
 | folio_id int null                           |    |   |
 | order_ids int[] null                        |    |   |
 | error text null  ('HTTP 4xx', 'odoo:        |    |   |
 |   success=false', 'breaker-open', 'timeout',|    |   |
 |   'network' — không PII)                    |    |   |
 | sample boolean not null default false       |    |   |
 | submitted_by_role text not null             |    |   |
 | submitted_by_ref text not null              |    |   |
 | created_at timestamptz not null default now |    |   |
 | updated_at timestamptz not null default now |    |   |
 +---------------------------------------------+    |   |
                                                    v   v
 +--------------------------+   +--------------------------+
 | scenario (đã có, P1)     |   | revision / snapshot      |
 |--------------------------|   | (đã có, P1/P4)           |
 | id uuid pk               |   |--------------------------|
 | owner_role / owner_ref   |   | revision.id, seq, trip   |
 | status 'draft' |         |   | snapshot.id, model, role |
 |   'submitted' | 'expired'|   +--------------------------+
 +--------------------------+

 Partial unique index:
   create unique index submission_one_live on submission (scenario_id)
     where state in ('pending','confirmed','unknown')
   -- một hàng "sống" mỗi scenario; 'failed' không tính, cho phép hàng retry mới.
   -- pg store bắt 23505 trên đúng tên index này -> 'already' (đổi tên là đổi cả hai).
 Index đọc: submission_scenario_idx (scenario_id, created_at desc) — latestSubmission.

 RLS: bật trên submission, KHÔNG có policy -> chỉ BFF nối bằng DATABASE_URL.

Part 4Against Casa's current tool (strategy-lab)

Casa quotes today with an internal tool called strategy-lab (five tabs) and the Odoo side's role workflow guide. The old tool prices in the browser and never stores what was quoted; this app takes every number from Odoo and keeps every version.

14✅ built
14🟡 partial
13⬜ not yet
3⛔ Odoo's job
44rows in total

Key: ✅ built · 🟡 partial · ⬜ not yet · ⛔ Odoo's job. Priority: P1 guest-visible or blocks a demo · P2 makes staff faster · P3 later. Table as of 25 Sep; the workspace tabs in progress will change AV-02.

Trip & Guests: entering the trip

IDFunctionIn the app todaySt.Depends onPriority
TG-01Trip form: dates, guest count, divers, dive window, transfer, full board/ Block1Form✅——
TG-02Guest type follows the signed-in role (no free choice)"Quoting as …", staff keep the radio✅——
TG-03Guests table: name, dives, dive days, meals, transfer, room, course, FOC, arrive/depart, comment/trip/:id GuestsTable✅——
TG-04"Everyone: full trip dates", "Auto-name guests"—⬜—P3
TG-05Pick real rooms (Standard A, B…) free on those dates; full rooms locked; a second guest in the same room shares itOnly rooms of the trip; + Add room by type🟡GET /v1/estimate/rooms (exists)P1
TG-06Room board: drag guests into rooms, rooms left (2/16 Std…), pax/cap, nightly tier, "Auto-fill rooms"—⬜TG-05P2
TG-07Dive schedule: days × guests, dive/3rd/nightDiveSchedule✅——
TG-08Dive planner by boat: drag guests onto boats, per-boat cap, "sitting out", "Auto-assign boats", "Copy this day → all days"Boat select in the cell (hidden when there are no boats)🟡GET /v1/estimate/boats returns real boats (B-018)P2
TG-09Extra day/night DM per dive day—⬜Trip dmByDay / extraDMByDay (Odoo has them)P2
TG-10Van planner: move guests between vans, split cost per van or evenly, pick-up time, cap 6, overload warning—⬜Trip vanSplit, vanMeta (Odoo has them)P2
TG-11Add-ons & equipment (nitrox, camera, extra DM…): qty × price, per guest, date range—⬜Trip items (Odoo has it, ≤ 50)P2
TG-12FOC: tickable only with 6 guests, "FOC limit reached", auto-untick when lostFOC checkbox without a limit🟡model.foc (exists)P1
TG-13Summary line: nights · guests · guest-nights · dive days · van runs · FOC earned/markedSummary chips (missing guest-nights, van runs, FOC)🟡—P3
TG-14Validation before pricing (dates, dive window, empty room…)validateTrip on both ends✅Q-009..Q-012—
TG-15Trip sheet PDF (guests, rooms, dive days, transfers, add-ons)—⬜—P3

Agent View (staff + agent, hidden from instructors)

IDFunctionIn the app todaySt.Depends onPriority
AV-01Two columns retail / net per guest"Agent view" toggle on the quote page🟡retail_model (present once the agent is verified)P2
AV-02A separate screen on the trip, hidden from instructors and guests— (currently shown to instructors)⬜—P1
AV-034 tiles: client retail value · net to Casa · agent margin · FOC incentive 5+1 (earned, guests to the next slot)—⬜commission field from Odoo (B-012); model.focP2
AV-04"Copy partner summary"—⬜AV-03P3

Guest Estimates: what the guest sees

IDFunctionIn the app todaySt.Depends onPriority
GE-01One quote card per guest + group total/quote/:token Per guest / Group✅——
GE-02Header: stay, nights, room, group, booked via, prepared, valid untilOnly "Version n · priced …"🟡rates.terms (validDays)P1
GE-03Itinerary written as sentences per guest per day ("Dive day. 2-dive trip on CE1 with 4 other divers…")One itinerary table for the whole group🟡dayPlans, vanRuns, presence (exist)P1
GE-04Grouped prices Rooms / Dining / Diving (per day) / Transport, discount lines, "you save vs retail"Flat price lines from quotes[].lines🟡Does quotes[].lines carry cat? Check with a real keyP1
GE-05Terms: quote validity, 50% deposit, subject to room/boat availability—⬜rates.terms (validDays, depositPct)P1
GE-06Pick guest ‹ › / whole group; show or hide prices / discounts—⬜—P3
GE-07Copy text / PDF per guest / Export allPrint CSS for the whole page🟡—P2
GE-08Share link for recipients; the owner gets a Share block✅ Share this quote✅——

Ops Sheet (staff)

IDFunctionIn the app todaySt.Depends onPriority
OS-01One sheet per day: front desk, housekeeping, dive centre, kitchen, transfers; printable/ops/:id✅——
OS-02Handover notes in 3 boxes (front desk / dive / kitchen & bar), saved per trip—⬜New column in the storeP2
OS-03Front-desk register with flags (courses…) and guest commentsCheck-in / check-out only🟡—P2
OS-04Housekeeping: nights used, peak occupancy / capRooms → guests only🟡roomNightsUsed, roomPeak (exist)P2
OS-05Dive centre: departures, DMs, tanks to fill, courses needing staffGroups by boat + counts only🟡Odoo does not return tanks/DM yet (B-020)P3
OS-06Kitchen: covers per meal (breakfast / lunch / dinner) + who arrives/leaves, peak x/capacityCovers per day only🟡covers is per day only — ask the Odoo ownerP3
OS-07Ops sheet PDFPrint CSS🟡—P3
OS-08List of every trip for staff/ops✅——

Settings (staff)

IDFunctionIn the app todaySt.Depends onPriority
ST-01App settings: draft retention, link expiry, currency/settings✅Q-001, Q-003—
ST-02Pricing Setup (room tiers, dive tiers, courses, transport, terms, commission %)—⛔Staff edit it in the Odoo UI—
ST-03Promotions (5+1, long-stay, early-bird, group dive, bundle)—⛔Odoo; staff only, via assumptions—
ST-04Scenario / price presets (pick any price list)—⛔pricelists + rates/manifest — off-limits for the app—
ST-05Profit & Costs, Marketing, Appearance—⬜Needs a staff key (only staff see cost); the lead decides if it is neededP3

Beyond the five tabs (only this app has it)

IDFunctionIn the app todaySt.Depends onPriority
UX-01Register / sign in / forgot password through Odoo—✅——
UX-02My quotes, versions, gated share links—✅——
UX-03Send reservation → Reserve → Booking (once, double-click safe)—✅ (fixture; waiting for Odoo to enable)——
UX-04EN / 中文, day / night—✅——

What unblocks the rest

  • Odoo side: a staff account; commission in compute (AV-03); confirm the submit endpoint name; covers per meal (OS-06); tanks and DMs per day (OS-05); real boats on staging (TG-08).
  • Casa: answer Q-001..Q-015; decide whether Profit & Costs belongs in the app (ST-05).

Part 5Open questions for Casa and the Odoo side

Anything that is Casa policy is not decided by the app: the question is recorded and the code runs on an interim value that configuration can change.

#QuestionInterim valueStatus
Q-001How long are drafts, snapshots and raw text kept? Are sent quotes kept forever?Drafts 90 days, text 30 days, sent never deleted; a settingmechanism set, numbers pending
Q-002Are quote links public or sign-in only?Guest links public, account links need sign-indecided
Q-003Which currency is shown; USD too?PHP; the setting only changes the labelmechanism set, numbers pending
Q-004Can an anonymous guest send a reservation?No, they sign in or register firstinterim
Q-005Does a link show the sent version or always the latest?Always the latest saved versionpending
Q-006Can an unverified agent save and share?Yes, at retail, with Pending verificationpending
Q-007Can individual guests pick a boat per day?Yes, once Odoo returns boatspending
Q-008Can guests dive on check-in and check-out days?Interim dive window is the whole staypending
Q-009Is there a zero-night day-use package?No: at least one nightpending
Q-010Can guests dive outside their stay?No: blockedpending
Q-011Can a room with no guests be quoted?Yes, with a warningpending
Q-012Can staff enter a trip with a past check-in?Staff exempt, other roles blockedpending
Q-013When an agent books for a client, whose contact goes on it?The signed-in person, editablepending
Q-014Can a link recipient send the reservation?No, only the quote ownerpending
Q-015After sending, does the app confirm by email or WhatsApp?No; the guest sees the Booking page, the front desk sees the foliopending

Still owed by the Odoo side

Open submit on staging

One real send to capture the response and confirm the folio matches the snapshot: P2's "done" criterion.

A staff account

A staff-role key to see cost and margin and capture a staff fixture.

commission for agents

A commission field in compute so Agent View can show margin and FOC incentive.

Boats and operations

Real boats on staging; tanks and DMs per day; covers per meal.

Behaviour questions

With a verified agent key, does Odoo still read guestType; the exact submit endpoint name.

Pinned spec

Add /v1/auth/* and /v1/estimate/boats to the spec; add rates_version.

Part 6Run and test

Run locally

npm install
cp bff/.env.example bff/.env.local   # then set SESSION_SECRET (≥ 32 chars)
npm run dev:app -w bff               # app + /api/* on http://localhost:5173, one process
curl http://localhost:5173/api/health   # -> {"ok":true,"mode":"fixture"}

Fixture mode

FIXTURE_MODE=1: the BFF answers with captured Odoo responses, 0 network calls, a Sample data label. Three fixture accounts (guest, agent, staff) exist; see the runbook in the repo.

Real mode

FIXTURE_MODE=0 plus the Odoo address and the guest service key in bff/.env.local (never committed). Never set ODOO_SUBMIT_ENABLED=1 without the lead and the Odoo side agreeing.

Store

npm run db:start runs Supabase locally. Without DATABASE_URL the RAM store is used and every draft is lost on restart.

Three fixture quirks that are not bugs

  • The model is fixed by trip shape and role: an anonymous trip always prices as a couple at ₱31,200; agent, instructor and staff sessions always get a 7-guest group at ₱245,310.
  • The boat list is empty, so the dive grid has no boat select.
  • Guest names on the price card (Ana, Ben…) come from the sample model; renaming in the table does not change them.

Test suites

CommandWhat it does
npm run typechecktsc for every workspace; green tests with red tsc is not done
npm run suite -- p1Runs one phase suite: p0..p4, p2, or all
npm run test:record -- --suite all --label "…"Typecheck + suite, appends one row to docs/ledgers/test-log.md

Latest all run (25 Sep 2026): 801 pass, 0 fail, 39 skipped. Technical detail: /estimator-tech.

Manual smoke test, scenarios A–H

ScenarioRoleWhat it checksSteps
A Anonymous Flow AanonymousForm without numbers, 1 POST, Outdated with no request, 1 PATCH, Save v1, quote page, open in private tab, Print, Send → Sign in, My quotes, bad token16
B Agent sign-inagentWrong password, throttling, "Quoting as Travel agent", draft merge, Pending verification, gated link, non-owner recipient13
C Register, forgot passwordpublicAgent fields, duplicate email, same forgot message, next= never leaves the site6
D Console and securityanyHttpOnly cookies, login returns no key, 401 on gated links, reopen is GET only5
E Guests, dive grid, itineraryagentAdd / remove guest, Add room, Course, 3rd / Night, shrink the dive window, 5-day itinerary, two blocking rules13
F Ops Sheet and SettingsstaffNon-staff blocked, every trip, 5 daily sheets with no money, Print, currency label only, in-row 42211
G Day themeanySwitch theme, survives F5, print stays black and white6
H Bookingguest with account, agentLocks on Update / Save, prefilled Reserve, double click = one 200, banner, 409 on resend, link recipient, closed gate 50312

More screenshots

Result page in the day theme
Day theme on Result.
Top of the quote page
Top of the quote page with the owner's Share block.
The form on a phone
Phone 390×844: form.
Result on a phone
Phone 390×844: Result.