Estimator Tools
Kỹ thuật · cho Trung và Nhật

Gọi API của Phillip: trường nào bắt buộc, trường nào im lặng làm sai giá.

Viết từ 14 lần gọi thật vào staging ngày 17/09/2026, không viết từ trí nhớ. Mỗi ví dụ trong trang này là request và response có thật.

estimate-api v1 · OpenAPI 3.1 · md5 14c078e9 · contracts/odoo/estimate-api.v1.json
Bản PDF để in hoặc gửi: tiếng Việt · English
  1. Spec chỉ bắt buộc một trường: trip. Nhưng có sáu trường mà thiếu là giá sai, vẫn trả 200, không cảnh báo.
  2. Thiếu diveFrom và diveTo là mất sạch tiền lặn. Đã đo: 10.000 peso biến mất, phản hồi vẫn 200 OK.
  3. trip.guestType không phải vai người gọi. Một cái quyết định giảm giá, cái kia quyết định bạn được thấy gì. Đừng lẫn.
  4. Trường lạ bị bỏ im lặng. Gửi agencyName thì Odoo trả 200 và quên nó. Không có lỗi nào báo cho bạn.
  5. Tốc độ không phải vấn đề. Năm lần gọi liên tiếp: 0,40 giây. Lần đầu sau khi nghỉ: 7,5 giây. Đặt timeout theo lần đầu, không theo trung bình.

Bản đồNăm cổng, hai cổng ghi được

https://casa-escondida-03-staging-web-37790335.dev.odoo.com/estimate-api
Cổng Làm gì Ghi? Gọi ẩn danh Đã đo
GET /v1/healthCòn sống khôngkhôngđược—
GET /v1/estimate/ratesBảng giá theo vai người gọikhôngđược705 B
GET /v1/estimate/roomsDanh sách phòng, kèm phòng trống nếu truyền ngàykhôngđược736 B
POST /v1/estimate/computeTính giá một chuyếnkhôngđược4–39 KB
POST /v1/booking/submitTạo folio và báo giá nháp trong OdooCÓchưa thử—

Bốn cổng đầu đã gọi thật nhiều lần. Cổng thứ năm chưa gọi lần nào, vì nó tạo hồ sơ thật trong staging của Phillip. Trước khi thử, báo Phillip một câu.

Câu hỏi chínhTrường nào bắt buộc

Câu trả lời có hai tầng, và tầng thứ hai mới là tầng nguy hiểm.

Tầng 1: thiếu thì lỗi 422, thấy ngay

CổngBắt buộc
computetrip
submittrip, contact.name, contact.email

Hết. Mọi trường khác trong Trip đều tùy chọn theo spec.

Tầng 2: thiếu thì giá sai, không ai báo

Trường Thiếu thì sao Đã đo
trip.diveFrom
trip.diveTo
Odoo lấy mặc định là tháng 10/2026 của chuyến mẫu. Ngày lặn của khách không nằm trong khoảng đó nên toàn bộ dòng tiền lặn biến mất. mất 10.000
trip.transportType Mặc định none. Khách có transport: true vẫn không sinh dòng xe nào. mất tiền xe
trip.checkIn
trip.checkOut
Mặc định 05 tới 09/10/2026 của chuyến mẫu. Tiền phòng và tiền ăn tính theo ngày sai. ngày sai
guest.roomId Khách không gắn phòng thì không có dòng tiền phòng cho người đó. mất tiền phòng
guest.days Khách có diver: true nhưng không có ngày nào thì không lặn ngày nào, và không có tiền lặn. mất tiền lặn
trip.guestType Mặc định retail. Đại lý bị tính giá lẻ, tức báo giá cao hơn thực tế 30% phần phòng. giá cao hơn thật

Bằng chứng: hai request chỉ khác nhau hai dòng

Cùng một khách, cùng một ngày lặn 02/12. Bên trái thiếu khoảng ngày lặn, bên phải có.

