feat: add process knowledge and after-sales workflows
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | import type { PageParam, PageResult } from '@vben/request'; |
| | | import { requestClient } from '#/api/request'; |
| | | |
| | | export interface Collaboration { |
| | | id: number; |
| | | ticketId: number; |
| | | category: string; |
| | | title: string; |
| | | content: string; |
| | | assigneeUserId: number; |
| | | requestedBy: number; |
| | | status: number; |
| | | result?: string; |
| | | createTime?: string; |
| | | completedTime?: string; |
| | | amount?: string; |
| | | direction?: string; |
| | | partyName?: string; |
| | | partyType?: string; |
| | | partyId?: number; |
| | | financeDraftId?: number; |
| | | previousId?: number; |
| | | businessApproverId?: number; |
| | | businessRemark?: string; |
| | | financeReview?: { status: number; remark?: string; reviewTime?: string }; |
| | | } |
| | | export interface CreateCollaboration { |
| | | ticketId?: number; |
| | | category?: string; |
| | | title: string; |
| | | content: string; |
| | | assigneeUserId?: number; |
| | | amount?: string; |
| | | direction?: string; |
| | | partyName?: string; |
| | | partyId?: number; |
| | | previousId?: number; |
| | | } |
| | | export const getMyCollaborations = (params: PageParam) => requestClient.get<PageResult<Collaboration>>('/aftersales/collaboration/my-page', { params }); |
| | | export const createCollaboration = (data: CreateCollaboration) => requestClient.post<number>('/aftersales/collaboration/create', data); |
| | | export const completeCollaboration = (id: number, result: string) => requestClient.put('/aftersales/collaboration/complete', { result }, { params: { id } }); |
| | | export const cancelCollaboration = (id: number) => requestClient.put('/aftersales/collaboration/cancel', undefined, { params: { id } }); |
| | | export const reviewCollaborationFinancial = (id: number, approved: boolean, remark?: string) => |
| | | requestClient.put('/aftersales/collaboration/review-financial', { approved, remark }, { params: { id } }); |
| | |
| | | |
| | | import { requestClient } from '#/api/request'; |
| | | |
| | | /** æå½ååºæ¯åç¨æ·æéç»è®¡åç¶æçå®å工忰 */ |
| | | export function getTicketOverview(sceneType: number) { |
| | | return requestClient.get<Record<string, number>>('/aftersales/ticket/overview', { params: { sceneType } }); |
| | | } |
| | | |
| | | export namespace AftersalesTicketApi { |
| | | /** å·¥åæç»è¡ */ |
| | | export interface TicketItem { |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | import type { PageParam, PageResult } from '@vben/request'; |
| | | import { requestClient } from '#/api/request'; |
| | | |
| | | export interface AfterSaleFinanceDraft { |
| | | id: number; |
| | | collaborationId: number; |
| | | ticketId: number; |
| | | category: string; |
| | | direction: string; |
| | | amount: string; |
| | | partyName?: string; |
| | | description: string; |
| | | businessApproverId: number; |
| | | status: number; |
| | | reviewerId?: number; |
| | | reviewRemark?: string; |
| | | reviewTime?: string; |
| | | } |
| | | export const getAfterSaleFinanceDraftPage = (params: PageParam & { status?: number }) => |
| | | requestClient.get<PageResult<AfterSaleFinanceDraft>>('/erp/finance/after-sale-draft/page', { params }); |
| | | export const reviewAfterSaleFinanceDraft = (id: number, approved: boolean, remark?: string) => |
| | | requestClient.put('/erp/finance/after-sale-draft/review', { approved, remark }, { params: { id } }); |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | import type { PageParam, PageResult } from '@vben/request'; |
| | | import { requestClient } from '#/api/request'; |
| | | |
| | | export interface KnowledgeResource { |
| | | processId?: number; |
| | | resourceType: 'MACHINE' | 'WORKSTATION' | 'OTHER'; |
| | | resourceId?: number; |
| | | capabilityDescription: string; |
| | | } |
| | | export interface KnowledgeReference { id: number; targetType: string; targetId: number; knowledgeVersion: number; createTime?: string } |
| | | export interface Knowledge { |
| | | id?: number; |
| | | code: string; |
| | | name: string; |
| | | version?: number; |
| | | productItemId?: number; |
| | | bomId?: number; |
| | | voltageMin?: number; |
| | | voltageMax?: number; |
| | | market: 'ALL' | 'DOMESTIC' | 'EXPORT'; |
| | | routeId?: number; |
| | | templateId?: number; |
| | | description?: string; |
| | | status?: number; |
| | | standards?: string[]; |
| | | resources?: KnowledgeResource[]; |
| | | } |
| | | |
| | | export const getKnowledgePage = (params: PageParam & { name?: string }) => |
| | | requestClient.get<PageResult<Knowledge>>('/mes/pd/knowledge/page', { params }); |
| | | export const getKnowledge = (id: number) => |
| | | requestClient.get<{ knowledge: Knowledge; standards: string[]; resources: KnowledgeResource[]; references: KnowledgeReference[] }>('/mes/pd/knowledge/get', { params: { id } }); |
| | | export const createKnowledge = (data: Knowledge) => requestClient.post<number>('/mes/pd/knowledge/create', data); |
| | | export const updateKnowledge = (data: Knowledge) => requestClient.put('/mes/pd/knowledge/update', data); |
| | | export const publishKnowledge = (id: number) => requestClient.put('/mes/pd/knowledge/publish', undefined, { params: { id } }); |
| | | export const reviseKnowledge = (id: number) => requestClient.post<number>('/mes/pd/knowledge/revise', undefined, { params: { id } }); |
| | | export const referenceKnowledgeRoute = (id: number, routeId: number) => requestClient.post('/mes/pd/knowledge/reference-route', undefined, { params: { id, routeId } }); |
| | | export const matchKnowledge = (params: { productId?: number; bomId?: number; voltage?: number; market?: string; standards?: string[] }) => |
| | | requestClient.get<Knowledge[]>('/mes/pd/knowledge/match', { params: { ...params, standards: params.standards?.join(',') } }); |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script setup lang="ts"> |
| | | import type { Collaboration, CreateCollaboration } from '#/api/aftersales/collaboration'; |
| | | import { computed, onMounted, reactive, ref } from 'vue'; |
| | | import { Page } from '@vben/common-ui'; |
| | | import { useUserStore } from '@vben/stores'; |
| | | import { Button, Form, FormItem, Input, InputNumber, Modal, Popconfirm, Select, Space, Table, Tag, message } from 'ant-design-vue'; |
| | | import { cancelCollaboration, completeCollaboration, createCollaboration, getMyCollaborations, reviewCollaborationFinancial } from '#/api/aftersales/collaboration'; |
| | | import { getTicketPage } from '#/api/aftersales/ticket'; |
| | | import { UserSelect } from '#/views/system/user/components'; |
| | | import SrmSupplierSelect from '#/views/srm/supplier/components/select.vue'; |
| | | |
| | | const userStore = useUserStore(); |
| | | const items = ref<Collaboration[]>([]); |
| | | const page = ref(1); |
| | | const total = ref(0); |
| | | const loading = ref(false); |
| | | const saving = ref(false); |
| | | const createOpen = ref(false); |
| | | const finishOpen = ref(false); |
| | | const finishId = ref<number>(); |
| | | const finishResult = ref(''); |
| | | const reviewOpen = ref(false); |
| | | const reviewId = ref<number>(); |
| | | const reviewApproved = ref(true); |
| | | const reviewRemark = ref(''); |
| | | const tickets = ref<{ label: string; value: number }[]>([]); |
| | | let ticketRequest = 0; |
| | | const form = reactive<CreateCollaboration>({ title: '', content: '' }); |
| | | const allowsAmount = computed(() => ['FINANCE', 'SUPPLIER', 'WARRANTY', 'QUALITY'].includes(form.category || '')); |
| | | const requiresAmount = computed(() => ['FINANCE', 'SUPPLIER', 'WARRANTY'].includes(form.category || '')); |
| | | const categories = [ |
| | | { value: 'QUALITY', label: 'è´¨éå¤ç' }, { value: 'FINANCE', label: 'è´¢å¡åå' }, |
| | | { value: 'SUPPLIER', label: 'ä¾åºåç´¢èµ' }, { value: 'WARRANTY', label: 'ä¸å
å¤ç' }, |
| | | { value: 'RESEARCH', label: 'ç ååé¦' }, { value: 'PROCESS', label: 'å·¥èºåé¦' }, |
| | | ]; |
| | | const columns = [ |
| | | { title: 'äºé¡¹', dataIndex: 'title' }, { title: 'ç±»å', key: 'category' }, |
| | | { title: 'å·¥åç¼å·', dataIndex: 'ticketId' }, { title: 'å起人', dataIndex: 'requestedBy' }, |
| | | { title: 'æ¥æ¶äºº', dataIndex: 'assigneeUserId' }, { title: 'ç¶æ', key: 'status' }, |
| | | { title: 'æä½', key: 'actions', width: 160 }, |
| | | ]; |
| | | async function load() { |
| | | loading.value = true; |
| | | try { const result = await getMyCollaborations({ pageNo: page.value, pageSize: 20 }); items.value = result.list; total.value = result.total; } |
| | | catch { /* 请æ±å±æ¾ç¤ºé误ã */ } finally { loading.value = false; } |
| | | } |
| | | async function searchTickets(keyword = '') { |
| | | const request = ++ticketRequest; |
| | | try { |
| | | // å§ç»æå®æ°æ®æéåºæ¯ï¼é¿å
æ åºæ¯æ¡ä»¶æ¶æ©å¤§å¯è§èå´ã |
| | | const [owned, involved] = await Promise.all([ |
| | | getTicketPage({ pageNo: 1, pageSize: 100, sceneType: 1, no: keyword || undefined }), |
| | | getTicketPage({ pageNo: 1, pageSize: 100, sceneType: 2, no: keyword || undefined }), |
| | | ]); |
| | | if (request !== ticketRequest) return; |
| | | const unique = new Map<number, { label: string; value: number }>(); |
| | | for (const ticket of [...owned.list, ...involved.list]) if (ticket.id != null) unique.set(ticket.id, { label: `${ticket.no || ticket.id} ${ticket.name || ''}`, value: ticket.id }); |
| | | tickets.value = [...unique.values()]; |
| | | } catch { /* 请æ±å±æ¾ç¤ºé误ã */ } |
| | | } |
| | | async function openCreate() { |
| | | Object.assign(form, { ticketId: undefined, category: undefined, title: '', content: '', assigneeUserId: undefined, |
| | | amount: undefined, direction: undefined, partyName: undefined, partyId: undefined, previousId: undefined }); |
| | | createOpen.value = true; |
| | | await searchTickets(); |
| | | } |
| | | async function retry(record: Collaboration) { |
| | | Object.assign(form, { ticketId: record.ticketId, category: record.category, title: record.title, content: record.content, |
| | | assigneeUserId: record.assigneeUserId, amount: record.amount, direction: record.direction, partyName: record.partyName, partyId: record.partyId, |
| | | previousId: record.id }); |
| | | createOpen.value = true; |
| | | await searchTickets(); |
| | | if (!tickets.value.some(ticket => ticket.value === record.ticketId)) |
| | | tickets.value.push({ value: record.ticketId, label: `å·¥å #${record.ticketId}` }); |
| | | } |
| | | async function create() { |
| | | if (!form.ticketId || !form.category || !form.title.trim() || !form.content.trim() || !form.assigneeUserId) { message.warning('è¯·å®æ´å¡«åäºé¡¹'); return; } |
| | | if ((requiresAmount.value && form.amount == null) || (form.amount != null && (Number(form.amount) <= 0 || !form.direction |
| | | || (form.category === 'SUPPLIER' && !form.partyId) |
| | | || (!['SUPPLIER', 'WARRANTY'].includes(form.category) && form.direction !== 'INTERNAL' && !form.partyName?.trim())))) { message.warning('è¯·å¡«åææéé¢ãèµéæ¹åå徿¥æ¹'); return; } |
| | | saving.value = true; |
| | | try { await createCollaboration(form); message.success('ååäºé¡¹å·²åèµ·'); createOpen.value = false; await load(); } |
| | | catch { /* 请æ±å±æ¾ç¤ºé误ã */ } finally { saving.value = false; } |
| | | } |
| | | function openFinish(id: number) { finishId.value = id; finishResult.value = ''; finishOpen.value = true; } |
| | | async function finish() { |
| | | if (!finishId.value || !finishResult.value.trim()) { message.warning('请填åå¤çç»æ'); return; } |
| | | saving.value = true; |
| | | try { await completeCollaboration(finishId.value, finishResult.value); message.success('ååäºé¡¹å·²å®æ'); finishOpen.value = false; await load(); } |
| | | catch { /* 请æ±å±æ¾ç¤ºé误ã */ } finally { saving.value = false; } |
| | | } |
| | | async function cancel(id: number) { |
| | | try { await cancelCollaboration(id); message.success('ååäºé¡¹å·²åæ¶'); await load(); } |
| | | catch { /* 请æ±å±æ¾ç¤ºé误ã */ } |
| | | } |
| | | function changeCategory() { form.partyId = undefined; form.partyName = undefined; if (!allowsAmount.value) { form.amount = undefined; form.direction = undefined; } } |
| | | function openReview(id: number, approved: boolean) { reviewId.value = id; reviewApproved.value = approved; reviewRemark.value = ''; reviewOpen.value = true; } |
| | | async function review() { |
| | | if (!reviewId.value || (!reviewApproved.value && !reviewRemark.value.trim())) { message.warning('éåæ¶è¯·å¡«åå®¡æ ¸æè§'); return; } |
| | | saving.value = true; |
| | | try { await reviewCollaborationFinancial(reviewId.value, reviewApproved.value, reviewRemark.value || undefined); |
| | | message.success(reviewApproved.value ? 'ä¸å¡å®¡æ ¸éè¿ï¼å·²çæè´¢å¡è稿' : 'å·²éå'); reviewOpen.value = false; await load(); } |
| | | catch { /* 请æ±å±æ¾ç¤ºé误ã */ } finally { saving.value = false; } |
| | | } |
| | | onMounted(load); |
| | | </script> |
| | | |
| | | <template> |
| | | <Page title="å®ååå"> |
| | | <p class="mb-3 text-gray-500">æ¶åéé¢çäºé¡¹å¨ä¸å¡å®¡æ ¸éè¿åçæè´¢å¡è稿ï¼èç¨¿å®¡æ ¸ä¸ä¼èªå¨ä»æ¬¾æè®°è´¦ã</p> |
| | | <Button v-access:code="['aftersales:collaboration:create']" type="primary" class="mb-3" @click="openCreate">åèµ·åå</Button> |
| | | <Table :columns="columns" :data-source="items" :loading="loading" row-key="id" |
| | | :pagination="{ current: page, pageSize: 20, total, showSizeChanger: false, onChange: (p: number) => { page = p; load(); } }"> |
| | | <template #expandedRowRender="{ record }"><p>äºé¡¹å
容ï¼{{ record.content }}</p><p v-if="record.result">å¤çç»æï¼{{ record.result }}</p> |
| | | <p v-if="record.amount != null">éé¢ï¼{{ record.amount }} å
ï¼æ¹åï¼{{ record.direction }}ï¼å¾æ¥æ¹ï¼{{ record.partyName || 'â' }}</p> |
| | | <p v-if="record.financeDraftId">è´¢å¡è稿ç¼å·ï¼{{ record.financeDraftId }}ï¼è´¢å¡å®¡æ ¸ï¼{{ record.financeReview?.status === 0 ? 'å¾
å®¡æ ¸' : record.financeReview?.status === 1 ? 'å®¡æ ¸éè¿' : record.financeReview?.status === 2 ? 'å·²éå' : 'ç¶æå¾
确认' }}</p> |
| | | <p v-if="record.previousId">ä¸ä¸ååäºé¡¹ç¼å·ï¼{{ record.previousId }}</p> |
| | | <p v-if="record.financeReview?.remark">è´¢å¡å®¡æ ¸æè§ï¼{{ record.financeReview.remark }}</p><p v-if="record.businessRemark">ä¸å¡å®¡æ ¸æè§ï¼{{ record.businessRemark }}</p> |
| | | </template> |
| | | <template #bodyCell="{ column, record }"> |
| | | <template v-if="column.key === 'category'">{{ categories.find(item => item.value === record.category)?.label || record.category }}</template> |
| | | <template v-else-if="column.key === 'status'"><Tag>{{ record.status === 1 ? (record.amount != null ? 'å¾
ä¸å¡å®¡æ ¸' : '已宿') : record.status === 2 ? '已忶' : record.status === 3 ? 'å·²çæè´¢å¡è稿' : record.status === 4 ? 'ä¸å¡å·²éå' : 'å¾
å¤ç' }}</Tag></template> |
| | | <template v-else-if="column.key === 'actions'"><Space> |
| | | <Button v-if="record.status === 0 && record.assigneeUserId === Number(userStore.userInfo?.id)" v-access:code="['aftersales:collaboration:update']" type="link" @click="openFinish(record.id)">å¤ç</Button> |
| | | <Popconfirm v-if="record.status === 0 && record.requestedBy === Number(userStore.userInfo?.id)" title="ç¡®è®¤åæ¶è¯¥äºé¡¹ï¼" @confirm="cancel(record.id)"><Button v-access:code="['aftersales:collaboration:update']" type="link" danger>åæ¶</Button></Popconfirm> |
| | | <Button v-if="record.status === 1 && record.amount != null" v-access:code="['aftersales:collaboration:approve']" type="link" @click="openReview(record.id, true)">ä¸å¡éè¿</Button> |
| | | <Button v-if="record.status === 1 && record.amount != null" v-access:code="['aftersales:collaboration:approve']" type="link" danger @click="openReview(record.id, false)">ä¸å¡éå</Button> |
| | | <Button v-if="record.requestedBy === Number(userStore.userInfo?.id) && (record.status === 4 || (record.status === 2 && record.previousId != null) || (record.status === 3 && record.financeReview?.status === 2))" v-access:code="['aftersales:collaboration:create']" type="link" @click="retry(record as Collaboration)">éæ°åèµ·</Button> |
| | | </Space></template> |
| | | </template> |
| | | </Table> |
| | | <Modal v-model:open="createOpen" title="åèµ·å®ååå" :confirm-loading="saving" @ok="create"> |
| | | <Form layout="vertical"> |
| | | <FormItem label="å®åå·¥å" required><Select v-model:value="form.ticketId" :options="tickets" :disabled="!!form.previousId" show-search :filter-option="false" placeholder="è¾å
¥å·¥åå·æç´¢" @search="searchTickets" /></FormItem> |
| | | <FormItem label="ååç±»å" required><Select v-model:value="form.category" :options="categories" :disabled="!!form.previousId" @change="changeCategory" /></FormItem> |
| | | <FormItem label="äºé¡¹åç§°" required><Input v-model:value="form.title" :maxlength="200" /></FormItem> |
| | | <FormItem label="å¾
å¤çå
容" required><Input.TextArea v-model:value="form.content" :rows="3" :maxlength="2000" /></FormItem> |
| | | <FormItem label="æ¥æ¶äºº" required><UserSelect v-model="form.assigneeUserId" /></FormItem> |
| | | <FormItem v-if="allowsAmount" label="æ¶åéé¢ï¼å
ï¼" :required="requiresAmount"><InputNumber v-model:value="form.amount" string-mode :min="0.01" :precision="2" class="w-full" /></FormItem> |
| | | <FormItem v-if="form.amount != null" label="èµéæ¹å" required><Select v-model:value="form.direction" :options="[{label:'åºæ¶',value:'RECEIVABLE'},{label:'åºä»',value:'PAYABLE'},{label:'å
鍿æ¬',value:'INTERNAL'}]" /></FormItem> |
| | | <FormItem v-if="form.amount != null && form.category === 'SUPPLIER'" label="ä¾åºå" required><SrmSupplierSelect v-model="form.partyId" :approval-filter="false" :supplier-name="form.partyName" @change="item => form.partyName = item?.name" /></FormItem> |
| | | <FormItem v-else-if="form.amount != null && form.category === 'WARRANTY'" label="客æ·">ä»å®åå·¥åå
³èç客æ·èªå¨è·å</FormItem> |
| | | <FormItem v-else-if="form.amount != null && form.direction !== 'INTERNAL'" label="徿¥æ¹åç§°" required><Input v-model:value="form.partyName" :maxlength="200" /></FormItem> |
| | | </Form> |
| | | </Modal> |
| | | <Modal v-model:open="finishOpen" title="å¤çååäºé¡¹" :confirm-loading="saving" @ok="finish"> |
| | | <Input.TextArea v-model:value="finishResult" :rows="4" :maxlength="2000" placeholder="å¡«åå¤çç»æ" /> |
| | | </Modal> |
| | | <Modal v-model:open="reviewOpen" :title="reviewApproved ? 'ä¸å¡å®¡æ ¸éè¿' : 'ä¸å¡å®¡æ ¸éå'" :confirm-loading="saving" @ok="review"> |
| | | <p v-if="reviewApproved" class="mb-2">确认åå°çæè´¢å¡è稿ï¼çå¾
è´¢å¡å®¡æ ¸ï¼ä¸ä¼èªå¨ä»æ¬¾æè®°è´¦ã</p> |
| | | <Input.TextArea v-model:value="reviewRemark" :rows="3" :maxlength="1000" :placeholder="reviewApproved ? 'å®¡æ ¸æè§ï¼å¯éï¼' : '请填åéååå '" /> |
| | | </Modal> |
| | | </Page> |
| | | </template> |
| | |
| | | import type { VxeTableGridOptions } from '#/adapter/vxe-table'; |
| | | import type { AftersalesTicketApi } from '#/api/aftersales/ticket'; |
| | | |
| | | import { ref } from 'vue'; |
| | | import { onMounted, ref } from 'vue'; |
| | | import { useRouter } from 'vue-router'; |
| | | |
| | | import { Page, useVbenModal } from '@vben/common-ui'; |
| | | import { AftersaleTicketStatusEnum } from '@vben/constants'; |
| | | |
| | | import { Button, message, Tabs, Tag } from 'ant-design-vue'; |
| | | import { Button, Card, Col, message, Row, Statistic, Tabs, Tag } from 'ant-design-vue'; |
| | | |
| | | import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table'; |
| | | import { deleteTicket, getTicketPage } from '#/api/aftersales/ticket'; |
| | | import { deleteTicket, getTicketOverview, getTicketPage } from '#/api/aftersales/ticket'; |
| | | import { cancelTicket } from '#/api/aftersales/ticket'; |
| | | import { $t } from '#/locales'; |
| | | |
| | |
| | | } |
| | | |
| | | const sceneType = ref('1'); |
| | | const overview = ref<Record<string, number>>({}); |
| | | const overviewLoading = ref(false); |
| | | let overviewRequest = 0; |
| | | const overviewItems = [ |
| | | { status: '0', label: 'è稿' }, { status: '10', label: 'å¤çä¸' }, |
| | | { status: '30', label: 'å¾
éè´§' }, { status: '40', label: 'å¾
鿬¾/æ¢è´§' }, |
| | | { status: '50', label: 'å·²å®ç»' }, { status: '60', label: 'å·²å
³é' }, |
| | | { status: '99', label: '已忶' }, |
| | | ]; |
| | | async function loadOverview() { |
| | | const request = ++overviewRequest; |
| | | overview.value = {}; |
| | | overviewLoading.value = true; |
| | | try { |
| | | const result = await getTicketOverview(Number(sceneType.value)); |
| | | if (request === overviewRequest) overview.value = result; |
| | | } catch { /* 请æ±å±æ¾ç¤ºé误ï¼é¿å
æ¾ç¤ºå
¶ä»åºæ¯çç»è®¡ã */ } |
| | | finally { if (request === overviewRequest) overviewLoading.value = false; } |
| | | } |
| | | onMounted(loadOverview); |
| | | |
| | | /** å¤çåºæ¯ç±»åç忢 */ |
| | | function handleChangeSceneType(key: number | string) { |
| | | sceneType.value = key.toString(); |
| | | gridApi.query(); |
| | | void loadOverview(); |
| | | } |
| | | |
| | | const [FormModal, formModalApi] = useVbenModal({ |
| | |
| | | /** å·æ°è¡¨æ ¼ */ |
| | | function handleRefresh() { |
| | | gridApi.query(); |
| | | void loadOverview(); |
| | | } |
| | | |
| | | /** å建工å */ |
| | |
| | | <Page auto-content-height> |
| | | <FormModal @success="handleRefresh" /> |
| | | |
| | | <Row :gutter="12" class="mb-3"> |
| | | <Col v-for="item in overviewItems" :key="item.status" :xs="12" :xl="3"> |
| | | <Card size="small" :loading="overviewLoading"><Statistic :title="item.label" :value="overview[item.status] ?? 'â'" /></Card> |
| | | </Col> |
| | | </Row> |
| | | |
| | | <Grid table-title="å®åå·¥åå表"> |
| | | <template #toolbar-actions> |
| | | <Tabs class="w-full" @change="handleChangeSceneType"> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script setup lang="ts"> |
| | | import type { AfterSaleFinanceDraft } from '#/api/erp/finance/after-sale-draft'; |
| | | import { onMounted, ref } from 'vue'; |
| | | import { Page } from '@vben/common-ui'; |
| | | import { Button, Input, Modal, Select, Space, Table, Tag, message } from 'ant-design-vue'; |
| | | import { getAfterSaleFinanceDraftPage, reviewAfterSaleFinanceDraft } from '#/api/erp/finance/after-sale-draft'; |
| | | |
| | | const rows = ref<AfterSaleFinanceDraft[]>([]); |
| | | const page = ref(1); |
| | | const total = ref(0); |
| | | const status = ref<number>(); |
| | | const loading = ref(false); |
| | | const saving = ref(false); |
| | | const dialogOpen = ref(false); |
| | | const reviewId = ref<number>(); |
| | | const approved = ref(true); |
| | | const remark = ref(''); |
| | | const categoryLabels: Record<string, string> = { QUALITY: 'è´¨éè´¹ç¨', FINANCE: 'è´¢å¡åå', SUPPLIER: 'ä¾åºåç´¢èµ', WARRANTY: 'ä¸å
èµä»' }; |
| | | const columns = [ |
| | | { title: 'è稿ç¼å·', dataIndex: 'id' }, { title: 'å®åå·¥å', dataIndex: 'ticketId' }, |
| | | { title: 'ååäºé¡¹', dataIndex: 'collaborationId' }, { title: 'è´¹ç¨ç±»å', key: 'category' }, { title: 'èµéæ¹å', key: 'direction' }, |
| | | { title: 'éé¢ï¼å
ï¼', dataIndex: 'amount' }, { title: '徿¥æ¹', dataIndex: 'partyName' }, |
| | | { title: 'ç¶æ', key: 'status' }, { title: 'æä½', key: 'actions', width: 150 }, |
| | | ]; |
| | | async function load() { |
| | | loading.value = true; |
| | | try { const result = await getAfterSaleFinanceDraftPage({ pageNo: page.value, pageSize: 20, status: status.value }); rows.value = result.list; total.value = result.total; } |
| | | catch { /* 请æ±å±æ¾ç¤ºé误ã */ } finally { loading.value = false; } |
| | | } |
| | | function openReview(id: number, passed: boolean) { reviewId.value = id; approved.value = passed; remark.value = ''; dialogOpen.value = true; } |
| | | async function review() { |
| | | if (!reviewId.value || (!approved.value && !remark.value.trim())) { message.warning('éåæ¶è¯·å¡«åå®¡æ ¸æè§'); return; } |
| | | saving.value = true; |
| | | try { await reviewAfterSaleFinanceDraft(reviewId.value, approved.value, remark.value || undefined); message.success('å®¡æ ¸å®æ'); dialogOpen.value = false; await load(); } |
| | | catch { /* 请æ±å±æ¾ç¤ºé误ã */ } finally { saving.value = false; } |
| | | } |
| | | onMounted(load); |
| | | </script> |
| | | |
| | | <template> |
| | | <Page title="å®åè´¢å¡è稿"> |
| | | <p class="mb-3 text-gray-500">è´¢å¡å®¡æ ¸ä»
è®°å½å®¡æ ¸ç»è®ºï¼ä¸ä¼èªå¨çæä»æ¬¾ãæ¶æ¬¾æä¼è®¡åè¯ã</p> |
| | | <Space class="mb-3"><Select v-model:value="status" allow-clear placeholder="å®¡æ ¸ç¶æ" style="width: 160px" |
| | | :options="[{label:'å¾
å®¡æ ¸',value:0},{label:'å®¡æ ¸éè¿',value:1},{label:'å·²éå',value:2}]" @change="page = 1; load()" /></Space> |
| | | <Table :columns="columns" :data-source="rows" :loading="loading" row-key="id" |
| | | :pagination="{ current: page, pageSize: 20, total, showSizeChanger: false, onChange: (p: number) => { page = p; load(); } }"> |
| | | <template #expandedRowRender="{ record }"><p>ä¸å¡è¯´æï¼{{ record.description }}</p><p>ä¸å¡å®¡æ ¸äººï¼{{ record.businessApproverId }}</p> |
| | | <p v-if="record.reviewerId">è´¢å¡å®¡æ ¸äººï¼{{ record.reviewerId }}ï¼æè§ï¼{{ record.reviewRemark || 'â' }}</p></template> |
| | | <template #bodyCell="{ column, record }"> |
| | | <template v-if="column.key === 'category'">{{ categoryLabels[record.category] || record.category }}</template> |
| | | <template v-else-if="column.key === 'direction'">{{ record.direction === 'RECEIVABLE' ? 'åºæ¶' : record.direction === 'PAYABLE' ? 'åºä»' : 'å
鍿æ¬' }}</template> |
| | | <template v-else-if="column.key === 'status'"><Tag>{{ record.status === 0 ? 'å¾
å®¡æ ¸' : record.status === 1 ? 'å®¡æ ¸éè¿' : 'å·²éå' }}</Tag></template> |
| | | <template v-else-if="column.key === 'actions'"><Space v-if="record.status === 0"> |
| | | <Button v-access:code="['erp:after-sale-finance:review']" type="link" @click="openReview(record.id, true)">éè¿</Button> |
| | | <Button v-access:code="['erp:after-sale-finance:review']" type="link" danger @click="openReview(record.id, false)">éå</Button> |
| | | </Space></template> |
| | | </template> |
| | | </Table> |
| | | <Modal v-model:open="dialogOpen" :title="approved ? 'è´¢å¡å®¡æ ¸éè¿' : 'è´¢å¡å®¡æ ¸éå'" :confirm-loading="saving" @ok="review"> |
| | | <p v-if="approved" class="mb-2">ç¡®è®¤å®¡æ ¸éè¿ï¼æ¤æä½ä¸ä¼èªå¨ä»æ¬¾æè®°è´¦ã</p> |
| | | <Input.TextArea v-model:value="remark" :rows="3" :maxlength="1000" :placeholder="approved ? 'å®¡æ ¸æè§ï¼å¯éï¼' : '请填åéååå '" /> |
| | | </Modal> |
| | | </Page> |
| | | </template> |
| | |
| | | await cancelSaleOrder(row.id!); |
| | | message.success('ä½åºæå'); |
| | | handleRefresh(); |
| | | } catch { |
| | | // 请æ±å±å·²æç¤ºå¤±è´¥åå ï¼ä¿çå表ä¾ç¨æ·å¤çåéè¯ã |
| | | } finally { |
| | | hideLoading(); |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script setup lang="ts"> |
| | | import type { Knowledge, KnowledgeReference } from '#/api/mes/pro/processdesign/knowledge'; |
| | | import { onMounted, reactive, ref } from 'vue'; |
| | | import { Page } from '@vben/common-ui'; |
| | | import { Button, Form, FormItem, Input, InputNumber, Modal, Popconfirm, Select, Space, Table, Tag, message } from 'ant-design-vue'; |
| | | import { createKnowledge, getKnowledge, getKnowledgePage, matchKnowledge, publishKnowledge, referenceKnowledgeRoute, reviseKnowledge, updateKnowledge } from '#/api/mes/pro/processdesign/knowledge'; |
| | | import { getRouteSimpleList } from '#/api/mes/pro/route'; |
| | | import { getTemplate, getTemplatePage } from '#/api/mes/pro/processdesign/template'; |
| | | import { MdItemSelect, MdProductBomSelect } from '#/views/mes/md/item/components'; |
| | | import DvMachinerySelect from '#/views/mes/dv/machinery/components/select.vue'; |
| | | import MdWorkstationSelect from '#/views/mes/md/workstation/components/select.vue'; |
| | | |
| | | const rows = ref<Knowledge[]>([]); |
| | | const total = ref(0); |
| | | const loading = ref(false); |
| | | const saving = ref(false); |
| | | const dialogOpen = ref(false); |
| | | const readOnly = ref(false); |
| | | const referenceOpen = ref(false); |
| | | const referenceKnowledgeId = ref<number>(); |
| | | const referenceRouteId = ref<number>(); |
| | | const references = ref<KnowledgeReference[]>([]); |
| | | const name = ref(''); |
| | | const page = ref(1); |
| | | const pageSize = 20; |
| | | const standardText = ref(''); |
| | | const resources = ref<NonNullable<Knowledge['resources']>>([]); |
| | | const matchStandardText = ref(''); |
| | | const matching = ref(false); |
| | | const matchResults = ref<Knowledge[] | null>(null); |
| | | const criteria = reactive<{ productId?: number; bomId?: number; voltage?: number; market?: string }>({}); |
| | | const routes = ref<{ label: string; value: number }[]>([]); |
| | | const templates = ref<{ label: string; value: number }[]>([]); |
| | | let templateRequest = 0; |
| | | const form = reactive<Knowledge>({ code: '', name: '', market: 'ALL' }); |
| | | const columns = [ |
| | | { title: 'ç¼ç ', dataIndex: 'code' }, { title: 'åç§°', dataIndex: 'name' }, |
| | | { title: 'çæ¬', dataIndex: 'version' }, { title: 'ç¶æ', key: 'status' }, |
| | | { title: 'æä½', key: 'actions', width: 160 }, |
| | | ]; |
| | | |
| | | async function load() { |
| | | matchResults.value = null; |
| | | loading.value = true; |
| | | try { |
| | | const result = await getKnowledgePage({ pageNo: page.value, pageSize, name: name.value || undefined }); |
| | | rows.value = result.list; |
| | | total.value = result.total; |
| | | } catch { /* 请æ±å±æ¾ç¤ºé误ã */ } finally { loading.value = false; } |
| | | } |
| | | async function match() { |
| | | matching.value = true; |
| | | try { |
| | | const standards = matchStandardText.value.split('\n').map(v => v.trim()).filter(Boolean); |
| | | matchResults.value = await matchKnowledge({ ...criteria, standards: standards.length ? standards : undefined }); |
| | | } catch { /* 请æ±å±æ¾ç¤ºé误ã */ } finally { matching.value = false; } |
| | | } |
| | | async function open(id?: number, viewOnly = false) { |
| | | Object.assign(form, { id: undefined, code: '', name: '', market: 'ALL', productItemId: undefined, |
| | | bomId: undefined, voltageMin: undefined, voltageMax: undefined, routeId: undefined, |
| | | templateId: undefined, description: undefined, standards: [] }); |
| | | standardText.value = ''; |
| | | resources.value = []; |
| | | references.value = []; |
| | | readOnly.value = viewOnly; |
| | | if (id) { |
| | | try { |
| | | const detail = await getKnowledge(id); |
| | | if (!detail) { message.warning('ç¥è¯ä¸åå¨'); return; } |
| | | Object.assign(form, detail.knowledge); |
| | | standardText.value = detail.standards.join('\n'); |
| | | resources.value = detail.resources.map(item => ({ processId: item.processId, resourceType: item.resourceType, |
| | | resourceId: item.resourceId, capabilityDescription: item.capabilityDescription })); |
| | | references.value = detail.references; |
| | | if (form.templateId != null && !templates.value.some(item => item.value === form.templateId)) { |
| | | try { |
| | | const template = await getTemplate(form.templateId); |
| | | if (template) templates.value.push({ label: template.templateName || template.templateCode || String(form.templateId), value: form.templateId }); |
| | | } catch { /* ä¿çç¥è¯è¯¦æ
ï¼æ¨¡æ¿è®¿é®æéç±åèªçæéæ§å¶ã */ } |
| | | } |
| | | } catch { return; } |
| | | } |
| | | dialogOpen.value = true; |
| | | } |
| | | async function save() { |
| | | if (!form.code.trim() || !form.name.trim()) { message.warning('请填åç¥è¯ç¼ç ååç§°'); return; } |
| | | if (form.voltageMin != null && form.voltageMax != null && form.voltageMin > form.voltageMax) { |
| | | message.warning('æä½çµåä¸è½å¤§äºæé«çµå'); return; |
| | | } |
| | | saving.value = true; |
| | | try { |
| | | const data = { ...form, standards: standardText.value.split('\n').map(v => v.trim()).filter(Boolean), resources: resources.value }; |
| | | if (form.id) await updateKnowledge(data); else await createKnowledge(data); |
| | | message.success('ä¿åæå'); dialogOpen.value = false; await load(); |
| | | } catch { /* 请æ±å±æ¾ç¤ºé误ï¼ä¿ç表å以便éè¯ã */ } finally { saving.value = false; } |
| | | } |
| | | async function publish(id: number) { |
| | | try { await publishKnowledge(id); message.success('å叿å'); await load(); } |
| | | catch { /* 请æ±å±æ¾ç¤ºé误ã */ } |
| | | } |
| | | async function revise(id: number) { |
| | | try { const draftId = await reviseKnowledge(id); message.success('å·²å建æ°çæ¬è稿'); await load(); await open(draftId); } |
| | | catch { /* 请æ±å±æ¾ç¤ºé误ã */ } |
| | | } |
| | | function openReference(id: number) { |
| | | referenceKnowledgeId.value = id; |
| | | referenceRouteId.value = undefined; |
| | | referenceOpen.value = true; |
| | | } |
| | | async function saveReference() { |
| | | if (referenceKnowledgeId.value == null || referenceRouteId.value == null) { message.warning('è¯·éæ©ç®æ å·¥èºè·¯çº¿'); return; } |
| | | saving.value = true; |
| | | try { |
| | | await referenceKnowledgeRoute(referenceKnowledgeId.value, referenceRouteId.value); |
| | | message.success('已记å½åèå
³ç³»'); referenceOpen.value = false; |
| | | } catch { /* 请æ±å±æ¾ç¤ºé误ã */ } finally { saving.value = false; } |
| | | } |
| | | async function searchTemplates(keyword = '') { |
| | | const request = ++templateRequest; |
| | | try { |
| | | const result = await getTemplatePage({ pageNo: 1, pageSize: 100, templateName: keyword || undefined }); |
| | | if (request === templateRequest) templates.value = result.list.filter(t => t.id != null) |
| | | .map(t => ({ label: t.templateName || t.templateCode || String(t.id), value: t.id! })); |
| | | } catch { /* 请æ±å±æ¾ç¤ºé误ã */ } |
| | | } |
| | | onMounted(async () => { |
| | | await load(); |
| | | const [routeResult] = await Promise.allSettled([getRouteSimpleList(), searchTemplates()]); |
| | | if (routeResult.status === 'fulfilled') |
| | | routes.value = routeResult.value.filter(r => r.id != null).map(r => ({ label: r.name || r.code || String(r.id), value: r.id! })); |
| | | }); |
| | | </script> |
| | | |
| | | <template> |
| | | <Page title="å·¥èºç¥è¯"> |
| | | <div class="mb-4 rounded bg-white p-4"> |
| | | <div class="mb-3 font-medium">å¹é
å·²åå¸çå·¥èºç¥è¯</div> |
| | | <div class="mb-3 text-gray-500">请填åå·²ç¥æ¡ä»¶ï¼æªå¡«åæ 忶䏿 ¡éªæ åï¼ç»æéç±å·¥èºäººå确认ã</div> |
| | | <Space wrap> |
| | | <MdItemSelect v-model="criteria.productId" placeholder="éæ©éç¨ç©æ" style="width: 240px" @change="criteria.bomId = undefined" /> |
| | | <MdProductBomSelect v-model="criteria.bomId" :item-id="criteria.productId" :disabled="criteria.productId == null" placeholder="æå® BOM ç©æï¼å¯éï¼" style="width: 240px" /> |
| | | <InputNumber v-model:value="criteria.voltage" :min="0" placeholder="çµåï¼kVï¼" /> |
| | | <Select v-model:value="criteria.market" allow-clear placeholder="å
å¤é" style="width: 120px" :options="[{label:'å
é',value:'DOMESTIC'},{label:'å¤é',value:'EXPORT'}]" /> |
| | | <Input v-model:value="matchStandardText" placeholder="æ åç¼ç ï¼å项ï¼" style="width: 180px" /> |
| | | <Button :loading="matching" @click="match">å¹é
ç¥è¯</Button> |
| | | <Button v-if="matchResults !== null" @click="load">è¿åå
¨é¨ç¥è¯</Button> |
| | | </Space> |
| | | </div> |
| | | <Space class="mb-4"> |
| | | <Input v-model:value="name" placeholder="ç¥è¯åç§°" allow-clear @press-enter="page = 1; load()" /> |
| | | <Button @click="page = 1; load()">æ¥è¯¢</Button> |
| | | <Button v-access:code="['mes:pd-knowledge:create']" type="primary" @click="open()">æ°å»ºç¥è¯</Button> |
| | | </Space> |
| | | <Table :columns="columns" :data-source="matchResults ?? rows" :loading="loading" row-key="id" |
| | | :pagination="matchResults !== null ? false : { current: page, pageSize, total, showSizeChanger: false, onChange: (p: number) => { page = p; load(); } }"> |
| | | <template #bodyCell="{ column, record }"> |
| | | <template v-if="column.key === 'status'"><Tag>{{ record.status === 1 ? 'å·²åå¸' : record.status === 2 ? 'å·²åç¨' : 'è稿' }}</Tag></template> |
| | | <template v-else-if="column.key === 'actions'"> |
| | | <Space> |
| | | <Button type="link" @click="open(record.id, true)">详æ
</Button> |
| | | <Button v-if="record.status === 0" v-access:code="['mes:pd-knowledge:update']" type="link" @click="open(record.id)">ç¼è¾</Button> |
| | | <template v-if="record.status === 0"> |
| | | <Popconfirm title="确认åå¸è¯¥çæ¬ï¼åå¸åä¸è½ç´æ¥ä¿®æ¹ã" @confirm="publish(record.id)"> |
| | | <Button v-access:code="['mes:pd-knowledge:publish']" type="link">åå¸</Button> |
| | | </Popconfirm> |
| | | </template> |
| | | <Button v-if="record.status === 1" v-access:code="['mes:pd-knowledge:create']" type="link" @click="revise(record.id)">å建æ°çæ¬</Button> |
| | | <Button v-if="record.status === 1" v-access:code="['mes:pd-knowledge:reference']" type="link" @click="openReference(record.id)">è®°å½åè</Button> |
| | | </Space> |
| | | </template> |
| | | </template> |
| | | </Table> |
| | | <Modal v-model:open="dialogOpen" :title="readOnly ? 'å·¥èºç¥è¯è¯¦æ
' : form.id ? 'ç¼è¾å·¥èºç¥è¯è稿' : 'æ°å»ºå·¥èºç¥è¯'" :footer="readOnly ? null : undefined" :confirm-loading="saving" @ok="save"> |
| | | <Form layout="vertical" :disabled="readOnly"> |
| | | <FormItem label="ç¥è¯ç¼ç " required><Input v-model:value="form.code" :disabled="!!form.id" :maxlength="64" /></FormItem> |
| | | <FormItem label="ç¥è¯åç§°" required><Input v-model:value="form.name" :maxlength="200" /></FormItem> |
| | | <FormItem label="éç¨å¸åº"><Select v-model:value="form.market" :options="[{label:'å
¨é¨',value:'ALL'},{label:'å
é',value:'DOMESTIC'},{label:'å¤é',value:'EXPORT'}]" /></FormItem> |
| | | <FormItem label="éç¨ç©æï¼ç空表示ä¸éï¼"><MdItemSelect v-model="form.productItemId" :disabled="readOnly" @change="form.bomId = undefined" /></FormItem> |
| | | <FormItem label="éç¨ BOM ç©æï¼å¯éï¼"><MdProductBomSelect v-model="form.bomId" :item-id="form.productItemId" :disabled="readOnly || form.productItemId == null" /></FormItem> |
| | | <FormItem label="æä½çµåï¼kVï¼"><InputNumber v-model:value="form.voltageMin" :min="0" class="w-full" /></FormItem> |
| | | <FormItem label="æé«çµåï¼kVï¼"><InputNumber v-model:value="form.voltageMax" :min="0" class="w-full" /></FormItem> |
| | | <FormItem label="åèå·¥èºè·¯çº¿"><Select v-model:value="form.routeId" :options="routes" allow-clear show-search option-filter-prop="label" /></FormItem> |
| | | <FormItem label="åèåæ°æ¨¡æ¿"><Select v-model:value="form.templateId" :options="templates" allow-clear show-search :filter-option="false" @search="searchTemplates" /></FormItem> |
| | | <FormItem label="éç¨æ åç¼ç ï¼æ¯è¡ä¸é¡¹ï¼"><Input.TextArea v-model:value="standardText" :rows="3" /></FormItem> |
| | | <FormItem label="çäº§èµæºè½å"> |
| | | <div v-for="(item, index) in resources" :key="index" class="mb-3 rounded border p-3"> |
| | | <Select v-model:value="item.resourceType" class="mb-2 w-full" :disabled="readOnly" |
| | | :options="[{label:'设å¤',value:'MACHINE'},{label:'å·¥ä½ç«',value:'WORKSTATION'},{label:'å
¶ä»',value:'OTHER'}]" |
| | | @change="item.resourceId = undefined" /> |
| | | <DvMachinerySelect v-if="item.resourceType === 'MACHINE'" v-model="item.resourceId" :disabled="readOnly" /> |
| | | <MdWorkstationSelect v-else-if="item.resourceType === 'WORKSTATION'" v-model="item.resourceId" :disabled="readOnly" /> |
| | | <Input.TextArea v-model:value="item.capabilityDescription" class="mt-2" :maxlength="500" :disabled="readOnly" placeholder="æè¿°è¯¥èµæºçè½åå约æ" /> |
| | | <Button v-if="!readOnly" danger type="link" @click="resources.splice(index, 1)">ç§»é¤èµæº</Button> |
| | | </div> |
| | | <Button v-if="!readOnly" type="dashed" @click="resources.push({ resourceType: 'OTHER', capabilityDescription: '' })">æ·»å èµæºè½å</Button> |
| | | </FormItem> |
| | | <FormItem v-if="readOnly && references.length" label="åèæ¤çæ¬çå·¥èºè·¯çº¿"> |
| | | <div v-for="reference in references" :key="reference.id">{{ routes.find(r => r.value === reference.targetId)?.label || `路线 #${reference.targetId}` }}ï¼çæ¬ {{ reference.knowledgeVersion }}ï¼</div> |
| | | </FormItem> |
| | | <FormItem label="éç¨è¯´æ"><Input.TextArea v-model:value="form.description" :rows="3" /></FormItem> |
| | | </Form> |
| | | </Modal> |
| | | <Modal v-model:open="referenceOpen" title="è®°å½è·¯çº¿åè" :confirm-loading="saving" @ok="saveReference"> |
| | | <p class="mb-3">æ¤æä½åªè®°å½åèå
³ç³»ï¼ä¸ä¼å¤å¶æä¿®æ¹å·¥èºè·¯çº¿ã</p> |
| | | <Select v-model:value="referenceRouteId" class="w-full" :options="routes" show-search option-filter-prop="label" placeholder="éæ©ç®æ å·¥èºè·¯çº¿" /> |
| | | </Modal> |
| | | </Page> |
| | | </template> |