# Property Match — 管理后台 `/admin/property-match` 完整说明

> 文档对象:`https://propertylabglobal.com/admin/property-match`(需登录,`auth:main`)
> 代码版本:2026-07-22 · branch `dev`
> 配套文档:`/property-match-public.md`(公开测评页说明)

---

## 1. 这个页面是什么

Property Match 测评的 **lead 管理后台**(页面标题:「房源配对 · 提交记录」)。销售/管理团队在这里:

1. 看到每一条公开页提交(答案 trail、推荐 unit、联系方式)
2. **按人去重** — 同一个人测评多次会合并成一行(以 email 为主键,其次电话)
3. 自动 **CRM enrich** — 用 email + 电话回查自家数据库:是否付过款/是会员、是否已购房、累计 Zoom 参会时长
4. 推进 lead pipeline:`新提交 → 已约 Zoom → 已联系 → 已成交`
5. 分配 **Appointment setter** 和 **Closer**,排正式**约见时间**(datetime)
6. 打**标签**、写**备注**(自动记录填写人 + 时间的 immutable log)
7. **日历视图** — 按月看全部已排约见,chip 按 Closer 上色

前端同样是 **Alpine.js 3** 单页(浅色后台风格,系统字体,`max-width:1580px`),
所有数据在页面加载时由服务器一次性注入(`@json($people)`),操作通过 fetch POST 即时保存,**无分页、无轮询**。

---

## 2. 文件与路由一览

| 类型 | 位置 |
| --- | --- |
| Routes | `routes/web.php:2995-3000`(prefix `admin/property-match`,namespace `InvestHink`) |
| Controller | `app/Http/Controllers/InvestHink/PropertyMatchAdminController.php`(extends `AuthedController` → **`auth:main` middleware**) |
| View(整页) | `resources/views/investhink/property-match/admin.blade.php` |
| Models | `src/InvestHink/PropertyMatchSubmission.php` · `src/InvestHink/PropertyMatchRemark.php` |
| Migrations | `2026_07_19_100000`(主表)· `2026_07_19_110000`(assignment)· `2026_07_20_000000`(remarks + tags)· `2026_07_20_120000`(appointment_at) |

### Routes(全部要登录)

| Method | URI | Action | Route name | 用途 |
| --- | --- | --- | --- | --- |
| GET | `/admin/property-match` | `index()` | `admin.property-match.index` | 主页面(一人一行) |
| POST | `/admin/property-match/{id}/status` | `updateStatus()` | `admin.property-match.status` | 改 pipeline 状态(整个人同步) |
| POST | `/admin/property-match/{id}/assign` | `assign()` | `admin.property-match.assign` | 设 appointment / closer / appointment_at |
| POST | `/admin/property-match/{id}/remark` | `addRemark()` | `admin.property-match.remark` | 追加一条备注 |
| POST | `/admin/property-match/{id}/tags` | `updateTags()` | `admin.property-match.tags` | 覆盖整组标签 |

`{id}` 是该人**任意一条** submission 的 id(前端固定传 `p.ids[0]`);写操作经 `personScope()` 同步到这个人的全部提交(见 §5)。

### 员工名单(model 常量,写死)

- `APPOINTMENT_STAFF`(约见安排):**Shawn、Ke Xin**
- `CLOSER_STAFF`(成交负责):**Zen、Dylan、Ke Xin、Shawn**

要增减人 → 改 `src/InvestHink/PropertyMatchSubmission.php` 两个常量即可,前端下拉/筛选/日历图例全部由 `@json` 注入自动跟随。

---

## 3. `index()` — 页面数据组装逻辑

1. **取数**:`PropertyMatchSubmission` 按 id 倒序取**最多 1000 条**(无分页 — 超过 1000 条旧提交不显示)。
2. **CRM enrich**(见 §4)。
3. **按人去重(person grouping)**:
   - 分组 key:`lower(trim(email))`;email 空 → 电话纯数字;都空 → `id{n}`(独立成行)
   - 每人聚合:全部 `subs`(id、match、answers、trail、status、时间、lang)、`ids`、
     `appointment` / `closer` / `appointment_at`(任一条有值即取)、`tags`(person 级同步)、
     `latest` 时间
   - **person 状态取"最高进度"**:rank `new=0 < booked=1 < contacted=2 < closed=3`,
     多条提交里取 rank 最大者作为该人显示状态
4. **备注**:`PropertyMatchRemark` 按这批 submission id 拉取(倒序,cap 5000),
   按人归并 — 一个人所有提交下的备注合成同一条时间线(body / author_name / created_at)。