// A — thiếu diveFrom/diveTo          // B — có diveFrom/diveTo
{"checkIn":"2026-12-01",              {"checkIn":"2026-12-01",
 "checkOut":"2026-12-03",              "checkOut":"2026-12-03",
                                       "diveFrom":"2026-12-02",
                                       "diveTo":"2026-12-02",
 "guests":[{"diver":true,              "guests":[{"diver":true,
   "days":{"2026-12-02":                 "days":{"2026-12-02":
     {"dive":true}}}]}                      {"dive":true}}}]}

→ HTTP 200                            → HTTP 200
→ diveDates: ["2026-10-06",           → diveDates: ["2026-12-02"]
              "2026-10-07",           → lines: room 11,000
              "2026-10-08"]                    dive 10,000
→ lines: room 11,000                  → catRev.dive: 10,000
→ catRev.dive: 0                      → warnings: ["no boat picked yet"]
→ warnings: []

Phía A không có một cảnh báo nào. Đây là loại lỗi đi thẳng tới khách hàng trả tiền.

Luật cho BFF: luôn gửi đủ Trip, mọi trường, kể cả trường rỗng. Không bao giờ gửi PATCH một phần lên compute.

HìnhHình dạng của một Trip

Cấu trúc payload Trip Một Trip gồm khối trường đơn, ba mảng con là phòng, khách và dòng thủ công; mỗi khách lại chứa bản đồ ngày lặn theo ngày và danh sách khóa học, còn mỗi ngày lặn là một bản ghi bốn trường. TRIP · ONE OBJECT Trường đơn checkIn * checkOut * diveFrom * diveTo * guestType * transportType * label bookedDaysAhead vanSplit vanMeta{} dmByDay{} extraDMByDay{} rooms[] tối đa 30 · id, type, name guests[] tối đa 40 · nơi giá hình thành diver · meals · transport · foc items[] tối đa 50 · dòng thủ công days{} khóa là ngày YYYY-MM-DD courses[] tối đa 5 mỗi khách DayPlanEntry dive · third · night · boatId CHÚ GIẢI * luôn phải gửi, thiếu là giá sai mảng quan trọng nhất: mỗi phần tử là một bảng giá riêng
Sáu trường có dấu sao là sáu trường ở tầng 2 phía trên. Chúng không bắt buộc theo spec, nhưng bắt buộc trên thực tế.

Tra cứuTừng trường một

Trip

TrườngKiểuMặc địnhGhi chú
labelstring—Tên nhóm, chỉ để hiển thị
guestTyperetail | agent | instructorretailQuyết định mức giảm giá. Không phải vai người gọi API
transportTypenone | roundtrip | onewaynoneMặc định là không có xe. Phải gửi rõ.
checkIn checkOutstring YYYY-MM-DDchuyến mẫuSố đêm = checkOut trừ checkIn
diveFrom diveTostring YYYY-MM-DDtháng 10/2026 Chặn cửa: ngày lặn của khách ngoài khoảng này thì không được tính
bookedDaysAheadnumber0Số ngày đặt trước, dùng cho khuyến mãi đặt sớm
rooms[]Room[][]Tối đa 30
guests[]Guest[][]Tối đa 40
items[]CustomItem[][]Tối đa 50, dòng tự nhập
vanSplitequal | vehiclevehicleChia tiền xe theo đầu người hay theo lượt xe
vanMeta{}map{}Ghi đè giờ và giá từng lượt xe, khóa dạng vanA|2026-10-05|0
dmByDay{} extraDMByDay{}map{}Số thợ lặn dẫn và thợ lặn dẫn thêm, theo ngày

Guest

