| | |
| | | |
| | | <div class="filter-layout"> |
| | | <el-form :model="searchForm" :inline="true" class="filter-form"> |
| | | <el-form-item label="月份范围"> |
| | | <el-form-item label="月份"> |
| | | <el-date-picker |
| | | v-model="searchForm.monthRange" |
| | | type="monthrange" |
| | | range-separator="至" |
| | | start-placeholder="开始月份" |
| | | end-placeholder="结束月份" |
| | | v-model="searchForm.month" |
| | | type="month" |
| | | value-format="YYYY-MM" |
| | | placeholder="选择月份" |
| | | class="w-260" |
| | | @change="handleQuery" |
| | | @change="handleMonthChange" |
| | | /> |
| | | </el-form-item> |
| | | <el-form-item label="产品类别"> |
| | | <el-form-item label="产品类型"> |
| | | <el-select |
| | | v-model="searchForm.category" |
| | | v-model="searchForm.productType" |
| | | clearable |
| | | filterable |
| | | placeholder="全部类别" |
| | | placeholder="全部类型" |
| | | class="w-180" |
| | | @change="handleQuery" |
| | | > |
| | | <el-option |
| | | v-for="item in categoryOptions" |
| | | :key="item" |
| | | :label="item" |
| | | :value="item" |
| | | :key="item.value" |
| | | :label="item.label" |
| | | :value="item.value" |
| | | /> |
| | | </el-select> |
| | | </el-form-item> |
| | |
| | | <el-select |
| | | v-model="searchForm.costType" |
| | | clearable |
| | | placeholder="全部类型" |
| | | placeholder="全部成本类型" |
| | | class="w-180" |
| | | @change="handleQuery" |
| | | > |
| | | <el-option label="能耗成本" value="能耗成本" /> |
| | | <el-option label="生产成本" value="生产成本" /> |
| | | <el-option |
| | | v-for="item in costTypeOptions" |
| | | :key="item.value" |
| | | :label="item.label" |
| | | :value="item.value" |
| | | /> |
| | | </el-select> |
| | | </el-form-item> |
| | | </el-form> |
| | |
| | | <el-button class="lux-btn" @click="handleReset">重置</el-button> |
| | | </div> |
| | | <div class="action-group"> |
| | | <el-dropdown trigger="click" @command="handleImportCommand"> |
| | | <el-button class="lux-btn" type="success" plain> |
| | | <el-button class="lux-btn" type="success" plain @click="openImportDialog"> |
| | | 标准成本导入 |
| | | <el-icon class="el-icon--right"><ArrowDown /></el-icon> |
| | | </el-button> |
| | | <template #dropdown> |
| | | <el-dropdown-menu> |
| | | <el-dropdown-item command="template">下载导入模板</el-dropdown-item> |
| | | <el-dropdown-item command="upload">Excel 导入</el-dropdown-item> |
| | | </el-dropdown-menu> |
| | | </template> |
| | | </el-dropdown> |
| | | <el-upload |
| | | ref="uploadRef" |
| | | class="hidden-upload" |
| | | :auto-upload="false" |
| | | :show-file-list="false" |
| | | accept=".xlsx,.xls" |
| | | :on-change="handleFileChange" |
| | | /> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </el-card> |
| | | |
| | | <ImportDialog |
| | | ref="importDialogRef" |
| | | v-model="importDialogVisible" |
| | | title="标准成本导入" |
| | | width="520px" |
| | | :headers="importHeaders" |
| | | :action="importAction" |
| | | :auto-upload="false" |
| | | :limit="1" |
| | | tip-text="仅允许导入 xls、xlsx 格式文件。" |
| | | :show-download-template="true" |
| | | :on-success="handleImportSuccess" |
| | | @confirm="handleImportConfirm" |
| | | @download-template="downloadTemplate" |
| | | @close="handleImportDialogClose" |
| | | @cancel="handleImportDialogClose" |
| | | /> |
| | | |
| | | <el-card class="panel-card glass-card kpi-card" shadow="never"> |
| | | <div class="kpi-strip"> |
| | |
| | | </div> |
| | | </template> |
| | | <el-table :data="pagedTableData" stripe class="lux-table" @sort-change="handleSortChange"> |
| | | <el-table-column prop="month" label="月份" width="110" /> |
| | | <el-table-column prop="category" label="产品类别" min-width="140" /> |
| | | <el-table-column prop="periodTime" label="月份" width="110" /> |
| | | <el-table-column prop="productType" label="产品类型" min-width="140" /> |
| | | <el-table-column prop="costType" label="成本类型" min-width="120" /> |
| | | <el-table-column prop="standardCost" label="标准成本(元)" sortable="custom" align="right"> |
| | | <template #default="scope">¥{{ formatMoney(scope.row.standardCost) }}</template> |
| | | <el-table-column prop="subjectName" label="科目" min-width="140" show-overflow-tooltip /> |
| | | <el-table-column prop="budgetQty" label="预算耗量" sortable="custom" align="right" min-width="120" /> |
| | | <el-table-column prop="budgetPrice" label="预算单价" sortable="custom" align="right" min-width="120" /> |
| | | <el-table-column prop="budgetTotal" label="预算总成本" sortable="custom" align="right" min-width="130"> |
| | | <template #default="scope">¥{{ formatMoney(scope.row.budgetTotal) }}</template> |
| | | </el-table-column> |
| | | <el-table-column prop="actualCost" label="实际成本(元)" sortable="custom" align="right"> |
| | | <template #default="scope">¥{{ formatMoney(scope.row.actualCost) }}</template> |
| | | <el-table-column prop="actualQty" label="实际耗量" sortable="custom" align="right" min-width="120" /> |
| | | <el-table-column prop="actualPrice" label="实际单价" sortable="custom" align="right" min-width="120" /> |
| | | <el-table-column prop="actualTotal" label="实际总成本" sortable="custom" align="right" min-width="130"> |
| | | <template #default="scope">¥{{ formatMoney(scope.row.actualTotal) }}</template> |
| | | </el-table-column> |
| | | <el-table-column prop="diff" label="差异(元)" sortable="custom" align="right"> |
| | | <template #default="scope"> |
| | | <span :class="scope.row.diff >= 0 ? 'cost-value' : 'ok-value'"> |
| | | {{ formatSignedMoney(scope.row.diff) }} |
| | | </span> |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column prop="diffRate" label="差异率" sortable="custom" align="right"> |
| | | <template #default="scope"> |
| | | <span :class="scope.row.diffRate >= 0 ? 'cost-value' : 'ok-value'"> |
| | | {{ formatPercent(scope.row.diffRate) }} |
| | | </span> |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column prop="diffQty" label="耗量差异" min-width="110" align="right" /> |
| | | <el-table-column prop="diffPrice" label="单价差异" min-width="110" align="right" /> |
| | | <el-table-column prop="diffTotal" label="总成本差异" min-width="110" align="right" /> |
| | | </el-table> |
| | | <div class="pagination-container"> |
| | | <el-pagination |
| | |
| | | ZoomIn, |
| | | } from "@element-plus/icons-vue"; |
| | | import { ElMessage } from "element-plus"; |
| | | import ImportDialog from "@/components/Dialog/ImportDialog.vue"; |
| | | import { getToken } from "@/utils/auth.js"; |
| | | import * as echarts from "echarts"; |
| | | // import * as XLSX from "xlsx"; |
| | | import { |
| | | downloadTemplate as downloadProductionSettlementTemplate, |
| | | getImportActionUrl, |
| | | getSettlement, |
| | | getTotalCosts, |
| | | getProductTypes, |
| | | } from "@/api/costAccounting/productionSettlementBatches"; |
| | | |
| | | const getDefaultMonthRange = () => { |
| | | const getDefaultMonth = () => { |
| | | const end = new Date(); |
| | | const start = new Date(); |
| | | start.setMonth(start.getMonth() - 2); |
| | | return [start.toISOString().slice(0, 7), end.toISOString().slice(0, 7)]; |
| | | return end.toISOString().slice(0, 7); |
| | | }; |
| | | |
| | | const searchForm = reactive({ |
| | | monthRange: getDefaultMonthRange(), |
| | | category: "", |
| | | month: getDefaultMonth(), |
| | | productType: "", |
| | | costType: "", |
| | | }); |
| | | |
| | | const uploadRef = ref(); |
| | | const categoryOptions = ref([]); |
| | | const costTypeOptions = ref([]); |
| | | |
| | | const importDialogVisible = ref(false); |
| | | const importDialogRef = ref(null); |
| | | |
| | | const importHeaders = computed(() => ({ |
| | | Authorization: `Bearer ${getToken()}`, |
| | | })); |
| | | |
| | | const importAction = computed(() => getImportActionUrl()); |
| | | |
| | | const chartRef = ref(null); |
| | | const largeChartRef = ref(null); |
| | | let chartInstance = null; |
| | |
| | | const largeChartVisible = ref(false); |
| | | const currentChartOption = ref(null); |
| | | |
| | | // ------------------------------ |
| | | // 假数据:用于先联调页面渲染 |
| | | // ------------------------------ |
| | | const actualCostSource = ref([]); |
| | | const standardCostSource = ref([]); |
| | | |
| | | const fakeMonths = ["2026-01", "2026-02", "2026-03"]; |
| | | const fakeCategories = [ |
| | | "粉煤灰", |
| | | "石灰", |
| | | "水泥", |
| | | "铝粉膏", |
| | | "脱模剂", |
| | | "石膏", |
| | | "打包带", |
| | | "防腐剂(板材用)", |
| | | "氧化镁(板材用)", |
| | | "冷挤丝(板材用)", |
| | | "卡扣(板材用)", |
| | | "材料小计", |
| | | "水", |
| | | "电", |
| | | "蒸汽", |
| | | ]; |
| | | |
| | | const fakeCostType = (category) => (["水", "电", "蒸汽"].includes(category) ? "能耗成本" : "生产成本"); |
| | | |
| | | // 每个类别的标准成本基准值(仅用于假数据) |
| | | const baseStandardCostByCategory = { |
| | | 粉煤灰: 98000, |
| | | 石灰: 52000, |
| | | 水泥: 175000, |
| | | 铝粉膏: 32000, |
| | | 脱模剂: 21000, |
| | | 石膏: 41000, |
| | | 打包带: 14500, |
| | | "防腐剂(板材用)": 12500, |
| | | "氧化镁(板材用)": 22000, |
| | | "冷挤丝(板材用)": 9800, |
| | | "卡扣(板材用)": 8600, |
| | | 材料小计: 420000, |
| | | 水: 6800, |
| | | 电: 26000, |
| | | 蒸汽: 52000, |
| | | }; |
| | | |
| | | // 月份波动系数(让图表看起来更“真实”一些) |
| | | const monthFactorByMonth = { |
| | | "2026-01": 1.0, |
| | | "2026-02": 1.06, |
| | | "2026-03": 0.97, |
| | | }; |
| | | |
| | | // 实际成本相对标准成本的偏移比例(用于测试正负差异展示) |
| | | const diffRatioByCategory = { |
| | | 粉煤灰: 0.05, |
| | | 石灰: -0.01, |
| | | 水泥: 0.03, |
| | | 铝粉膏: 0.0, |
| | | 脱模剂: -0.04, |
| | | 石膏: 0.02, |
| | | 打包带: -0.03, |
| | | "防腐剂(板材用)": 0.06, |
| | | "氧化镁(板材用)": -0.02, |
| | | "冷挤丝(板材用)": 0.01, |
| | | "卡扣(板材用)": -0.05, |
| | | 材料小计: 0.02, |
| | | 水: -0.01, |
| | | 电: 0.04, |
| | | 蒸汽: -0.03, |
| | | }; |
| | | |
| | | const buildFakeSources = () => { |
| | | const stdRows = []; |
| | | const actRows = []; |
| | | |
| | | for (const month of fakeMonths) { |
| | | const monthFactor = monthFactorByMonth[month] ?? 1; |
| | | const monthAdj = month === "2026-02" ? 0.005 : month === "2026-03" ? -0.006 : 0; |
| | | |
| | | for (const category of fakeCategories) { |
| | | const costType = fakeCostType(category); |
| | | const base = baseStandardCostByCategory[category] ?? 0; |
| | | const standardCost = Math.round(base * monthFactor); |
| | | const diffRatio = (diffRatioByCategory[category] ?? 0) + monthAdj; |
| | | const actualCost = Math.round(standardCost * (1 + diffRatio)); |
| | | |
| | | stdRows.push({ month, category, costType, standardCost }); |
| | | actRows.push({ month, category, costType, actualCost }); |
| | | } |
| | | } |
| | | |
| | | standardCostSource.value = stdRows; |
| | | actualCostSource.value = actRows; |
| | | }; |
| | | |
| | | buildFakeSources(); |
| | | |
| | | const categoryOptions = computed(() => { |
| | | const all = [...actualCostSource.value, ...standardCostSource.value]; |
| | | return Array.from(new Set(all.map((item) => item.category))); |
| | | const settlementRows = ref([]); |
| | | const totalCosts = reactive({ |
| | | budgetTotal: 0, |
| | | actualTotal: 0, |
| | | diffTotal: 0, |
| | | diffRate: "0%", |
| | | }); |
| | | |
| | | const inRange = (value, range) => { |
| | | if (!Array.isArray(range) || range.length !== 2 || !range[0] || !range[1]) return true; |
| | | return value >= range[0] && value <= range[1]; |
| | | }; |
| | | |
| | | const mergedRows = computed(() => { |
| | | const key = (item) => `${item.month}__${item.category}__${item.costType}`; |
| | | const stdMap = new Map(standardCostSource.value.map((item) => [key(item), item])); |
| | | const actMap = new Map(actualCostSource.value.map((item) => [key(item), item])); |
| | | const keySet = new Set([...stdMap.keys(), ...actMap.keys()]); |
| | | const rows = []; |
| | | |
| | | for (const k of keySet) { |
| | | const std = stdMap.get(k); |
| | | const act = actMap.get(k); |
| | | const month = std?.month || act?.month || ""; |
| | | const category = std?.category || act?.category || ""; |
| | | const costType = std?.costType || act?.costType || ""; |
| | | const standardCost = Number(std?.standardCost || 0); |
| | | const actualCost = Number(act?.actualCost || 0); |
| | | const diff = actualCost - standardCost; |
| | | const diffRate = standardCost === 0 ? 0 : (diff / standardCost) * 100; |
| | | |
| | | rows.push({ month, category, costType, standardCost, actualCost, diff, diffRate }); |
| | | } |
| | | |
| | | return rows.sort((a, b) => { |
| | | if (a.month !== b.month) return a.month > b.month ? 1 : -1; |
| | | if (a.category !== b.category) return a.category.localeCompare(b.category, "zh-Hans-CN"); |
| | | return a.costType.localeCompare(b.costType, "zh-Hans-CN"); |
| | | }); |
| | | }); |
| | | |
| | | const tableData = computed(() => |
| | | mergedRows.value.filter((item) => { |
| | | const hitMonth = inRange(item.month, searchForm.monthRange); |
| | | const hitCategory = !searchForm.category || item.category === searchForm.category; |
| | | const tableData = computed(() => { |
| | | return (Array.isArray(settlementRows.value) ? settlementRows.value : []).filter((item) => { |
| | | const hitMonth = !searchForm.month || item.periodTime === searchForm.month; |
| | | const hitProductType = !searchForm.productType || item.productType === searchForm.productType; |
| | | const hitCostType = !searchForm.costType || item.costType === searchForm.costType; |
| | | return hitMonth && hitCategory && hitCostType; |
| | | }) |
| | | ); |
| | | return hitMonth && hitProductType && hitCostType; |
| | | }); |
| | | }); |
| | | |
| | | const page = reactive({ |
| | | current: 1, |
| | |
| | | return sortedTableData.value.slice(start, start + page.size); |
| | | }); |
| | | |
| | | const overview = computed(() => { |
| | | const standardCost = tableData.value.reduce((sum, item) => sum + item.standardCost, 0); |
| | | const actualCost = tableData.value.reduce((sum, item) => sum + item.actualCost, 0); |
| | | const diff = actualCost - standardCost; |
| | | const diffRate = standardCost === 0 ? 0 : (diff / standardCost) * 100; |
| | | return { standardCost, actualCost, diff, diffRate }; |
| | | }); |
| | | const overview = computed(() => ({ |
| | | standardCost: Number(totalCosts.budgetTotal || 0), |
| | | actualCost: Number(totalCosts.actualTotal || 0), |
| | | diff: Number(totalCosts.diffTotal || 0), |
| | | diffRate: totalCosts.diffRate ?? "0%", |
| | | })); |
| | | |
| | | const getChartData = () => { |
| | | const xAxis = tableData.value.map( |
| | | (item) => `${item.month}\n${item.category}-${item.costType.replace("成本", "")}` |
| | | // 图表口径:按“科目”汇总展示全部科目 |
| | | const agg = new Map(); |
| | | for (const row of tableData.value) { |
| | | const subjectName = String(row?.subjectName || "").trim() || "-"; |
| | | const budgetTotal = Number(row?.budgetTotal || 0); |
| | | const actualTotal = Number(row?.actualTotal || 0); |
| | | const bucket = agg.get(subjectName) || { subjectName, budgetTotal: 0, actualTotal: 0 }; |
| | | bucket.budgetTotal += Number.isFinite(budgetTotal) ? budgetTotal : 0; |
| | | bucket.actualTotal += Number.isFinite(actualTotal) ? actualTotal : 0; |
| | | agg.set(subjectName, bucket); |
| | | } |
| | | |
| | | const rows = Array.from(agg.values()).sort((a, b) => |
| | | String(a.subjectName).localeCompare(String(b.subjectName), "zh-Hans-CN") |
| | | ); |
| | | const standard = tableData.value.map((item) => item.standardCost); |
| | | const actual = tableData.value.map((item) => item.actualCost); |
| | | const diffRate = tableData.value.map((item) => Number(item.diffRate.toFixed(2))); |
| | | return { xAxis, standard, actual, diffRate }; |
| | | |
| | | const xAxis = rows.map((item) => item.subjectName); |
| | | const standard = rows.map((item) => item.budgetTotal); |
| | | const actual = rows.map((item) => item.actualTotal); |
| | | const diffRate = rows.map((item) => { |
| | | const base = Number(item.budgetTotal || 0); |
| | | const diff = Number(item.actualTotal || 0) - base; |
| | | if (!base) return 0; |
| | | return (diff / base) * 100; |
| | | }); |
| | | |
| | | return { xAxis, standard, actual, diffRate, rows }; |
| | | }; |
| | | |
| | | const buildChartOption = () => { |
| | | const { xAxis, standard, actual, diffRate } = getChartData(); |
| | | const { xAxis, standard, actual, diffRate, rows } = getChartData(); |
| | | return { |
| | | animation: true, |
| | | animationDuration: 920, |
| | |
| | | textStyle: { color: "rgba(15, 23, 42, 0.88)" }, |
| | | extraCssText: "box-shadow: 0 12px 40px rgba(15, 23, 42, 0.12); border-radius: 12px;", |
| | | formatter: (params) => { |
| | | const row = tableData.value[params[0]?.dataIndex] || {}; |
| | | const row = rows?.[params[0]?.dataIndex] || {}; |
| | | const budgetTotal = Number(row?.budgetTotal || 0); |
| | | const actualTotal = Number(row?.actualTotal || 0); |
| | | const diff = actualTotal - budgetTotal; |
| | | const rate = budgetTotal ? (diff / budgetTotal) * 100 : 0; |
| | | return [ |
| | | `${row.month || ""} ${row.category || ""} ${row.costType || ""}`, |
| | | `标准成本:¥${formatMoney(row.standardCost || 0)}`, |
| | | `实际成本:¥${formatMoney(row.actualCost || 0)}`, |
| | | `差异:${formatSignedMoney(row.diff || 0)}`, |
| | | `差异率:${formatPercent(row.diffRate || 0)}`, |
| | | `科目:${row.subjectName || "-"}`, |
| | | `预算总成本:¥${formatMoney(budgetTotal)}`, |
| | | `实际总成本:¥${formatMoney(actualTotal)}`, |
| | | `差异:${formatSignedMoney(diff)}`, |
| | | `差异率:${formatPercent(rate)}`, |
| | | ].join("<br/>"); |
| | | }, |
| | | }, |
| | |
| | | standardCostSource.value = Array.from(map.values()); |
| | | }; |
| | | |
| | | const handleFileChange = async (uploadFile) => { |
| | | try { |
| | | const file = uploadFile.raw; |
| | | if (!file) return; |
| | | const data = await file.arrayBuffer(); |
| | | const workbook = XLSX.read(data, { type: "array" }); |
| | | const sheetName = workbook.SheetNames[0]; |
| | | const sheet = workbook.Sheets[sheetName]; |
| | | const rows = XLSX.utils.sheet_to_json(sheet, { defval: "" }); |
| | | const parsed = parseImportedRows(rows); |
| | | if (!parsed.length) { |
| | | ElMessage.warning("导入失败:模板内容为空或字段不匹配"); |
| | | return; |
| | | } |
| | | replaceStandardSourceByImport(parsed); |
| | | ElMessage.success(`导入成功:${parsed.length} 条标准成本记录`); |
| | | handleQuery(); |
| | | } catch (error) { |
| | | console.error(error); |
| | | ElMessage.error("导入失败,请检查 Excel 格式"); |
| | | } finally { |
| | | uploadRef.value?.clearFiles?.(); |
| | | } |
| | | const openImportDialog = () => { |
| | | importDialogVisible.value = true; |
| | | }; |
| | | |
| | | const openUploadSelector = () => { |
| | | const input = uploadRef.value?.$el?.querySelector?.("input[type='file']"); |
| | | if (!input) { |
| | | ElMessage.warning("上传组件尚未就绪,请稍后重试"); |
| | | return; |
| | | } |
| | | input.click(); |
| | | const handleImportConfirm = () => { |
| | | importDialogRef.value?.submit?.(); |
| | | }; |
| | | |
| | | const handleImportCommand = (command) => { |
| | | if (command === "template") { |
| | | downloadTemplate(); |
| | | return; |
| | | } |
| | | if (command === "upload") { |
| | | openUploadSelector(); |
| | | } |
| | | const handleImportDialogClose = () => { |
| | | importDialogRef.value?.clearFiles?.(); |
| | | }; |
| | | |
| | | const downloadTemplate = () => { |
| | | const sample = [ |
| | | { 月份: "2026-03", 产品类别: "粉煤灰", 成本类型: "标准生产成本", 标准成本: 98000 }, |
| | | { 月份: "2026-03", 产品类别: "水泥", 成本类型: "标准生产成本", 标准成本: 175000 }, |
| | | { 月份: "2026-03", 产品类别: "电", 成本类型: "标准能耗成本", 标准成本: 26000 }, |
| | | { 月份: "2026-03", 产品类别: "蒸汽", 成本类型: "标准能耗成本", 标准成本: 52000 }, |
| | | { 月份: "2026-03", 产品类别: "水", 成本类型: "标准能耗成本", 标准成本: 6800 }, |
| | | ]; |
| | | const ws = XLSX.utils.json_to_sheet(sample); |
| | | const wb = XLSX.utils.book_new(); |
| | | XLSX.utils.book_append_sheet(wb, ws, "标准成本模板"); |
| | | XLSX.writeFile(wb, "标准成本按月导入模板.xlsx"); |
| | | ElMessage.success("模板已下载"); |
| | | downloadProductionSettlementTemplate({ periodTime: searchForm.month || undefined }) |
| | | .then((data) => { |
| | | const blob = |
| | | data instanceof Blob |
| | | ? data |
| | | : new Blob([data], { |
| | | type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", |
| | | }); |
| | | const url = window.URL.createObjectURL(blob); |
| | | const link = document.createElement("a"); |
| | | link.href = url; |
| | | link.download = "标准成本导入模板.xlsx"; |
| | | document.body.appendChild(link); |
| | | link.click(); |
| | | document.body.removeChild(link); |
| | | window.URL.revokeObjectURL(url); |
| | | ElMessage.success("模板下载成功"); |
| | | }) |
| | | .catch(() => { |
| | | ElMessage.error("模板下载失败"); |
| | | }); |
| | | }; |
| | | |
| | | const handleQuery = () => { |
| | | const handleImportSuccess = (response) => { |
| | | const code = response?.code; |
| | | const msg = response?.msg || response?.message; |
| | | if (code === 200) { |
| | | ElMessage.success(msg || "导入成功"); |
| | | importDialogVisible.value = false; |
| | | importDialogRef.value?.clearFiles?.(); |
| | | handleQuery(); |
| | | return; |
| | | } |
| | | ElMessage.error(msg || "导入失败"); |
| | | }; |
| | | |
| | | const normalizeSettlementResponse = (data) => { |
| | | const map = data && typeof data === "object" ? data : {}; |
| | | const rows = []; |
| | | for (const [costType, list] of Object.entries(map)) { |
| | | if (!Array.isArray(list)) continue; |
| | | for (const item of list) { |
| | | rows.push({ |
| | | ...item, |
| | | periodTime: searchForm.month, |
| | | costType: costType, |
| | | }); |
| | | } |
| | | } |
| | | return rows; |
| | | }; |
| | | |
| | | const fetchCategoryOptions = async () => { |
| | | if (!searchForm.month) { |
| | | categoryOptions.value = []; |
| | | return; |
| | | } |
| | | try { |
| | | const { data } = await getProductTypes({ periodTime: searchForm.month }); |
| | | const list = Array.isArray(data) ? data : data?.records || []; |
| | | categoryOptions.value = list.map((item) => ({ |
| | | label: item.label || item.name || item.typeName || item, |
| | | value: item.value || item.code || item.typeCode || item, |
| | | })); |
| | | } catch (e) { |
| | | categoryOptions.value = []; |
| | | } |
| | | }; |
| | | |
| | | const fetchCostTypeOptions = async () => { |
| | | if (!searchForm.month) { |
| | | costTypeOptions.value = []; |
| | | return; |
| | | } |
| | | try { |
| | | // 不带 costType,拿到完整分组 key 用于下拉选项 |
| | | const res = await getSettlement({ periodTime: searchForm.month }); |
| | | const map = res?.data || {}; |
| | | const keys = Object.keys(map || {}); |
| | | costTypeOptions.value = keys.map((k) => ({ label: k, value: k })); |
| | | } catch (e) { |
| | | costTypeOptions.value = []; |
| | | } |
| | | }; |
| | | |
| | | const handleMonthChange = () => { |
| | | searchForm.productType = ""; |
| | | searchForm.costType = ""; |
| | | Promise.all([fetchCategoryOptions(), fetchCostTypeOptions()]).then(() => { |
| | | handleQuery(); |
| | | }); |
| | | }; |
| | | |
| | | const handleQuery = async () => { |
| | | try { |
| | | const params = { |
| | | periodTime: searchForm.month || undefined, |
| | | productType: searchForm.productType || undefined, |
| | | costType: searchForm.costType || undefined, |
| | | }; |
| | | const [settlementRes, totalRes] = await Promise.all([getSettlement(params), getTotalCosts(params)]); |
| | | const map = settlementRes?.data || {}; |
| | | const rows = normalizeSettlementResponse(map); |
| | | settlementRows.value = rows; |
| | | |
| | | const totals = totalRes?.data || {}; |
| | | totalCosts.budgetTotal = Number(totals.budgetTotal || 0); |
| | | totalCosts.actualTotal = Number(totals.actualTotal || 0); |
| | | totalCosts.diffTotal = Number(totals.diffTotal || 0); |
| | | totalCosts.diffRate = totals.diffRate ?? "0%"; |
| | | |
| | | updateChart(); |
| | | } catch (e) { |
| | | settlementRows.value = []; |
| | | totalCosts.budgetTotal = 0; |
| | | totalCosts.actualTotal = 0; |
| | | totalCosts.diffTotal = 0; |
| | | totalCosts.diffRate = "0%"; |
| | | updateChart(); |
| | | } |
| | | }; |
| | | |
| | | const handleReset = () => { |
| | | searchForm.monthRange = getDefaultMonthRange(); |
| | | searchForm.category = ""; |
| | | searchForm.month = getDefaultMonth(); |
| | | searchForm.productType = ""; |
| | | searchForm.costType = ""; |
| | | tableSort.prop = ""; |
| | | tableSort.order = ""; |
| | |
| | | }; |
| | | |
| | | const formatPercent = (v) => { |
| | | if (typeof v === "string" && v.trim().endsWith("%")) return v.trim(); |
| | | const n = Number.parseFloat(v); |
| | | const value = Number.isFinite(n) ? n : 0; |
| | | const sign = value >= 0 ? "+" : ""; |
| | |
| | | } |
| | | updateChart(); |
| | | }); |
| | | Promise.all([fetchCategoryOptions(), fetchCostTypeOptions()]).then(() => { |
| | | handleQuery(); |
| | | }); |
| | | window.addEventListener("resize", handleResize); |
| | | }); |
| | | |