yyb
9 小时以前 132c4132360f9dc103e4c9f5bbc8cc36d1429e43
生产成本核算
已添加1个文件
767 ■■■■■ 文件已修改
src/views/costAccounting/productionCostAccounting/index.vue 767 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/costAccounting/productionCostAccounting/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,767 @@
<template>
  <div class="production-cost-page">
    <el-card class="filter-card" shadow="never">
      <template #header>
        <div class="card-head">
          <div class="card-head-left">
            <el-icon class="card-icon ui-icon"><DataLine /></el-icon>
            <span class="card-title">生产成本核算</span>
            <span class="subtle">成本 = Î£ æŠ•入量 Ã— å¯¹åº”单价</span>
          </div>
          <div class="card-head-right">
            <el-radio-group
              v-model="statisticsType"
              size="small"
              @change="handleTypeChange"
            >
              <el-radio-button label="day">按日</el-radio-button>
              <el-radio-button label="month">按月</el-radio-button>
            </el-radio-group>
          </div>
        </div>
      </template>
      <div class="filter-layout">
        <el-form :model="searchForm" :inline="true" class="filter-form">
          <el-form-item label="时间范围">
            <el-date-picker
              v-if="statisticsType === 'day'"
              v-model="searchForm.dateRange"
              type="daterange"
              range-separator="至"
              start-placeholder="开始日期"
              end-placeholder="结束日期"
              value-format="YYYY-MM-DD"
              class="w-260"
              @change="handleQuery"
            />
            <el-date-picker
              v-else
              v-model="searchForm.monthRange"
              type="monthrange"
              range-separator="至"
              start-placeholder="开始月份"
              end-placeholder="结束月份"
              value-format="YYYY-MM"
              class="w-260"
              @change="handleQuery"
            />
          </el-form-item>
          <el-form-item label="产品类别">
            <el-select
              v-model="searchForm.category"
              clearable
              filterable
              placeholder="全部类别"
              class="w-180"
              @change="handleQuery"
            >
              <el-option
                v-for="item in categoryOptions"
                :key="item"
                :label="item"
                :value="item"
              />
            </el-select>
          </el-form-item>
          <el-form-item label="生产订单">
            <el-select
              v-model="searchForm.orderNo"
              clearable
              filterable
              placeholder="全部订单"
              class="w-180"
              @change="handleQuery"
            >
              <el-option
                v-for="item in orderOptions"
                :key="item"
                :label="item"
                :value="item"
              />
            </el-select>
          </el-form-item>
        </el-form>
        <div class="filter-actions">
          <el-button class="lux-btn" type="primary" @click="handleQuery">
            åˆ·æ–°
          </el-button>
          <el-button class="lux-btn" @click="handleReset">重置</el-button>
          <el-button class="lux-btn" type="success" plain @click="handleExport">
            å¯¼å‡º
          </el-button>
        </div>
      </div>
    </el-card>
    <el-card class="panel-card" shadow="never">
      <div class="kpi-strip">
        <div class="kpi-item kpi-total">
          <div class="kpi-label">总生产成本</div>
          <div class="kpi-value">Â¥{{ formatMoney(overview.totalCost) }}</div>
        </div>
        <div class="kpi-item kpi-raw">
          <div class="kpi-label">原料成本</div>
          <div class="kpi-value">Â¥{{ formatMoney(overview.rawCost) }}</div>
        </div>
        <div class="kpi-item kpi-aux">
          <div class="kpi-label">辅料成本</div>
          <div class="kpi-value">Â¥{{ formatMoney(overview.auxCost) }}</div>
        </div>
        <div class="kpi-item kpi-order">
          <div class="kpi-label">订单数量</div>
          <div class="kpi-value">{{ overview.orderCount }}</div>
        </div>
      </div>
    </el-card>
    <el-row :gutter="14" class="summary-row">
      <el-col :span="12">
        <el-card class="table-card" shadow="never">
          <template #header>
            <div class="panel-head">
              <span class="card-title">按产品类别汇总</span>
            </div>
          </template>
          <el-table :data="categorySummary" stripe class="lux-table" height="260">
            <el-table-column prop="category" label="产品类别" min-width="140" />
            <el-table-column prop="rawCost" label="原料成本(元)" align="right">
              <template #default="scope">
                <span class="price-value">{{ formatMoney(scope.row.rawCost) }}</span>
              </template>
            </el-table-column>
            <el-table-column prop="auxCost" label="辅料成本(元)" align="right">
              <template #default="scope">
                <span class="price-value">{{ formatMoney(scope.row.auxCost) }}</span>
              </template>
            </el-table-column>
            <el-table-column prop="totalCost" label="总成本(元)" align="right">
              <template #default="scope">
                <span class="cost-value">Â¥{{ formatMoney(scope.row.totalCost) }}</span>
              </template>
            </el-table-column>
          </el-table>
        </el-card>
      </el-col>
      <el-col :span="12">
        <el-card class="table-card" shadow="never">
          <template #header>
            <div class="panel-head">
              <span class="card-title">按生产订单汇总</span>
            </div>
          </template>
          <el-table :data="orderSummary" stripe class="lux-table" height="260">
            <el-table-column prop="orderNo" label="生产订单" min-width="150" />
            <el-table-column prop="category" label="产品类别" min-width="120" />
            <el-table-column prop="totalCost" label="总成本(元)" align="right">
              <template #default="scope">
                <span class="cost-value">Â¥{{ formatMoney(scope.row.totalCost) }}</span>
              </template>
            </el-table-column>
          </el-table>
        </el-card>
      </el-col>
    </el-row>
    <el-card class="table-card" shadow="never">
      <template #header>
        <div class="panel-head">
          <span class="card-title">多维度汇总明细</span>
          <span class="subtle">{{ timeColumnLabel }} + äº§å“ç±»åˆ« + ç”Ÿäº§è®¢å•</span>
        </div>
      </template>
      <el-table :data="pagedTableData" stripe class="lux-table">
        <el-table-column prop="timeLabel" :label="timeColumnLabel" min-width="110" />
        <el-table-column prop="category" label="产品类别" min-width="120" />
        <el-table-column prop="orderNo" label="生产订单" min-width="150" />
        <el-table-column prop="rawCost" label="原料成本(元)" align="right">
          <template #default="scope">
            <span class="price-value">{{ formatMoney(scope.row.rawCost) }}</span>
          </template>
        </el-table-column>
        <el-table-column prop="auxCost" label="辅料成本(元)" align="right">
          <template #default="scope">
            <span class="price-value">{{ formatMoney(scope.row.auxCost) }}</span>
          </template>
        </el-table-column>
        <el-table-column prop="totalCost" label="总成本(元)" align="right">
          <template #default="scope">
            <span class="cost-value">Â¥{{ formatMoney(scope.row.totalCost) }}</span>
          </template>
        </el-table-column>
        <el-table-column label="拆分明细" width="92" fixed="right">
          <template #default="scope">
            <el-button link type="primary" @click="openDetail(scope.row)">查看</el-button>
          </template>
        </el-table-column>
      </el-table>
      <div class="pagination-container">
        <el-pagination
          v-model:current-page="page.current"
          v-model:page-size="page.size"
          :page-sizes="[10, 20, 50, 100]"
          :total="tableData.length"
          layout="total, sizes, prev, pager, next, jumper"
          @size-change="handleSizeChange"
          @current-change="handleCurrentChange"
        />
      </div>
    </el-card>
    <el-drawer
      v-model="detailVisible"
      title="生产成本拆分明细"
      size="560px"
      destroy-on-close
    >
      <div v-if="detailRow" class="drawer-head">
        <div><b>{{ timeColumnLabel }}:</b>{{ detailRow.timeLabel }}</div>
        <div><b>产品类别:</b>{{ detailRow.category }}</div>
        <div><b>生产订单:</b>{{ detailRow.orderNo }}</div>
      </div>
      <el-table :data="detailMaterials" class="lux-table" stripe>
        <el-table-column prop="materialName" label="物料名称" min-width="120" />
        <el-table-column prop="materialType" label="类型" width="80" />
        <el-table-column prop="quantity" label="投入量" align="right">
          <template #default="scope">
            {{ formatNumber(scope.row.quantity, 2) }} {{ scope.row.unit }}
          </template>
        </el-table-column>
        <el-table-column prop="unitPrice" label="单价(元)" align="right">
          <template #default="scope">
            {{ formatNumber(scope.row.unitPrice, 2) }}
          </template>
        </el-table-column>
        <el-table-column prop="cost" label="成本(元)" align="right">
          <template #default="scope">
            <span class="cost-value">Â¥{{ formatMoney(scope.row.cost) }}</span>
          </template>
        </el-table-column>
      </el-table>
      <div class="drawer-foot">
        <span>原料:¥{{ formatMoney(detailRawCost) }}</span>
        <span>辅料:¥{{ formatMoney(detailAuxCost) }}</span>
        <span class="strong">合计:¥{{ formatMoney(detailTotalCost) }}</span>
      </div>
    </el-drawer>
  </div>