TrườngKiểuMặc địnhGhi chú
idstring—Phải duy nhất trong chuyến, dùng để nối với items.gids
namestring, ≤120Guest—
diverbooleantrueMặc định là CÓ lặn. Người không lặn phải gửi rõ false
mealsbooleantrue1.500 một ngày, không bao giờ được giảm giá
transportbooleantrueChỉ có tác dụng khi trip.transportType khác none
focbooleanfalseĐánh dấu người được miễn phí. Odoo tính ra số suất, người chọn ai.
roomIdstringnullPhải khớp một rooms[].id. Không khớp thì không có tiền phòng.
courses[]dsd refresher ow aow rescue[]Tối đa 5. Cả năm loại đều tính được, dù rates chỉ liệt kê ba.
days{}map YYYY-MM-DD → DayPlanEntry{}Chỉ ngày nằm trong khoảng diveFrom–diveTo mới được tính
arrive departstring YYYY-MM-DD""Đến muộn hoặc về sớm. Ảnh hưởng tiền phòng chia theo đêm.
vanA vanDstring, ≤120null Bẫy: công cụ của Sky lưu số nguyên. Gửi số là lỗi 422.
commentstring, ≤500""—

Room · DayPlanEntry · CustomItem · VanMeta

Đối tượngTrườngGhi chú
Roomid, type, nametype là standard, deluxe hoặc suite. name gắn vào phòng thật, ví dụ "Deluxe B". Tên sai không báo lỗi, chỉ mất ràng buộc.
DayPlanEntrydive, third, night, boatIdTất cả boolean trừ boatId. Không chọn tàu thì vẫn tính tiền, chỉ thêm một cảnh báo.
CustomItemid, name, price, qty, mode, date, dateTo, gids[]Dòng tự nhập, dùng cho mọi thứ Odoo không có sẵn. mode là chuỗi tự do trong spec, nhưng công cụ chỉ dùng each và split.
VanMetadate, time, price, focGhi đè một lượt xe cụ thể. Khóa của map là vanA|ngày|số thứ tự.

Dễ nhầmHai chữ "vai" khác nhau

trip.guestType

  • Nằm trong payload, do bạn gửi lên
  • Quyết định giá bao nhiêu: đại lý giảm 30% phần phòng
  • Khách lẻ, đại lý, huấn luyện viên

Vai người gọi (response.role)

  • Odoo tự xác định từ khóa truy cập, bạn không gửi được
  • Quyết định bạn thấy gì: giá vốn, lãi, bảng giá so sánh
  • Gọi ẩn danh hôm nay luôn ra guest
Vai người gọi model.kpis.cost model.costs retail_model assumptions Gửi assumptions lên thì sao
guestnullnullnullnullBị bỏ qua, đã thử
agentnullnullcónullBị bỏ qua
staffcócócócóĐược chấp nhận, trộn đè lên giá mặc định

Ba dòng trên lấy từ spec. Chỉ dòng guest đã kiểm chứng thật, vì chưa có khóa truy cập cho hai vai kia. Đó là mục số một trong danh sách gửi Phillip.

POST /v1/estimate/computeVí dụ chạy được, sao chép là dùng

Hai khách, hai đêm phòng Standard, một người lặn một ngày, ăn cả ngày. Đây là request nhỏ nhất mà vẫn đúng giá.

curl -X POST https://casa-escondida-03-staging-web-37790335.dev.odoo.com/estimate-api/v1/estimate/compute \
  -H "content-type: application/json" -d '{
  "trip": {
    "label": "Retail couple",
    "guestType": "retail",
    "transportType": "none",
    "checkIn": "2026-11-20", "checkOut": "2026-11-22",
    "diveFrom": "2026-11-21", "diveTo": "2026-11-21",
    "bookedDaysAhead": 30,
    "rooms": [ { "id": "r1", "type": "standard" } ],
    "guests": [
      { "id": "g1", "name": "Ana", "diver": true,  "meals": true, "transport": false,
        "foc": false, "roomId": "r1", "courses": [],
        "days": { "2026-11-21": { "dive": true, "third": false, "night": false } } },
      { "id": "g2", "name": "Ben", "diver": false, "meals": true, "transport": false,
        "foc": false, "roomId": "r1", "courses": [], "days": {} }
    ],
    "items": []
  }
}'

Trả về, đã rút gọn:

