接入V4任务卡目录查询

This commit is contained in:
andy
2026-07-19 15:35:58 +07:00
parent 8b37232ac9
commit d808732107
16 changed files with 803 additions and 38 deletions

View File

@@ -4,15 +4,15 @@
| --- | --- |
| 最近更新 | 2026-07-19 |
| 当前分支 | `feature/huangting` |
| 当前阶段 | M002 V4 入站、多卡模型、持久化基线、入站写入、查询接口、卡片确认、复核解阻、目录校验、订单详情时间线、DB 目录Lookup API 并行 |
| 当前重点 | M002 V4 已完成 DB 管理目录与 Lookup API V1 后端;后续可做 V4 前端 lookup 接入、目录管理后台或真实 PMS / OPERA / OHIP 同步 |
| 当前阶段 | M002 V4 入站、多卡模型、持久化基线、入站写入、查询接口、卡片确认、复核解阻、目录校验、订单详情时间线、DB 目录Lookup API 和前端 lookup 接入并行 |
| 当前重点 | M002 V4 已完成 DB 管理目录与 Lookup API V1 后端,并完成前端 V4 多卡字段 lookup 第一版接入;后续可做目录管理后台或真实 PMS / OPERA / OHIP 同步 |
## 1. 当前 Checkpoint
- 名称:`M002-V4-CP11-db-catalog-lookup-api-v1`
- 状态Done已新增 `workflow_reservation_catalog_account``workflow_reservation_catalog_code`,将 `QBD_TRAVEL``LIAN_TAI``HANATOUR_TD`、Room Type、Rate Code、Market、Source 固定种子导入数据库,并开放 Account / Room Type / Rate Code lookup API已补启动阶段默认酒店目录初始化和 lookup keyword 无结果时的 wrapper 元数据修正
- 名称:`M002-V4-CP12-frontend-lookup-and-multicard-integration`
- 状态Done前端已按 V4 任务卡 `fields[].options_source` 接入 Account / Room Type / Rate Code lookup API替换前端硬编码目录选项V4 多卡详情页会展示 lookup 空列表、stale、warnings、目录来源版本和 Account 派生 Market / Source确认 / 复核仍只提交 code订单详情页已消费 `v4_order_tasks[]` 展示 V4 订单任务时间线
- 目标:让 V4 任务详情字段控件不再依赖前端硬编码选项,后端入站、确认、复核统一按当前酒店数据库目录做校验。
- 边界:本轮不接真实 PMS / OPERA / OHIP 同步,不做目录管理后台,不新增 `workflow_reservation_catalog_sync_run`,不做 SuperAgent 目录机器接口,不做前端页面
- 边界:本轮不接真实 PMS / OPERA / OHIP 同步,不做目录管理后台,不新增 `workflow_reservation_catalog_sync_run`,不做 SuperAgent 目录机器接口,不废弃旧 V2/V3 接口
## 2. 当前优先级
@@ -34,12 +34,12 @@
- `docs/import/` 下按日期导入的资料是输入材料,不等同于当前权威开发契约;当前开发应优先看 `docs/project/README.md` 标记为当前有效或权威契约的文档。
- 后续每完成一个 Feature 或 Checkpoint需要更新本文件避免项目状态继续沉淀在聊天记录里。
- M010 Rooming List Excel 生成后端 CP1 和前端 V1 已实现:前端 `/reservation/rooming-lists/new` 上传来源名单和手工字段,后端同步生成 `.xlsx` 直接下载,第一版不落库、不上传 OSS。
- M002 V4 CP1 当前已完成入站解析和现有任务链路过渡适配M002 V4 CP2 已完成订单任务与多卡领域模型设计M002 V4 CP3 已完成 V4 订单任务、多卡和 S10/S99 来源通知表结构与 Repository 基线M002 V4 CP4 已完成入站写入新模型M002 V4 CP5 已完成前端查询接口并补齐订单详情 `v4_order_tasks[]` 时间线M002 V4 CP6 已完成普通卡片确认和 S10/S99 来源通知 ackM002 V4 CP7 已完成 `REVIEW_REQUIRED` 卡复核解阻和复核场景订单归属确认M002 V4 CP8 已完成目录校验、V4 卡片 `fields[]` 字段白名单、确认写入白名单收口和嵌套业务字段目录校验M002 V4 CP11 已完成数据库目录、初始化种子、启动补种子、Account / Room Type / Rate Code lookup API并把 V4 入站、确认、复核目录校验切换到当前酒店数据库目录。V4 前端页面、目录管理后台、真实 PMS 同步和 SuperAgent 目录机器接口仍未完成。
- M002 V4 CP1 当前已完成入站解析和现有任务链路过渡适配M002 V4 CP2 已完成订单任务与多卡领域模型设计M002 V4 CP3 已完成 V4 订单任务、多卡和 S10/S99 来源通知表结构与 Repository 基线M002 V4 CP4 已完成入站写入新模型M002 V4 CP5 已完成前端查询接口并补齐订单详情 `v4_order_tasks[]` 时间线M002 V4 CP6 已完成普通卡片确认和 S10/S99 来源通知 ackM002 V4 CP7 已完成 `REVIEW_REQUIRED` 卡复核解阻和复核场景订单归属确认M002 V4 CP8 已完成目录校验、V4 卡片 `fields[]` 字段白名单、确认写入白名单收口和嵌套业务字段目录校验M002 V4 CP11 已完成数据库目录、初始化种子、启动补种子、Account / Room Type / Rate Code lookup API并把 V4 入站、确认、复核目录校验切换到当前酒店数据库目录M002 V4 CP12 已完成前端 lookup 接入第一版和 V4 订单任务时间线消费。目录管理后台、真实 PMS 同步和 SuperAgent 目录机器接口仍未完成。
- M002 V4 CP2 已确认V4 工作台统一列表草案为 `/api/reservation/workbench-items`,业务订单任务接口新开 `/api/reservation/order-tasks/**`S10/S99 来源通知详情草案为 `/api/reservation/source-notifications/{notificationId}`S10/S99 使用来源通知模型,不再挂隐藏技术订单;`FIT + BOOKING_CODE` 不建 ACTIVE 唯一约束匹配多条进人工复核Basic Information 必须先确认Account / Market / Source 当前通过数据库目录读取和派生;旧 V2/V3 任务详情和草稿确认接口后续可逐步废弃。
## 5. Next Steps
- 后续如继续做 M002 V4可优先进入 V4 前端 lookup / 多卡页面接入 checkpoint或进入目录管理后台、真实 PMS / OPERA / OHIP 目录同步和 `workflow_reservation_catalog_sync_run` checkpoint。
- 后续如继续做 M002 V4可优先进入 V4 多卡真实联调验收、目录管理后台、真实 PMS / OPERA / OHIP 目录同步和 `workflow_reservation_catalog_sync_run` checkpoint。
- 后续新增重要功能时,优先在 `docs/project/requirements/` 或未来 `docs/specs/` 中形成 Spec再实现代码。
- M010 后续如需预览、历史记录、OSS 下载、订单 / 任务预填或客户字段目录化,再单独开前后端 checkpoint。

