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.
- 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. - Thiếu
diveFromvàdiveTolà mất sạch tiền lặn. Đã đo: 10.000 peso biến mất, phản hồi vẫn 200 OK. trip.guestTypekhô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.- Trường lạ bị bỏ im lặng. Gửi
agencyNamethì Odoo trả 200 và quên nó. Không có lỗi nào báo cho bạn. - 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/health | Còn sống không | không | được | — |
GET /v1/estimate/rates | Bảng giá theo vai người gọi | không | được | 705 B |
GET /v1/estimate/rooms | Danh sách phòng, kèm phòng trống nếu truyền ngày | không | được | 736 B |
POST /v1/estimate/compute | Tính giá một chuyến | không | được | 4–39 KB |
POST /v1/booking/submit | Tạo folio và báo giá nháp trong Odoo | CÓ | 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ổng | Bắt buộc |
|---|---|
compute | trip |
submit | trip, 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.diveFromtrip.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.checkIntrip.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
Tra cứuTừng trường một
Trip
| Trường | Kiểu | Mặc định | Ghi chú |
|---|---|---|---|
label | string | — | Tên nhóm, chỉ để hiển thị |
guestType | retail | agent | instructor | retail | Quyết định mức giảm giá. Không phải vai người gọi API |
transportType | none | roundtrip | oneway | none | Mặc định là không có xe. Phải gửi rõ. |
checkIn checkOut | string YYYY-MM-DD | chuyến mẫu | Số đêm = checkOut trừ checkIn |
diveFrom diveTo | string YYYY-MM-DD | thá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 |
bookedDaysAhead | number | 0 | Số 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 |
vanSplit | equal | vehicle | vehicle | Chia 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ường | Kiểu | Mặc định | Ghi chú |
|---|---|---|---|
id | string | — | Phải duy nhất trong chuyến, dùng để nối với items.gids |
name | string, ≤120 | Guest | — |
diver | boolean | true | Mặc định là CÓ lặn. Người không lặn phải gửi rõ false |
meals | boolean | true | 1.500 một ngày, không bao giờ được giảm giá |
transport | boolean | true | Chỉ có tác dụng khi trip.transportType khác none |
foc | boolean | false | Đánh dấu người được miễn phí. Odoo tính ra số suất, người chọn ai. |
roomId | string | null | Phả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 depart | string YYYY-MM-DD | "" | Đến muộn hoặc về sớm. Ảnh hưởng tiền phòng chia theo đêm. |
vanA vanD | string, ≤120 | null | Bẫy: công cụ của Sky lưu số nguyên. Gửi số là lỗi 422. |
comment | string, ≤500 | "" | — |
Room · DayPlanEntry · CustomItem · VanMeta
| Đối tượng | Trường | Ghi chú |
|---|---|---|
Room | id, type, name | type 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. |
DayPlanEntry | dive, third, night, boatId | Tấ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. |
CustomItem | id, 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. |
VanMeta | date, time, price, foc | Ghi đè 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 |
|---|---|---|---|---|---|
guest | null | null | null | null | Bị bỏ qua, đã thử |
agent | null | null | có | null | Bị bỏ qua |
staff | có | 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óa | Là 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. |
catRev | Tổng theo nhóm: phòng, ăn, lặn, khóa học, xe, đồ thuê, thêm | Bảng tóm tắt một dòng mỗi nhóm |
kpis | Doanh thu, giá vốn, lãi, biên, doanh thu mỗi đêm khách | Bố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ười | Nế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ỗi | Hiện dưới dạng nhắc việc, không chặn thao tác |
roomAvailability | Phòng còn trống trong đúng khoảng ngày của chuyến | Dùng để đổ vào ô chọn phòng, khỏi gọi rooms riêng |
dayPlans vanRuns presence | Dữ 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ếp | 0,36–0,40 s | — |
| compute, lần đầu sau khi nghỉ | 7,55 s | Timeout 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ết | Chi tiết |
|---|---|
| Bắt buộc | contact.name, contact.email, trip |
| Danh sách khách | Phillip 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ùng | Odoo 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ần | Chư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] }
}
diveTiersđọc theo số thợ lặn xuống nước trong ngày đó, không phải tổng số khách.courseRatesthiếu hai loại: refresher và rescue. Nhưng engine vẫn tính đúng 5.500 và 20.000 khi gửi lên. Không dựng bảng chọn khóa học từ đây.- Deluxe 3-4 người trả về 16400,010000000002. Làm tròn ở phía BFF trước khi hiển thị.
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, vanSplit | Tên giống hệt | chép thẳng |
rooms[].id, .type | Giống, Odoo thêm name | Chép thẳng; name để trống cho tới khi UI có chọn phòng thật |
guests[].vanA, .vanD int | vanA, vanD string | Phải đổi kiểu, gửi số là 422 |
items[].mode = each | split | chuỗi tự do | Giữ đúng hai giá trị đó, đừng nghĩ ra giá trị mới |
trip.agencyName | khô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.groupId | khô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ầu | Vì sao | Mức |
|---|---|---|---|
| 1 | Khai báo securitySchemes và cấp khóa cho ba vai | Chưa có thì không test được vai staff, và BFF không biết đặt header nào | chặn |
| 2 |
Idempotency-Key cho submit | Bấ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 |
| 3 | Danh sách khách trong submit, kèm external_ref | Phillip đã 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ào | cần |
| 5 | GET /v1/booking/{folio_id} | Để biết folio đã gửi, đã trả tiền chưa, và khi nào được xóa bản nháp | cần |
| 6 | Ví dụ response trong spec | Khô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ỗi | Cô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à rescue | Engine 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 |
| 9 | Mặc định lấy từ chuyến mẫu | Thiế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 |
| 10 | Giả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
diveFromphả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
vanAvàvanDtừ số sang chuỗi - Dịch
loctrong 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ị