中文操作类型(入库展示用;与库中已有中文值对齐) * * @return array */ public static function actionLabelMap(): array { return [ 'issue_submit' => '下发通知', 'audit_select' => '确认供应商', 'purchase_confirm' => '审核确认供应商', 'purchase_reject' => '审核驳回', 'bid_open_verify' => '开标验证', 'audit_append_supplier' => '补加供应商', 'audit_resend_sms' => '重发短信', 'audit_resend_email' => '重发邮件', 'manual_add' => '手工新增', 'pick_soft_delete' => '初选删除', 'save_qty_price' => '保存数量限价', 'mark_complete' => '直接完结', 'audit_abandon' => '审批重新下发', 'archive_abandon' => '历史重新下发', ]; } /** * 同一业务动作的可选别名(查询时一并匹配) * * @return array */ public static function actionAliases(): array { return [ 'issue_submit' => ['下发通知', '下发', 'issue_submit'], 'audit_select' => ['确认供应商', 'audit_select'], 'purchase_confirm' => ['审核确认供应商', 'purchase_confirm'], 'purchase_reject' => ['审核驳回', 'purchase_reject'], 'bid_open_verify' => ['开标验证', 'bid_open_verify'], 'audit_append_supplier' => ['补加供应商', 'audit_append_supplier'], 'audit_resend_sms' => ['重发短信', 'audit_resend_sms'], 'audit_resend_email' => ['重发邮件', 'audit_resend_email'], 'manual_add' => ['手工新增', 'manual_add'], 'pick_soft_delete' => ['初选删除', 'pick_soft_delete'], 'save_qty_price' => ['保存数量限价', 'save_qty_price'], 'mark_complete' => ['直接完结', '完结', 'mark_complete'], 'audit_abandon' => ['审批重新下发', '重新下发', 'audit_abandon'], 'archive_abandon' => ['历史重新下发', '存证重新下发', 'archive_abandon'], ]; } public static function toActionLabel(string $action): string { $action = trim($action); if ($action === '') { return ''; } $map = self::actionLabelMap(); if (isset($map[$action])) { return $map[$action]; } // 已是中文别名时,归一到主标签 foreach (self::actionAliases() as $code => $aliases) { if (in_array($action, $aliases, true)) { return $map[$code] ?? $action; } } return $action; } /** * 查询用:同时匹配中文标签、别名与历史英文码 * * @param string|array $actions * @return string[] */ public static function expandActionQueryValues($actions): array { $aliases = self::actionAliases(); $map = self::actionLabelMap(); $out = []; foreach ((array)$actions as $a) { $a = trim((string)$a); if ($a === '') { continue; } $out[$a] = true; $code = $a; if (!isset($aliases[$code])) { foreach ($aliases as $c => $list) { if (in_array($a, $list, true) || (isset($map[$c]) && $map[$c] === $a)) { $code = $c; break; } } } if (isset($aliases[$code])) { foreach ($aliases[$code] as $v) { $out[$v] = true; } } if (isset($map[$code])) { $out[$map[$code]] = true; } } return array_keys($out); } /** * 组装入库行(英文字段,操作类型存中文) * * @return array */ public static function buildInsertRow( int $scydgyId, string $action, string $content, ?int $purchaseOrderId, int $adminId, string $adminName ): array { return [ self::COL_SCYDGY_ID => $scydgyId, self::COL_PO_ID => $purchaseOrderId, self::COL_ADMIN_ID => $adminId, self::COL_ADMIN_NAME => self::cut($adminName !== '' ? $adminName : '未知用户', 64), self::COL_ACTION => self::cut(self::toActionLabel($action), 64), self::COL_CONTENT => self::cut($content, 1000), self::COL_TIME => ProcuremenTime::nowDateTime(), ]; } /** * @param array{0:int,1:string} $admin [id, name] */ public static function write(int $scydgyId, string $action, string $content, ?int $purchaseOrderId, array $admin): void { if (!ProcuremenGuard::isValidScydgyId($scydgyId) || $content === '') { return; } $adminId = (int)($admin[0] ?? 0); $adminName = (string)($admin[1] ?? '未知用户'); try { Db::table('purchase_order_oper_log')->insert( self::buildInsertRow($scydgyId, $action, $content, $purchaseOrderId, $adminId, $adminName) ); } catch (\Throwable $e) { Log::write('procuremen oper log: ' . $e->getMessage(), 'error'); } } /** * 查询结果规范化(兼容偶发中文列名 → 英文键) * * @param array $log * @return array */ public static function normalizeRow(array $log): array { $map = [ '工序ID' => 'scydgy_id', '采购订单ID' => 'purchase_order_id', '操作人ID' => 'admin_id', '操作人' => 'admin_name', '操作类型' => 'action', '操作内容' => 'content', '创建时间' => 'createtime', ]; foreach ($map as $cn => $en) { if (array_key_exists($cn, $log) && !array_key_exists($en, $log)) { $log[$en] = $log[$cn]; } } if (isset($log['action'])) { $log['action'] = self::toActionLabel((string)$log['action']); } return $log; } /** * @param string|array $actions */ public static function resolveLatestTime(int $scydgyId, $actions): string { if (!ProcuremenGuard::isValidScydgyId($scydgyId)) { return ''; } $acts = self::expandActionQueryValues($actions); if ($acts === []) { return ''; } try { $log = Db::table('purchase_order_oper_log') ->where(self::COL_SCYDGY_ID, $scydgyId) ->where(self::COL_ACTION, 'in', $acts) ->order('id', 'desc') ->find(); if (is_array($log)) { $log = self::normalizeRow($log); $text = ProcuremenTime::formatDisplayDateTime($log['createtime'] ?? null); if ($text !== '') { return $text; } } } catch (\Throwable $e) { } return ''; } public static function resolveMarkCompleteTime(int $scydgyId): string { return self::resolveLatestTime($scydgyId, 'mark_complete'); } /** * @param array> $logs * @param array $processNameBySid scydgy_id => 工序名称 * @return array> */ public static function formatLogRowsForDisplay(array $logs, array $processNameBySid = []): array { foreach ($logs as &$lg) { if (!is_array($lg)) { continue; } $lg = self::normalizeRow($lg); $lg['createtime_text'] = ProcuremenTime::formatDisplayDateTime($lg['createtime'] ?? null); $lg['content'] = ProcuremenTime::formatOperLogContent( (string)($lg['content'] ?? ''), (string)($lg['action'] ?? '') ); $lg['action_text'] = self::toActionLabel((string)($lg['action'] ?? '')); $lg = self::enrichSaveQtyPriceProcessName($lg, $processNameBySid); } unset($lg); $logs = self::mergeDuplicateProcessLogs($logs, $processNameBySid); $logs = self::enrichAdminDisplayFields($logs); foreach ($logs as &$lg) { if (!is_array($lg)) { continue; } $lg = self::splitActionAndDetailForDisplay($lg); } unset($lg); return $logs; } /** * 展示拆成「操作类型」+「操作说明」,说明里不再重复类型前缀 * * @param array $lg * @return array */ protected static function splitActionAndDetailForDisplay(array $lg): array { $action = trim((string)($lg['action_text'] ?? '')); if ($action === '') { $action = self::toActionLabel((string)($lg[self::COL_ACTION] ?? '')); } $content = trim((string)($lg[self::COL_CONTENT] ?? '')); $content = self::stripLeadingActionPrefixes($content, $action); if ($action === '') { $action = '操作'; } $lg['action_text'] = $action; $lg[self::COL_CONTENT] = $content; return $lg; } /** * @return string[] 按长度降序 */ protected static function contentActionPrefixes(string $action): array { $action = self::toActionLabel(trim($action)); $list = []; if ($action !== '') { $list[] = $action; } // 历史/文案别名 $aliases = [ '下发通知' => ['下发', '下发通知'], '保存数量限价' => ['保存数量限价', '保存本次数量、最高限价'], '审批重新下发' => ['审批重新下发', '重新下发'], '历史重新下发' => ['历史重新下发', '存证重新下发', '重新下发'], '审核确认供应商' => ['审核确认供应商', '采购确认'], '确认供应商' => ['确认供应商'], '开标验证' => ['开标验证'], '补加供应商' => ['补加供应商'], '直接完结' => ['直接完结', '完结'], '审核驳回' => ['审核驳回', '采购终审驳回'], ]; if (isset($aliases[$action])) { foreach ($aliases[$action] as $a) { $list[] = $a; } } $list = array_values(array_unique(array_filter(array_map('trim', $list)))); usort($list, static function ($a, $b) { return mb_strlen((string)$b, 'UTF-8') <=> mb_strlen((string)$a, 'UTF-8'); }); return $list; } protected static function stripLeadingActionPrefixes(string $content, string $action): string { $content = trim($content); if ($content === '') { return ''; } foreach (self::contentActionPrefixes($action) as $p) { foreach ([$p . ':', $p . ':'] as $pref) { if (mb_strpos($content, $pref, 0, 'UTF-8') === 0) { return trim(mb_substr($content, mb_strlen($pref, 'UTF-8'), null, 'UTF-8')); } } } return $content; } /** * 历史「保存数量限价」说明补工序名:…:压折线本次数量「330」,最高限价「10」 * * @param array $lg * @param array $processNameBySid * @return array */ protected static function enrichSaveQtyPriceProcessName(array $lg, array $processNameBySid): array { $action = trim((string)($lg['action_text'] ?? $lg[self::COL_ACTION] ?? '')); if ($action !== '保存数量限价' && $action !== 'save_qty_price') { return $lg; } $content = trim((string)($lg[self::COL_CONTENT] ?? '')); if ($content === '') { return $lg; } $sid = (int)($lg[self::COL_SCYDGY_ID] ?? 0); $proc = trim((string)($processNameBySid[$sid] ?? '')); if ($proc === '') { return $lg; } // 已含工序名则不重复 if (mb_strpos($content, $proc . '本次数量', 0, 'UTF-8') !== false || mb_strpos($content, '(' . $proc . ')', 0, 'UTF-8') !== false) { return $lg; } if (mb_strpos($content, ':本次数量', 0, 'UTF-8') !== false) { $lg[self::COL_CONTENT] = preg_replace('/:本次数量/u', ':' . $proc . '本次数量', $content, 1); } elseif (mb_strpos($content, ':本次数量', 0, 'UTF-8') !== false) { $lg[self::COL_CONTENT] = preg_replace('/:本次数量/u', ':' . $proc . '本次数量', $content, 1); } return $lg; } /** * 操作人前加所属组别;说明文案中的人员名同样补组别 * * @param array> $logs * @return array> */ public static function enrichAdminDisplayFields(array $logs): array { $ids = []; foreach ($logs as $lg) { if (!is_array($lg)) { continue; } $id = (int)($lg[self::COL_ADMIN_ID] ?? 0); if ($id > 0) { $ids[$id] = true; } } $map = self::loadAdminGroupNicknameMap(array_keys($ids)); // 仅用于纠正历史误伤:说明里曾把「组别 昵称」拼进供应商名,展示时还原为昵称/公司名 $nameToLabeled = self::buildAdminNameLabelMap(self::loadAllAdminGroupNicknameMap()); foreach (self::buildAdminNameLabelMap($map) as $name => $labeled) { $nameToLabeled[$name] = $labeled; } uksort($nameToLabeled, static function ($a, $b) { return mb_strlen((string)$b, 'UTF-8') <=> mb_strlen((string)$a, 'UTF-8'); }); foreach ($logs as &$lg) { if (!is_array($lg)) { continue; } $id = (int)($lg[self::COL_ADMIN_ID] ?? 0); $info = ($id > 0 && isset($map[$id])) ? $map[$id] : null; $nick = is_array($info) ? trim((string)($info['nickname'] ?? '')) : ''; if ($nick === '') { $nick = trim((string)($lg[self::COL_ADMIN_NAME] ?? '')); } if ($nick === '') { $nick = '未知用户'; } $group = is_array($info) ? trim((string)($info['group_name'] ?? '')) : ''; $lg['admin_group'] = $group !== '' ? $group : '—'; $lg['admin_nickname'] = $nick; // 操作人显示「组别 昵称」 $lg[self::COL_ADMIN_NAME] = self::formatAdminWithGroup($group, $nick); $content = (string)($lg[self::COL_CONTENT] ?? ''); if ($content !== '' && $nameToLabeled !== []) { // 先还原历史误伤的供应商名 $content = self::unprefixMistakenNamesInText($content, $nameToLabeled); // 开标验证等说明里是人员名,再补组别 $actionLabel = self::toActionLabel((string)($lg['action_text'] ?? $lg[self::COL_ACTION] ?? '')); if (self::actionContentNeedsPersonGroup($actionLabel)) { $content = self::prefixNamesInText($content, $nameToLabeled); } $lg[self::COL_CONTENT] = $content; } } unset($lg); return $logs; } /** 操作说明里会出现人员姓名、需补所属组别的类型 */ protected static function actionContentNeedsPersonGroup(string $action): bool { $action = self::toActionLabel(trim($action)); return $action === '开标验证'; } /** * 按 admin.id 解析展示名「组别 昵称」 * * @param array $adminIds * @return array */ public static function resolveAdminDisplayLabels(array $adminIds): array { $map = self::loadAdminGroupNicknameMap($adminIds); $out = []; foreach ($adminIds as $rawId) { $id = (int)$rawId; if ($id <= 0) { continue; } $info = isset($map[$id]) && is_array($map[$id]) ? $map[$id] : null; $nick = is_array($info) ? trim((string)($info['nickname'] ?? '')) : ''; $group = is_array($info) ? trim((string)($info['group_name'] ?? '')) : ''; if ($nick === '') { $nick = 'ID' . $id; } $out[$id] = self::formatAdminWithGroup($group, $nick); } return $out; } /** * @param array $adminMap * @return array 原名 => 「组别 名称」 */ protected static function buildAdminNameLabelMap(array $adminMap): array { $out = []; foreach ($adminMap as $info) { if (!is_array($info)) { continue; } $nick = trim((string)($info['nickname'] ?? '')); if ($nick === '') { continue; } $group = trim((string)($info['group_name'] ?? '')); $labeled = self::formatAdminWithGroup($group, $nick); if ($labeled !== $nick) { $out[$nick] = $labeled; } } // 长名优先,避免短名误伤 uksort($out, static function ($a, $b) { return mb_strlen((string)$b, 'UTF-8') <=> mb_strlen((string)$a, 'UTF-8'); }); return $out; } protected static function formatAdminWithGroup(string $group, string $name): string { $name = trim($name); $group = trim($group); if ($name === '') { return $group !== '' ? $group : '未知用户'; } if ($group === '' || $group === '—' || $group === $name) { return $name; } // 已带组别则不再重复 if (mb_strpos($name, $group, 0, 'UTF-8') === 0) { return $name; } return $group . ' ' . $name; } /** * @param array $nameToLabeled */ protected static function prefixNamesInText(string $text, array $nameToLabeled): string { foreach ($nameToLabeled as $name => $labeled) { if ($name === '' || $labeled === '' || $name === $labeled) { continue; } if (mb_strpos($text, $labeled, 0, 'UTF-8') !== false) { continue; } if (mb_strpos($text, $name, 0, 'UTF-8') === false) { continue; } $text = str_replace($name, $labeled, $text); } return $text; } /** * 还原说明里误拼的「组别 昵称」→ 原名(避免供应商名被改成「浙江新华管理员 新华印刷…」) * * @param array $nameToLabeled */ protected static function unprefixMistakenNamesInText(string $text, array $nameToLabeled): string { // 长标签优先替换 $pairs = []; foreach ($nameToLabeled as $name => $labeled) { if ($name === '' || $labeled === '' || $name === $labeled) { continue; } $pairs[$labeled] = $name; } uksort($pairs, static function ($a, $b) { return mb_strlen((string)$b, 'UTF-8') <=> mb_strlen((string)$a, 'UTF-8'); }); foreach ($pairs as $labeled => $name) { if (mb_strpos($text, $labeled, 0, 'UTF-8') === false) { continue; } $text = str_replace($labeled, $name, $text); } return $text; } /** * @return array */ public static function loadAllAdminGroupNicknameMap(): array { try { $ids = Db::name('admin')->column('id'); } catch (\Throwable $e) { $ids = []; } if (!is_array($ids) || $ids === []) { return []; } return self::loadAdminGroupNicknameMap(array_map('intval', $ids)); } /** * @param array $adminIds * @return array */ public static function loadAdminGroupNicknameMap(array $adminIds): array { $out = []; $adminIds = array_values(array_filter(array_map('intval', $adminIds))); if ($adminIds === []) { return $out; } try { $admins = Db::name('admin')->where('id', 'in', $adminIds)->field('id,nickname,username')->select(); } catch (\Throwable $e) { $admins = []; } if (is_array($admins)) { foreach ($admins as $a) { if (!is_array($a)) { continue; } $id = (int)($a['id'] ?? 0); if ($id <= 0) { continue; } $nick = trim((string)($a['nickname'] ?? '')); if ($nick === '') { $nick = trim((string)($a['username'] ?? '')); } $out[$id] = ['nickname' => $nick, 'group_name' => '', 'username' => trim((string)($a['username'] ?? ''))]; } } try { $rows = Db::name('auth_group_access') ->alias('aga') ->join('auth_group ag', 'aga.group_id = ag.id', 'LEFT') ->where('aga.uid', 'in', $adminIds) ->field('aga.uid,ag.name') ->select(); } catch (\Throwable $e) { $rows = []; } if (is_array($rows)) { foreach ($rows as $r) { if (!is_array($r)) { continue; } $uid = (int)($r['uid'] ?? 0); $gname = trim((string)($r['name'] ?? '')); if ($uid <= 0 || $gname === '') { continue; } if (!isset($out[$uid])) { $out[$uid] = ['nickname' => '', 'group_name' => $gname]; } elseif ($out[$uid]['group_name'] === '') { $out[$uid]['group_name'] = $gname; } elseif (strpos($out[$uid]['group_name'], $gname) === false) { $out[$uid]['group_name'] .= '、' . $gname; } } } return $out; } /** * 多工序短时间内相同操作合并为一条,并带上工序名称 * 例:审核确认供应商:配套、快递已选定「xxx」 * * @param array> $logs * @param array $processNameBySid * @return array> */ public static function mergeDuplicateProcessLogs(array $logs, array $processNameBySid = []): array { if ($logs === []) { return []; } $groups = []; $order = []; foreach ($logs as $idx => $lg) { if (!is_array($lg)) { continue; } $adminId = (int)($lg[self::COL_ADMIN_ID] ?? 0); $admin = trim((string)($lg[self::COL_ADMIN_NAME] ?? '')); $action = trim((string)($lg['action_text'] ?? $lg[self::COL_ACTION] ?? '')); $content = trim((string)($lg[self::COL_CONTENT] ?? '')); $contentCore = self::normalizeMergeContentCore($action, $content); $ts = self::logTimeTs($lg); // 60 秒内、同操作人+同操作类型+同说明核心 → 合并 $bucket = null; foreach ($groups as $gk => $g) { if (($g['admin_id'] !== $adminId && $g['admin'] !== $admin) || $g['action'] !== $action || $g['content_core'] !== $contentCore) { continue; } if (abs($ts - (int)$g['ts']) <= 60) { $bucket = $gk; break; } } if ($bucket === null) { $bucket = 'g' . $idx; $groups[$bucket] = [ 'admin_id' => $adminId, 'admin' => $admin, 'action' => $action, 'content_core' => $contentCore, 'ts' => $ts, 'row' => $lg, 'sids' => [], 'contents' => [], ]; $order[] = $bucket; } $sid = (int)($lg[self::COL_SCYDGY_ID] ?? 0); if ($sid !== 0) { $groups[$bucket]['sids'][$sid] = true; } $groups[$bucket]['contents'][] = $content; // 保留最早一条作展示底稿,时间用最早 $rowTs = self::logTimeTs($groups[$bucket]['row']); if ($ts > 0 && ($rowTs === 0 || $ts < $rowTs)) { $groups[$bucket]['row'] = $lg; $groups[$bucket]['ts'] = $ts; } } $out = []; foreach ($order as $gk) { $g = $groups[$gk]; $row = $g['row']; $sids = array_keys($g['sids']); $processNames = []; foreach ($sids as $sid) { $nm = trim((string)($processNameBySid[$sid] ?? '')); if ($nm !== '' && !in_array($nm, $processNames, true)) { $processNames[] = $nm; } } $content = trim((string)($row[self::COL_CONTENT] ?? '')); if (count($sids) > 1 || count($processNames) > 1) { $content = self::buildMergedProcessContent( (string)($g['action'] ?? ''), $content, $processNames ); } $row[self::COL_CONTENT] = $content; $row['action_text'] = (string)($g['action'] !== '' ? $g['action'] : ($row['action_text'] ?? '')); $out[] = $row; } return $out; } /** * 提取可合并的内容核心(去掉前缀动作名后的业务说明) */ protected static function normalizeMergeContentCore(string $action, string $content): string { return self::stripLeadingActionPrefixes(trim($content), trim($action)); } /** * @param array $processNames */ protected static function buildMergedProcessContent(string $action, string $content, array $processNames): string { $core = self::normalizeMergeContentCore($action, $content); $procText = implode('、', $processNames); if ($procText === '') { return $core !== '' ? $core : $content; } // 工序名放在操作说明里,不盖住操作类型 if ($core !== '') { return $procText . ' ' . $core; } return $procText; } /** * @param array $lg */ protected static function logTimeTs(array $lg): int { $raw = trim((string)($lg['createtime_text'] ?? $lg[self::COL_TIME] ?? '')); if ($raw === '') { return 0; } $ts = strtotime(str_replace('T', ' ', $raw)); return ($ts !== false && $ts > 0) ? (int)$ts : 0; } private static function cut(string $s, int $max): string { if (function_exists('mb_substr')) { return mb_substr($s, 0, $max, 'UTF-8'); } return strlen($s) <= $max ? $s : substr($s, 0, $max); } }