5. **统计** `$totals`:`all`=唯一人数,`subs`=总测评次数,`new`=还停在 new 的人数,
   `booked`=已到 booked 及以上的人数(页头还会算 **转化率** = booked/all%)。
6. **推荐分布** `$byMatch`:submissions 按 `match_title` groupBy count,降序 — 页头一排小卡片。

---

## 4. CRM enrich(`enrich()` 方法)— 回查自家数据库

用提交的 **email(lower/trim)+ 电话(去非数字,≥7 位)** 批量匹配三张业务表,
全程 bulk query + lookup map(不逐行查询);**整段 try/catch 包裹** —
任何 schema 变动只会 `Log::warning('PropertyMatch enrich failed')`,不会弄挂页面。

| 数据源 | 匹配方式 | 得出 |
| --- | --- | --- |
| `wf_stripe_charges`(与 /webinar-funnel/membership 同源) | billingEmail / billingPhone;条件 `amount >= 48` 且 status ∈ succeeded/refunded;cap 5000 | `paid` 累计付款额;金额恰为 **799 / 1799 / 2388** 判定为「**PropertyLab 会员**」,否则「已付款」 |
| `wf_property_bookings` | email / phone;cap 2000,取最新一条 | `property` 已购房项目名 + `propUnit` 单元号 |
| `zoom_event_participants` | user_email(仅 email) | `zoomMin` 累计参会分钟(SUM duration_seconds / 60) |

每个 submission 得到:`{known, member, paid, property, propUnit, zoomMin}`;
`known` = 三者任一命中。没命中的人在列表显示「**陌生访客**」。
匹配优先 email,email 无果才用电话。

---

## 5. 四个写操作(POST)

所有写操作都先 `findOrFail($id)`,再经 **`personScope()`** 扩散:
按该提交的 email(lower/trim)匹配同人**全部** submissions 一起 update
(email 为空则只改这一条)。所以状态/分配/标签在同一个人的多次测评之间永远一致。

### `updateStatus` — `{status}`
- 必须是 `STATUSES` 里的 key(`new/booked/contacted/closed`),否则 422
- 整个人所有提交统一改;返回 `{ok, status}`

### `assign` — `{appointment?, closer?, appointment_at?}`
- `appointment` 只接受 `APPOINTMENT_STAFF` 名单值或空(空=清除)
- `closer` 只接受 `CLOSER_STAFF` 名单值或空
- `appointment_at`:空字符串=清除;否则 `strtotime()` 解析,存 `Y-m-d H:i:00`
- 三个字段可单独或组合传;只更新出现在 request 的字段

### `addRemark` — `{body}`
- body trim 后不能为空(否则 422「备注不能为空」),截取 2000 字
- **作者快照**:取当前登录 admin 的 `profile->full_name`(fallback email,再 fallback "Admin"),
  连同 `author_id` 存入 `property_match_remarks` — **immutable log,无编辑/删除接口**
- 备注挂在**具体那条 submission** 上(不经 personScope),但展示时按人合并
- 返回新备注 JSON(body / author_name / created_at / created_h)供前端即时插入列表顶部

### `updateTags` — `{tags: []}`
- 接受数组或逗号分隔字符串;每个 tag trim、截 24 字、去重,**最多 12 个**
- 整组**覆盖式**写入(JSON_UNESCAPED_UNICODE),经 personScope 同步整个人
- 返回清洗后的 `{ok, tags}`

**Table `property_match_remarks`**:`id · submission_id(index) · author_id · author_name(120,快照) · body(text) · timestamps`。

---

## 6. 前端 UI 详解(`admin.blade.php`,Alpine 组件 `pmAdmin()`)

### 6.1 页头统计卡

- **TOTAL** — 唯一客户数 · 共 N 次测评
- **NEW**(蓝)— 只看结果 · 还没约见
- **BOOKED**(琥珀)— 已约 Zoom · 转化 N%(booked/all)
- 下方一排「**推荐分布**」小卡:每个 match_title 的提交次数

### 6.2 两个 Tab

**📋 列表**(默认)和 **🗓 日历**(tab 上带已约人数角标)。

### 6.3 列表 Tab — 工具栏(搜索 + 筛选,全部前端过滤,即时生效)

- **搜索框**:关键词模糊匹配 姓名 / 电话 / email / appointment / closer / status / **标签** / **备注内容+作者** / 每次测评的 match_title / unit / tag
- **员工筛选**:全部 | Shawn | Ke Xin | Zen | Dylan(可多选,OR 逻辑,匹配 appointment 或 closer 任一)
- **未分配 Closer**(虚线按钮,带人数)— AND 条件
- **已约时间 · N** / **未约时间 · N** — 按 `appointment_at` 有无过滤,AND 条件
- 有筛选时右侧显示 `x / y` 命中计数

