PrintPlanner.php 73 KB

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