Files
laoyang 2e40133d8f v62:影院管理补齐接口(统计 / 年度报告 / 高级搜索)+ 名册带工作经历
用户 2026-10-19:「设置里面的影院管理,这些功能都还没有,应该补充,
就是增加影院呢、修改影院呢、高级搜索、统计、年度报告,这些全部都应该补充进去。」

一、三个新子路由(都是**家族级**,必须在 `$id > 0` 分支之前处理)

  GET /api/v1/movies/stats   总览 + 按影院 / 按人 / 按月 / 按年
  GET /api/v1/movies/report  年度报告(以登录者为中心,?year= 可切年)
  GET /api/v1/movies/search  片名 / 影院 / 人 / 起止日期的组合搜索

⚠️ 全部复用**网页端那一份 `MovieStats`**(overview/byCinema/byPerson/byMonth/
   byYear/annual/years/search)—— 统计数字两边必须一致,否则用户会发现
   "网页 124 部、App 121 部"。
⚠️ 三个路由都做了 **`$id === 0` 守卫**:`/movies/stats` 不能被当成"id=0 的详情"。
⚠️ `search` 在条件全空时**不查全库**(返回 has_cond=0),与网页 `hasCond` 同行为 —
   否则"搜索"等于"列出全部观影"。

二、features 声明 movies_stats / movies_report / movies_search
    老端上据此隐藏入口(新端上不用探测,但让能力可发现是这套约定的一部分)。

三、名册带 `jobs`(用户:「名册的列表太空了,应该显示更多内容,例如工作单位、职务」)
    新增 `PersonJob::mapFor(array $personIds)` —— **一次 `IN (…)` 批量取回**。
    ⚠️ 不能在装饰循环里逐人 `PersonJob::list()`:名册上千人就是上千次查询(N+1)。
    ⚠️ 排序与 `list()` 逐字一致(当前段在前 → 开始时间倒序 → id 倒序),
       否则"列表显示的单位"和"详情里第一段"会是两个不同的单位。
    ⚠️ 端上列表实际用的是 `persons.org` / `persons.title`(本地库有那两列),
       `jobs` 是给"接口直出"的场景预备的 —— 两条链路都保留。

验证:_fb_v62_cinema_api.py 45/0(独立副本 + 真 MariaDB);
      覆盖影院 CRUD 10 条 + 统计 8 + 年报 7 + 搜索 7 + 名册带 jobs 4;
      php -l 全量通过。
线上:本提交对应的代码**已在线上**(13:17 那次部署带上去的,非本次操作),
      真机实测统计页显示的是真实数据(横店电影城 4 / 上影影城 3…)。
2026-10-19 15:27:08 +08:00