</template>
<script setup>
import { computed, reactive, ref, watch } from "vue";
import { DataLine } from "@element-plus/icons-vue";
import { ElMessage } from "element-plus";
const statisticsType = ref("day");
const getDefaultDateRange = () => {
  const end = new Date();
  const start = new Date();
  start.setDate(start.getDate() - 6);
  return [start.toISOString().slice(0, 10), end.toISOString().slice(0, 10)];
};
const getDefaultMonthRange = () => {
  const end = new Date();
  const start = new Date();
  start.setMonth(start.getMonth() - 2);
  return [start.toISOString().slice(0, 7), end.toISOString().slice(0, 7)];
};
const searchForm = reactive({
  dateRange: getDefaultDateRange(),
  monthRange: getDefaultMonthRange(),
  category: "",
  orderNo: "",
});
const sourceRecords = ref([
  { date: "2026-03-17", category: "瓷砖", orderNo: "PO-260317-01", materialName: "陶瓷粉", materialType: "原料", quantity: 1200, unit: "kg", unitPrice: 2.8 },
  { date: "2026-03-17", category: "瓷砖", orderNo: "PO-260317-01", materialName: "釉料", materialType: "辅料", quantity: 180, unit: "kg", unitPrice: 8.6 },
  { date: "2026-03-17", category: "æ°´æ³¥", orderNo: "PO-260317-02", materialName: "熟料", materialType: "原料", quantity: 2200, unit: "kg", unitPrice: 1.36 },
  { date: "2026-03-17", category: "æ°´æ³¥", orderNo: "PO-260317-02", materialName: "石膏", materialType: "辅料", quantity: 260, unit: "kg", unitPrice: 0.92 },
  { date: "2026-03-18", category: "砂浆", orderNo: "PO-260318-01", materialName: "机制砂", materialType: "原料", quantity: 1600, unit: "kg", unitPrice: 0.58 },
  { date: "2026-03-18", category: "砂浆", orderNo: "PO-260318-01", materialName: "保水剂", materialType: "辅料", quantity: 65, unit: "kg", unitPrice: 11.4 },
  { date: "2026-03-19", category: "瓷砖", orderNo: "PO-260319-01", materialName: "陶瓷粉", materialType: "原料", quantity: 980, unit: "kg", unitPrice: 2.9 },
  { date: "2026-03-19", category: "瓷砖", orderNo: "PO-260319-01", materialName: "色料", materialType: "辅料", quantity: 42, unit: "kg", unitPrice: 15.8 },
  { date: "2026-03-19", category: "砂浆", orderNo: "PO-260319-03", materialName: "机制砂", materialType: "原料", quantity: 1400, unit: "kg", unitPrice: 0.56 },
  { date: "2026-03-19", category: "砂浆", orderNo: "PO-260319-03", materialName: "减水剂", materialType: "辅料", quantity: 74, unit: "kg", unitPrice: 7.2 },
  { date: "2026-03-20", category: "æ°´æ³¥", orderNo: "PO-260320-02", materialName: "熟料", materialType: "原料", quantity: 2400, unit: "kg", unitPrice: 1.33 },
  { date: "2026-03-20", category: "æ°´æ³¥", orderNo: "PO-260320-02", materialName: "矿粉", materialType: "辅料", quantity: 380, unit: "kg", unitPrice: 1.08 },
]);
const normalizedRecords = computed(() =>
  sourceRecords.value.map((item) => {
    const month = item.date.slice(0, 7);
    const cost = Number(item.quantity) * Number(item.unitPrice);
    return { ...item, month, cost };
  })
);
const categoryOptions = computed(() =>
  Array.from(new Set(normalizedRecords.value.map((item) => item.category)))
);
const orderOptions = computed(() =>
  Array.from(new Set(normalizedRecords.value.map((item) => item.orderNo)))
);
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 getMonthRangeDays = (monthRange) => {
  if (!Array.isArray(monthRange) || monthRange.length !== 2 || !monthRange[0] || !monthRange[1]) {
    return 0;
  }
  const [startMonth, endMonth] = monthRange;
  const startDate = new Date(`${startMonth}-01T00:00:00`);
  const endDate = new Date(`${endMonth}-01T00:00:00`);
  if (Number.isNaN(startDate.getTime()) || Number.isNaN(endDate.getTime()) || startDate > endDate) {
    return 0;
  }
  const endMonthLastDay = new Date(endDate.getFullYear(), endDate.getMonth() + 1, 0);
  const diffMs = endMonthLastDay.getTime() - startDate.getTime();
  return Math.floor(diffMs / (24 * 60 * 60 * 1000)) + 1;
};
const buildQueryParams = () => {
  const isDay = statisticsType.value === "day";
  const params = {
    statisticsType: statisticsType.value,
    category: searchForm.category || undefined,
    orderNo: searchForm.orderNo || undefined,
  };
  if (isDay) {
    const [startDate, endDate] = searchForm.dateRange || [];
    params.startDate = startDate;
    params.endDate = endDate;
  } else {
    const [startMonth, endMonth] = searchForm.monthRange || [];
    params.startMonth = startMonth;
    params.endMonth = endMonth;
    params.days = getMonthRangeDays(searchForm.monthRange);
  }
  return params;
};
const filteredRecords = computed(() =>
  normalizedRecords.value.filter((item) => {
    const hitTime =
      statisticsType.value === "day"
        ? inRange(item.date, searchForm.dateRange)
        : inRange(item.month, searchForm.monthRange);
    const hitCategory = !searchForm.category || item.category === searchForm.category;
    const hitOrder = !searchForm.orderNo || item.orderNo === searchForm.orderNo;
    return hitTime && hitCategory && hitOrder;
  })
);
const timeColumnLabel = computed(() => (statisticsType.value === "day" ? "日期" : "月份"));
const aggregateBy = (list, keyFn) => {
  const map = new Map();
  for (const item of list) {
    const key = keyFn(item);
    if (!map.has(key)) {
      map.set(key, {
        rawCost: 0,
        auxCost: 0,
        totalCost: 0,
        materials: [],
      });
    }
    const bucket = map.get(key);
    if (item.materialType === "原料") bucket.rawCost += item.cost;
    if (item.materialType === "辅料") bucket.auxCost += item.cost;
    bucket.totalCost += item.cost;
    bucket.materials.push(item);
  }
  return map;
};
const groupedMap = computed(() =>
  aggregateBy(filteredRecords.value, (item) => {
    const timeKey = statisticsType.value === "day" ? item.date : item.month;
    return `${timeKey}__${item.category}__${item.orderNo}`;
  })
);
const tableData = computed(() => {
  const rows = [];
  for (const [key, val] of groupedMap.value) {
    const [timeLabel, category, orderNo] = key.split("__");
    rows.push({
      key,
      timeLabel,
      category,
      orderNo,
      rawCost: val.rawCost,
      auxCost: val.auxCost,
      totalCost: val.totalCost,
      materials: val.materials,
    });
  }
  return rows.sort((a, b) => (a.timeLabel > b.timeLabel ? -1 : 1));
});
const page = reactive({
  current: 1,
  size: 10,
});
const pagedTableData = computed(() => {
  const start = (page.current - 1) * page.size;
  return tableData.value.slice(start, start + page.size);
});
const categorySummary = computed(() => {
  const map = aggregateBy(filteredRecords.value, (item) => item.category);
  const rows = [];
  for (const [category, val] of map) {
    rows.push({
      category,
      rawCost: val.rawCost,
      auxCost: val.auxCost,
      totalCost: val.totalCost,
    });
  }
  return rows.sort((a, b) => b.totalCost - a.totalCost);
});
const orderSummary = computed(() => {
  const map = aggregateBy(filteredRecords.value, (item) => item.orderNo);
  const rows = [];
  for (const [orderNo, val] of map) {
    rows.push({
      orderNo,
      category: val.materials[0]?.category || "-",
      totalCost: val.totalCost,
    });
  }
  return rows.sort((a, b) => b.totalCost - a.totalCost);
});
const overview = computed(() => {
  const rawCost = filteredRecords.value
    .filter((item) => item.materialType === "原料")
    .reduce((sum, item) => sum + item.cost, 0);
  const auxCost = filteredRecords.value
    .filter((item) => item.materialType === "辅料")
    .reduce((sum, item) => sum + item.cost, 0);
  const orderCount = new Set(filteredRecords.value.map((item) => item.orderNo)).size;
  return {
    rawCost,
    auxCost,
    totalCost: rawCost + auxCost,
    orderCount,
  };
});
const detailVisible = ref(false);
const detailRow = ref(null);
const detailMaterials = computed(() => detailRow.value?.materials || []);
const detailRawCost = computed(() =>
  detailMaterials.value
    .filter((item) => item.materialType === "原料")
    .reduce((sum, item) => sum + item.cost, 0)
);
const detailAuxCost = computed(() =>
  detailMaterials.value
    .filter((item) => item.materialType === "辅料")
    .reduce((sum, item) => sum + item.cost, 0)
);
const detailTotalCost = computed(() =>
  detailMaterials.value.reduce((sum, item) => sum + item.cost, 0)
);
const openDetail = (row) => {
  detailRow.value = row;
  detailVisible.value = true;
};
const handleTypeChange = () => {
  handleQuery();
};
const handleQuery = () => {
  page.current = 1;
  const queryParams = buildQueryParams();
  console.log("[productionCostAccounting] query params:", queryParams);
  ElMessage.success("已按条件完成汇总");
};
const handleReset = () => {
  searchForm.dateRange = getDefaultDateRange();
  searchForm.monthRange = getDefaultMonthRange();
  searchForm.category = "";
  searchForm.orderNo = "";
  handleQuery();
};
const handleSizeChange = (val) => {
  page.size = val;
  page.current = 1;
};
const handleCurrentChange = (val) => {
  page.current = val;
};
const handleExport = () => {
  const headers = [timeColumnLabel.value, "产品类别", "生产订单", "原料成本", "辅料成本", "总成本"];
  const lines = tableData.value.map((row) =>
    [
      row.timeLabel,
      row.category,
      row.orderNo,
      row.rawCost.toFixed(2),
      row.auxCost.toFixed(2),
      row.totalCost.toFixed(2),
    ].join(",")
  );
  const csv = [headers.join(","), ...lines].join("\n");
  const blob = new Blob(["\uFEFF" + csv], { type: "text/csv;charset=utf-8;" });
  const url = URL.createObjectURL(blob);
  const link = document.createElement("a");
  link.href = url;
  link.download = `生产成本汇总_${statisticsType.value}_${Date.now()}.csv`;
  link.click();
  URL.revokeObjectURL(url);
  ElMessage.success("导出成功");
};
const formatMoney = (v) => {
  const n = Number.parseFloat(v);
  const value = Number.isFinite(n) ? n : 0;
  return value.toLocaleString("zh-CN", {
    minimumFractionDigits: 2,
    maximumFractionDigits: 2,
  });
};
const formatNumber = (v, digits = 2) => {
  const n = Number.parseFloat(v);
  if (!Number.isFinite(n)) return "--";
  return n.toLocaleString("zh-CN", {
    minimumFractionDigits: digits,
    maximumFractionDigits: digits,
  });
};
watch(tableData, () => {
  const maxPage = Math.max(1, Math.ceil(tableData.value.length / page.size));
  if (page.current > maxPage) page.current = maxPage;
});
</script>
<style scoped lang="scss">
.production-cost-page {
  --lux-bg: #f6f7fb;
  --lux-card: rgba(255, 255, 255, 0.86);
  --lux-border: rgba(15, 23, 42, 0.08);
  --lux-text: rgba(15, 23, 42, 0.92);
  --lux-subtle: rgba(15, 23, 42, 0.58);
  --lux-muted: rgba(15, 23, 42, 0.38);
  --lux-primary: #2f6fed;
  --lux-success: #16a34a;
  --lux-warning: #f59e0b;
  --lux-danger: #ef4444;
  --lux-shadow: 0 18px 50px rgba(15, 23, 42, 0.08);
  --lux-shadow-soft: 0 10px 28px rgba(15, 23, 42, 0.06);
  --lux-radius: 14px;
  padding: 18px 22px 24px;
  background: radial-gradient(
      1200px 420px at 20% 0%,
      rgba(47, 111, 237, 0.1),
      transparent 55%
    ),
    linear-gradient(180deg, var(--lux-bg) 0%, #ffffff 58%);
}
.filter-card,
.panel-card,
.table-card {
  border-radius: var(--lux-radius);
  border-color: var(--lux-border);
  background: var(--lux-card);
  box-shadow: var(--lux-shadow-soft);
}
.filter-card {
  margin-bottom: 16px;
}
.panel-card,
.summary-row {
  margin-bottom: 14px;
}
.filter-layout {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 14px;
}
.filter-form {
  display: flex;
  flex-wrap: wrap;
  gap: 10px 14px;
}
.filter-form :deep(.el-form-item) {
  margin: 0;
}
.filter-actions {
  display: flex;
  gap: 10px;
}
.card-head,
.panel-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
}
.card-head-left {
  display: flex;
  align-items: center;
  gap: 8px;
}
.card-head-right {
  display: flex;
  align-items: center;
}
.card-icon {
  color: var(--lux-primary);
}
.card-title {
  font-weight: 760;
  color: var(--lux-text);
}
.subtle {
  color: var(--lux-subtle);
  font-size: 12px;
}
.kpi-strip {
  display: grid;
  grid-template-columns: repeat(4, minmax(0, 1fr));
  gap: 12px;
}
.kpi-item {
  padding: 12px 14px;
  border-radius: 12px;
  border: 1px solid rgba(15, 23, 42, 0.08);
}
.kpi-total {
  background: linear-gradient(135deg, rgba(47, 111, 237, 0.1), rgba(255, 255, 255, 0.86));
}
.kpi-raw {
  background: linear-gradient(135deg, rgba(22, 163, 74, 0.1), rgba(255, 255, 255, 0.86));
}
.kpi-aux {
  background: linear-gradient(135deg, rgba(245, 158, 11, 0.1), rgba(255, 255, 255, 0.86));
}
.kpi-order {
  background: linear-gradient(135deg, rgba(100, 116, 139, 0.1), rgba(255, 255, 255, 0.86));
}
.kpi-label {
  font-size: 12px;
  color: var(--lux-subtle);
}
.kpi-value {
  font-size: 22px;
  margin-top: 6px;
  font-weight: 780;
  color: var(--lux-text);
}
.price-value {
  font-weight: 700;
  color: var(--lux-success);
}
.cost-value {
  font-weight: 700;
  color: var(--lux-danger);
}
.drawer-head {
  display: grid;
  gap: 8px;
  margin-bottom: 12px;
  color: var(--lux-text);
}
.drawer-foot {
  display: flex;
  justify-content: flex-end;
  gap: 18px;
  margin-top: 12px;
  color: var(--lux-text);
}
.pagination-container {
  display: flex;
  justify-content: flex-end;
  padding-top: 12px;
}
.strong {
  font-weight: 800;
}
.w-260 {
  width: 260px;
}
.w-180 {
  width: 180px;
}
::deep(.lux-table) {
  border-radius: 12px;
  overflow: hidden;
}
::deep(.lux-table th.el-table__cell) {
  background: rgba(15, 23, 42, 0.03);
}
::deep(.lux-table .el-table__row:hover > td.el-table__cell) {
  background-color: rgba(47, 111, 237, 0.06) !important;
}
@media (max-width: 1100px) {
  .kpi-strip {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }
  .filter-layout {
    flex-direction: column;
  }
}
</style>