diff --git a/docs/android_debug_full_fix_guide.md b/docs/android_debug_full_fix_guide.md new file mode 100644 index 00000000..e385b0be --- /dev/null +++ b/docs/android_debug_full_fix_guide.md @@ -0,0 +1,2980 @@ +# 安卓端调试成功全过程问题总结与修复指南 + +> **文档类型**:经验沉淀文档(非简单周报) +> **维护人**:黄镇堡 +> **最后更新**:2026-04-20 +> **项目**:智慧医养 mall(uni-app x) +> **分支**:`huangzhenbao-admin` +> **AppID**:`__UNI__81482FF` + +--- + +## 目录 + +1. [文档说明](#1-文档说明) +2. [时间范围与排查范围](#2-时间范围与排查范围) +3. [总体结论](#3-总体结论) +4. [生成本地打包APP资源阶段——UTS 语法问题](#4-生成本地打包app资源阶段uts-语法问题) +5. [Run 到 Android 端时的报错与修复全过程](#5-run-到-android-端时的报错与修复全过程) +6. [JS / UTS / Kotlin 语义差异总结](#6-js--uts--kotlin-语义差异总结) +7. [Android 适配与页面运行问题总结](#7-android-适配与页面运行问题总结) +8. [额外发现的重要隐藏问题](#8-额外发现的重要隐藏问题) +9. [面向后续开发的编码规范清单](#9-面向后续开发的编码规范清单) +10. [面向后续排查的标准流程清单](#10-面向后续排查的标准流程清单) +11. [关键文件与关键改动索引](#11-关键文件与关键改动索引) +12. [结语](#12-结语) + +--- + +## 1. 文档说明 + +### 1.1 目的 + +本文档记录了将 uni-app x 商家端项目从"能在小程序/H5运行"推进到"能在 Android 真机正常调试运行"的完整过程。 + +内容覆盖三个层次: + +- **编译层**:HBuilderX 的 UTS → Kotlin 编译阶段报错 +- **构建层**:Android Studio `assembleDebug` 阶段 Kotlin 符号缺失报错 +- **运行层**:APK 安装后页面布局异常、逻辑错误、跳转失败 + +### 1.2 阅读建议 + +| 角色 | 建议阅读章节 | +| ------------------------------ | ------------ | +| 新接手本项目的开发者 | 全部 | +| 遇到 UTS 编译报错 | §4 + §6 | +| 遇到 Android Studio 构建失败 | §5 | +| 遇到 Android 页面布局/显示问题 | §7 | +| 快速查规范 | §9 + §10 | + +### 1.3 项目技术栈 + +| 层次 | 技术 | +| ---------------- | ---------------------------------------- | +| 前端框架 | uni-app x(HBuilderX 5.07) | +| 前端语言 | UTS(TypeScript 子集,编译为 Kotlin) | +| 后端 | Supabase(PostgreSQL + Edge Functions) | +| Android 构建 | Android Studio Ladybug + `assembleDebug` | +| Android 最低版本 | API 21(Android 5.0) | + +--- + +## 2. 时间范围与排查范围 + +### 2.1 时间范围 + +- **开始**:2026-04-13(开始尝试 Android 本地打包) +- **完成**:2026-04-20(merchant 端能够在 Android 上正常运行) + +### 2.2 关键提交节点 + +| 提交 Hash | 提交信息 | 意义 | +| ----------------------- | ------------------------------ | ----------------------------------------------- | +| `5ab4789d` | 保存能够打包成功的版本 | **基线**:首次编译通过,但 Android 运行仍有问题 | +| `8765e948` | 小程序条件编译 | 分离小程序/Android 条件编译 | +| `5473f0fc` / `3c256812` | 修复页面展示bug | 布局适配修复 | +| `f99bd5a7` | merchant能够在安卓上面进行运行 | **终点**:Android 端完整可用 | + +### 2.3 排查范围 + +- **页面**:所有 `pages/mall/merchant/**`(共约 30 个页面)+ `pages/user/login` +- **公共组件**:`components/merchant-tabbar/`、`utils/eventHelpers.uts`(新增) +- **配置文件**:`pages.json`、`manifest.json`、`fix_kt_files.ps1` +- **Android 项目**:`D:\Android\Project\mall\app\src\main\java\` + +### 2.4 主要错误文件索引 + +| 文件 | 内容 | +| -------------------------------------- | --------------------------------------------------- | +| `errors_round1_clean.txt` | A阶段第一轮 UTS 编译错误(清洗版) | +| `errors_round2.txt` | A阶段第二轮 UTS 编译错误 | +| `D:\Android\Project\mall\报错信息.txt` | B阶段 Android Studio Kotlin 符号缺失错误 | +| `kotlinc_errors_real.txt` | 独立 kotlinc 编译尝试的过滤结果(已确认无参考价值) | +| `build_output_r1_android.txt` | Android Studio assembleDebug 完整输出 | + +--- + +## 3. 总体结论 + +### 3.1 根本性结论 + +**uni-app x 的 UTS 并不是 JavaScript,是一门独立的强类型语言,编译到 Kotlin 后遵循 Kotlin 的严格类型规则。** + +大量问题来源于以 JS 习惯写 UTS: + +- JS 中合法的表达式(`if (str)`、`a === b`、`obj.field`)在 UTS/Kotlin 中要么报编译错误,要么产生意料之外的运行时行为。 +- CSS 在 Web 浏览器中自动降级处理,在 Android 原生渲染器中不行,无效属性直接被忽略或崩溃。 + +### 3.2 三阶段问题分布 + +``` +┌────────────────────────────────────────────────────────────────────────┐ +│ 阶段A:HBuilderX "生成本地打包APP资源" │ +│ 工具:HBuilderX 5.07 UTS编译器 │ +│ 产物:.kt 文件到 unpackage/dist/build/app-android/ │ +│ 问题:UTS 语法错误(13类),编译直接失败 │ +├────────────────────────────────────────────────────────────────────────┤ +│ 阶段B:Android Studio assembleDebug │ +│ 工具:Gradle + Kotlin 编译器 │ +│ 产物:app-debug.apk │ +│ 问题:Kotlin 符号 unresolved(uni SDK AAR缺失)、=== 运行时语义错误 │ +├────────────────────────────────────────────────────────────────────────┤ +│ 阶段C:APK 安装到真机/模拟器运行 │ +│ 工具:Android 真机 │ +│ 问题:布局截断、顶部遮挡、双层TabBar、跳转白屏、无法滚动 │ +└────────────────────────────────────────────────────────────────────────┘ +``` + +### 3.3 问题数量统计 + +| 阶段 | 问题分类数 | 已解决 | 备注 | +| ---------------- | ---------- | -------- | -------------------------------------------------------- | +| A(UTS编译) | 13类 | 13类 | | +| B(Android构建) | 11类 | 9类 | AAR依赖需要补充;getPushClientId待重写 | +| C(运行适配) | 14类 | 14类 | 含flex-direction、scroll-into-view ID、safe-area双重兜底 | +| 隐藏问题 | 6类 | 6类 | 含AkReq链路模式、sessionUser访问模式 | +| **合计** | **44类** | **42类** | | + +--- + +## 4. 生成本地打包APP资源阶段——UTS 语法问题 + +> **触发场景**:在 HBuilderX 中点击"发行 → 本地打包 → 生成本地打包App资源",UTS 编译器报错,`.kt` 文件无法生成。 +> +> **错误来源文件**:`errors_round1_clean.txt`、`errors_round2.txt` + +--- + +### 4.1 非严格布尔条件(最常见) + +**错误信息** + +``` +condition type mismatch: String but Boolean expected +condition type mismatch: Int? but Boolean expected +``` + +**根因** +JavaScript 有 truthy/falsy 机制(空字符串、0、null 均为 falsy),UTS/Kotlin 的 `if` 条件**必须是 Boolean 类型**,不接受其他类型隐式转换。 + +**错误写法 → 修复写法** + +```typescript +// ❌ JS习惯写法(UTS报错) +if (this.merchantId) { ... } +v-if="notice.tag" +if (item.url) { ... } + +// ✅ 修复后 +if (this.merchantId !== '') { ... } +v-if="notice.tag !== ''" +if (item.url.length > 0) { ... } + +// ❌ 可空类型 +if (result.data) { ... } // result.data: string | null + +// ✅ 修复后 +if (result.data != null && result.data !== '') { ... } +``` + +**影响文件**:`profile.uvue`、`index.uvue`、`messages.uvue`、`finance.uvue` 等大量文件。 + +--- + +### 4.2 onLoad 参数字段访问 + +**错误信息** + +``` +unresolved reference 'type' +unresolved reference 'orderId' +``` + +**根因** +`onLoad(options: any)` 中的 `options` 在 Android UTS 运行时是 `UTSJSONObject`,不能用点访问(`.field`)读取属性;`as any` 掩盖了类型问题,运行时返回 null。 + +```typescript +// ❌ 错误写法 +onLoad(options: any) { + const type = options.type // unresolved reference + const id = options?.orderId as string +} + +// ✅ 修复写法 +onLoad(options: UTSJSONObject | null) { + if (options != null) { + const type = options.getString('type') ?? '' + const id = options.getString('orderId') ?? '' + } +} +``` + +--- + +### 4.3 `any[]` 泛型不兼容 + +**错误信息** + +``` +type mismatch: inferred type is Array but Array was expected +``` + +**根因** +UTS 不支持 `any[]`,Android 端数组必须有明确的元素类型。 + +```typescript +// ❌ 错误写法 +const rawData = response.data as any[]; +const item = rawData[0] as UTSJSONObject; + +// ✅ 修复写法 +const rawData = response.data as Array; +const item = rawData[0]; // 已知类型,不需要二次 as +``` + +--- + +### 4.4 UTSJSONObject 点访问 + +**错误信息** + +``` +unresolved reference 'name' +unresolved reference 'total_sales' +``` + +**根因** +`UTSJSONObject` 不是普通对象,字段不能用点访问,必须用方法访问。 + +```typescript +// ❌ 错误写法 +const name = shopInfo.name; +const count = shopInfo.total; + +// ✅ 修复写法(按值类型选方法) +const name = shopInfo.getString("name") ?? ""; +const count = shopInfo.getNumber("total") ?? 0; +const flag = shopInfo.getBoolean("is_active") ?? false; +const sub = shopInfo.getJSON("address"); // 嵌套对象 +const list = shopInfo.getArray("items"); // 嵌套数组 +``` + +--- + +### 4.5 request/uploadFile 回调类型不匹配 + +**错误信息** + +``` +argument type mismatch: AkRequestSuccessResult is not assignable to RequestSuccess +return type mismatch: AkUploadFileSuccessResult vs UniUploadFileSuccess +``` + +**根因** +自定义请求封装 `AkReq` 的回调类型(`AkRequestSuccessResult`)与 uni-app x 的标准类型(`RequestSuccess`)不兼容,UTS 强类型检查不允许隐式兼容。 + +**修复**:对所有使用 `AkReq` 回调的地方,显式声明回调参数类型为项目自定义类型,或用中间变量转换。 + +--- + +### 4.6 内联箭头函数事件处理器 + +**错误信息** + +``` +only expressions are allowed here +``` + +**根因** +UTS 模板中的 `@click` 不支持内联多行逻辑,只能调用命名方法。 + +```html + + + + +``` + +```typescript +// script 中 +handleClick() { + this.doA() + this.doB() +} +``` + +--- + +### 4.7 picker 事件类型错误 + +**错误信息** + +``` +UniPickerChangeEvent does not exist +argument type mismatch: UniInputEvent vs expected +``` + +**根因** +`` 的 `@change` 事件在 uni-app x Android 端使用 `UniInputEvent`(或通用事件接口),`UniPickerChangeEvent` 不存在。 + +**修复**:提取 `utils/eventHelpers.uts` 工具函数统一处理: + +```typescript +// utils/eventHelpers.uts +export function getPickerIdx(e: UniInputEvent): number { + return parseInt(e.detail.value as string) ?? 0; +} + +export function getInputVal(e: UniInputEvent): string { + return (e.detail.value as string) ?? ""; +} +``` + +模板使用: + +```html + +``` + +--- + +### 4.8 对象字面量未声明命名类型 + +**错误信息** + +``` +smart cast to 'String' is impossible +unresolved reference 'link' +``` + +**根因** +UTS 的对象字面量需要有已声明的 `type` 类型定义,否则编译器无法推断字段名,smart cast 失败。 + +```typescript +// ❌ 错误写法 +const item = { link: '/page', title: '标题' } +if (item.link) { ... } // unresolved reference 'link' + +// ✅ 修复写法 +type NavItem = { link: string; title: string } +const item: NavItem = { link: '/page', title: '标题' } +if (item.link !== '') { ... } +``` + +--- + +### 4.9 `String?` 赋值给 `String` + +**错误信息** + +``` +assignment type mismatch: String? cannot be assigned to String +``` + +**根因** +`UTSJSONObject.getString()` 返回 `String?`(可空),不能直接赋给非空 `String` 变量。 + +```typescript +// ❌ 错误写法 +const name: string = obj.getString("name"); // String? → String 报错 + +// ✅ 修复写法 +const name: string = obj.getString("name") ?? ""; +``` + +--- + +### 4.10 `Record` 类型使用 + +**错误信息** + +``` +unresolved reference 'Record' +type argument is not within its bounds +``` + +**根因** +UTS Android 端不支持 `Record` 泛型,Kotlin 没有对应的直接映射。 + +```typescript +// ❌ 错误写法 +const map: Record = {}; +map["key"] = 1; + +// ✅ 修复写法(用 if-else 替代或用 UTSJSONObject) +const map = {} as UTSJSONObject; +// 或者直接用具体 type 替代 Record +``` + +--- + +### 4.11 函数参数变量名不一致 + +**错误信息** + +``` +unresolved reference 'link' +unresolved reference 'item' +``` + +**根因** +事件处理函数的形参名与函数体内使用的变量名不一致(如声明时叫 `item`,调用时传的是 `tip.link`)。 + +**修复**:参见 §7 问题9——将事件处理器改为只接收基础类型参数,在模板层完成字段提取: + +```html + + + + viewDetail(link: string) { if (link.length > 0) { uni.navigateTo({ url: link + }) } } +``` + +--- + +### 4.12 可空接收者运算符调用 + +**错误信息** + +``` +operator call is prohibited on nullable receiver: String? +``` + +**根因** +`String?` 类型不能直接进行字符串运算,需要先用 `?? ''` 确保非空。 + +```typescript +// ❌ 错误写法 +const total = price * count; // price: number | null + +// ✅ 修复写法 +const total = (price ?? 0) * count; +``` + +--- + +### 4.13 多级可选链类型推断失败 + +**错误信息** + +``` +Cannot infer type for this parameter. Please specify it explicitly. +Only safe (?.) or non-null asserted (!!.) calls are allowed on a nullable receiver +``` + +**根因** +UTS/Kotlin 不支持像 JS 那样无限级联的可选链(`a?.b?.c?.d`),多级嵌套时编译器无法推断中间类型。 + +```typescript +// ❌ 错误写法 +const val = response?.data?.items?.[0]?.name; + +// ✅ 修复写法(分步判空) +if (response != null && response.data != null) { + const items = response.data as Array; + if (items.length > 0) { + const name = items[0].getString("name") ?? ""; + } +} +``` + +--- + +### 4.14 UTS 编译阶段问题汇总表 + +| # | 错误类型 | 典型报错 | 核心修复 | +| --- | -------------------- | ------------------------------------------ | ---------------------------- | +| 1 | 非布尔条件 | `condition type mismatch: String` | `!== ''` / `!= null` | +| 2 | onLoad参数点访问 | `unresolved reference 'type'` | `UTSJSONObject.getString()` | +| 3 | `any[]` 泛型 | `Array vs Array` | 明确泛型类型 | +| 4 | UTSJSONObject点访问 | `unresolved reference 'name'` | `.getString()` 等方法 | +| 5 | 回调类型不匹配 | `AkRequestSuccessResult vs RequestSuccess` | 显式类型声明 | +| 6 | 内联箭头函数 | `only expressions are allowed` | 抽取命名方法 | +| 7 | picker事件类型 | `UniPickerChangeEvent does not exist` | `eventHelpers.uts` | +| 8 | 未声明类型对象 | `smart cast impossible` | 声明 `type` + `as TypeName` | +| 9 | `String?` → `String` | `assignment type mismatch` | `?? ''` 兜底 | +| 10 | `Record` | `unresolved reference 'Record'` | 改用具体类型或 UTSJSONObject | +| 11 | 参数名不一致 | `unresolved reference 'link'` | 模板层提取基础类型参数 | +| 12 | 可空运算符 | `prohibited on nullable receiver` | `?? 0` 后运算 | +| 13 | 多级可选链 | `Cannot infer type` | 分步判空 | + +--- + +## 5. Run 到 Android 端时的报错与修复全过程 + +> **触发场景**:UTS 编译通过,生成 `.kt` 文件后,复制到 Android Studio 项目执行 `assembleDebug`,出现 Kotlin 编译错误;或 APK 安装后逻辑行为异常。 +> +> **错误来源文件**:`D:\Android\Project\mall\报错信息.txt`、`build_output_r1_android.txt` + +--- + +### 5.1 问题链路概览 + +``` +HBuilderX UTS编译 → .kt文件生成 + ↓ +复制到 D:\Android\Project\mall\app\src\main\java\ + ↓ +运行 fix_kt_files.ps1(必须!) + ↓ +Android Studio assembleDebug + ↓ +adb install / Run +``` + +--- + +### 5.2 问题1:`main.uts` / `App.uvue` 语法错误导致整个编译中止 + +**现象**:HBuilderX 在处理入口文件时报错,后续所有 `.kt` 文件无法生成。 + +**根因**:`main.uts` 中有 UTS 不支持的写法,或 `App.uvue` 的 ``,错误表明编译器在读到 `` 时还没有找到对应的 `` 关闭标签。 + +### 根因 + +在添加"删除等级确认弹窗"时,多文件替换操作的 `oldString` 匹配了包含 `\n\t\n` 的区块,但生成的 `newString` 重新排布后,模板的 `` 结构被混入了 ``、`` 这类文档级边界标签的替换操作,**oldString 不应包含这些边界标签**;应该只替换边界内部的内容,让边界标签保持原位不动。批量替换后,必须用 `Select-String -Pattern "template|/template|script|/script"` 验证三段式结构完整性。 + +--- + +## 第二轮通用修复规律总结 + +### 规律一:`uni.showModal` / `uni.showActionSheet` 回调中不能用 Promise 链 + +| ❌ 旧模式 | ✅ 新模式 | +| -------------------------------------------- | ----------------------------------------- | +| `success: (res) => { asyncFn().then(...) }` | 响应式状态弹窗 + `async method` + `await` | +| `void this._doSomething()` 在 void lambda 中 | 在组件的 `async method` 中直接 `await` | + +**影响页面**:settings.uvue(退出)、members.uvue(删除等级)、任何需要在系统弹窗确认后执行异步操作的页面。 + +### 规律二:安卓端三大滚动/布局模式 + +| 场景 | 正确模式 | +| ---------------- | ----------------------------------------------------------------------------------- | +| 纯列表页 | 根 `height:100% flex-column overflow:hidden` + scroll-view `flex:1 overflow:hidden` | +| 含底部操作栏 | 同上 + 操作栏 `flex-shrink:0`(不用 position:fixed) | +| scroll-view 方向 | `direction="vertical"` 不是 `scroll-y` | + +### 规律三:退出登录必须三清 + +1. **清 Supabase 内存 session**:`await supa.signOut()` +2. **清所有 storage key**:token × 3 + user_id + 商家相关 key × 4 +3. **清 store 内存态**:`setIsLoggedIn(false)` + `setUserProfile({...})` + +缺任何一步,App 启动时的恢复逻辑都可能把用户重新送回商家端。 + +### 规律四:onShow 中刷新列表必须先重置分页 + +```typescript +onShow() { + this.page = 1 // 必须 + this.hasMore = true // 必须 + this.list = [] // 必须 + this.loadData() +} +``` + +### 规律五:多处批量替换后的必检项 + +```powershell +# 检查三段式结构完整性 +Select-String -Path "xxx.uvue" -Pattern "^|^|^" + +# 检查关键 import 是否存在 +Select-String -Path "xxx.uvue" -Pattern "import supa|type MemberLevel" +``` + +--- + +## 第二轮未解决问题(需后续跟进) + +| 序号 | 问题 | 当前状态 | 建议 | +| ---- | -------------------------------------------------------------------- | ---------------------------------------------------- | -------------------------------------------------- | +| P-01 | 消息中心/聊天功能结构未完善 | 页面存在但功能简陋,未实现会话列表/未读标记/订单关联 | 参考正规电商商家端重新设计消息架构 | +| P-02 | 扫码功能未实现 | `scan.uvue` 页面存在但功能为占位符 | 接入 `uni.scanCode` API | +| P-03 | 会员客户列表加载时出现"暂无客户"再显示数据(loading/empty 状态混淆) | 已知但未在本轮修复 | 添加 `isLoading` 状态,loading 中不显示 empty 状态 | +| P-04 | uni-app SDK AAR 依赖缺失(延续自第一轮 §5.7) | 涉及 `uni_showModal` 等 API 的 Kotlin 符号缺失 | 从 HBuilderX 本地资源包补充 AAR 到 app/libs/ | +| P-05 | getPushClientId 推送 CID 注册未实现(延续自第一轮 §5.12) | 待用正确的 UTS 签名重新实现 | 参考 §5.12 中的修复模板 | + +--- + +_第二轮记录更新:2026-04-21_ + +--- + +# 第三轮消费者端安卓适配补充记录(2026-05-13) + +> **本轮时间**:2026-05-13 +> **调试阶段**:消费者端主路径页面安卓真机适配补充 +> **背景**:前两轮主要覆盖商家端编译、构建与运行问题;本轮补充处理消费者端首页主链路中的列表滚动与视觉不一致问题,重点是 `购物车`、`我的`、`我的订单` 三个页面。 + +--- + +## 第三轮问题总览 + +| 序号 | 问题名称 | 页面/模块 | 问题类型 | 是否已解决 | 备注 | +| ----- | ---------------------------------- | --------------- | -------- | ---------- | --------------------------------------------------------- | +| R3-01 | 购物车页面无法上下滑动 | consumer/cart | 安卓布局 | ✅ 已解决 | `100vh + overflow:hidden + scroll-view height:0` 组合失效 | +| R3-02 | 我的页面无法上下滑动 | consumer/my | 安卓布局 | ✅ 已解决 | 与购物车同源,scroll-view 未拿到明确高度 | +| R3-03 | 我的订单页面无法上下滑动 | consumer/orders | 安卓布局 | ✅ 已解决 | 已计算 viewportHeight,但未绑定到滚动容器 | +| R3-04 | 我的页面安卓配色与小程序结果不一致 | consumer/my | 安卓视觉 | ✅ 已解决 | 渐变与叠层卡片在 Android 原生渲染器中回退异常 | + +--- + +## R3-01/R3-02/R3-03:消费者端主列表页面无法上下滑动(根容器高度链断裂) + +### 现象 + +- `pages/main/cart.uvue`:购物车商品列表和推荐商品区域在安卓端无法下滑 +- `pages/main/profile.uvue`:我的页面钱包、订单、猜你喜欢区域无法下滑 +- `pages/mall/consumer/orders.uvue`:我的订单列表只能显示首屏内容,后续订单无法通过手势下滑查看 + +### 出现位置 + +- `d:\骅锋\mall\pages\main\cart.uvue` +- `d:\骅锋\mall\pages\main\profile.uvue` +- `d:\骅锋\mall\pages\mall\consumer\orders.uvue` + +### 问题类型 + +安卓端布局 / scroll-view 高度计算 + +### 根因分析 + +三个页面的结构本质一致: + +```css +/* ❌ 旧写法的共同模式 */ +.page { + height: 100vh; + min-height: 100vh; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.content-scroll { + flex: 1; + height: 0; +} +``` + +在小程序 / H5 中,这类写法通常仍能滚动,因为浏览器会把 `flex: 1` 和 `height: 0` 结合成可收缩的滚动区域;但在 uni-app x 的 Android 原生渲染链路中,`scroll-view` 必须拿到**明确的像素高度**。一旦父容器只给出 `100vh`、内部再靠 `height: 0` 收缩,原生布局引擎就可能把滚动区计算成非 scrollable 容器,最终表现为: + +- 页面内容正常渲染 +- 可见区域能显示首屏内容 +- 手指上下滑动无响应 + +`orders.uvue` 的情况更隐蔽:源码里已经计算了 `viewportHeight`,但该值仅用于虚拟列表可视区估算,没有实际绑定到 `scroll-view` 容器,所以安卓端仍然拿不到真实可滚动高度。 + +### 修复方式 + +修复原则统一为:**继续保留原有布局结构,但只在 Android 端为页面根容器和 scroll-view 注入明确像素高度**,避免影响小程序现有表现。 + +#### 购物车 / 我的页面修复模式 + +```typescript +const pageWindowHeight = + systemInfo.windowHeight ?? systemInfo.screenHeight ?? 0; + +// #ifdef APP-ANDROID +isAndroidApp.value = true; +// #endif + +const pageStyle = computed((): string => { + if (!isAndroidApp.value) return ""; + if (pageWindowHeight.value <= 0) return ""; + return ( + "height:" + + pageWindowHeight.value + + "px;min-height:" + + pageWindowHeight.value + + "px;" + ); +}); + +const contentStyle = computed((): string => { + if (!isAndroidApp.value) return ""; + const contentHeight = pageWindowHeight.value - fixedHeaderHeight; + return "height:" + contentHeight + "px;min-height:" + contentHeight + "px;"; +}); +``` + +模板中把样式显式绑定到页面容器和 `scroll-view`: + +```html + + + +``` + +#### 我的订单页面修复模式 + +`orders.uvue` 已有 `viewportHeight`,所以只需让这个值真正进入滚动容器: + +```typescript +pageWindowHeight.value = + systemInfo.windowHeight ?? systemInfo.screenHeight ?? 0; +const windowHeight = pageWindowHeight.value; +const calculatedViewport = windowHeight - statusBarHeight.value - 44 - 48; +viewportHeight.value = + calculatedViewport > 0 ? calculatedViewport : windowHeight; + +// #ifdef APP-ANDROID +isAndroidApp.value = true; +// #endif +``` + +```typescript +const ordersContentStyle = computed((): string => { + if (!isAndroidApp.value) return ""; + if (viewportHeight.value <= 0) return ""; + return ( + "height:" + + viewportHeight.value + + "px;min-height:" + + viewportHeight.value + + "px;" + ); +}); +``` + +### 涉及文件 + +- `d:\骅锋\mall\pages\main\cart.uvue` +- `d:\骅锋\mall\pages\main\profile.uvue` +- `d:\骅锋\mall\pages\mall\consumer\orders.uvue` + +### 经验总结 + +> **规律**:消费者端主列表页在 Android 上如果出现“首屏能显示、但完全不能滚动”,优先检查是否存在 `height: 100vh + overflow: hidden + scroll-view height: 0/flex:1` 组合。对 uni-app x Android 来说,scroll-view 最稳妥的做法不是依赖 Web 风格的收缩布局,而是给出明确的像素高度。 +> +> **规范**:有固定头部、筛选条、底部结算栏的页面,Android 端要么使用完整的 `height: 100%` 高度链,要么直接通过 `windowHeight - 固定区域高度` 计算 scroll-view 像素高度;不要只把高度算在 JS 里而不绑定到模板。 + +--- + +## R3-04:我的页面安卓端配色与小程序不一致(渐变与叠层卡片回退异常) + +### 现象 + +`pages/main/profile.uvue` 在微信小程序中顶部是明显的红色渐变,会员条是深棕金色;但在安卓真机中,顶部和钱包会员区出现发白、发灰、层次减弱的问题,视觉结果与小程序明显不一致。 + +### 出现位置 + +`d:\骅锋\mall\pages\main\profile.uvue` + +### 问题类型 + +安卓端视觉兼容 / CSS 渐变回退 + +### 根因分析 + +问题不在数据,而在样式实现方式:页面头部和会员条使用了复杂叠层视觉,核心依赖如下写法: + +```css +/* ❌ 旧写法 */ +.jd-profile-top { + background: linear-gradient(180deg, #ff3b30 0%, #ff5a36 60%, #f5f5f5 100%); +} + +.wallet-plus-ribbon { + background: linear-gradient(90deg, #5a3a21 0%, #75542f 52%, #a68049 100%); +} +``` + +在小程序端,这类渐变简写可以正常渲染;但在 Android 原生渲染器中,复杂渐变、卡片叠层和浅色蒙版叠加后,容易出现: + +- 渐变层未按预期渲染 +- 背景色回退到默认浅色 +- 文本和图标看起来像“发白”“褪色” + +这类问题不会报编译错误,属于纯渲染兼容差异。 + +### 修复方式 + +处理原则是:**保留小程序端原视觉,同时为 Android 增加稳定的配色回退类**。 + +#### 步骤一:把单一 `background` 拆成 `background-color + background-image` + +```css +.jd-profile-top { + background-color: #ff5a36; + background-image: linear-gradient( + 180deg, + #ff3b30 0%, + #ff5a36 60%, + #f5f5f5 100% + ); +} + +.wallet-plus-ribbon { + background-color: #8a6638; + background-image: linear-gradient( + 90deg, + #5a3a21 0%, + #75542f 52%, + #a68049 100% + ); +} +``` + +#### 步骤二:只在 Android 端切换到稳定回退样式 + +```html + + +``` + +```css +.jd-profile-top-android { + background-image: none; + background-color: #ff5a36; +} + +.wallet-plus-ribbon-android { + background-image: none; + background-color: #8a6638; +} +``` + +这样做的结果是: + +- 小程序端继续保留原来的渐变视觉 +- Android 端使用稳定纯色回退,不再出现大面积发白发灰 +- 端间通过条件类隔离,不互相影响 + +### 涉及文件 + +`d:\骅锋\mall\pages\main\profile.uvue` + +### 经验总结 + +> **规律**:uni-app x Android 对复杂 `linear-gradient`、多层卡片叠色和透明蒙版的支持不如小程序稳定。遇到“同一份代码,小程序颜色正常、Android 发灰发白”的情况,应优先怀疑渐变和叠层渲染回退,而不是业务数据或主题变量。 +> +> **规范**:复杂视觉区域采用“两层策略”最稳妥:默认样式保留渐变,小程序/H5 使用完整视觉;Android 端通过 `#ifdef APP-ANDROID` 或运行时 `isAndroidApp` 类名切换到纯色回退,保证颜色稳定。 + +--- + +## 第三轮通用修复规律总结 + +### 规律一:消费者端主链路页面的 scroll-view 不要依赖浏览器式自适应高度 + +| 场景 | 更稳妥的 Android 写法 | +| ----------------------------- | ------------------------------------------------------------------ | +| 页面只有一个滚动主体 | 根容器显式高度 + scroll-view 显式高度 | +| 页面有固定头部 / Tab / 筛选条 | `windowHeight - 固定区域高度` 计算出内容区像素高度 | +| 页面内部有虚拟列表 | 虚拟列表 `viewportHeight` 既用于计算,也必须真正绑定到 scroll-view | + +### 规律二:端差异优先做“条件运行”而不是直接改公共样式 + +```typescript +// #ifdef APP-ANDROID +isAndroidApp.value = true; +// #endif +``` + +通过端标记切换模板类名或样式字符串,可以保证: + +1. Android 端拿到稳定布局/配色 +2. 小程序端保持原有视觉和交互 +3. 后续排查时能快速判断某个问题是不是 Android 专用回退逻辑触发 + +### 规律三:视觉兼容优先降级到“可控纯色”,不要硬扛复杂渐变 + +当页面关键区域承担品牌色表达时,Android 端出现颜色失真,优先级应是: + +1. 先保证颜色正确 +2. 再保证层次接近 +3. 最后才追求和小程序完全一致的渐变细节 + +原因很简单:用户首先感知的是“颜色对不对”,其次才是“渐变够不够精致”。 + +--- + +_第三轮记录更新:2026-05-13_ + + +--- + +## 13. Delivery 端地址空白问题(PostgreSQL 函数层面) + +> **记录时间**:2026-05-28 +> **涉及表**:`ec_care_tasks`、`hss_service_orders` +> **涉及函数**:`public.delivery_build_order_json(...)` +> **影响端**:医疗-delivery(配送端订单详情页、路线页) + +--- + +### 13.1 问题现象 + +Consumer 端下单后,订单详情页地址显示正常。但 Delivery 端的订单详情页(`detail.uvue`)和路线页(`route.uvue`)中,`order.address`、`order.addressDetail` 等地址字段始终为空白。 + +同时: +- `elderGender`(性别)和 `elderAge`(年龄)显示正常 +- 只有地址相关字段(`address`、`addressSummary`、`addressDetail`、`fullAddress`、`latitude`、`longitude`)为空 + +--- + +### 13.2 数据层背景 + +本项目存在**双订单存储**: + +| 表 | 说明 | 地址字段 | +|----|------|----------| +| `ec_care_tasks` | Care 路径(新) | `address_snapshot`(JSONB)、`address_snapshot_json`(JSONB) | +| `hss_service_orders` | Legacy 路径(旧) | `address_snapshot_json`(JSONB,NOT NULL DEFAULT `'{}'::jsonb`) | + +Consumer 端创建订单时,根据 `useAddressSnapshot` 参数决定写入 `address_snapshot` 还是 `address_snapshot_json`。`ec_care_tasks` 表两列同时存在,`hss_service_orders` 表只有 `address_snapshot_json` 列。 + +Delivery 端通过 RPC 调用 `rpc_delivery_order_list` / `rpc_delivery_dashboard` / `rpc_delivery_order_detail` 获取订单,这些 RPC 内部统一调用 `delivery_build_order_json` 函数来构建返回的 JSON 结构。 + +--- + +### 13.3 排查过程 + +#### 第一轮:怀疑空对象阻断 fallback + +最初怀疑 `address_snapshot_json` 列存储了空对象 `'{}'::jsonb`,导致 `COALESCE` 将其视为有效值而跳过 fallback 到 `address_snapshot`。 + +尝试修复:执行 `20260528_fix_address_fallback.sql`,在 `v_address` 的 `COALESCE` 中加入 `NULLIF` 排除空对象: + +```sql +v_address JSONB := COALESCE( + NULLIF(p_raw -> 'address_snapshot_json', '{}'::jsonb), + NULLIF(p_raw -> 'address_snapshot_json', 'null'::jsonb), + NULLIF(p_raw -> 'address_snapshot', '{}'::jsonb), + NULLIF(p_raw -> 'address_snapshot', 'null'::jsonb), + '{}'::jsonb +); +``` + +**结果**:执行后 Delivery 端地址仍然空白,修复未生效。 + +#### 第二轮:诊断脚本定位 + +创建并执行诊断脚本 `20260528_debug_address.sql`,三个诊断查询: + +**诊断1:确认函数版本** +```sql +SELECT proname, + CASE WHEN prosrc LIKE '%NULLIF(p_raw -> ''address_snapshot_json'', ''{}''::jsonb)%' THEN 'FIXED' ... END AS address_fallback_status, + CASE WHEN prosrc LIKE '%|| jsonb_build_object%' THEN 'SPLIT' ELSE 'SINGLE_BLOCK' END AS function_structure +FROM pg_proc WHERE proname = 'delivery_build_order_json'; +``` + +结果:`FIXED (含 NULLIF 排除空对象)` + `SPLIT (小块拼接)`。说明函数版本已经是最新修复版。 + +**诊断2:查看订单地址数据状态** +```sql +SELECT id, task_no, + COALESCE(address_snapshot_json::TEXT, 'NULL') AS addr_json_raw, + COALESCE(address_snapshot::TEXT, 'NULL') AS addr_snap_raw +FROM public.ec_care_tasks ORDER BY created_at DESC LIMIT 5; +``` + +结果:`address_snapshot_json` 全部是 `NULL`,`address_snapshot` **有完整数据**(包含 `fullAddress`、`detailAddress`、`latitude`、`longitude` 等字段)。 + +**诊断3:直接测试函数输出** +```sql +SELECT delivery_build_order_json(to_jsonb(t), '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, NULL, 'care') ->> 'address' AS test_address +FROM public.ec_care_tasks t ORDER BY created_at DESC LIMIT 1; +``` + +结果:`test_address` = **空**,但 `test_gender` = "女"、`test_age` = 78(正常)。 + +#### 第三轮:逐步拆解 COALESCE + +为了确认 `COALESCE` + `NULLIF` 的行为,执行逐步测试: + +```sql +WITH test_data AS (SELECT to_jsonb(t) AS p_raw FROM public.ec_care_tasks t ORDER BY created_at DESC LIMIT 1) +SELECT + p_raw -> 'address_snapshot_json' AS step1_raw_json_col, -- NULL + NULLIF(p_raw -> 'address_snapshot_json', '{}'::jsonb) AS step2, -- NULL + NULLIF(p_raw -> 'address_snapshot_json', 'null'::jsonb) AS step3, -- NULL + p_raw -> 'address_snapshot' AS step4_raw_snap_col, -- 有完整数据 + NULLIF(p_raw -> 'address_snapshot', '{}'::jsonb) AS step5, -- 有完整数据 + COALESCE( + NULLIF(p_raw -> 'address_snapshot_json', '{}'::jsonb), + NULLIF(p_raw -> 'address_snapshot_json', 'null'::jsonb), + NULLIF(p_raw -> 'address_snapshot', '{}'::jsonb), + NULLIF(p_raw -> 'address_snapshot', 'null'::jsonb), + '{}'::jsonb + ) AS final_v_address -- NULL ❌ +FROM test_data; +``` + +**关键发现**: +- `step5`(单独计算的 `NULLIF`)= **有完整数据的 JSONB 对象** +- `final_v_address`(同样的表达式嵌套在 `COALESCE` 中)= **NULL** + +这说明 **`COALESCE(NULLIF(...))` 的组合在 PostgreSQL 中对 JSONB 类型存在不可预期的行为**。虽然每个子表达式单独执行都正确,但嵌套在 `COALESCE` 内部时,`v_address` 最终变成了 `NULL`。 + +#### 第四轮:确认 `to_jsonb` 直接访问正常 + +为了排除 `to_jsonb(t)` 本身的问题,直接测试: + +```sql +SELECT + to_jsonb(t) -> 'address_snapshot' ->> 'fullAddress' AS test_fullAddress, + to_jsonb(t) -> 'address_snapshot' ->> 'detailAddress' AS test_detail +FROM public.ec_care_tasks t ORDER BY created_at DESC LIMIT 1; +``` + +结果:`test_fullAddress` = "广东省梅州市梅县区扶大镇新城科技园景逸花园 骅锋科技",`test_detail` = "骅锋科技"。 + +**结论:数据完全正常,问题只在函数内部的 `v_address` 赋值逻辑。** + +--- + +### 13.4 根因分析 + +PostgreSQL 的 `COALESCE` + `NULLIF` 组合在处理 JSONB 类型时存在**隐式类型推断陷阱**: + +1. `p_raw -> 'address_snapshot_json'` 返回 SQL `NULL` +2. `NULLIF(NULL, '{}'::jsonb)` 返回 SQL `NULL` +3. `NULLIF(NULL, 'null'::jsonb)` 返回 SQL `NULL` +4. `p_raw -> 'address_snapshot'` 返回有数据的 JSONB 对象 +5. `NULLIF(对象, '{}'::jsonb)` 应该返回该对象 + +但当这些表达式嵌套在 `COALESCE` 中时,`COALESCE` 的类型统一推断机制与 `NULLIF` 的返回值类型产生了冲突,导致整个表达式最终返回 `NULL` 而非预期的 JSONB 对象。 + +> 注:此行为在 PostgreSQL 文档中无明确定义,属于 JSONB 类型与 `COALESCE`/`NULLIF` 交互的边界情况。同样的逻辑对 `TEXT`、`INTEGER` 等基础类型工作正常。 + +--- + +### 13.5 修复方案 + +**核心改动**:将 `v_address` 的赋值从 `COALESCE(NULLIF(...))` 改为 `CASE WHEN jsonb_typeof(...) = 'object'`,完全绕过 `COALESCE` + `NULLIF` 的陷阱。 + +```sql +-- 旧写法(有bug) +v_address JSONB := COALESCE( + NULLIF(p_raw -> 'address_snapshot_json', '{}'::jsonb), + NULLIF(p_raw -> 'address_snapshot_json', 'null'::jsonb), + NULLIF(p_raw -> 'address_snapshot', '{}'::jsonb), + NULLIF(p_raw -> 'address_snapshot', 'null'::jsonb), + '{}'::jsonb +); + +-- 新写法(可靠) +v_address JSONB := CASE + WHEN jsonb_typeof(p_raw -> 'address_snapshot_json') = 'object' + AND p_raw -> 'address_snapshot_json' != '{}'::jsonb + AND p_raw -> 'address_snapshot_json' != 'null'::jsonb + THEN p_raw -> 'address_snapshot_json' + WHEN jsonb_typeof(p_raw -> 'address_snapshot') = 'object' + AND p_raw -> 'address_snapshot' != '{}'::jsonb + AND p_raw -> 'address_snapshot' != 'null'::jsonb + THEN p_raw -> 'address_snapshot' + ELSE '{}'::jsonb +END; +``` + +**修复文件**:`mall_sql/migrations/20260528_fix_address_v2.sql` + +**验证**: +```sql +SELECT delivery_build_order_json(to_jsonb(t), '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, NULL, 'care') ->> 'address' AS test_address +FROM public.ec_care_tasks t ORDER BY created_at DESC LIMIT 1; +-- 结果:"广东省梅州市梅县区扶大镇新城科技园景逸花园 骅锋科技" ✅ +``` + +--- + +### 13.6 经验教训 + +| # | 教训 | +|---|------| +| 1 | **不要用 `COALESCE(NULLIF(...))` 处理 JSONB 类型**。PostgreSQL 中 JSONB 与 `COALESCE`/`NULLIF` 的组合行为不稳定,应改用 `CASE WHEN` + `jsonb_typeof()` | +| 2 | **数据层诊断优先于前端排查**。当 Delivery 端显示空白而 Consumer 端正常时,首先应检查 RPC 函数对同一条数据的实际输出,而不是怀疑前端渲染逻辑 | +| 3 | **逐步拆解复杂表达式**。`step5` 单独执行正确但 `final_v_address` 为 NULL,说明问题出在表达式嵌套层面,而非数据本身 | +| 4 | **保留诊断脚本归档**。`20260528_debug_address.sql` 可作为后续类似问题的排查模板 | + +--- + +_第四轮记录更新:2026-05-28_ diff --git a/mall_sql/migrations/20260528_debug_address.sql b/mall_sql/migrations/20260528_debug_address.sql new file mode 100644 index 00000000..0263e5fa --- /dev/null +++ b/mall_sql/migrations/20260528_debug_address.sql @@ -0,0 +1,66 @@ +-- ============================================ +-- 诊断脚本:定位 delivery 端地址空白问题 +-- 请复制全部内容执行,把结果截图发给我 +-- ============================================ + +-- 诊断1:确认 delivery_build_order_json 当前版本 +SELECT + proname, + CASE + WHEN prosrc LIKE '%NULLIF(p_raw -> ''address_snapshot_json'', ''{}''::jsonb)%' THEN 'FIXED (含 NULLIF 排除空对象)' + WHEN prosrc LIKE '%p_raw -> ''address_snapshot''%' THEN 'HAS_FALLBACK 但缺少 NULLIF' + ELSE 'NO_FALLBACK (只有 address_snapshot_json)' + END AS address_fallback_status, + CASE + WHEN prosrc LIKE '%|| jsonb_build_object%' THEN 'SPLIT (小块拼接)' + ELSE 'SINGLE_BLOCK (单个大块)' + END AS function_structure +FROM pg_proc +WHERE proname = 'delivery_build_order_json'; + + +-- 诊断2:查看 ec_care_tasks 最近5条订单的地址数据状态 +SELECT + id, + task_no, + elder_name, + assigned_to, + COALESCE(address_snapshot_json::TEXT, 'NULL') AS addr_json_raw, + COALESCE(address_snapshot::TEXT, 'NULL') AS addr_snap_raw, + CASE + WHEN address_snapshot_json IS NULL THEN 'json_null' + WHEN address_snapshot_json = '{}'::jsonb THEN 'json_empty' + WHEN address_snapshot_json = 'null'::jsonb THEN 'json_literal_null' + ELSE 'json_has_data' + END AS json_status, + CASE + WHEN address_snapshot IS NULL THEN 'snap_null' + WHEN address_snapshot = '{}'::jsonb THEN 'snap_empty' + WHEN address_snapshot = 'null'::jsonb THEN 'snap_literal_null' + ELSE 'snap_has_data' + END AS snap_status +FROM public.ec_care_tasks +ORDER BY created_at DESC +LIMIT 5; + + +-- 诊断3:直接测试 delivery_build_order_json 对最近一条 care 订单的输出 +-- 如果返回的 address 为空字符串,说明函数内部有问题 +SELECT + t.id, + t.task_no, + delivery_build_order_json( + to_jsonb(t), + '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, NULL, 'care' + ) ->> 'address' AS test_address, + delivery_build_order_json( + to_jsonb(t), + '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, NULL, 'care' + ) ->> 'elderGender' AS test_gender, + delivery_build_order_json( + to_jsonb(t), + '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, NULL, 'care' + ) ->> 'elderAge' AS test_age +FROM public.ec_care_tasks t +ORDER BY t.created_at DESC +LIMIT 1; diff --git a/mall_sql/migrations/20260528_diagnose_address.sql b/mall_sql/migrations/20260528_diagnose_address.sql new file mode 100644 index 00000000..38d10858 --- /dev/null +++ b/mall_sql/migrations/20260528_diagnose_address.sql @@ -0,0 +1,30 @@ +-- ============================================ +-- 诊断:查看订单的地址数据 +-- ============================================ + +-- 查看 ec_care_tasks 中这个订单的地址字段(用订单ID替换 'xxx') +SELECT + id, + task_no, + COALESCE(address_snapshot_json::TEXT, 'NULL') AS address_snapshot_json_raw, + COALESCE(address_snapshot::TEXT, 'NULL') AS address_snapshot_raw, + CASE + WHEN address_snapshot_json IS NULL THEN 'null' + WHEN address_snapshot_json = '{}'::jsonb THEN 'empty_object' + ELSE 'has_data' + END AS json_status, + CASE + WHEN address_snapshot IS NULL THEN 'null' + WHEN address_snapshot = '{}'::jsonb THEN 'empty_object' + ELSE 'has_data' + END AS snapshot_status +FROM public.ec_care_tasks +WHERE task_no LIKE '%20260528%' -- 根据订单号调整 +ORDER BY created_at DESC +LIMIT 5; + +-- 直接测试 delivery_build_order_json 对这条数据的输出 +-- SELECT delivery_build_order_json( +-- (SELECT to_jsonb(t) FROM public.ec_care_tasks t WHERE t.id = '订单UUID'::uuid), +-- '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, NULL, 'care' +-- ) ->> 'address' AS test_address; diff --git a/mall_sql/migrations/20260528_fix_address_fallback.sql b/mall_sql/migrations/20260528_fix_address_fallback.sql new file mode 100644 index 00000000..1ff8732c --- /dev/null +++ b/mall_sql/migrations/20260528_fix_address_fallback.sql @@ -0,0 +1,222 @@ +-- ============================================ +-- 修复 delivery 端地址读取问题 +-- 增强 address_snapshot / address_snapshot_json 的兼容性 +-- ============================================ + +CREATE OR REPLACE FUNCTION public.delivery_build_order_json( + p_raw JSONB, + p_logs JSONB DEFAULT '[]'::jsonb, + p_records JSONB DEFAULT '[]'::jsonb, + p_evidence JSONB DEFAULT '[]'::jsonb, + p_exception JSONB DEFAULT NULL, + p_source TEXT DEFAULT 'legacy' +) +RETURNS JSONB +LANGUAGE plpgsql +IMMUTABLE +AS $$ +DECLARE + v_service JSONB := COALESCE(p_raw -> 'service_snapshot_json', jsonb_build_object('category', COALESCE(p_raw ->> 'service_category', ''), 'price', COALESCE((p_raw ->> 'service_price')::NUMERIC, 0))); + v_address JSONB := COALESCE( + NULLIF(p_raw -> 'address_snapshot_json', '{}'::jsonb), + NULLIF(p_raw -> 'address_snapshot_json', 'null'::jsonb), + NULLIF(p_raw -> 'address_snapshot', '{}'::jsonb), + NULLIF(p_raw -> 'address_snapshot', 'null'::jsonb), + '{}'::jsonb + ); + v_raw_status TEXT := COALESCE(p_raw ->> 'status', 'assigned'); + v_normalized_status TEXT; + v_front_status TEXT; + v_checkin_record JSONB; + v_service_record JSONB; + v_service_items JSONB; + v_record_json JSONB; + v_timeline JSONB; + v_status_logs JSONB; + v_evidence_list JSONB; +BEGIN + IF p_source = 'care' THEN + IF COALESCE(p_raw ->> 'accepted_by_family_at', '') <> '' THEN + v_normalized_status := 'completed'; + ELSIF COALESCE(p_raw ->> 'acceptance_pending_at', '') <> '' THEN + v_normalized_status := 'pending_acceptance'; + ELSIF COALESCE(p_raw ->> 'service_started_at', '') <> '' THEN + v_normalized_status := 'in_service'; + ELSIF COALESCE(p_raw ->> 'checked_in_at', '') <> '' THEN + v_normalized_status := 'arrived'; + ELSIF COALESCE(p_raw ->> 'departed_at', '') <> '' THEN + v_normalized_status := 'departed'; + ELSIF COALESCE(p_raw ->> 'accepted_at', '') <> '' THEN + v_normalized_status := 'accepted'; + ELSE + v_normalized_status := CASE v_raw_status + WHEN 'ORDER_ACCEPTED' THEN 'accepted' + WHEN 'ORDER_CHECKED_IN' THEN 'arrived' + WHEN 'ORDER_IN_SERVICE' THEN 'in_service' + WHEN 'ACCEPTANCE_PENDING' THEN 'pending_acceptance' + WHEN 'ORDER_EXCEPTION' THEN 'exception' + WHEN 'ORDER_REJECTED' THEN 'rejected' + WHEN 'ORDER_CANCELLED' THEN 'cancelled' + WHEN 'ORDER_COMPLETED' THEN 'completed' + ELSE 'assigned' + END; + END IF; + ELSE + v_normalized_status := lower(v_raw_status); + END IF; + + v_front_status := public.delivery_front_status(v_normalized_status, p_raw); + v_timeline := public.delivery_build_timeline(p_logs); + v_status_logs := public.delivery_build_status_logs(p_logs, COALESCE(p_raw ->> 'id', '')); + v_evidence_list := public.delivery_build_evidence(p_evidence, COALESCE(p_raw ->> 'id', '')); + + SELECT item INTO v_checkin_record + FROM jsonb_array_elements(COALESCE(p_records, '[]'::jsonb)) item + WHERE COALESCE(item ->> 'record_type', item ->> 'care_record_type', '') = 'checkin' + ORDER BY COALESCE(item ->> 'created_at', '') DESC + LIMIT 1; + + SELECT item INTO v_service_record + FROM jsonb_array_elements(COALESCE(p_records, '[]'::jsonb)) item + WHERE COALESCE(item ->> 'record_type', item ->> 'care_record_type', '') <> 'checkin' + AND COALESCE(item ->> 'record_type', item ->> 'care_record_type', '') <> 'review' + ORDER BY COALESCE(item ->> 'created_at', '') DESC + LIMIT 1; + + IF v_service_record IS NULL THEN + v_service_record := v_checkin_record; + END IF; + + v_service_items := COALESCE(v_service_record -> 'service_items_json', public.delivery_default_service_items(COALESCE(p_raw ->> 'id', ''), COALESCE(p_raw ->> 'service_name', ''))); + + IF v_service_record IS NULL THEN + v_record_json := NULL; + ELSE + v_record_json := jsonb_build_object( + 'id', COALESCE(v_service_record ->> 'id', ''), + 'orderId', COALESCE(p_raw ->> 'id', ''), + 'startTime', COALESCE(v_service_record ->> 'started_at', v_service_record ->> 'service_started_at', ''), + 'endTime', COALESCE(v_service_record ->> 'finished_at', v_service_record ->> 'service_finished_at', ''), + 'actualDurationMinutes', COALESCE((v_service_record ->> 'duration_minutes')::INTEGER, (v_service_record ->> 'actual_duration_minutes')::INTEGER, 0), + 'serviceItems', COALESCE(v_service_items, '[]'::jsonb), + 'serviceContent', COALESCE( + ( + SELECT jsonb_agg(elem ->> 'name') + FROM jsonb_array_elements(COALESCE(v_service_items, '[]'::jsonb)) elem + WHERE COALESCE((elem ->> 'completed')::BOOLEAN, false) + ), + '[]'::jsonb + ), + 'processNote', COALESCE(v_service_record ->> 'summary', v_service_record ->> 'content', ''), + 'elderStatus', '', + 'healthMetrics', jsonb_build_object('bloodPressure', '', 'heartRate', '', 'bloodSugar', '', 'bloodOxygen', ''), + 'materialsUsed', '', + 'abnormalNote', '', + 'photos', COALESCE( + ( + SELECT jsonb_agg(item ->> 'file_url') + FROM jsonb_array_elements(COALESCE(p_evidence, '[]'::jsonb)) item + WHERE COALESCE(item ->> 'phase', '') = 'service' + ), + '[]'::jsonb + ), + 'staffRemark', COALESCE(v_service_record ->> 'remark', ''), + 'familyConfirmation', jsonb_build_object('method', 'none', 'code', '', 'signatureName', '', 'signatureUrl', '', 'confirmedAt', ''), + 'createdAt', COALESCE(v_service_record ->> 'created_at', ''), + 'updatedAt', COALESCE(v_service_record ->> 'updated_at', '') + ); + END IF; + + RETURN jsonb_build_object( + 'id', COALESCE(p_raw ->> 'id', ''), + 'orderNo', COALESCE(NULLIF(p_raw ->> 'task_no', ''), p_raw ->> 'order_no', ''), + 'serviceType', COALESCE(NULLIF(v_service ->> 'category', ''), '居家服务'), + 'serviceName', COALESCE(p_raw ->> 'service_name', ''), + 'serviceCategory', COALESCE(v_service ->> 'category', ''), + 'serviceItems', COALESCE(v_service_items, '[]'::jsonb), + 'elderId', COALESCE(p_raw ->> 'elder_id', p_raw ->> 'user_id', ''), + 'elderName', COALESCE(NULLIF(p_raw ->> 'recipient_name', ''), p_raw ->> 'elder_name', ''), + 'elderNameMasked', COALESCE(NULLIF(p_raw ->> 'recipient_name', ''), p_raw ->> 'elder_name', ''), + 'elderGender', COALESCE(NULLIF(p_raw ->> 'elder_gender', ''), p_raw ->> 'recipient_gender', ''), + 'elderAge', COALESCE((p_raw ->> 'elder_age')::INTEGER, (p_raw ->> 'recipient_age')::INTEGER, 0), + 'elderPhone', COALESCE(NULLIF(p_raw ->> 'recipient_phone', ''), p_raw ->> 'elder_phone', ''), + 'elderPhoneMasked', COALESCE(NULLIF(p_raw ->> 'recipient_phone', ''), p_raw ->> 'elder_phone', ''), + 'fullElderName', COALESCE(NULLIF(p_raw ->> 'recipient_name', ''), p_raw ->> 'elder_name', ''), + 'fullPhone', COALESCE(NULLIF(p_raw ->> 'recipient_phone', ''), p_raw ->> 'elder_phone', ''), + 'contactRelation', '家属', + 'addressSummary', COALESCE(NULLIF(v_address ->> 'fullAddress', ''), NULLIF(v_address ->> 'full_address', ''), NULLIF(v_address ->> 'address', ''), ''), + 'address', COALESCE(NULLIF(v_address ->> 'fullAddress', ''), NULLIF(v_address ->> 'full_address', ''), NULLIF(v_address ->> 'address', ''), ''), + 'addressDetail', COALESCE(NULLIF(v_address ->> 'detailAddress', ''), NULLIF(v_address ->> 'detail_address', ''), ''), + 'fullAddress', COALESCE(NULLIF(v_address ->> 'fullAddress', ''), NULLIF(v_address ->> 'full_address', ''), NULLIF(v_address ->> 'address', ''), ''), + 'latitude', COALESCE((v_address ->> 'latitude')::NUMERIC, 0), + 'longitude', COALESCE((v_address ->> 'longitude')::NUMERIC, 0) + ) || jsonb_build_object( + 'appointmentTime', COALESCE(NULLIF(p_raw ->> 'appointment_time', ''), p_raw ->> 'scheduled_at', ''), + 'appointmentStartTime', COALESCE(NULLIF(p_raw ->> 'appointment_time', ''), p_raw ->> 'scheduled_at', ''), + 'appointmentEndTime', COALESCE(NULLIF(p_raw ->> 'appointment_time', ''), p_raw ->> 'scheduled_at', ''), + 'duration', COALESCE((p_raw ->> 'duration_minutes')::INTEGER, 90), + 'estimatedDuration', COALESCE((p_raw ->> 'duration_minutes')::INTEGER, 90), + 'price', COALESCE((v_service ->> 'price')::NUMERIC, 0), + 'staffIncome', COALESCE((v_service ->> 'price')::NUMERIC, 0), + 'distance', '', + 'actualStartTime', COALESCE(p_raw ->> 'service_started_at', ''), + 'actualEndTime', COALESCE(NULLIF(p_raw ->> 'completed_at', ''), p_raw ->> 'service_completed_at', ''), + 'status', v_front_status, + 'statusText', public.delivery_status_text(v_front_status), + 'statusTone', public.delivery_status_tone(v_front_status), + 'riskTags', '[]'::jsonb, + 'healthTags', '[]'::jsonb, + 'careLevel', COALESCE(v_service ->> 'category', ''), + 'needFamilyPresent', false, + 'needMaterials', false, + 'remark', COALESCE(p_raw ->> 'remark', ''), + 'merchantId', COALESCE(p_raw ->> 'merchant_id', ''), + 'merchantName', COALESCE(p_raw ->> 'merchant_name', ''), + 'deliveryStaffId', COALESCE(NULLIF(p_raw ->> 'current_staff_id', ''), p_raw ->> 'assigned_to', ''), + 'deliveryStaffName', COALESCE(p_raw ->> 'delivery_staff_name', '') + ) || jsonb_build_object( + 'acceptTime', COALESCE(p_raw ->> 'accepted_at', ''), + 'departTime', COALESCE(p_raw ->> 'departed_at', ''), + 'arriveTime', COALESCE(NULLIF(p_raw ->> 'arrived_at', ''), p_raw ->> 'checked_in_at', ''), + 'checkinTime', COALESCE(NULLIF(p_raw ->> 'checked_in_at', ''), p_raw ->> 'arrived_at', ''), + 'startServiceTime', COALESCE(p_raw ->> 'service_started_at', ''), + 'finishTime', COALESCE(NULLIF(p_raw ->> 'completed_at', ''), p_raw ->> 'service_completed_at', ''), + 'cancelReason', COALESCE(p_raw ->> 'cancel_reason', ''), + 'exceptionType', COALESCE(p_exception ->> 'exception_type', ''), + 'exceptionDesc', COALESCE(p_exception ->> 'description', p_exception ->> 'remark', ''), + 'evidenceList', COALESCE(v_evidence_list, '[]'::jsonb), + 'signatureUrl', '', + 'signatureName', '', + 'satisfactionStatus', CASE WHEN v_front_status = 'pending_acceptance' THEN '待验收' WHEN v_front_status = 'completed' THEN '已验收' ELSE '待评价' END, + 'settlementStatus', CASE WHEN v_front_status = 'completed' THEN '待结算' ELSE '待确认' END, + 'archiveStatus', '未归档', + 'createdAt', COALESCE(p_raw ->> 'created_at', ''), + 'updatedAt', COALESCE(p_raw ->> 'updated_at', ''), + 'contactName', COALESCE(p_raw ->> 'contact_name', ''), + 'contactPhone', COALESCE(p_raw ->> 'contact_phone', ''), + 'notices', '[]'::jsonb, + 'timeline', COALESCE(v_timeline, '[]'::jsonb), + 'statusLog', COALESCE(v_status_logs, '[]'::jsonb) + ) || jsonb_build_object( + 'serviceSummary', COALESCE(v_service_record ->> 'summary', v_service_record ->> 'content', ''), + 'progressNote', COALESCE(v_service_record ->> 'remark', ''), + 'distanceKm', '', + 'allowCheckinRadiusMeters', 100, + 'lastLocation', CASE + WHEN v_checkin_record IS NULL THEN NULL + ELSE jsonb_build_object( + 'latitude', COALESCE((v_checkin_record ->> 'latitude')::NUMERIC, (v_checkin_record ->> 'checkin_latitude')::NUMERIC, 0), + 'longitude', COALESCE((v_checkin_record ->> 'longitude')::NUMERIC, (v_checkin_record ->> 'checkin_longitude')::NUMERIC, 0), + 'address', COALESCE(v_checkin_record ->> 'location_text', v_checkin_record ->> 'checkin_address', ''), + 'time', COALESCE(v_checkin_record ->> 'checked_in_at', v_checkin_record ->> 'checkin_time', '') + ) + END, + 'trackPoints', COALESCE(v_service_record -> 'track_points_json', '[]'::jsonb), + 'serviceRecord', v_record_json, + 'abnormalReport', public.delivery_build_abnormal(p_exception, COALESCE(p_raw ->> 'id', '')) + ); +END; +$$; + +-- 刷新 PostgREST schema cache +NOTIFY pgrst, 'reload schema'; diff --git a/mall_sql/migrations/20260528_fix_address_v2.sql b/mall_sql/migrations/20260528_fix_address_v2.sql new file mode 100644 index 00000000..26be80d7 --- /dev/null +++ b/mall_sql/migrations/20260528_fix_address_v2.sql @@ -0,0 +1,227 @@ +-- ============================================ +-- 修复 delivery 端地址读取问题(v2) +-- 根因:COALESCE + NULLIF 对 JSONB 类型处理不可靠 +-- 改用 CASE WHEN jsonb_typeof() 明确判断有效对象 +-- ============================================ + +CREATE OR REPLACE FUNCTION public.delivery_build_order_json( + p_raw JSONB, + p_logs JSONB DEFAULT '[]'::jsonb, + p_records JSONB DEFAULT '[]'::jsonb, + p_evidence JSONB DEFAULT '[]'::jsonb, + p_exception JSONB DEFAULT NULL, + p_source TEXT DEFAULT 'legacy' +) +RETURNS JSONB +LANGUAGE plpgsql +IMMUTABLE +AS $$ +DECLARE + v_service JSONB := COALESCE(p_raw -> 'service_snapshot_json', jsonb_build_object('category', COALESCE(p_raw ->> 'service_category', ''), 'price', COALESCE((p_raw ->> 'service_price')::NUMERIC, 0))); + v_address JSONB := CASE + WHEN jsonb_typeof(p_raw -> 'address_snapshot_json') = 'object' + AND p_raw -> 'address_snapshot_json' != '{}'::jsonb + AND p_raw -> 'address_snapshot_json' != 'null'::jsonb + THEN p_raw -> 'address_snapshot_json' + WHEN jsonb_typeof(p_raw -> 'address_snapshot') = 'object' + AND p_raw -> 'address_snapshot' != '{}'::jsonb + AND p_raw -> 'address_snapshot' != 'null'::jsonb + THEN p_raw -> 'address_snapshot' + ELSE '{}'::jsonb + END; + v_raw_status TEXT := COALESCE(p_raw ->> 'status', 'assigned'); + v_normalized_status TEXT; + v_front_status TEXT; + v_checkin_record JSONB; + v_service_record JSONB; + v_service_items JSONB; + v_record_json JSONB; + v_timeline JSONB; + v_status_logs JSONB; + v_evidence_list JSONB; +BEGIN + IF p_source = 'care' THEN + IF COALESCE(p_raw ->> 'accepted_by_family_at', '') <> '' THEN + v_normalized_status := 'completed'; + ELSIF COALESCE(p_raw ->> 'acceptance_pending_at', '') <> '' THEN + v_normalized_status := 'pending_acceptance'; + ELSIF COALESCE(p_raw ->> 'service_started_at', '') <> '' THEN + v_normalized_status := 'in_service'; + ELSIF COALESCE(p_raw ->> 'checked_in_at', '') <> '' THEN + v_normalized_status := 'arrived'; + ELSIF COALESCE(p_raw ->> 'departed_at', '') <> '' THEN + v_normalized_status := 'departed'; + ELSIF COALESCE(p_raw ->> 'accepted_at', '') <> '' THEN + v_normalized_status := 'accepted'; + ELSE + v_normalized_status := CASE v_raw_status + WHEN 'ORDER_ACCEPTED' THEN 'accepted' + WHEN 'ORDER_CHECKED_IN' THEN 'arrived' + WHEN 'ORDER_IN_SERVICE' THEN 'in_service' + WHEN 'ACCEPTANCE_PENDING' THEN 'pending_acceptance' + WHEN 'ORDER_EXCEPTION' THEN 'exception' + WHEN 'ORDER_REJECTED' THEN 'rejected' + WHEN 'ORDER_CANCELLED' THEN 'cancelled' + WHEN 'ORDER_COMPLETED' THEN 'completed' + ELSE 'assigned' + END; + END IF; + ELSE + v_normalized_status := lower(v_raw_status); + END IF; + + v_front_status := public.delivery_front_status(v_normalized_status, p_raw); + v_timeline := public.delivery_build_timeline(p_logs); + v_status_logs := public.delivery_build_status_logs(p_logs, COALESCE(p_raw ->> 'id', '')); + v_evidence_list := public.delivery_build_evidence(p_evidence, COALESCE(p_raw ->> 'id', '')); + + SELECT item INTO v_checkin_record + FROM jsonb_array_elements(COALESCE(p_records, '[]'::jsonb)) item + WHERE COALESCE(item ->> 'record_type', item ->> 'care_record_type', '') = 'checkin' + ORDER BY COALESCE(item ->> 'created_at', '') DESC + LIMIT 1; + + SELECT item INTO v_service_record + FROM jsonb_array_elements(COALESCE(p_records, '[]'::jsonb)) item + WHERE COALESCE(item ->> 'record_type', item ->> 'care_record_type', '') <> 'checkin' + AND COALESCE(item ->> 'record_type', item ->> 'care_record_type', '') <> 'review' + ORDER BY COALESCE(item ->> 'created_at', '') DESC + LIMIT 1; + + IF v_service_record IS NULL THEN + v_service_record := v_checkin_record; + END IF; + + v_service_items := COALESCE(v_service_record -> 'service_items_json', public.delivery_default_service_items(COALESCE(p_raw ->> 'id', ''), COALESCE(p_raw ->> 'service_name', ''))); + + IF v_service_record IS NULL THEN + v_record_json := NULL; + ELSE + v_record_json := jsonb_build_object( + 'id', COALESCE(v_service_record ->> 'id', ''), + 'orderId', COALESCE(p_raw ->> 'id', ''), + 'startTime', COALESCE(v_service_record ->> 'started_at', v_service_record ->> 'service_started_at', ''), + 'endTime', COALESCE(v_service_record ->> 'finished_at', v_service_record ->> 'service_finished_at', ''), + 'actualDurationMinutes', COALESCE((v_service_record ->> 'duration_minutes')::INTEGER, (v_service_record ->> 'actual_duration_minutes')::INTEGER, 0), + 'serviceItems', COALESCE(v_service_items, '[]'::jsonb), + 'serviceContent', COALESCE( + ( + SELECT jsonb_agg(elem ->> 'name') + FROM jsonb_array_elements(COALESCE(v_service_items, '[]'::jsonb)) elem + WHERE COALESCE((elem ->> 'completed')::BOOLEAN, false) + ), + '[]'::jsonb + ), + 'processNote', COALESCE(v_service_record ->> 'summary', v_service_record ->> 'content', ''), + 'elderStatus', '', + 'healthMetrics', jsonb_build_object('bloodPressure', '', 'heartRate', '', 'bloodSugar', '', 'bloodOxygen', ''), + 'materialsUsed', '', + 'abnormalNote', '', + 'photos', COALESCE( + ( + SELECT jsonb_agg(item ->> 'file_url') + FROM jsonb_array_elements(COALESCE(p_evidence, '[]'::jsonb)) item + WHERE COALESCE(item ->> 'phase', '') = 'service' + ), + '[]'::jsonb + ), + 'staffRemark', COALESCE(v_service_record ->> 'remark', ''), + 'familyConfirmation', jsonb_build_object('method', 'none', 'code', '', 'signatureName', '', 'signatureUrl', '', 'confirmedAt', ''), + 'createdAt', COALESCE(v_service_record ->> 'created_at', ''), + 'updatedAt', COALESCE(v_service_record ->> 'updated_at', '') + ); + END IF; + + RETURN jsonb_build_object( + 'id', COALESCE(p_raw ->> 'id', ''), + 'orderNo', COALESCE(NULLIF(p_raw ->> 'task_no', ''), p_raw ->> 'order_no', ''), + 'serviceType', COALESCE(NULLIF(v_service ->> 'category', ''), '居家服务'), + 'serviceName', COALESCE(p_raw ->> 'service_name', ''), + 'serviceCategory', COALESCE(v_service ->> 'category', ''), + 'serviceItems', COALESCE(v_service_items, '[]'::jsonb), + 'elderId', COALESCE(p_raw ->> 'elder_id', p_raw ->> 'user_id', ''), + 'elderName', COALESCE(NULLIF(p_raw ->> 'recipient_name', ''), p_raw ->> 'elder_name', ''), + 'elderNameMasked', COALESCE(NULLIF(p_raw ->> 'recipient_name', ''), p_raw ->> 'elder_name', ''), + 'elderGender', COALESCE(NULLIF(p_raw ->> 'elder_gender', ''), p_raw ->> 'recipient_gender', ''), + 'elderAge', COALESCE((p_raw ->> 'elder_age')::INTEGER, (p_raw ->> 'recipient_age')::INTEGER, 0), + 'elderPhone', COALESCE(NULLIF(p_raw ->> 'recipient_phone', ''), p_raw ->> 'elder_phone', ''), + 'elderPhoneMasked', COALESCE(NULLIF(p_raw ->> 'recipient_phone', ''), p_raw ->> 'elder_phone', ''), + 'fullElderName', COALESCE(NULLIF(p_raw ->> 'recipient_name', ''), p_raw ->> 'elder_name', ''), + 'fullPhone', COALESCE(NULLIF(p_raw ->> 'recipient_phone', ''), p_raw ->> 'elder_phone', ''), + 'contactRelation', '家属', + 'addressSummary', COALESCE(NULLIF(v_address ->> 'fullAddress', ''), NULLIF(v_address ->> 'full_address', ''), NULLIF(v_address ->> 'address', ''), ''), + 'address', COALESCE(NULLIF(v_address ->> 'fullAddress', ''), NULLIF(v_address ->> 'full_address', ''), NULLIF(v_address ->> 'address', ''), ''), + 'addressDetail', COALESCE(NULLIF(v_address ->> 'detailAddress', ''), NULLIF(v_address ->> 'detail_address', ''), ''), + 'fullAddress', COALESCE(NULLIF(v_address ->> 'fullAddress', ''), NULLIF(v_address ->> 'full_address', ''), NULLIF(v_address ->> 'address', ''), ''), + 'latitude', COALESCE((v_address ->> 'latitude')::NUMERIC, 0), + 'longitude', COALESCE((v_address ->> 'longitude')::NUMERIC, 0) + ) || jsonb_build_object( + 'appointmentTime', COALESCE(NULLIF(p_raw ->> 'appointment_time', ''), p_raw ->> 'scheduled_at', ''), + 'appointmentStartTime', COALESCE(NULLIF(p_raw ->> 'appointment_time', ''), p_raw ->> 'scheduled_at', ''), + 'appointmentEndTime', COALESCE(NULLIF(p_raw ->> 'appointment_time', ''), p_raw ->> 'scheduled_at', ''), + 'duration', COALESCE((p_raw ->> 'duration_minutes')::INTEGER, 90), + 'estimatedDuration', COALESCE((p_raw ->> 'duration_minutes')::INTEGER, 90), + 'price', COALESCE((v_service ->> 'price')::NUMERIC, 0), + 'staffIncome', COALESCE((v_service ->> 'price')::NUMERIC, 0), + 'distance', '', + 'actualStartTime', COALESCE(p_raw ->> 'service_started_at', ''), + 'actualEndTime', COALESCE(NULLIF(p_raw ->> 'completed_at', ''), p_raw ->> 'service_completed_at', ''), + 'status', v_front_status, + 'statusText', public.delivery_status_text(v_front_status), + 'statusTone', public.delivery_status_tone(v_front_status), + 'riskTags', '[]'::jsonb, + 'healthTags', '[]'::jsonb, + 'careLevel', COALESCE(v_service ->> 'category', ''), + 'needFamilyPresent', false, + 'needMaterials', false, + 'remark', COALESCE(p_raw ->> 'remark', ''), + 'merchantId', COALESCE(p_raw ->> 'merchant_id', ''), + 'merchantName', COALESCE(p_raw ->> 'merchant_name', ''), + 'deliveryStaffId', COALESCE(NULLIF(p_raw ->> 'current_staff_id', ''), p_raw ->> 'assigned_to', ''), + 'deliveryStaffName', COALESCE(p_raw ->> 'delivery_staff_name', '') + ) || jsonb_build_object( + 'acceptTime', COALESCE(p_raw ->> 'accepted_at', ''), + 'departTime', COALESCE(p_raw ->> 'departed_at', ''), + 'arriveTime', COALESCE(NULLIF(p_raw ->> 'arrived_at', ''), p_raw ->> 'checked_in_at', ''), + 'checkinTime', COALESCE(NULLIF(p_raw ->> 'checked_in_at', ''), p_raw ->> 'arrived_at', ''), + 'startServiceTime', COALESCE(p_raw ->> 'service_started_at', ''), + 'finishTime', COALESCE(NULLIF(p_raw ->> 'completed_at', ''), p_raw ->> 'service_completed_at', ''), + 'cancelReason', COALESCE(p_raw ->> 'cancel_reason', ''), + 'exceptionType', COALESCE(p_exception ->> 'exception_type', ''), + 'exceptionDesc', COALESCE(p_exception ->> 'description', p_exception ->> 'remark', ''), + 'evidenceList', COALESCE(v_evidence_list, '[]'::jsonb), + 'signatureUrl', '', + 'signatureName', '', + 'satisfactionStatus', CASE WHEN v_front_status = 'pending_acceptance' THEN '待验收' WHEN v_front_status = 'completed' THEN '已验收' ELSE '待评价' END, + 'settlementStatus', CASE WHEN v_front_status = 'completed' THEN '待结算' ELSE '待确认' END, + 'archiveStatus', '未归档', + 'createdAt', COALESCE(p_raw ->> 'created_at', ''), + 'updatedAt', COALESCE(p_raw ->> 'updated_at', ''), + 'contactName', COALESCE(p_raw ->> 'contact_name', ''), + 'contactPhone', COALESCE(p_raw ->> 'contact_phone', ''), + 'notices', '[]'::jsonb, + 'timeline', COALESCE(v_timeline, '[]'::jsonb), + 'statusLog', COALESCE(v_status_logs, '[]'::jsonb) + ) || jsonb_build_object( + 'serviceSummary', COALESCE(v_service_record ->> 'summary', v_service_record ->> 'content', ''), + 'progressNote', COALESCE(v_service_record ->> 'remark', ''), + 'distanceKm', '', + 'allowCheckinRadiusMeters', 100, + 'lastLocation', CASE + WHEN v_checkin_record IS NULL THEN NULL + ELSE jsonb_build_object( + 'latitude', COALESCE((v_checkin_record ->> 'latitude')::NUMERIC, (v_checkin_record ->> 'checkin_latitude')::NUMERIC, 0), + 'longitude', COALESCE((v_checkin_record ->> 'longitude')::NUMERIC, (v_checkin_record ->> 'checkin_longitude')::NUMERIC, 0), + 'address', COALESCE(v_checkin_record ->> 'location_text', v_checkin_record ->> 'checkin_address', ''), + 'time', COALESCE(v_checkin_record ->> 'checked_in_at', v_checkin_record ->> 'checkin_time', '') + ) + END, + 'trackPoints', COALESCE(v_service_record -> 'track_points_json', '[]'::jsonb), + 'serviceRecord', v_record_json, + 'abnormalReport', public.delivery_build_abnormal(p_exception, COALESCE(p_raw ->> 'id', '')) + ); +END; +$$; + +-- 刷新 PostgREST schema cache +NOTIFY pgrst, 'reload schema';