199 lines
8.7 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* 工作经历:单位 / 职务 / 职称,**可以有多段**(v24 用户 2026-10-03 第 4 条)。
*
* 用户原话:「工作单位、职称、职务,都是可以填写多个的,因为一个人的工作可能会变,
* 我们需要记住他工作和职务的变化。」
*
* 所以这里存的是**一条条经历**而不是三个字段:
* - 每段有 单位 / 职务 / 职称 / 起止年月 / 备注;
* - 没有结束年月的就是"当前"那一段(列表里排最前、标「现在」);
* - 有起止的按时间倒序排(最近的在上面)。
*
* ⚠️ `persons.org` / `persons.title` **保留**,但语义变成"当前单位/当前职称的快照":
* 名册列表、家谱、观影人标签这些地方只需要"现在在哪儿",不该为了一行文字去 JOIN
* 一张历史表;而且已有数据不会因为这次改动而消失。
* → 保存时由 syncCurrent() 自动把"最新那段"回写到那两个字段。
*/
declare(strict_types=1);
namespace App;
final class PersonJob
{
/** 一段经历:单位/职务/职称/起止/备注;timeline 里 sort 越小越靠前 */
public const MAX = 20; // 一个人最多留 20 段(再多也不会是真的工作史)
/** 某个人的工作经历(当前那段在前,其余按开始时间倒序) */
public static function list(int $personId): array
{
if ($personId <= 0) {
return [];
}
$st = Database::get()->prepare(
'SELECT * FROM person_jobs WHERE person_id = ?
ORDER BY (end_ym IS NULL) DESC, COALESCE(start_ym, "") DESC, id DESC'
);
$st->execute([$personId]);
return $st->fetchAll();
}
/**
* **批量**取一批人的工作经历(一个人的当前段在前,其余按开始时间倒序)。
*
* 🔴 v62(用户 2026-10-19):「现在名册的列表太空了,应该显示更多内容,
* 例如**工作单位、职务**。」
* ⇒ 名册列表(上千人)要显示单位/职务,**不能**对每个人各调一次
* `list()` —— 那是 N+1 查询(1000 人 = 1000 次 SQL)。
* 这里一次 `IN (...)` 取回来,再按 person_id 分桶。
*
* ⚠️ 返回的是**每个 id 的完整经历数组**(不是只给第一段):
* 端上列表只显示第一段(当前/最近的那段),但详情页要全部 ——
* 同一份数据一次给足,省得端上为详情再打一次接口。
* ⚠️ 排序与 `list()` **逐字一致**(当前段在前 → 开始时间倒序 → id 倒序):
* 否则"列表显示的单位"和"详情里第一段"会是两个不同的单位。
*
* @param int[] $personIds
* @return array<int,array> person_id => [经历, …](没有经历的人**不出现**在结果里)
*/
public static function mapFor(array $personIds): array
{
$ids = array_values(array_unique(array_filter(
array_map('intval', $personIds), static fn(int $v): bool => $v > 0)));
if (count($ids) === 0) {
return [];
}
// ⚠️ `IN (?,?,…)` 的占位符按实际个数生成 —— 不能直接把数组塞进去
$ph = implode(',', array_fill(0, count($ids), '?'));
$st = Database::get()->prepare(
"SELECT * FROM person_jobs WHERE person_id IN ({$ph})
ORDER BY person_id, (end_ym IS NULL) DESC, COALESCE(start_ym, \"\") DESC, id DESC"
);
$st->execute($ids);
$out = [];
foreach ($st->fetchAll() as $r) {
$out[(int)$r['person_id']][] = $r;
}
return $out;
}
/**
* 整组替换(与 Person::replaceContacts 同一套做法:表单里就是一组行,
* 删行/改行/加行一起提交,最省心 —— 逐行增删要额外的行 id 体系)。
*
* ⚠️ 空行(单位、职务、职称都空)会被丢掉,不留垃圾行。
*/
public static function replace(int $personId, array $rows): void
{
if ($personId <= 0) {
return;
}
$db = Database::get();
$db->prepare('DELETE FROM person_jobs WHERE person_id = ?')->execute([$personId]);
$ins = $db->prepare(
'INSERT INTO person_jobs (person_id, org, title, rank_name, start_ym, end_ym, note)
VALUES (?,?,?,?,?,?,?)'
);
$n = 0;
foreach ($rows as $row) {
$org = mb_substr(trim((string)($row['org'] ?? '')), 0, 120, 'UTF-8');
$title = mb_substr(trim((string)($row['title'] ?? '')), 0, 120, 'UTF-8');
$rank = mb_substr(trim((string)($row['rank_name'] ?? '')), 0, 120, 'UTF-8');
$note = mb_substr(trim((string)($row['note'] ?? '')), 0, 200, 'UTF-8');
if ($org === '' && $title === '' && $rank === '') {
continue; // 整行空 → 丢掉
}
if ($n >= self::MAX) {
break;
}
$ins->execute([
$personId,
$org !== '' ? $org : null,
$title !== '' ? $title : null,
$rank !== '' ? $rank : null,
self::ym($row['start_ym'] ?? ''),
self::ym($row['end_ym'] ?? ''),
$note !== '' ? $note : null,
]);
$n++;
}
self::syncCurrent($personId);
}
/**
* 把"当前那一段"回写到 persons.org / persons.title(列表页等地方读的是那两个字段)。
*
* 取"当前"的规则:**没有结束年月的那一段**优先;都填了结束时间就取开始时间最晚的。
*
* 🔴🔴 **经历表为空时什么都不做**(绝不把 org/title 清空)。
* 这一条是实测踩出来的:老库里 `persons.org` 有值、但 `person_jobs` 还没有行
* (升级回填之前、或某次操作删光了经历)。此时如果按"取不到就置空"来写,
* 任何一次 `replace()` 都会把**用户填的单位抹掉** —— 而 replace 被调用在
* "编辑资料"“导入联系人”这类常规路径上,等于随手一保存就丢数据。
* 正确的语义是:**经历表是"更详细的那一份",不是"唯一的一份"** ——
* 它没内容时,就去尊重 org/title 里已有的值。
*/
public static function syncCurrent(int $personId): void
{
$list = self::list($personId);
if (!$list) {
return; // 见上面的说明:没有经历 ≠ 没有单位,别动
}
$cur = null;
foreach ($list as $r) {
if ($r['end_ym'] === null || $r['end_ym'] === '') {
$cur = $r;
break; // list() 已把"无结束"的排在最前
}
}
if ($cur === null) {
$cur = $list[0]; // 全是历史:取最近一段(list 已按开始时间倒序)
}
Database::get()->prepare('UPDATE persons SET org = ?, title = ? WHERE id = ?')
->execute([
$cur['org'] ?: null,
// ⚠️ title 列里放「职务」,rank_name 放「职称」——两个都要能显示,
// 列表页只显示 title,所以把职称拼在后面(有职务时"教授 · 博导"这样)。
self::joinTitle($cur['title'] ?? '', $cur['rank_name'] ?? ''),
$personId,
]);
}
/** 职务 + 职称 合成一个显示串(哪个空就只用另一个;都空返回 null) */
public static function joinTitle(?string $title, ?string $rank): ?string
{
$t = trim((string)$title);
$r = trim((string)$rank);
if ($t !== '' && $r !== '') {
return $t . ' · ' . $r;
}
$v = $t !== '' ? $t : $r;
return $v !== '' ? $v : null;
}
/**
* 从**旧的两个字段**造一段经历(升级回填用)。
* @return array 一行 ['org','title','rank_name','start_ym','end_ym','note']
*/
public static function rowFromLegacy(?string $org, ?string $title): ?array
{
$o = trim((string)$org);
$t = trim((string)$title);
if ($o === '' && $t === '') {
return null;
}
// 老数据的 title 是"职务 / 职称"随手填的,分不出哪个是哪个 →
// 整串放进 title(职务),rank_name 留空。不猜。
return ['org' => $o, 'title' => $t, 'rank_name' => '',
'start_ym' => '', 'end_ym' => '', 'note' => ''];
}
/** 'YYYY-MM' 归一化;不合法返回 null */
private static function ym($v): ?string
{
$v = trim((string)$v);
return preg_match('/^\d{4}-\d{2}$/', $v) ? $v : null;
}
}