{
  "ok": true,
  "role": "guest",
  "retail_model": null,          // chỉ staff và agent mới có
  "assumptions": null,           // chỉ staff mới có
  "model": {
    "N": 2,                                        // số đêm
    "stayDates": ["2026-11-20", "2026-11-21"],
    "diveDates": ["2026-11-21"],
    "quotes": [
      { "g": { "id": "g1", "name": "Ana", ... },   // cả đối tượng khách, lặp lại
        "isFoc": false,
        "lines": [
          { "cat": "room",  "label": "Standard A — 2 nights",
            "sub": "nightly rate ÷ that night's roommates, summed over your stay",
            "gross": 7600, "discs": [], "net": 7600 },
          { "cat": "meals", "label": "Full board — 2 days",  "gross": 3000, ... },
          { "cat": "dive",  "label": "Boat dives — Sat, Nov 21",
            "sub": "2-dive boat trip · 1 diver out", "gross": 10000, ... }
        ],
        "gross": 20600, "total": 20600, "discountTotal": 0 },
      { "g": { "id": "g2", "name": "Ben", ... }, "total": 10600, ... }
    ],
    "catRev":  { "room": 15200, "meals": 6000, "dive": 10000, "course": 0,
                 "transport": 0, "gear": 0, "extras": 0 },
    "kpis":    { "revenue": 31200, "cost": null, "profit": null, "margin": null,
                 "rpgn": 7800, "discounts": 0, "guests": 2, "nights": 2 },
    "foc":     { "ok": true, "per": 5, "bracket": 0, "entitled": 0, "marked": 0,
                 "stay": {...}, "dive": {...} },
    "warnings": [ { "level": "warn", "text": "Sat, Nov 21: no boat picked yet for Ana." } ],
    "dayPlans": [...], "vanRuns": [...], "presence": {...}, "covers": {...},
    "roomNames": {...}, "roomAvailability": {...}
  }
}

Đọc phần trả về

KhóaLà gìDùng cho UI thế nào
quotes[]Một khối cho mỗi khách, mỗi khối có các dòng tiềnĐây là thứ hiển thị cho khách. label và sub đã viết sẵn tiếng Anh, dùng thẳng được.
catRevTổng theo nhóm: phòng, ăn, lặn, khóa học, xe, đồ thuê, thêmBảng tóm tắt một dòng mỗi nhóm
kpisDoanh thu, giá vốn, lãi, biên, doanh thu mỗi đêm kháchBốn ô cuối chỉ hiện khi vai là staff
focĐược mấy suất miễn phí, đã đánh dấu mấy ngườiNếu entitled lớn hơn marked thì nhắc nhân viên chọn người
warnings[]Cảnh báo vận hành, không phải lỗiHiện dưới dạng nhắc việc, không chặn thao tác
roomAvailabilityPhòng còn trống trong đúng khoảng ngày của chuyếnDùng để đổ vào ô chọn phòng, khỏi gọi rooms riêng
dayPlans vanRuns presenceDữ liệu vận hành theo ngày Nặng: mỗi mục nhúng lại cả đối tượng khách. Chiếm 27 trong 39 KB của ví dụ 7 khách.

LỗiBốn dạng, tất cả đều 422

// thiếu trường bắt buộc
{"detail":[{"type":"missing","loc":["body","trip"],"msg":"Field required","input":{}}]}

// sai enum
{"detail":[{"type":"enum","loc":["body","trip","guestType"],
            "msg":"Input should be 'retail', 'agent' or 'instructor'","input":"vip",
            "ctx":{"expected":"'retail', 'agent' or 'instructor'"}}]}

// sai kiểu  ← đây là bẫy vanA
{"detail":[{"type":"string_type","loc":["body","trip","guests",0,"vanA"],
            "msg":"Input should be a valid string","input":0}]}

// vượt giới hạn
{"detail":[{"type":"too_long","loc":["body","trip","guests"],
            "msg":"List should have at most 40 items after validation, not 80",
            "input":[ ...toàn bộ payload được trả lại... ]}]}

Bốn dạng đều theo chuẩn Pydantic: detail là mảng, mỗi phần tử có loc chỉ thẳng tới trường sai. BFF nên dịch loc thành đường dẫn trường trong form để tô đỏ đúng ô. Lưu ý dạng cuối trả lại nguyên payload trong input, nên đừng ghi thẳng lỗi vào log.