View File

@@ -16,6 +16,7 @@
v-if="isEditable(field) && isSelectField(field)"
class="v4-field__control"
:aria-invalid="fieldError(field) ? 'true' : 'false'"
:disabled="selectDisabled(field)"
:value="fieldValue(field)"
@change="updateField(field, $event)"
>
@@ -76,6 +77,12 @@
v-if="field.control_hint"
class="v4-field__hint"
>{{ field.control_hint }}</small>
<small
v-for="hint in lookupHints(field)"
:key="hint.key"
class="v4-field__hint"
:class="`v4-field__hint--${hint.tone}`"
>{{ hint.message }}</small>
<small
v-for="error in visibleFieldErrors(field)"
:key="error"
@@ -86,15 +93,27 @@
</template>
<script setup lang="ts">
import { computed, reactive, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import type { ReservationRecord, ReservationV4TaskCardFieldResult } from '@/types/reservation'
import {
fetchReservationV4AccountLookups,
fetchReservationV4RateCodeLookups,
fetchReservationV4RoomTypeLookups,
} from '@/services/reservationService'
import type {
ReservationRecord,
ReservationV4CatalogLookupItem,
ReservationV4CatalogLookupResult,
ReservationV4TaskCardFieldResult,
} from '@/types/reservation'
import {
isReservationV4EditableField,
readV4FieldValue,
reservationV4LookupKindForOptionsSource,
reservationV4FieldKey,
staticReservationV4Options,
stringifyReservationV4SafeDisplayValue,
type ReservationV4LookupKind,
} from '@/utils/reservationV4FieldRules'
const props = defineProps<{
@@ -103,6 +122,7 @@ const props = defineProps<{
readOnly: boolean
validationErrors?: Record<string, string>
editableKeys?: string[]
hotelId?: string
}>()
const emit = defineEmits<{
@@ -110,6 +130,65 @@ const emit = defineEmits<{
}>()
const { t } = useI18n()
type FieldOption = {
value: string
label: string
item?: ReservationV4CatalogLookupItem
}
type LookupHintTone = 'normal' | 'warning' | 'error'
type LookupHint = {
key: string
message: string
tone: LookupHintTone
}
type LookupState = {
loading: boolean
error: string
result: ReservationV4CatalogLookupResult | null
}
const invalidLookupValues = reactive<Record<string, string>>({})
const lookupStates = reactive<Record<ReservationV4LookupKind, LookupState>>({
ACCOUNT: createLookupState(),
ROOM_TYPE: createLookupState(),
RATE_CODE: createLookupState(),
})
const lookupRequestVersions: Record<ReservationV4LookupKind, number> = {
ACCOUNT: 0,
ROOM_TYPE: 0,
RATE_CODE: 0,
}
const lookupKinds = computed(() => {
const kinds = props.fields
.filter((field) => isSelectControl(field) && isReservationV4EditableField(field))
.map((field) => reservationV4LookupKindForOptionsSource(field.options_source))
.filter((kind): kind is ReservationV4LookupKind => Boolean(kind))
return [...new Set(kinds)]
})
watch(
[lookupKinds, () => props.hotelId],
() => {
resetLookupStates()
lookupKinds.value.forEach((kind) => {
void loadLookup(kind)
})
},
{ immediate: true },
)
watch(
[
() => props.modelValue,
() => lookupStates.ACCOUNT.result,
() => lookupStates.ROOM_TYPE.result,
() => lookupStates.RATE_CODE.result,
],
() => {
clearInvalidLoadedCatalogValues()
},
{ deep: true },
)
function fieldKey(field: ReservationV4TaskCardFieldResult): string {
return reservationV4FieldKey(field)
@@ -123,7 +202,7 @@ function isEditable(field: ReservationV4TaskCardFieldResult): boolean {
}
function isSelectField(field: ReservationV4TaskCardFieldResult): boolean {
return normalizeControlType(field.control_type) === 'SELECT' && fieldOptions(field).length > 0
return isSelectControl(field) && Boolean(reservationV4LookupKindForOptionsSource(field.options_source))
}
function isDateField(field: ReservationV4TaskCardFieldResult): boolean {
@@ -138,8 +217,25 @@ function isTextAreaField(field: ReservationV4TaskCardFieldResult): boolean {
return normalizeControlType(field.control_type) === 'TEXTAREA'
}
function fieldOptions(field: ReservationV4TaskCardFieldResult): Array<{ value: string; label: string }> {
return staticReservationV4Options(field.options_source)
function fieldOptions(field: ReservationV4TaskCardFieldResult): FieldOption[] {
const kind = reservationV4LookupKindForOptionsSource(field.options_source)
const options = kind
? (lookupStates[kind].result?.items ?? []).map((item) => ({
value: item.code,
label: lookupItemLabel(item),
item,
}))
: []
return options
}
function selectDisabled(field: ReservationV4TaskCardFieldResult): boolean {
const kind = reservationV4LookupKindForOptionsSource(field.options_source)
if (!kind) {
return false
}
const state = lookupStates[kind]
return state.loading || Boolean(state.error)
}
function fieldValue(field: ReservationV4TaskCardFieldResult): string | number {
@@ -158,12 +254,92 @@ function fieldValue(field: ReservationV4TaskCardFieldResult): string | number {
function updateField(field: ReservationV4TaskCardFieldResult, event: Event): void {
const target = event.target as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement
delete invalidLookupValues[reservationV4FieldKey(field)]
emit('update:modelValue', {
...props.modelValue,
[reservationV4FieldKey(field)]: target.value,
})
}
function lookupHints(field: ReservationV4TaskCardFieldResult): LookupHint[] {
const kind = reservationV4LookupKindForOptionsSource(field.options_source)
if (!kind) {
return []
}
const state = lookupStates[kind]
if (state.loading) {
return [{
key: `${kind}-loading`,
message: t('taskV4.lookup.loading'),
tone: 'normal',
}]
}
if (state.error) {
return [{
key: `${kind}-error`,
message: state.error,
tone: 'error',
}]
}
const result = state.result
if (!result) {
return []
}
const hints: LookupHint[] = []
const selectedItem = selectedLookupItem(field)
if (kind === 'ACCOUNT' && selectedItem && (selectedItem.market_code || selectedItem.source_code)) {
hints.push({
key: `${kind}-derived`,
message: t('taskV4.lookup.accountDerived', {
market: selectedItem.market_code ?? '-',
source: selectedItem.source_code ?? '-',
}),
tone: 'normal',
})
}
if (result.stale) {
hints.push({
key: `${kind}-stale`,
message: t('taskV4.lookup.stale'),
tone: 'warning',
})
}
result.warnings.forEach((warning, index) => {
hints.push({
key: `${kind}-warning-${index}`,
message: t('taskV4.lookup.warning', { warning }),
tone: 'warning',
})
})
if (!result.items.length) {
hints.push({
key: `${kind}-empty`,
message: t('taskV4.lookup.empty'),
tone: 'warning',
})
}
if (result.catalog_source || result.catalog_version) {
hints.push({
key: `${kind}-meta`,
message: t('taskV4.lookup.catalogMeta', {
source: result.catalog_source ?? '-',
version: result.catalog_version ?? '-',
}),
tone: 'normal',
})
}
const invalidValue = invalidLookupValues[reservationV4FieldKey(field)]
if (invalidValue) {
hints.push({
key: `${kind}-invalid-current`,
message: t('taskV4.lookup.currentNotInCatalog', { value: invalidValue }),
tone: 'error',
})
}
return hints
}
function visibleFieldErrors(field: ReservationV4TaskCardFieldResult): string[] {
return [
...(field.validation_errors ?? []),
@@ -186,6 +362,122 @@ function stringifyV4Value(value: unknown): string {
function normalizeControlType(value: string | null): string {
return value?.trim().replace(/[\s-]+/g, '_').toUpperCase() ?? ''
}
function isSelectControl(field: ReservationV4TaskCardFieldResult): boolean {
return ['SELECT', 'LOOKUP'].includes(normalizeControlType(field.control_type))
}
function lookupItemLabel(item: ReservationV4CatalogLookupItem): string {
if (item.display_name && item.display_name !== item.code) {
return `${item.code} - ${item.display_name}`
}
return item.code
}
function selectedLookupItem(field: ReservationV4TaskCardFieldResult): ReservationV4CatalogLookupItem | null {
const kind = reservationV4LookupKindForOptionsSource(field.options_source)
const value = String(fieldValue(field) || '')
if (!kind || !value) {
return null
}
return lookupStates[kind].result?.items.find((item) => item.code === value) ?? null
}
function createLookupState(): LookupState {
return {
loading: false,
error: '',
result: null,
}
}
function resetLookupStates(): void {
const allKinds: ReservationV4LookupKind[] = ['ACCOUNT', 'ROOM_TYPE', 'RATE_CODE']
allKinds.forEach((kind) => {
lookupRequestVersions[kind] += 1
lookupStates[kind].loading = false
lookupStates[kind].error = ''
lookupStates[kind].result = null
})
Object.keys(invalidLookupValues).forEach((key) => {
delete invalidLookupValues[key]
})
}
async function loadLookup(kind: ReservationV4LookupKind): Promise<void> {
const state = lookupStates[kind]
const requestVersion = ++lookupRequestVersions[kind]
state.loading = true
state.error = ''
try {
const result = await lookupFetcher(kind)({
...(props.hotelId ? { hotel_id: props.hotelId } : {}),
page_num: 1,
page_size: 100,
})
if (requestVersion === lookupRequestVersions[kind]) {
state.result = {
...result,
items: result.items ?? [],
warnings: result.warnings ?? [],
}
}
} catch {
if (requestVersion === lookupRequestVersions[kind]) {
state.error = t('taskV4.lookup.error')
}
} finally {
if (requestVersion === lookupRequestVersions[kind]) {
state.loading = false
}
}
}
function lookupFetcher(kind: ReservationV4LookupKind) {
switch (kind) {
case 'ACCOUNT':
return fetchReservationV4AccountLookups
case 'ROOM_TYPE':
return fetchReservationV4RoomTypeLookups
case 'RATE_CODE':
return fetchReservationV4RateCodeLookups
}
}
function clearInvalidLoadedCatalogValues(): void {
const nextValues = { ...props.modelValue }
let changed = false
props.fields.forEach((field) => {
if (!isSelectField(field) || !isReservationV4EditableField(field)) {
return
}
const kind = reservationV4LookupKindForOptionsSource(field.options_source)
const result = kind ? lookupStates[kind].result : null
if (!kind || !result || !isFullyLoadedCatalog(result)) {
return
}
const key = reservationV4FieldKey(field)
const currentValue = String(readV4FieldValue(field, nextValues) || '')
if (!currentValue) {
return
}
if (result.items.some((item) => item.code === currentValue)) {
delete invalidLookupValues[key]
return
}
invalidLookupValues[key] = currentValue
nextValues[key] = ''
changed = true
})
if (changed) {
emit('update:modelValue', nextValues)
}
}
function isFullyLoadedCatalog(result: ReservationV4CatalogLookupResult): boolean {
return result.items.length >= result.page.total
}
</script>
<style scoped>
@@ -255,6 +547,16 @@ function normalizeControlType(value: string | null): string {
font-size: 12px;
}
.v4-field__hint--warning {
color: var(--th-color-warning);
font-weight: 700;
}
.v4-field__hint--error {
color: var(--th-color-danger);
font-weight: 700;
}
.v4-field__error {
color: var(--th-color-danger);
font-size: 12px;

View File

@@ -406,6 +406,8 @@ export default {
order: {
summary: 'Order summary',
taskQueue: 'Order task queue',
v4TaskTimeline: 'V4 order task timeline',
v4TaskTimelineEmpty: 'No V4 order tasks.',
sourceMessage: 'Source message',
businessKeySource: 'Business key source',
updatedAt: 'Last updated',
@@ -500,6 +502,16 @@ export default {
cardConfirmed: 'Card confirmed and detail refreshed.',
reviewResolved: 'Review submitted and detail refreshed.',
validationFailed: 'Fix current-card field errors first.',
lookup: {
loading: 'Loading catalog',
empty: 'No options in the current catalog. A no-match search does not mean the catalog is uninitialized.',
stale: 'This catalog is a stale snapshot. You can still select from it; backend validation remains final.',
warning: 'Catalog note: {warning}',
error: 'Catalog is temporarily unavailable. Refresh and retry.',
catalogMeta: 'Catalog source: {source}, version: {version}',
accountDerived: 'Market: {market} / Source: {source} will be shown from the catalog; backend confirmation remains final.',
currentNotInCatalog: 'Original value "{value}" is not in the current catalog. Select again.',
},
sourceMessage: {
subject: 'Subject',
sender: 'Sender',

View File

@@ -406,6 +406,8 @@ export default {
order: {
summary: 'สรุปออเดอร์',
taskQueue: 'คิวงานของออเดอร์นี้',
v4TaskTimeline: 'ไทม์ไลน์งานออเดอร์ V4',
v4TaskTimelineEmpty: 'ยังไม่มีงานออเดอร์ V4',
sourceMessage: 'ข้อความต้นทาง',
businessKeySource: 'แหล่งที่มาของเลขธุรกิจ',
updatedAt: 'อัปเดตล่าสุด',
@@ -500,6 +502,16 @@ export default {
cardConfirmed: 'ยืนยันการ์ดแล้วและรีเฟรชรายละเอียดแล้ว',
reviewResolved: 'ส่งผลตรวจสอบแล้วและรีเฟรชรายละเอียดแล้ว',
validationFailed: 'โปรดแก้ไขข้อมูลในการ์ดปัจจุบันก่อน',
lookup: {
loading: 'กำลังโหลดแค็ตตาล็อก',
empty: 'ไม่มีตัวเลือกในแค็ตตาล็อกปัจจุบัน หากค้นหาไม่พบไม่ได้หมายความว่าแค็ตตาล็อกยังไม่เริ่มต้น',
stale: 'แค็ตตาล็อกนี้เป็น snapshot เก่า ยังเลือกได้ แต่การตรวจสอบฝั่งหลังบ้านเป็นผลสุดท้าย',
warning: 'หมายเหตุแค็ตตาล็อก: {warning}',
error: 'แค็ตตาล็อกไม่พร้อมใช้งานชั่วคราว โปรดรีเฟรชแล้วลองใหม่',
catalogMeta: 'แหล่งที่มาแค็ตตาล็อก: {source}, เวอร์ชัน: {version}',
accountDerived: 'จะแสดง Market: {market} / Source: {source} จากแค็ตตาล็อก โดยผลยืนยันจากหลังบ้านเป็นผลสุดท้าย',
currentNotInCatalog: 'ค่าเดิม "{value}" ไม่อยู่ในแค็ตตาล็อกปัจจุบัน โปรดเลือกใหม่',
},
sourceMessage: {
subject: 'หัวข้อ',
sender: 'ผู้ส่ง',

View File

@@ -406,6 +406,8 @@ export default {
order: {
summary: '订单摘要',
taskQueue: '同订单任务队列',
v4TaskTimeline: 'V4 订单任务时间线',
v4TaskTimelineEmpty: '暂无 V4 订单任务。',
sourceMessage: '来源消息',
businessKeySource: '业务号来源',
updatedAt: '最近更新',
@@ -500,6 +502,16 @@ export default {
cardConfirmed: '卡片已确认,详情已刷新。',
reviewResolved: '复核已提交,详情已刷新。',
validationFailed: '请先修正当前卡片字段。',
lookup: {
loading: '目录加载中',
empty: '当前目录没有可选项;如果是搜索无结果,不代表目录未初始化。',
stale: '当前目录为过期快照,仍可选择,最终以后端校验为准。',
warning: '目录提示:{warning}',
error: '目录暂不可用,请刷新后重试。',
catalogMeta: '目录来源:{source},版本:{version}',
accountDerived: '选择后将参考目录展示 Market{market} / Source{source},最终以后端确认结果为准。',
currentNotInCatalog: '原值“{value}”不在当前目录中,请重新选择。',
},
sourceMessage: {
subject: '主题',
sender: '发件人',

View File

@@ -26,6 +26,8 @@ import type {
ReservationTaskPayloadMutationRequest,
ReservationTaskPayloadMutationResult,
ReservationV4CardConfirmRequest,
ReservationV4CatalogLookupFilters,
ReservationV4CatalogLookupResult,
ReservationV4OrderTaskDetailResult,
ReservationV4OrderTaskListFilters,
ReservationV4OrderTaskListResult,
@@ -67,6 +69,30 @@ export async function fetchReservationV4OrderTasks(
)
}
export async function fetchReservationV4AccountLookups(
filters: ReservationV4CatalogLookupFilters = {},
): Promise<ReservationV4CatalogLookupResult> {
return getJson<ReservationV4CatalogLookupResult>(
withQuery('/api/reservation/lookups/accounts', withReservationHotel(filters)),
)
}
export async function fetchReservationV4RoomTypeLookups(
filters: ReservationV4CatalogLookupFilters = {},
): Promise<ReservationV4CatalogLookupResult> {
return getJson<ReservationV4CatalogLookupResult>(
withQuery('/api/reservation/lookups/room-types', withReservationHotel(filters)),
)
}
export async function fetchReservationV4RateCodeLookups(
filters: ReservationV4CatalogLookupFilters = {},
): Promise<ReservationV4CatalogLookupResult> {
return getJson<ReservationV4CatalogLookupResult>(
withQuery('/api/reservation/lookups/rate-codes', withReservationHotel(filters)),
)
}
export async function fetchReservationOrderDetail(orderId: string): Promise<ReservationOrderDetailResult> {
return getJson<ReservationOrderDetailResult>(
withQuery(`/api/reservation/orders/${encodeURIComponent(orderId)}`, {

View File

@@ -8,8 +8,11 @@ import {
fetchReservationOrders,
fetchReservationTaskDetail,
fetchReservationTaskList,
fetchReservationV4AccountLookups,
fetchReservationV4OrderTaskDetail,
fetchReservationV4OrderTasks,
fetchReservationV4RateCodeLookups,
fetchReservationV4RoomTypeLookups,
fetchReservationV4SourceNotificationDetail,
fetchReservationV4WorkbenchItems,
fetchSourceMessageConversation,
@@ -188,6 +191,85 @@ describe('reservationService real API mode', () => {
)
})
it('fetches V4 catalog lookups with selected hotel and query params', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(mockJsonResponse({
hotel_id: 'HOTEL-BKK',
catalog_type: 'ACCOUNT',
catalog_source: 'SYSTEM_MANAGED',
catalog_version: 'v1',
stale: false,
items: [],
page: {
page_num: 1,
page_size: 20,
total: 0,
},
warnings: [],
}))
.mockResolvedValueOnce(mockJsonResponse({
hotel_id: 'HOTEL-BKK',
catalog_type: 'ROOM_TYPE',
catalog_source: 'PMS_SYNC',
catalog_version: 'v2',
stale: true,
items: [],
page: {
page_num: 1,
page_size: 20,
total: 0,
},
warnings: ['PMS sync stale'],
}))
.mockResolvedValueOnce(mockJsonResponse({
hotel_id: 'HOTEL-BKK',
catalog_type: 'RATE_CODE',
catalog_source: 'SYSTEM_MANAGED',
catalog_version: 'v3',
stale: false,
items: [],
page: {
page_num: 1,
page_size: 20,
total: 0,
},
warnings: [],
}))
setReservationHotelIdProvider(() => 'HOTEL-BKK')
await fetchReservationV4AccountLookups({
keyword: 'QBD',
page_num: 1,
page_size: 20,
})
await fetchReservationV4RoomTypeLookups({
keyword: 'RM',
page_num: 1,
page_size: 20,
})
await fetchReservationV4RateCodeLookups({
keyword: 'BAR',
page_num: 1,
page_size: 20,
})
expect(fetchMock).toHaveBeenNthCalledWith(
1,
'/api/reservation/lookups/accounts?hotel_id=HOTEL-BKK&keyword=QBD&page_num=1&page_size=20',
expect.objectContaining({ method: 'GET' }),
)
expect(fetchMock).toHaveBeenNthCalledWith(
2,
'/api/reservation/lookups/room-types?hotel_id=HOTEL-BKK&keyword=RM&page_num=1&page_size=20',
expect.objectContaining({ method: 'GET' }),
)
expect(fetchMock).toHaveBeenNthCalledWith(
3,
'/api/reservation/lookups/rate-codes?hotel_id=HOTEL-BKK&keyword=BAR&page_num=1&page_size=20',
expect.objectContaining({ method: 'GET' }),
)
})
it('fetches V4 order task and source notification details by id', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(mockJsonResponse({ order_task: { order_task_id: '9001' } }))
@@ -482,6 +564,7 @@ describe('reservationService real API mode', () => {
updated_at: '2026-07-08T03:10:00Z',
},
tasks: [],
v4_order_tasks: [],
warnings: [],
}),
)

View File

@@ -7,6 +7,7 @@ import {
buildReservationV4ReviewOverrides,
isReservationV4ConfirmWritableField,
isReservationV4ReviewWritableField,
reservationV4LookupKindForOptionsSource,
stringifyReservationV4SafeDisplayValue,
validateReservationV4Fields,
} from '@/utils/reservationV4FieldRules'
@@ -188,4 +189,11 @@ describe('reservationV4FieldRules', () => {
'/basic_information/account_code': 'Account is required',
})
})
it('maps V4 options_source to lookup kinds without returning fixed catalog options', () => {
expect(reservationV4LookupKindForOptionsSource('reservation_v4_account_catalog')).toBe('ACCOUNT')
expect(reservationV4LookupKindForOptionsSource('RESERVATION_V4_ROOM_TYPE_CATALOG')).toBe('ROOM_TYPE')
expect(reservationV4LookupKindForOptionsSource('reservation_v4_rate_code_catalog')).toBe('RATE_CODE')
expect(reservationV4LookupKindForOptionsSource('static_enum')).toBeNull()
})
})

View File

@@ -20,7 +20,10 @@ vi.mock('@/services/reservationService', async (importOriginal) => {
...actual,
ackReservationV4SourceNotification: vi.fn(),
confirmReservationV4OrderTaskCard: vi.fn(),
fetchReservationV4AccountLookups: vi.fn(),
fetchReservationV4OrderTaskDetail: vi.fn(),
fetchReservationV4RateCodeLookups: vi.fn(),
fetchReservationV4RoomTypeLookups: vi.fn(),
fetchReservationV4SourceNotificationDetail: vi.fn(),
resolveReservationV4OrderTaskCardReview: vi.fn(),
}
@@ -33,12 +36,16 @@ describe('reservation V4 pages', () => {
sessionStorage.clear()
vi.mocked(service.ackReservationV4SourceNotification).mockReset()
vi.mocked(service.confirmReservationV4OrderTaskCard).mockReset()
vi.mocked(service.fetchReservationV4AccountLookups).mockReset()
vi.mocked(service.fetchReservationV4OrderTaskDetail).mockReset()
vi.mocked(service.fetchReservationV4RateCodeLookups).mockReset()
vi.mocked(service.fetchReservationV4RoomTypeLookups).mockReset()
vi.mocked(service.fetchReservationV4SourceNotificationDetail).mockReset()
vi.mocked(service.resolveReservationV4OrderTaskCardReview).mockReset()
mockCatalogLookups()
})
it('renders V4 order task cards and confirms only editable basic information fields', async () => {
it('renders V4 order task cards, loads lookup options and confirms only selected codes', async () => {
const detail = createOrderTaskDetail()
vi.mocked(service.fetchReservationV4OrderTaskDetail).mockResolvedValue(detail)
vi.mocked(service.confirmReservationV4OrderTaskCard).mockResolvedValue({
@@ -60,8 +67,21 @@ describe('reservation V4 pages', () => {
expect(wrapper.text()).toContain('基础信息卡')
expect(wrapper.text()).toContain('业务任务卡')
expect(wrapper.text()).toContain('Backend contract issue')
expect(service.fetchReservationV4AccountLookups).toHaveBeenCalledWith({
hotel_id: 'HOTEL-TEST',
page_num: 1,
page_size: 100,
})
expect(service.fetchReservationV4RoomTypeLookups).toHaveBeenCalledWith({
hotel_id: 'HOTEL-TEST',
page_num: 1,
page_size: 100,
})
await wrapper.find('select').setValue('HANATOUR')
await wrapper.find('select').setValue('ACC-LIVE')
await flushPromises()
expect(wrapper.text()).toContain('LEISURE')
expect(wrapper.text()).toContain('TRAVEL_AGENT')
await wrapper.findAll('button').find((button) => button.text().includes('确认卡片'))?.trigger('click')
await flushPromises()
@@ -69,7 +89,7 @@ describe('reservation V4 pages', () => {
version: 7,
confirmed_payload: {
basic_information: {
account_code: 'HANATOUR',
account_code: 'ACC-LIVE',
},
},
})
@@ -153,6 +173,74 @@ describe('reservation V4 pages', () => {
})
})
it('shows non-blocking lookup empty, stale and warning states', async () => {
const detail = createOrderTaskDetail()
vi.mocked(service.fetchReservationV4AccountLookups).mockResolvedValue({
hotel_id: 'HOTEL-TEST',
catalog_type: 'ACCOUNT',
catalog_source: 'PMS_SYNC',
catalog_version: 'stale-v1',
stale: true,
items: [],
page: {
page_num: 1,
page_size: 100,
total: 0,
},
warnings: ['PMS sync is stale'],
})
vi.mocked(service.fetchReservationV4OrderTaskDetail).mockResolvedValue(detail)
const wrapper = await mountWithPlugins(
ReservationV4OrderTaskDetailView,
'/reservation/order-tasks/9001',
)
await flushPromises()
expect(wrapper.text()).toContain(zhCN.taskV4.lookup.empty)
expect(wrapper.text()).toContain(zhCN.taskV4.lookup.stale)
expect(wrapper.text()).toContain('PMS sync is stale')
expect(wrapper.text()).toContain('PMS_SYNC')
expect(wrapper.text()).toContain('stale-v1')
})
it('does not submit a catalog field value that is missing from the loaded lookup result', async () => {
const detail = createOrderTaskDetail({
basicAccountValue: 'UNKNOWN_ACCOUNT',
basicAccountRequired: true,
})
vi.mocked(service.fetchReservationV4AccountLookups).mockResolvedValue({
hotel_id: 'HOTEL-TEST',
catalog_type: 'ACCOUNT',
catalog_source: 'SYSTEM_MANAGED',
catalog_version: 'catalog-v1',
stale: false,
items: [],
page: {
page_num: 1,
page_size: 100,
total: 0,
},
warnings: [],
})
vi.mocked(service.fetchReservationV4OrderTaskDetail).mockResolvedValue(detail)
const wrapper = await mountWithPlugins(
ReservationV4OrderTaskDetailView,
'/reservation/order-tasks/9001',
)
await flushPromises()
expect(wrapper.text()).toContain('UNKNOWN_ACCOUNT')
expect(wrapper.text()).toContain(zhCN.taskV4.lookup.currentNotInCatalog.replace('{value}', 'UNKNOWN_ACCOUNT'))
await wrapper.findAll('button').find((button) => button.text().includes('确认卡片'))?.trigger('click')
await flushPromises()
expect(service.confirmReservationV4OrderTaskCard).not.toHaveBeenCalled()
expect(wrapper.text()).toContain(zhCN.taskV4.validationFailed)
})
it('requires confirmed order id before resolving an unresolved V4 review card', async () => {
const detail = createOrderTaskDetail({
businessCardStatus: 'REVIEW_REQUIRED',
@@ -277,10 +365,83 @@ async function mountWithPlugins(component: object, initialPath: string) {
})
}
function mockCatalogLookups() {
vi.mocked(service.fetchReservationV4AccountLookups).mockResolvedValue({
hotel_id: 'HOTEL-TEST',
catalog_type: 'ACCOUNT',
catalog_source: 'SYSTEM_MANAGED',
catalog_version: 'catalog-v1',
stale: false,
items: [
{
code: 'ACC-LIVE',
display_name: 'Live Account',
status: 'ACTIVE',
catalog_source: 'SYSTEM_MANAGED',
market_code: 'LEISURE',
market_name: 'Leisure',
source_code: 'TRAVEL_AGENT',
source_name: 'Travel Agent',
adult_capacity: null,
pricing_available: null,
},
],
page: {
page_num: 1,
page_size: 100,
total: 1,
},
warnings: [],
})
vi.mocked(service.fetchReservationV4RoomTypeLookups).mockResolvedValue({
hotel_id: 'HOTEL-TEST',
catalog_type: 'ROOM_TYPE',
catalog_source: 'SYSTEM_MANAGED',
catalog_version: 'catalog-v1',
stale: false,
items: [
{
code: 'TWN',
display_name: 'Twin',
status: 'ACTIVE',
catalog_source: 'SYSTEM_MANAGED',
market_code: null,
market_name: null,
source_code: null,
source_name: null,
adult_capacity: 2,
pricing_available: null,
},
],
page: {
page_num: 1,
page_size: 100,
total: 1,
},
warnings: [],
})
vi.mocked(service.fetchReservationV4RateCodeLookups).mockResolvedValue({
hotel_id: 'HOTEL-TEST',
catalog_type: 'RATE_CODE',
catalog_source: 'SYSTEM_MANAGED',
catalog_version: 'catalog-v1',
stale: false,
items: [],
page: {
page_num: 1,
page_size: 100,
total: 0,
},
warnings: [],
})
}
function createOrderTaskDetail(options: {
businessCardStatus?: string
businessCardAvailability?: Partial<ReservationV4TaskCardResult['availability']>
sourceDisplayPayload?: ReservationV4TaskCardResult['display_payload']
basicAccountValue?: string
basicAccountRequired?: boolean
} = {}): ReservationV4OrderTaskDetailResult {
const sourceCard = createCard('card-source', 'SOURCE_MESSAGE_DISPLAY', 'READONLY', {
fields: [],
@@ -301,7 +462,8 @@ function createOrderTaskDetail(options: {
fields: [
createField('/basic_information/account_code', {
display_name: 'Account',
value: '',
value: options.basicAccountValue ?? '',
required: options.basicAccountRequired ?? false,
options_source: 'RESERVATION_V4_ACCOUNT_CATALOG',
control_type: 'SELECT',
}),

View File

@@ -270,6 +270,35 @@ function createOrderDetailResult(displayName = 'GRP-001') {
created_at: '2026-07-08T04:00:00Z',
},
],
v4_order_tasks: [
{
order_task_id: '9001',
order_ref: 'order-ref-1',
order_task_status: 'OPEN',
card_counts: {
total_count: 3,
readonly_count: 1,
pending_confirm_count: 1,
review_required_count: 1,
confirmed_count: 0,
},
source_message_summary: {
source_message_id: '30003',
hotel_id: 'HOTEL-TEST',
external_message_id: 'm-v4-1',
external_conversation_id: 'thread-v4-1',
subject: 'V4 Booking Package',
sender_summary: 'v4@example.test',
received_at: '2026-07-08T05:00:00Z',
source_sent_at: null,
conversation_message_count: 1,
},
source_received_at: '2026-07-08T05:00:00Z',
created_at: '2026-07-08T05:00:00Z',
updated_at: '2026-07-08T05:10:00Z',
latest_activity_at: '2026-07-08T05:10:00Z',
},
],
warnings: [],
}
}
@@ -461,6 +490,9 @@ describe('reservation P0 views', () => {
expect(wrapper.text()).toContain('2')
expect(wrapper.text()).toContain('前序任务未完成')
expect(wrapper.text()).toContain('查看邮件会话')
expect(wrapper.text()).toContain(zhCN.order.v4TaskTimeline)
expect(wrapper.text()).toContain('order-ref-1')
expect(wrapper.text()).toContain('V4 Booking Package')
expect(wrapper.text()).not.toContain('PREVIOUS_TASK_NOT_FINISHED')
expect(wrapper.text()).not.toContain('来源邮件摘要字段待后端补充')
})

View File

@@ -184,6 +184,39 @@ export interface ReservationV4OrderTaskListFilters {
page_size?: number
}
export type ReservationV4CatalogType = 'ACCOUNT' | 'ROOM_TYPE' | 'RATE_CODE' | string
export interface ReservationV4CatalogLookupFilters {
hotel_id?: string
keyword?: string
page_num?: number
page_size?: number
}
export interface ReservationV4CatalogLookupItem {
code: string
display_name: string
status: string | null
catalog_source: string | null
market_code: string | null
market_name: string | null
source_code: string | null
source_name: string | null
adult_capacity: number | null
pricing_available: boolean | null
}
export interface ReservationV4CatalogLookupResult {
hotel_id: string
catalog_type: ReservationV4CatalogType
catalog_source: string | null
catalog_version: string | null
stale: boolean
items: ReservationV4CatalogLookupItem[]
page: ReservationPageResult
warnings: string[]
}
export interface ReservationV4SourceMessageSummary {
source_message_id: string
hotel_id: string
@@ -406,9 +439,22 @@ export interface ReservationOrderTaskTimelineItem {
created_at: string | null
}
export interface ReservationV4OrderTaskTimelineItem {
order_task_id: string
order_ref: string | null
order_task_status: ReservationV4OrderTaskStatus | string
card_counts: ReservationV4CardCounts
source_message_summary: ReservationV4SourceMessageSummary | null
source_received_at: string | null
created_at: string | null
updated_at: string | null
latest_activity_at: string | null
}
export interface ReservationOrderDetailResult {
order: ReservationOrderSummaryResult
tasks: ReservationOrderTaskTimelineItem[]
v4_order_tasks: ReservationV4OrderTaskTimelineItem[]
warnings: string[]
}

View File

@@ -22,6 +22,7 @@ const reviewWritablePointerPrefixes = ['/basic_information/', '/business_fields/
const hiddenDisplayValue = Symbol('reservation-v4-hidden-display-value')
export type ReservationV4FieldErrorMap = Record<string, string>
export type ReservationV4LookupKind = 'ACCOUNT' | 'ROOM_TYPE' | 'RATE_CODE'
export function reservationV4FieldKey(field: ReservationV4TaskCardFieldResult): string {
return field.field_pointer || field.field_path
@@ -163,34 +164,19 @@ export function mapReservationV4BackendDetailsToFields(
}
}
export function staticReservationV4Options(optionsSource: string | null): Array<{ value: string; label: string }> {
export function reservationV4LookupKindForOptionsSource(optionsSource: string | null): ReservationV4LookupKind | null {
switch (normalizeV4RuleCode(optionsSource)) {
case 'RESERVATION_V4_ACCOUNT_CATALOG':
case 'ACCOUNT_CATALOG':
return [
{ value: 'HANATOUR', label: 'HANATOUR' },
{ value: 'LIAN_TAI', label: 'LIAN TAI' },
{ value: 'QBD_TRAVEL', label: 'QBD Travel' },
]
return 'ACCOUNT'
case 'RESERVATION_V4_ROOM_TYPE_CATALOG':
case 'ROOM_TYPE_CATALOG':
return [
{ value: 'TWN', label: 'TWN' },
{ value: 'DBL', label: 'DBL' },
{ value: 'KING', label: 'KING' },
{ value: 'SGL', label: 'SGL' },
{ value: 'TRP', label: 'TRP' },
]
return 'ROOM_TYPE'
case 'RESERVATION_V4_RATE_CODE_CATALOG':
case 'RATE_CODE_CATALOG':
return [
{ value: 'BAR', label: 'BAR' },
{ value: 'RACK', label: 'RACK' },
{ value: 'GROUP', label: 'GROUP' },
{ value: 'FIT', label: 'FIT' },
]
return 'RATE_CODE'
default:
return []
return null
}
}

View File

@@ -68,6 +68,41 @@
</div>
</section>
<section class="th-section side-section">
<div class="th-section-header">
<h2 class="th-section-title">
{{ t('order.v4TaskTimeline') }}
</h2>
</div>
<div class="side-section__body">
<p
v-if="!v4OrderTasks.length"
class="side-empty"
>
{{ t('order.v4TaskTimelineEmpty') }}
</p>
<template v-else>
<RouterLink
v-for="item in v4OrderTasks"
:key="item.order_task_id"
class="v4-timeline-item"
:to="`/reservation/order-tasks/${item.order_task_id}`"
>
<span>
<strong>{{ v4OrderReference(item) }}</strong>
<small>{{ item.source_message_summary?.subject ?? '-' }}</small>
</span>
<ReservationStatusBadge :status="item.order_task_status" />
<small>
{{ t('taskList.cardCountsTotal', { total: item.card_counts.total_count }) }}
·
{{ formatReservationDateTime(item.latest_activity_at ?? item.updated_at ?? item.created_at) }}
</small>
</RouterLink>
</template>
</div>
</section>
<section class="th-section source-gap">
<h2>{{ t('task.evidence') }}</h2>
<template v-if="activeTask">
@@ -134,7 +169,7 @@ import ReservationTaskDetailPanel from '@/components/reservation/ReservationTask
import ReservationTaskQueue from '@/components/reservation/ReservationTaskQueue.vue'
import { fetchReservationOrderDetail } from '@/services/reservationService'
import { useAuthStore } from '@/stores/authStore'
import type { ReservationOrderDetailResult } from '@/types/reservation'
import type { ReservationOrderDetailResult, ReservationV4OrderTaskTimelineItem } from '@/types/reservation'
import { formatReservationDateTime } from '@/utils/reservationFormat'
const route = useRoute()
@@ -147,6 +182,7 @@ const activeTaskId = ref('')
const orderId = computed(() => String(route.params.orderId ?? ''))
const activeTask = computed(() => detail.value?.tasks.find((task) => task.task_id === activeTaskId.value) ?? null)
const v4OrderTasks = computed(() => detail.value?.v4_order_tasks ?? [])
watch(
orderId,
@@ -181,6 +217,10 @@ async function loadOrder(nextOrderId: string): Promise<void> {
loading.value = false
}
}
function v4OrderReference(item: ReservationV4OrderTaskTimelineItem): string {
return item.order_ref?.trim() || item.source_message_summary?.subject?.trim() || `#${item.order_task_id}`
}
</script>
<style scoped>
@@ -295,6 +335,48 @@ async function loadOrder(nextOrderId: string): Promise<void> {
line-height: 1.6;
}
.side-empty {
margin: 0;
color: var(--th-color-slate-500);
font-size: 13px;
line-height: 1.6;
}
.v4-timeline-item {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
border: 1px solid var(--th-color-slate-200);
border-radius: var(--th-radius-sm);
background: var(--th-color-white);
color: inherit;
padding: 10px;
text-decoration: none;
}
.v4-timeline-item > span {
display: grid;
gap: 3px;
min-width: 0;
}
.v4-timeline-item strong {
color: var(--th-color-slate-900);
font-size: 13px;
overflow-wrap: anywhere;
}
.v4-timeline-item small {
color: var(--th-color-slate-500);
font-size: 12px;
font-weight: 700;
overflow-wrap: anywhere;
}
.v4-timeline-item > small {
grid-column: 1 / -1;
}
.source-gap dl {
display: grid;
gap: 10px;

View File

@@ -522,6 +522,7 @@ const TaskCardSection = defineComponent({
submittingCardId.value === props.card.card_id,
editableKeys: editableFieldKeys(props.card),
validationErrors: cardFieldErrors.value[props.card.card_id] ?? {},
hotelId: detail.value?.order_task.hotel_id,
'onUpdate:modelValue': (values: ReservationRecord) => setCardValues(props.card, values),
}),
safePayloadRows(props.card.display_payload).length

View File

@@ -509,6 +509,7 @@ RESERVATION_ROOMING_LIST_GENERATE
- `review-resolution` 请求示例:`{"version":0,"reason":"确认房型映射","confirmed_order_id":"123456","field_overrides":[{"field_pointer":"/business_fields/room_items/0/pms_room_type_code","value":"RM2"}]}``confirmed_order_id` 在订单任务归属未解决时必填;如果订单任务已经绑定订单且 `target_resolution_status=RESOLVED`,只能不传或传当前同一个订单 ID不能借该接口切换到其它订单。`field_pointer` 必须来自当前卡允许编辑的 `basic_information.*``business_fields.*` 叶子字段;展示 payload 有 `missing_fields[]` 时只提交清单里的 pointer没有显式清单时只提交当前值为 `null` / 空字符串的未解决叶子字段;如果是目录校验错误,也可以提交后端 `fields[].validation_errors` 对应的字段 pointer。前端不要提交来源邮件、路由、`target_order``order_ref`、缺失字段清单、`manual_review`、raw evidence、校验诊断字段也不能替换整个对象 / 数组。
- V4 `fields[]` 第一版字段说明Basic Information 固定返回 `/basic_information/account_code``/basic_information/market_code``/basic_information/source_code`;其中 Account `control_type=select``options_source=reservation_v4_account_catalog`Market / Source 为只读派生字段。业务卡会按展示 payload 里的业务叶子字段返回字段白名单,例如 `/room_items/0/room_type_code``/business_fields/room_items/0/pms_room_type_code`;前端不要自行补未返回字段。
- V4 CP11 已开放独立目录 lookup API。前端应使用 `GET /api/reservation/lookups/accounts``GET /api/reservation/lookups/room-types``GET /api/reservation/lookups/rate-codes` 渲染 Account / Room Type / Rate Code 选项;用户提交确认或复核时只提交稳定 `code`,不要提交显示名、派生 Market / Source 或目录完整对象;后端确认前仍会重新校验目录。`keyword` 查不到只表示当前筛选无结果,不能仅凭 `items=[]` 判断目录未初始化,应结合 `catalog_source``catalog_version``warnings[]`
- M002 V4 CP12 前端已接入上述三个 lookup APIV4 多卡详情页会按当前卡 `fields[].options_source` 拉取目录选项,空 `items[]``stale=true``warnings[]` 作为非阻塞提示展示Account 选择后只展示目录返回的 `market_code` / `source_code` 辅助确认,确认 / 复核请求仍只提交用户选择的 code。
- V4 新模型确认口径是不保存后端草稿、卡片最终确认后锁定、技术异常不进入用户可处理卡、当前不生成 OPERA 模拟操作。Basic Information 必须先确认;其它业务卡第一版不强制逐张顺序确认。现有 V3 `draft``confirm``manual-review-resolutions` 和 OPERA 模拟接口仍只代表旧链路能力,不能直接等同 V4 多卡最终接口。
- V4 S10/S99 已采用来源通知模型入库:新 V4 `route_code=S10/S99` 不再挂隐藏技术订单,也不再创建旧 `SOURCE_MESSAGE_ONLY` 任务;对应工作台 / 来源通知详情查询接口和 ack 写接口已开放。旧 `SOURCE_MESSAGE_ONLY` 只读任务仅代表 V3 S10/S99 和旧 S000/S999 兼容数据。
- M002 V3 的结构化 `S10/S99` 入站、40 条 P0.1 路由枚举 / 稳定配置、`UNHANDLED_CURRENT_INTENT``adapter_contract_error` transition 最小落库、任务列表 / 订单时间线 / 任务详情 V3 路由字段和只读诊断块透出、type-known manual review 同卡解阻第一版、typed infrastructure error、P0 fixtures 回归基线和 Parent Group / Cancel Allotment 路由修订均已完成。

View File

@@ -1099,4 +1099,4 @@ POST /api/reservation/tasks/{taskId}/order-binding
- `manual-review-resolutions` 成功响应中的 `opera_operations[]` 数量请后端最终确认;前端不写死两条,只按返回内容刷新展示。
- 系统管理菜单树增强接口已完成:`GET /api/admin/menus/tree``PUT /api/admin/menus/tree-order`
- Manual Invoice 第一阶段的客户 / 联系人目录来源、模板初始文件、VAT 配置和生成记录是否必须落库,已在 M009 中列为开发前确认项。
- V4 真实目录与 Lookup API 第一版已在后端 CP11 落地前端后续可以按 `GET /api/reservation/lookups/accounts``GET /api/reservation/lookups/room-types``GET /api/reservation/lookups/rate-codes` 接入 V4 字段选择控件;真实 PMS 同步、目录管理后台和 SuperAgent 目录机器接口仍后置。
- V4 真实目录与 Lookup API 第一版已在后端 CP11 落地前端 CP12 已接入 `GET /api/reservation/lookups/accounts``GET /api/reservation/lookups/room-types``GET /api/reservation/lookups/rate-codes` 用于 V4 字段选择控件。前端按 `options_source` 选择接口,空列表 / stale / warnings 只做非阻塞提示,确认和复核仍只提交 code;真实 PMS 同步、目录管理后台和 SuperAgent 目录机器接口仍后置。