From c3ecfbfd56bddf6e5d8211662f1a15c6bb702098 Mon Sep 17 00:00:00 2001 From: nebula_chen Date: Wed, 1 Apr 2026 14:47:31 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E8=AF=A2=E4=BB=B7=E6=93=8D?= =?UTF-8?q?=E4=BD=9C=E6=97=A5=E5=BF=97=E6=9F=A5=E8=AF=A2=E6=8E=A5=E5=8F=A3?= =?UTF-8?q?=EF=BC=9B=E5=AE=9E=E7=8E=B0=E5=89=8D=E7=AB=AF=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E2=80=9C=E8=AF=A2=E4=BB=B7=E6=93=8D=E4=BD=9C=E6=97=A5=E5=BF=97?= =?UTF-8?q?=E6=9F=A5=E8=AF=A2=E2=80=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../apps/pisadmin/miscprocurement/views.py | 15 +- web/src/router/backEnd.ts | 23 +- .../miscprocurement/rfqmiscellaneous/api.ts | 3 + .../rfqmiscellaneous/miscInquiryDetail.vue | 389 +++++++++++++++++- .../miscprocurement/rfs_operation_logs/api.ts | 5 + .../rfs_operation_logs/crud.tsx | 164 ++++++++ .../rfs_operation_logs/index.vue | 31 ++ 7 files changed, 625 insertions(+), 5 deletions(-) create mode 100644 web/src/views/pisadmin/miscprocurement/rfs_operation_logs/api.ts create mode 100644 web/src/views/pisadmin/miscprocurement/rfs_operation_logs/crud.tsx create mode 100644 web/src/views/pisadmin/miscprocurement/rfs_operation_logs/index.vue diff --git a/backend/apps/pisadmin/miscprocurement/views.py b/backend/apps/pisadmin/miscprocurement/views.py index 4ce8136..71d5584 100644 --- a/backend/apps/pisadmin/miscprocurement/views.py +++ b/backend/apps/pisadmin/miscprocurement/views.py @@ -1514,11 +1514,22 @@ class RFQOperationLogsViewSet(CustomModelViewSet): create_serializer_class = RFQOperationLogsSerializer update_serializer_class = RFQOperationLogsSerializer filter_fields = ( - "inquiry_no", "quotation_no", "operation_type", "operation_user", "purchase_type", ) search_fields = ("inquiry_no", "quotation_no", "operation_user", "operation_desc") - ordering = ("-create_datetime", "-id") \ No newline at end of file + ordering = ("-create_datetime", "-id") + + def filter_queryset(self, queryset): + """询价单号模糊查询、按询价主表采购负责人筛选(GET 参数 ``inquiry_no``、``buyer``)。""" + queryset = super().filter_queryset(queryset) + inquiry_no = (self.request.query_params.get("inquiry_no") or "").strip() + if inquiry_no: + queryset = queryset.filter(inquiry_no__icontains=inquiry_no) + buyer = (self.request.query_params.get("buyer") or "").strip() + if buyer: + nos = Inquiry.objects.filter(buyer__icontains=buyer).values_list("inquiry_no", flat=True) + queryset = queryset.filter(inquiry_no__in=list(nos)) + return queryset \ No newline at end of file diff --git a/web/src/router/backEnd.ts b/web/src/router/backEnd.ts index e810bd8..dd8a577 100644 --- a/web/src/router/backEnd.ts +++ b/web/src/router/backEnd.ts @@ -67,6 +67,23 @@ const pisadminMiscMaterialsIndexRoute: RouteRecordRaw = { icon: '' } }; + +/** 询价单操作日志(隐藏菜单;与菜单中配置的同名页可并存)。 */ +const pisadminRfsOperationLogsRoute: RouteRecordRaw = { + path: '/pisadmin/miscprocurement/rfs_operation_logs/index', + name: 'PisadminRfsOperationLogs', + component: () => import('/@/views/pisadmin/miscprocurement/rfs_operation_logs/index.vue'), + meta: { + title: '询价单操作日志', + isLink: '', + isHide: true, + isKeepAlive: false, + isAffix: false, + isIframe: false, + roles: ['admin'], + icon: '' + } +}; import { storeToRefs } from 'pinia'; import pinia from '/@/stores/index'; import { useUserInfo } from '/@/stores/userInfo'; @@ -127,7 +144,8 @@ export async function initBackEndControlRoutes() { pissupplierQuotationDetailRoute, pisadminRfqMiscInquiryDetailRoute, pisadminRfqMiscComparePriceRoute, - pisadminMiscMaterialsIndexRoute + pisadminMiscMaterialsIndexRoute, + pisadminRfsOperationLogsRoute ] // 添加动态路由 await setAddRoute(); @@ -144,7 +162,8 @@ export async function setRouters(){ pissupplierQuotationDetailRoute, pisadminRfqMiscInquiryDetailRoute, pisadminRfqMiscComparePriceRoute, - pisadminMiscMaterialsIndexRoute + pisadminMiscMaterialsIndexRoute, + pisadminRfsOperationLogsRoute ] dynamicRoutes.forEach((item:any)=>{ router.addRoute(item) diff --git a/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/api.ts b/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/api.ts index e66cd2d..87b3ef1 100644 --- a/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/api.ts +++ b/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/api.ts @@ -17,6 +17,9 @@ export const StartBargainingObj = (id: string | number) => request({ url: `${bas export const ConfirmNegotiationObj = (id: string | number, data?: Record) => request({ url: `${baseUrl}${id}/confirm_negotiation/`, method: 'put', data: data ?? {} }) export const SubmitPriceAuditObj = (id: string | number) => request({ url: `${baseUrl}${id}/submit_price_audit/`, method: 'put' }) +/** 询价单操作日志(按主键,返回该单 inquiry_no 下全部记录,时间倒序) */ +export const GetOperationLogs = (id: string | number) => + request({ url: `${baseUrl}${id}/operation_logs/`, method: 'get' }) /** 查询杂采议价记录(比价议价价格存此表,非报价明细「中标价格」) */ export const GetNegotiationRecordsObj = (id: string | number, params?: { part_id?: string }) => request({ url: `${baseUrl}${id}/negotiation_records/`, method: 'get', params: params ?? {} }) diff --git a/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/miscInquiryDetail.vue b/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/miscInquiryDetail.vue index 56b7b1b..4f883dd 100644 --- a/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/miscInquiryDetail.vue +++ b/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/miscInquiryDetail.vue @@ -429,6 +429,80 @@ + + +
+ +
+
+
+
+ + 履历时间轴(询价单:{{ form.inquiry_no || '—' }}) +
+
+
+
+
+
+
+
{{ step.label }}
+
{{ step.timeText }}
+
+
+
+
+
+
+
+ + 操作履历明细表 +
+ + + + + + + + + + + + + + + + + + +
+
+
@@ -446,7 +520,7 @@ import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue' import { useRoute, useRouter } from 'vue-router' import { ElMessage } from 'element-plus' -import { ArrowLeft } from '@element-plus/icons-vue' +import { ArrowLeft, Clock, Document } from '@element-plus/icons-vue' import { normalizeDict, formatCostTemplateVersion, formatRfqApiErrorMessage } from './crud' import * as api from './api' import * as costTemplateApi from '../cost_template/api' @@ -492,6 +566,22 @@ const STATUS_OPEN = 1 const STATUS_CONFIRMED = 2 const STATUS_PUBLISHED = 3 +/** 履历时间轴节点(与主状态 1–7 + 结束里程碑 8 对齐) */ +const TIMELINE_STATUS_CODES = [1, 2, 3, 4, 5, 6, 7, 8] as const + +/** 与后端 RFQOperationLogs.OPERATION_TYPE_CHOICES 一致 */ +const OPERATION_TYPE_LABELS: Record = { + 1: '询价单创建', + 2: '询价单确认', + 3: '询价单发布', + 4: '询价单还原', + 6: '供应商报价', + 7: '比议价', + 8: '议价审核提交', + 9: '议价审核完成', + 10: '议价审核驳回' +} + const paymentMethods = [ { value: 1, label: '月结30天' }, { value: 2, label: '月结60天' }, @@ -974,6 +1064,23 @@ const pageTitle = computed(() => { }) const activeTab = ref('base') const skipTemplateWatch = ref(false) + +type RfqOperationLogRow = { + id?: number + operation_type?: number + operation_user?: string | null + operation_time?: string | null + operation_desc?: string | null + inquiry_no?: string + quotation_no?: string + per_status?: string | null + cur_status?: string | null + is_show_user?: number | null + purchase_type?: number +} + +const operationLogs = ref([]) +const operationLogsLoading = ref(false) let handlingPlantChange = false const userStore = useUserInfo() /** 与主表 `purchase_dept` CharField(max_length=20) 一致 */ @@ -1352,6 +1459,145 @@ const statusTagType = (s: unknown) => { } const statusLabel = (s: unknown) => statusDict.find((i) => i.value === getStatusCode(s))?.label || String(s ?? '') +const normalizeOperationLogRow = (raw: Record): RfqOperationLogRow => { + const ot = raw.operation_type + const opType = typeof ot === 'number' ? ot : Number(ot) + return { + ...raw, + operation_type: Number.isFinite(opType) ? opType : 0 + } as RfqOperationLogRow +} + +const parseOperationLogTime = (t: string | null | undefined): number => { + if (!t) return 0 + const s = String(t).trim() + const d = new Date(s.includes('T') ? s : s.replace(' ', 'T')) + return Number.isNaN(d.getTime()) ? 0 : d.getTime() +} + +const formatTimelineDay = (raw: string | null | undefined) => { + if (!raw) return '' + const s = String(raw).trim() + return s.length >= 10 ? s.slice(0, 10) : s +} + +const operationLogsChronological = computed(() => { + const list = [...operationLogs.value] + list.sort((a, b) => { + const d = parseOperationLogTime(a.operation_time) - parseOperationLogTime(b.operation_time) + if (d !== 0) return d + return (Number(a.id) || 0) - (Number(b.id) || 0) + }) + return list +}) + +const firstCurStatusDayByLabel = computed(() => { + const map = new Map() + for (const row of operationLogsChronological.value) { + const cur = (row.cur_status || '').trim() + if (cur && !map.has(cur)) { + map.set(cur, formatTimelineDay(row.operation_time)) + } + } + return map +}) + +const timelineEndStepLabel = computed(() => { + const s = getStatusCode(form.status) + if (s === 9) return '落标(结束)' + if (s === 8) return '核价通过(结束)' + return '已完成' +}) + +const timelineStepState = (stepCode: number, status: number): 'completed' | 'current' | 'pending' => { + if (status === 0) return 'pending' + if (status >= 8) { + return stepCode <= 8 ? 'completed' : 'pending' + } + if (stepCode < status) return 'completed' + if (stepCode === status) return 'current' + return 'pending' +} + +const timelineStepsView = computed(() => { + const st = getStatusCode(form.status) + return TIMELINE_STATUS_CODES.map((code) => { + const label = code === 8 ? timelineEndStepLabel.value : statusLabel(code) + const state = timelineStepState(code, st) + let timeText = '—' + if (code === 8) { + const tWin = firstCurStatusDayByLabel.value.get('核价通过(结束)') + const tLose = firstCurStatusDayByLabel.value.get('落标(结束)') + timeText = tWin || tLose || (state === 'pending' ? '待处理' : '—') + } else { + const lb = statusDict.find((x) => x.value === code)?.label || '' + if (lb) { + timeText = firstCurStatusDayByLabel.value.get(lb) || (state === 'pending' ? '待处理' : '—') + } + } + return { code, label, state, timeText } + }) +}) + +/** 明细表按时间正序,与参考稿「操作履历明细表」阅读习惯一致 */ +const operationLogsTableRows = computed(() => operationLogsChronological.value) + +const operationTypeLabel = (t: unknown) => { + const n = typeof t === 'number' ? t : Number(t) + if (!Number.isFinite(n) || n <= 0) return '—' + return OPERATION_TYPE_LABELS[n] || `类型${n}` +} + +/** 与参考稿 HTML 色块含义接近的样式类 */ +const operationTypeBadgeClass = (t: unknown) => { + const n = typeof t === 'number' ? t : Number(t) + if (n === 1) return 'op-type--create' + if (n === 2) return 'op-type--confirm' + if (n === 3) return 'op-type--publish' + if (n === 4) return 'op-type--restore' + if (n === 6) return 'op-type--quote' + if (n === 7) return 'op-type--compare' + if (n === 8 || n === 9) return 'op-type--audit' + if (n === 10) return 'op-type--reject' + return 'op-type--default' +} + +const formatOperationStatusChange = (per: string | null | undefined, cur: string | null | undefined) => { + const a = (per || '').trim() + const b = (cur || '').trim() + if (!a && !b) return '—' + if (!a) return `${b || '—'}` + if (!b) return `${a} → —` + return `${a} → ${b}` +} + +const loadOperationLogs = async () => { + if (!currentId.value) { + operationLogs.value = [] + return + } + operationLogsLoading.value = true + try { + const res = await api.GetOperationLogs(currentId.value) + const raw = unwrapResponseData(res) + const list = Array.isArray(raw) ? raw : (raw as { results?: unknown[] })?.results + operationLogs.value = Array.isArray(list) + ? list.map((row) => normalizeOperationLogRow(row as Record)) + : [] + } catch { + operationLogs.value = [] + ElMessage.error('加载操作日志失败') + } finally { + operationLogsLoading.value = false + } +} + +watch(activeTab, (name) => { + if (name === 'operation_logs' && currentId.value) { + void loadOperationLogs() + } +}) + const partNoHiddenSections = new Set(['加工成本', '其它成本', '利润', '税金']) const partNoHiddenDisplaySections = new Set(['材料成本', '加工成本', '其它成本', '利润', '税金']) @@ -1584,6 +1830,7 @@ const resetForm = () => { Object.assign(form, emptyForm()) costRows.value = [] sectionAddConfig.value = {} + operationLogs.value = [] activeTab.value = 'base' } @@ -2610,4 +2857,144 @@ onMounted(() => { color: #0f766e; padding: 8px 0; } + +/* —— 询价单操作日志(履历时间轴 + 明细表) */ +.operation-log-empty { + padding: 24px 12px 40px; + min-height: 240px; + display: flex; + align-items: center; + justify-content: center; +} +.operation-log-root { + display: flex; + flex-direction: column; + gap: 18px; +} +.op-timeline-card, +.op-table-card { + background: var(--el-bg-color); + border: 1px solid var(--el-border-color-lighter); + border-radius: 8px; + padding: 16px 18px; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06); +} +.op-timeline-header, +.op-table-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 14px; +} +.op-timeline-header__icon, +.op-table-header__icon { + font-size: 18px; + color: var(--el-color-primary); +} +.op-timeline-header__title, +.op-table-header__title { + font-size: 16px; + font-weight: 600; + color: var(--el-text-color-primary); +} +.op-timeline-body { + overflow-x: auto; + padding: 4px 0 8px; +} +.op-timeline-track { + position: relative; + min-width: 720px; + padding: 8px 0 4px; +} +.op-timeline-line { + position: absolute; + left: 0; + right: 0; + top: 15px; + height: 2px; + background: var(--el-border-color); + z-index: 0; +} +.op-timeline-steps { + position: relative; + z-index: 1; + display: flex; + width: 100%; +} +.op-timeline-step { + flex: 1; + text-align: center; + min-width: 0; +} +.op-timeline-dot { + width: 14px; + height: 14px; + border-radius: 50%; + margin: 0 auto 8px; + box-sizing: border-box; + border: 2px solid transparent; +} +.op-timeline-dot.is-completed { + background: var(--el-color-primary); + border-color: var(--el-color-primary-light-5); +} +.op-timeline-dot.is-current { + background: var(--el-color-success); + border-color: var(--el-color-success-light-3); +} +.op-timeline-dot.is-pending { + background: var(--el-fill-color-dark); + border-color: var(--el-border-color-darker); +} +.op-timeline-step.is-pending .op-timeline-label, +.op-timeline-step.is-pending .op-timeline-time { + color: var(--el-text-color-placeholder); +} +.op-timeline-label { + font-size: 13px; + font-weight: 600; + color: var(--el-text-color-primary); + margin-bottom: 4px; + line-height: 1.25; +} +.op-timeline-time { + font-size: 12px; + color: var(--el-text-color-secondary); +} +.op-type-badge { + display: inline-block; + padding: 2px 8px; + border-radius: 4px; + font-size: 12px; + font-weight: 600; + color: #fff; + line-height: 1.4; +} +.op-type--create { + background: #67c23a; +} +.op-type--confirm { + background: #409eff; +} +.op-type--publish { + background: #9c27b0; +} +.op-type--restore { + background: #909399; +} +.op-type--quote { + background: #e6a23c; +} +.op-type--compare { + background: #607d8b; +} +.op-type--audit { + background: #e91e63; +} +.op-type--reject { + background: #f56c6c; +} +.op-type--default { + background: #909399; +} diff --git a/web/src/views/pisadmin/miscprocurement/rfs_operation_logs/api.ts b/web/src/views/pisadmin/miscprocurement/rfs_operation_logs/api.ts new file mode 100644 index 0000000..024e959 --- /dev/null +++ b/web/src/views/pisadmin/miscprocurement/rfs_operation_logs/api.ts @@ -0,0 +1,5 @@ +import { request } from '/@/utils/service' + +const baseUrl = '/api/pisadmin/miscprocurement/rfq_operation_logs/' + +export const GetList = (params: any) => request({ url: baseUrl, method: 'get', params }) diff --git a/web/src/views/pisadmin/miscprocurement/rfs_operation_logs/crud.tsx b/web/src/views/pisadmin/miscprocurement/rfs_operation_logs/crud.tsx new file mode 100644 index 0000000..269ae48 --- /dev/null +++ b/web/src/views/pisadmin/miscprocurement/rfs_operation_logs/crud.tsx @@ -0,0 +1,164 @@ +import { CreateCrudOptionsProps, CreateCrudOptionsRet } from '@fast-crud/fast-crud' +import * as api from './api' + +/** 与后端 RFQOperationLogs.OPERATION_TYPE_CHOICES 一致 */ +const OPERATION_TYPE_LABELS: Record = { + 1: '询价单创建', + 2: '询价单确认', + 3: '询价单发布', + 4: '询价单还原', + 6: '供应商报价', + 7: '比议价', + 8: '议价审核提交', + 9: '议价审核完成', + 10: '议价审核驳回' +} + +const PURCHASE_TYPE_LABELS: Record = { + 1: '策采', + 2: '杂采' +} + +const formatStatusChange = (per: unknown, cur: unknown) => { + const a = String(per ?? '').trim() + const b = String(cur ?? '').trim() + if (!a && !b) return '—' + if (!a) return `${b || '—'}` + if (!b) return `${a} → —` + return `${a} → ${b}` +} + +export const createCrudOptions = function ({ crudExpose, context: _ctx }: CreateCrudOptionsProps): CreateCrudOptionsRet { + void crudExpose + void _ctx + return { + crudOptions: { + form: { + labelWidth: '110px' + }, + request: { + pageRequest: async (query) => api.GetList(query) + }, + table: { + rowKey: 'id', + stripe: true, + border: true + }, + actionbar: { + buttons: { + add: { show: false } + } + }, + rowHandle: { + show: false + }, + columns: { + inquiry_no: { + title: '询价单号', + type: 'text', + search: { + show: true, + component: { props: { placeholder: '请输入询价单号', clearable: true } } + }, + form: { show: false }, + column: { minWidth: 130, showOverflowTooltip: true } + }, + buyer: { + title: '采购负责人', + type: 'text', + search: { + show: true, + component: { props: { placeholder: '请输入采购负责人', clearable: true } } + }, + form: { show: false }, + column: { show: false } + }, + purchase_type: { + title: '采购类别', + type: 'text', + search: { show: false }, + form: { show: false }, + column: { + width: 88, + formatter: ({ row }: { row: any }) => { + const n = Number(row?.purchase_type) + return PURCHASE_TYPE_LABELS[n] ?? (row?.purchase_type != null ? String(row.purchase_type) : '—') + } + } + }, + operation_time: { + title: '操作时间', + type: 'datetime', + search: { show: false }, + form: { show: false }, + column: { + width: 168, + showOverflowTooltip: true, + sortable: 'custom' + } + }, + operation_type: { + title: '操作类型', + type: 'text', + search: { show: false }, + form: { show: false }, + column: { + width: 130, + showOverflowTooltip: true, + formatter: ({ row }: { row: any }) => { + const n = Number(row?.operation_type) + if (!Number.isFinite(n) || n <= 0) return '—' + return OPERATION_TYPE_LABELS[n] || `类型${n}` + } + } + }, + operation_user: { + title: '操作人', + type: 'text', + search: { show: false }, + form: { show: false }, + column: { + width: 110, + showOverflowTooltip: true, + formatter: ({ row }: { row: any }) => { + const s = String(row?.operation_user ?? '').trim() + return s || '—' + } + } + }, + status_change: { + title: '状态变更', + type: 'text', + search: { show: false }, + form: { show: false }, + column: { + minWidth: 160, + showOverflowTooltip: true, + formatter: ({ row }: { row: any }) => formatStatusChange(row?.per_status, row?.cur_status) + } + }, + operation_desc: { + title: '操作描述', + type: 'text', + search: { show: false }, + form: { show: false }, + column: { minWidth: 180, showOverflowTooltip: true } + }, + quotation_no: { + title: '报价单号', + type: 'text', + search: { show: false }, + form: { show: false }, + column: { + width: 116, + showOverflowTooltip: true, + formatter: ({ row }: { row: any }) => { + const q = String(row?.quotation_no ?? '').trim() + return q && q !== '-' ? q : '—' + } + } + } + } + } + } +} diff --git a/web/src/views/pisadmin/miscprocurement/rfs_operation_logs/index.vue b/web/src/views/pisadmin/miscprocurement/rfs_operation_logs/index.vue new file mode 100644 index 0000000..debe9fe --- /dev/null +++ b/web/src/views/pisadmin/miscprocurement/rfs_operation_logs/index.vue @@ -0,0 +1,31 @@ + + + + +