ProcuremenDashboard.php 42 KB

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