PrintPlanner.php 74 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924
  1. <?php
  2. namespace app\common\library\ps;
  3. use app\common\model\ps\Factory;
  4. use app\common\model\ps\Product;
  5. use app\common\model\ps\Template;
  6. use app\common\model\ps\Workorder;
  7. use app\common\model\User;
  8. use think\Config;
  9. use think\Db;
  10. use think\Exception;
  11. /**
  12. * 套打数据与分张规则。
  13. * 对齐现有流程单:按投料数量拆张、带工序顺序、先组数据再预览。
  14. */
  15. class PrintPlanner
  16. {
  17. /**
  18. * 组装一次套打所需的填充数据与分张计划
  19. *
  20. * @param string $bizType product|workorder
  21. * @param int $bizId
  22. * @param array $options sheet_qty/tldx 每张数量;qty/num 覆盖数量;processes/process_string 覆盖工序
  23. * @return array
  24. * @throws Exception
  25. */
  26. public static function prepare($bizType, $bizId, array $options = [])
  27. {
  28. if (!in_array($bizType, ['product', 'workorder'], true)) {
  29. throw new Exception('业务类型不正确');
  30. }
  31. $factory = Factory::get(FactoryContext::id());
  32. if (!$factory) {
  33. throw new Exception('工厂不存在');
  34. }
  35. $product = null;
  36. $workorder = null;
  37. $qtyDefault = 0;
  38. $sheetQtyDefault = 0;
  39. $bizId = (int)$bizId;
  40. if ($bizId > 0 && $bizType === 'workorder') {
  41. $workorder = Workorder::with('processes')->find($bizId);
  42. if (!$workorder) {
  43. throw new Exception('工单不存在');
  44. }
  45. $product = $workorder['product_id'] ? Product::with('processes')->find($workorder['product_id']) : null;
  46. $processes = self::resolveProcesses($workorder['processes'], $product ? $product['processes'] : [], $options);
  47. $qtyDefault = $workorder['qty'];
  48. $sheetQtyDefault = $workorder['sheet_qty'];
  49. $storedSpec = trim((string)($workorder['spec'] ?? ''));
  50. if ($storedSpec === '' && $product) {
  51. $storedSpec = trim((string)$product['spec']);
  52. }
  53. $header = [
  54. 'workorder_id' => (int)$workorder['id'],
  55. 'workorder_no' => self::pickText($options, 'no', (string)$workorder['no']),
  56. 'batch_no' => (string)$workorder['batch_no'],
  57. 'plan_date' => (string)$workorder['plan_date'],
  58. 'customer' => (string)$workorder['customer'],
  59. 'remark' => (string)$workorder['remark'],
  60. 'spec' => $storedSpec,
  61. ];
  62. $storedName = trim((string)($workorder['product_name'] ?? ''));
  63. $nameOverride = self::pickText($options, 'name', $storedName);
  64. if ($nameOverride === '' && $product) {
  65. $nameOverride = (string)$product['name'];
  66. }
  67. if ($nameOverride !== '' && $product) {
  68. $product['name'] = $nameOverride;
  69. if ($storedSpec !== '') {
  70. $product['spec'] = $storedSpec;
  71. }
  72. } elseif ($nameOverride !== '') {
  73. $product = ['id' => 0, 'code' => '', 'name' => $nameOverride, 'spec' => $storedSpec, 'unit' => ''];
  74. }
  75. } elseif ($bizId > 0) {
  76. $product = Product::with('processes')->find($bizId);
  77. if (!$product) {
  78. throw new Exception('产品不存在');
  79. }
  80. $processes = self::resolveProcesses([], $product['processes'], $options);
  81. $qtyDefault = $product['qty'];
  82. $sheetQtyDefault = $product['sheet_qty'];
  83. $nameOverride = self::pickText($options, 'name', '');
  84. if ($nameOverride !== '') {
  85. $product['name'] = $nameOverride;
  86. }
  87. $codeOverride = self::pickText($options, 'code', self::pickText($options, 'no', ''));
  88. if ($codeOverride !== '') {
  89. $product['code'] = $codeOverride;
  90. }
  91. $header = [
  92. 'workorder_id' => 0,
  93. 'workorder_no' => '',
  94. 'batch_no' => '',
  95. 'plan_date' => '',
  96. 'customer' => '',
  97. 'remark' => (string)$product['remark'],
  98. ];
  99. } else {
  100. $no = self::pickText($options, 'no', self::pickText($options, 'code', ''));
  101. $name = self::pickText($options, 'name', '');
  102. if ($no === '' && $name === '') {
  103. throw new Exception('请填写工单或产品编号、名称');
  104. }
  105. $qtyDefault = 0;
  106. $sheetQtyDefault = 0;
  107. $processes = self::resolveProcesses([], [], $options);
  108. $product = [
  109. 'id' => 0,
  110. 'code' => $bizType === 'product' ? $no : '',
  111. 'name' => $name !== '' ? $name : $no,
  112. 'spec' => '',
  113. 'unit' => '',
  114. ];
  115. $header = [
  116. 'workorder_id' => 0,
  117. 'workorder_no' => $bizType === 'workorder' ? $no : '',
  118. 'batch_no' => '',
  119. 'plan_date' => '',
  120. 'customer' => '',
  121. 'remark' => '',
  122. ];
  123. }
  124. $qty = self::pickQty($options, $qtyDefault);
  125. $header['qty_plan'] = $qty;
  126. $sheetQty = self::pickSheetQty($options, $sheetQtyDefault);
  127. $sheets = self::splitSheets($qty, $sheetQty);
  128. $operator = User::get(FactoryContext::userId());
  129. $processNames = [];
  130. foreach ($processes as $item) {
  131. $processNames[] = $item['name'];
  132. }
  133. $specText = isset($header['spec'])
  134. ? (string)$header['spec']
  135. : ($product ? (string)$product['spec'] : '');
  136. if ($specText === '' && $product) {
  137. $specText = (string)$product['spec'];
  138. }
  139. $fill = [
  140. 'factory.name' => (string)$factory['name'],
  141. 'factory.code' => (string)$factory['code'],
  142. 'factory.contact' => (string)$factory['contact'],
  143. 'factory.mobile' => (string)$factory['mobile'],
  144. 'product.id' => $product ? (int)$product['id'] : 0,
  145. 'product.code' => $product ? (string)$product['code'] : '',
  146. 'product.name' => $product ? (string)$product['name'] : '',
  147. 'product.spec' => $specText,
  148. 'product.unit' => $product ? (string)$product['unit'] : '',
  149. 'workorder.no' => $header['workorder_no'],
  150. 'workorder.qty' => self::formatQty($qty),
  151. 'qty_plan' => self::formatQty($qty),
  152. 'workorder.qty_plan' => self::formatQty($qty),
  153. 'workorder.batch_no' => $header['batch_no'],
  154. 'workorder.pack_no' => isset($header['pack_no']) ? (string)$header['pack_no'] : '',
  155. 'workorder.plan_date' => $header['plan_date'],
  156. 'workorder.customer' => $header['customer'],
  157. 'workorder.remark' => $header['remark'],
  158. 'workorder.spec' => $specText,
  159. 'process_names' => implode('、', $processNames),
  160. 'sheet_qty' => self::formatQty($sheetQty),
  161. 'operator' => $operator ? (string)($operator['nickname'] ?: $operator['username']) : '',
  162. 'print_time' => date('Y-m-d H:i:s'),
  163. ];
  164. $extra = isset($options['extra']) && is_array($options['extra']) ? $options['extra'] : [];
  165. foreach ($extra as $key => $value) {
  166. if (is_scalar($value)) {
  167. $fill[(string)$key] = (string)$value;
  168. }
  169. }
  170. $sheetRows = [];
  171. foreach ($sheets as $sheet) {
  172. $sheetFill = $fill;
  173. $sheetFill['sheet.no'] = (string)$sheet['no'];
  174. $sheetFill['sheet.total'] = (string)$sheet['total'];
  175. $sheetFill['sheet.qty'] = self::formatQty($sheet['qty']);
  176. $sheetFill['sheet.no_text'] = $sheet['no'] . '/' . $sheet['total'];
  177. $sheetFill['workorder.pack_no'] = $sheet['total'] . '-' . $sheet['no'];
  178. $sheetFill['sheet.pack_no'] = $sheet['total'] . '-' . $sheet['no'];
  179. $sheetFill['qty_plan'] = self::formatQty($qty);
  180. $sheetFill['workorder.qty_plan'] = self::formatQty($qty);
  181. $sheetRows[] = [
  182. 'no' => $sheet['no'],
  183. 'total' => $sheet['total'],
  184. 'qty' => $sheet['qty'],
  185. 'qty_text' => self::formatQty($sheet['qty']),
  186. 'no_text' => $sheet['no'] . '/' . $sheet['total'],
  187. 'fill' => $sheetFill,
  188. ];
  189. }
  190. $plateTotal = count($sheetRows);
  191. $from = (int)($options['sheet_from'] ?? $options['sheet_start'] ?? 0);
  192. $to = (int)($options['sheet_to'] ?? $options['sheet_end'] ?? 0);
  193. if ($from < 1) {
  194. $from = 1;
  195. }
  196. if ($to < 1 || $to > $plateTotal) {
  197. $to = $plateTotal;
  198. }
  199. if ($from > $to) {
  200. $from = 1;
  201. }
  202. $printed = [];
  203. foreach ($sheetRows as $row) {
  204. if ($row['no'] >= $from && $row['no'] <= $to) {
  205. $printed[] = $row;
  206. }
  207. }
  208. if (!$printed) {
  209. $printed = $sheetRows;
  210. }
  211. $productArr = [];
  212. if (is_object($product) && method_exists($product, 'toArray')) {
  213. $productArr = $product->toArray();
  214. } elseif (is_array($product)) {
  215. $productArr = $product;
  216. }
  217. $workorderArr = [];
  218. if (is_object($workorder) && method_exists($workorder, 'toArray')) {
  219. $workorderArr = $workorder->toArray();
  220. }
  221. return [
  222. 'biz_type' => $bizType,
  223. 'biz_id' => (int)$bizId,
  224. 'factory' => [
  225. 'id' => (int)$factory['id'],
  226. 'name' => (string)$factory['name'],
  227. 'code' => (string)$factory['code'],
  228. 'contact' => (string)$factory['contact'],
  229. 'mobile' => (string)$factory['mobile'],
  230. ],
  231. 'product' => $productArr,
  232. 'workorder' => $workorderArr,
  233. 'header' => $header,
  234. 'processes' => $processes,
  235. 'process_names' => $processNames,
  236. 'qty_plan' => $qty,
  237. 'sheet_qty' => $sheetQty,
  238. 'plate_total' => $plateTotal,
  239. 'sheet_from' => $from,
  240. 'sheet_to' => $to,
  241. 'total_sheets' => count($printed),
  242. 'sheets' => $printed,
  243. 'fill' => $fill,
  244. ];
  245. }
  246. /**
  247. * 按模板占位符或默认表格生成每张 HTML
  248. *
  249. * @param array $plan
  250. * @param string $htmlTpl 前端传入的带 {field} 占位符的 HTML;空则用默认表格
  251. * @param array $schema 有原稿时附带套打文字坐标
  252. * @return array
  253. */
  254. public static function renderPages(array $plan, $htmlTpl = '', array $schema = [])
  255. {
  256. $pages = [];
  257. foreach ($plan['sheets'] as $sheet) {
  258. if ($htmlTpl !== '') {
  259. $html = self::replacePlaceholders($htmlTpl, $sheet['fill'], $plan['processes']);
  260. } else {
  261. $html = self::defaultSheetHtml($plan, $sheet);
  262. }
  263. $pages[] = [
  264. 'no' => $sheet['no'],
  265. 'total' => $sheet['total'],
  266. 'qty' => $sheet['qty'],
  267. 'title' => '流程单 ' . $sheet['no_text'],
  268. 'html' => $html,
  269. 'fill' => $sheet['fill'],
  270. 'print_items' => ($schema && self::isOverlay($schema))
  271. ? self::overlayPrintItems($schema, $sheet['fill'], $plan['processes'])
  272. : [],
  273. ];
  274. }
  275. return $pages;
  276. }
  277. /**
  278. * 读取当前模板版本排版
  279. * @param Template $template
  280. * @return string
  281. */
  282. public static function schemaJson(Template $template)
  283. {
  284. $versionId = (int)$template['current_version_id'];
  285. if ($versionId <= 0) {
  286. return '';
  287. }
  288. // 官方模板 version.factory_id=0,不能走工厂隔离,否则套打读不到格子。
  289. $json = Db::name('ps_template_version')->where('id', $versionId)->value('schema_json');
  290. return $json ? (string)$json : '';
  291. }
  292. /**
  293. * 从模板 schema 取出套打 HTML。
  294. * overlay:纸上已有模板样式,只按格子位置输出填写数据。
  295. * compose:空白纸自己搭的文字/线框/填写格。
  296. * @param string $schemaJson
  297. * @param string $fallbackImage
  298. * @param bool $includeBackground 仅对照用;套打输出不要底图
  299. * @return string
  300. */
  301. public static function htmlFromSchema($schemaJson, $fallbackImage = '', $includeBackground = false)
  302. {
  303. $schema = self::decodeSchema($schemaJson, $fallbackImage);
  304. if (self::isOverlay($schema) && !empty($schema['background'])) {
  305. return self::imageSheetHtml($schema['background'], $schema, $includeBackground);
  306. }
  307. if (!empty($schema['elements'])) {
  308. return self::composeSheetHtml($schema);
  309. }
  310. if (!empty($schema['html'])) {
  311. return (string)$schema['html'];
  312. }
  313. $raw = trim((string)$schemaJson);
  314. if ($raw !== '' && stripos($raw, '<') !== false && $raw[0] !== '{') {
  315. return $raw;
  316. }
  317. return self::composeSheetHtml($schema);
  318. }
  319. /**
  320. * 有原稿图的模板:打印纸上已有样式,只输出填写字段。
  321. * @param array $schema
  322. * @return bool
  323. */
  324. public static function isOverlay(array $schema)
  325. {
  326. if (trim((string)($schema['background'] ?? $schema['file_url'] ?? '')) === '') {
  327. return false;
  328. }
  329. return (bool)self::fillElements($schema);
  330. }
  331. /**
  332. * 套打时真正往纸上写字的格子。
  333. * @param array $schema
  334. * @return array
  335. */
  336. public static function fillElements(array $schema)
  337. {
  338. $out = [];
  339. foreach ($schema['elements'] ?? [] as $el) {
  340. if (!is_array($el)) {
  341. continue;
  342. }
  343. if (($el['type'] ?? '') === 'field' || ($el['role'] ?? '') === 'fill' || trim((string)($el['key'] ?? '')) !== '') {
  344. $out[] = $el;
  345. }
  346. }
  347. if (!$out && !empty($schema['cells']) && is_array($schema['cells'])) {
  348. foreach ($schema['cells'] as $el) {
  349. if (is_array($el) && trim((string)($el['key'] ?? '')) !== '') {
  350. $out[] = $el;
  351. }
  352. }
  353. }
  354. return $out;
  355. }
  356. /**
  357. * 把库里的 schema 规整成可编辑、可套打的结构。
  358. * @param string $schemaJson
  359. * @param string $fallbackImage
  360. * @return array
  361. */
  362. public static function decodeSchema($schemaJson, $fallbackImage = '')
  363. {
  364. $decoded = [];
  365. $raw = trim((string)$schemaJson);
  366. if ($raw !== '') {
  367. $tmp = json_decode($raw, true);
  368. if (is_array($tmp)) {
  369. $decoded = $tmp;
  370. }
  371. }
  372. $bg = '';
  373. foreach (['background', 'file_url', 'image', 'cover'] as $key) {
  374. if (!empty($decoded[$key])) {
  375. $bg = (string)$decoded[$key];
  376. break;
  377. }
  378. }
  379. if ($bg === '') {
  380. $bg = (string)$fallbackImage;
  381. }
  382. $legacy = false;
  383. $page = isset($decoded['page']) && is_array($decoded['page']) ? $decoded['page'] : [];
  384. $rawElements = self::collectLayoutItems($decoded);
  385. $elements = self::normalizeElements(
  386. $rawElements,
  387. (int)($page['image_width'] ?? 0),
  388. (int)($page['image_height'] ?? 0)
  389. );
  390. if (!$elements && !empty($decoded['fields'])) {
  391. $elements = self::elementsFromFields($decoded['fields']);
  392. $legacy = true;
  393. }
  394. $mode = (string)($decoded['mode'] ?? '');
  395. if ($bg !== '') {
  396. $mode = 'overlay';
  397. } elseif ($mode !== 'compose') {
  398. $mode = 'compose';
  399. }
  400. if (!$elements && $mode === 'compose' && $bg === '') {
  401. $elements = [
  402. self::makeElement('title', 'text', '生产流程单', '', '', 15, 4, 70, 6, 20, 'center', true, false),
  403. ];
  404. }
  405. $elements = self::resolveQrField(self::alignFlowCardFields(self::alignHeaderFields(self::bindPrintKeys($elements))));
  406. $fields = [];
  407. foreach ($elements as $el) {
  408. if (($el['type'] ?? '') === 'field' || ($el['key'] ?? '') !== '') {
  409. $fields[] = $el;
  410. }
  411. }
  412. $pageW = isset($page['w']) ? (float)$page['w'] : 210;
  413. $pageH = isset($page['h']) ? (float)$page['h'] : 297;
  414. if ($pageW <= 0) {
  415. $pageW = 210;
  416. }
  417. if ($pageH <= 0) {
  418. $pageH = 297;
  419. }
  420. $pageBg = self::color($page['background'] ?? ($decoded['page_background'] ?? '#ffffff'), false, '#ffffff');
  421. $cells = [];
  422. foreach ($elements as $el) {
  423. if (($el['key'] ?? '') !== ''
  424. || ($el['role'] ?? '') === 'fill'
  425. || ($el['type'] ?? '') === 'field'
  426. || preg_match('/二维码/', (string)($el['label'] ?? ''))
  427. ) {
  428. $cells[] = [
  429. 'id' => $el['id'],
  430. 'key' => $el['key'],
  431. 'label' => $el['label'],
  432. 'text' => $el['text'],
  433. 'x' => $el['x'],
  434. 'y' => $el['y'],
  435. 'w' => $el['w'],
  436. 'h' => $el['h'],
  437. ];
  438. }
  439. }
  440. return [
  441. 'name' => (string)($decoded['name'] ?? $decoded['title'] ?? ''),
  442. 'title' => (string)($decoded['title'] ?? $decoded['name'] ?? ''),
  443. 'mode' => $mode,
  444. 'page' => [
  445. 'w' => $pageW,
  446. 'h' => $pageH,
  447. 'paper_size' => (string)($decoded['paper_size'] ?? ($page['paper_size'] ?? '')),
  448. 'dpi' => isset($page['dpi']) ? (int)$page['dpi'] : 0,
  449. 'background' => $pageBg,
  450. 'image_width' => isset($page['image_width']) ? (int)$page['image_width'] : 0,
  451. 'image_height' => isset($page['image_height']) ? (int)$page['image_height'] : 0,
  452. ],
  453. 'background' => $bg,
  454. 'file_url' => $bg,
  455. 'mark_image' => (string)($decoded['mark_image'] ?? ''),
  456. 'elements' => $elements,
  457. 'cells' => $cells,
  458. 'fields' => $fields,
  459. 'legacy_overlay' => $legacy,
  460. 'processes' => isset($decoded['processes']) && is_array($decoded['processes']) ? $decoded['processes'] : [],
  461. 'html' => (string)($decoded['html'] ?? ''),
  462. 'source' => (string)($decoded['source'] ?? ''),
  463. ];
  464. }
  465. /**
  466. * 按百分比坐标输出填写字段。套打默认不含底图。
  467. * @param string $imageUrl
  468. * @param array $schema
  469. * @param bool $includeImage
  470. * @return string
  471. */
  472. public static function imageSheetHtml($imageUrl, array $schema = [], $includeImage = false)
  473. {
  474. $src = htmlspecialchars(self::printImageSrc($imageUrl), ENT_QUOTES, 'UTF-8');
  475. $fields = self::fillElements($schema);
  476. $page = isset($schema['page']) && is_array($schema['page']) ? $schema['page'] : [];
  477. $pageW = (float)($page['w'] ?? 210);
  478. $pageH = (float)($page['h'] ?? 297);
  479. if ($pageW <= 0) {
  480. $pageW = 210;
  481. }
  482. if ($pageH <= 0) {
  483. $pageH = 297;
  484. }
  485. $typeface = self::overlayTypeface($schema);
  486. $html = '<div style="position:relative;width:' . $pageW . 'mm;height:' . $pageH
  487. . 'mm;margin:0;overflow:hidden;background:transparent;font-family:' . $typeface['css']
  488. . ';font-size:' . $typeface['px'] . 'px;font-weight:normal;line-height:1.2;">';
  489. if ($includeImage && $src !== '') {
  490. $html .= '<img src="' . $src . '" style="position:absolute;left:0;top:0;width:100%;height:100%;border:0;display:block;" alt="流程单底图" />';
  491. }
  492. foreach ($fields as $field) {
  493. $key = trim((string)($field['key'] ?? ''));
  494. $text = (string)($field['text'] ?? '');
  495. if ($key === '' && $text === '') {
  496. continue;
  497. }
  498. $left = self::pct($field['x'] ?? 0);
  499. $top = self::pct($field['y'] ?? 0);
  500. $width = self::pct($field['w'] ?? 10);
  501. $height = self::pct($field['h'] ?? 4);
  502. $align = in_array($field['align'] ?? 'left', ['left', 'center', 'right'], true) ? $field['align'] : 'left';
  503. $color = self::color($field['color'] ?? '#111111', false, '#111111');
  504. $content = $key !== ''
  505. ? '{' . $key . '}'
  506. : htmlspecialchars($text, ENT_QUOTES, 'UTF-8');
  507. $html .= '<div style="position:absolute;left:' . $left . '%;top:' . $top
  508. . '%;width:' . $width . '%;height:' . $height . '%;font-size:' . $typeface['px']
  509. . 'px;font-weight:normal;font-style:normal;font-family:' . $typeface['css']
  510. . ';overflow:hidden;white-space:nowrap;text-align:' . $align
  511. . ';color:' . $color . ';background:transparent;">' . $content . '</div>';
  512. }
  513. $html .= '</div>';
  514. return $html;
  515. }
  516. /**
  517. * 套打文字项:按格子毫米坐标输出,不含底图。
  518. * @param array $schema
  519. * @param array $fill
  520. * @param array $processes
  521. * @return array
  522. */
  523. public static function overlayPrintItems(array $schema, array $fill, array $processes = [])
  524. {
  525. $page = isset($schema['page']) && is_array($schema['page']) ? $schema['page'] : [];
  526. $pageW = (float)($page['w'] ?? 210);
  527. $pageH = (float)($page['h'] ?? 297);
  528. if ($pageW <= 0) {
  529. $pageW = 210;
  530. }
  531. if ($pageH <= 0) {
  532. $pageH = 297;
  533. }
  534. $fields = self::fillElements($schema);
  535. $typeface = self::overlayTypeface($schema, $fields);
  536. $items = [];
  537. foreach ($fields as $field) {
  538. $key = trim((string)($field['key'] ?? ''));
  539. $text = $key !== ''
  540. ? self::fillText($key, $fill, $processes)
  541. : (string)($field['text'] ?? '');
  542. $text = trim($text);
  543. $xMm = self::pct($field['x'] ?? 0) / 100 * $pageW;
  544. $yMm = self::pct($field['y'] ?? 0) / 100 * $pageH;
  545. $wMm = max(8, self::pct($field['w'] ?? 10) / 100 * $pageW);
  546. $hMm = max(3, self::pct($field['h'] ?? 4) / 100 * $pageH);
  547. if ($key === 'qr') {
  548. $payload = self::qrPayload($fill);
  549. if ($payload === '') {
  550. continue;
  551. }
  552. $qrW = max(8, self::pct($field['w'] ?? 10) / 100 * $pageW);
  553. $qrH = max(8, self::pct($field['h'] ?? 4) / 100 * $pageH);
  554. $items[] = array_merge(
  555. ['type' => 'qrcode', 'text' => $payload],
  556. self::centerQrInCell($xMm, $yMm, $qrW, $qrH)
  557. );
  558. continue;
  559. }
  560. if ($text === '') {
  561. continue;
  562. }
  563. // 识版色块常贴着上框线,略向下收进格内,避免字压在表格线上。
  564. $padY = min(1.1, $hMm * 0.18);
  565. $padX = min(0.8, $wMm * 0.04);
  566. $yMm += $padY;
  567. $xMm += $padX;
  568. $hMm = max(3.2, $hMm - $padY * 1.4);
  569. $wMm = max(6, $wMm - $padX);
  570. $align = $field['align'] ?? 'left';
  571. $items[] = [
  572. 'top' => round($yMm, 2) . 'mm',
  573. 'left' => round($xMm, 2) . 'mm',
  574. 'width' => round($wMm, 2) . 'mm',
  575. 'height' => round($hMm, 2) . 'mm',
  576. 'text' => $text,
  577. 'font' => $typeface['family'],
  578. 'size' => $typeface['pt'],
  579. 'align' => $align === 'center' ? 2 : ($align === 'right' ? 3 : 1),
  580. 'bold' => $typeface['bold'],
  581. ];
  582. }
  583. return $items;
  584. }
  585. /**
  586. * 二维码居中于识版格子内(不做文字字段的内边距)。
  587. * @param float $xMm
  588. * @param float $yMm
  589. * @param float $wMm
  590. * @param float $hMm
  591. * @return array{top:string,left:string,width:string,height:string}
  592. */
  593. protected static function centerQrInCell($xMm, $yMm, $wMm, $hMm)
  594. {
  595. $wMm = max(8, (float)$wMm);
  596. $hMm = max(8, (float)$hMm);
  597. $inset = min(1.0, $wMm * 0.04, $hMm * 0.04);
  598. $innerW = max(8, $wMm - $inset * 2);
  599. $innerH = max(8, $hMm - $inset * 2);
  600. $size = min($innerW, $innerH);
  601. $left = $xMm + ($wMm - $size) / 2;
  602. $top = $yMm + ($hMm - $size) / 2;
  603. return [
  604. 'top' => round($top, 2) . 'mm',
  605. 'left' => round($left, 2) . 'mm',
  606. 'width' => round($size, 2) . 'mm',
  607. 'height' => round($size, 2) . 'mm',
  608. ];
  609. }
  610. /**
  611. * 同一张套打单上的填写文字用同一字体、同一字号、同一字重。
  612. * @param array $schema
  613. * @param array|null $fields
  614. * @return array{family:string,css:string,px:int,pt:int,bold:int}
  615. */
  616. protected static function overlayTypeface(array $schema, array $fields = null)
  617. {
  618. if ($fields === null) {
  619. $fields = self::fillElements($schema);
  620. }
  621. $sizes = [];
  622. foreach ($fields as $field) {
  623. if (trim((string)($field['key'] ?? '')) === 'qr') {
  624. continue;
  625. }
  626. $px = max(8, (int)($field['fontSize'] ?? 13));
  627. $sizes[] = $px;
  628. }
  629. $px = 13;
  630. if ($sizes) {
  631. $freq = array_count_values($sizes);
  632. arsort($freq);
  633. $px = (int)key($freq);
  634. }
  635. $pt = (int)max(8, min(12, round($px * 72 / 96)));
  636. return [
  637. 'family' => self::overlayFontFamily(),
  638. 'css' => self::overlayFontCss(),
  639. 'px' => $px,
  640. 'pt' => $pt,
  641. 'bold' => 0,
  642. ];
  643. }
  644. /**
  645. * 套打 overlay 专用字体(不影响 compose 排版)。
  646. * @return string
  647. */
  648. protected static function overlayFontFamily()
  649. {
  650. return '微软雅黑';
  651. }
  652. /**
  653. * @return string
  654. */
  655. protected static function overlayFontCss()
  656. {
  657. return "'Microsoft YaHei','微软雅黑',sans-serif";
  658. }
  659. /**
  660. * 流程单二维码:写入可被微信打开的信息页地址(纯文本微信会提示无法识别)。
  661. * @param array $fill
  662. * @return string
  663. */
  664. public static function qrPayload(array $fill)
  665. {
  666. $no = trim((string)($fill['workorder.no'] ?? ''));
  667. if ($no === '') {
  668. $no = trim((string)($fill['workorder.batch_no'] ?? ''));
  669. }
  670. $data = [
  671. 'no' => $no,
  672. 'name' => trim((string)($fill['product.name'] ?? '')),
  673. 'qty' => trim((string)($fill['qty_plan'] ?? ($fill['workorder.qty'] ?? ''))),
  674. 'plate' => trim((string)($fill['sheet_qty'] ?? '')),
  675. 'total' => trim((string)($fill['sheet.total'] ?? '')),
  676. 'cur' => trim((string)($fill['sheet.no'] ?? '')),
  677. ];
  678. $token = self::storeQrCard($data);
  679. if ($token !== '') {
  680. return self::qrBaseUrl() . '/psqr.php?k=' . $token;
  681. }
  682. $packed = rtrim(strtr(base64_encode(json_encode($data, JSON_UNESCAPED_UNICODE)), '+/', '-_'), '=');
  683. return self::qrBaseUrl() . '/psqr.php?d=' . $packed;
  684. }
  685. /**
  686. * 微信手机必须能访问该地址。可在 extra/ps.php 的 qr_base_url 写成 http://processSheet.com
  687. * @return string
  688. */
  689. protected static function qrBaseUrl()
  690. {
  691. $cfg = trim((string)(Config::get('site.ps_qr_base') ?: (Config::get('ps.qr_base_url') ?: '')));
  692. if ($cfg !== '') {
  693. return rtrim($cfg, '/');
  694. }
  695. try {
  696. $req = request();
  697. return rtrim($req->domain() . $req->root(), '/');
  698. } catch (\Exception $e) {
  699. return '';
  700. }
  701. }
  702. /**
  703. * 短码存流程单信息,二维码只带短地址,格子小也能扫清。
  704. * @param array $data
  705. * @return string
  706. */
  707. protected static function storeQrCard(array $data)
  708. {
  709. $token = substr(bin2hex(random_bytes(5)), 0, 10);
  710. $json = json_encode($data, JSON_UNESCAPED_UNICODE);
  711. $dirs = [
  712. RUNTIME_PATH . 'psqr' . DIRECTORY_SEPARATOR,
  713. ROOT_PATH . 'public' . DIRECTORY_SEPARATOR . 'uploads' . DIRECTORY_SEPARATOR . 'psqr' . DIRECTORY_SEPARATOR,
  714. ];
  715. foreach ($dirs as $dir) {
  716. if (!is_dir($dir)) {
  717. @mkdir($dir, 0755, true);
  718. }
  719. if (@file_put_contents($dir . $token . '.json', $json) !== false) {
  720. return $token;
  721. }
  722. }
  723. return '';
  724. }
  725. /**
  726. * @param string $key
  727. * @param array $fill
  728. * @param array $processes
  729. * @return string
  730. */
  731. public static function fillText($key, array $fill, array $processes = [])
  732. {
  733. if (preg_match('/^process\.(\d+)(?:\.(name|code))?$/', $key, $m)) {
  734. $idx = (int)$m[1] - 1;
  735. if (!isset($processes[$idx])) {
  736. return '';
  737. }
  738. $attr = !empty($m[2]) ? $m[2] : 'name';
  739. return (string)($processes[$idx][$attr] ?? '');
  740. }
  741. return isset($fill[$key]) ? (string)$fill[$key] : '';
  742. }
  743. /**
  744. * @param string $path
  745. * @return string
  746. */
  747. public static function printImageSrc($path)
  748. {
  749. $path = trim((string)$path);
  750. if ($path === '') {
  751. return '';
  752. }
  753. if (preg_match('#^(https?:)?//#i', $path) || strpos($path, 'data:') === 0) {
  754. return $path;
  755. }
  756. return cdnurl($path, true);
  757. }
  758. /**
  759. * 白底排版:把拆出的文字、填写格、线框画成可打印 HTML。
  760. * @param array $schema
  761. * @return string
  762. */
  763. public static function composeSheetHtml(array $schema)
  764. {
  765. $page = isset($schema['page']) && is_array($schema['page']) ? $schema['page'] : ['w' => 210, 'h' => 297];
  766. $pageW = (float)($page['w'] ?? 210);
  767. $pageH = (float)($page['h'] ?? 297);
  768. if ($pageW <= 0) {
  769. $pageW = 210;
  770. }
  771. if ($pageH <= 0) {
  772. $pageH = 297;
  773. }
  774. $elements = self::normalizeElements($schema['elements'] ?? []);
  775. $pageBg = self::color($page['background'] ?? '#ffffff', false, '#ffffff');
  776. $html = '<div style="position:relative;width:' . $pageW . 'mm;height:' . $pageH
  777. . 'mm;margin:0;background:' . $pageBg . ';overflow:hidden;font-family:SimSun,serif;color:#111;line-height:1.15;">';
  778. foreach ($elements as $el) {
  779. $html .= self::elementHtml($el);
  780. }
  781. $html .= '</div>';
  782. return $html;
  783. }
  784. /**
  785. * 合并 cells/elements 等识版结果。
  786. * @param array $decoded
  787. * @return array
  788. */
  789. public static function collectLayoutItems(array $decoded)
  790. {
  791. if (!empty($decoded['cells']) && is_array($decoded['cells'])) {
  792. return array_values($decoded['cells']);
  793. }
  794. $list = [];
  795. foreach (['elements', 'blocks', 'items', 'nodes'] as $key) {
  796. if (empty($decoded[$key]) || !is_array($decoded[$key])) {
  797. continue;
  798. }
  799. foreach ($decoded[$key] as $item) {
  800. $list[] = $item;
  801. }
  802. }
  803. return $list;
  804. }
  805. /**
  806. * @param mixed $items
  807. * @param int $imageW
  808. * @param int $imageH
  809. * @return array
  810. */
  811. public static function normalizeElements($items, $imageW = 0, $imageH = 0)
  812. {
  813. if (!is_array($items)) {
  814. return [];
  815. }
  816. list($sx, $sy) = self::axisScale($items, $imageW, $imageH);
  817. $list = [];
  818. $i = 0;
  819. foreach ($items as $item) {
  820. $i++;
  821. if (is_string($item) && trim($item) !== '') {
  822. $key = self::mapFieldKey($item);
  823. $list[] = self::buildElement([
  824. 'id' => 'e' . $i,
  825. 'type' => $key !== '' ? 'field' : 'text',
  826. 'text' => $key !== '' ? '' : $item,
  827. 'key' => $key,
  828. 'x' => 8,
  829. 'y' => 6 + ($i - 1) * 5,
  830. 'w' => 30,
  831. 'h' => 4,
  832. ], 1);
  833. continue;
  834. }
  835. if (!is_array($item)) {
  836. continue;
  837. }
  838. $item['id'] = $item['id'] ?? ('e' . $i);
  839. foreach (['x' => $sx, 'w' => $sx, 'width' => $sx, 'y' => $sy, 'h' => $sy, 'height' => $sy] as $k => $scale) {
  840. if (isset($item[$k]) && is_numeric($item[$k])) {
  841. $item[$k] = (float)$item[$k] * $scale;
  842. }
  843. }
  844. $list[] = self::buildElement($item, 1);
  845. }
  846. return self::pruneNoise($list);
  847. }
  848. /**
  849. * @param array $items
  850. * @param int $imageW
  851. * @param int $imageH
  852. * @return array [sx, sy]
  853. */
  854. protected static function axisScale($items, $imageW = 0, $imageH = 0)
  855. {
  856. $maxX = 0;
  857. $maxY = 0;
  858. foreach ($items as $item) {
  859. if (!is_array($item)) {
  860. continue;
  861. }
  862. $x = (float)($item['x'] ?? 0);
  863. $y = (float)($item['y'] ?? 0);
  864. $w = (float)($item['w'] ?? ($item['width'] ?? 0));
  865. $h = (float)($item['h'] ?? ($item['height'] ?? 0));
  866. $maxX = max($maxX, $x, $x + $w, $w);
  867. $maxY = max($maxY, $y, $y + $h, $h);
  868. }
  869. if ($maxX <= 1.5 && $maxY <= 1.5 && ($maxX > 0 || $maxY > 0)) {
  870. return [100, 100];
  871. }
  872. if ($maxX > 110 || $maxY > 110) {
  873. $denX = max((float)$imageW, $maxX, 1);
  874. $denY = max((float)$imageH, $maxY, 1);
  875. return [100 / $denX, 100 / $denY];
  876. }
  877. return [1, 1];
  878. }
  879. /**
  880. * 模型常刷出成百条废线,只保留能当表格分隔的。
  881. * @param array $list
  882. * @return array
  883. */
  884. protected static function pruneNoise(array $list)
  885. {
  886. $lines = [];
  887. $others = [];
  888. foreach ($list as $el) {
  889. if (($el['type'] ?? '') === 'line') {
  890. $lines[] = $el;
  891. } else {
  892. $others[] = $el;
  893. }
  894. }
  895. if (count($lines) <= 36) {
  896. return array_merge($others, $lines);
  897. }
  898. $kept = [];
  899. foreach ($lines as $el) {
  900. if (max((float)$el['w'], (float)$el['h']) >= 8) {
  901. $kept[] = $el;
  902. }
  903. }
  904. if (count($kept) > 36) {
  905. $kept = array_slice($kept, 0, 36);
  906. }
  907. return array_merge($others, $kept);
  908. }
  909. /**
  910. * @param mixed $fields
  911. * @return array
  912. */
  913. public static function elementsFromFields($fields)
  914. {
  915. $list = [];
  916. foreach (self::normalizeOverlayFields($fields, false) as $field) {
  917. $type = $field['key'] !== '' ? 'field' : 'text';
  918. $list[] = self::buildElement([
  919. 'id' => $field['id'],
  920. 'type' => $type,
  921. 'text' => $field['text'],
  922. 'key' => $field['key'],
  923. 'label' => $field['label'],
  924. 'x' => $field['x'],
  925. 'y' => $field['y'],
  926. 'w' => $field['w'],
  927. 'h' => $field['h'],
  928. 'fontSize' => $field['fontSize'],
  929. 'align' => $field['align'],
  930. 'border' => $type === 'field',
  931. ], 1);
  932. }
  933. return $list;
  934. }
  935. /**
  936. * 套打可绑定字段
  937. * @return array
  938. */
  939. public static function fieldCatalog()
  940. {
  941. return [
  942. ['key' => 'factory.name', 'label' => '工厂名称'],
  943. ['key' => 'factory.code', 'label' => '工厂编码'],
  944. ['key' => 'factory.contact', 'label' => '联系人'],
  945. ['key' => 'factory.mobile', 'label' => '工厂电话'],
  946. ['key' => 'product.code', 'label' => '产品编码'],
  947. ['key' => 'product.name', 'label' => '产品名称'],
  948. ['key' => 'product.spec', 'label' => '规格'],
  949. ['key' => 'product.unit', 'label' => '单位'],
  950. ['key' => 'workorder.no', 'label' => '工单号'],
  951. ['key' => 'workorder.qty', 'label' => '工单数量'],
  952. ['key' => 'qty_plan', 'label' => '总数量'],
  953. ['key' => 'workorder.qty_plan', 'label' => '计划总量'],
  954. ['key' => 'workorder.batch_no', 'label' => '批次'],
  955. ['key' => 'workorder.pack_no', 'label' => '码包号'],
  956. ['key' => 'qr', 'label' => '二维码区域'],
  957. ['key' => 'workorder.plan_date', 'label' => '计划日期'],
  958. ['key' => 'workorder.customer', 'label' => '客户'],
  959. ['key' => 'workorder.spec', 'label' => '工单规格'],
  960. ['key' => 'workorder.remark', 'label' => '备注'],
  961. ['key' => 'sheet.qty', 'label' => '本张数量'],
  962. ['key' => 'sheet.no_text', 'label' => '页码'],
  963. ['key' => 'process_names', 'label' => '工序'],
  964. ['key' => 'operator', 'label' => '操作员'],
  965. ['key' => 'print_time', 'label' => '打印时间'],
  966. ];
  967. }
  968. /**
  969. * @param mixed $fields
  970. * @return array
  971. */
  972. public static function normalizeOverlayFields($fields, $fillDefault = true)
  973. {
  974. $catalog = [];
  975. foreach (self::fieldCatalog() as $item) {
  976. $catalog[$item['key']] = $item['label'];
  977. }
  978. $list = [];
  979. if (!is_array($fields)) {
  980. $fields = [];
  981. }
  982. $scale = self::coordScale($fields);
  983. $i = 0;
  984. $y = 8;
  985. foreach ($fields as $item) {
  986. $i++;
  987. if (is_string($item) && trim($item) !== '') {
  988. $key = self::mapFieldKey($item);
  989. $list[] = self::makeField('f' . $i, $key, $catalog[$key] ?? $item, 52, $y, 30, 3.6);
  990. $y += 5.2;
  991. continue;
  992. }
  993. if (!is_array($item)) {
  994. continue;
  995. }
  996. $key = self::mapFieldKey($item['key'] ?? $item['k'] ?? $item['name'] ?? '');
  997. $label = trim((string)($item['label'] ?? ''));
  998. if ($label === '' && $key !== '' && isset($catalog[$key])) {
  999. $label = $catalog[$key];
  1000. }
  1001. $id = (string)($item['id'] ?? ('f' . $i));
  1002. $list[] = self::makeField(
  1003. $id,
  1004. $key,
  1005. $label,
  1006. isset($item['x']) ? $item['x'] * $scale : 52,
  1007. isset($item['y']) ? $item['y'] * $scale : $y,
  1008. isset($item['w']) ? $item['w'] * $scale : (isset($item['width']) ? $item['width'] * $scale : 30),
  1009. isset($item['h']) ? $item['h'] * $scale : (isset($item['height']) ? $item['height'] * $scale : 3.6),
  1010. isset($item['fontSize']) ? $item['fontSize'] : 13,
  1011. isset($item['align']) ? $item['align'] : 'left',
  1012. isset($item['text']) ? $item['text'] : ''
  1013. );
  1014. $y += 5.2;
  1015. }
  1016. if (!$list && $fillDefault) {
  1017. foreach (['product.name', 'product.code', 'workorder.no', 'sheet.qty', 'workorder.plan_date', 'operator'] as $idx => $key) {
  1018. $list[] = self::makeField('f' . ($idx + 1), $key, $catalog[$key], 52, 8 + $idx * 5.2, 30, 3.6);
  1019. }
  1020. }
  1021. return $list;
  1022. }
  1023. /**
  1024. * @param string $name
  1025. * @return string
  1026. */
  1027. public static function mapFieldKey($name)
  1028. {
  1029. $name = trim((string)$name);
  1030. $alias = [
  1031. '产品名称' => 'product.name', '产品' => 'product.name', '品名' => 'product.name', '名称' => 'product.name',
  1032. '产品编码' => 'product.code', '编码' => 'product.code', '货号' => 'product.code',
  1033. '规格' => 'product.spec', '单位' => 'product.unit',
  1034. '工厂' => 'factory.name', '厂名' => 'factory.name',
  1035. '工单号' => 'workorder.no', '单号' => 'workorder.no', '流程单号' => 'workorder.no',
  1036. '订单号' => 'workorder.no', '销售订单号' => 'workorder.no', '制单号' => 'workorder.no',
  1037. '数量' => 'qty_plan', '总数量' => 'qty_plan', '总投料数' => 'qty_plan', '计划总量' => 'qty_plan',
  1038. '本张数量' => 'sheet.qty', '投料' => 'sheet.qty',
  1039. '批次' => 'workorder.batch_no', '生产批次号' => 'workorder.batch_no',
  1040. '码包号' => 'workorder.pack_no', '包号' => 'workorder.pack_no',
  1041. '客户' => 'workorder.customer',
  1042. '日期' => 'workorder.plan_date', '计划日' => 'workorder.plan_date',
  1043. '备注' => 'workorder.remark', '工单规格' => 'workorder.spec', '操作员' => 'operator', '时间' => 'print_time',
  1044. '页码' => 'sheet.no_text', '工序' => 'process_names',
  1045. ];
  1046. if (preg_match('/^二维码/', $name) || $name === 'QR' || $name === 'qr') {
  1047. return 'qr';
  1048. }
  1049. if (preg_match('/^工序\s*([一二三四五六七八九十百零〇两\d]+)$/u', $name, $m)) {
  1050. $n = self::chineseToInt($m[1]);
  1051. if ($n > 0) {
  1052. return 'process.' . $n . '.name';
  1053. }
  1054. }
  1055. if (preg_match('/^process\.\d+\.(name|code)$/', $name)) {
  1056. return $name;
  1057. }
  1058. if (isset($alias[$name])) {
  1059. return $alias[$name];
  1060. }
  1061. $allowed = array_column(self::fieldCatalog(), 'key');
  1062. return in_array($name, $allowed, true) ? $name : '';
  1063. }
  1064. /**
  1065. * 把「工序一」等格子绑到 process.N.name,把总数量绑到计划总量。
  1066. * @param array $elements
  1067. * @return array
  1068. */
  1069. protected static function bindPrintKeys(array $elements)
  1070. {
  1071. $seq = 1;
  1072. foreach ($elements as &$el) {
  1073. if (!is_array($el)) {
  1074. continue;
  1075. }
  1076. $label = trim((string)($el['label'] ?? ''));
  1077. $key = trim((string)($el['key'] ?? ''));
  1078. if ($label !== '') {
  1079. $mapped = self::mapFieldKey($label);
  1080. if ($mapped !== '') {
  1081. $key = $mapped;
  1082. }
  1083. }
  1084. if ($key === 'process_names' || preg_match('/^工序/', $label)) {
  1085. if (!preg_match('/^process\.\d+/', $key)) {
  1086. $n = 0;
  1087. if (preg_match('/^工序\s*([一二三四五六七八九十百零〇两\d]+)$/u', $label, $m)) {
  1088. $n = self::chineseToInt($m[1]);
  1089. }
  1090. $key = 'process.' . ($n > 0 ? $n : $seq) . '.name';
  1091. }
  1092. }
  1093. if (preg_match('/^process\.(\d+)/', $key, $m)) {
  1094. $seq = max($seq, (int)$m[1] + 1);
  1095. }
  1096. if (in_array($label, ['总数量', '总投料数', '计划总量'], true)) {
  1097. $key = 'qty_plan';
  1098. }
  1099. $el['key'] = $key;
  1100. }
  1101. unset($el);
  1102. return $elements;
  1103. }
  1104. /**
  1105. * 页眉:最宽的横条是备注;顶部一排三个小格是批次 / 订单 / 码包号。
  1106. * @param array $elements
  1107. * @return array
  1108. */
  1109. protected static function alignHeaderFields(array $elements)
  1110. {
  1111. $wideIdx = null;
  1112. $wideW = 48;
  1113. $topSmall = [];
  1114. foreach ($elements as $idx => $el) {
  1115. if (!is_array($el)) {
  1116. continue;
  1117. }
  1118. $w = (float)($el['w'] ?? 0);
  1119. $h = (float)($el['h'] ?? 0);
  1120. $y = (float)($el['y'] ?? 0);
  1121. $x = (float)($el['x'] ?? 0);
  1122. if ($w > $wideW && $h < 10) {
  1123. $wideW = $w;
  1124. $wideIdx = $idx;
  1125. }
  1126. if ($y < 22 && $w >= 10 && $w <= 28 && $h >= 2.5 && $h <= 8) {
  1127. $topSmall[] = $idx;
  1128. }
  1129. }
  1130. if ($wideIdx !== null) {
  1131. $elements[$wideIdx]['key'] = 'workorder.remark';
  1132. $elements[$wideIdx]['label'] = '备注';
  1133. }
  1134. usort($topSmall, function ($a, $b) use ($elements) {
  1135. return ((float)$elements[$a]['x'] <=> (float)$elements[$b]['x']);
  1136. });
  1137. if (count($topSmall) >= 3) {
  1138. $keys = [
  1139. ['workorder.batch_no', '生产批次号'],
  1140. ['workorder.no', '订单号'],
  1141. ['workorder.pack_no', '码包号'],
  1142. ];
  1143. for ($i = 0; $i < 3; $i++) {
  1144. $idx = $topSmall[$i];
  1145. $elements[$idx]['key'] = $keys[$i][0];
  1146. $elements[$idx]['label'] = $keys[$i][1];
  1147. }
  1148. }
  1149. return $elements;
  1150. }
  1151. /**
  1152. * 流程卡右侧大块空白通常是二维码区;优先绑定面积最大的右侧格子。
  1153. * @param array $elements
  1154. * @return array
  1155. */
  1156. protected static function resolveQrField(array $elements)
  1157. {
  1158. $reserved = [
  1159. 'product.name', 'product.code', 'product.spec', 'qty_plan',
  1160. 'workorder.batch_no', 'workorder.no', 'workorder.pack_no', 'workorder.remark',
  1161. 'sheet.qty', 'sheet.no_text', 'operator', 'print_time', 'process_names',
  1162. ];
  1163. $qrIndices = [];
  1164. $bestIdx = null;
  1165. $bestArea = 0;
  1166. foreach ($elements as $idx => $el) {
  1167. if (!is_array($el)) {
  1168. continue;
  1169. }
  1170. $key = trim((string)($el['key'] ?? ''));
  1171. $label = trim((string)($el['label'] ?? ''));
  1172. if ($key === 'qr' || preg_match('/二维码/u', $label)) {
  1173. $qrIndices[] = $idx;
  1174. }
  1175. $x = (float)($el['x'] ?? 0);
  1176. $y = (float)($el['y'] ?? 0);
  1177. $w = (float)($el['w'] ?? 0);
  1178. $h = (float)($el['h'] ?? 0);
  1179. if ($x < 30 || $w < 12 || $h < 12) {
  1180. continue;
  1181. }
  1182. if ($y < 6 && $h < 16) {
  1183. continue;
  1184. }
  1185. if ($key !== '' && ($key === 'qr' || preg_match('/^process\./', $key) || in_array($key, $reserved, true))) {
  1186. if ($key !== 'qr') {
  1187. continue;
  1188. }
  1189. }
  1190. $area = $w * $h;
  1191. if ($area > $bestArea) {
  1192. $bestArea = $area;
  1193. $bestIdx = $idx;
  1194. }
  1195. }
  1196. if ($bestIdx === null) {
  1197. return $elements;
  1198. }
  1199. foreach ($qrIndices as $idx) {
  1200. if ($idx === $bestIdx) {
  1201. continue;
  1202. }
  1203. $area = (float)($elements[$idx]['w'] ?? 0) * (float)($elements[$idx]['h'] ?? 0);
  1204. if ($area < $bestArea * 0.75) {
  1205. $elements[$idx]['key'] = '';
  1206. }
  1207. }
  1208. $elements[$bestIdx]['key'] = 'qr';
  1209. $elements[$bestIdx]['label'] = '二维码区域';
  1210. $elements[$bestIdx]['type'] = 'field';
  1211. $elements[$bestIdx]['role'] = 'fill';
  1212. return $elements;
  1213. }
  1214. /**
  1215. * 产品工序流程卡:色块标签常把「产品名称 / 总投料数」和工序列认反。
  1216. * 按位置纠正——工序名称列是左侧一叠格子,其上一行从左到右是名称、总投料数、规格。
  1217. * @param array $elements
  1218. * @return array
  1219. */
  1220. protected static function alignFlowCardFields(array $elements)
  1221. {
  1222. $candidates = [];
  1223. foreach ($elements as $idx => $el) {
  1224. if (!is_array($el)) {
  1225. continue;
  1226. }
  1227. $w = (float)($el['w'] ?? 0);
  1228. $h = (float)($el['h'] ?? 0);
  1229. if ($w > 45 || $h > 10) {
  1230. continue;
  1231. }
  1232. if (($el['key'] ?? '') === '' && ($el['role'] ?? '') !== 'fill' && ($el['type'] ?? '') !== 'field') {
  1233. continue;
  1234. }
  1235. $candidates[] = $idx;
  1236. }
  1237. if (count($candidates) < 8) {
  1238. return $elements;
  1239. }
  1240. $byX = [];
  1241. foreach ($candidates as $idx) {
  1242. $xBand = (int)round((float)$elements[$idx]['x']);
  1243. $byX[$xBand][] = $idx;
  1244. }
  1245. $stack = [];
  1246. foreach ($byX as $ids) {
  1247. if (count($ids) < 8) {
  1248. continue;
  1249. }
  1250. usort($ids, function ($a, $b) use ($elements) {
  1251. return ((float)$elements[$a]['y'] <=> (float)$elements[$b]['y']);
  1252. });
  1253. if (count($ids) > count($stack)) {
  1254. $stack = $ids;
  1255. }
  1256. }
  1257. if (count($stack) < 8) {
  1258. return $elements;
  1259. }
  1260. $stackTop = (float)$elements[$stack[0]]['y'];
  1261. $headerRow = [];
  1262. foreach ($candidates as $idx) {
  1263. if (in_array($idx, $stack, true)) {
  1264. continue;
  1265. }
  1266. $y = (float)$elements[$idx]['y'];
  1267. $h = (float)$elements[$idx]['h'];
  1268. if ($y + $h <= $stackTop + 0.8 && $y >= $stackTop - 12) {
  1269. $headerRow[] = $idx;
  1270. }
  1271. }
  1272. usort($headerRow, function ($a, $b) use ($elements) {
  1273. return ((float)$elements[$a]['x'] <=> (float)$elements[$b]['x']);
  1274. });
  1275. if (count($headerRow) >= 2) {
  1276. $elements[$headerRow[0]]['key'] = 'product.name';
  1277. $elements[$headerRow[0]]['label'] = '产品名称';
  1278. $elements[$headerRow[1]]['key'] = 'qty_plan';
  1279. $elements[$headerRow[1]]['label'] = '总投料数';
  1280. if (isset($headerRow[2])) {
  1281. $elements[$headerRow[2]]['key'] = 'product.spec';
  1282. $elements[$headerRow[2]]['label'] = '规格';
  1283. }
  1284. }
  1285. $seq = 1;
  1286. foreach ($stack as $idx) {
  1287. $elements[$idx]['key'] = 'process.' . $seq . '.name';
  1288. $elements[$idx]['label'] = '工序' . $seq;
  1289. $seq++;
  1290. }
  1291. return $elements;
  1292. }
  1293. /**
  1294. * @param string $text
  1295. * @return int
  1296. */
  1297. public static function chineseToInt($text)
  1298. {
  1299. $text = trim((string)$text);
  1300. if ($text === '') {
  1301. return 0;
  1302. }
  1303. if (preg_match('/^\d+$/', $text)) {
  1304. return (int)$text;
  1305. }
  1306. $d = ['零' => 0, '〇' => 0, '一' => 1, '二' => 2, '两' => 2, '三' => 3, '四' => 4, '五' => 5, '六' => 6, '七' => 7, '八' => 8, '九' => 9];
  1307. if (isset($d[$text])) {
  1308. return $d[$text];
  1309. }
  1310. if ($text === '十') {
  1311. return 10;
  1312. }
  1313. if (preg_match('/^十([一二三四五六七八九])$/u', $text, $m)) {
  1314. return 10 + $d[$m[1]];
  1315. }
  1316. if (preg_match('/^([一二三四五六七八九])十([一二三四五六七八九])?$/u', $text, $m)) {
  1317. return $d[$m[1]] * 10 + (!empty($m[2]) ? $d[$m[2]] : 0);
  1318. }
  1319. return 0;
  1320. }
  1321. protected static function makeField($id, $key, $label, $x, $y, $w, $h, $fontSize = 13, $align = 'left', $text = '')
  1322. {
  1323. return [
  1324. 'id' => (string)$id,
  1325. 'key' => (string)$key,
  1326. 'label' => (string)$label,
  1327. 'x' => self::pct($x),
  1328. 'y' => self::pct($y),
  1329. 'w' => max(4, self::pct($w)),
  1330. 'h' => max(2, self::pct($h)),
  1331. 'fontSize' => max(8, (int)$fontSize),
  1332. 'align' => $align === 'center' || $align === 'right' ? $align : 'left',
  1333. 'text' => (string)$text,
  1334. ];
  1335. }
  1336. protected static function makeElement($id, $type, $text, $key, $label, $x, $y, $w, $h, $fontSize = 13, $align = 'left', $bold = false, $border = false)
  1337. {
  1338. return self::buildElement([
  1339. 'id' => $id,
  1340. 'type' => $type,
  1341. 'text' => $text,
  1342. 'key' => $key,
  1343. 'label' => $label,
  1344. 'x' => $x,
  1345. 'y' => $y,
  1346. 'w' => $w,
  1347. 'h' => $h,
  1348. 'fontSize' => $fontSize,
  1349. 'align' => $align,
  1350. 'bold' => $bold,
  1351. 'border' => $border,
  1352. ], 1);
  1353. }
  1354. /**
  1355. * 把模型输出的一块规整成可还原、可套打的元素。
  1356. * @param array $item
  1357. * @param float $scale
  1358. * @return array
  1359. */
  1360. public static function buildElement(array $item, $scale = 1)
  1361. {
  1362. $style = isset($item['style']) && is_array($item['style']) ? $item['style'] : [];
  1363. $pick = function ($keys, $default = null) use ($item, $style) {
  1364. foreach ((array)$keys as $key) {
  1365. if (array_key_exists($key, $item) && $item[$key] !== '' && $item[$key] !== null) {
  1366. return $item[$key];
  1367. }
  1368. if (array_key_exists($key, $style) && $style[$key] !== '' && $style[$key] !== null) {
  1369. return $style[$key];
  1370. }
  1371. }
  1372. return $default;
  1373. };
  1374. $type = strtolower((string)$pick(['type', 'kind'], ''));
  1375. $typeMap = [
  1376. 'label' => 'text', 'title' => 'text', 'static' => 'text', 'word' => 'text',
  1377. 'input' => 'field', 'blank' => 'field', 'fill' => 'field', 'placeholder' => 'field',
  1378. 'hr' => 'line', 'vline' => 'line', 'hline' => 'line', 'rule' => 'line',
  1379. 'rect' => 'box', 'border' => 'box', 'frame' => 'box',
  1380. 'td' => 'cell', 'gridcell' => 'cell',
  1381. ];
  1382. if (isset($typeMap[$type])) {
  1383. $type = $typeMap[$type];
  1384. }
  1385. if ($type === '') {
  1386. $type = 'cell';
  1387. }
  1388. $key = self::mapFieldKey((string)$pick(['key', 'k', 'bind'], ''));
  1389. $text = (string)$pick(['text', 'content', 'value'], '');
  1390. $role = strtolower((string)$pick(['role'], ''));
  1391. if ($role === 'fill' || $role === 'input' || $role === 'blank') {
  1392. $role = 'fill';
  1393. } elseif ($role === 'label' || $role === 'header' || $role === 'title') {
  1394. $role = 'label';
  1395. } else {
  1396. $role = '';
  1397. }
  1398. if ($type === '' && $key !== '') {
  1399. $type = 'field';
  1400. }
  1401. $forceBorder = false;
  1402. if ($type === 'cell' && ($role === 'fill' || $key !== '')) {
  1403. $type = 'field';
  1404. $forceBorder = true;
  1405. }
  1406. if ($type === 'cell') {
  1407. $type = 'text';
  1408. $forceBorder = true;
  1409. }
  1410. if ($type === '' && $text !== '') {
  1411. $type = 'text';
  1412. }
  1413. if ($type === '') {
  1414. $type = 'text';
  1415. }
  1416. if ($type === 'text' && $key !== '' && trim($text) === '') {
  1417. $type = 'field';
  1418. }
  1419. if ($role === '') {
  1420. $role = ($type === 'field' || $key !== '') ? 'fill' : 'label';
  1421. }
  1422. $label = trim((string)$pick(['label'], ''));
  1423. if ($label === '' && $key !== '') {
  1424. foreach (self::fieldCatalog() as $cat) {
  1425. if ($cat['key'] === $key) {
  1426. $label = $cat['label'];
  1427. break;
  1428. }
  1429. }
  1430. }
  1431. $minW = $type === 'line' ? 0.2 : 1.2;
  1432. $minH = $type === 'line' ? 0.2 : 1.2;
  1433. $borderFlag = $pick(['border'], null);
  1434. $borders = $pick(['borders'], '');
  1435. $borderWidth = (int)$pick(['borderWidth', 'border_width'], 0);
  1436. if ($borderFlag === null) {
  1437. $borderFlag = $forceBorder || in_array($type, ['field', 'box'], true) || $borders !== '' || $borderWidth > 0;
  1438. }
  1439. $borderFlag = (bool)$borderFlag;
  1440. if ($borderFlag && $borderWidth < 1) {
  1441. $borderWidth = 1;
  1442. }
  1443. if (!$borderFlag) {
  1444. $borderWidth = 0;
  1445. $borders = '';
  1446. } elseif ($borders === '' || $borders === null) {
  1447. $borders = 'tblr';
  1448. }
  1449. $bg = $pick(['background', 'bg', 'fillColor'], $type === 'field' ? '#ffffff' : 'transparent');
  1450. if ($bg === 'fill' || $bg === 'field') {
  1451. $bg = '#ffffff';
  1452. }
  1453. return [
  1454. 'id' => (string)$pick(['id'], 'e1'),
  1455. 'type' => in_array($type, ['text', 'field', 'line', 'box'], true) ? $type : 'text',
  1456. 'role' => $role,
  1457. 'text' => $text,
  1458. 'key' => $key,
  1459. 'label' => $label,
  1460. 'x' => self::pct($pick(['x'], 8) * $scale),
  1461. 'y' => self::pct($pick(['y'], 6) * $scale),
  1462. 'w' => max($minW, self::pct(($pick(['w', 'width'], $type === 'line' ? 80 : 18)) * $scale)),
  1463. 'h' => max($minH, self::pct(($pick(['h', 'height'], $type === 'line' ? 0.4 : 4)) * $scale)),
  1464. 'fontSize' => max(8, (int)$pick(['fontSize', 'size', 'font_size'], 13)),
  1465. 'align' => in_array($pick(['align', 'textAlign'], 'left'), ['center', 'right'], true) ? $pick(['align', 'textAlign'], 'left') : 'left',
  1466. 'bold' => (!empty($item['bold']) || ($style['bold'] ?? false) || $pick(['fontWeight'], '') === 'bold') ? 1 : 0,
  1467. 'border' => $borderFlag ? 1 : 0,
  1468. 'color' => self::color($pick(['color', 'textColor'], '#111111'), false, '#111111'),
  1469. 'background' => self::color($bg, true, 'transparent'),
  1470. 'borderColor' => self::color($pick(['borderColor', 'stroke', 'lineColor'], '#000000'), false, '#000000'),
  1471. 'borderWidth' => $borderWidth,
  1472. 'borders' => is_array($borders) ? self::bordersToString($borders) : strtolower((string)$borders),
  1473. 'fontFamily' => self::fontFamily($pick(['fontFamily', 'font', 'font_family'], 'SimSun')),
  1474. 'row' => (int)$pick(['row', 'r'], 0),
  1475. 'col' => (int)$pick(['col', 'c'], 0),
  1476. ];
  1477. }
  1478. protected static function elementHtml(array $el)
  1479. {
  1480. $left = self::pct($el['x']);
  1481. $top = self::pct($el['y']);
  1482. $width = self::pct($el['w']);
  1483. $height = self::pct($el['h']);
  1484. $type = $el['type'] ?? 'text';
  1485. $color = self::color($el['color'] ?? '#111111', false, '#111111');
  1486. $bg = self::color($el['background'] ?? 'transparent', true, 'transparent');
  1487. $base = 'position:absolute;left:' . $left . '%;top:' . $top . '%;width:' . $width . '%;height:' . $height
  1488. . '%;box-sizing:border-box;';
  1489. if ($type === 'line') {
  1490. $vertical = $height > $width;
  1491. $bc = self::color($el['borderColor'] ?? $color, false, '#000000');
  1492. $bw = max(1, (int)($el['borderWidth'] ?? 1));
  1493. $style = $base . ($vertical
  1494. ? ('border-left:' . $bw . 'px solid ' . $bc . ';')
  1495. : ('border-top:' . $bw . 'px solid ' . $bc . ';'));
  1496. return '<div style="' . $style . '"></div>';
  1497. }
  1498. $border = self::borderCss($el);
  1499. if ($type === 'box' && $border === '') {
  1500. $border = 'border:1px solid ' . self::color($el['borderColor'] ?? '#000000', false, '#000000') . ';';
  1501. }
  1502. $size = max(8, (int)$el['fontSize']);
  1503. $align = in_array($el['align'], ['left', 'center', 'right'], true) ? $el['align'] : 'left';
  1504. $justify = $align === 'center' ? 'center' : ($align === 'right' ? 'flex-end' : 'flex-start');
  1505. $weight = !empty($el['bold']) ? 'bold' : 'normal';
  1506. $font = htmlspecialchars(self::fontFamily($el['fontFamily'] ?? 'SimSun'), ENT_QUOTES, 'UTF-8');
  1507. $pad = $border !== '' ? 'padding:1px 4px;' : '';
  1508. $content = '';
  1509. if (($type === 'field' || ($el['role'] ?? '') === 'fill') && ($el['key'] ?? '') !== '') {
  1510. $content = '{' . $el['key'] . '}';
  1511. } else {
  1512. $content = htmlspecialchars((string)($el['text'] ?? ''), ENT_QUOTES, 'UTF-8');
  1513. }
  1514. $bgCss = ($bg !== '' && $bg !== 'transparent') ? 'background:' . $bg . ';' : '';
  1515. return '<div style="' . $base . $border . $pad . $bgCss
  1516. . 'font-family:' . $font . ';font-size:' . $size . 'px;font-weight:' . $weight
  1517. . ';color:' . $color . ';display:flex;align-items:center;justify-content:' . $justify
  1518. . ';overflow:hidden;white-space:nowrap;">' . $content . '</div>';
  1519. }
  1520. protected static function borderCss(array $el)
  1521. {
  1522. $width = (int)($el['borderWidth'] ?? 0);
  1523. if ($width < 1 && !empty($el['border'])) {
  1524. $width = 1;
  1525. }
  1526. if ($width < 1) {
  1527. return '';
  1528. }
  1529. $color = self::color($el['borderColor'] ?? '#000000', false, '#000000');
  1530. $sides = strtolower((string)($el['borders'] ?? 'tblr'));
  1531. if ($sides === '1' || $sides === 'true' || $sides === 'all') {
  1532. $sides = 'tblr';
  1533. }
  1534. $map = ['t' => 'top', 'r' => 'right', 'b' => 'bottom', 'l' => 'left'];
  1535. $css = '';
  1536. foreach ($map as $k => $name) {
  1537. if (strpos($sides, $k) !== false) {
  1538. $css .= 'border-' . $name . ':' . $width . 'px solid ' . $color . ';';
  1539. }
  1540. }
  1541. return $css;
  1542. }
  1543. protected static function bordersToString($borders)
  1544. {
  1545. $out = '';
  1546. $map = ['t' => 't', 'top' => 't', 'r' => 'r', 'right' => 'r', 'b' => 'b', 'bottom' => 'b', 'l' => 'l', 'left' => 'l'];
  1547. foreach ($map as $k => $ch) {
  1548. if (!empty($borders[$k])) {
  1549. $out .= $ch;
  1550. }
  1551. }
  1552. $out = count_chars($out, 3);
  1553. return $out !== '' ? $out : 'tblr';
  1554. }
  1555. protected static function fontFamily($name)
  1556. {
  1557. $name = trim((string)$name);
  1558. $allow = ['SimSun', 'SimHei', 'KaiTi', 'Microsoft YaHei', '微软雅黑', 'FangSong', 'Arial', 'serif', 'sans-serif'];
  1559. foreach ($allow as $item) {
  1560. if (strcasecmp($item, $name) === 0) {
  1561. return $item === 'Microsoft YaHei' ? '微软雅黑' : $item;
  1562. }
  1563. }
  1564. if (preg_match('/微软雅黑|微软黑|yahei/i', $name)) {
  1565. return '微软雅黑';
  1566. }
  1567. if (preg_match('/宋体|song/i', $name)) {
  1568. return 'SimSun';
  1569. }
  1570. if (preg_match('/黑体|^hei$/i', $name)) {
  1571. return 'SimHei';
  1572. }
  1573. if (preg_match('/楷/i', $name)) {
  1574. return 'KaiTi';
  1575. }
  1576. return 'SimSun';
  1577. }
  1578. /**
  1579. * @param mixed $value
  1580. * @param bool $allowTransparent
  1581. * @param string $default
  1582. * @return string
  1583. */
  1584. protected static function color($value, $allowTransparent = false, $default = '#111111')
  1585. {
  1586. $value = trim((string)$value);
  1587. if ($value === '') {
  1588. return $default;
  1589. }
  1590. $low = strtolower($value);
  1591. if ($allowTransparent && in_array($low, ['transparent', 'none', ''], true)) {
  1592. return 'transparent';
  1593. }
  1594. $named = [
  1595. 'black' => '#000000', 'white' => '#ffffff', 'red' => '#cc0000',
  1596. 'blue' => '#0033cc', 'gray' => '#666666', 'grey' => '#666666',
  1597. 'green' => '#008000', 'yellow' => '#c9a227',
  1598. ];
  1599. if (isset($named[$low])) {
  1600. return $named[$low];
  1601. }
  1602. if (preg_match('/^#([0-9a-f]{3})$/i', $value, $m)) {
  1603. $h = $m[1];
  1604. return '#' . $h[0] . $h[0] . $h[1] . $h[1] . $h[2] . $h[2];
  1605. }
  1606. if (preg_match('/^#([0-9a-f]{6})$/i', $value)) {
  1607. return strtolower($value);
  1608. }
  1609. if (preg_match('/rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)/i', $value, $m)) {
  1610. return sprintf('#%02x%02x%02x', min(255, (int)$m[1]), min(255, (int)$m[2]), min(255, (int)$m[3]));
  1611. }
  1612. return $default;
  1613. }
  1614. protected static function coordScale($items)
  1615. {
  1616. $maxCoord = 0;
  1617. if (!is_array($items)) {
  1618. return 1;
  1619. }
  1620. foreach ($items as $item) {
  1621. if (!is_array($item)) {
  1622. continue;
  1623. }
  1624. foreach (['x', 'y', 'w', 'h', 'width', 'height'] as $k) {
  1625. if (isset($item[$k])) {
  1626. $maxCoord = max($maxCoord, (float)$item[$k]);
  1627. }
  1628. }
  1629. }
  1630. return ($maxCoord > 0 && $maxCoord <= 1.5) ? 100 : 1;
  1631. }
  1632. protected static function pct($value)
  1633. {
  1634. $n = round((float)$value, 2);
  1635. if ($n < 0) {
  1636. return 0;
  1637. }
  1638. if ($n > 100) {
  1639. return 100;
  1640. }
  1641. return $n;
  1642. }
  1643. /**
  1644. * 工单工序优先,没有则回退产品工序(对应旧系统按印件取历史工艺)
  1645. * @param mixed $workorderProcesses
  1646. * @param mixed $productProcesses
  1647. * @param array $options
  1648. * @return array
  1649. */
  1650. protected static function resolveProcesses($workorderProcesses, $productProcesses, array $options)
  1651. {
  1652. if (array_key_exists('processes', $options) && is_array($options['processes'])) {
  1653. $list = [];
  1654. $seq = 1;
  1655. foreach ($options['processes'] as $item) {
  1656. if (is_string($item) && trim($item) !== '') {
  1657. $list[] = [
  1658. 'seq' => $seq++,
  1659. 'code' => '',
  1660. 'name' => trim($item),
  1661. 'workstation' => '',
  1662. 'standard_time' => '',
  1663. 'qc_points' => '',
  1664. 'remark' => '',
  1665. ];
  1666. } elseif (is_array($item) && (trim((string)($item['name'] ?? '')) !== '' || trim((string)($item['code'] ?? '')) !== '')) {
  1667. $name = trim((string)($item['name'] ?? ''));
  1668. $code = trim((string)($item['code'] ?? ''));
  1669. $list[] = [
  1670. 'seq' => isset($item['seq']) && $item['seq'] !== '' ? (int)$item['seq'] : $seq++,
  1671. 'code' => $code,
  1672. 'name' => $name !== '' ? $name : $code,
  1673. 'workstation' => isset($item['workstation']) ? (string)$item['workstation'] : '',
  1674. 'standard_time' => isset($item['standard_time']) ? (string)$item['standard_time'] : '',
  1675. 'qc_points' => isset($item['qc_points']) ? (string)$item['qc_points'] : '',
  1676. 'remark' => isset($item['remark']) ? (string)$item['remark'] : '',
  1677. ];
  1678. }
  1679. }
  1680. return $list;
  1681. }
  1682. $string = isset($options['process_string']) ? $options['process_string'] : (isset($options['string']) ? $options['string'] : '');
  1683. if (is_string($string) && trim($string) !== '') {
  1684. $names = array_values(array_filter(array_map('trim', explode(',', $string))));
  1685. return self::resolveProcesses([], [], ['processes' => $names]);
  1686. }
  1687. $source = [];
  1688. if ($workorderProcesses && count($workorderProcesses)) {
  1689. $source = $workorderProcesses;
  1690. } elseif ($productProcesses && count($productProcesses)) {
  1691. $source = $productProcesses;
  1692. }
  1693. $list = [];
  1694. foreach ($source as $row) {
  1695. $list[] = [
  1696. 'seq' => (int)$row['seq'],
  1697. 'code' => (string)($row['code'] ?? ''),
  1698. 'name' => (string)$row['name'],
  1699. 'workstation' => (string)$row['workstation'],
  1700. 'standard_time' => (string)$row['standard_time'],
  1701. 'qc_points' => (string)$row['qc_points'],
  1702. 'remark' => (string)$row['remark'],
  1703. ];
  1704. }
  1705. return $list;
  1706. }
  1707. protected static function pickText(array $options, $key, $default = '')
  1708. {
  1709. if (!isset($options[$key]) || $options[$key] === null) {
  1710. return (string)$default;
  1711. }
  1712. $text = trim((string)$options[$key]);
  1713. return $text !== '' ? $text : (string)$default;
  1714. }
  1715. protected static function pickQty(array $options, $default)
  1716. {
  1717. if (isset($options['qty']) && $options['qty'] !== '' && $options['qty'] !== null) {
  1718. return self::toQty($options['qty']);
  1719. }
  1720. if (isset($options['num']) && $options['num'] !== '' && $options['num'] !== null) {
  1721. return self::toQty($options['num']);
  1722. }
  1723. return self::toQty($default);
  1724. }
  1725. protected static function pickSheetQty(array $options, $default)
  1726. {
  1727. foreach (['sheet_qty', 'tldx'] as $key) {
  1728. if (isset($options[$key]) && $options[$key] !== '' && $options[$key] !== null) {
  1729. return self::toQty($options[$key]);
  1730. }
  1731. }
  1732. return self::toQty($default);
  1733. }
  1734. /**
  1735. * 按每版数量拆张。一版 = 一张流程单。
  1736. * 余数只要大于 0(哪怕只剩 1 件)也单独打一版,不满都算。
  1737. * @param float $qty
  1738. * @param float $sheetQty
  1739. * @return array
  1740. * @throws Exception
  1741. */
  1742. protected static function splitSheets($qty, $sheetQty)
  1743. {
  1744. if ($qty <= 0) {
  1745. $qty = 1;
  1746. }
  1747. if ($sheetQty <= 0) {
  1748. return [['no' => 1, 'total' => 1, 'qty' => $qty]];
  1749. }
  1750. $full = (int)floor($qty / $sheetQty);
  1751. $remain = round($qty - $full * $sheetQty, 4);
  1752. $total = $full + ($remain > 0 ? 1 : 0);
  1753. if ($total < 1) {
  1754. $total = 1;
  1755. }
  1756. $max = (int)(Config::get('ps.print.max_sheets') ?: 200);
  1757. if ($total > $max) {
  1758. throw new Exception('拆张数量超过上限 ' . $max . ',请加大每版数量');
  1759. }
  1760. $sheets = [];
  1761. $left = $qty;
  1762. for ($i = 1; $i <= $total; $i++) {
  1763. $current = $i === $total ? $left : min($sheetQty, $left);
  1764. if ($current <= 0) {
  1765. $current = $sheetQty;
  1766. }
  1767. $sheets[] = [
  1768. 'no' => $i,
  1769. 'total' => $total,
  1770. 'qty' => $current,
  1771. ];
  1772. $left = round($left - $current, 4);
  1773. }
  1774. return $sheets;
  1775. }
  1776. protected static function toQty($value)
  1777. {
  1778. if ($value === '' || $value === null) {
  1779. return 0;
  1780. }
  1781. return round((float)$value, 4);
  1782. }
  1783. protected static function formatQty($value)
  1784. {
  1785. $text = rtrim(rtrim(number_format((float)$value, 4, '.', ''), '0'), '.');
  1786. return $text === '' ? '0' : $text;
  1787. }
  1788. protected static function replacePlaceholders($html, array $fill, array $processes)
  1789. {
  1790. $processHtml = self::processTableHtml($processes);
  1791. $html = str_replace('{processes.html}', $processHtml, $html);
  1792. foreach ($processes as $i => $row) {
  1793. $n = $i + 1;
  1794. $pairs = [
  1795. 'process.' . $n . '.name' => $row['name'] ?? '',
  1796. 'process.' . $n => $row['name'] ?? '',
  1797. 'process.' . $n . '.code' => $row['code'] ?? '',
  1798. ];
  1799. foreach ($pairs as $key => $value) {
  1800. $html = str_replace('{' . $key . '}', htmlspecialchars((string)$value, ENT_QUOTES, 'UTF-8'), $html);
  1801. }
  1802. }
  1803. $html = preg_replace('/\{process\.\d+(?:\.[a-z_]+)?\}/', '', $html);
  1804. foreach ($fill as $key => $value) {
  1805. $html = str_replace('{' . $key . '}', htmlspecialchars((string)$value, ENT_QUOTES, 'UTF-8'), $html);
  1806. }
  1807. return $html;
  1808. }
  1809. protected static function defaultSheetHtml(array $plan, array $sheet)
  1810. {
  1811. $fill = $sheet['fill'];
  1812. $e = function ($key) use ($fill) {
  1813. return htmlspecialchars(isset($fill[$key]) ? $fill[$key] : '', ENT_QUOTES, 'UTF-8');
  1814. };
  1815. $html = '<div style="font-family:SimSun,serif;font-size:14px;padding:12px;">';
  1816. $html .= '<h2 style="text-align:center;margin:0 0 8px;">生产流程单</h2>';
  1817. $html .= '<div style="text-align:right;margin-bottom:8px;">第 ' . $e('sheet.no_text') . ' 张</div>';
  1818. $html .= '<table border="1" cellspacing="0" cellpadding="6" width="100%" style="border-collapse:collapse;">';
  1819. $html .= '<tr><td>工厂</td><td>' . $e('factory.name') . '</td><td>工单号</td><td>' . $e('workorder.no') . '</td></tr>';
  1820. $html .= '<tr><td>产品</td><td>' . $e('product.name') . '</td><td>编码</td><td>' . $e('product.code') . '</td></tr>';
  1821. $html .= '<tr><td>规格</td><td>' . $e('product.spec') . '</td><td>单位</td><td>' . $e('product.unit') . '</td></tr>';
  1822. $html .= '<tr><td>批次</td><td>' . $e('workorder.batch_no') . '</td><td>客户</td><td>' . $e('workorder.customer') . '</td></tr>';
  1823. $html .= '<tr><td>本张数量</td><td>' . $e('sheet.qty') . '</td><td>计划总量</td><td>' . htmlspecialchars(self::formatQty($plan['qty_plan']), ENT_QUOTES, 'UTF-8') . '</td></tr>';
  1824. $html .= '<tr><td>交货/计划日</td><td>' . $e('workorder.plan_date') . '</td><td>操作员</td><td>' . $e('operator') . '</td></tr>';
  1825. $html .= '</table>';
  1826. $html .= '<h3 style="margin:12px 0 6px;">工序</h3>';
  1827. $html .= self::processTableHtml($plan['processes']);
  1828. if ($fill['workorder.remark'] !== '') {
  1829. $html .= '<p>备注:' . $e('workorder.remark') . '</p>';
  1830. }
  1831. $html .= '<p style="text-align:right;color:#666;">打印时间:' . $e('print_time') . '</p>';
  1832. $html .= '</div>';
  1833. return $html;
  1834. }
  1835. protected static function processTableHtml(array $processes)
  1836. {
  1837. $html = '<table border="1" cellspacing="0" cellpadding="6" width="100%" style="border-collapse:collapse;font-family:SimSun,serif;font-size:13px;">';
  1838. $html .= '<tr><th>序号</th><th>工序</th><th>工位</th><th>标准工时</th><th>质检要点</th></tr>';
  1839. if (!$processes) {
  1840. $html .= '<tr><td colspan="5" style="text-align:center;">暂无工序</td></tr>';
  1841. } else {
  1842. foreach ($processes as $row) {
  1843. $html .= '<tr>';
  1844. $html .= '<td>' . htmlspecialchars((string)$row['seq'], ENT_QUOTES, 'UTF-8') . '</td>';
  1845. $html .= '<td>' . htmlspecialchars((string)$row['name'], ENT_QUOTES, 'UTF-8') . '</td>';
  1846. $html .= '<td>' . htmlspecialchars((string)$row['workstation'], ENT_QUOTES, 'UTF-8') . '</td>';
  1847. $html .= '<td>' . htmlspecialchars((string)$row['standard_time'], ENT_QUOTES, 'UTF-8') . '</td>';
  1848. $html .= '<td>' . htmlspecialchars((string)$row['qc_points'], ENT_QUOTES, 'UTF-8') . '</td>';
  1849. $html .= '</tr>';
  1850. }
  1851. }
  1852. $html .= '</table>';
  1853. return $html;
  1854. }
  1855. }