ProcuremenDashboard.php 41 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249
  1. <?php
  2. namespace app\common\library;
  3. use think\Db;
  4. /**
  5. * 控制台 — 协助采购(采购供应商)统计
  6. */
  7. class ProcuremenDashboard
  8. {
  9. /**
  10. * @return array{
  11. * today: array{issued:int,confirm:int,approval:int,completed:int},
  12. * week: array{issued:int,confirm:int,approval:int,completed:int},
  13. * pending: array{confirm:int,approval:int},
  14. * supplierRank: array{quote: array, selected: array, period_label: string},
  15. * kpi: array<string, array{today:int,month:int,link:string,link_text:string}>,
  16. * updated: string
  17. * }
  18. */
  19. public static function buildStats(): array
  20. {
  21. $todayStart = date('Y-m-d 00:00:00');
  22. $todayEnd = date('Y-m-d H:i:s');
  23. $weekStart = date('Y-m-d 00:00:00', strtotime('monday this week'));
  24. $weekEnd = date('Y-m-d H:i:s');
  25. return [
  26. 'kpi' => self::buildKpiCards(),
  27. 'today' => self::countStageMetrics($todayStart, $todayEnd),
  28. 'week' => self::countStageMetrics($weekStart, $weekEnd),
  29. 'pending' => self::countCurrentPending(),
  30. 'supplierRank' => self::buildSupplierRankings(10, date('Y-m-01 00:00:00'), date('Y-m-t 23:59:59')),
  31. 'last7' => self::buildLast7DaysChart(),
  32. 'updated' => date('Y-m-d H:i:s'),
  33. ];
  34. }
  35. /**
  36. * 控制台首行 KPI:今日 / 本月
  37. *
  38. * @return array<string, mixed>
  39. */
  40. public static function buildKpiCards(): array
  41. {
  42. $todayStart = date('Y-m-d 00:00:00');
  43. $todayEnd = date('Y-m-d 23:59:59');
  44. $monthStart = date('Y-m-01 00:00:00');
  45. $monthEnd = date('Y-m-t 23:59:59');
  46. $inboundToday = self::countInboundScoreInRange($todayStart, $todayEnd);
  47. $inboundMonth = self::countInboundScoreInRange($monthStart, $monthEnd);
  48. return [
  49. 'supplier' => [
  50. 'total' => self::countActiveSuppliers(),
  51. 'link' => '',
  52. 'link_text' => '',
  53. ],
  54. // 兼容旧模板字段(图表面板仍可能引用)
  55. 'confirm' => [
  56. 'today' => self::countQuotedPendingConfirmOrders(),
  57. 'month' => self::countQuotedPendingConfirmOrders(),
  58. 'link' => 'procuremen/audit',
  59. 'link_text' => '供应商确认',
  60. ],
  61. 'approval' => [
  62. 'today' => $inboundToday['total'],
  63. 'month' => $inboundMonth['total'],
  64. 'link' => 'procuremenarchive/index',
  65. 'link_text' => '历史存证档案查询',
  66. ],
  67. 'completed' => [
  68. 'today' => self::countNewRfqInRange($todayStart, $todayEnd),
  69. 'month' => self::countNewRfqInRange($monthStart, $monthEnd),
  70. 'link' => 'procuremen/rfqlist',
  71. 'link_text' => '查询询价',
  72. ],
  73. // 新 KPI
  74. 'import' => [
  75. 'today' => self::countImportDemandInRange($todayStart, $todayEnd),
  76. 'month' => self::countImportDemandInRange($monthStart, $monthEnd),
  77. 'link' => 'procuremen/pick',
  78. 'link_text' => '采购供应商初选',
  79. ],
  80. 'quote_pending' => [
  81. 'today' => self::countQuotedPendingConfirmOrders(),
  82. 'month' => self::countQuotedPendingConfirmOrders(),
  83. 'link' => 'procuremen/audit',
  84. 'link_text' => '供应商确认',
  85. ],
  86. 'inbound' => [
  87. 'today' => $inboundToday,
  88. 'month' => $inboundMonth,
  89. 'link' => 'procuremenarchive/index',
  90. 'link_text' => '历史存证档案查询',
  91. ],
  92. 'rfq_new' => [
  93. 'today' => self::countNewRfqInRange($todayStart, $todayEnd),
  94. 'month' => self::countNewRfqInRange($monthStart, $monthEnd),
  95. 'link' => 'procuremen/rfqlist',
  96. 'link_text' => '查询询价',
  97. ],
  98. 'rfq_quoted' => [
  99. 'today' => self::countQuotedRfqInRange($todayStart, $todayEnd),
  100. 'month' => self::countQuotedRfqInRange($monthStart, $monthEnd),
  101. 'link' => 'procuremen/rfqlist',
  102. 'link_text' => '查询询价',
  103. ],
  104. ];
  105. }
  106. /**
  107. * 导入需求:初选池中按提交日期(dputrecord,空则 dStamp)落在区间内的工序条数
  108. */
  109. protected static function countImportDemandInRange(string $start, string $end): int
  110. {
  111. try {
  112. $timeExpr = 'COALESCE('
  113. . "NULLIF(NULLIF(TRIM(CAST(b.dputrecord AS CHAR(32))), ''), '0000-00-00'),"
  114. . "NULLIF(NULLIF(TRIM(CAST(a.dStamp AS CHAR(32))), ''), '0000-00-00')"
  115. . ')';
  116. $n = Db::table('scydgy')
  117. ->alias('a')
  118. ->join('mcyd b', 'b.ICYDID = a.ICYDID AND b.iStatus >= 10', 'inner')
  119. ->where([
  120. 'a.bwjg' => 1,
  121. 'a.iEndBz' => 0,
  122. 'a.iType' => 0,
  123. 'a.iStatus' => 10,
  124. ])
  125. ->whereRaw("({$timeExpr}) >= ? AND ({$timeExpr}) <= ?", [$start, $end])
  126. ->count('a.ID');
  127. return (int)$n;
  128. } catch (\Throwable $e) {
  129. return 0;
  130. }
  131. }
  132. /**
  133. * 报价供应商待确认:当前待确认供应商,且至少一家已填单价
  134. */
  135. protected static function countQuotedPendingConfirmOrders(): int
  136. {
  137. $rows = self::loadPendingConfirmRows();
  138. if ($rows === []) {
  139. return 0;
  140. }
  141. $sidList = [];
  142. $orderBySid = [];
  143. foreach ($rows as $row) {
  144. if (!is_array($row)) {
  145. continue;
  146. }
  147. $sid = (int)($row['scydgy_id'] ?? 0);
  148. if ($sid === 0) {
  149. continue;
  150. }
  151. $sidList[$sid] = true;
  152. $orderBySid[$sid] = $row;
  153. }
  154. if ($sidList === []) {
  155. return 0;
  156. }
  157. try {
  158. $details = Db::table('purchase_order_detail')
  159. ->where('scydgy_id', 'in', array_keys($sidList))
  160. ->field('scydgy_id,amount')
  161. ->select();
  162. } catch (\Throwable $e) {
  163. return 0;
  164. }
  165. if (!is_array($details)) {
  166. return 0;
  167. }
  168. $quotedSids = [];
  169. foreach ($details as $d) {
  170. if (!is_array($d)) {
  171. continue;
  172. }
  173. $amount = trim((string)($d['amount'] ?? ''));
  174. if ($amount === '' || $amount === '0' || $amount === '0.00') {
  175. continue;
  176. }
  177. $sid = (int)($d['scydgy_id'] ?? 0);
  178. if ($sid !== 0) {
  179. $quotedSids[$sid] = true;
  180. }
  181. }
  182. if ($quotedSids === []) {
  183. return 0;
  184. }
  185. $matched = [];
  186. foreach ($quotedSids as $sid => $_) {
  187. if (isset($orderBySid[$sid])) {
  188. $matched[] = $orderBySid[$sid];
  189. }
  190. }
  191. return self::countDistinctOrders($matched);
  192. }
  193. /**
  194. * 已入库打分:合格 / 不合格订单数(按订单号去重)
  195. *
  196. * @return array{pass:int,fail:int,total:int}
  197. */
  198. protected static function countInboundScoreInRange(string $start, string $end): array
  199. {
  200. $empty = ['pass' => 0, 'fail' => 0, 'total' => 0];
  201. try {
  202. $rows = Db::table('purchase_order_inbound_score')
  203. ->whereRaw(
  204. '(COALESCE(updatetime, createtime) >= ? AND COALESCE(updatetime, createtime) <= ?)',
  205. [$start, $end]
  206. )
  207. ->field('ccydh,scydgy_id,result')
  208. ->select();
  209. } catch (\Throwable $e) {
  210. return $empty;
  211. }
  212. if (!is_array($rows) || $rows === []) {
  213. return $empty;
  214. }
  215. $passKeys = [];
  216. $failKeys = [];
  217. foreach ($rows as $row) {
  218. if (!is_array($row)) {
  219. continue;
  220. }
  221. $result = trim((string)($row['result'] ?? ''));
  222. $ccydh = trim((string)($row['ccydh'] ?? ''));
  223. $key = $ccydh !== '' ? $ccydh : ('_sid_' . (int)($row['scydgy_id'] ?? 0));
  224. if ($result === '合格') {
  225. $passKeys[$key] = true;
  226. } elseif ($result === '不合格') {
  227. $failKeys[$key] = true;
  228. }
  229. }
  230. $pass = count($passKeys);
  231. $fail = count($failKeys);
  232. return [
  233. 'pass' => $pass,
  234. 'fail' => $fail,
  235. 'total' => $pass + $fail,
  236. ];
  237. }
  238. /**
  239. * 新增询价:手工单 scydgy_id&lt;0
  240. */
  241. protected static function countNewRfqInRange(string $start, string $end): int
  242. {
  243. try {
  244. $expr = self::pickTimeSqlExpr();
  245. $query = Db::table('purchase_order')->field('id,CCYDH');
  246. self::applyNotDeletedWhere($query);
  247. $query->where('scydgy_id', '<', 0);
  248. $query->whereRaw("({$expr}) >= ? AND ({$expr}) <= ?", [$start, $end]);
  249. $rows = $query->select();
  250. } catch (\Throwable $e) {
  251. return 0;
  252. }
  253. return self::countDistinctOrders(is_array($rows) ? $rows : []);
  254. }
  255. /**
  256. * 供应商已报价询价:区间内新增询价中,至少一家已填单价
  257. */
  258. protected static function countQuotedRfqInRange(string $start, string $end): int
  259. {
  260. try {
  261. $expr = self::pickTimeSqlExpr();
  262. $query = Db::table('purchase_order')->field('id,CCYDH,scydgy_id');
  263. self::applyNotDeletedWhere($query);
  264. $query->where('scydgy_id', '<', 0);
  265. $query->whereRaw("({$expr}) >= ? AND ({$expr}) <= ?", [$start, $end]);
  266. $rows = $query->select();
  267. } catch (\Throwable $e) {
  268. return 0;
  269. }
  270. if (!is_array($rows) || $rows === []) {
  271. return 0;
  272. }
  273. $sidList = [];
  274. $orderBySid = [];
  275. foreach ($rows as $row) {
  276. if (!is_array($row)) {
  277. continue;
  278. }
  279. $sid = (int)($row['scydgy_id'] ?? 0);
  280. if ($sid >= 0) {
  281. continue;
  282. }
  283. $sidList[$sid] = true;
  284. $orderBySid[$sid] = $row;
  285. }
  286. if ($sidList === []) {
  287. return 0;
  288. }
  289. try {
  290. $details = Db::table('purchase_order_detail')
  291. ->where('scydgy_id', 'in', array_keys($sidList))
  292. ->field('scydgy_id,amount')
  293. ->select();
  294. } catch (\Throwable $e) {
  295. return 0;
  296. }
  297. if (!is_array($details)) {
  298. return 0;
  299. }
  300. $quotedSids = [];
  301. foreach ($details as $d) {
  302. if (!is_array($d)) {
  303. continue;
  304. }
  305. $amount = trim((string)($d['amount'] ?? ''));
  306. if ($amount === '' || $amount === '0' || $amount === '0.00') {
  307. continue;
  308. }
  309. $sid = (int)($d['scydgy_id'] ?? 0);
  310. if ($sid < 0) {
  311. $quotedSids[$sid] = true;
  312. }
  313. }
  314. $matched = [];
  315. foreach ($quotedSids as $sid => $_) {
  316. if (isset($orderBySid[$sid])) {
  317. $matched[] = $orderBySid[$sid];
  318. }
  319. }
  320. return self::countDistinctOrders($matched);
  321. }
  322. /**
  323. * @param string[] $wflowValues
  324. */
  325. protected static function countWflowOrdersInPickRange(array $wflowValues, string $start, string $end): int
  326. {
  327. try {
  328. $expr = self::pickTimeSqlExpr();
  329. $query = Db::table('purchase_order')->field('id,CCYDH');
  330. self::applyNotDeletedWhere($query);
  331. $query->whereIn('wflow_status', $wflowValues);
  332. $query->whereRaw("({$expr}) >= ? AND ({$expr}) <= ?", [$start, $end]);
  333. $rows = $query->select();
  334. } catch (\Throwable $e) {
  335. return 0;
  336. }
  337. return self::countDistinctOrders(is_array($rows) ? $rows : []);
  338. }
  339. protected static function countQuoteSuppliersInPickRange(string $start, string $end): int
  340. {
  341. $scydgyIds = self::loadScydgyIdsInPickTimeRange($start, $end);
  342. if ($scydgyIds === []) {
  343. return 0;
  344. }
  345. try {
  346. $rows = Db::table('purchase_order_detail')
  347. ->where('scydgy_id', 'in', $scydgyIds)
  348. ->field('company_name,amount')
  349. ->select();
  350. } catch (\Throwable $e) {
  351. return 0;
  352. }
  353. if (!is_array($rows)) {
  354. return 0;
  355. }
  356. $names = [];
  357. foreach ($rows as $row) {
  358. if (!is_array($row)) {
  359. continue;
  360. }
  361. $name = trim((string)($row['company_name'] ?? ''));
  362. $amount = trim((string)($row['amount'] ?? ''));
  363. if ($name === '' || $amount === '' || $amount === '0' || $amount === '0.00') {
  364. continue;
  365. }
  366. $names[$name] = true;
  367. }
  368. return count($names);
  369. }
  370. protected static function countActiveSuppliers(): int
  371. {
  372. try {
  373. $query = Db::table('customer');
  374. $query->where(function ($q) {
  375. $q->where('status', 1)
  376. ->whereOr('status', '1')
  377. ->whereOr('status', '')
  378. ->whereNull('status');
  379. });
  380. return (int)$query->count();
  381. } catch (\Throwable $e) {
  382. return self::countDistinctSuppliersFromDetails();
  383. }
  384. }
  385. protected static function countDistinctSuppliersFromDetails(): int
  386. {
  387. try {
  388. $rows = Db::table('purchase_order_detail')
  389. ->field('company_name')
  390. ->select();
  391. } catch (\Throwable $e) {
  392. return 0;
  393. }
  394. if (!is_array($rows)) {
  395. return 0;
  396. }
  397. $names = [];
  398. foreach ($rows as $row) {
  399. if (!is_array($row)) {
  400. continue;
  401. }
  402. $name = trim((string)($row['company_name'] ?? ''));
  403. if ($name !== '') {
  404. $names[$name] = true;
  405. }
  406. }
  407. return count($names);
  408. }
  409. protected static function countDistinctOrdersInPickTimeRange(string $start, string $end): int
  410. {
  411. try {
  412. $expr = self::pickTimeSqlExpr();
  413. $query = Db::table('purchase_order')->field('id,CCYDH');
  414. self::applyNotDeletedWhere($query);
  415. $query->whereRaw("({$expr}) >= ? AND ({$expr}) <= ?", [$start, $end]);
  416. $rows = $query->select();
  417. } catch (\Throwable $e) {
  418. return 0;
  419. }
  420. return self::countDistinctOrders(is_array($rows) ? $rows : []);
  421. }
  422. /**
  423. * 供应商排名(本月):参与报价次数 / 被选中次数
  424. *
  425. * @return array{
  426. * quote: array<int, array{rank:int,name:string,value:int,percent:int}>,
  427. * selected: array<int, array{rank:int,name:string,value:int,percent:int}>,
  428. * period_label: string,
  429. * supplier_total: int
  430. * }
  431. */
  432. public static function buildSupplierRankings(int $limit = 10, ?string $start = null, ?string $end = null): array
  433. {
  434. $start = $start ?: date('Y-m-d 00:00:00', strtotime('first day of this month'));
  435. $end = $end ?: date('Y-m-t 23:59:59');
  436. return [
  437. 'quote' => self::rankSuppliersByQuoteInRange($start, $end, $limit),
  438. 'selected' => self::rankSuppliersBySelectedInRange($start, $end, $limit),
  439. 'period_label' => '本月',
  440. 'supplier_total' => self::countActiveSuppliers(),
  441. ];
  442. }
  443. /**
  444. * @param array<string, int> $counts
  445. * @return array<int, array{rank:int,name:string,value:int,percent:int}>
  446. */
  447. protected static function formatSupplierRankListFromCounts(array $counts, int $limit): array
  448. {
  449. if ($counts === []) {
  450. return [];
  451. }
  452. arsort($counts);
  453. $top = array_slice($counts, 0, max(1, $limit), true);
  454. $max = $top ? max($top) : 0;
  455. $out = [];
  456. $rank = 1;
  457. foreach ($top as $name => $value) {
  458. $out[] = [
  459. 'rank' => $rank++,
  460. 'name' => (string)$name,
  461. 'value' => (int)$value,
  462. 'percent' => $max > 0 ? (int)round($value / $max * 100) : 0,
  463. ];
  464. }
  465. return $out;
  466. }
  467. /**
  468. * @param array<string, array<string|int, bool>> $buckets
  469. * @return array<int, array{rank:int,name:string,value:int,percent:int}>
  470. */
  471. protected static function formatSupplierRankList(array $buckets, int $limit): array
  472. {
  473. if ($buckets === []) {
  474. return [];
  475. }
  476. $counts = [];
  477. foreach ($buckets as $name => $keys) {
  478. $counts[$name] = is_array($keys) ? count($keys) : 0;
  479. }
  480. arsort($counts);
  481. $top = array_slice($counts, 0, max(1, $limit), true);
  482. $max = $top ? max($top) : 0;
  483. $out = [];
  484. $rank = 1;
  485. foreach ($top as $name => $value) {
  486. $out[] = [
  487. 'rank' => $rank++,
  488. 'name' => (string)$name,
  489. 'value' => (int)$value,
  490. 'percent' => $max > 0 ? (int)round($value / $max * 100) : 0,
  491. ];
  492. }
  493. return $out;
  494. }
  495. /**
  496. * 按已填写报价金额的供应商统计参与报价次数(明细行数)
  497. *
  498. * @return array<int, array{rank:int,name:string,value:int,percent:int}>
  499. */
  500. protected static function rankSuppliersByQuoteInRange(string $start, string $end, int $limit): array
  501. {
  502. $scydgyIds = self::loadScydgyIdsInPickTimeRange($start, $end);
  503. if ($scydgyIds === []) {
  504. return [];
  505. }
  506. try {
  507. $rows = Db::table('purchase_order_detail')
  508. ->where('scydgy_id', 'in', $scydgyIds)
  509. ->field('company_name,amount')
  510. ->select();
  511. } catch (\Throwable $e) {
  512. return [];
  513. }
  514. if (!is_array($rows)) {
  515. return [];
  516. }
  517. $counts = [];
  518. foreach ($rows as $row) {
  519. if (!is_array($row)) {
  520. continue;
  521. }
  522. $name = trim((string)($row['company_name'] ?? ''));
  523. $amount = trim((string)($row['amount'] ?? ''));
  524. if ($name === '' || $amount === '' || $amount === '0' || $amount === '0.00') {
  525. continue;
  526. }
  527. $counts[$name] = ($counts[$name] ?? 0) + 1;
  528. }
  529. return self::formatSupplierRankListFromCounts($counts, $limit);
  530. }
  531. /**
  532. * 按主表 pick_company_name 统计被选中订单数
  533. *
  534. * @return array<int, array{rank:int,name:string,value:int,percent:int}>
  535. */
  536. protected static function rankSuppliersBySelectedInRange(string $start, string $end, int $limit): array
  537. {
  538. try {
  539. $expr = self::pickTimeSqlExpr();
  540. $query = Db::table('purchase_order')
  541. ->field('CCYDH,pick_company_name');
  542. self::applyNotDeletedWhere($query);
  543. $query->whereRaw("({$expr}) >= ? AND ({$expr}) <= ?", [$start, $end]);
  544. $query->whereRaw("pick_company_name IS NOT NULL AND TRIM(CAST(pick_company_name AS CHAR(255))) <> ''");
  545. $rows = $query->select();
  546. } catch (\Throwable $e) {
  547. return [];
  548. }
  549. if (!is_array($rows)) {
  550. return [];
  551. }
  552. $buckets = [];
  553. foreach ($rows as $row) {
  554. if (!is_array($row)) {
  555. continue;
  556. }
  557. $name = trim((string)($row['pick_company_name'] ?? ''));
  558. if ($name === '') {
  559. continue;
  560. }
  561. $orderKey = self::resolveOrderKey($row);
  562. if (!isset($buckets[$name])) {
  563. $buckets[$name] = [];
  564. }
  565. $buckets[$name][$orderKey] = true;
  566. }
  567. return self::formatSupplierRankList($buckets, $limit);
  568. }
  569. /**
  570. * @return int[]
  571. */
  572. protected static function loadScydgyIdsInPickTimeRange(string $start, string $end): array
  573. {
  574. try {
  575. $expr = self::pickTimeSqlExpr();
  576. $query = Db::table('purchase_order')->field('scydgy_id');
  577. self::applyNotDeletedWhere($query);
  578. $query->whereRaw("({$expr}) >= ? AND ({$expr}) <= ?", [$start, $end]);
  579. $rows = $query->select();
  580. } catch (\Throwable $e) {
  581. return [];
  582. }
  583. if (!is_array($rows)) {
  584. return [];
  585. }
  586. $ids = [];
  587. foreach ($rows as $row) {
  588. if (!is_array($row)) {
  589. continue;
  590. }
  591. $sid = (int)($row['scydgy_id'] ?? 0);
  592. if ($sid > 0) {
  593. $ids[$sid] = true;
  594. }
  595. }
  596. return array_keys($ids);
  597. }
  598. /**
  599. * 近 7 天每日趋势(按订单号去重)
  600. *
  601. * @return array{
  602. * labels: string[],
  603. * issued: int[],
  604. * confirm: int[],
  605. * approval: int[],
  606. * completed: int[]
  607. * }
  608. */
  609. public static function buildLast7DaysChart(): array
  610. {
  611. $dayKeys = [];
  612. $labels = [];
  613. for ($i = 6; $i >= 0; $i--) {
  614. $day = date('Y-m-d', strtotime('-' . $i . ' days'));
  615. $dayKeys[] = $day;
  616. $labels[] = date('m-d', strtotime($day));
  617. }
  618. if ($dayKeys === []) {
  619. return ['labels' => [], 'issued' => [], 'confirm' => [], 'approval' => [], 'completed' => []];
  620. }
  621. $rangeStart = $dayKeys[0] . ' 00:00:00';
  622. $rangeEnd = $dayKeys[count($dayKeys) - 1] . ' 23:59:59';
  623. $startTs = ProcuremenTime::parseToTimestamp($rangeStart);
  624. $endTs = ProcuremenTime::parseToTimestamp($rangeEnd);
  625. $issuedBuckets = self::bucketOperLogOrdersByDay(
  626. self::loadOperLogsInRange(['issue_submit'], $startTs, $endTs),
  627. $dayKeys,
  628. $startTs,
  629. $endTs
  630. );
  631. $confirmBuckets = self::bucketOperLogOrdersByDay(
  632. self::loadOperLogsInRange(['issue_submit'], $startTs, $endTs),
  633. $dayKeys,
  634. $startTs,
  635. $endTs
  636. );
  637. $approvalBuckets = self::bucketOperLogOrdersByDay(
  638. self::loadOperLogsInRange(['audit_select'], $startTs, $endTs),
  639. $dayKeys,
  640. $startTs,
  641. $endTs
  642. );
  643. $completedBuckets = self::bucketOperLogOrdersByDay(
  644. self::loadOperLogsInRange(['purchase_confirm', 'mark_complete'], $startTs, $endTs),
  645. $dayKeys,
  646. $startTs,
  647. $endTs
  648. );
  649. return [
  650. 'labels' => $labels,
  651. 'issued' => self::bucketCounts($issuedBuckets, $dayKeys),
  652. 'confirm' => self::bucketCounts($confirmBuckets, $dayKeys),
  653. 'approval' => self::bucketCounts($approvalBuckets, $dayKeys),
  654. 'completed' => self::bucketCounts($completedBuckets, $dayKeys),
  655. ];
  656. }
  657. /**
  658. * @param string[] $dayKeys
  659. * @return array<string, array<string, bool>>
  660. */
  661. protected static function emptyDayBuckets(array $dayKeys): array
  662. {
  663. $out = [];
  664. foreach ($dayKeys as $day) {
  665. $out[$day] = [];
  666. }
  667. return $out;
  668. }
  669. /**
  670. * @param array<string, array<string, bool>> $buckets
  671. * @param string[] $dayKeys
  672. * @return int[]
  673. */
  674. protected static function bucketCounts(array $buckets, array $dayKeys): array
  675. {
  676. $out = [];
  677. foreach ($dayKeys as $day) {
  678. $out[] = isset($buckets[$day]) ? count($buckets[$day]) : 0;
  679. }
  680. return $out;
  681. }
  682. /**
  683. * @param array<string, mixed> $row
  684. * @param string[] $dayKeys
  685. */
  686. protected static function resolveRowDayKey(array $row, array $dayKeys): ?string
  687. {
  688. $ts = 0;
  689. foreach (['pick_time', 'createtime', 'dputrecord', 'dStamp'] as $key) {
  690. $t = ProcuremenTime::parseToTimestamp($row[$key] ?? null);
  691. if ($t > $ts) {
  692. $ts = $t;
  693. }
  694. }
  695. if ($ts <= 0) {
  696. return null;
  697. }
  698. $day = date('Y-m-d', $ts);
  699. return in_array($day, $dayKeys, true) ? $day : null;
  700. }
  701. /**
  702. * @param array<string, mixed> $row
  703. */
  704. protected static function resolveOrderKey(array $row): string
  705. {
  706. $dh = trim((string)($row['CCYDH'] ?? ''));
  707. return $dh !== '' ? $dh : ('_id_' . (int)($row['id'] ?? 0));
  708. }
  709. /**
  710. * @param array<string, mixed> $row
  711. */
  712. protected static function isPendingConfirmRow(array $row): bool
  713. {
  714. return ProcuremenStatus::isWflowPendingConfirm($row['wflow_status'] ?? '');
  715. }
  716. /**
  717. * @param array<int, string> $actions
  718. * @return array<int, array{scydgy_id:int, createtime:mixed}>
  719. */
  720. protected static function loadOperLogsInRange(array $actions, int $startTs, int $endTs): array
  721. {
  722. try {
  723. $logs = Db::table('purchase_order_oper_log')
  724. ->where(ProcuremenOperLog::COL_ACTION, 'in', ProcuremenOperLog::expandActionQueryValues($actions))
  725. ->field(ProcuremenOperLog::COL_SCYDGY_ID . ',' . ProcuremenOperLog::COL_TIME)
  726. ->select();
  727. } catch (\Throwable $e) {
  728. return [];
  729. }
  730. if (!is_array($logs)) {
  731. return [];
  732. }
  733. $out = [];
  734. foreach ($logs as $log) {
  735. if (!is_array($log)) {
  736. continue;
  737. }
  738. $log = ProcuremenOperLog::normalizeRow($log);
  739. $ts = ProcuremenTime::parseToTimestamp($log['createtime'] ?? null);
  740. if ($ts < $startTs || $ts > $endTs) {
  741. continue;
  742. }
  743. $sid = (int)($log['scydgy_id'] ?? 0);
  744. if ($sid > 0) {
  745. $out[] = ['scydgy_id' => $sid, 'createtime' => $log['createtime'] ?? null];
  746. }
  747. }
  748. return $out;
  749. }
  750. /**
  751. * @param array<int, array{scydgy_id:int, createtime:mixed}> $logs
  752. * @param string[] $dayKeys
  753. * @return array<string, array<string, bool>>
  754. */
  755. protected static function bucketOperLogOrdersByDay(array $logs, array $dayKeys, int $startTs, int $endTs): array
  756. {
  757. $buckets = self::emptyDayBuckets($dayKeys);
  758. if ($logs === []) {
  759. return $buckets;
  760. }
  761. $sidSet = [];
  762. foreach ($logs as $log) {
  763. $sidSet[(int)$log['scydgy_id']] = true;
  764. }
  765. $orderMap = [];
  766. foreach (self::loadOrdersByScydgyIds(array_keys($sidSet)) as $row) {
  767. if (!is_array($row)) {
  768. continue;
  769. }
  770. $sid = (int)($row['scydgy_id'] ?? 0);
  771. if ($sid > 0) {
  772. $orderMap[$sid] = self::resolveOrderKey($row);
  773. }
  774. }
  775. foreach ($logs as $log) {
  776. $sid = (int)$log['scydgy_id'];
  777. if ($sid <= 0 || !isset($orderMap[$sid])) {
  778. continue;
  779. }
  780. $ts = ProcuremenTime::parseToTimestamp($log['createtime'] ?? null);
  781. if ($ts < $startTs || $ts > $endTs) {
  782. continue;
  783. }
  784. $day = date('Y-m-d', $ts);
  785. if (!isset($buckets[$day])) {
  786. continue;
  787. }
  788. $buckets[$day][$orderMap[$sid]] = true;
  789. }
  790. return $buckets;
  791. }
  792. /**
  793. * 按操作日志统计时段内去重订单数(与近7天图口径一致)
  794. *
  795. * @param string[] $actions
  796. */
  797. protected static function countOperLogDistinctOrdersInRange(array $actions, string $start, string $end): int
  798. {
  799. $startTs = ProcuremenTime::parseToTimestamp($start);
  800. $endTs = ProcuremenTime::parseToTimestamp($end);
  801. if ($startTs <= 0 || $endTs <= 0 || $actions === []) {
  802. return 0;
  803. }
  804. $logs = self::loadOperLogsInRange($actions, $startTs, $endTs);
  805. if ($logs === []) {
  806. return 0;
  807. }
  808. $sidSet = [];
  809. foreach ($logs as $log) {
  810. $sid = (int)($log['scydgy_id'] ?? 0);
  811. if ($sid > 0) {
  812. $sidSet[$sid] = true;
  813. }
  814. }
  815. if ($sidSet === []) {
  816. return 0;
  817. }
  818. $keys = [];
  819. foreach (self::loadOrdersByScydgyIds(array_keys($sidSet)) as $row) {
  820. if (!is_array($row)) {
  821. continue;
  822. }
  823. $keys[self::resolveOrderKey($row)] = true;
  824. }
  825. return count($keys);
  826. }
  827. /**
  828. * 本月待处理:与「确认供应商 / 采购确认」列表一致(当前队列 + 下发月份 + 订单号去重)
  829. */
  830. protected static function countPendingStageOrdersInMonth(string $stage, string $monthStart, string $monthEnd): int
  831. {
  832. $rows = self::loadPendingStageRowsWithTime($stage);
  833. if ($rows === []) {
  834. return 0;
  835. }
  836. $matched = [];
  837. foreach ($rows as $row) {
  838. if (!is_array($row)) {
  839. continue;
  840. }
  841. $normalized = self::normalizeRowForListMonth($row);
  842. if (!self::rowMatchesMonthRange($normalized, $monthStart, $monthEnd, 'pick_time')) {
  843. continue;
  844. }
  845. $matched[] = $normalized;
  846. }
  847. return self::countDistinctOrders($matched);
  848. }
  849. /**
  850. * @return array<int, array<string, mixed>>
  851. */
  852. protected static function loadPendingStageRowsWithTime(string $stage): array
  853. {
  854. try {
  855. $fields = 'id,CCYDH,scydgy_id,pick_time,createtime,dputrecord,dStamp,wflow_status,status';
  856. $query = Db::table('purchase_order')->field($fields);
  857. self::applyNotDeletedWhere($query);
  858. if ($stage === 'confirm') {
  859. $query->whereIn('wflow_status', ProcuremenStatus::wflowPendingConfirmValues());
  860. } else {
  861. $vals = ProcuremenStatus::wflowPendingApprovalValues();
  862. $quoted = [];
  863. foreach ($vals as $v) {
  864. $quoted[] = "'" . str_replace("'", "''", (string)$v) . "'";
  865. }
  866. if ($quoted === []) {
  867. return [];
  868. }
  869. $query->whereRaw('TRIM(CAST(wflow_status AS CHAR)) IN (' . implode(',', $quoted) . ')');
  870. $query->whereRaw('NOT (' . ProcuremenStatus::sqlPoCompleted('status') . ')');
  871. }
  872. $rows = $query->select();
  873. } catch (\Throwable $e) {
  874. return [];
  875. }
  876. return is_array($rows) ? $rows : [];
  877. }
  878. /**
  879. * 列表月份筛选前补齐时间字段(与 buildListRowFromPurchaseOrderDbRow 回退一致)
  880. *
  881. * @param array<string, mixed> $row
  882. * @return array<string, mixed>
  883. */
  884. protected static function normalizeRowForListMonth(array $row): array
  885. {
  886. $r = $row;
  887. $r['createtime'] = ProcuremenTime::formatDisplayDateTime($row['createtime'] ?? null);
  888. $dpOut = trim((string)($r['dputrecord'] ?? ''));
  889. if ($dpOut === '' || stripos($dpOut, '0000-00-00') === 0) {
  890. $fallback = trim((string)($r['dStamp'] ?? ''));
  891. if ($fallback !== '' && stripos($fallback, '0000-00-00') !== 0) {
  892. $r['dputrecord'] = $fallback;
  893. } elseif ($r['createtime'] !== '') {
  894. $r['dputrecord'] = $r['createtime'];
  895. }
  896. }
  897. $pickOut = trim((string)($r['pick_time'] ?? ''));
  898. if ($pickOut === '' || stripos($pickOut, '0000-00-00') === 0) {
  899. foreach (['createtime', 'dputrecord', 'dStamp'] as $pickFallbackKey) {
  900. $pickFallback = trim((string)($r[$pickFallbackKey] ?? ''));
  901. if ($pickFallback !== '' && stripos($pickFallback, '0000-00-00') !== 0) {
  902. $r['pick_time'] = $pickFallback;
  903. break;
  904. }
  905. }
  906. }
  907. return $r;
  908. }
  909. /**
  910. * @param array<string, mixed> $row
  911. */
  912. protected static function resolveRowListTime(array $row, string $primary = 'pick_time'): string
  913. {
  914. $keys = array_values(array_unique([$primary, 'pick_time', 'createtime', 'dputrecord', 'dStamp']));
  915. foreach ($keys as $k) {
  916. $t = trim((string)($row[$k] ?? ''));
  917. if ($t !== '' && stripos($t, '0000-00-00') !== 0) {
  918. return $t;
  919. }
  920. }
  921. return '';
  922. }
  923. /**
  924. * @param array<string, mixed> $row
  925. */
  926. protected static function rowMatchesMonthRange(array $row, string $monthStart, string $monthEnd, string $primary = 'pick_time'): bool
  927. {
  928. $listTime = self::resolveRowListTime($row, $primary);
  929. if ($listTime === '' || stripos($listTime, '0000-00-00') === 0
  930. || !preg_match('/^([12]\d{3})-(\d{1,2})-(\d{1,2})/', $listTime, $m)) {
  931. return false;
  932. }
  933. $mo = (int)$m[2];
  934. if ($mo < 1 || $mo > 12) {
  935. return false;
  936. }
  937. $ymRow = $m[1] . '-' . str_pad((string)$mo, 2, '0', STR_PAD_LEFT);
  938. $rowMonthStart = $ymRow . '-01 00:00:00';
  939. $rowMonthEnd = date('Y-m-t 23:59:59', strtotime($rowMonthStart));
  940. return !(strcmp($rowMonthEnd, $monthStart) < 0 || strcmp($rowMonthStart, $monthEnd) > 0);
  941. }
  942. /**
  943. * @return array{issued:int,confirm:int,approval:int,completed:int}
  944. */
  945. protected static function countStageMetrics(string $start, string $end): array
  946. {
  947. return [
  948. 'issued' => self::countIssuedInRange($start, $end),
  949. 'confirm' => self::countOperLogDistinctOrdersInRange(['issue_submit'], $start, $end),
  950. 'approval' => self::countOperLogDistinctOrdersInRange(['audit_select'], $start, $end),
  951. 'completed' => self::countCompletedInRange($start, $end),
  952. ];
  953. }
  954. /**
  955. * @return array{confirm:int,approval:int}
  956. */
  957. protected static function countCurrentPending(): array
  958. {
  959. return [
  960. 'confirm' => self::countDistinctOrders(self::loadPendingConfirmRows()),
  961. 'approval' => self::countDistinctOrders(self::loadPendingApprovalRows()),
  962. ];
  963. }
  964. protected static function countIssuedInRange(string $start, string $end): int
  965. {
  966. $startTs = ProcuremenTime::parseToTimestamp($start);
  967. $endTs = ProcuremenTime::parseToTimestamp($end);
  968. if ($startTs <= 0 || $endTs <= 0) {
  969. return 0;
  970. }
  971. $confirmSidSet = self::loadPendingConfirmScydgySet();
  972. $keys = [];
  973. $logs = self::loadOperLogsInRange(['issue_submit'], $startTs, $endTs);
  974. foreach ($logs as $log) {
  975. $sid = (int)($log['scydgy_id'] ?? 0);
  976. if ($sid <= 0 || isset($confirmSidSet[$sid])) {
  977. continue;
  978. }
  979. foreach (self::loadOrdersByScydgyIds([$sid]) as $row) {
  980. if (!is_array($row)) {
  981. continue;
  982. }
  983. $keys[self::resolveOrderKey($row)] = true;
  984. }
  985. }
  986. if ($keys !== []) {
  987. return count($keys);
  988. }
  989. return self::countIssuedByPickTimePastConfirm($start, $end);
  990. }
  991. /**
  992. * 无下发日志时:本时段 pick_time 内且流程已进入待审批/已审核(不含仍停在待确认)
  993. */
  994. protected static function countIssuedByPickTimePastConfirm(string $start, string $end): int
  995. {
  996. try {
  997. $expr = self::pickTimeSqlExpr();
  998. $query = Db::table('purchase_order')->field('id,CCYDH,wflow_status');
  999. self::applyNotDeletedWhere($query);
  1000. $query->whereRaw("({$expr}) >= ? AND ({$expr}) <= ?", [$start, $end]);
  1001. $query->whereIn('wflow_status', array_merge(
  1002. ProcuremenStatus::wflowPendingApprovalValues(),
  1003. ProcuremenStatus::wflowApprovedValues()
  1004. ));
  1005. $rows = $query->select();
  1006. } catch (\Throwable $e) {
  1007. return 0;
  1008. }
  1009. return self::countDistinctOrders(is_array($rows) ? $rows : []);
  1010. }
  1011. /**
  1012. * @return array<int, bool>
  1013. */
  1014. protected static function loadPendingConfirmScydgySet(): array
  1015. {
  1016. $set = [];
  1017. foreach (self::loadPendingConfirmRows() as $row) {
  1018. if (!is_array($row)) {
  1019. continue;
  1020. }
  1021. $sid = (int)($row['scydgy_id'] ?? 0);
  1022. if ($sid > 0) {
  1023. $set[$sid] = true;
  1024. }
  1025. }
  1026. return $set;
  1027. }
  1028. protected static function countCompletedInRange(string $start, string $end): int
  1029. {
  1030. $startTs = ProcuremenTime::parseToTimestamp($start);
  1031. $endTs = ProcuremenTime::parseToTimestamp($end);
  1032. if ($startTs <= 0 || $endTs <= 0) {
  1033. return 0;
  1034. }
  1035. try {
  1036. $logs = Db::table('purchase_order_oper_log')
  1037. ->where(ProcuremenOperLog::COL_ACTION, 'in', ProcuremenOperLog::expandActionQueryValues(['purchase_confirm', 'mark_complete']))
  1038. ->field(ProcuremenOperLog::COL_SCYDGY_ID . ',' . ProcuremenOperLog::COL_TIME)
  1039. ->select();
  1040. } catch (\Throwable $e) {
  1041. return 0;
  1042. }
  1043. if (!is_array($logs) || $logs === []) {
  1044. return 0;
  1045. }
  1046. $sidSet = [];
  1047. foreach ($logs as $log) {
  1048. if (!is_array($log)) {
  1049. continue;
  1050. }
  1051. $log = ProcuremenOperLog::normalizeRow($log);
  1052. $ts = ProcuremenTime::parseToTimestamp($log['createtime'] ?? null);
  1053. if ($ts < $startTs || $ts > $endTs) {
  1054. continue;
  1055. }
  1056. $sid = (int)($log['scydgy_id'] ?? 0);
  1057. if ($sid > 0) {
  1058. $sidSet[$sid] = true;
  1059. }
  1060. }
  1061. if ($sidSet === []) {
  1062. return 0;
  1063. }
  1064. return self::countDistinctOrders(self::loadOrdersByScydgyIds(array_keys($sidSet)));
  1065. }
  1066. /**
  1067. * @param int[] $scydgyIds
  1068. * @return array<int, array<string, mixed>>
  1069. */
  1070. protected static function loadOrdersByScydgyIds(array $scydgyIds): array
  1071. {
  1072. $scydgyIds = array_values(array_unique(array_filter(array_map('intval', $scydgyIds))));
  1073. if ($scydgyIds === []) {
  1074. return [];
  1075. }
  1076. try {
  1077. $query = Db::table('purchase_order')
  1078. ->where('scydgy_id', 'in', $scydgyIds)
  1079. ->field('id,CCYDH,scydgy_id,wflow_status');
  1080. self::applyNotDeletedWhere($query);
  1081. $rows = $query->select();
  1082. } catch (\Throwable $e) {
  1083. return [];
  1084. }
  1085. return is_array($rows) ? $rows : [];
  1086. }
  1087. /**
  1088. * @return array<int, array<string, mixed>>
  1089. */
  1090. protected static function loadPendingConfirmRows(): array
  1091. {
  1092. try {
  1093. $query = Db::table('purchase_order')->field('id,CCYDH,scydgy_id');
  1094. self::applyNotDeletedWhere($query);
  1095. $query->whereIn('wflow_status', ProcuremenStatus::wflowPendingConfirmValues());
  1096. $rows = $query->select();
  1097. } catch (\Throwable $e) {
  1098. return [];
  1099. }
  1100. return is_array($rows) ? $rows : [];
  1101. }
  1102. /**
  1103. * @return array<int, array<string, mixed>>
  1104. */
  1105. protected static function loadPendingApprovalRows(): array
  1106. {
  1107. try {
  1108. $query = Db::table('purchase_order')->field('id,CCYDH');
  1109. self::applyNotDeletedWhere($query);
  1110. $query->whereIn('wflow_status', ProcuremenStatus::wflowPendingApprovalValues());
  1111. $query->whereRaw('NOT (' . ProcuremenStatus::sqlPoCompleted('status') . ')');
  1112. $rows = $query->select();
  1113. } catch (\Throwable $e) {
  1114. return [];
  1115. }
  1116. return is_array($rows) ? $rows : [];
  1117. }
  1118. /**
  1119. * @param array<int, array<string, mixed>> $rows
  1120. */
  1121. protected static function countDistinctOrders(array $rows): int
  1122. {
  1123. if ($rows === []) {
  1124. return 0;
  1125. }
  1126. $keys = [];
  1127. foreach ($rows as $row) {
  1128. if (!is_array($row)) {
  1129. continue;
  1130. }
  1131. $dh = trim((string)($row['CCYDH'] ?? ''));
  1132. $keys[$dh !== '' ? $dh : ('_id_' . (int)($row['id'] ?? 0))] = true;
  1133. }
  1134. return count($keys);
  1135. }
  1136. protected static function applyNotDeletedWhere($query): void
  1137. {
  1138. $query->whereRaw(
  1139. '(mod_rq IS NULL OR TRIM(CAST(mod_rq AS CHAR(32))) = \'\' OR TRIM(CAST(mod_rq AS CHAR(32))) LIKE \'0000-00-00%\')'
  1140. );
  1141. }
  1142. protected static function pickTimeSqlExpr(): string
  1143. {
  1144. $norm = function (string $col): string {
  1145. return "NULLIF(NULLIF(TRIM(CAST({$col} AS CHAR(32))), ''), '0000-00-00')";
  1146. };
  1147. $createtime = 'IF(createtime > 946684800, FROM_UNIXTIME(createtime), ' . $norm('createtime') . ')';
  1148. $parts = [
  1149. $norm('pick_time'),
  1150. $createtime,
  1151. $norm('dputrecord'),
  1152. $norm('dStamp'),
  1153. ];
  1154. return 'COALESCE(' . implode(', ', $parts) . ')';
  1155. }
  1156. }