ProcuremenOperLog.php 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956
  1. <?php
  2. namespace app\common\library;
  3. use think\Db;
  4. use think\Log;
  5. /**
  6. * 协助采购操作日志(purchase_order_oper_log)
  7. *
  8. * 表字段为英文;写入时「action」存中文操作类型,查询时兼容历史英文码。
  9. */
  10. class ProcuremenOperLog
  11. {
  12. public const COL_SCYDGY_ID = 'scydgy_id';
  13. public const COL_PO_ID = 'purchase_order_id';
  14. public const COL_ADMIN_ID = 'admin_id';
  15. public const COL_ADMIN_NAME = 'admin_name';
  16. public const COL_ACTION = 'action';
  17. public const COL_CONTENT = 'content';
  18. public const COL_TIME = 'createtime';
  19. /**
  20. * 英文码 => 中文操作类型(入库展示用;与库中已有中文值对齐)
  21. *
  22. * @return array<string, string>
  23. */
  24. public static function actionLabelMap(): array
  25. {
  26. return [
  27. 'issue_submit' => '下发通知',
  28. 'audit_select' => '确认供应商',
  29. 'purchase_confirm' => '审核确认供应商',
  30. 'purchase_reject' => '审核驳回',
  31. 'bid_open_verify' => '开标验证',
  32. 'manual_pick' => '指定供应商',
  33. 'audit_append_supplier' => '补加供应商',
  34. 'rfq_append_supplier' => '补加供应商',
  35. 'rfq_notify_salesman' => '询价通知业务员',
  36. 'rfq_append_salesman' => '添加通知业务员',
  37. 'audit_resend_sms' => '重发短信',
  38. 'audit_resend_email' => '重发邮件',
  39. 'manual_add' => '手工新增',
  40. 'pick_soft_delete' => '初选删除',
  41. 'save_qty_price' => '保存数量限价',
  42. 'mark_complete' => '直接完结',
  43. 'inbound_score' => '质量评分',
  44. 'delivery_score' => '交货情况',
  45. 'arrival_notify' => '订单已到货通知',
  46. 'audit_abandon' => '审批重新下发',
  47. 'archive_abandon' => '历史重新下发',
  48. ];
  49. }
  50. /**
  51. * 同一业务动作的可选别名(查询时一并匹配)
  52. *
  53. * @return array<string, string[]>
  54. */
  55. public static function actionAliases(): array
  56. {
  57. return [
  58. 'issue_submit' => ['下发通知', '下发', 'issue_submit'],
  59. 'audit_select' => ['确认供应商', 'audit_select'],
  60. 'purchase_confirm' => ['审核确认供应商', 'purchase_confirm'],
  61. 'purchase_reject' => ['审核驳回', 'purchase_reject'],
  62. 'bid_open_verify' => ['开标验证', 'bid_open_verify'],
  63. 'manual_pick' => ['指定供应商', 'manual_pick'],
  64. 'audit_append_supplier' => ['补加供应商', 'audit_append_supplier', 'rfq_append_supplier'],
  65. 'rfq_append_supplier' => ['补加供应商', 'rfq_append_supplier'],
  66. 'rfq_notify_salesman' => ['询价通知业务员', '邮箱通知业务员', 'rfq_notify_salesman'],
  67. 'rfq_append_salesman' => ['添加通知业务员', 'rfq_append_salesman'],
  68. 'audit_resend_sms' => ['重发短信', 'audit_resend_sms'],
  69. 'audit_resend_email' => ['重发邮件', 'audit_resend_email'],
  70. 'manual_add' => ['手工新增', 'manual_add'],
  71. 'pick_soft_delete' => ['初选删除', 'pick_soft_delete'],
  72. 'save_qty_price' => ['保存数量限价', 'save_qty_price'],
  73. 'mark_complete' => ['直接完结', '完结', 'mark_complete'],
  74. 'inbound_score' => ['质量评分', '入库评分', '是否合格', 'inbound_score'],
  75. 'delivery_score' => ['交货情况', '确认交货', 'delivery_score'],
  76. 'arrival_notify' => ['订单已到货通知', '通知已到货', 'arrival_notify'],
  77. 'audit_abandon' => ['审批重新下发', '重新下发', 'audit_abandon'],
  78. 'archive_abandon' => ['历史重新下发', '存证重新下发', 'archive_abandon'],
  79. ];
  80. }
  81. public static function toActionLabel(string $action): string
  82. {
  83. $action = trim($action);
  84. if ($action === '') {
  85. return '';
  86. }
  87. $map = self::actionLabelMap();
  88. if (isset($map[$action])) {
  89. return $map[$action];
  90. }
  91. // 已是中文别名时,归一到主标签
  92. foreach (self::actionAliases() as $code => $aliases) {
  93. if (in_array($action, $aliases, true)) {
  94. return $map[$code] ?? $action;
  95. }
  96. }
  97. return $action;
  98. }
  99. /**
  100. * 查询用:同时匹配中文标签、别名与历史英文码
  101. *
  102. * @param string|array<int, string> $actions
  103. * @return string[]
  104. */
  105. public static function expandActionQueryValues($actions): array
  106. {
  107. $aliases = self::actionAliases();
  108. $map = self::actionLabelMap();
  109. $out = [];
  110. foreach ((array)$actions as $a) {
  111. $a = trim((string)$a);
  112. if ($a === '') {
  113. continue;
  114. }
  115. $out[$a] = true;
  116. $code = $a;
  117. if (!isset($aliases[$code])) {
  118. foreach ($aliases as $c => $list) {
  119. if (in_array($a, $list, true) || (isset($map[$c]) && $map[$c] === $a)) {
  120. $code = $c;
  121. break;
  122. }
  123. }
  124. }
  125. if (isset($aliases[$code])) {
  126. foreach ($aliases[$code] as $v) {
  127. $out[$v] = true;
  128. }
  129. }
  130. if (isset($map[$code])) {
  131. $out[$map[$code]] = true;
  132. }
  133. }
  134. return array_keys($out);
  135. }
  136. /**
  137. * 组装入库行(英文字段,操作类型存中文)
  138. *
  139. * @return array<string, mixed>
  140. */
  141. public static function buildInsertRow(
  142. int $scydgyId,
  143. string $action,
  144. string $content,
  145. ?int $purchaseOrderId,
  146. int $adminId,
  147. string $adminName
  148. ): array {
  149. return [
  150. self::COL_SCYDGY_ID => $scydgyId,
  151. self::COL_PO_ID => $purchaseOrderId,
  152. self::COL_ADMIN_ID => $adminId,
  153. self::COL_ADMIN_NAME => self::cut($adminName !== '' ? $adminName : '未知用户', 64),
  154. self::COL_ACTION => self::cut(self::toActionLabel($action), 64),
  155. self::COL_CONTENT => self::cut($content, 1000),
  156. self::COL_TIME => ProcuremenTime::nowDateTime(),
  157. ];
  158. }
  159. /**
  160. * @param array{0:int,1:string} $admin [id, name]
  161. */
  162. public static function write(int $scydgyId, string $action, string $content, ?int $purchaseOrderId, array $admin): void
  163. {
  164. if (!ProcuremenGuard::isValidScydgyId($scydgyId) || $content === '') {
  165. return;
  166. }
  167. $adminId = (int)($admin[0] ?? 0);
  168. $adminName = (string)($admin[1] ?? '未知用户');
  169. try {
  170. Db::table('purchase_order_oper_log')->insert(
  171. self::buildInsertRow($scydgyId, $action, $content, $purchaseOrderId, $adminId, $adminName)
  172. );
  173. } catch (\Throwable $e) {
  174. Log::write('procuremen oper log: ' . $e->getMessage(), 'error');
  175. }
  176. }
  177. /**
  178. * 查询结果规范化(兼容偶发中文列名 → 英文键)
  179. *
  180. * @param array<string, mixed> $log
  181. * @return array<string, mixed>
  182. */
  183. public static function normalizeRow(array $log): array
  184. {
  185. $map = [
  186. '工序ID' => 'scydgy_id',
  187. '采购订单ID' => 'purchase_order_id',
  188. '操作人ID' => 'admin_id',
  189. '操作人' => 'admin_name',
  190. '操作类型' => 'action',
  191. '操作内容' => 'content',
  192. '创建时间' => 'createtime',
  193. ];
  194. foreach ($map as $cn => $en) {
  195. if (array_key_exists($cn, $log) && !array_key_exists($en, $log)) {
  196. $log[$en] = $log[$cn];
  197. }
  198. }
  199. if (isset($log['action'])) {
  200. $log['action'] = self::toActionLabel((string)$log['action']);
  201. }
  202. return $log;
  203. }
  204. /**
  205. * @param string|array<int, string> $actions
  206. */
  207. public static function resolveLatestTime(int $scydgyId, $actions): string
  208. {
  209. if (!ProcuremenGuard::isValidScydgyId($scydgyId)) {
  210. return '';
  211. }
  212. $acts = self::expandActionQueryValues($actions);
  213. if ($acts === []) {
  214. return '';
  215. }
  216. try {
  217. $log = Db::table('purchase_order_oper_log')
  218. ->where(self::COL_SCYDGY_ID, $scydgyId)
  219. ->where(self::COL_ACTION, 'in', $acts)
  220. ->order('id', 'desc')
  221. ->find();
  222. if (is_array($log)) {
  223. $log = self::normalizeRow($log);
  224. $text = ProcuremenTime::formatDisplayDateTime($log['createtime'] ?? null);
  225. if ($text !== '') {
  226. return $text;
  227. }
  228. }
  229. } catch (\Throwable $e) {
  230. }
  231. return '';
  232. }
  233. /**
  234. * 从已加载的操作日志中取最近时间(避免详情页重复查库)
  235. *
  236. * @param array<int, array<string, mixed>> $logs
  237. * @param string|array<int, string> $actions
  238. */
  239. public static function resolveLatestTimeFromLogs(array $logs, int $scydgyId, $actions): string
  240. {
  241. if (!ProcuremenGuard::isValidScydgyId($scydgyId) || $logs === []) {
  242. return '';
  243. }
  244. $acts = self::expandActionQueryValues($actions);
  245. if ($acts === []) {
  246. return '';
  247. }
  248. $actSet = [];
  249. foreach ($acts as $a) {
  250. $actSet[(string)$a] = true;
  251. $actSet[self::toActionLabel((string)$a)] = true;
  252. }
  253. $bestId = -1;
  254. $bestText = '';
  255. foreach ($logs as $log) {
  256. if (!is_array($log)) {
  257. continue;
  258. }
  259. $sid = (int)($log[self::COL_SCYDGY_ID] ?? 0);
  260. if ($sid !== $scydgyId) {
  261. continue;
  262. }
  263. $action = trim((string)($log[self::COL_ACTION] ?? ''));
  264. if ($action === '' || (!isset($actSet[$action]) && !isset($actSet[self::toActionLabel($action)]))) {
  265. continue;
  266. }
  267. $id = (int)($log['id'] ?? $log['ID'] ?? 0);
  268. if ($id < $bestId) {
  269. continue;
  270. }
  271. $text = ProcuremenTime::formatDisplayDateTime($log['createtime'] ?? null);
  272. if ($text === '') {
  273. continue;
  274. }
  275. $bestId = $id;
  276. $bestText = $text;
  277. }
  278. return $bestText;
  279. }
  280. public static function resolveMarkCompleteTime(int $scydgyId): string
  281. {
  282. return self::resolveLatestTime($scydgyId, 'mark_complete');
  283. }
  284. /**
  285. * @param array<int, array<string, mixed>> $logs
  286. * @param array<int, string> $processNameBySid scydgy_id => 工序名称
  287. * @return array<int, array<string, mixed>>
  288. */
  289. public static function formatLogRowsForDisplay(array $logs, array $processNameBySid = []): array
  290. {
  291. foreach ($logs as &$lg) {
  292. if (!is_array($lg)) {
  293. continue;
  294. }
  295. $lg = self::normalizeRow($lg);
  296. $lg['createtime_text'] = ProcuremenTime::formatDisplayDateTime($lg['createtime'] ?? null);
  297. $lg['content'] = ProcuremenTime::formatOperLogContent(
  298. (string)($lg['content'] ?? ''),
  299. (string)($lg['action'] ?? '')
  300. );
  301. $lg['action_text'] = self::toActionLabel((string)($lg['action'] ?? ''));
  302. $lg = self::enrichSaveQtyPriceProcessName($lg, $processNameBySid);
  303. }
  304. unset($lg);
  305. $logs = self::mergeDuplicateProcessLogs($logs, $processNameBySid);
  306. $logs = self::enrichAdminDisplayFields($logs);
  307. foreach ($logs as &$lg) {
  308. if (!is_array($lg)) {
  309. continue;
  310. }
  311. $lg = self::splitActionAndDetailForDisplay($lg);
  312. }
  313. unset($lg);
  314. return $logs;
  315. }
  316. /**
  317. * 展示拆成「操作类型」+「操作说明」,说明里不再重复类型前缀
  318. *
  319. * @param array<string, mixed> $lg
  320. * @return array<string, mixed>
  321. */
  322. protected static function splitActionAndDetailForDisplay(array $lg): array
  323. {
  324. $action = trim((string)($lg['action_text'] ?? ''));
  325. if ($action === '') {
  326. $action = self::toActionLabel((string)($lg[self::COL_ACTION] ?? ''));
  327. }
  328. $content = trim((string)($lg[self::COL_CONTENT] ?? ''));
  329. $content = self::stripLeadingActionPrefixes($content, $action);
  330. // 入库评分:只展示合格/不合格,去掉工序括号
  331. if ($action === '入库评分' || self::toActionLabel((string)($lg[self::COL_ACTION] ?? '')) === '入库评分') {
  332. if (preg_match('/(合格|不合格)/u', $content, $m)) {
  333. $content = $m[1];
  334. }
  335. }
  336. // 指定/确认供应商:统一为「公司」或「公司;说明」
  337. if ($action === '指定供应商' || $action === '确认供应商') {
  338. $content = self::simplifySupplierPickLogContent($action, $content);
  339. }
  340. if ($action === '') {
  341. $action = '操作';
  342. }
  343. $lg['action_text'] = $action;
  344. $lg[self::COL_CONTENT] = $content;
  345. return $lg;
  346. }
  347. /**
  348. * @return string[] 按长度降序
  349. */
  350. protected static function contentActionPrefixes(string $action): array
  351. {
  352. $action = self::toActionLabel(trim($action));
  353. $list = [];
  354. if ($action !== '') {
  355. $list[] = $action;
  356. }
  357. // 历史/文案别名
  358. $aliases = [
  359. '下发通知' => ['下发', '下发通知'],
  360. '保存数量限价' => ['保存数量限价', '保存本次数量、最高限价', '保存本次数量、最高限价、备注', '保存本次数量、最高限价、招标备注'],
  361. '审批重新下发' => ['审批重新下发', '重新下发'],
  362. '历史重新下发' => ['历史重新下发', '存证重新下发', '重新下发'],
  363. '审核确认供应商' => ['审核确认供应商', '采购确认'],
  364. '确认供应商' => ['确认供应商'],
  365. '开标验证' => ['开标验证'],
  366. '补加供应商' => ['补加供应商'],
  367. '询价通知业务员' => ['询价通知业务员', '邮箱通知业务员'],
  368. '添加通知业务员' => ['添加通知业务员'],
  369. '直接完结' => ['直接完结', '完结'],
  370. '审核驳回' => ['审核驳回', '采购终审驳回'],
  371. ];
  372. if (isset($aliases[$action])) {
  373. foreach ($aliases[$action] as $a) {
  374. $list[] = $a;
  375. }
  376. }
  377. $list = array_values(array_unique(array_filter(array_map('trim', $list))));
  378. usort($list, static function ($a, $b) {
  379. return mb_strlen((string)$b, 'UTF-8') <=> mb_strlen((string)$a, 'UTF-8');
  380. });
  381. return $list;
  382. }
  383. protected static function stripLeadingActionPrefixes(string $content, string $action): string
  384. {
  385. $content = trim($content);
  386. if ($content === '') {
  387. return '';
  388. }
  389. foreach (self::contentActionPrefixes($action) as $p) {
  390. foreach ([$p . ':', $p . ':'] as $pref) {
  391. if (mb_strpos($content, $pref, 0, 'UTF-8') === 0) {
  392. return trim(mb_substr($content, mb_strlen($pref, 'UTF-8'), null, 'UTF-8'));
  393. }
  394. }
  395. }
  396. return $content;
  397. }
  398. /**
  399. * 历史「保存数量限价」说明补工序名:…:压折线本次数量「330」,最高限价「10」
  400. *
  401. * @param array<string, mixed> $lg
  402. * @param array<int, string> $processNameBySid
  403. * @return array<string, mixed>
  404. */
  405. protected static function enrichSaveQtyPriceProcessName(array $lg, array $processNameBySid): array
  406. {
  407. $action = trim((string)($lg['action_text'] ?? $lg[self::COL_ACTION] ?? ''));
  408. if ($action !== '保存数量限价' && $action !== 'save_qty_price') {
  409. return $lg;
  410. }
  411. $content = trim((string)($lg[self::COL_CONTENT] ?? ''));
  412. if ($content === '') {
  413. return $lg;
  414. }
  415. // 展示时去掉未填写的空「」片段(历史日志也适用)
  416. $content = preg_replace('/,\s*最高限价「」/u', '', $content);
  417. $content = preg_replace('/,\s*(?:招标)?备注「」/u', '', $content);
  418. $content = preg_replace('/最高限价「」,\s*/u', '', $content);
  419. $content = preg_replace('/(?:招标)?备注「」$/u', '', $content);
  420. $content = preg_replace('/,\s*$/u', '', trim((string)$content));
  421. $lg[self::COL_CONTENT] = $content;
  422. $sid = (int)($lg[self::COL_SCYDGY_ID] ?? 0);
  423. $proc = trim((string)($processNameBySid[$sid] ?? ''));
  424. if ($proc === '') {
  425. return $lg;
  426. }
  427. // 已含工序名则不重复
  428. if (mb_strpos($content, $proc . '本次数量', 0, 'UTF-8') !== false
  429. || mb_strpos($content, '(' . $proc . ')', 0, 'UTF-8') !== false) {
  430. return $lg;
  431. }
  432. if (mb_strpos($content, ':本次数量', 0, 'UTF-8') !== false) {
  433. $lg[self::COL_CONTENT] = preg_replace('/:本次数量/u', ':' . $proc . '本次数量', $content, 1);
  434. } elseif (mb_strpos($content, ':本次数量', 0, 'UTF-8') !== false) {
  435. $lg[self::COL_CONTENT] = preg_replace('/:本次数量/u', ':' . $proc . '本次数量', $content, 1);
  436. }
  437. return $lg;
  438. }
  439. /**
  440. * 操作人展示:所属部门(按日志 admin_id 查角色组)+ 日志快照 admin_name
  441. * 名称不取 admin.nickname,避免覆盖操作当时写入的名称
  442. *
  443. * @param array<int, array<string, mixed>> $logs
  444. * @return array<int, array<string, mixed>>
  445. */
  446. public static function enrichAdminDisplayFields(array $logs): array
  447. {
  448. $ids = [];
  449. foreach ($logs as $lg) {
  450. if (!is_array($lg)) {
  451. continue;
  452. }
  453. $id = (int)($lg[self::COL_ADMIN_ID] ?? 0);
  454. if ($id > 0) {
  455. $ids[$id] = true;
  456. }
  457. }
  458. $map = self::loadAdminGroupNicknameMap(array_keys($ids));
  459. // 仅用于纠正历史误伤:说明里曾把「组别 昵称」拼进供应商名,展示时还原为昵称/公司名
  460. $nameToLabeled = self::buildAdminNameLabelMap(self::loadAllAdminGroupNicknameMap());
  461. foreach (self::buildAdminNameLabelMap($map) as $name => $labeled) {
  462. $nameToLabeled[$name] = $labeled;
  463. }
  464. uksort($nameToLabeled, static function ($a, $b) {
  465. return mb_strlen((string)$b, 'UTF-8') <=> mb_strlen((string)$a, 'UTF-8');
  466. });
  467. foreach ($logs as &$lg) {
  468. if (!is_array($lg)) {
  469. continue;
  470. }
  471. $id = (int)($lg[self::COL_ADMIN_ID] ?? 0);
  472. $info = ($id > 0 && isset($map[$id])) ? $map[$id] : null;
  473. // 名称:日志 admin_name 快照(操作当时写入)
  474. $snapshot = trim((string)($lg[self::COL_ADMIN_NAME] ?? ''));
  475. $nick = is_array($info) ? trim((string)($info['nickname'] ?? '')) : '';
  476. $displayName = $snapshot !== '' ? $snapshot : $nick;
  477. if ($displayName === '') {
  478. $displayName = '未知用户';
  479. }
  480. // 所属部门:仅用 admin_id → 角色组名
  481. $group = is_array($info) ? trim((string)($info['group_name'] ?? '')) : '';
  482. $lg['admin_group'] = $group !== '' ? $group : '—';
  483. $lg['admin_nickname'] = $displayName;
  484. $lg[self::COL_ADMIN_NAME] = self::formatAdminWithGroup($group, $displayName);
  485. $content = (string)($lg[self::COL_CONTENT] ?? '');
  486. if ($content !== '' && $nameToLabeled !== []) {
  487. // 先还原历史误伤的供应商名
  488. $content = self::unprefixMistakenNamesInText($content, $nameToLabeled);
  489. // 开标验证等说明里是人员名,再补组别
  490. $actionLabel = self::toActionLabel((string)($lg['action_text'] ?? $lg[self::COL_ACTION] ?? ''));
  491. if (self::actionContentNeedsPersonGroup($actionLabel)) {
  492. $content = self::prefixNamesInText($content, $nameToLabeled);
  493. }
  494. $lg[self::COL_CONTENT] = $content;
  495. }
  496. }
  497. unset($lg);
  498. return $logs;
  499. }
  500. /** 操作说明里会出现人员姓名、需补所属组别的类型 */
  501. protected static function actionContentNeedsPersonGroup(string $action): bool
  502. {
  503. $action = self::toActionLabel(trim($action));
  504. return $action === '开标验证';
  505. }
  506. /**
  507. * 按 admin.id 解析展示名「组别 昵称」
  508. *
  509. * @param array<int, int> $adminIds
  510. * @return array<int, string>
  511. */
  512. public static function resolveAdminDisplayLabels(array $adminIds): array
  513. {
  514. $map = self::loadAdminGroupNicknameMap($adminIds);
  515. $out = [];
  516. foreach ($adminIds as $rawId) {
  517. $id = (int)$rawId;
  518. if ($id <= 0) {
  519. continue;
  520. }
  521. $info = isset($map[$id]) && is_array($map[$id]) ? $map[$id] : null;
  522. $nick = is_array($info) ? trim((string)($info['nickname'] ?? '')) : '';
  523. $group = is_array($info) ? trim((string)($info['group_name'] ?? '')) : '';
  524. if ($nick === '') {
  525. $nick = 'ID' . $id;
  526. }
  527. $out[$id] = self::formatAdminWithGroup($group, $nick);
  528. }
  529. return $out;
  530. }
  531. /**
  532. * @param array<int, array{nickname:string,group_name:string}> $adminMap
  533. * @return array<string, string> 原名 => 「组别 名称」
  534. */
  535. protected static function buildAdminNameLabelMap(array $adminMap): array
  536. {
  537. $out = [];
  538. foreach ($adminMap as $info) {
  539. if (!is_array($info)) {
  540. continue;
  541. }
  542. $nick = trim((string)($info['nickname'] ?? ''));
  543. if ($nick === '') {
  544. continue;
  545. }
  546. $group = trim((string)($info['group_name'] ?? ''));
  547. $labeled = self::formatAdminWithGroup($group, $nick);
  548. if ($labeled !== $nick) {
  549. $out[$nick] = $labeled;
  550. }
  551. }
  552. // 长名优先,避免短名误伤
  553. uksort($out, static function ($a, $b) {
  554. return mb_strlen((string)$b, 'UTF-8') <=> mb_strlen((string)$a, 'UTF-8');
  555. });
  556. return $out;
  557. }
  558. protected static function formatAdminWithGroup(string $group, string $name): string
  559. {
  560. $name = trim($name);
  561. $group = trim($group);
  562. if ($name === '') {
  563. return $group !== '' ? $group : '未知用户';
  564. }
  565. if ($group === '' || $group === '—' || $group === $name) {
  566. return $name;
  567. }
  568. // 已带组别则不再重复
  569. if (mb_strpos($name, $group, 0, 'UTF-8') === 0) {
  570. return $name;
  571. }
  572. return $group . ' ' . $name;
  573. }
  574. /**
  575. * @param array<string, string> $nameToLabeled
  576. */
  577. protected static function prefixNamesInText(string $text, array $nameToLabeled): string
  578. {
  579. foreach ($nameToLabeled as $name => $labeled) {
  580. if ($name === '' || $labeled === '' || $name === $labeled) {
  581. continue;
  582. }
  583. if (mb_strpos($text, $labeled, 0, 'UTF-8') !== false) {
  584. continue;
  585. }
  586. if (mb_strpos($text, $name, 0, 'UTF-8') === false) {
  587. continue;
  588. }
  589. $text = str_replace($name, $labeled, $text);
  590. }
  591. return $text;
  592. }
  593. /**
  594. * 还原说明里误拼的「组别 昵称」→ 原名(避免供应商名被改成「浙江新华管理员 新华印刷…」)
  595. *
  596. * @param array<string, string> $nameToLabeled
  597. */
  598. protected static function unprefixMistakenNamesInText(string $text, array $nameToLabeled): string
  599. {
  600. // 长标签优先替换
  601. $pairs = [];
  602. foreach ($nameToLabeled as $name => $labeled) {
  603. if ($name === '' || $labeled === '' || $name === $labeled) {
  604. continue;
  605. }
  606. $pairs[$labeled] = $name;
  607. }
  608. uksort($pairs, static function ($a, $b) {
  609. return mb_strlen((string)$b, 'UTF-8') <=> mb_strlen((string)$a, 'UTF-8');
  610. });
  611. foreach ($pairs as $labeled => $name) {
  612. if (mb_strpos($text, $labeled, 0, 'UTF-8') === false) {
  613. continue;
  614. }
  615. $text = str_replace($labeled, $name, $text);
  616. }
  617. return $text;
  618. }
  619. /**
  620. * @return array<int, array{nickname:string,group_name:string,username?:string}>
  621. */
  622. public static function loadAllAdminGroupNicknameMap(): array
  623. {
  624. try {
  625. $ids = Db::name('admin')->column('id');
  626. } catch (\Throwable $e) {
  627. $ids = [];
  628. }
  629. if (!is_array($ids) || $ids === []) {
  630. return [];
  631. }
  632. return self::loadAdminGroupNicknameMap(array_map('intval', $ids));
  633. }
  634. /**
  635. * @param array<int, int> $adminIds
  636. * @return array<int, array{nickname:string,group_name:string,username?:string}>
  637. */
  638. public static function loadAdminGroupNicknameMap(array $adminIds): array
  639. {
  640. $out = [];
  641. $adminIds = array_values(array_filter(array_map('intval', $adminIds)));
  642. if ($adminIds === []) {
  643. return $out;
  644. }
  645. try {
  646. $admins = Db::name('admin')->where('id', 'in', $adminIds)->field('id,nickname,username')->select();
  647. } catch (\Throwable $e) {
  648. $admins = [];
  649. }
  650. if (is_array($admins)) {
  651. foreach ($admins as $a) {
  652. if (!is_array($a)) {
  653. continue;
  654. }
  655. $id = (int)($a['id'] ?? 0);
  656. if ($id <= 0) {
  657. continue;
  658. }
  659. $nick = trim((string)($a['nickname'] ?? ''));
  660. if ($nick === '') {
  661. $nick = trim((string)($a['username'] ?? ''));
  662. }
  663. $out[$id] = ['nickname' => $nick, 'group_name' => '', 'username' => trim((string)($a['username'] ?? ''))];
  664. }
  665. }
  666. try {
  667. $rows = Db::name('auth_group_access')
  668. ->alias('aga')
  669. ->join('auth_group ag', 'aga.group_id = ag.id', 'LEFT')
  670. ->where('aga.uid', 'in', $adminIds)
  671. ->field('aga.uid,ag.name')
  672. ->select();
  673. } catch (\Throwable $e) {
  674. $rows = [];
  675. }
  676. if (is_array($rows)) {
  677. foreach ($rows as $r) {
  678. if (!is_array($r)) {
  679. continue;
  680. }
  681. $uid = (int)($r['uid'] ?? 0);
  682. $gname = trim((string)($r['name'] ?? ''));
  683. if ($uid <= 0 || $gname === '') {
  684. continue;
  685. }
  686. if (!isset($out[$uid])) {
  687. $out[$uid] = ['nickname' => '', 'group_name' => $gname];
  688. } elseif ($out[$uid]['group_name'] === '') {
  689. $out[$uid]['group_name'] = $gname;
  690. } elseif (strpos($out[$uid]['group_name'], $gname) === false) {
  691. $out[$uid]['group_name'] .= '、' . $gname;
  692. }
  693. }
  694. }
  695. return $out;
  696. }
  697. /**
  698. * 多工序短时间内相同操作合并为一条,并带上工序名称
  699. * 例:审核确认供应商:配套、快递已选定「xxx」
  700. *
  701. * @param array<int, array<string, mixed>> $logs
  702. * @param array<int, string> $processNameBySid
  703. * @return array<int, array<string, mixed>>
  704. */
  705. public static function mergeDuplicateProcessLogs(array $logs, array $processNameBySid = []): array
  706. {
  707. if ($logs === []) {
  708. return [];
  709. }
  710. $groups = [];
  711. $order = [];
  712. foreach ($logs as $idx => $lg) {
  713. if (!is_array($lg)) {
  714. continue;
  715. }
  716. $adminId = (int)($lg[self::COL_ADMIN_ID] ?? 0);
  717. $admin = trim((string)($lg[self::COL_ADMIN_NAME] ?? ''));
  718. $action = trim((string)($lg['action_text'] ?? $lg[self::COL_ACTION] ?? ''));
  719. $content = trim((string)($lg[self::COL_CONTENT] ?? ''));
  720. $contentCore = self::normalizeMergeContentCore($action, $content);
  721. $ts = self::logTimeTs($lg);
  722. // 60 秒内、同操作人+同操作类型+同说明核心 → 合并
  723. $bucket = null;
  724. foreach ($groups as $gk => $g) {
  725. if (($g['admin_id'] !== $adminId && $g['admin'] !== $admin)
  726. || $g['action'] !== $action
  727. || $g['content_core'] !== $contentCore) {
  728. continue;
  729. }
  730. if (abs($ts - (int)$g['ts']) <= 60) {
  731. $bucket = $gk;
  732. break;
  733. }
  734. }
  735. if ($bucket === null) {
  736. $bucket = 'g' . $idx;
  737. $groups[$bucket] = [
  738. 'admin_id' => $adminId,
  739. 'admin' => $admin,
  740. 'action' => $action,
  741. 'content_core' => $contentCore,
  742. 'ts' => $ts,
  743. 'row' => $lg,
  744. 'sids' => [],
  745. 'contents' => [],
  746. ];
  747. $order[] = $bucket;
  748. }
  749. $sid = (int)($lg[self::COL_SCYDGY_ID] ?? 0);
  750. if ($sid !== 0) {
  751. $groups[$bucket]['sids'][$sid] = true;
  752. }
  753. $groups[$bucket]['contents'][] = $content;
  754. // 保留最早一条作展示底稿,时间用最早
  755. $rowTs = self::logTimeTs($groups[$bucket]['row']);
  756. if ($ts > 0 && ($rowTs === 0 || $ts < $rowTs)) {
  757. $groups[$bucket]['row'] = $lg;
  758. $groups[$bucket]['ts'] = $ts;
  759. }
  760. }
  761. $out = [];
  762. foreach ($order as $gk) {
  763. $g = $groups[$gk];
  764. $row = $g['row'];
  765. $sids = array_keys($g['sids']);
  766. $processNames = [];
  767. foreach ($sids as $sid) {
  768. $nm = trim((string)($processNameBySid[$sid] ?? ''));
  769. if ($nm !== '' && !in_array($nm, $processNames, true)) {
  770. $processNames[] = $nm;
  771. }
  772. }
  773. $content = trim((string)($row[self::COL_CONTENT] ?? ''));
  774. if (count($sids) > 1 || count($processNames) > 1) {
  775. $content = self::buildMergedProcessContent(
  776. (string)($g['action'] ?? ''),
  777. $content,
  778. $processNames
  779. );
  780. }
  781. $row[self::COL_CONTENT] = $content;
  782. $row['action_text'] = (string)($g['action'] !== '' ? $g['action'] : ($row['action_text'] ?? ''));
  783. $out[] = $row;
  784. }
  785. return $out;
  786. }
  787. /**
  788. * 提取可合并的内容核心(去掉前缀动作名后的业务说明)
  789. */
  790. protected static function normalizeMergeContentCore(string $action, string $content): string
  791. {
  792. $core = self::stripLeadingActionPrefixes(trim($content), trim($action));
  793. $actionLabel = self::toActionLabel(trim($action));
  794. // 入库评分:同结果多工序分条 → 只按「合格/不合格」合并
  795. if ($actionLabel === '入库评分' || trim($action) === 'inbound_score') {
  796. if (preg_match('/^(合格|不合格)/u', $core, $m)) {
  797. return $m[1];
  798. }
  799. if (preg_match('/(合格|不合格)/u', $core, $m)) {
  800. return $m[1];
  801. }
  802. }
  803. return $core;
  804. }
  805. /**
  806. * @param array<int, string> $processNames
  807. */
  808. protected static function buildMergedProcessContent(string $action, string $content, array $processNames): string
  809. {
  810. $core = self::normalizeMergeContentCore($action, $content);
  811. $actionLabel = self::toActionLabel(trim($action));
  812. // 入库评分:只保留合格/不合格,不拼工序名
  813. if ($actionLabel === '入库评分' || trim($action) === 'inbound_score') {
  814. $result = $core !== '' ? $core : '合格';
  815. if (preg_match('/(合格|不合格)/u', $result, $m)) {
  816. $result = $m[1];
  817. }
  818. return $result;
  819. }
  820. // 下发通知:不拼工序名(工序已在「本单工序」区展示)
  821. if ($actionLabel === '下发通知' || trim($action) === 'issue_submit' || trim($action) === '下发') {
  822. return $core !== '' ? $core : $content;
  823. }
  824. // 指定供应商 / 确认供应商:只保留供应商与说明,不拼工序名
  825. if ($actionLabel === '指定供应商' || trim($action) === 'manual_pick'
  826. || $actionLabel === '确认供应商' || trim($action) === 'audit_select' || trim($action) === 'audit_confirm'
  827. ) {
  828. return self::simplifySupplierPickLogContent($actionLabel, $core !== '' ? $core : $content);
  829. }
  830. $procText = implode('、', $processNames);
  831. if ($procText === '') {
  832. return $core !== '' ? $core : $content;
  833. }
  834. // 工序名放在操作说明里,不盖住操作类型
  835. if ($core !== '') {
  836. return $procText . ' ' . $core;
  837. }
  838. return $procText;
  839. }
  840. /**
  841. * 指定/确认供应商操作说明:去掉历史工序前缀与「已选定」等冗余文案
  842. */
  843. protected static function simplifySupplierPickLogContent(string $actionLabel, string $content): string
  844. {
  845. $content = trim($content);
  846. if ($content === '') {
  847. return '';
  848. }
  849. // 去掉开头工序名:xxx、yyy 或 [xxx、yyy]
  850. $content = preg_replace('/^\[?[^\n\[\]]{1,120}?\]?\s*(?=指定供应商|确认供应商|已选定)/u', '', $content);
  851. $content = trim((string)$content);
  852. $company = '';
  853. if (preg_match('/「([^」]+)」/u', $content, $m)) {
  854. $company = trim($m[1]);
  855. } elseif (preg_match('/(?:指定供应商|确认供应商)[::]\s*(?:已选定)?\s*[「"]?([^」";;\n]+)/u', $content, $m)) {
  856. $company = trim($m[1]);
  857. }
  858. if ($actionLabel === '指定供应商') {
  859. $reason = '';
  860. if (preg_match('/说明[::]\s*(.+)$/u', $content, $rm)) {
  861. $reason = trim($rm[1]);
  862. }
  863. if ($company === '') {
  864. return $content;
  865. }
  866. return $company . ($reason !== '' ? ';说明:' . $reason : '');
  867. }
  868. if ($company !== '') {
  869. return $company;
  870. }
  871. return $content;
  872. }
  873. /**
  874. * @param array<string, mixed> $lg
  875. */
  876. protected static function logTimeTs(array $lg): int
  877. {
  878. $raw = trim((string)($lg['createtime_text'] ?? $lg[self::COL_TIME] ?? ''));
  879. if ($raw === '') {
  880. return 0;
  881. }
  882. $ts = strtotime(str_replace('T', ' ', $raw));
  883. return ($ts !== false && $ts > 0) ? (int)$ts : 0;
  884. }
  885. private static function cut(string $s, int $max): string
  886. {
  887. if (function_exists('mb_substr')) {
  888. return mb_substr($s, 0, $max, 'UTF-8');
  889. }
  890. return strlen($s) <= $max ? $s : substr($s, 0, $max);
  891. }
  892. }