rawMaterialAnalysis.vue 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634
  1. <template>
  2. <div class="page">
  3. <header class="header">
  4. <el-button
  5. icon="el-icon-back"
  6. @click="$router.push('/tianzhu/raw-material')"
  7. >返回总览</el-button
  8. >
  9. <h2>{{ config.title }}</h2>
  10. <div class="filters">
  11. <el-date-picker
  12. v-model="range"
  13. type="daterange"
  14. value-format="yyyy-MM-dd"
  15. :clearable="false"
  16. range-separator="至"
  17. />
  18. <el-input
  19. v-model="rawMaterialName"
  20. clearable
  21. placeholder="原材料品类名称"
  22. />
  23. <el-input v-model="supplierName" clearable placeholder="供应商名称" />
  24. <el-button
  25. type="primary"
  26. icon="el-icon-search"
  27. :loading="loading"
  28. @click="load"
  29. >查询</el-button
  30. >
  31. </div>
  32. </header>
  33. <p v-if="notice" class="note">{{ notice }}</p>
  34. <el-alert v-if="error" type="error" :title="error" :closable="false" />
  35. <main>
  36. <section v-for="item in config.charts" :key="item.key" class="panel">
  37. <h3
  38. >{{ item.title }}<span>{{ item.unit }}</span></h3
  39. >
  40. <div :ref="item.key" class="chart"></div>
  41. </section>
  42. </main>
  43. </div>
  44. </template>
  45. <script>
  46. import * as echarts from 'echarts';
  47. import {
  48. getRawMaterialInventoryMonitoring,
  49. getRawMaterialPurchaseAnalysis,
  50. getRawMaterialSupplierAnalysis,
  51. getRawMaterialTransportWeighingAnalysis
  52. } from '@/api/tianzhu/rawMaterial';
  53. const pad = (n) => String(n).padStart(2, '0');
  54. const formatDate = (d) =>
  55. d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate());
  56. const value = (v) => Number(v || 0);
  57. const todayRange = () => {
  58. const now = new Date();
  59. return [
  60. formatDate(new Date(now.getFullYear(), now.getMonth(), 1)),
  61. formatDate(now)
  62. ];
  63. };
  64. const dayOf = (time) => String(time || '').slice(0, 10);
  65. const previousDay = (time) => {
  66. const d = new Date(dayOf(time) + 'T00:00:00');
  67. d.setDate(d.getDate() - 1);
  68. return formatDate(d);
  69. };
  70. const pageConfig = {
  71. supplier: {
  72. title: '供应商分析',
  73. loader: getRawMaterialSupplierAnalysis,
  74. noticeKey: 'supplyQuantityNotice',
  75. charts: [
  76. {
  77. key: 'supplierRanks',
  78. title: '供应商供货量排名',
  79. unit: '单位:t',
  80. type: 'rank',
  81. rows: 'supplierRanks'
  82. },
  83. {
  84. key: 'supplierCompare',
  85. title: '供应商净重与供货数量对比',
  86. unit: '净重(t) / 供货数量',
  87. type: 'compare',
  88. rows: 'supplierQuantityComparisons'
  89. }
  90. ]
  91. },
  92. material: {
  93. title: '原材料采购分析',
  94. loader: getRawMaterialPurchaseAnalysis,
  95. charts: [
  96. {
  97. key: 'materialPurchase',
  98. title: '各原材料采购量对比',
  99. unit: '单位:t',
  100. type: 'bar',
  101. rows: 'materialPurchaseVolumes'
  102. },
  103. {
  104. key: 'warehouseInbound',
  105. title: '储料仓库存分布',
  106. unit: '单位:t',
  107. type: 'pie',
  108. rows: 'warehouseInboundDistributions'
  109. }
  110. ]
  111. },
  112. inventory: {
  113. title: '库存监控',
  114. loader: getRawMaterialInventoryMonitoring,
  115. noticeKey: 'inventoryBalanceNotice',
  116. charts: [
  117. {
  118. key: 'balanceTrend',
  119. title: '库存余额趋势',
  120. unit: '单位:t',
  121. type: 'area',
  122. rows: 'inventoryBalanceTrends'
  123. },
  124. {
  125. key: 'inboundConsumption',
  126. title: '进料 vs 消耗对比',
  127. unit: '单位:t',
  128. type: 'inout',
  129. rows: 'inboundConsumptionTrends'
  130. }
  131. ]
  132. },
  133. transport: {
  134. title: '运输与过磅分析',
  135. loader: getRawMaterialTransportWeighingAnalysis,
  136. charts: [
  137. {
  138. key: 'vehicleRank',
  139. title: '车辆运输量排名',
  140. unit: '单位:t',
  141. type: 'rank',
  142. rows: 'vehicleTransportRanks'
  143. },
  144. {
  145. key: 'receiptWay',
  146. title: '收料方式占比',
  147. unit: '单位:车次',
  148. type: 'pie',
  149. rows: 'receiptWayRatios'
  150. },
  151. {
  152. key: 'dailyTrips',
  153. title: '每日车次趋势',
  154. unit: '单位:车次',
  155. type: 'line',
  156. rows: 'dailyPurchaseTrips'
  157. }
  158. ]
  159. }
  160. };
  161. export default {
  162. name: 'TianzhuRawMaterialAnalysis',
  163. data() {
  164. const q = this.$route.query;
  165. const range =
  166. q.startTime && q.endTime
  167. ? [dayOf(q.startTime), previousDay(q.endTime)]
  168. : todayRange();
  169. return {
  170. range,
  171. rawMaterialName: q.rawMaterialName || '',
  172. supplierName: q.supplierName || '',
  173. data: {},
  174. error: '',
  175. loading: false,
  176. charts: [],
  177. drawTimer: null,
  178. resizeTimer: null,
  179. lateResizeTimer: null
  180. };
  181. },
  182. computed: {
  183. type() {
  184. return this.$route.meta.drillType || 'supplier';
  185. },
  186. config() {
  187. return pageConfig[this.type] || pageConfig.supplier;
  188. },
  189. notice() {
  190. return this.config.noticeKey ? this.data[this.config.noticeKey] : '';
  191. }
  192. },
  193. watch: {
  194. '$route.meta.drillType'() {
  195. this.load();
  196. }
  197. },
  198. mounted() {
  199. window.addEventListener('resize', this.resize);
  200. this.load();
  201. },
  202. activated() {
  203. this.scheduleDraw();
  204. },
  205. beforeDestroy() {
  206. window.removeEventListener('resize', this.resize);
  207. clearTimeout(this.drawTimer);
  208. clearTimeout(this.resizeTimer);
  209. clearTimeout(this.lateResizeTimer);
  210. this.dispose();
  211. },
  212. methods: {
  213. query() {
  214. const end = new Date(this.range[1] + 'T00:00:00');
  215. end.setDate(end.getDate() + 1);
  216. const rawMaterialName = this.rawMaterialName.trim();
  217. const supplierName = this.supplierName.trim();
  218. return {
  219. startTime: this.range[0] + ' 00:00:00',
  220. endTime: formatDate(end) + ' 00:00:00',
  221. mixOrgId: this.$route.query.mixOrgId || undefined,
  222. rawMaterialName: rawMaterialName || undefined,
  223. supplierName: supplierName || undefined
  224. };
  225. },
  226. async load() {
  227. if (this.loading || !this.range || this.range.length !== 2) return;
  228. this.loading = true;
  229. this.error = '';
  230. try {
  231. this.data = (await this.config.loader(this.query())) || {};
  232. this.scheduleDraw();
  233. } catch (e) {
  234. this.error = e.message || '获取原材料下钻数据失败';
  235. } finally {
  236. this.loading = false;
  237. }
  238. },
  239. scheduleDraw() {
  240. clearTimeout(this.drawTimer);
  241. clearTimeout(this.resizeTimer);
  242. clearTimeout(this.lateResizeTimer);
  243. this.$nextTick(() => {
  244. this.drawTimer = setTimeout(() => {
  245. this.draw();
  246. this.resizeTimer = setTimeout(this.resize, 120);
  247. this.lateResizeTimer = setTimeout(this.resize, 500);
  248. }, 80);
  249. });
  250. },
  251. dispose() {
  252. this.charts.forEach((chart) => chart.dispose());
  253. this.charts = [];
  254. },
  255. resize() {
  256. this.charts.forEach((chart) => chart.resize());
  257. },
  258. init(key, rows) {
  259. const node = Array.isArray(this.$refs[key])
  260. ? this.$refs[key][0]
  261. : this.$refs[key];
  262. if (!node) return null;
  263. const chart = echarts.init(node);
  264. if (!rows.length)
  265. chart.setOption({
  266. graphic: [
  267. {
  268. type: 'text',
  269. left: 'center',
  270. top: 'middle',
  271. style: { text: '暂无数据', fill: '#83acd0', fontSize: 14 }
  272. }
  273. ]
  274. });
  275. this.charts.push(chart);
  276. return chart;
  277. },
  278. axis(unit) {
  279. return {
  280. type: 'value',
  281. name: unit,
  282. nameTextStyle: { color: '#9bc7eb' },
  283. axisLabel: { color: '#9bc7eb' },
  284. splitLine: {
  285. lineStyle: { color: 'rgba(105,175,226,.22)', type: 'dashed' }
  286. }
  287. };
  288. },
  289. draw() {
  290. this.dispose();
  291. this.config.charts.forEach((item) => {
  292. const rows = Array.isArray(this.data[item.rows])
  293. ? this.data[item.rows]
  294. : [];
  295. if (item.type === 'rank') this.rank(item.key, rows, 't');
  296. if (item.type === 'bar') this.bar(item.key, rows, 't');
  297. if (item.type === 'pie') this.pie(item.key, rows);
  298. if (item.type === 'line') this.line(item.key, rows, '车次');
  299. if (item.type === 'area') this.area(item.key, rows);
  300. if (item.type === 'inout') this.inout(item.key, rows);
  301. if (item.type === 'compare') this.compare(item.key, rows);
  302. });
  303. },
  304. rank(key, rows, unit) {
  305. const chart = this.init(key, rows);
  306. if (!chart || !rows.length) return;
  307. const sorted = [...rows].sort(
  308. (a, b) => value(b.value) - value(a.value)
  309. );
  310. chart.setOption({
  311. color: ['#20b7ff'],
  312. tooltip: {
  313. trigger: 'axis',
  314. valueFormatter: (v) => value(v).toLocaleString() + ' ' + unit
  315. },
  316. grid: { left: 128, right: 42, top: 22, bottom: 26 },
  317. xAxis: this.axis(unit),
  318. yAxis: {
  319. type: 'category',
  320. inverse: true,
  321. data: sorted.map((row) => row.name || '未标注'),
  322. axisLabel: { color: '#9bc7eb', width: 116, overflow: 'truncate' },
  323. axisLine: { lineStyle: { color: '#197abb' } }
  324. },
  325. series: [
  326. {
  327. type: 'bar',
  328. data: sorted.map((row) => value(row.value)),
  329. barMaxWidth: 24,
  330. label: {
  331. show: true,
  332. position: 'right',
  333. color: '#d8ecff',
  334. formatter: (p) => value(p.value).toLocaleString()
  335. },
  336. itemStyle: { borderRadius: [0, 4, 4, 0] }
  337. }
  338. ]
  339. });
  340. },
  341. bar(key, rows, unit) {
  342. const chart = this.init(key, rows);
  343. if (!chart || !rows.length) return;
  344. chart.setOption({
  345. color: ['#36e0a4'],
  346. tooltip: {
  347. trigger: 'axis',
  348. valueFormatter: (v) => value(v).toLocaleString() + ' ' + unit
  349. },
  350. grid: { left: 58, right: 18, top: 26, bottom: 62 },
  351. xAxis: {
  352. type: 'category',
  353. data: rows.map((row) => row.name || '未标注'),
  354. axisLabel: {
  355. color: '#9bc7eb',
  356. interval: 0,
  357. rotate: rows.length > 5 ? 28 : 0
  358. },
  359. axisLine: { lineStyle: { color: '#197abb' } }
  360. },
  361. yAxis: this.axis(unit),
  362. series: [
  363. {
  364. type: 'bar',
  365. data: rows.map((row) => value(row.value)),
  366. barMaxWidth: 34,
  367. itemStyle: { borderRadius: [4, 4, 0, 0] }
  368. }
  369. ]
  370. });
  371. },
  372. pie(key, rows) {
  373. const chart = this.init(key, rows);
  374. if (!chart || !rows.length) return;
  375. chart.setOption({
  376. color: ['#20b7ff', '#36e0a4', '#ffc54a', '#a582ff', '#ff7a7a'],
  377. tooltip: { trigger: 'item' },
  378. legend: {
  379. bottom: 0,
  380. type: 'scroll',
  381. textStyle: { color: '#a9cdeb' }
  382. },
  383. series: [
  384. {
  385. type: 'pie',
  386. radius: ['42%', '67%'],
  387. data: rows.map((row) => ({
  388. name: row.name || '未标注',
  389. value: value(row.value)
  390. })),
  391. label: { color: '#d7edff', formatter: '{b}\n{d}%' }
  392. }
  393. ]
  394. });
  395. },
  396. line(key, rows, unit) {
  397. const chart = this.init(key, rows);
  398. if (!chart || !rows.length) return;
  399. chart.setOption({
  400. color: ['#20b7ff'],
  401. tooltip: {
  402. trigger: 'axis',
  403. valueFormatter: (v) => value(v).toLocaleString() + ' ' + unit
  404. },
  405. grid: { left: 58, right: 18, top: 28, bottom: 36 },
  406. xAxis: {
  407. type: 'category',
  408. data: rows.map((row) => row.statDate),
  409. axisLabel: { color: '#9bc7eb' },
  410. axisLine: { lineStyle: { color: '#197abb' } }
  411. },
  412. yAxis: this.axis(unit),
  413. series: [
  414. {
  415. type: 'line',
  416. smooth: true,
  417. showSymbol: false,
  418. areaStyle: { opacity: 0.14 },
  419. data: rows.map((row) => value(row.value))
  420. }
  421. ]
  422. });
  423. },
  424. area(key, rows) {
  425. const chart = this.init(key, rows);
  426. if (!chart || !rows.length) return;
  427. chart.setOption({
  428. color: ['#ffc54a'],
  429. tooltip: {
  430. trigger: 'axis',
  431. valueFormatter: (v) => value(v).toLocaleString() + ' t'
  432. },
  433. grid: { left: 58, right: 18, top: 28, bottom: 36 },
  434. xAxis: {
  435. type: 'category',
  436. data: rows.map((row) => row.statDate),
  437. axisLabel: { color: '#9bc7eb' },
  438. axisLine: { lineStyle: { color: '#197abb' } }
  439. },
  440. yAxis: this.axis('t'),
  441. series: [
  442. {
  443. type: 'line',
  444. smooth: true,
  445. showSymbol: false,
  446. areaStyle: { opacity: 0.22 },
  447. data: rows.map((row) => value(row.endingInventory || row.value))
  448. }
  449. ]
  450. });
  451. },
  452. inout(key, rows) {
  453. const chart = this.init(key, rows);
  454. if (!chart || !rows.length) return;
  455. chart.setOption({
  456. color: ['#20b7ff', '#36e0a4'],
  457. tooltip: {
  458. trigger: 'axis',
  459. valueFormatter: (v) => value(v).toLocaleString() + ' t'
  460. },
  461. legend: { top: 0, textStyle: { color: '#a9cdeb' } },
  462. grid: { left: 58, right: 18, top: 42, bottom: 36 },
  463. xAxis: {
  464. type: 'category',
  465. data: rows.map((row) => row.statDate),
  466. axisLabel: { color: '#9bc7eb' },
  467. axisLine: { lineStyle: { color: '#197abb' } }
  468. },
  469. yAxis: this.axis('t'),
  470. series: [
  471. {
  472. name: '进料',
  473. type: 'bar',
  474. barMaxWidth: 28,
  475. data: rows.map((row) => value(row.purchaseVolume))
  476. },
  477. {
  478. name: '消耗',
  479. type: 'bar',
  480. barMaxWidth: 28,
  481. data: rows.map((row) => value(row.consumptionVolume))
  482. }
  483. ]
  484. });
  485. },
  486. compare(key, rows) {
  487. const chart = this.init(key, rows);
  488. if (!chart || !rows.length) return;
  489. chart.setOption({
  490. color: ['#20b7ff', '#ffc54a'],
  491. tooltip: { trigger: 'axis' },
  492. legend: { top: 0, textStyle: { color: '#a9cdeb' } },
  493. grid: { left: 58, right: 18, top: 42, bottom: 62 },
  494. xAxis: {
  495. type: 'category',
  496. data: rows.map((row) => row.name || '未标注'),
  497. axisLabel: {
  498. color: '#9bc7eb',
  499. interval: 0,
  500. rotate: rows.length > 5 ? 28 : 0
  501. },
  502. axisLine: { lineStyle: { color: '#197abb' } }
  503. },
  504. yAxis: this.axis(''),
  505. series: [
  506. {
  507. name: '净重(t)',
  508. type: 'bar',
  509. barMaxWidth: 26,
  510. data: rows.map((row) => value(row.value))
  511. },
  512. {
  513. name: '供货数量',
  514. type: 'bar',
  515. barMaxWidth: 26,
  516. data: rows.map((row) => value(row.compareValue))
  517. }
  518. ]
  519. });
  520. }
  521. }
  522. };
  523. </script>
  524. <style scoped>
  525. .page {
  526. min-height: 100vh;
  527. padding: 16px;
  528. color: #d8ecff;
  529. background: #020d28;
  530. background-image: linear-gradient(
  531. rgba(31, 93, 157, 0.12) 1px,
  532. transparent 1px
  533. ),
  534. linear-gradient(90deg, rgba(31, 93, 157, 0.12) 1px, transparent 1px);
  535. background-size: 32px 32px;
  536. }
  537. .header {
  538. display: flex;
  539. align-items: center;
  540. justify-content: space-between;
  541. gap: 12px;
  542. padding-bottom: 12px;
  543. border-bottom: 1px solid #167fcd;
  544. }
  545. h2 {
  546. margin: 0;
  547. color: #fff;
  548. letter-spacing: 3px;
  549. }
  550. .filters {
  551. display: flex;
  552. gap: 8px;
  553. align-items: center;
  554. justify-content: flex-end;
  555. flex-wrap: wrap;
  556. }
  557. .filters .el-date-editor {
  558. width: 260px;
  559. }
  560. .filters .el-input {
  561. width: 170px;
  562. }
  563. .note {
  564. margin: 12px 0 0;
  565. color: #87b7da;
  566. font-size: 12px;
  567. }
  568. main {
  569. display: grid;
  570. grid-template-columns: repeat(2, minmax(320px, 1fr));
  571. gap: 12px;
  572. margin-top: 14px;
  573. }
  574. .panel {
  575. height: 310px;
  576. padding: 10px;
  577. border: 1px solid #167fcd;
  578. background: rgba(4, 37, 88, 0.86);
  579. }
  580. h3 {
  581. margin: 0;
  582. color: #e6f6ff;
  583. font-size: 14px;
  584. }
  585. h3 span {
  586. margin-left: 8px;
  587. color: #79a9ce;
  588. font-weight: normal;
  589. font-size: 12px;
  590. }
  591. .chart {
  592. height: 275px;
  593. width: 100%;
  594. }
  595. :deep(.el-input__inner) {
  596. background: #06265b;
  597. border-color: #167fcd;
  598. color: #d8ecff;
  599. }
  600. :deep(.el-range-input) {
  601. background: transparent;
  602. color: #d8ecff;
  603. }
  604. :deep(.el-range-separator),
  605. :deep(.el-input__icon) {
  606. color: #a9cdeb;
  607. }
  608. :deep(.el-button) {
  609. background: #06488e;
  610. border-color: #168ee1;
  611. color: #fff;
  612. }
  613. @media (max-width: 1000px) {
  614. main {
  615. grid-template-columns: 1fr;
  616. }
  617. .header {
  618. align-items: flex-start;
  619. flex-direction: column;
  620. }
  621. .filters {
  622. justify-content: flex-start;
  623. }
  624. .filters .el-date-editor,
  625. .filters .el-input {
  626. width: 245px;
  627. }
  628. }
  629. </style>