Timeout và thử lại

Tình huốngĐo đượcĐề xuất
compute, gọi liên tiếp0,36–0,40 s—
compute, lần đầu sau khi nghỉ7,55 sTimeout 15 giây, không phải 8
compute, thử lại—Thử lại một lần khi 502 hoặc 503. An toàn vì compute không ghi gì.
submit, thử lại— Không tự thử lại. Chưa có Idempotency-Key nên thử lại là tạo folio thứ hai.

POST /v1/booking/submitCổng duy nhất ghi vào Odoo

{
  "contact": { "name": "Ana Cruz", "email": "ana@example.com", "phone": "+63..." },
  "trip":    { ...đúng Trip đã gửi cho compute... }
}

→ { "success": true, "folio_id": 162, "order_ids": [418, 419] }
Điều cần biếtChi tiết
Bắt buộccontact.name, contact.email, trip
Danh sách kháchPhillip nói ngày 14/09 rằng sẽ thêm. Spec hôm nay chưa có, nên tên từng khách chưa sang được Odoo.
Chống trùngOdoo gộp mọi yêu cầu đang mở của cùng một khách vào một bản ghi. Một đại lý gửi hai đoàn khác nhau trong tuần sẽ bị gộp làm một.
Bấm hai lầnChưa có Idempotency-Key. BFF phải tự khóa, không được trông vào Odoo.
Trạng thái sau đóKhông có cổng nào để hỏi folio đã trả tiền chưa. Đây là mục số năm gửi Phillip.

Chưa ai gọi cổng này

Ví dụ phía trên dựng từ spec, không phải từ một lần gọi thật, vì gọi là tạo folio thật trên staging của Phillip. Trước khi thử lần đầu: báo Phillip, và dùng một email dễ nhận ra để anh ấy xóa được.

Hai cổng đọcrates và rooms

GET /v1/estimate/rates

{
  "roomRates": { "standard": {"1pax":5500,"2pax":7600},
                 "deluxe":   {"1-2pax":11200,"3-4pax":16400.010000000002},
                 "suite":    {"1-2pax":14200,"3-4pax":18400} },
  "diveTiers": { "1":10000, "2":5500, "3":4500, "4":3600 },
  "mealRate":  1500,
  "courseRates": { "dsd":5500, "ow":22000, "aow":18000 },   // thiếu refresher, rescue
  "transport": { "roundtrip":13000, "oneway":6500 },
  "terms":     { "depositPct":50 },
  "roomNames": { "standard":[16 phòng], "deluxe":[4], "suite":[4] }
}

GET /v1/estimate/rooms?check_in=2026-11-20&check_out=2026-11-22

{ "ok": true,
  "rooms":     { "standard":["Standard A"..."Standard P"], "deluxe":[...], "suite":[...] },
  "available": { "standard":[...], "deluxe":[...], "suite":[...] },   // null nếu không truyền ngày
  "check_in": "2026-11-20", "check_out": "2026-11-22" }

Casa có đúng 24 phòng: 16 standard, 4 deluxe, 4 suite. Con số này khớp tuyệt đối với công cụ của Sky. Cùng dữ liệu này cũng nằm trong response của compute ở roomNames và roomAvailability, nên trong luồng tính giá thì không cần gọi riêng.

Ánh xạForm của Sky sang Trip của Odoo

Trong công cụ của Sky Trong Trip của Odoo Việc BFF phải làm
trip.label, checkIn, checkOut, diveFrom, diveTo, guestType, transportType, bookedDaysAhead, vanSplitTên giống hệtchép thẳng
rooms[].id, .typeGiống, Odoo thêm nameChép thẳng; name để trống cho tới khi UI có chọn phòng thật
guests[].vanA, .vanD intvanA, vanD stringPhải đổi kiểu, gửi số là 422
items[].mode = each | splitchuỗi tự doGiữ đúng hai giá trị đó, đừng nghĩ ra giá trị mới
trip.agencyNamekhông cóGiữ ở kho nháp. Odoo bỏ im lặng, đã thử.
trip.opsNotes (desk, dive, kitchen)không cóGiữ ở kho nháp, chờ Phillip mở chỗ chứa
trip.manual (ghi đè giá từng khách)không cóChuyển thành dòng items, hoặc bỏ tính năng ở bản đầu
trip.groupIdkhông cóKhóa nội bộ, giữ ở kho nháp
assumptions (bảng giá sửa tay)Có, nhưng chỉ staffĐừng gửi từ giao diện khách. Bị bỏ qua, đã thử.

