PrintPlanner.php 69 KB

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