|
|
@@ -0,0 +1,431 @@
|
|
|
+<script setup lang="ts" name="ProcurementStage3Panel">
|
|
|
+// Stage-3 材料采购 · 真实只读模型面板。
|
|
|
+// 唯一数据来源:GET /api/aidop/s8/order-flow/orders/by-id/{salesOrderId}/stage3
|
|
|
+// (后端 query-time 组合数据中台标准层:采购身份 × WMS 采购完成态 × 来料检验质量)。
|
|
|
+//
|
|
|
+// 本面板只做 label / tag 映射,不重新判断任何业务结论:
|
|
|
+// · 不按数量推采购完成,也不读 mdp_std_purchase_order.status;
|
|
|
+// · 不把 MULTIPLE_UNRESOLVED 化简成已接受或不合格;
|
|
|
+// · 不把 NOT_OBSERVED 显示成未完成 —— 「没观测到」与「确认没做完」是两回事。
|
|
|
+//
|
|
|
+// 范围恒为 ATTRIBUTED_ONLY:只含能可靠归属到本订单的采购行,不是采购需求全集,
|
|
|
+// 因此本面板任何情况下都不出现「阶段已完成 / 100%」。
|
|
|
+import { computed, ref, watch } from 'vue';
|
|
|
+import type {
|
|
|
+ Stage3ProcurementDetail,
|
|
|
+ Stage3ProcurementLine,
|
|
|
+ Stage3ObjectStatus,
|
|
|
+ Stage3ProcurementStatus,
|
|
|
+ Stage3QualityStatus,
|
|
|
+} from '/@/views/aidop/s8/api/s8OrderFlowDomainApi';
|
|
|
+
|
|
|
+interface Props {
|
|
|
+ detail: Stage3ProcurementDetail | null;
|
|
|
+ loading?: boolean;
|
|
|
+ errorMessage?: string | null;
|
|
|
+}
|
|
|
+
|
|
|
+const props = withDefaults(defineProps<Props>(), {
|
|
|
+ loading: false,
|
|
|
+ errorMessage: null,
|
|
|
+});
|
|
|
+
|
|
|
+const emit = defineEmits<{ (e: 'refresh'): void }>();
|
|
|
+
|
|
|
+// ── 状态文案:与后端 enum 一一对应,前端不新增语义 ─────────────────────────────
|
|
|
+const PROCUREMENT_LABEL: Record<Stage3ProcurementStatus, string> = {
|
|
|
+ COMPLETED: '已完成',
|
|
|
+ NOT_COMPLETED: '未完成',
|
|
|
+ UNKNOWN: '未知',
|
|
|
+ NOT_OBSERVED: '未观测',
|
|
|
+};
|
|
|
+const QUALITY_LABEL: Record<Stage3QualityStatus, string> = {
|
|
|
+ ACCEPTED: '已接受',
|
|
|
+ REJECTED: '不合格',
|
|
|
+ PENDING: '待判定',
|
|
|
+ NOT_OBSERVED: '未观测',
|
|
|
+ MULTIPLE_UNRESOLVED: '多次检验待归并',
|
|
|
+};
|
|
|
+// UNKNOWN 写成「信息不足」比「未知」更容易被业务读懂,但代码值仍是后端 enum。
|
|
|
+const OBJECT_LABEL: Record<Stage3ObjectStatus, string> = {
|
|
|
+ COMPLETED: '已完成',
|
|
|
+ NOT_COMPLETED: '未完成',
|
|
|
+ UNKNOWN: '信息不足',
|
|
|
+};
|
|
|
+
|
|
|
+type TagType = 'success' | 'warning' | 'danger' | 'info';
|
|
|
+const PROCUREMENT_TAG: Record<Stage3ProcurementStatus, TagType> = {
|
|
|
+ COMPLETED: 'success',
|
|
|
+ NOT_COMPLETED: 'warning',
|
|
|
+ UNKNOWN: 'info',
|
|
|
+ NOT_OBSERVED: 'info',
|
|
|
+};
|
|
|
+const QUALITY_TAG: Record<Stage3QualityStatus, TagType> = {
|
|
|
+ ACCEPTED: 'success',
|
|
|
+ REJECTED: 'danger',
|
|
|
+ PENDING: 'warning',
|
|
|
+ NOT_OBSERVED: 'info',
|
|
|
+ MULTIPLE_UNRESOLVED: 'warning',
|
|
|
+};
|
|
|
+const OBJECT_TAG: Record<Stage3ObjectStatus, TagType> = {
|
|
|
+ COMPLETED: 'success',
|
|
|
+ NOT_COMPLETED: 'warning',
|
|
|
+ UNKNOWN: 'info',
|
|
|
+};
|
|
|
+
|
|
|
+const IQC_ATTRIBUTION_LABEL: Record<string, string> = {
|
|
|
+ UNIQUE: '唯一归属',
|
|
|
+ UNATTRIBUTED: '无可归属检验单',
|
|
|
+ AMBIGUOUS: '归属多义(同单同料命中多行)',
|
|
|
+};
|
|
|
+
|
|
|
+// ── 本地筛选:返回行数很小(单订单已归属采购行),本地过滤即可,不动后端分页 ──────
|
|
|
+const keyword = ref('');
|
|
|
+const procurementFilter = ref<'' | Stage3ProcurementStatus>('');
|
|
|
+const qualityFilter = ref<'' | Stage3QualityStatus>('');
|
|
|
+const objectFilter = ref<'' | Stage3ObjectStatus>('');
|
|
|
+
|
|
|
+// 切订单时清掉筛选,避免上一单的筛选把新单过滤成空表
|
|
|
+watch(
|
|
|
+ () => props.detail?.salesOrderId,
|
|
|
+ () => {
|
|
|
+ keyword.value = '';
|
|
|
+ procurementFilter.value = '';
|
|
|
+ qualityFilter.value = '';
|
|
|
+ objectFilter.value = '';
|
|
|
+ },
|
|
|
+);
|
|
|
+
|
|
|
+const allLines = computed<Stage3ProcurementLine[]>(() => props.detail?.lines ?? []);
|
|
|
+
|
|
|
+const lines = computed<Stage3ProcurementLine[]>(() => {
|
|
|
+ const kw = keyword.value.trim().toLowerCase();
|
|
|
+ return allLines.value.filter((l) => {
|
|
|
+ if (procurementFilter.value && l.procurementStatus !== procurementFilter.value) return false;
|
|
|
+ if (qualityFilter.value && l.qualityStatus !== qualityFilter.value) return false;
|
|
|
+ if (objectFilter.value && l.objectStatus !== objectFilter.value) return false;
|
|
|
+ if (!kw) return true;
|
|
|
+ return [l.poNo, l.materialCode, l.materialName, l.supplierName]
|
|
|
+ .some((v) => (v ?? '').toLowerCase().includes(kw));
|
|
|
+ });
|
|
|
+});
|
|
|
+
|
|
|
+const summary = computed(() => props.detail?.summary ?? null);
|
|
|
+const isAttributedOnly = computed(() => props.detail?.dataScope === 'ATTRIBUTED_ONLY');
|
|
|
+const isEmpty = computed(
|
|
|
+ () => !props.loading && !props.errorMessage && (props.detail?.summary.attributedPoLineCount ?? 0) === 0,
|
|
|
+);
|
|
|
+const hasFilter = computed(
|
|
|
+ () => !!(keyword.value.trim() || procurementFilter.value || qualityFilter.value || objectFilter.value),
|
|
|
+);
|
|
|
+
|
|
|
+function dash(v: unknown): string {
|
|
|
+ return v === null || v === undefined || v === '' ? '-' : String(v);
|
|
|
+}
|
|
|
+function formatQty(v: number | null): string {
|
|
|
+ return v === null || v === undefined ? '-' : Number(v).toLocaleString('zh-CN', { maximumFractionDigits: 3 });
|
|
|
+}
|
|
|
+function formatDate(v: string | null): string {
|
|
|
+ return v ? String(v).slice(0, 10) : '-';
|
|
|
+}
|
|
|
+function formatRate(v: number | null | undefined): string {
|
|
|
+ return v === null || v === undefined ? '-' : `${v}%`;
|
|
|
+}
|
|
|
+function qualityTooltip(l: Stage3ProcurementLine): string {
|
|
|
+ const attr = IQC_ATTRIBUTION_LABEL[l.iqcAttributionStatus] ?? l.iqcAttributionStatus;
|
|
|
+ return `检验单归属:${attr};命中检验单 ${l.iqcBillCount} 张`;
|
|
|
+}
|
|
|
+</script>
|
|
|
+
|
|
|
+<template>
|
|
|
+ <section class="stage3-panel">
|
|
|
+ <header class="stage3-panel__head">
|
|
|
+ <span class="stage3-panel__bar" />
|
|
|
+ <h2 class="stage3-panel__title">材料采购 · 阶段详情</h2>
|
|
|
+ <el-tag v-if="isAttributedOnly" size="small" type="info" effect="plain" class="stage3-panel__scope">
|
|
|
+ 已归属数据
|
|
|
+ </el-tag>
|
|
|
+ <el-tooltip
|
|
|
+ v-if="isAttributedOnly"
|
|
|
+ placement="top"
|
|
|
+ content="当前仅统计能够可靠归属到该订单的采购行,不代表完整采购需求集合。"
|
|
|
+ >
|
|
|
+ <span class="stage3-panel__scope-hint">范围说明</span>
|
|
|
+ </el-tooltip>
|
|
|
+ <span class="stage3-panel__spacer" />
|
|
|
+ <el-button size="small" text :disabled="loading" @click="emit('refresh')">刷新</el-button>
|
|
|
+ </header>
|
|
|
+
|
|
|
+ <div v-if="loading" class="stage3-panel__hint">材料采购数据加载中</div>
|
|
|
+ <div v-else-if="errorMessage" class="stage3-panel__hint stage3-panel__hint--error">
|
|
|
+ {{ errorMessage || '材料采购数据加载失败' }}
|
|
|
+ </div>
|
|
|
+ <div v-else-if="isEmpty" class="stage3-panel__hint">当前暂无可可靠归属到该订单的采购数据</div>
|
|
|
+
|
|
|
+ <template v-else-if="detail && summary">
|
|
|
+ <!-- Summary -->
|
|
|
+ <div class="stage3-panel__block">
|
|
|
+ <div class="stage3-panel__caption">
|
|
|
+ 阶段概览(统计口径:已可靠归属到本订单的采购行)
|
|
|
+ </div>
|
|
|
+ <div class="stage3-panel__metrics">
|
|
|
+ <div class="stage3-metric">
|
|
|
+ <span class="stage3-metric__label">已归属采购行</span>
|
|
|
+ <span class="stage3-metric__value">{{ summary.attributedPoLineCount }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="stage3-metric">
|
|
|
+ <span class="stage3-metric__label">采购已完成</span>
|
|
|
+ <span class="stage3-metric__value">{{ summary.procurementCompletedCount }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="stage3-metric">
|
|
|
+ <span class="stage3-metric__label">质量已接受</span>
|
|
|
+ <span class="stage3-metric__value">{{ summary.qualityAcceptedCount }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="stage3-metric">
|
|
|
+ <span class="stage3-metric__label">已完成对象</span>
|
|
|
+ <span class="stage3-metric__value">{{ summary.objectCompletedCount }}</span>
|
|
|
+ </div>
|
|
|
+ <div v-if="summary.knownObjectCompletionRate !== null" class="stage3-metric">
|
|
|
+ <span class="stage3-metric__label">已归属对象完成率</span>
|
|
|
+ <span class="stage3-metric__value">{{ formatRate(summary.knownObjectCompletionRate) }}</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 未知 / 未观测必须可见,否则用户会把「没数据」读成「没做完」 -->
|
|
|
+ <div class="stage3-panel__breakdown">
|
|
|
+ <span class="stage3-breakdown">
|
|
|
+ <b>采购</b>
|
|
|
+ 完成 {{ summary.procurementCompletedCount }} ·
|
|
|
+ 未完成 {{ summary.procurementNotCompletedCount }} ·
|
|
|
+ 未知 {{ summary.procurementUnknownCount }} ·
|
|
|
+ 未观测 {{ summary.procurementNotObservedCount }}
|
|
|
+ </span>
|
|
|
+ <span class="stage3-breakdown">
|
|
|
+ <b>质量</b>
|
|
|
+ 接受 {{ summary.qualityAcceptedCount }} ·
|
|
|
+ 不合格 {{ summary.qualityRejectedCount }} ·
|
|
|
+ 待判定 {{ summary.qualityPendingCount }} ·
|
|
|
+ 未观测 {{ summary.qualityNotObservedCount }} ·
|
|
|
+ 待归并 {{ summary.qualityMultipleUnresolvedCount }}
|
|
|
+ </span>
|
|
|
+ <span class="stage3-breakdown">
|
|
|
+ <b>对象</b>
|
|
|
+ 完成 {{ summary.objectCompletedCount }} ·
|
|
|
+ 未完成 {{ summary.objectNotCompletedCount }} ·
|
|
|
+ 信息不足 {{ summary.objectUnknownCount }}
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- Lines -->
|
|
|
+ <div class="stage3-panel__block">
|
|
|
+ <div class="stage3-panel__toolbar">
|
|
|
+ <span class="stage3-panel__caption">采购行明细</span>
|
|
|
+ <span class="stage3-panel__spacer" />
|
|
|
+ <el-input v-model="keyword" size="small" clearable placeholder="搜索采购单号 / 物料 / 供应商" class="stage3-panel__search" />
|
|
|
+ <el-select v-model="procurementFilter" size="small" clearable placeholder="采购状态" class="stage3-panel__select">
|
|
|
+ <el-option v-for="(label, key) in PROCUREMENT_LABEL" :key="key" :label="label" :value="key" />
|
|
|
+ </el-select>
|
|
|
+ <el-select v-model="qualityFilter" size="small" clearable placeholder="质量状态" class="stage3-panel__select">
|
|
|
+ <el-option v-for="(label, key) in QUALITY_LABEL" :key="key" :label="label" :value="key" />
|
|
|
+ </el-select>
|
|
|
+ <el-select v-model="objectFilter" size="small" clearable placeholder="对象状态" class="stage3-panel__select">
|
|
|
+ <el-option v-for="(label, key) in OBJECT_LABEL" :key="key" :label="label" :value="key" />
|
|
|
+ </el-select>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div v-if="lines.length === 0 && hasFilter" class="stage3-panel__hint">
|
|
|
+ 当前筛选条件下没有采购行
|
|
|
+ </div>
|
|
|
+ <table v-else class="stage3-panel__table">
|
|
|
+ <thead>
|
|
|
+ <tr>
|
|
|
+ <th>采购单号</th>
|
|
|
+ <th>行号</th>
|
|
|
+ <th>物料编码</th>
|
|
|
+ <th>物料名称</th>
|
|
|
+ <th>规格</th>
|
|
|
+ <th>供应商</th>
|
|
|
+ <th>采购数量</th>
|
|
|
+ <th>要求日期</th>
|
|
|
+ <th>采购状态</th>
|
|
|
+ <th>质量状态</th>
|
|
|
+ <th>对象状态</th>
|
|
|
+ <th>采购申请号</th>
|
|
|
+ <th>工单号</th>
|
|
|
+ </tr>
|
|
|
+ </thead>
|
|
|
+ <tbody>
|
|
|
+ <tr v-for="l in lines" :key="`${l.poNo}#${l.poLine}`">
|
|
|
+ <td>{{ dash(l.poNo) }}</td>
|
|
|
+ <td>{{ dash(l.poLine) }}</td>
|
|
|
+ <td>{{ dash(l.materialCode) }}</td>
|
|
|
+ <td class="stage3-panel__text">{{ dash(l.materialName) }}</td>
|
|
|
+ <td>{{ dash(l.specification) }}</td>
|
|
|
+ <td class="stage3-panel__text">{{ dash(l.supplierName ?? l.supplierCode) }}</td>
|
|
|
+ <td>{{ formatQty(l.orderQty) }}</td>
|
|
|
+ <td>{{ formatDate(l.dueDate) }}</td>
|
|
|
+ <td>
|
|
|
+ <el-tag size="small" :type="PROCUREMENT_TAG[l.procurementStatus]" effect="plain">
|
|
|
+ {{ PROCUREMENT_LABEL[l.procurementStatus] }}
|
|
|
+ </el-tag>
|
|
|
+ </td>
|
|
|
+ <td>
|
|
|
+ <el-tooltip placement="top" :content="qualityTooltip(l)">
|
|
|
+ <el-tag size="small" :type="QUALITY_TAG[l.qualityStatus]" effect="plain">
|
|
|
+ {{ QUALITY_LABEL[l.qualityStatus] }}
|
|
|
+ </el-tag>
|
|
|
+ </el-tooltip>
|
|
|
+ </td>
|
|
|
+ <td>
|
|
|
+ <el-tag size="small" :type="OBJECT_TAG[l.objectStatus]" effect="plain">
|
|
|
+ {{ OBJECT_LABEL[l.objectStatus] }}
|
|
|
+ </el-tag>
|
|
|
+ </td>
|
|
|
+ <td>{{ dash(l.purchaseRequestNo) }}</td>
|
|
|
+ <td>{{ dash(l.workOrder) }}</td>
|
|
|
+ </tr>
|
|
|
+ </tbody>
|
|
|
+ </table>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ </section>
|
|
|
+</template>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+.stage3-panel {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 12px;
|
|
|
+}
|
|
|
+.stage3-panel__head {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 10px;
|
|
|
+}
|
|
|
+.stage3-panel__bar {
|
|
|
+ width: 5px;
|
|
|
+ height: 18px;
|
|
|
+ border-radius: 999px;
|
|
|
+ background: var(--order-accent, #7bd0ff);
|
|
|
+}
|
|
|
+.stage3-panel__title {
|
|
|
+ margin: 0;
|
|
|
+ font-size: 16px;
|
|
|
+ font-weight: 700;
|
|
|
+}
|
|
|
+.stage3-panel__scope-hint {
|
|
|
+ font-size: 12px;
|
|
|
+ color: var(--order-text-muted, #909097);
|
|
|
+ border-bottom: 1px dashed var(--order-border, rgba(144, 144, 151, 0.38));
|
|
|
+ cursor: help;
|
|
|
+}
|
|
|
+.stage3-panel__spacer {
|
|
|
+ flex: 1;
|
|
|
+}
|
|
|
+.stage3-panel__hint {
|
|
|
+ padding: 14px 16px;
|
|
|
+ border-radius: 12px;
|
|
|
+ background: var(--order-panel, rgba(25, 28, 34, 0.72));
|
|
|
+ border: 1px dashed var(--order-border, rgba(144, 144, 151, 0.18));
|
|
|
+ color: var(--order-text-secondary, #c6c6cd);
|
|
|
+ font-size: 13px;
|
|
|
+ text-align: center;
|
|
|
+}
|
|
|
+.stage3-panel__hint--error {
|
|
|
+ color: #ff8a8a;
|
|
|
+ border-style: solid;
|
|
|
+ border-color: rgba(255, 77, 79, 0.32);
|
|
|
+ background: rgba(255, 77, 79, 0.08);
|
|
|
+}
|
|
|
+.stage3-panel__block {
|
|
|
+ background: rgba(25, 28, 34, 0.55);
|
|
|
+ border: 1px solid rgba(69, 70, 77, 0.28);
|
|
|
+ border-radius: 10px;
|
|
|
+ padding: 12px;
|
|
|
+ overflow-x: auto;
|
|
|
+}
|
|
|
+.stage3-panel__caption {
|
|
|
+ font-size: 12px;
|
|
|
+ color: var(--order-text-muted, #909097);
|
|
|
+ margin-bottom: 8px;
|
|
|
+}
|
|
|
+.stage3-panel__toolbar {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 8px;
|
|
|
+ margin-bottom: 8px;
|
|
|
+ flex-wrap: wrap;
|
|
|
+}
|
|
|
+.stage3-panel__search {
|
|
|
+ width: 220px;
|
|
|
+}
|
|
|
+.stage3-panel__select {
|
|
|
+ width: 140px;
|
|
|
+}
|
|
|
+.stage3-panel__metrics {
|
|
|
+ display: flex;
|
|
|
+ flex-wrap: wrap;
|
|
|
+ gap: 10px;
|
|
|
+}
|
|
|
+.stage3-metric {
|
|
|
+ min-width: 132px;
|
|
|
+ flex: 1 1 132px;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 4px;
|
|
|
+ padding: 10px 12px;
|
|
|
+ border-radius: 8px;
|
|
|
+ background: rgba(15, 19, 26, 0.6);
|
|
|
+ border: 1px solid rgba(69, 70, 77, 0.22);
|
|
|
+}
|
|
|
+.stage3-metric__label {
|
|
|
+ font-size: 12px;
|
|
|
+ color: var(--order-text-muted, #909097);
|
|
|
+}
|
|
|
+.stage3-metric__value {
|
|
|
+ font-size: 20px;
|
|
|
+ font-weight: 700;
|
|
|
+ color: var(--order-text-primary, #e1e2eb);
|
|
|
+ font-family: 'JetBrains Mono', 'Cascadia Code', Menlo, monospace;
|
|
|
+}
|
|
|
+.stage3-panel__breakdown {
|
|
|
+ display: flex;
|
|
|
+ flex-wrap: wrap;
|
|
|
+ gap: 16px;
|
|
|
+ margin-top: 10px;
|
|
|
+ font-size: 12px;
|
|
|
+ color: var(--order-text-secondary, #c6c6cd);
|
|
|
+}
|
|
|
+.stage3-breakdown b {
|
|
|
+ color: var(--order-text-muted, #909097);
|
|
|
+ font-weight: 600;
|
|
|
+ margin-right: 4px;
|
|
|
+}
|
|
|
+.stage3-panel__table {
|
|
|
+ width: 100%;
|
|
|
+ border-collapse: collapse;
|
|
|
+ font-size: 12px;
|
|
|
+ color: var(--order-text-primary, #e1e2eb);
|
|
|
+}
|
|
|
+.stage3-panel__table th,
|
|
|
+.stage3-panel__table td {
|
|
|
+ padding: 8px 10px;
|
|
|
+ text-align: center;
|
|
|
+ border: 1px solid rgba(69, 70, 77, 0.18);
|
|
|
+ font-family: 'JetBrains Mono', 'Cascadia Code', Menlo, monospace;
|
|
|
+ white-space: nowrap;
|
|
|
+}
|
|
|
+.stage3-panel__table th {
|
|
|
+ background: rgba(15, 19, 26, 0.7);
|
|
|
+ font-weight: 600;
|
|
|
+ font-family: 'PingFang SC', sans-serif;
|
|
|
+}
|
|
|
+.stage3-panel__text {
|
|
|
+ font-family: 'PingFang SC', sans-serif;
|
|
|
+ text-align: left;
|
|
|
+ max-width: 180px;
|
|
|
+ overflow: hidden;
|
|
|
+ text-overflow: ellipsis;
|
|
|
+}
|
|
|
+</style>
|