<template>
|
<view class="material-consumption">
|
<PageHeader title="材料消耗核算"
|
@back="goBack" />
|
<!-- 筛选区域 -->
|
<view class="search-section">
|
<view class="search-bar">
|
<view class="search-input">
|
<up-input class="search-text"
|
placeholder="请输入生产订单号"
|
v-model="searchForm.npsNo"
|
@change="handleQuery"
|
clearable />
|
</view>
|
</view>
|
<view class="search-bar mt-16">
|
<view class="search-input">
|
<up-input class="search-text"
|
placeholder="请输入成品名称/规格"
|
v-model="searchForm.productName"
|
@change="handleQuery"
|
clearable />
|
</view>
|
</view>
|
<view class="filter-actions-row">
|
<view class="switch-item">
|
<text class="switch-label">只看差异</text>
|
<up-switch v-model="searchForm.onlyDiff"
|
size="18"
|
@change="handleQuery" />
|
</view>
|
</view>
|
<view class="date-picker-container">
|
<view class="date-input"
|
@click="showCalendar = true">
|
<up-icon name="calendar"
|
size="20"
|
color="#999"></up-icon>
|
<text class="date-text"
|
:class="{ 'placeholder': !searchForm.startDate }">{{ dateRangeText }}</text>
|
<view v-if="searchForm.startDate"
|
class="clear-icon-wrapper"
|
@click.stop="handleClearDate">
|
<up-icon name="close-circle-fill"
|
size="18"
|
color="#c0c4cc"></up-icon>
|
</view>
|
</view>
|
<view class="search-btn-wrapper">
|
<up-button type="primary"
|
size="small"
|
text="搜索"
|
@click.stop="handleQuery"></up-button>
|
</view>
|
</view>
|
</view>
|
<!-- 汇总 -->
|
<view class="summary-bar"
|
v-if="groupList.length > 0">
|
<text class="summary-text">共 {{ groupList.length }} 个订单 / {{ allRows.length }} 行物料</text>
|
</view>
|
<!-- 列表区域(按生产订单分组) -->
|
<view v-if="loading && groupList.length === 0"
|
class="loading-box">
|
<up-loading-icon text="加载中..."></up-loading-icon>
|
</view>
|
<view class="ledger-list"
|
v-else-if="groupList.length > 0">
|
<view class="ledger-item"
|
v-for="group in visibleGroups"
|
:key="group.key">
|
<view class="item-header">
|
<view class="item-left">
|
<view class="document-icon">
|
<up-icon name="file-text"
|
size="16"
|
color="#ffffff"></up-icon>
|
</view>
|
<text class="item-id">{{ group.npsNo }}</text>
|
</view>
|
<view class="item-right">
|
<text class="item-index">{{ group.items.length }} 项物料</text>
|
</view>
|
</view>
|
<view class="order-product">
|
<text class="order-product-label">成品</text>
|
<text class="order-product-name">{{ group.finishedProduct }}</text>
|
</view>
|
<up-divider></up-divider>
|
<view class="material-list">
|
<view class="material-item"
|
:class="{ 'row-warn': isDiffWarn(m) }"
|
v-for="(m, mi) in group.items"
|
:key="mi">
|
<view class="material-header">
|
<view class="material-name-box">
|
<text class="material-name">{{ m.materialName || "-" }}</text>
|
<text class="material-model">{{ m.materialModel || "-" }}</text>
|
</view>
|
<view class="material-tags">
|
<text class="off-bom-tag"
|
v-if="isOffBom(m)">BOM外</text>
|
<text class="diff-tag"
|
:class="{ warn: isDiffWarn(m) }">{{ fmtRate(m.diffRate) }}</text>
|
</view>
|
</view>
|
<view class="material-grid">
|
<view class="detail-cell">
|
<text class="cell-label">单位</text>
|
<text class="cell-value">{{ m.unit || "-" }}</text>
|
</view>
|
<view class="detail-cell">
|
<text class="cell-label">BOM单耗</text>
|
<text class="cell-value">{{ fmtQty(m.unitQuantity) }}</text>
|
</view>
|
<view class="detail-cell">
|
<text class="cell-label">完工数量</text>
|
<text class="cell-value">{{ fmtQty(m.completeQty) }}</text>
|
</view>
|
<view class="detail-cell">
|
<text class="cell-label">标准消耗</text>
|
<text class="cell-value">{{ fmtQty(m.standardQty) }}</text>
|
</view>
|
<view class="detail-cell">
|
<text class="cell-label">实际消耗</text>
|
<text class="cell-value">{{ fmtQty(m.actualQty) }}</text>
|
</view>
|
<view class="detail-cell">
|
<text class="cell-label">差异数量</text>
|
<text class="cell-value"
|
:class="{ warn: isDiffWarn(m) }">{{ fmtQty(m.diffQty) }}</text>
|
</view>
|
</view>
|
</view>
|
</view>
|
</view>
|
<up-loadmore :status="pageStatus"
|
v-if="groupList.length >= pageSize" />
|
</view>
|
<view v-else
|
class="no-data">
|
<up-empty mode="data"
|
text="暂无数据"></up-empty>
|
</view>
|
<!-- 日历选择器 -->
|
<up-calendar :show="showCalendar"
|
mode="range"
|
:maxDate="maxDate"
|
minDate="2026-01-01"
|
:monthNum="monthNum"
|
:defaultDate="defaultDateRange"
|
@confirm="onDateConfirm"
|
@close="showCalendar = false"></up-calendar>
|
</view>
|
</template>
|
|
<script setup>
|
import { ref, reactive, computed } from "vue";
|
import { onReachBottom, onShow } from "@dcloudio/uni-app";
|
import dayjs from "dayjs";
|
import PageHeader from "@/components/PageHeader.vue";
|
import { stat } from "@/api/productionManagement/materialConsumption.js";
|
import { getWeekRange } from "@/utils/common";
|
|
const loading = ref(false);
|
const showCalendar = ref(false);
|
const maxDate = dayjs().format("YYYY-MM-DD");
|
const monthNum = computed(() => {
|
const min = dayjs("2022-02-01");
|
const max = dayjs(maxDate);
|
return max.diff(min, "month") + 1;
|
});
|
|
// 默认查询本周(周一 ~ 今天)
|
const weekRange = getWeekRange();
|
const searchForm = reactive({
|
startDate: weekRange.startDate,
|
endDate: weekRange.endDate,
|
npsNo: "",
|
productName: "",
|
onlyDiff: false,
|
});
|
|
// 全量明细行(接口非分页,前端按订单分组后分批渲染)
|
const allRows = ref([]);
|
const pageSize = 10;
|
const showCount = ref(pageSize);
|
const pageStatus = ref("loadmore");
|
|
const dateRangeText = computed(() => {
|
if (searchForm.startDate && searchForm.endDate) {
|
return `${searchForm.startDate} 至 ${searchForm.endDate}`;
|
}
|
return "请选择日期区间";
|
});
|
|
// 打开日历时回显当前区间
|
const defaultDateRange = computed(() =>
|
searchForm.startDate && searchForm.endDate
|
? [searchForm.startDate, searchForm.endDate]
|
: []
|
);
|
|
// 按生产订单分组
|
const groupList = computed(() => {
|
const map = new Map();
|
(allRows.value || []).forEach(row => {
|
const key = String(row.productionOrderId ?? row.npsNo ?? "unknown");
|
if (!map.has(key)) {
|
map.set(key, {
|
key,
|
npsNo: row.npsNo || "-",
|
finishedProduct: row.finishedProduct || "-",
|
items: [],
|
});
|
}
|
map.get(key).items.push(row);
|
});
|
return Array.from(map.values());
|
});
|
|
const visibleGroups = computed(() => groupList.value.slice(0, showCount.value));
|
|
const goBack = () => {
|
uni.navigateBack();
|
};
|
|
// 数值兜底
|
const toNum = v => Number(v) || 0;
|
const fmtQty = v => {
|
if (v === null || v === undefined || v === "") return "-";
|
const n = Number(v);
|
if (Number.isNaN(n)) return "-";
|
return n.toFixed(2);
|
};
|
const fmtRate = v => {
|
if (v === null || v === undefined || v === "") return "-";
|
const n = Number(v);
|
if (Number.isNaN(n)) return "-";
|
return (n > 0 ? "+" : "") + n.toFixed(2) + "%";
|
};
|
// 差异率绝对值 > 10 标红
|
const isDiffWarn = row => {
|
if (row?.diffRate === null || row?.diffRate === undefined || row?.diffRate === "") {
|
return false;
|
}
|
return Math.abs(Number(row.diffRate)) > 10;
|
};
|
// BOM外投入:单耗与标准消耗均为0
|
const isOffBom = row => toNum(row?.unitQuantity) === 0 && toNum(row?.standardQty) === 0;
|
|
const buildParams = () => {
|
const params = {};
|
if (searchForm.startDate) params.startDate = searchForm.startDate;
|
if (searchForm.endDate) params.endDate = searchForm.endDate;
|
if (searchForm.npsNo) params.npsNo = searchForm.npsNo;
|
if (searchForm.productName) params.productName = searchForm.productName;
|
if (searchForm.onlyDiff) params.onlyDiff = true;
|
return params;
|
};
|
|
const syncStatus = () => {
|
pageStatus.value =
|
showCount.value >= groupList.value.length ? "nomore" : "loadmore";
|
};
|
|
const getList = async () => {
|
loading.value = true;
|
try {
|
const res = await stat(buildParams());
|
const rows = res?.data ?? res ?? [];
|
allRows.value = Array.isArray(rows) ? rows : [];
|
showCount.value = pageSize;
|
syncStatus();
|
} catch (e) {
|
// 错误文案已由 request 统一 toast
|
} finally {
|
loading.value = false;
|
}
|
};
|
|
const handleQuery = () => {
|
getList();
|
};
|
|
const onDateConfirm = e => {
|
searchForm.startDate = e[0];
|
searchForm.endDate = e[e.length - 1];
|
showCalendar.value = false;
|
getList();
|
};
|
|
const handleClearDate = () => {
|
searchForm.startDate = "";
|
searchForm.endDate = "";
|
getList();
|
};
|
|
onShow(() => {
|
getList();
|
});
|
|
onReachBottom(() => {
|
if (pageStatus.value === "nomore" || pageStatus.value === "loading") return;
|
pageStatus.value = "loading";
|
setTimeout(() => {
|
showCount.value += pageSize;
|
syncStatus();
|
}, 200);
|
});
|
</script>
|
|
<style scoped lang="scss">
|
@import "@/styles/procurement-common.scss";
|
|
.material-consumption {
|
min-height: 100vh;
|
background: #f8f9fa;
|
display: flex;
|
flex-direction: column;
|
}
|
|
.mt-16 {
|
margin-top: 16rpx;
|
}
|
|
.filter-actions-row {
|
display: flex;
|
align-items: center;
|
justify-content: space-between;
|
margin-top: 16rpx;
|
}
|
|
.switch-item {
|
display: flex;
|
align-items: center;
|
}
|
|
.switch-label {
|
font-size: 14px;
|
color: #333;
|
margin-right: 12rpx;
|
}
|
|
.date-picker-container {
|
display: flex;
|
align-items: center;
|
width: 100%;
|
margin-top: 16rpx;
|
|
.date-input {
|
flex: 1;
|
height: 80rpx;
|
background-color: #f5f7fa;
|
border: 1rpx solid #e4e7ed;
|
border-radius: 12rpx;
|
display: flex;
|
align-items: center;
|
padding: 0 24rpx;
|
margin-right: 20rpx;
|
|
.date-text {
|
font-size: 28rpx;
|
color: #303133;
|
margin-left: 16rpx;
|
flex: 1;
|
|
&.placeholder {
|
color: #c0c4cc;
|
}
|
}
|
}
|
|
.search-btn-wrapper {
|
width: 140rpx;
|
}
|
}
|
|
.loading-box {
|
display: flex;
|
justify-content: center;
|
padding-top: 100rpx;
|
}
|
|
.summary-bar {
|
padding: 0 20px 4px;
|
|
.summary-text {
|
font-size: 12px;
|
color: #999;
|
}
|
}
|
|
.order-product {
|
display: flex;
|
align-items: center;
|
padding-bottom: 12px;
|
|
.order-product-label {
|
font-size: 12px;
|
color: #999;
|
background: #f5f5f5;
|
padding: 2px 8px;
|
border-radius: 4px;
|
margin-right: 8px;
|
}
|
|
.order-product-name {
|
font-size: 14px;
|
color: #333;
|
font-weight: 500;
|
flex: 1;
|
}
|
}
|
|
.material-item {
|
padding: 12px 0;
|
border-bottom: 1px solid #f5f5f5;
|
|
&:last-child {
|
border-bottom: none;
|
}
|
|
&.row-warn {
|
background: rgba(238, 10, 36, 0.04);
|
}
|
|
.material-header {
|
display: flex;
|
align-items: flex-start;
|
justify-content: space-between;
|
margin-bottom: 10px;
|
|
.material-name-box {
|
display: flex;
|
align-items: center;
|
flex: 1;
|
flex-wrap: wrap;
|
|
.material-name {
|
font-size: 14px;
|
color: #333;
|
font-weight: 500;
|
margin-right: 8px;
|
}
|
|
.material-model {
|
font-size: 12px;
|
color: #999;
|
}
|
}
|
|
.material-tags {
|
display: flex;
|
align-items: center;
|
flex-shrink: 0;
|
}
|
|
.off-bom-tag {
|
font-size: 11px;
|
color: #ff9800;
|
border: 1px solid #ffcc80;
|
border-radius: 4px;
|
padding: 0 6px;
|
margin-right: 8px;
|
}
|
|
.diff-tag {
|
font-size: 12px;
|
color: #999;
|
|
&.warn {
|
color: #ee0a24;
|
font-weight: 600;
|
}
|
}
|
}
|
|
.material-grid {
|
display: flex;
|
flex-wrap: wrap;
|
|
.detail-cell {
|
width: 33.33%;
|
display: flex;
|
flex-direction: column;
|
margin-bottom: 8px;
|
|
.cell-label {
|
font-size: 11px;
|
color: #999;
|
margin-bottom: 2px;
|
}
|
|
.cell-value {
|
font-size: 13px;
|
color: #333;
|
|
&.warn {
|
color: #ee0a24;
|
font-weight: 600;
|
}
|
}
|
}
|
}
|
}
|
</style>
|