feat: 前端补齐排产约束、风险监测与招投标比价界面
## 内容
### 需求13 排产数据
- 工单表单/列表新增排产优先级、内外销分类
- 物料表单新增外协提前期
- api/mdm/item、api/mes/pro/workorder 同步类型与字段
### 需求14 自动排产
- 自动排产弹窗展示缺料校验结果
### 需求20 隐患整改
- 隐患分派新增"通知接收账号"选择,支持责任人未绑定账号的场景
- api/safety 同步
### 需求21 风险监测
- 新增 api/safety/risk-source.ts 风险源接口
### 需求22 询报比价
- 招投标详情页完善比价与中标操作
### 其他
- 售后退货详情、工单表单、BPM 流程设计器相关调整
## 验证
前端构建待验证。
| | |
| | | |
| | | /** å建æµç¨æ¨¡å */ |
| | | export async function createModel(data: BpmModelApi.Model) { |
| | | return requestClient.post('/bpm/model/create', data); |
| | | return requestClient.post<string>('/bpm/model/create', data); |
| | | } |
| | | |
| | | /** å 餿µç¨æ¨¡å */ |
| | |
| | | salesPrice?: number; |
| | | costPrice?: number; |
| | | safetyStock?: number; |
| | | leadTime?: number; // éè´æåæï¼å¤©ï¼ |
| | | outsourceLeadTime?: number; // å¤åæåæï¼å¤©ï¼ |
| | | minStock?: number; |
| | | maxStock?: number; |
| | | isBatchManaged: boolean; |
| | |
| | | vendorCode?: string; // ä¾åºåç¼ç |
| | | batchCode?: string; // æ¹æ¬¡å· |
| | | requestDate?: number; // éæ±æ¥æ |
| | | schedulePriority?: number; // æäº§ä¼å
çº§ï¼æ°å¼è¶å¤§è¶ä¼å
|
| | | marketType?: 'DOMESTIC' | 'EXPORT'; // ç±ä¸å¡ç¡®è®¤çå
å¤éåç±» |
| | | deliveryDeviation?: string; // æäº§é¡µæéæ±æ¥æåå®é
å®ææ¥æè®¡ç®ç说æ |
| | | parentId?: number; // ç¶è®¢åç¼å· |
| | | parentCode?: string; // ç¶è®¢åç¼ç |
| | | finishDate?: number; // 宿æ¶é´ |
| | |
| | | export function deleteHazard(id: number) { |
| | | return requestClient.delete('/safety/hazard/delete', { params: { id } }); |
| | | } |
| | | export function assignHazard(data: { id: number; assigneeId: number; rectifyDeadline?: string; remark?: string }) { |
| | | export function assignHazard(data: { id: number; assigneeId: number; notifyUserId?: number; rectifyDeadline?: string; remark?: string }) { |
| | | return requestClient.put('/safety/hazard/assign', data); |
| | | } |
| | | export function rectifyHazard(data: { id: number; rectifyDesc: string; rectifyPhotoUrls?: string }) { |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | import type { PageParam, PageResult } from '@vben/request'; |
| | | import { requestClient } from '#/api/request'; |
| | | export interface SafetyRiskSource { |
| | | id?: number; name: string; category: 'MAJOR' | 'HIGH_VOLTAGE' | 'OTHER'; location?: string; |
| | | metricName: string; unit?: string; lowerLimit?: number; upperLimit?: number; |
| | | currentValue?: number; observedAt?: string; riskStatus?: 'NORMAL' | 'ALARM' | 'UNKNOWN'; remark?: string; |
| | | } |
| | | export function getRiskSourcePage(params: PageParam & { name?: string }) { return requestClient.get<PageResult<SafetyRiskSource>>('/safety/risk-source/page', { params }); } |
| | | export function createRiskSource(data: SafetyRiskSource) { return requestClient.post<number>('/safety/risk-source/create', data); } |
| | | export function updateRiskSource(data: SafetyRiskSource) { return requestClient.put<boolean>('/safety/risk-source/update', data); } |
| | | export function deleteRiskSource(id: number) { return requestClient.delete<boolean>('/safety/risk-source/delete', { params: { id } }); } |
| | |
| | | <script lang="ts" setup> |
| | | import type { AftersalesReturnApi } from '#/api/aftersales/return'; |
| | | import type { AftersalesTicketApi } from '#/api/aftersales/ticket'; |
| | | import type { ErpSaleOrderApi } from '#/api/erp/sale/order'; |
| | | |
| | | import { computed, onMounted, ref } from 'vue'; |
| | | import { useRoute, useRouter } from 'vue-router'; |
| | |
| | | import { Page } from '@vben/common-ui'; |
| | | import { AftersaleReturnStatusEnum } from '@vben/constants'; |
| | | |
| | | import { Button, Card, Descriptions, message, Popconfirm, Select, Tag } from 'ant-design-vue'; |
| | | import { Button, Card, Descriptions, InputNumber, message, Popconfirm, Select, Tag } from 'ant-design-vue'; |
| | | |
| | | import { useVbenForm } from '#/adapter/form'; |
| | | import { approveReturn, createReturn, getReturn } from '#/api/aftersales/return'; |
| | | import { getTicket } from '#/api/aftersales/ticket'; |
| | | import { AftersalesTicketSelect } from '#/views/aftersales/ticket/components'; |
| | | import { getSaleOrder } from '#/api/erp/sale/order'; |
| | | |
| | | defineOptions({ name: 'AftersalesReturnDetail' }); |
| | | |
| | |
| | | const submitting = ref(false); |
| | | const returnData = ref<AftersalesReturnApi.Return>(); |
| | | const ticketData = ref<AftersalesTicketApi.Ticket>(); |
| | | const saleOrderItems = ref<ErpSaleOrderApi.SaleOrderItem[]>([]); |
| | | const returnUnitPrices = ref<Record<number, number | undefined>>({}); |
| | | |
| | | const returnId = computed(() => route.params.id as string); |
| | | const ticketIdFromQuery = computed(() => Number(route.query.ticketId || 0)); |
| | |
| | | loading.value = true; |
| | | try { |
| | | ticketData.value = await getTicket(ticketIdFromQuery.value); |
| | | saleOrderItems.value = []; |
| | | if (ticketData.value?.saleOrderId) { |
| | | const order = await getSaleOrder(ticketData.value.saleOrderId); |
| | | saleOrderItems.value = order.items || []; |
| | | } |
| | | returnUnitPrices.value = {}; |
| | | } finally { |
| | | loading.value = false; |
| | | } |
| | | } |
| | | |
| | | async function handleTicketChange(item: Record<string, unknown> | undefined) { |
| | | saleOrderItems.value = []; |
| | | returnUnitPrices.value = {}; |
| | | if (!item?.id) { ticketData.value = undefined; return; } |
| | | loading.value = true; |
| | | try { |
| | | ticketData.value = await getTicket(item.id as number); |
| | | if (ticketData.value?.saleOrderId) { |
| | | const order = await getSaleOrder(ticketData.value.saleOrderId); |
| | | saleOrderItems.value = order.items || []; |
| | | } |
| | | } finally { |
| | | loading.value = false; |
| | | } |
| | |
| | | message.warning('请å
å¨å®åå·¥åä¸å¡«åææçéè´§ç©æåæ°é'); |
| | | return; |
| | | } |
| | | if (needReturnItems.value.some((item) => item.id == null || returnUnitPrices.value[item.id] == null || !Number.isFinite(returnUnitPrices.value[item.id]) || returnUnitPrices.value[item.id]! < 0)) { |
| | | message.warning('è¯·ä¸ºæ¯æ¡éè´§ç©æå¡«åç»ç¡®è®¤çéè´åä»·'); |
| | | return; |
| | | } |
| | | const newId = await createReturn({ |
| | | ticketId: ticketData.value!.id!, |
| | | customerId: ticketData.value!.customerId!, |
| | |
| | | defectCategory: formValues.defectCategory as string | undefined, |
| | | remark: formValues.remark as string | undefined, |
| | | items: needReturnItems.value.map((item) => { |
| | | const orderItem = saleOrderItems.value.find((oi) => oi.productId === item.itemId); |
| | | const unitPrice = orderItem?.productPrice || 0; |
| | | const unitPrice = returnUnitPrices.value[item.id!]!; |
| | | const returnQuantity = item.returnQuantity || 0; |
| | | return { |
| | | ticketItemId: item.id, |
| | |
| | | <th class="border p-2 text-left">ç©æåç§°</th> |
| | | <th class="border p-2 text-left">æ¹æ¬¡å·</th> |
| | | <th class="border p-2 text-right">éè´§æ°é</th> |
| | | <th class="border p-2 text-right">éè´§åä»·</th> |
| | | </tr> |
| | | </thead> |
| | | <tbody> |
| | |
| | | <td class="border p-2">{{ item.itemName }}</td> |
| | | <td class="border p-2">{{ item.batchCode || '-' }}</td> |
| | | <td class="border p-2 text-right">{{ item.returnQuantity || '-' }}</td> |
| | | <td class="border p-2 text-right"><InputNumber v-if="item.id != null" v-model:value="returnUnitPrices[item.id]" :min="0" :precision="2" placeholder="请è¾å
¥åä»·" /></td> |
| | | </tr> |
| | | </tbody> |
| | | </table> |
| | |
| | | import type { VbenFormApi, VbenFormSchema } from '#/adapter/form'; |
| | | import type { VxeTableGridOptions } from '#/adapter/vxe-table'; |
| | | import type { AftersalesTicketApi } from '#/api/aftersales/ticket'; |
| | | import type { ErpSaleOrderApi } from '#/api/erp/sale/order'; |
| | | |
| | | import { h, markRaw } from 'vue'; |
| | | |
| | |
| | | |
| | | import { z } from '#/adapter/form'; |
| | | import CrmCustomerSelect from '#/components/crm-customer-select.vue'; |
| | | import { ErpSaleOrderSelect } from '#/views/erp/sale/order/components'; |
| | | import { MdItemSelect } from '#/views/mes/md/item/components'; |
| | | import { WmBatchSelect } from '#/views/wls/batch/components'; |
| | | import { UserSelect } from '#/views/system/user/components'; |
| | |
| | | /** å·¥å表å */ |
| | | export function useTicketFormSchema( |
| | | formType: TicketFormType, |
| | | formApi?: VbenFormApi, |
| | | onSaleOrderChange?: (order: Record<string, unknown> | undefined) => void, |
| | | ): VbenFormSchema[] { |
| | | const readonly = isHeaderReadonly(formType); |
| | | return [ |
| | |
| | | rules: 'required', |
| | | }, |
| | | { |
| | | fieldName: 'saleOrderId', |
| | | label: 'éå®è®¢åå·', |
| | | component: markRaw(ErpSaleOrderSelect), |
| | | componentProps: { |
| | | placeholder: 'è¯·éæ©éå®è®¢å', |
| | | outStatus: 3, |
| | | onChange: (item: Record<string, unknown> | undefined) => { |
| | | if (formApi) { |
| | | formApi.setFieldValue('saleOrderNo', item?.no ?? ''); |
| | | if (item?.customerId) { |
| | | formApi.setFieldValue('customerId', item.customerId); |
| | | } |
| | | } |
| | | onSaleOrderChange?.(item); |
| | | }, |
| | | }, |
| | | }, |
| | | { |
| | | fieldName: 'saleOrderNo', |
| | | label: 'éå®è®¢åå·', |
| | | component: 'Input', |
| | | dependencies: { |
| | | triggerFields: [''], |
| | | show: () => false, |
| | | }, |
| | | componentProps: { placeholder: '请è¾å
¥å
³è订åå·ï¼éå¡«ï¼' }, |
| | | }, |
| | | { |
| | | fieldName: 'customerId', |
| | |
| | | export function useTicketItemFormSchema( |
| | | clientId?: number, |
| | | salesOrderCode?: string, |
| | | orderItems?: ErpSaleOrderApi.SaleOrderItem[], |
| | | formApi?: VbenFormApi, |
| | | ): VbenFormSchema[] { |
| | | const hasOrderItems = orderItems && orderItems.length > 0; |
| | | return [ |
| | | { |
| | | fieldName: 'itemId', |
| | | label: 'ç©æ', |
| | | component: hasOrderItems ? 'Select' : markRaw(MdItemSelect), |
| | | componentProps: hasOrderItems |
| | | ? { |
| | | placeholder: 'è¯·éæ©ç©æ', |
| | | options: orderItems!.map((oi) => ({ |
| | | label: `${oi.productName || ''} (${oi.productBarCode || oi.productId})`, |
| | | value: oi.productId, |
| | | })), |
| | | showSearch: true, |
| | | onChange: (value: number) => { |
| | | const oi = orderItems!.find((o) => o.productId === value); |
| | | if (oi && formApi) { |
| | | formApi.setValues({ |
| | | itemName: oi.productName, |
| | | itemCode: oi.productBarCode || '', |
| | | saleQuantity: oi.count, |
| | | batchCode: oi.batchCode || '', |
| | | }); |
| | | } |
| | | }, |
| | | } |
| | | : { |
| | | placeholder: 'è¯·éæ©ç©æ', |
| | | }, |
| | | component: markRaw(MdItemSelect), |
| | | componentProps: { |
| | | placeholder: 'è¯·éæ©ç©æ', |
| | | onChange: (item: { code?: string; name?: string } | undefined) => { |
| | | formApi?.setValues({ itemCode: item?.code || '', itemName: item?.name || '' }); |
| | | }, |
| | | }, |
| | | rules: 'selectRequired', |
| | | }, |
| | | { |
| | |
| | | component: 'Input', |
| | | dependencies: { triggerFields: [''], show: () => false }, |
| | | }, |
| | | ...(hasOrderItems |
| | | ? [ |
| | | { |
| | | fieldName: 'batchCode', |
| | | label: 'æ¹æ¬¡', |
| | | component: 'Input', |
| | | componentProps: { |
| | | disabled: true, |
| | | placeholder: 'èªå¨ä»è®¢å带å
¥', |
| | | }, |
| | | }, |
| | | ] |
| | | : [ |
| | | { |
| | | fieldName: 'batchId', |
| | | label: 'æ¹æ¬¡', |
| | | component: markRaw(WmBatchSelect), |
| | | componentProps: { |
| | | clientId, |
| | | placeholder: 'è¯·éæ©æ¹æ¬¡', |
| | | salesOrderCode, |
| | | }, |
| | | dependencies: { |
| | | triggerFields: ['itemId'], |
| | | componentProps: (values: Record<string, unknown>) => ({ |
| | | clientId, |
| | | itemId: values.itemId, |
| | | placeholder: 'è¯·éæ©æ¹æ¬¡', |
| | | salesOrderCode, |
| | | }), |
| | | }, |
| | | }, |
| | | ]), |
| | | { |
| | | fieldName: 'batchId', |
| | | label: 'æ¹æ¬¡', |
| | | component: markRaw(WmBatchSelect), |
| | | componentProps: { clientId, placeholder: 'è¯·éæ©æ¹æ¬¡', salesOrderCode }, |
| | | dependencies: { |
| | | triggerFields: ['itemId'], |
| | | componentProps: (values: Record<string, unknown>) => ({ |
| | | clientId, itemId: values.itemId, placeholder: 'è¯·éæ©æ¹æ¬¡', salesOrderCode, |
| | | }), |
| | | }, |
| | | }, |
| | | { |
| | | fieldName: 'saleQuantity', |
| | | label: 'é宿°é', |
| | |
| | | import type { TicketFormType } from '../data'; |
| | | |
| | | import type { AftersalesTicketApi } from '#/api/aftersales/ticket'; |
| | | import type { ErpSaleOrderApi } from '#/api/erp/sale/order'; |
| | | import type { VxeTableGridOptions } from '#/adapter/vxe-table'; |
| | | |
| | | import { computed, nextTick, ref } from 'vue'; |
| | |
| | | resolveTicket, |
| | | updateTicket, |
| | | } from '#/api/aftersales/ticket'; |
| | | import { getSaleOrder } from '#/api/erp/sale/order'; |
| | | import { $t } from '#/locales'; |
| | | |
| | | import { useTicketFormSchema, useTicketItemFormSchema, useTicketItemGridColumns } from '../data'; |
| | |
| | | return $t('ui.actionTitle.create', ['å®åå·¥å']); |
| | | }); |
| | | |
| | | // éå®è®¢åç©æè¿æ»¤ |
| | | const orderItems = ref<ErpSaleOrderApi.SaleOrderItem[]>([]); |
| | | |
| | | function handleSaleOrderChange(item: Record<string, unknown> | undefined) { |
| | | if (!item?.id) { |
| | | orderItems.value = []; |
| | | return; |
| | | } |
| | | orderItems.value = (item.items as ErpSaleOrderApi.SaleOrderItem[]) || []; |
| | | } |
| | | |
| | | // 主表å |
| | | const [Form, formApi] = useVbenForm({ |
| | | commonConfig: { componentProps: { class: 'w-full' }, formItemClass: 'col-span-1', labelWidth: 110 }, |
| | |
| | | // Modalï¼å·¥å表åå¼¹æ¡ï¼å¿
é¡»å¨ ItemModal ä¹åï¼ç¡®ä¿ connectedComponent 注å
¥å°æ£ç¡®çå¼¹æ¡ |
| | | const [Modal, modalApi] = useVbenModal({ |
| | | async onOpenChange(isOpen: boolean) { |
| | | if (!isOpen) { formData.value = undefined; items.value = []; orderItems.value = []; showClassify.value = false; return; } |
| | | if (!isOpen) { formData.value = undefined; items.value = []; showClassify.value = false; return; } |
| | | const data = modalApi.getData<{ formType: TicketFormType; id?: number }>(); |
| | | formType.value = data.formType; |
| | | itemsGridApi.setGridOptions({ columns: useTicketItemGridColumns(isEdit.value) }); |
| | | formApi.setState({ schema: useTicketFormSchema(formType.value, formApi, handleSaleOrderChange) }); |
| | | formApi.setState({ schema: useTicketFormSchema(formType.value) }); |
| | | formApi.setDisabled(!isEdit.value); |
| | | modalApi.setState({ showConfirmButton: false, showCancelButton: false }); |
| | | if (data?.id) { |
| | |
| | | status: 'done', id: item.id, |
| | | })); |
| | | await formApi.setFieldValue('blobIds', blobIds); |
| | | } |
| | | if (formData.value?.saleOrderId) { |
| | | const order = await getSaleOrder(formData.value.saleOrderId); |
| | | orderItems.value = (order as Record<string, unknown>).items as ErpSaleOrderApi.SaleOrderItem[] || []; |
| | | } |
| | | } finally { modalApi.unlock(); } |
| | | } else { |
| | |
| | | onOpenChange(isOpen: boolean) { |
| | | if (!isOpen) { editingItemIndex.value = null; return; } |
| | | const d = itemModalApi.getData<AftersalesTicketApi.TicketItem | null>(); |
| | | itemFormApi.setState({ schema: useTicketItemFormSchema(formData.value?.customerId, formData.value?.saleOrderNo, orderItems.value, itemFormApi) }); |
| | | itemFormApi.setState({ schema: useTicketItemFormSchema(formData.value?.customerId, formData.value?.saleOrderNo, itemFormApi) }); |
| | | if (d) { itemFormApi.setValues(d as Record<string, unknown>); } else { itemFormApi.resetForm(); } |
| | | }, |
| | | }); |
| | |
| | | submitting.value = true; |
| | | try { |
| | | const data = (await formApi.getValues()) as Record<string, unknown>; |
| | | if (formData.value?.saleOrderId && data.saleOrderNo !== formData.value.saleOrderNo) { |
| | | message.warning('æ¤å·¥åå·²å
³èåå²è®¢åï¼ä¸è½ç´æ¥ä¿®æ¹è®¢åå·'); |
| | | return; |
| | | } |
| | | if (formData.value?.id) { |
| | | await updateTicket({ ...formData.value, ...data, items: items.value } as AftersalesTicketApi.Ticket); |
| | | } else { |
| | |
| | | }, |
| | | }, |
| | | { |
| | | component: 'InputNumber', |
| | | fieldName: 'leadTime', |
| | | label: 'éè´æåæï¼å¤©ï¼', |
| | | componentProps: { class: '!w-full', min: 0, precision: 0, placeholder: '请è¾å
¥éè´æåæ' }, |
| | | }, |
| | | { |
| | | component: 'InputNumber', |
| | | fieldName: 'outsourceLeadTime', |
| | | label: 'å¤åæåæï¼å¤©ï¼', |
| | | componentProps: { class: '!w-full', min: 0, precision: 0, placeholder: '请è¾å
¥å¤åæåæ' }, |
| | | }, |
| | | { |
| | | component: "InputNumber", |
| | | fieldName: "minStock", |
| | | label: "æä½åºå", |
| | |
| | | |
| | | const actionType = route.params.type as string; // æä½ç±»åï¼createãcopyãupdate |
| | | const currentStep = ref(-1); // æ¥éª¤æ§å¶ã-1 ç¨äºï¼ä¸å¼å§å
¨é¨ä¸å±ç¤ºçå½å页颿°æ®åå§å宿 |
| | | const processVisited = ref(false); |
| | | watch(currentStep, (step) => { |
| | | if (step === 2) processVisited.value = true; |
| | | }, { flush: 'sync' }); |
| | | const steps = [ |
| | | { title: 'åºæ¬ä¿¡æ¯', validator: validateBasic }, |
| | | { title: '表å设计', validator: validateForm }, |
| | |
| | | |
| | | // æµç¨è®¾è®¡æ ¡éª |
| | | try { |
| | | if (!processDesignRef.value) { |
| | | currentStep.value = 2; |
| | | message.warning('请å
æ£æ¥æµç¨è®¾è®¡'); |
| | | return false; |
| | | } |
| | | await validateProcess(); |
| | | } catch { |
| | | currentStep.value = 2; |
| | |
| | | } |
| | | deployLoading.value = true; |
| | | // 1.2 æ ¡éªæææ¥éª¤ |
| | | await validateAllSteps(); |
| | | if (!(await validateAllSteps())) return; |
| | | |
| | | // 2.1 æ´æ°è¡¨åæ°æ® |
| | | const modelData = { |
| | |
| | | if (formData.value.id) { |
| | | await updateModel(modelData); |
| | | } else { |
| | | const result = await createModel(modelData); |
| | | formData.value.id = result.id; |
| | | formData.value.id = await createModel(modelData); |
| | | } |
| | | // 2.3 åå¸ |
| | | await deployModel(formData.value.id); |
| | |
| | | <Card :body-style="{ padding: '10px' }" class="mb-4"> |
| | | <div class="mt-12"> |
| | | <!-- ç¬¬ä¸æ¥ï¼åºæ¬ä¿¡æ¯ --> |
| | | <div v-if="currentStep === 0" class="mx-auto w-4/6"> |
| | | <div v-if="currentStep >= 0" v-show="currentStep === 0" class="mx-auto w-4/6"> |
| | | <BasicInfo |
| | | v-model="formData" |
| | | :category-list="categoryList" |
| | |
| | | /> |
| | | </div> |
| | | <!-- ç¬¬äºæ¥ï¼è¡¨å设计 --> |
| | | <div v-if="currentStep === 1" class="mx-auto w-4/6"> |
| | | <div v-if="currentStep >= 0" v-show="currentStep === 1" class="mx-auto w-4/6"> |
| | | <FormDesign |
| | | v-model="formData" |
| | | :form-list="formList" |
| | |
| | | |
| | | <!-- ç¬¬ä¸æ¥ï¼æµç¨è®¾è®¡ --> |
| | | <ProcessDesign |
| | | v-if="currentStep === 2" |
| | | v-if="processVisited" |
| | | v-show="currentStep === 2" |
| | | :active="currentStep === 2" |
| | | v-model="formData" |
| | | ref="processDesignRef" |
| | | /> |
| | |
| | | |
| | | import type { BpmModelApi } from '#/api/bpm/model'; |
| | | |
| | | import { inject, onBeforeUnmount, provide, ref, shallowRef, watch } from 'vue'; |
| | | import { inject, nextTick, onBeforeUnmount, provide, ref, shallowRef, watch } from 'vue'; |
| | | |
| | | import { ContentWrap } from '@vben/common-ui'; |
| | | import { BpmModelFormType } from '@vben/constants'; |
| | |
| | | |
| | | defineOptions({ name: 'BpmModelEditor' }); |
| | | |
| | | defineProps<{ |
| | | const props = defineProps<{ |
| | | active: boolean; |
| | | modelId?: string; |
| | | modelKey: string; |
| | | modelName: string; |
| | |
| | | const modelData = inject('modelData') as Ref; // 注å
¥æ¨¡åæ°æ® |
| | | |
| | | const modeler = shallowRef(); // BPMN Modeler |
| | | let fitted = false; |
| | | watch(() => [props.active, modeler.value] as const, async ([active, instance]) => { |
| | | if (!active || !instance) return; |
| | | await nextTick(); |
| | | const canvas = instance.get('canvas'); |
| | | canvas.resized(); |
| | | if (!fitted) { |
| | | canvas.zoom('fit-viewport', 'auto'); |
| | | fitted = true; |
| | | } |
| | | }); |
| | | const controlForm = ref({ |
| | | simulation: true, |
| | | labelEditing: false, |
| | |
| | | ], |
| | | }; |
| | | |
| | | /** çå¬è¡¨å ID ååï¼å è½½è¡¨åæ°æ® */ |
| | | /** 表åç±»åæ ID å忶忥é¢è§ï¼é¿å
弿¥è¯·æ±è¦çæ°çéæ©ã */ |
| | | let formRequest = 0; |
| | | watch( |
| | | () => modelData.value.formId, |
| | | async (newFormId) => { |
| | | if (newFormId && modelData.value.formType === BpmModelFormType.NORMAL) { |
| | | () => [modelData.value.formId, modelData.value.formType] as const, |
| | | async ([newFormId, newFormType]) => { |
| | | const request = ++formRequest; |
| | | formPreview.value.rule = []; |
| | | if (newFormId && newFormType === BpmModelFormType.NORMAL) { |
| | | const data = await getForm(newFormId); |
| | | if (request !== formRequest) return; |
| | | setConfAndFields2(formPreview.value, data.conf, data.fields); |
| | | // 设置åªè¯» |
| | | formPreview.value.rule.forEach((item) => { |
| | | item.props = { ...item.props, disabled: true }; |
| | | }); |
| | | } else { |
| | | formPreview.value.rule = []; |
| | | } |
| | | }, |
| | | { immediate: true }, |
| | |
| | | import SimpleModelDesign from './simple-model-design.vue'; |
| | | |
| | | const modelData = defineModel<any>(); // å建æ¬å°æ°æ®å¯æ¬ |
| | | defineProps<{ active: boolean }>(); |
| | | const processData = inject('processData') as Ref; |
| | | |
| | | const simpleDesign = ref(); |
| | |
| | | :model-id="modelData.id" |
| | | :model-key="modelData.key" |
| | | :model-name="modelData.name" |
| | | :active="active" |
| | | @success="handleDesignSuccess" |
| | | /> |
| | | </template> |
| | |
| | | <script lang="ts" setup> |
| | | import type { VxeTableGridOptions } from '#/adapter/vxe-table'; |
| | | import type { MesDvRepairApi } from '#/api/mes/dv/repair'; |
| | | import type { MesCollectApi } from '#/api/mes/dv/collect'; |
| | | |
| | | import { onBeforeUnmount, ref } from 'vue'; |
| | | |
| | | import { Page, useVbenModal } from '@vben/common-ui'; |
| | | import { MesDvRepairStatusEnum } from '@vben/constants'; |
| | | import { downloadFileFromBlobPart } from '@vben/utils'; |
| | | |
| | | import { Button, message } from 'ant-design-vue'; |
| | | import { Alert, Button, Descriptions, message, Modal, Table } from 'ant-design-vue'; |
| | | |
| | | import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table'; |
| | | import { deleteRepair, exportRepair, getRepairPage } from '#/api/mes/dv/repair'; |
| | | import { getCollectAlarmPage, getCollectDeviceList } from '#/api/mes/dv/collect'; |
| | | import { $t } from '#/locales'; |
| | | |
| | | import { useGridColumns, useGridFormSchema } from './data'; |
| | | import Form from './modules/form.vue'; |
| | | |
| | | const diagnosisOpen = ref(false); |
| | | const diagnosisLoading = ref(false); |
| | | const diagnosisError = ref(''); |
| | | const diagnosisRow = ref<MesDvRepairApi.Repair>(); |
| | | const diagnosisDevice = ref<MesCollectApi.CollectDevice>(); |
| | | const diagnosisAlarms = ref<MesCollectApi.CollectAlarm[]>([]); |
| | | let diagnosisSequence = 0; |
| | | let diagnosisTimer: ReturnType<typeof setInterval> | undefined; |
| | | |
| | | async function refreshDiagnosis() { |
| | | const row = diagnosisRow.value; |
| | | if (!diagnosisOpen.value || !row?.machineryId || diagnosisLoading.value) return; |
| | | const sequence = ++diagnosisSequence; |
| | | diagnosisLoading.value = true; |
| | | diagnosisError.value = ''; |
| | | try { |
| | | const devices = await getCollectDeviceList(); |
| | | const device = devices.find((item) => item.machineryId === row.machineryId); |
| | | const alarms = device?.collectDeviceCode |
| | | ? await getCollectAlarmPage({ pageNo: 1, pageSize: 20, deviceCode: device.collectDeviceCode } as any) |
| | | : undefined; |
| | | if (sequence !== diagnosisSequence) return; |
| | | diagnosisDevice.value = device; |
| | | diagnosisAlarms.value = alarms?.list || []; |
| | | } catch { |
| | | if (sequence === diagnosisSequence) { |
| | | diagnosisDevice.value = undefined; |
| | | diagnosisAlarms.value = []; |
| | | diagnosisError.value = 'è®¾å¤æ°éæåè¦æ°æ®å 载失败ï¼è¯·æ£æ¥æéåæ°éæå¡ã'; |
| | | } |
| | | } finally { |
| | | if (sequence === diagnosisSequence) diagnosisLoading.value = false; |
| | | } |
| | | } |
| | | function openDiagnosis(row: MesDvRepairApi.Repair) { |
| | | diagnosisSequence++; |
| | | diagnosisLoading.value = false; |
| | | diagnosisRow.value = row; |
| | | diagnosisDevice.value = undefined; |
| | | diagnosisAlarms.value = []; |
| | | diagnosisOpen.value = true; |
| | | void refreshDiagnosis(); |
| | | if (diagnosisTimer) clearInterval(diagnosisTimer); |
| | | diagnosisTimer = setInterval(() => void refreshDiagnosis(), 30000); |
| | | } |
| | | function closeDiagnosis() { |
| | | diagnosisSequence++; |
| | | diagnosisLoading.value = false; |
| | | if (diagnosisTimer) clearInterval(diagnosisTimer); |
| | | diagnosisTimer = undefined; |
| | | } |
| | | onBeforeUnmount(closeDiagnosis); |
| | | |
| | | const [FormModal, formModalApi] = useVbenModal({ |
| | | connectedComponent: Form, |
| | |
| | | auth: ['mes:dv-repair:query'], |
| | | onClick: handleDetail.bind(null, row), |
| | | }, |
| | | { |
| | | label: 'æ°éè¯æ', |
| | | type: 'link', |
| | | auth: ['mes:collect:query'], |
| | | onClick: openDiagnosis.bind(null, row), |
| | | }, |
| | | ]" |
| | | /> |
| | | </template> |
| | | </Grid> |
| | | <Modal v-model:open="diagnosisOpen" title="è®¾å¤æ°éè¯æ" :footer="null" width="800px" @cancel="closeDiagnosis"> |
| | | <Alert class="mb-4" type="info" show-icon message="æ¾ç¤ºæè¿ééæ°æ®ååè¦ï¼30 ç§å·æ°ä¸æ¬¡ï¼é信离线ä¸çåäºæºæ¢°æ
éã" /> |
| | | <Alert v-if="diagnosisError" class="mb-4" type="error" show-icon :message="diagnosisError" /> |
| | | <template v-else-if="diagnosisDevice"> |
| | | <Descriptions bordered size="small" :column="2"> |
| | | <Descriptions.Item label="设å¤">{{ diagnosisDevice.machineryName || diagnosisRow?.machineryName || '-' }}</Descriptions.Item> |
| | | <Descriptions.Item label="å¨çº¿ç¶æ">{{ diagnosisDevice.online == null ? 'æªç¥' : diagnosisDevice.online ? 'å¨çº¿' : '离线' }}</Descriptions.Item> |
| | | <Descriptions.Item label="è¿è¡ç¶æ">{{ diagnosisDevice.stsName || 'æªä¸æ¥' }}</Descriptions.Item> |
| | | <Descriptions.Item label="æè¿éé">{{ diagnosisDevice.lastCollectTime || 'ææ æ°æ®' }}</Descriptions.Item> |
| | | <Descriptions.Item label="æªæ¶é¤åè¦">{{ diagnosisDevice.activeAlarmCount ?? 'æªç¥' }}</Descriptions.Item> |
| | | <Descriptions.Item label="ç½å
³åå§å¼">{{ diagnosisDevice.dataJson || 'ææ æ°æ®' }}</Descriptions.Item> |
| | | </Descriptions> |
| | | <Table class="mt-4" :loading="diagnosisLoading" :data-source="diagnosisAlarms" :pagination="false" row-key="id" size="small" |
| | | :columns="[{ title: 'åè¦æ¶é´', dataIndex: 'wmTime' }, { title: 'åè¦å
容', dataIndex: 'wmMsg' }, { title: 'ç¶æ', dataIndex: 'status' }]" /> |
| | | </template> |
| | | <Alert v-else-if="!diagnosisLoading" type="warning" show-icon message="è®¾å¤æªå
³èæ°éææ²¡æå¯¹åºééæ°æ®ï¼æ æ³è¿è¡è¿ç¨æ°æ®è¯æã" /> |
| | | <Button class="mt-4" :loading="diagnosisLoading" @click="refreshDiagnosis">å·æ°</Button> |
| | | </Modal> |
| | | </Page> |
| | | </template> |
| | |
| | | width: 100, |
| | | }, |
| | | { |
| | | field: 'schedulePriority', |
| | | title: 'æäº§ä¼å
级', |
| | | width: 100, |
| | | }, |
| | | { |
| | | field: 'marketType', |
| | | title: 'å
å¤é', |
| | | width: 90, |
| | | formatter: ({ cellValue }) => cellValue === 'DOMESTIC' ? 'å
é' : cellValue === 'EXPORT' ? 'å¤é' : 'æªè®¾ç½®', |
| | | }, |
| | | { |
| | | field: 'deliveryDeviation', |
| | | title: '交æåå·®', |
| | | width: 130, |
| | | }, |
| | | { |
| | | field: 'progressPercent', |
| | | title: 'ç¶æè¿åº¦', |
| | | width: 150, |
| | |
| | | } from '@vben/constants'; |
| | | |
| | | import { Button, Card, Progress, Tabs, message } from 'ant-design-vue'; |
| | | import dayjs from 'dayjs'; |
| | | |
| | | import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table'; |
| | | import { getGanttTaskList } from '#/api/mes/pro/task'; |
| | |
| | | const quantity = row.quantity ?? 0; |
| | | const produced = row.quantityProduced ?? 0; |
| | | Object.assign(row, { completionProgressQuantity: isCompletedWorkOrder(row) ? Math.max(produced, quantity) : produced }); |
| | | if (row.status === MesProWorkOrderStatusEnum.CANCELED) { |
| | | row.deliveryDeviation = '已忶'; |
| | | } else if (!row.requestDate || !dayjs(row.requestDate).isValid()) { |
| | | row.deliveryDeviation = 'æªè®¾ç½®äº¤æ'; |
| | | } else if (isCompletedWorkOrder(row) && (!row.finishDate || !dayjs(row.finishDate).isValid())) { |
| | | row.deliveryDeviation = '缺å°å®é
宿æ¶é´'; |
| | | } else { |
| | | const measured = isCompletedWorkOrder(row) ? dayjs(row.finishDate) : dayjs(); |
| | | const days = measured.startOf('day').diff(dayjs(row.requestDate).startOf('day'), 'day'); |
| | | row.deliveryDeviation = days > 0 ? `${isCompletedWorkOrder(row) ? 'å»¶æ' : '已龿'} ${days} 天` : isCompletedWorkOrder(row) ? 'ææå®æ' : 'æªé¾æ'; |
| | | } |
| | | } |
| | | |
| | | const [Grid, gridApi] = useVbenVxeGrid({ |
| | |
| | | <Modal title="ä¸é®æäº§" class="w-[min(96vw,1200px)]"> |
| | | <div class="space-y-5 px-1"> |
| | | <Alert type="info" show-icon message="å
æ ¡éªä¸é¢è§ï¼å确认æäº§" |
| | | description="æäº¤æä¼å
åå·¥åºé¡ºåºå®æå·¥ä½ç«ä¸è®¾å¤ï¼æ¯å¤©09:00â18:00ï¼æé¤å¨æ«åå·²é
ç½®è忥ï¼é工使¶é´èªå¨é¡ºå»¶ãå·²é¨åæäº§çå·¥ååªè¡¥é½å©ä½æ°éï¼æå¤30个工åã200个æ°ä»»å¡ã" /> |
| | | description="æå·¥å BOM åå½åå¯ç¨åºåæ£æ¥é½å¥ï¼æ£é¤å»ç»ãå ç¨åå¯ç¨çå®å
¨åºåï¼ä¸å«èæçº¿è¾¹åºï¼å
ææäº§ä¼å
级ãåæäº¤æå®æè®¾å¤ãæäº¤æ¶éæ°æ£æ¥ï¼æäº§ä¸ä¼é¢å åºåï¼ç¶åå·¥åçæªæ¥äº§åºä¸è®¡ä½ç°æåºåã" /> |
| | | <div class="flex flex-wrap items-end gap-3"> |
| | | <label class="flex flex-col gap-2"> |
| | | <span class="font-medium">ææ©å¼å·¥æ¶é´</span> |
| | |
| | | help: 'é¢è®¡äº¤ä»/å®ææ¥æï¼ç¨äºæäº§ãè¿åº¦è·è¸ªä¸äº¤æé¢è¦ã', |
| | | rules: 'required', |
| | | }, |
| | | { |
| | | fieldName: 'schedulePriority', |
| | | label: 'æäº§ä¼å
级', |
| | | component: 'InputNumber', |
| | | componentProps: { min: 0, max: 100, precision: 0, disabled: headerReadonly, class: '!w-full' }, |
| | | defaultValue: 0, |
| | | help: 'æ°åè¶å¤§è¶å
æï¼ç¸åä¼å
级æäº¤ææåºï¼ç¶å·¥åä»éçå¾
åå·¥åã', |
| | | }, |
| | | { |
| | | fieldName: 'marketType', |
| | | label: 'å¸åºç±»å', |
| | | component: 'Select', |
| | | componentProps: { disabled: headerReadonly, allowClear: true, options: [{ label: 'å
é', value: 'DOMESTIC' }, { label: 'å¤é', value: 'EXPORT' }] }, |
| | | help: 'æä¸å¡å®é
éæ©ï¼æäº§ä¼å
级ç±ä¸æ¹å段åç¬ç»´æ¤ã', |
| | | }, |
| | | // { |
| | | // fieldName: 'feedbackRole', |
| | | // label: 'æ¥å·¥è§è²', |
| | |
| | | uploadSafetyFiles, |
| | | } from '#/api/safety'; |
| | | import EmployeeSelect from '#/views/hrm/employee/components/select-dropdown.vue'; |
| | | import { UserSelect } from '#/views/system/user/components'; |
| | | |
| | | import { |
| | | HAZARD_LEVEL_COLOR, |
| | |
| | | }; |
| | | |
| | | const assignOpen = ref(false); |
| | | const assignModel = reactive<{ id: number; assigneeId?: number; rectifyDeadline?: string; remark?: string }>({ |
| | | const assignModel = reactive<{ id: number; assigneeId?: number; notifyUserId?: number; rectifyDeadline?: string; remark?: string }>({ |
| | | id: 0, |
| | | assigneeId: undefined, |
| | | }); |
| | |
| | | function openAssign(row: SafetyApi.Hazard) { |
| | | assignModel.id = row.id!; |
| | | assignModel.assigneeId = row.assigneeId; |
| | | assignModel.notifyUserId = undefined; |
| | | assignModel.rectifyDeadline = row.rectifyDeadline; |
| | | assignModel.remark = ''; |
| | | assignOpen.value = true; |
| | |
| | | await assignHazard({ |
| | | id: assignModel.id, |
| | | assigneeId: assignModel.assigneeId, |
| | | notifyUserId: assignModel.notifyUserId, |
| | | rectifyDeadline: assignModel.rectifyDeadline, |
| | | remark: assignModel.remark, |
| | | }); |
| | |
| | | <FormItem label="æ´æ¹è´£ä»»äºº" required> |
| | | <EmployeeSelect v-model="assignModel.assigneeId" placeholder="è¾å
¥å§åæå·¥å·æç´¢æ´æ¹è´£ä»»äºº" /> |
| | | </FormItem> |
| | | <FormItem label="éç¥æ¥æ¶è´¦å·"> |
| | | <UserSelect v-model="assignModel.notifyUserId" placeholder="åå·¥æªç»å®è´¦å·æ¶ï¼è¯·æç¡®éæ©éç¥æ¥æ¶äºº" /> |
| | | <div class="text-xs text-gray-500">å工已ç»å®è´¦å·æ¶èªå¨éç¥æ¬äººï¼æªç»å®æ¶å¯æå®ä»£æ¶äººï¼éèªè¡ç¡®è®¤éç¥éè¾¾ã</div> |
| | | </FormItem> |
| | | <FormItem label="æ´æ¹æé"> |
| | | <DatePicker v-model:value="assignModel.rectifyDeadline" value-format="YYYY-MM-DD" class="w-full" /> |
| | | </FormItem> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script setup lang="ts"> |
| | | import type { SafetyRiskSource } from '#/api/safety/risk-source'; |
| | | import { onMounted, reactive, ref } from 'vue'; |
| | | import { Page } from '@vben/common-ui'; |
| | | import { Alert, Button, Card, Form, FormItem, Input, InputNumber, message, Modal, Popconfirm, Select, Space, Table, Tag } from 'ant-design-vue'; |
| | | import { createRiskSource, deleteRiskSource, getRiskSourcePage, updateRiskSource } from '#/api/safety/risk-source'; |
| | | |
| | | defineOptions({ name: 'SafetyRiskSource' }); |
| | | const rows=ref<SafetyRiskSource[]>([]); const loading=ref(false); const saving=ref(false); const open=ref(false); |
| | | const filters=reactive<{name?:string}>({}); const paging=reactive({current:1,pageSize:10,total:0}); |
| | | const blank=():SafetyRiskSource=>({name:'',category:'MAJOR',location:'',metricName:'',unit:'',lowerLimit:undefined,upperLimit:undefined,currentValue:undefined,remark:''}); |
| | | const model=reactive<SafetyRiskSource>(blank()); |
| | | const categories:Record<string,string>={MAJOR:'é大å±é©æº',HIGH_VOLTAGE:'é«åè¯éªåºå',OTHER:'å
¶ä»'}; |
| | | const states:Record<string,string>={NORMAL:'æ£å¸¸',ALARM:'è¶
é',UNKNOWN:'æªç¥'}; |
| | | const columns=[{title:'å±é©æºåç§°',dataIndex:'name'},{title:'ç±»å«',key:'category'},{title:'ä½ç½®',dataIndex:'location'}, |
| | | {title:'çæµææ ',dataIndex:'metricName'},{title:'å½åå¼',key:'value'},{title:'éå¼',key:'limits'}, |
| | | {title:'æ°å¼åæ´æ¶é´',dataIndex:'observedAt'},{title:'ç¶æ',key:'status'},{title:'æä½',key:'action',width:150}]; |
| | | async function load(reset=false){if(reset)paging.current=1;loading.value=true;try{const r=await getRiskSourcePage({pageNo:paging.current,pageSize:paging.pageSize,...filters});rows.value=r.list;paging.total=r.total;}finally{loading.value=false;}} |
| | | function edit(row?:SafetyRiskSource){Object.assign(model,blank(),{id:undefined},row||{});open.value=true;} |
| | | async function save(){ |
| | | if(!model.name.trim()||!model.metricName.trim()){message.warning('请填åå±é©æºåç§°åçæµææ ');return;} |
| | | if(model.lowerLimit!=null&&model.upperLimit!=null&&model.lowerLimit>model.upperLimit){message.warning('ä¸éä¸è½å¤§äºä¸é');return;} |
| | | saving.value=true;try{if(model.id)await updateRiskSource(model);else await createRiskSource(model);message.success('ä¿åæå');open.value=false;await load();}finally{saving.value=false;} |
| | | } |
| | | async function remove(id:number){await deleteRiskSource(id);message.success('å·²å é¤');await load();} |
| | | onMounted(()=>load()); |
| | | </script> |
| | | <template> |
| | | <Page auto-content-height> |
| | | <Card title="å±é©æºå°è´¦"> |
| | | <Alert class="mb-4" type="info" show-icon message="人工维æ¤å½åçæµå¼åéå¼ï¼æ¤é¡µé¢ä¸ä»£è¡¨ç°åºèªå¨ééï¼äº¦ä¸ä¿ååå²è§æµåºåã" /> |
| | | <Space class="mb-3"><Input v-model:value="filters.name" placeholder="å±é©æºåç§°" allow-clear /><Button @click="load(true)">æ¥è¯¢</Button><Button v-access:code="['safety:risk:create']" type="primary" @click="edit()">æ°å¢å±é©æº</Button></Space> |
| | | <Table :columns="columns" :data-source="rows" :loading="loading" :pagination="paging" row-key="id" :scroll="{x:1100}" @change="p=>{paging.current=p.current||1;paging.pageSize=p.pageSize||10;load();}"> |
| | | <template #bodyCell="{column,record}"> |
| | | <template v-if="column.key==='category'">{{categories[record.category]||record.category}}</template> |
| | | <template v-else-if="column.key==='value'">{{record.currentValue??'æªå½å
¥'}} {{record.unit||''}}</template> |
| | | <template v-else-if="column.key==='limits'">{{record.lowerLimit??'ä¸é'}} ï½ {{record.upperLimit??'ä¸é'}} {{record.unit||''}}</template> |
| | | <Tag v-else-if="column.key==='status'" :color="record.riskStatus==='ALARM'?'red':record.riskStatus==='NORMAL'?'green':'default'">{{states[record.riskStatus]||'æªç¥'}}</Tag> |
| | | <Space v-else-if="column.key==='action'"><Button v-access:code="['safety:risk:update']" type="link" @click="edit(record as SafetyRiskSource)">ç¼è¾</Button><Popconfirm title="确认å é¤è¯¥å±é©æºï¼" @confirm="remove(record.id)"><Button v-access:code="['safety:risk:delete']" type="link" danger>å é¤</Button></Popconfirm></Space> |
| | | </template> |
| | | </Table> |
| | | </Card> |
| | | <Modal v-model:open="open" :title="model.id?'ç¼è¾å±é©æº':'æ°å¢å±é©æº'" :confirm-loading="saving" @ok="save"> |
| | | <Form layout="vertical"> |
| | | <FormItem label="åç§°" required><Input v-model:value="model.name" :maxlength="120" /></FormItem> |
| | | <FormItem label="ç±»å«" required><Select v-model:value="model.category" :options="Object.entries(categories).map(([value,label])=>({value,label}))" /></FormItem> |
| | | <FormItem label="ä½ç½®"><Input v-model:value="model.location" :maxlength="160" /></FormItem> |
| | | <FormItem label="çæµææ " required><Input v-model:value="model.metricName" :maxlength="80" /></FormItem> |
| | | <FormItem label="åä½"><Input v-model:value="model.unit" :maxlength="32" /></FormItem> |
| | | <FormItem label="ä¸éï¼å¯ç©ºï¼"><InputNumber v-model:value="model.lowerLimit" class="w-full" /></FormItem> |
| | | <FormItem label="ä¸éï¼å¯ç©ºï¼"><InputNumber v-model:value="model.upperLimit" class="w-full" /></FormItem> |
| | | <FormItem label="å½åå¼ï¼äººå·¥å½å
¥ï¼"><InputNumber v-model:value="model.currentValue" class="w-full" /></FormItem> |
| | | <FormItem label="夿³¨"><Input.TextArea v-model:value="model.remark" :maxlength="500" /></FormItem> |
| | | </Form> |
| | | </Modal> |
| | | </Page> |
| | | </template> |
| | |
| | | // ========== æ¥ä»·ç®¡ç ========== |
| | | const quoteList = ref<SrmTenderApi.QuoteVO[]>([]); |
| | | const quoteLoading = ref(false); |
| | | const compareLoading = ref(false); |
| | | const compareQuotes = ref<Record<number, SrmTenderApi.QuoteVO[]>>({}); |
| | | const compareLoaded = ref(false); |
| | | const quoteFormVisible = ref(false); |
| | | const quoteForm = ref<SrmTenderApi.QuoteVO>({ bidId: undefined!, tenderMaterialId: undefined! }); |
| | | const selectedBidId = ref<number>(); |
| | |
| | | else await createQuote(quoteForm.value); |
| | | quoteFormVisible.value = false; |
| | | await loadQuotes(selectedBidId.value); |
| | | compareLoaded.value = false; |
| | | } catch { /* 请æ±å±å·²æç¤ºé误ï¼ä¿ç表å */ } |
| | | } |
| | | |
| | | /** æææ åå«è¯»åæ¥ä»·ï¼ç¼ºæ¥ç©æä¸åä¸å®æ´æ¥ä»·æ¯è¾ã */ |
| | | async function loadComparison() { |
| | | compareLoading.value = true; |
| | | compareLoaded.value = false; |
| | | try { |
| | | await Promise.all([loadBids(), loadMaterials()]); |
| | | const bids = bidList.value.filter((bid) => bid.id != null && bid.bidStatus !== BID_STATUS.WITHDRAWN); |
| | | const entries = await Promise.all(bids.map(async (bid) => [bid.id!, await getQuoteList(bid.id!)] as const)); |
| | | compareQuotes.value = Object.fromEntries(entries); |
| | | compareLoaded.value = true; |
| | | } catch { |
| | | compareQuotes.value = {}; |
| | | message.error('æ¯ä»·æ°æ®å 载失败ï¼è¯·éè¯'); |
| | | } finally { |
| | | compareLoading.value = false; |
| | | } |
| | | } |
| | | |
| | | const comparisonRows = computed(() => bidList.value |
| | | .filter((bid) => bid.id != null && bid.bidStatus !== BID_STATUS.WITHDRAWN) |
| | | .map((bid) => { |
| | | const quotes = compareQuotes.value[bid.id!] || []; |
| | | const byMaterial = new Map(quotes.map((quote) => [quote.tenderMaterialId, quote])); |
| | | const valid = materialList.value.every((material) => material.id != null && byMaterial.get(material.id)?.quotePrice != null); |
| | | return { bid, quotes: byMaterial, complete: materialList.value.length > 0 && valid }; |
| | | })); |
| | | |
| | | const comparableLowestPrices = computed(() => { |
| | | const result = new Map<number, number>(); |
| | | for (const material of materialList.value) { |
| | | if (material.id == null) continue; |
| | | const quotes = comparisonRows.value.map((row) => row.quotes.get(material.id!)) |
| | | .filter((quote): quote is SrmTenderApi.QuoteVO => quote?.quotePrice != null); |
| | | if (quotes.length < 2 || quotes.some((quote) => quote.taxRate == null) |
| | | || new Set(quotes.map((quote) => quote.taxRate)).size !== 1) continue; |
| | | const prices = quotes.map((quote) => Number(quote.quotePrice)).filter(Number.isFinite); |
| | | if (prices.length === quotes.length) result.set(material.id, Math.min(...prices)); |
| | | } |
| | | return result; |
| | | }); |
| | | function handleAddQuote() { |
| | | quoteForm.value = { bidId: selectedBidId.value!, tenderMaterialId: undefined! }; |
| | | quoteFormVisible.value = true; |
| | |
| | | </a-empty> |
| | | </a-tab-pane> |
| | | |
| | | <a-tab-pane v-if="isSimpleMode" key="comparison"> |
| | | <template #tab> |
| | | <a-space :size="4"><IconifyIcon icon="ant-design:table-outlined" />ä¾åºåæ¯ä»·</a-space> |
| | | </template> |
| | | <div class="mb-3 flex items-center gap-3"> |
| | | <a-button :loading="compareLoading" @click="loadComparison">{{ compareLoaded ? 'å·æ°æ¯ä»·' : 'å è½½æ¯ä»·' }}</a-button> |
| | | <span class="text-gray-500">æç©æå±ç¤ºæ¥ä»·ï¼è³å°ä¸¤å®¶ç¨çä¸è´æ¶æ è®°æä½åä»·ï¼ç¼ºæ¥æç¨çä¸åéäººå·¥æ ¸å¯¹ï¼å®æ è¿éèèå塿¡ä»¶ã</span> |
| | | </div> |
| | | <div v-if="compareLoaded" class="overflow-x-auto"> |
| | | <table class="w-full border-collapse text-sm"> |
| | | <thead><tr> |
| | | <th class="border p-2 text-left">ä¾åºå / ææ ç¼å·</th> |
| | | <th v-for="material in materialList" :key="material.id" class="border p-2 text-left"> |
| | | {{ material.productName }}ï¼{{ material.productCode }}ï¼ / {{ material.quantity }} {{ material.unit }} |
| | | </th> |
| | | <th class="border p-2 text-left">宿´æ§ / ææ æ»é¢</th> |
| | | </tr></thead> |
| | | <tbody><tr v-for="row in comparisonRows" :key="row.bid.id"> |
| | | <td class="border p-2">{{ row.bid.supplierName }}<br />{{ row.bid.bidNo }}</td> |
| | | <td v-for="material in materialList" :key="material.id" class="border p-2"> |
| | | <template v-if="material.id != null && row.quotes.get(material.id)?.quotePrice != null"> |
| | | åä»· {{ row.quotes.get(material.id)?.quotePrice }}<br /> |
| | | <a-tag v-if="comparableLowestPrices.has(material.id) && Number(row.quotes.get(material.id)?.quotePrice) === comparableLowestPrices.get(material.id)" color="green">æä½åä»·</a-tag> |
| | | ç¨ç {{ row.quotes.get(material.id)?.taxRate != null ? `${row.quotes.get(material.id)?.taxRate}%` : 'æªå¡«' }}<br /> |
| | | 交æ {{ row.quotes.get(material.id)?.deliveryCycle != null ? `${row.quotes.get(material.id)?.deliveryCycle} 天` : 'æªå¡«' }} |
| | | </template> |
| | | <span v-else class="text-orange-600">æªæ¥ä»·</span> |
| | | </td> |
| | | <td class="border p-2">{{ row.complete ? 'å·²æ¥å
¨é¨ç©æ' : 'æ¥ä»·ä¸å®æ´' }}<br />æ»é¢ {{ row.bid.bidTotalAmount ?? 'æªç»´æ¤' }}</td> |
| | | </tr></tbody> |
| | | </table> |
| | | <a-empty v-if="!comparisonRows.length" description="ææ ææææ " /> |
| | | </div> |
| | | </a-tab-pane> |
| | | |
| | | <!-- Tab4: 弿 --> |
| | | <a-tab-pane v-if="!isSimpleMode" key="bidOpen"> |
| | | <template #tab> |