ProcuremenOperLog.php 32 KB

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