Gửi PhillipMười mục, ba mục đầu là chặn đường

#Yêu cầuVì saoMức
1Khai báo securitySchemes và cấp khóa cho ba vaiChưa có thì không test được vai staff, và BFF không biết đặt header nàochặn
2 Idempotency-Key cho submitBấm hai lần là hai folio. Thay cho cách gộp hiện tại, vốn gộp nhầm hai đoàn của cùng đại lý.chặn
3Danh sách khách trong submit, kèm external_refPhillip đã hứa ngày 14/09. Không có thì tên khách không sang Odoo và không đối chiếu được.chặn
4 rates_version trong response của computeĐể bản lưu chứng minh được nó tính theo bảng giá nàocần
5GET /v1/booking/{folio_id}Để biết folio đã gửi, đã trả tiền chưa, và khi nào được xóa bản nhápcần
6Ví dụ response trong specKhông có ví dụ nên mock trả chuỗi vô nghĩa. Ba ví dụ là đủ.cần
7 vanA và vanD: số hay chuỗiCông cụ lưu số, spec đòi chuỗi. Một trong hai bên phải đổi.cần
8 courseRates thiếu refresher và rescueEngine tính được nhưng bảng giá không công bố. UI không dựng được danh sách khóa học.cần
9Mặc định lấy từ chuyến mẫuThiếu diveFrom thì nhận ngày tháng 10/2026 chứ không phải rỗng. Nên trả về lỗi, hoặc ít nhất một cảnh báo.nguy hiểm
10Giảm nhẹ response vanRuns, presence và dayPlans nhúng lại cả khách nhiều lần: 27 trên 39 KB.sau cũng được

Trong repo tn-casa-quotation-estimator đã có ba bài test khẳng định các mục 1, 2 và 4 vẫn còn thiếu. Chúng sẽ chuyển sang đỏ đúng ngày Phillip làm xong, và đó là cách mình biết.

Hết chặn đườngDựng mock trả số thật

Spec không có ví dụ nên Prism sinh ra chuỗi vô nghĩa. Cách sửa không cần chờ Phillip: lưu chính những response thật ở trên thành tệp ví dụ.

contracts/odoo/
  estimate-api.v1.json          # spec đã ghim, md5 14c078e9
  examples/
    compute.retail-couple.json  # 2 khách, 2 đêm, 1 ngày lặn   →  4 KB
    compute.agent-group.json    # 7 khách, 4 đêm, FOC, khóa học → 39 KB
    compute.course.json         # refresher + open water
    rates.json                  # bảng giá thật
    rooms.json                  # 24 phòng thật
    errors.422-*.json           # bốn dạng lỗi

npm run mock -w contracts       # Prism, cổng 4010, trả ví dụ thật
npm run spec:check -w contracts  # so spec staging với bản đã ghim, lệch là thoát 1

Việc của pod Contract

  • Lưu sáu tệp ví dụ ở trên vào repo
  • Cho Prism dùng ví dụ thay vì tự sinh
  • Thêm một test: gửi chuyến thiếu diveFrom phải ra tiền lặn bằng 0, để khi Phillip sửa thì mình biết

Việc của pod BFF

  • Một hàm dựng Trip đầy đủ, không bao giờ để trống sáu trường có dấu sao
  • Đổi vanA và vanD từ số sang chuỗi
  • Dịch loc trong lỗi 422 thành đường dẫn trường của form
  • Làm tròn tiền trước khi hiển thị