### 6.4 列表 Tab — 表格(13 列,min-width 1750px 横向滚动)

| 列 | 内容 |
| --- | --- |
| 线索 | 姓名;多次测评显示 `×N` 蓝色角标;CRM 未命中显示「陌生访客」 |
| 联系方式 | 电话 + email |
| Payment | 「PropertyLab 会员」(绿)或「已付款」+ RM 累计金额;无则 — |
| Property | 已购房项目名(紫)+ 单元号;无则 — |
| Zoom | 累计参会时长(`75m` → `1h 15m` 格式);无则 — |
| 推荐 unit | 最新一次测评的 match_title + unit + tag;多次测评注「共 N 次测评」 |
| Appointment | 下拉(— / Shawn / Ke Xin),改动即 POST 保存 |
| Closer | 下拉(— / Zen / Dylan / Ke Xin / Shawn),改动即保存 |
| 约见时间 | `datetime-local` 原生控件,改动即保存(未设置时灰显) |
| 备注 · 标签 | 紫色标签 chips + 最近一条备注(2 行截断,带作者);空则显示「+ 备注 / 标签」 |
| 状态 | 彩色 badge:新提交(蓝)/ 已约 Zoom(琥珀)/ 已联系(紫)/ 已成交(绿) |
| 最近提交 | 最新一次测评时间(MM/DD HH:mm) |
| › | 展开箭头 |

**点击整行展开详情面板**(下拉/日期/备注列已 `@click.stop` 不触发展开):

- **测评记录 · N 次** — 每次测评一张卡:match_title + unit + 时间 + 完整答案 trail chips
- **CRM 匹配 · 数据库** — 付费 / 已购房 / Zoom 时长 三行 key-value;未命中显示「数据库里没匹配到 —— 新访客 / 陌生号码。」
- **跟进状态 · STATUS** — 状态下拉,改动即保存
- **在 WhatsApp 联系 →** — `wa.me/{电话去非数字}` 直达
- **标签 · TAGS** — 可删(×)可加(输入按回车),≤12 个,即时保存
- **备注 · REMARKS** — textarea(支持 Cmd/Ctrl+Enter 快捷保存)+「保存备注」按钮(保存中 disabled);
  下方倒序时间线,每条:内容 + **作者 · YYYY-MM-DD HH:mm**;失败 alert 提示

### 6.5 日历 Tab

- 月视图,**周一开头**,6 行 42 格,前后月补灰格;今天日期蓝色圆圈
- 数据源 = 所有有 `appointment_at` 的人;chip 显示 `时间(12h制) + 姓名`,同日按时间排序
- **chip 颜色按 Closer**:Shawn 蓝 / Ke Xin 粉 / Zen 绿 / Dylan 橙 / 无 Closer 灰(顶部有图例);hover 显示 tooltip(姓名 · 时间 · 负责人)
- **点击 chip 跳回列表**:自动清筛选、展开该人详情行并平滑滚动定位
- 「‹ 上月 / 今天 / 下月 ›」导航;无任何约见时显示引导文案

### 6.6 保存机制(前端)

- 所有写操作 **optimistic update**:先改本地状态,再 fetch POST(`post()` helper,silent catch);
  只有备注用 `postJson()` 等待响应(要拿服务器生成的作者/时间戳)
- CSRF 经 `<meta name="csrf-token">` 注入所有请求
- 页面数据一次性 `@json` 注入:`PEOPLE / STATUSES / APPT / CLOSER / STAFF / URL_BASE`

---

## 7. 值得注意的实现细节 / 已知限制

- **1000 条 cap 无分页**:index 只取最新 1000 条 submissions;备注 cap 5000。量大后需要加分页/归档。
- **两种"时间"要分清**:`slot` = 买家在公开页自选的**偏好时段文字**(工作日白天等,目前后台表格未展示);
  `appointment_at` = 管理员排的**正式约见 datetime**(驱动日历)。
- **person 同步以 email 为准**:改状态/分配/标签会同步「相同 email」的所有提交;
  同人换 email 再测,会被当成两个人。
- **enrich 防御性设计**:三张表任何 schema 变动只降级(全员显示陌生访客)不炸页面,查 log 关键字 `PropertyMatch enrich failed`。
- **optimistic 保存无失败回滚**(备注除外):网络失败时 UI 显示已改但 DB 未存,刷新即还原。
- **无权限分级**:任何能登录 `auth:main` 的账号都能看和改;无操作审计(除了备注自带作者快照)。
- 备注**只能追加**,没有编辑/删除 — 有意设计成 immutable log。
