> ' . escapeshellarg($log) . ' 2>&1 &'); } return true; } /** * 处理一条识版任务。 * @param int $id * @return array 任务行 * @throws Exception */ public static function run($id) { $id = (int)$id; $job = Db::name('ps_ai_job')->where('id', $id)->find(); if (!$job) { throw new Exception('识版任务不存在'); } @set_time_limit(1200); @ini_set('memory_limit', '512M'); self::keepDbAlive(); Db::name('ps_ai_job')->where('id', $id)->update([ 'status' => 'running', 'error_msg' => '', 'updatetime' => time(), ]); try { $path = self::resolveFilePath($job['file_url']); $client = LlmClient::forRecognize(); $local = LlmClient::local(); $model = trim((string)$job['model']); $fileType = $job['file_type'] === 'pdf' ? 'pdf' : 'image'; $raw = ''; $usedModel = $model; $parsed = ['cells' => []]; if ($fileType === 'pdf') { $imagePath = self::pdfFirstPageImage($path); if ($imagePath) { $got = self::extractLayout($client, $imagePath, $model); @unlink($imagePath); $raw = $got['raw']; $usedModel = $got['model']; $parsed = $got['schema']; } else { $text = self::pdfPlainText($path); if ($text === '') { throw new Exception('当前环境无法解析 PDF,请改传流程单图片(JPG/PNG)'); } $raw = $local->chat(self::promptCsv() . "\n\n以下是 PDF 抽取的文字:\n" . mb_substr($text, 0, 8000), $local->textModel(), false); $usedModel = $local->textModel(); $parsed = self::parseLayoutRaw($raw); } } else { $got = self::extractLayout($client, $path, $model); $raw = $got['raw']; $usedModel = $got['model']; $parsed = $got['schema']; } self::keepDbAlive(); $rawStore = mb_substr((string)$raw, 0, 60000); Db::name('ps_ai_job')->where('id', $id)->update([ 'model' => $usedModel, 'raw_text' => $rawStore, 'updatetime' => time(), ]); $schema = self::normalizeSchema($parsed, $job, $path); $templateId = self::saveTemplate($job, $schema); self::keepDbAlive(); Db::name('ps_ai_job')->where('id', $id)->update([ 'status' => 'success', 'model' => $usedModel, 'raw_text' => $rawStore, 'schema_json' => json_encode($schema, JSON_UNESCAPED_UNICODE), 'template_id' => $templateId, 'error_msg' => '', 'updatetime' => time(), ]); } catch (\Exception $e) { try { self::keepDbAlive(); Db::name('ps_ai_job')->where('id', $id)->update([ 'status' => 'fail', 'error_msg' => mb_substr($e->getMessage(), 0, 500), 'updatetime' => time(), ]); } catch (\Exception $ignore) { // 断线时至少不要让 CLI 再抛一次把状态钉死在 running } $job = ['id' => $id, 'status' => 'fail', 'error_msg' => $e->getMessage()]; try { $job = Db::name('ps_ai_job')->where('id', $id)->find() ?: $job; } catch (\Exception $ignore) { } $job['status'] = 'fail'; $job['error_msg'] = $e->getMessage(); return $job; } self::keepDbAlive(); return Db::name('ps_ai_job')->where('id', $id)->find(); } /** * @param int $limit * @return int[] */ public static function pendingIds($limit = 1) { $rows = Db::name('ps_ai_job') ->where('status', 'pending') ->order('id asc') ->limit(max(1, (int)$limit)) ->column('id'); return array_map('intval', $rows ?: []); } /** * 把上传返回的完整网址收成站内相对路径。 * @param string $fileUrl * @return string */ public static function localFileUrl($fileUrl) { $fileUrl = trim((string)$fileUrl); if ($fileUrl === '') { return ''; } if (preg_match('#^https?://[^/]+(/.*)$#i', $fileUrl, $m)) { $fileUrl = $m[1]; } $fileUrl = preg_replace('#\?.*$#', '', $fileUrl); return '/' . ltrim(str_replace('\\', '/', $fileUrl), '/'); } /** * @param string $fileUrl * @return string * @throws Exception */ protected static function resolveFilePath($fileUrl) { $fileUrl = self::localFileUrl($fileUrl); if ($fileUrl === '' || $fileUrl === '/') { throw new Exception('原稿文件为空'); } if (preg_match('#^https?://#i', $fileUrl)) { throw new Exception('暂不支持远程 URL,请先上传到本站'); } $relative = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, ltrim($fileUrl, '/\\')); $candidates = [ ROOT_PATH . 'public' . DIRECTORY_SEPARATOR . $relative, ROOT_PATH . $relative, ]; foreach ($candidates as $path) { if (is_file($path)) { return $path; } } throw new Exception('原稿文件不存在:' . $fileUrl); } /** * @param string $pdfPath * @return string 临时图片路径,失败返回空 */ protected static function pdfFirstPageImage($pdfPath) { if (!class_exists('\Imagick')) { return ''; } try { $im = new \Imagick(); $im->setResolution(120, 120); $im->readImage($pdfPath . '[0]'); $im->setImageFormat('jpeg'); $tmp = tempnam(sys_get_temp_dir(), 'psai') . '.jpg'; $im->writeImage($tmp); $im->clear(); $im->destroy(); return is_file($tmp) ? $tmp : ''; } catch (\Exception $e) { return ''; } } /** * @param string $pdfPath * @return string */ protected static function pdfPlainText($pdfPath) { $bin = IS_WIN ? 'pdftotext.exe' : 'pdftotext'; $tmp = tempnam(sys_get_temp_dir(), 'pstxt'); @exec(escapeshellarg($bin) . ' -enc UTF-8 ' . escapeshellarg($pdfPath) . ' ' . escapeshellarg($tmp), $out, $code); if ($code === 0 && is_file($tmp)) { $text = (string)file_get_contents($tmp); @unlink($tmp); return trim($text); } @unlink($tmp); return ''; } /** * @param array $schema * @param array $job * @param string $imagePath * @return array */ protected static function normalizeSchema(array $schema, array $job, $imagePath = '') { $name = trim((string)($schema['name'] ?? $schema['title'] ?? '')); $fileUrl = (string)$job['file_url']; $probe = is_file($imagePath) ? LayoutProbe::inspect($imagePath) : ['width' => 0, 'height' => 0, 'background' => '#ffffff', 'ratio' => 297 / 210]; $rawItems = PrintPlanner::collectLayoutItems($schema); $elements = PrintPlanner::normalizeElements($rawItems, (int)$probe['width'], (int)$probe['height']); if (!$elements && !empty($schema['fields'])) { $elements = PrintPlanner::elementsFromFields($schema['fields']); } if ($name === '' || $name === '模板名') { foreach ($elements as $el) { if (trim((string)($el['text'] ?? '')) !== '') { $name = mb_substr(trim($el['text']), 0, 40); break; } } if ($name === '' || $name === '模板名') { $name = 'AI识版 ' . date('Y-m-d H:i'); } } $processes = self::normalizeProcesses($schema['processes'] ?? []); $title = trim((string)($schema['title'] ?? $name)); $pageIn = isset($schema['page']) && is_array($schema['page']) ? $schema['page'] : []; $pageW = 210; $ratio = $probe['ratio'] > 0 ? $probe['ratio'] : 297 / 210; $pageH = isset($pageIn['h']) ? (float)$pageIn['h'] : round(210 * $ratio, 2); $pageBg = $pageIn['background'] ?? $probe['background']; $cells = []; foreach ($elements as $el) { if (($el['key'] ?? '') !== '' || ($el['role'] ?? '') === 'fill' || ($el['type'] ?? '') === 'field') { $cells[] = [ 'id' => $el['id'], 'key' => $el['key'], 'label' => $el['label'], 'x' => $el['x'], 'y' => $el['y'], 'w' => $el['w'], 'h' => $el['h'], ]; } } return [ 'name' => mb_substr($name, 0, 100), 'title' => $title, 'mode' => 'overlay', 'page' => [ 'w' => $pageW, 'h' => $pageH > 0 ? $pageH : round(210 * $ratio, 2), 'background' => $pageBg ?: '#ffffff', 'image_width' => (int)$probe['width'], 'image_height' => (int)$probe['height'], ], 'elements' => $elements, 'cells' => $cells, 'fields' => [], 'processes' => $processes, 'background' => $fileUrl, 'file_url' => $fileUrl, 'source' => 'ai', ]; } /** * @param array $job * @param array $schema * @return int */ protected static function saveTemplate(array $job, array $schema) { $now = time(); $factoryId = (int)$job['factory_id']; $userId = (int)$job['user_id']; $cover = (string)$job['file_url']; $existId = (int)($job['template_id'] ?? 0); if ($existId > 0) { $exist = Db::name('ps_template') ->where('id', $existId) ->where('factory_id', $factoryId) ->where('is_system', 0) ->find(); if ($exist) { $next = (int)Db::name('ps_template_version')->where('template_id', $existId)->max('version') + 1; if ($next < 1) { $next = 1; } $versionId = (int)Db::name('ps_template_version')->insertGetId([ 'template_id' => $existId, 'factory_id' => $factoryId, 'version' => $next, 'schema_json' => json_encode($schema, JSON_UNESCAPED_UNICODE), 'remark' => 'AI重新拆版', 'createtime' => $now, ]); Db::name('ps_template')->where('id', $existId)->update([ 'name' => $schema['name'], 'cover' => $cover, 'current_version_id' => $versionId, 'updatetime' => $now, ]); return $existId; } } $templateId = (int)Db::name('ps_template')->insertGetId([ 'factory_id' => $factoryId, 'creator_user_id' => $userId, 'name' => $schema['name'], 'cover' => $cover, 'source' => 'ai', 'license_type' => 'self', 'locked' => 0, 'source_template_id' => 0, 'source_version_id' => 0, 'current_version_id' => 0, 'is_system' => 0, 'status' => 'normal', 'createtime' => $now, 'updatetime' => $now, ]); $versionId = (int)Db::name('ps_template_version')->insertGetId([ 'template_id' => $templateId, 'factory_id' => $factoryId, 'version' => 1, 'schema_json' => json_encode($schema, JSON_UNESCAPED_UNICODE), 'remark' => 'AI识版自动入库', 'createtime' => $now, ]); Db::name('ps_template')->where('id', $templateId)->update([ 'current_version_id' => $versionId, 'updatetime' => $now, ]); return $templateId; } /** * @param mixed $fields * @return string[] */ protected static function normalizeFields($fields) { if (!is_array($fields)) { return ['factory.name', 'product.name', 'workorder.no', 'sheet.qty']; } $alias = [ '产品名称' => 'product.name', '产品' => 'product.name', '品名' => 'product.name', '产品编码' => 'product.code', '编码' => 'product.code', '货号' => 'product.code', '规格' => 'product.spec', '单位' => 'product.unit', '工厂' => 'factory.name', '厂名' => 'factory.name', '工单号' => 'workorder.no', '单号' => 'workorder.no', '流程单号' => 'workorder.no', '数量' => 'workorder.qty', '本张数量' => 'sheet.qty', '投料' => 'sheet.qty', '批次' => 'workorder.batch_no', '客户' => 'workorder.customer', '日期' => 'workorder.plan_date', '计划日' => 'workorder.plan_date', '备注' => 'workorder.remark', '操作员' => 'operator', '时间' => 'print_time', ]; $allowed = [ 'factory.name', 'factory.code', 'factory.contact', 'factory.mobile', 'product.code', 'product.name', 'product.spec', 'product.unit', 'workorder.no', 'workorder.qty', 'workorder.batch_no', 'workorder.plan_date', 'workorder.customer', 'workorder.remark', 'sheet.no', 'sheet.total', 'sheet.qty', 'sheet.no_text', 'process_names', 'operator', 'print_time', ]; $out = []; foreach ($fields as $item) { if (is_array($item)) { $item = $item['key'] ?? $item['name'] ?? ''; } $item = trim((string)$item); if (isset($alias[$item])) { $item = $alias[$item]; } if (in_array($item, $allowed, true) && !in_array($item, $out, true)) { $out[] = $item; } } return $out ?: ['factory.name', 'product.name', 'workorder.no', 'sheet.qty']; } /** * @param mixed $processes * @return array */ protected static function normalizeProcesses($processes) { if (!is_array($processes)) { return []; } $list = []; $seq = 1; foreach ($processes as $row) { if (is_string($row) && trim($row) !== '') { $list[] = [ 'seq' => $seq++, 'name' => trim($row), 'workstation' => '', 'standard_time' => '', 'qc_points' => '', ]; continue; } if (!is_array($row) || empty($row['name'])) { continue; } $list[] = [ 'seq' => isset($row['seq']) ? (int)$row['seq'] : $seq++, 'name' => (string)$row['name'], 'workstation' => isset($row['workstation']) ? (string)$row['workstation'] : '', 'standard_time' => isset($row['standard_time']) ? (string)$row['standard_time'] : '', 'qc_points' => isset($row['qc_points']) ? (string)$row['qc_points'] : '', ]; } return $list; } /** * @param array $schema * @return string */ protected static function fallbackHtml(array $schema) { $title = htmlspecialchars((string)($schema['title'] ?? $schema['name'] ?? '生产流程单'), ENT_QUOTES, 'UTF-8'); return '
| 工厂 | {factory.name} | 工单号 | {workorder.no} |
| 产品 | {product.name} | 编码 | {product.code} |
| 规格 | {product.spec} | 单位 | {product.unit} |
| 批次 | {workorder.batch_no} | 客户 | {workorder.customer} |
| 本张数量 | {sheet.qty} | 操作员 | {operator} |
备注:{workorder.remark}
' . '打印时间:{print_time}
' . '