BizExtractor.php 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356
  1. <?php
  2. namespace app\common\library\ps;
  3. use think\Exception;
  4. /**
  5. * 从表格/文本/图片中提取产品或工单。优先规则解析表格,再用大模型补全。
  6. */
  7. class BizExtractor
  8. {
  9. /**
  10. * @param string $filePath
  11. * @param string $type product|workorder
  12. * @return array {items, source, model}
  13. * @throws Exception
  14. */
  15. public static function extract($filePath, $type = 'workorder')
  16. {
  17. $type = $type === 'product' ? 'product' : 'workorder';
  18. if (!is_file($filePath)) {
  19. throw new Exception('文件不存在');
  20. }
  21. $ext = strtolower(pathinfo($filePath, PATHINFO_EXTENSION));
  22. $text = '';
  23. $hintRows = [];
  24. $model = '';
  25. if (in_array($ext, ['csv', 'txt', 'json'], true)) {
  26. $text = (string)file_get_contents($filePath);
  27. $hintRows = self::parseTabular($text, $type);
  28. } elseif (in_array($ext, ['xlsx', 'xls'], true)) {
  29. $text = self::xlsxToText($filePath);
  30. $hintRows = self::parseTabular($text, $type);
  31. } elseif ($ext === 'pdf') {
  32. $text = self::pdfText($filePath);
  33. if ($text === '' && class_exists('\Imagick')) {
  34. $img = self::pdfFirstImage($filePath);
  35. if ($img) {
  36. try {
  37. $got = self::visionExtract($img, $type);
  38. @unlink($img);
  39. return [
  40. 'items' => self::normalizeItems($got['items'], $type),
  41. 'source' => 'vision',
  42. 'model' => $got['model'],
  43. ];
  44. } catch (\Exception $e) {
  45. @unlink($img);
  46. }
  47. }
  48. }
  49. $hintRows = self::parseTabular($text, $type);
  50. } elseif (in_array($ext, ['png', 'jpg', 'jpeg', 'webp', 'bmp', 'gif'], true)) {
  51. $got = self::visionExtract($filePath, $type);
  52. return [
  53. 'items' => self::normalizeItems($got['items'], $type),
  54. 'source' => 'vision',
  55. 'model' => $got['model'],
  56. ];
  57. } else {
  58. throw new Exception('不支持的文件类型,请上传 csv / xlsx / txt / pdf / 图片');
  59. }
  60. $items = $hintRows;
  61. $ai = self::llmExtract($text, $type, $hintRows);
  62. if (!empty($ai['items'])) {
  63. $items = $ai['items'];
  64. $model = $ai['model'];
  65. }
  66. if (!$items) {
  67. throw new Exception('未能从文件中识别出有效的' . ($type === 'product' ? '产品' : '工单') . '信息');
  68. }
  69. return [
  70. 'items' => self::normalizeItems($items, $type),
  71. 'source' => $model ? 'ai' : 'file',
  72. 'model' => $model,
  73. ];
  74. }
  75. /**
  76. * @param array $items
  77. * @param string $type
  78. * @return array
  79. */
  80. public static function normalizeItems(array $items, $type)
  81. {
  82. $out = [];
  83. foreach ($items as $item) {
  84. if (!is_array($item)) {
  85. continue;
  86. }
  87. $processes = BizIngest::normalizeProcesses($item);
  88. if ($type === 'product') {
  89. $code = trim((string)($item['code'] ?? $item['product_code'] ?? $item['no'] ?? ''));
  90. $name = trim((string)($item['name'] ?? $item['product_name'] ?? ''));
  91. if ($code === '' && $name === '') {
  92. continue;
  93. }
  94. $out[] = [
  95. 'code' => $code,
  96. 'name' => $name !== '' ? $name : $code,
  97. 'spec' => trim((string)($item['spec'] ?? '')),
  98. 'unit' => trim((string)($item['unit'] ?? '件')),
  99. 'qty' => (float)($item['qty'] ?? $item['quantity'] ?? $item['total_qty'] ?? 0),
  100. 'sheet_qty' => (float)($item['sheet_qty'] ?? $item['per_sheet'] ?? 0),
  101. 'remark' => trim((string)($item['remark'] ?? '')),
  102. 'process_count' => count($processes) ?: (int)($item['process_count'] ?? 0),
  103. 'processes' => $processes,
  104. ];
  105. } else {
  106. $no = trim((string)($item['no'] ?? $item['workorder_no'] ?? $item['code'] ?? ''));
  107. if ($no === '' && empty($item['product_code']) && empty($item['name'])) {
  108. continue;
  109. }
  110. $out[] = [
  111. 'no' => $no,
  112. 'product_code' => trim((string)($item['product_code'] ?? '')),
  113. 'product_name' => trim((string)($item['product_name'] ?? $item['name'] ?? '')),
  114. 'qty' => (float)($item['qty'] ?? $item['quantity'] ?? $item['total_qty'] ?? 0),
  115. 'sheet_qty' => (float)($item['sheet_qty'] ?? $item['per_sheet'] ?? 0),
  116. 'batch_no' => trim((string)($item['batch_no'] ?? $item['batch'] ?? '')),
  117. 'customer' => trim((string)($item['customer'] ?? '')),
  118. 'plan_date' => trim((string)($item['plan_date'] ?? $item['date'] ?? '')),
  119. 'remark' => trim((string)($item['remark'] ?? '')),
  120. 'process_count' => count($processes) ?: (int)($item['process_count'] ?? 0),
  121. 'processes' => $processes,
  122. ];
  123. }
  124. }
  125. return $out;
  126. }
  127. protected static function llmExtract($text, $type, array $hintRows)
  128. {
  129. $text = trim((string)$text);
  130. if ($text === '') {
  131. return ['items' => $hintRows, 'model' => ''];
  132. }
  133. if (mb_strlen($text) > 12000) {
  134. $text = mb_substr($text, 0, 12000);
  135. }
  136. $online = LlmClient::online();
  137. $client = $online->enabled() ? $online : LlmClient::local();
  138. if (!$client->enabled()) {
  139. return ['items' => $hintRows, 'model' => ''];
  140. }
  141. $kind = $type === 'product' ? '产品' : '工单';
  142. $schema = $type === 'product'
  143. ? '{"items":[{"code":"产品编号","name":"名称","spec":"规格","unit":"单位","process_count":2,"processes":[{"code":"工艺编号","seq":1,"name":"工序名","workstation":"工位","standard_time":"工时","qc_points":""}]}]}'
  144. : '{"items":[{"no":"工单号","product_code":"产品编号","product_name":"产品名称","qty":100,"batch_no":"批次","customer":"客户","plan_date":"2026-01-01","process_count":2,"processes":[{"code":"工艺编号","seq":1,"name":"工序名","workstation":"工位","standard_time":"工时","qc_points":""}]}]}';
  145. $prompt = "从下面内容提取{$kind}信息,只输出 JSON,不要解释。格式:{$schema}\n"
  146. . "qty 是总数量,process_count 是工序条数,processes 里 code 是工艺编号。可有多条。\n\n"
  147. . $text;
  148. try {
  149. $raw = $client->chat($prompt, null, true);
  150. $parsed = self::decodeJson($raw);
  151. $items = isset($parsed['items']) && is_array($parsed['items']) ? $parsed['items'] : [];
  152. return ['items' => $items ?: $hintRows, 'model' => $client->textModel()];
  153. } catch (\Exception $e) {
  154. return ['items' => $hintRows, 'model' => ''];
  155. }
  156. }
  157. protected static function visionExtract($imagePath, $type)
  158. {
  159. $client = LlmClient::forRecognize();
  160. if (!$client->enabled()) {
  161. throw new Exception('未配置可用的视觉模型,无法识别图片');
  162. }
  163. $kind = $type === 'product' ? '产品' : '工单';
  164. $prompt = "提取图中的{$kind}:编号、名称、总数量、工序/工艺编号和工序名。只输出 JSON:"
  165. . '{"items":[{"no":"","code":"","name":"","qty":0,"processes":[{"code":"","seq":1,"name":""}]}]}';
  166. $got = $client->vision($prompt, $imagePath);
  167. $parsed = self::decodeJson($got['content'] ?? '');
  168. $items = isset($parsed['items']) && is_array($parsed['items']) ? $parsed['items'] : [];
  169. return ['items' => $items, 'model' => $got['model'] ?? ''];
  170. }
  171. protected static function decodeJson($raw)
  172. {
  173. $raw = trim((string)$raw);
  174. if ($raw === '') {
  175. return [];
  176. }
  177. $try = json_decode($raw, true);
  178. if (is_array($try)) {
  179. return $try;
  180. }
  181. if (preg_match('/\{[\s\S]*\}/', $raw, $m)) {
  182. $try = json_decode($m[0], true);
  183. if (is_array($try)) {
  184. return $try;
  185. }
  186. }
  187. return [];
  188. }
  189. protected static function parseTabular($text, $type)
  190. {
  191. $lines = preg_split("/\r\n|\n|\r/", trim((string)$text));
  192. if (!$lines) {
  193. return [];
  194. }
  195. $rows = [];
  196. foreach ($lines as $line) {
  197. $line = trim($line);
  198. if ($line === '') {
  199. continue;
  200. }
  201. $rows[] = preg_split("/\t|,|,|;|;/", $line);
  202. }
  203. if (count($rows) < 2) {
  204. return [];
  205. }
  206. $header = array_map(function ($v) {
  207. return strtolower(trim((string)$v));
  208. }, $rows[0]);
  209. $map = self::headerMap($header, $type);
  210. $items = [];
  211. $grouped = [];
  212. for ($i = 1; $i < count($rows); $i++) {
  213. $assoc = [];
  214. foreach ($map as $idx => $key) {
  215. $assoc[$key] = isset($rows[$i][$idx]) ? trim((string)$rows[$i][$idx]) : '';
  216. }
  217. $pk = $type === 'product' ? ($assoc['code'] ?? '') : ($assoc['no'] ?? '');
  218. if ($pk === '') {
  219. $pk = 'row' . $i;
  220. }
  221. if (!isset($grouped[$pk])) {
  222. $grouped[$pk] = $assoc;
  223. $grouped[$pk]['processes'] = [];
  224. }
  225. $pName = $assoc['process_name'] ?? '';
  226. $pCode = $assoc['process_code'] ?? '';
  227. if ($pName !== '' || $pCode !== '') {
  228. $grouped[$pk]['processes'][] = [
  229. 'code' => $pCode,
  230. 'name' => $pName,
  231. 'seq' => count($grouped[$pk]['processes']) + 1,
  232. ];
  233. }
  234. }
  235. foreach ($grouped as $item) {
  236. $items[] = $item;
  237. }
  238. return $items;
  239. }
  240. protected static function headerMap(array $header, $type)
  241. {
  242. $map = [];
  243. foreach ($header as $i => $h) {
  244. $h = str_replace(' ', '', $h);
  245. if (preg_match('/工单|workorder|^no$/', $h)) {
  246. $map[$i] = 'no';
  247. } elseif (preg_match('/产品编号|产品编码|物料|sku|^code$/', $h)) {
  248. $map[$i] = $type === 'product' ? 'code' : 'product_code';
  249. } elseif (preg_match('/产品名|名称|^name$/', $h)) {
  250. $map[$i] = $type === 'product' ? 'name' : 'product_name';
  251. } elseif (preg_match('/每版|每张|sheet_qty|tldx/', $h)) {
  252. $map[$i] = 'sheet_qty';
  253. } elseif (preg_match('/数量|qty|quantity/', $h)) {
  254. $map[$i] = 'qty';
  255. } elseif (preg_match('/批次|batch/', $h)) {
  256. $map[$i] = 'batch_no';
  257. } elseif (preg_match('/客户|customer/', $h)) {
  258. $map[$i] = 'customer';
  259. } elseif (preg_match('/日期|date/', $h)) {
  260. $map[$i] = 'plan_date';
  261. } elseif (preg_match('/规格|spec/', $h)) {
  262. $map[$i] = 'spec';
  263. } elseif (preg_match('/单位|unit/', $h)) {
  264. $map[$i] = 'unit';
  265. } elseif (preg_match('/工艺编号|工序编号|processcode|op.?code/', $h)) {
  266. $map[$i] = 'process_code';
  267. } elseif (preg_match('/工艺名|工序名|processname|工序/', $h)) {
  268. $map[$i] = 'process_name';
  269. }
  270. }
  271. return $map;
  272. }
  273. protected static function xlsxToText($path)
  274. {
  275. if (!class_exists('ZipArchive')) {
  276. return (string)@file_get_contents($path);
  277. }
  278. $zip = new \ZipArchive();
  279. if ($zip->open($path) !== true) {
  280. return '';
  281. }
  282. $shared = [];
  283. $ss = $zip->getFromName('xl/sharedStrings.xml');
  284. if ($ss) {
  285. if (preg_match_all('/<t[^>]*>([^<]*)<\/t>/', $ss, $m)) {
  286. $shared = $m[1];
  287. }
  288. }
  289. $sheet = $zip->getFromName('xl/worksheets/sheet1.xml');
  290. $zip->close();
  291. if (!$sheet) {
  292. return '';
  293. }
  294. $rows = [];
  295. if (preg_match_all('/<row[^>]*>([\s\S]*?)<\/row>/', $sheet, $rowMatch)) {
  296. foreach ($rowMatch[1] as $rowXml) {
  297. $cells = [];
  298. if (preg_match_all('/<c[^>]*?(t="s")?[^>]*>(?:<v>([^<]*)<\/v>)?/', $rowXml, $cMatch, PREG_SET_ORDER)) {
  299. foreach ($cMatch as $c) {
  300. $val = isset($c[2]) ? $c[2] : '';
  301. if (!empty($c[1]) && isset($shared[(int)$val])) {
  302. $val = html_entity_decode($shared[(int)$val], ENT_QUOTES, 'UTF-8');
  303. }
  304. $cells[] = $val;
  305. }
  306. }
  307. if ($cells) {
  308. $rows[] = implode("\t", $cells);
  309. }
  310. }
  311. }
  312. return implode("\n", $rows);
  313. }
  314. protected static function pdfText($path)
  315. {
  316. $bin = IS_WIN ? 'pdftotext.exe' : 'pdftotext';
  317. $tmp = tempnam(sys_get_temp_dir(), 'pstxt');
  318. @exec(escapeshellarg($bin) . ' -enc UTF-8 ' . escapeshellarg($path) . ' ' . escapeshellarg($tmp), $out, $code);
  319. $text = '';
  320. if ($code === 0 && is_file($tmp)) {
  321. $text = trim((string)file_get_contents($tmp));
  322. }
  323. @unlink($tmp);
  324. return $text;
  325. }
  326. protected static function pdfFirstImage($pdfPath)
  327. {
  328. try {
  329. $im = new \Imagick();
  330. $im->setResolution(120, 120);
  331. $im->readImage($pdfPath . '[0]');
  332. $im->setImageFormat('jpeg');
  333. $tmp = tempnam(sys_get_temp_dir(), 'psimp') . '.jpg';
  334. $im->writeImage($tmp);
  335. $im->clear();
  336. $im->destroy();
  337. return is_file($tmp) ? $tmp : '';
  338. } catch (\Exception $e) {
  339. return '';
  340. }
  341. }
  342. }