gaoluyang
2 天以前 173855b299e89930b5ea2ff9a9295fde9d0f55b5
feat: 同步质检、生产、销售、财务与人事前端功能

- MES:IPQC 工单驱动与附件、盘号展示,新增质检统计、巡检、工时与计件工资、
销售通知台账、盘点/入库相关页面;移除旧排班日历页面
- CRM:回款审批、发票录入与付款节点、报价单 OCR/模板、客户地址、客户对账单
- ERP:采购发票、财务对账与核销页面
- HRM:社保与薪资核算页面调整
- SRM:询价页面调整
- 公共:业务枚举、质量规则引擎与单据渲染同步

Co-Authored-By: Claude Code <noreply@anthropic.com>
已添加29个文件
已删除31个文件
已重命名1个文件
已修改76个文件
12936 ■■■■■ 文件已修改
src/api/crm/customerAddress/index.ts 43 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/crm/financeReport/index.ts 171 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/crm/invoice/index.ts 57 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/crm/receivable/index.ts 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/crm/saleQuotation/index.ts 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/erp/purchase/invoice/index.ts 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/hrm/salary/calculation/index.ts 45 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/hrm/salary/employee-social-security/index.ts 32 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/cal/calendar/index.ts 27 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/cal/holiday/index.ts 39 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/cal/plan/index.ts 59 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/cal/plan/shift/index.ts 36 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/cal/plan/team/index.ts 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/cal/team/index.ts 53 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/cal/team/member/index.ts 51 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/dv/machinery/index.ts 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/pd/project/index.ts 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/pro/feedback/index.ts 37 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/pro/mps/index.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/pro/process/index.ts 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/pro/wage/index.ts 191 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/pro/worktime/index.ts 158 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/qc/ipqc/index.ts 80 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/qc/ncr/index.ts 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/qc/patrol/index.ts 58 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/qc/statistics/index.ts 65 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/qc/template-process/index.ts 46 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/wm/productsales/index.ts 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/wm/salesnotice/index.ts 58 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/wm/transaction/index.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/srm/inquiry/index.ts 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/quality/engine/report-evaluator.ts 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/quality/engine/rule-engine.ts 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/constants/src/biz-mes-enum.ts 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/constants/src/dict-enum.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/utils/contract-document.ts 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/backlog/modules/receivable-audit-list.vue 32 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/customer/detail/index.vue 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/customer/detail/modules/address-list.vue 213 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/customerStatement/index.vue 447 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/invoice/data.ts 48 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/invoice/index.vue 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/invoice/modules/entry-form.vue 154 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/invoice/modules/payment-nodes-editor.vue 117 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/invoice/modules/sale-out-select.vue 174 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/receivable/data.ts 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/receivable/detail/data.ts 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/receivable/detail/index.vue 47 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/receivable/index.vue 44 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/saleQuotation/data.ts 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/saleQuotation/index.vue 98 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/saleQuotation/modules/ocr-upload.vue 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/finance/reconciliation/data.ts 122 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/finance/reconciliation/index.vue 119 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/finance/statistics/index.vue 62 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/finance/writeoff/index.vue 92 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/purchase/invoice/data.ts 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/purchase/invoice/modules/item-receipt-select.vue 186 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/sale/order/data.ts 63 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/sale/order/index.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/employee/modules/detail.vue 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/employee/modules/social-security-form.vue 162 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/employee/modules/social-security-list.vue 129 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/resignation/index.vue 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/salary/calculation/data.ts 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/salary/calculation/index.vue 345 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/salary/employee-social-security/data.ts 34 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/salary/employee-social-security/index.vue 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/salary/employee-social-security/modules/form.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/salary/employee-social-security/modules/history.vue 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/salary/payment/index.vue 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/salary/payment/modules/detail.vue 35 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/cal/calendar/components/date-cell.vue 138 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/cal/calendar/components/index.ts 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/cal/calendar/components/legend.vue 40 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/cal/calendar/components/panel.vue 106 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/cal/calendar/components/use-calendar.ts 100 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/cal/calendar/index.vue 35 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/cal/calendar/modules/team-view.vue 85 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/cal/calendar/modules/type-view.vue 86 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/cal/calendar/modules/user-view.vue 92 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/cal/holiday/data.ts 48 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/cal/holiday/index.vue 233 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/cal/holiday/modules/form.vue 92 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/cal/plan/data.ts 303 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/cal/plan/index.vue 147 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/cal/plan/modules/form.vue 166 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/cal/plan/modules/shift-list.vue 258 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/cal/plan/modules/team-list.vue 233 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/cal/team/components/index.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/cal/team/components/select-dialog.vue 153 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/cal/team/components/select.vue 90 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/cal/team/data.ts 199 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/cal/team/modules/form.vue 116 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/cal/team/modules/member-list.vue 203 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pd/project/index.vue 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/feedback/data.ts 240 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/feedback/modules/form.vue 175 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/feedback/modules/worktime-aux-input.vue 276 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/mps/index.vue 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/process/data.ts 65 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/route/data.ts 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/task/data.ts 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/workorder-center/modules/quick-feedback.vue 51 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/workorder/data.ts 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/workorder/modules/process-form.vue 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/workorder/modules/process-list.vue 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/worktime-aux/data.ts 338 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/worktime-aux/index.vue 124 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/worktime-aux/modules/form.vue 81 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/worktime-report/index.vue 220 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/process-design/process/data.ts 198 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/process-design/process/index.vue 60 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/process-design/process/modules/form.vue 55 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/process-design/process/modules/quality-rule-manage.vue 279 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/process-design/process/modules/wage-field-editor.vue 212 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/process-design/process/modules/wage-field-manage.vue 242 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/process-design/process/modules/wage-rule-manage.vue 309 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/process-design/route/data.ts 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/ipqc/data.ts 197 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/ipqc/modules/form.vue 713 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/ncr/data.ts 96 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/ncr/index.vue 142 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/patrol/data.ts 293 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/patrol/index.vue 71 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/patrol/modules/form.vue 129 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/statistics/index.vue 294 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/template/index.vue 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/qc/template/modules/process-bind.vue 237 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/workbench/components/tabs/TaskDetailTab.vue 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/srm/inquiry/index.vue 49 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/srm/supplier/data.ts 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/productsales/index.vue 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/salesnotice/data.ts 166 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/salesnotice/index.vue 84 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/salesnotice/modules/form.vue 66 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/transaction/data.ts 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/crm/customerAddress/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,43 @@
import { requestClient } from '#/api/request';
export namespace CrmCustomerAddressApi {
  /** å®¢æˆ·æ”¶è´§åœ°å€ */
  export interface CustomerAddress {
    id?: number;
    customerId: number;
    receiverName: string;
    receiverMobile: string;
    areaId?: number;
    detailAddress: string;
    defaultFlag?: boolean;
    remark?: string;
    createTime?: string;
  }
}
/** æŸ¥è¯¢å®¢æˆ·æ”¶è´§åœ°å€åˆ—表 */
export function getCustomerAddressList(customerId: number) {
  return requestClient.get<CrmCustomerAddressApi.CustomerAddress[]>(
    '/crm/customer-address/list',
    { params: { customerId } },
  );
}
/** åˆ›å»ºå®¢æˆ·æ”¶è´§åœ°å€ */
export function createCustomerAddress(
  data: CrmCustomerAddressApi.CustomerAddress,
) {
  return requestClient.post<number>('/crm/customer-address/create', data);
}
/** ä¿®æ”¹å®¢æˆ·æ”¶è´§åœ°å€ */
export function updateCustomerAddress(
  data: CrmCustomerAddressApi.CustomerAddress,
) {
  return requestClient.put('/crm/customer-address/update', data);
}
/** åˆ é™¤å®¢æˆ·æ”¶è´§åœ°å€ */
export function deleteCustomerAddress(id: number) {
  return requestClient.delete(`/crm/customer-address/delete?id=${id}`);
}
src/api/crm/financeReport/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,171 @@
import { requestClient } from '#/api/request';
export namespace CrmFinanceReportApi {
  /** å®¢æˆ·å¾€æ¥-查询参数 */
  export interface CustomerStatementParams {
    beginTime?: string;
    customerId: number;
    endTime?: string;
    includeUnapprovedOrder?: boolean;
  }
  /** å®¢æˆ·å¾€æ¥-汇总列表查询参数 */
  export interface CustomerStatementSummaryParams {
    beginTime?: string;
    endTime?: string;
    includeUnapprovedOrder?: boolean;
  }
  /** å®¢æˆ·å¾€æ¥æ±‡æ€»ï¼ˆå·¦ä¾§å®¢æˆ·æ€»è§ˆï¼‰ */
  export interface CustomerStatementSummary {
    customerId: number;
    customerName: string;
    orderCount: number;
    invoiceCount: number;
    receiptCount: number;
    totalOrderAmount: number;
    totalInvoiceAmount: number;
    totalCollectedAmount: number;
    uncollectedAmount: number;
  }
  /** å®¢æˆ·å¾€æ¥å¯¹è´¦å• */
  export interface CustomerStatement {
    customerId: number;
    customerName: string;
    orderCount: number;
    orderTotalAmount: number;
    shippedAmount: number;
    invoicedAmount: number;
    collectedAmount: number;
    shippedNotInvoicedAmount: number;
    outstandingAmount: number;
    nextMonthPlanAmount: number;
    orders: CustomerStatementOrderRow[];
    invoices: CustomerStatementInvoiceRow[];
    receipts: CustomerStatementReceiptRow[];
  }
  /** å®¢æˆ·å¾€æ¥å¯¹è´¦å•-订单行 */
  export interface CustomerStatementOrderRow {
    saleOrderId: number;
    saleOrderNo: string;
    contractId?: number;
    orderTime?: string;
    orderAmount: number;
    shippedAmount: number;
    invoicedAmount: number;
    collectedAmount: number;
    shippedNotInvoicedAmount: number;
    uninvoicedAmount: number;
    outstandingAmount: number;
    status?: number;
  }
  /** å®¢æˆ·å¾€æ¥å¯¹è´¦å•-开票行 */
  export interface CustomerStatementInvoiceRow {
    id: number;
    no: string;
    invoiceNo?: string;
    invoiceTime?: string;
    invoiceAmount: number;
    collectedAmount: number;
    remainingReceivable: number;
    saleOrderId?: number;
    saleOrderNo?: string;
    status?: number;
  }
  /** å®¢æˆ·å¾€æ¥å¯¹è´¦å•-回款行 */
  export interface CustomerStatementReceiptRow {
    id: number;
    no: string;
    returnTime?: string;
    amount: number;
    invoiceId?: number;
    invoiceNo?: string;
    status?: number;
  }
  /** å·²å‘货未开票明细 */
  export interface ShippedNotInvoiced {
    saleOutId: number;
    saleOutCode: string;
    salesDate?: string;
    saleOrderId?: number;
    saleOrderNo?: string;
    customerId: number;
    customerName: string;
    outAmount: number;
    invoicedAmount: number;
    remainInvoiceAmount: number;
  }
  /** æœªæ ¸é”€åº”收明细 */
  export interface UnwrittenOff {
    invoiceId: number;
    invoiceNo: string;
    taxInvoiceNo?: string;
    customerId: number;
    customerName: string;
    contractId?: number;
    saleOrderId?: number;
    invoiceTime?: string;
    invoiceAmount: number;
    collectedAmount: number;
    remainAmount: number;
    agingDays: number;
    agingBucket: string;
  }
  /** è´¢åŠ¡æœˆåº¦è¶‹åŠ¿ */
  export interface FinanceTrend {
    month: string;
    invoicedAmount: number;
    collectedAmount: number;
  }
}
/** èŽ·å¾—å„å®¢æˆ·å¾€æ¥æ±‡æ€»åˆ—è¡¨ï¼ˆå·¦ä¾§å®¢æˆ·æ€»è§ˆï¼‰ */
export function getCustomerStatementSummaryList(
  params?: CrmFinanceReportApi.CustomerStatementSummaryParams,
) {
  return requestClient.get<CrmFinanceReportApi.CustomerStatementSummary[]>(
    '/crm/finance/report/customer-statement/summary-list',
    { params },
  );
}
/** èŽ·å¾—å®¢æˆ·å¾€æ¥å¯¹è´¦æ•°æ® */
export function getCustomerStatement(
  params: CrmFinanceReportApi.CustomerStatementParams,
) {
  return requestClient.get<CrmFinanceReportApi.CustomerStatement>(
    '/crm/finance/report/customer-statement',
    { params },
  );
}
/** èŽ·å¾—å·²å‘è´§æœªå¼€ç¥¨æ˜Žç»† */
export function getShippedNotInvoicedList(customerId?: number) {
  return requestClient.get<CrmFinanceReportApi.ShippedNotInvoiced[]>(
    '/crm/finance/report/shipped-not-invoiced',
    { params: { customerId } },
  );
}
/** èŽ·å¾—æœªæ ¸é”€åº”æ”¶æ˜Žç»† */
export function getUnwrittenOffList(customerId?: number) {
  return requestClient.get<CrmFinanceReportApi.UnwrittenOff[]>(
    '/crm/finance/report/unwritten-off',
    { params: { customerId } },
  );
}
/** èŽ·å¾—è´¢åŠ¡æœˆåº¦è¶‹åŠ¿ */
export function getFinanceMonthlyTrend(months = 6) {
  return requestClient.get<CrmFinanceReportApi.FinanceTrend[]>(
    '/crm/finance/report/monthly-trend',
    { params: { months } },
  );
}
src/api/crm/invoice/index.ts
@@ -29,6 +29,16 @@
    invoiceNo?: string;
    invoiceTitle?: string;
    price?: number;
    saleOrderId?: number;
    saleOrderNo?: string;
    saleOutId?: number;
    saleOutCode?: string;
    contractAmount?: number;
    prepaymentAmount?: number;
    deliveryAmount?: number;
    acceptanceAmount?: number;
    paymentNodes?: PaymentNode[];
    entryStatus?: number;
    invoiceTime?: string;
    auditStatus?: number;
    remark?: string;
@@ -44,6 +54,32 @@
    creatorName?: string;
    createTime?: string;
    updateTime?: string;
  }
  /** æ¬¾é¡¹æ—¶é—´èŠ‚ç‚¹ */
  export interface PaymentNode {
    id?: number;
    /** é˜¶æ®µï¼š1合同款 2预付款 3到货款 4验收款 5质保金 */
    stage: number;
    amount?: number;
    returnTime?: string;
    receiptedPrice?: number;
    status?: number;
    remark?: string;
  }
  /** å¼€ç¥¨å¯é€‰å‡ºåº“单 */
  export interface SaleOut {
    id: number;
    code: string;
    saleOrderId?: number;
    salesOrderCode?: string;
    salesDate?: string;
    status?: number;
    statusName?: string;
    totalPrice?: number;
    invoicedPrice?: number;
    remainInvoicePrice?: number;
  }
  /** å¼€ç¥¨ç®€è¦ä¿¡æ¯ï¼ˆç”¨äºŽå›žæ¬¾å…³è”选择) */
@@ -140,3 +176,24 @@
    file,
  });
}
/** èŽ·å¾—å¼€ç¥¨å¯é€‰çš„é”€å”®å‡ºåº“å•ï¼ˆæŒ‰é”€å”®è®¢å•ï¼‰ */
export function getInvoiceSaleOutList(saleOrderId: number, id?: number) {
  return requestClient.get<CrmInvoiceApi.SaleOut[]>(
    '/crm/invoice/sale-out-list',
    { params: { saleOrderId, id } },
  );
}
/** å¼€ç¥¨ç”³è¯·å½•入发票(审批通过后填写真实发票信息与款项时间节点) */
export function entryInvoice(data: {
  id: number;
  invoiceNo: string;
  invoiceTitle?: string;
  invoiceTime: string;
  blobIds?: number[];
  paymentNodes?: CrmInvoiceApi.PaymentNode[];
  remark?: string;
}) {
  return requestClient.put('/crm/invoice/entry', data);
}
src/api/crm/receivable/index.ts
@@ -17,7 +17,7 @@
    invoiceId?: number;
    invoice?: InvoiceInfo;
    auditStatus: number;
    processInstanceId: number;
    processInstanceId: string;
    returnTime: Date;
    returnType: number;
    price: number;
@@ -98,6 +98,13 @@
  return requestClient.download('/crm/receivable/export-excel', { params });
}
/** æäº¤å›žæ¬¾å®¡æ‰¹ */
export function submitReceivable(id: number, remark?: string) {
  return requestClient.put('/crm/receivable/submit', null, {
    params: { id, remark },
  });
}
/** æ›´æ–°å›žæ¬¾çš„状态(审核/反审核) */
export function updateReceivableStatus(id: number, status: number) {
  return requestClient.put('/crm/receivable/update-status', null, {
src/api/crm/saleQuotation/index.ts
@@ -36,6 +36,14 @@
    items?: SaleQuotationItem[];
  }
  /** å®¡æ‰¹æµç¨‹å®šä¹‰ */
  export interface ApproveProcess {
    id: string;
    key: string;
    name: string;
    version: number;
  }
  /** æŠ¥ä»·å•物料明细 */
  export interface SaleQuotationItem {
    id: number;
@@ -108,4 +116,25 @@
  return requestClient.post<number>('/crm/sale-quotation/convert-contract', null, {
    params: { id },
  });
}
/** æäº¤æŠ¥ä»·å•审批 */
export function submitSaleQuotation(id: number, processDefinitionKey: string) {
  return requestClient.put('/crm/sale-quotation/submit', null, {
    params: { id, processDefinitionKey },
  });
}
/** èŽ·å–æŠ¥ä»·å•å®¡æ‰¹æµç¨‹åˆ—è¡¨ */
export function getSaleQuotationApproveProcessList() {
  return requestClient.get<CrmSaleQuotationApi.ApproveProcess[]>(
    '/crm/sale-quotation/approve-process-list',
  );
}
/** ä¸‹è½½å¸¦ç« æŠ¥ä»·å• Word */
export function downloadSaleQuotationDocument(id: number) {
  return requestClient.download('/crm/sale-quotation/export-word', {
    params: { id },
  });
}
src/api/erp/purchase/invoice/index.ts
@@ -22,6 +22,8 @@
    supplierName?: string;
    purchaseOrderId?: number;
    order?: PurchaseOrder;
    itemReceiptId?: number;
    itemReceiptCode?: string;
    invoiceNo?: string;
    invoiceTitle?: string;
    price?: number;
@@ -35,6 +37,18 @@
    creatorName?: string;
    createTime?: string;
    updateTime?: string;
  }
  /** æ¥ç¥¨å¯é€‰å…¥åº“单 */
  export interface ItemReceipt {
    id: number;
    code: string;
    name?: string;
    receiptDate?: string;
    status?: number;
    totalPrice?: number;
    invoicedPrice?: number;
    remainInvoicePrice?: number;
  }
  /** æ¥ç¥¨ç®€è¦ä¿¡æ¯ï¼ˆç”¨äºŽä»˜æ¬¾å…³è”选择) */
@@ -86,6 +100,14 @@
  return requestClient.download('/erp/purchase-invoice/export-excel', { params });
}
/** æŸ¥è¯¢æ¥ç¥¨å¯é€‰å…¥åº“单列表(按采购订单过滤,返回未取消入库单及可来票金额) */
export function getItemReceiptList(purchaseOrderId: number, id?: number) {
  return requestClient.get<ErpPurchaseInvoiceApi.ItemReceipt[]>(
    '/erp/purchase-invoice/item-receipt-list',
    { params: { purchaseOrderId, id } },
  );
}
/** æŸ¥è¯¢æ¥ç¥¨ä¸‹æ‹‰åˆ—表(按供应商可选来票,已上传发票附件且剩余可付金额大于 0) */
export function getPurchaseInvoiceSimpleList(
  supplierId: number,
src/api/hrm/salary/calculation/index.ts
@@ -18,6 +18,8 @@
    mealAllowance?: number;
    transportAllowance?: number;
    otherAllowance?: number;
    /** æŠ¥å·¥å·¥èµ„(按生产报工汇总,可人工调整) */
    workWage?: number;
    socialSecurityDeduction?: number;
    housingFundDeduction?: number;
    taxDeduction?: number;
@@ -59,6 +61,41 @@
    period: string;
    deptId?: number;
  }
  /** æŠ¥å·¥å·¥èµ„字段明细 */
  export interface WorkWageField {
    code?: string;
    name?: string;
    /** è®¡ç®—角色:1-系数 2-加减金额 3-计价数量 4-仅记录 */
    role?: number;
    value?: string;
    unitPrice?: number;
    /** æœ¬å­—段计入工资的金额 */
    amount?: number;
  }
  /** æŠ¥å·¥å·¥èµ„明细(一条报工单) */
  export interface WorkWageDetail {
    feedbackId?: number;
    feedbackNo?: string;
    processId?: number;
    processName?: string;
    feedbackTime?: string;
    status?: number;
    billingType?: number;
    workHours?: number;
    feedbackQuantity?: number;
    qualifiedQuantity?: number;
    mainWage?: number;
    auxWage?: number;
    /** è‡ªå®šä¹‰å·¥èµ„字段调整合计 */
    fieldWage?: number;
    preQualityWage?: number;
    /** è´¨é‡è°ƒæ•´ï¼ˆæ‰£æ¬¾ä¸ºè´Ÿï¼‰ */
    qualityAdjustment?: number;
    finalWage?: number;
    fields?: WorkWageField[];
  }
}
/** æŸ¥è¯¢è–ªé…¬æ ¸ç®—分页列表 */
@@ -91,6 +128,14 @@
  );
}
/** æŸ¥è¯¢å‘˜å·¥æŸæ ¸ç®—周期的报工工资明细 */
export function getWorkWageDetail(params: { period: string; userId: number }) {
  return requestClient.get<HrmSalaryCalculationApi.WorkWageDetail[]>(
    '/hrm/salary/calculation/work-wage-detail',
    { params },
  );
}
/** æ‰¹é‡ä¿å­˜è–ªé…¬æ ¸ç®—记录 */
export function saveSalaryCalculation(data: HrmSalaryCalculationApi.SalarySaveParam) {
  return requestClient.post('/hrm/salary/calculation/save', data);
src/api/hrm/salary/employee-social-security/index.ts
@@ -3,11 +3,10 @@
import { requestClient } from '#/api/request';
export namespace HrmEmployeeSocialSecurityApi {
  /** å‘˜å·¥ç¤¾ä¿å…¬ç§¯é‡‘档案 */
  /** éƒ¨é—¨ç¤¾ä¿å…¬ç§¯é‡‘档案 */
  export interface EmployeeSocialSecurity {
    id?: number;
    userId?: number;
    userName?: string;
    deptId?: number;
    deptName?: string;
    socialSecuritySchemeId?: number;
    schemeName?: string;
@@ -19,14 +18,15 @@
    createTime?: string;
  }
  /** å‘˜å·¥ç¤¾ä¿å…¬ç§¯é‡‘档案查询参数 */
  /** éƒ¨é—¨ç¤¾ä¿å…¬ç§¯é‡‘档案查询参数 */
  export interface EmployeeSocialSecurityPageParam extends PageParam {
    userName?: string;
    deptId?: number;
    status?: number;
    effectiveDate?: string;
  }
}
/** èŽ·å–å‘˜å·¥ç¤¾ä¿å…¬ç§¯é‡‘æ¡£æ¡ˆåˆ†é¡µåˆ—è¡¨ */
/** èŽ·å–éƒ¨é—¨ç¤¾ä¿å…¬ç§¯é‡‘æ¡£æ¡ˆåˆ†é¡µåˆ—è¡¨ */
export function getEmployeeSocialSecurityPage(
  params: HrmEmployeeSocialSecurityApi.EmployeeSocialSecurityPageParam,
) {
@@ -36,42 +36,42 @@
  );
}
/** èŽ·å–å‘˜å·¥ç¤¾ä¿å…¬ç§¯é‡‘æ¡£æ¡ˆè¯¦æƒ… */
/** èŽ·å–éƒ¨é—¨ç¤¾ä¿å…¬ç§¯é‡‘æ¡£æ¡ˆè¯¦æƒ… */
export function getEmployeeSocialSecurity(id: number) {
  return requestClient.get<HrmEmployeeSocialSecurityApi.EmployeeSocialSecurity>(
    `/hrm/salary/employee-social-security/get?id=${id}`,
  );
}
/** èŽ·å–å‘˜å·¥ç¤¾ä¿å…¬ç§¯é‡‘æ¡£æ¡ˆåŽ†å²åˆ—è¡¨ */
export function getEmployeeSocialSecurityListByUser(userId: number) {
/** èŽ·å–éƒ¨é—¨ç¤¾ä¿å…¬ç§¯é‡‘æ¡£æ¡ˆåŽ†å²åˆ—è¡¨ */
export function getEmployeeSocialSecurityListByDept(deptId: number) {
  return requestClient.get<HrmEmployeeSocialSecurityApi.EmployeeSocialSecurity[]>(
    `/hrm/salary/employee-social-security/list-by-user?userId=${userId}`,
    `/hrm/salary/employee-social-security/list-by-dept?deptId=${deptId}`,
  );
}
/** èŽ·å–å‘˜å·¥æœ€æ–°ç”Ÿæ•ˆçš„ç¤¾ä¿å…¬ç§¯é‡‘æ¡£æ¡ˆ */
export function getLatestEmployeeSocialSecurity(userId: number) {
/** èŽ·å–éƒ¨é—¨æœ€æ–°ç”Ÿæ•ˆçš„ç¤¾ä¿å…¬ç§¯é‡‘æ¡£æ¡ˆ */
export function getLatestEmployeeSocialSecurity(deptId: number) {
  return requestClient.get<HrmEmployeeSocialSecurityApi.EmployeeSocialSecurity>(
    `/hrm/salary/employee-social-security/get-latest?userId=${userId}`,
    `/hrm/salary/employee-social-security/get-latest?deptId=${deptId}`,
  );
}
/** åˆ›å»ºå‘˜å·¥ç¤¾ä¿å…¬ç§¯é‡‘档案 */
/** åˆ›å»ºéƒ¨é—¨ç¤¾ä¿å…¬ç§¯é‡‘档案 */
export function createEmployeeSocialSecurity(
  data: HrmEmployeeSocialSecurityApi.EmployeeSocialSecurity,
) {
  return requestClient.post('/hrm/salary/employee-social-security/create', data);
}
/** æ›´æ–°å‘˜å·¥ç¤¾ä¿å…¬ç§¯é‡‘档案 */
/** æ›´æ–°éƒ¨é—¨ç¤¾ä¿å…¬ç§¯é‡‘档案 */
export function updateEmployeeSocialSecurity(
  data: HrmEmployeeSocialSecurityApi.EmployeeSocialSecurity,
) {
  return requestClient.put('/hrm/salary/employee-social-security/update', data);
}
/** åˆ é™¤å‘˜å·¥ç¤¾ä¿å…¬ç§¯é‡‘档案 */
/** åˆ é™¤éƒ¨é—¨ç¤¾ä¿å…¬ç§¯é‡‘档案 */
export function deleteEmployeeSocialSecurity(id: number) {
  return requestClient.delete(`/hrm/salary/employee-social-security/delete?id=${id}`);
}
src/api/mes/cal/calendar/index.ts
ÎļþÒÑɾ³ý
src/api/mes/cal/holiday/index.ts
ÎļþÒÑɾ³ý
src/api/mes/cal/plan/index.ts
ÎļþÒÑɾ³ý
src/api/mes/cal/plan/shift/index.ts
ÎļþÒÑɾ³ý
src/api/mes/cal/plan/team/index.ts
ÎļþÒÑɾ³ý
src/api/mes/cal/team/index.ts
ÎļþÒÑɾ³ý
src/api/mes/cal/team/member/index.ts
ÎļþÒÑɾ³ý
src/api/mes/dv/machinery/index.ts
@@ -41,10 +41,11 @@
  );
}
/** æŸ¥è¯¢è®¾å¤‡ç²¾ç®€åˆ—表 */
export function getMachinerySimpleList() {
/** æŸ¥è¯¢è®¾å¤‡ç²¾ç®€åˆ—表;传 workstationId æ—¶åªè¿”回该工作站绑定的设备 */
export function getMachinerySimpleList(workstationId?: number) {
  return requestClient.get<MesDvMachineryApi.Machinery[]>(
    '/mes/dv/machinery/simple-list',
    { params: workstationId ? { workstationId } : undefined },
  );
}
src/api/mes/pd/project/index.ts
@@ -77,6 +77,13 @@
  return requestClient.download('/mes/pd/project/export-excel', { params });
}
/** å¯¼å‡ºé¡¹ç›®è·Ÿè¸ªè¡¨ Word(表头自动填充,跟踪记录留空可手填) */
export function exportPdProjectTrackingWord(id: number) {
  return requestClient.download('/mes/pd/project/export-word', {
    params: { id },
  });
}
/** å‘布(待发布→待上传) */
export function publishPdProject(id: number) {
  return requestClient.put('/mes/pd/project/publish', undefined, {
src/api/mes/pro/feedback/index.ts
@@ -57,6 +57,29 @@
    otherScrapQuantity?: number; // å…¶ä»–废品数量
    feedbackUserId?: number; // æŠ¥å·¥ç”¨æˆ·ç¼–号
    feedbackUserNickname?: string; // æŠ¥å·¥äººæ˜µç§°
    billingType?: number; // è®¡è´¹ç±»åž‹å¿«ç…§ï¼š1-计时 2-计件
    wageRate?: number; // å·¥èµ„定额快照:计时元/小时,计件元/ä»¶
    workHours?: number; // æœ¬æ¬¡æŠ¥å·¥å·¥æ—¶ï¼ˆå°æ—¶ï¼‰ï¼Œè®¡æ—¶è®¡è–ªå¿…å¡«
    wageAmount?: number; // æœ¬æ¬¡æŠ¥å·¥å·¥èµ„
    machineryId?: number; // æœ¬æ¬¡æŠ¥å·¥ä¸»ç”¨è®¾å¤‡ç¼–号
    machineryName?: string; // ä¸»ç”¨è®¾å¤‡åç§°
    wageRuleId?: number; // å‘½ä¸­çš„工序工资单价规则编号
    auxPaidHours?: number; // è¾…助计薪工时(小时)
    auxWageAmount?: number; // æœ¬æ¬¡è¾…助工资
    qualityPassRate?: number; // è¿‡ç¨‹æ£€éªŒåˆæ ¼çŽ‡ï¼ˆ%)
    qualityCoefficient?: number; // è´¨é‡ç³»æ•°
    qualityDeduction?: number; // è´¨é‡æ‰£æ¬¾é‡‘额
    wageBreakdown?: string; // å·¥èµ„计算明细快照(JSON)
    productionLength?: number; // æœ¬æ¬¡ç”Ÿäº§é•¿åº¦ï¼ˆç±³ï¼‰ï¼Œä»…用于计算标准工时
    standardWorkHours?: number; // æ ‡å‡†å·¥æ—¶ï¼ˆå°æ—¶ï¼‰ï¼Œåªç”¨äºŽè€ƒæ ¸å¯¹æ¯”
    standardPrimaryHours?: number; // æ ‡å‡†å·¥æ—¶-主工时(小时)
    standardAuxHours?: number; // æ ‡å‡†å·¥æ—¶-辅助工时(小时)
    stdCalcNote?: string; // æ ‡å‡†å·¥æ—¶è®¡ç®—依据
    workHoursDiff?: number; // å·¥æ—¶å·®å¼‚ = å®žé™… - æ ‡å‡†
    workHoursDiffRate?: number; // å·¥æ—¶å·®å¼‚率(%)
    standardWarnFlag?: boolean; // æ˜¯å¦è¶…出工时考核阈值
    standardWarnMessage?: string; // å·¥æ—¶è€ƒæ ¸æç¤º
    auxList?: AuxValue[]; // è¾…助工时录入明细
    approveUserId?: number; // å®¡æ ¸ç”¨æˆ·ç¼–号
    approveUserNickname?: string; // å®¡æ ¸äººæ˜µç§°
    status?: number; // çŠ¶æ€
@@ -66,6 +89,18 @@
    processInstanceId?: string; // BPM æµç¨‹å®žä¾‹ç¼–号
    processDefinitionKey?: string; // BPM æµç¨‹å®šä¹‰ Key
    paramValues?: ParamValue[];
  }
  export interface AuxValue {
    auxItemId: number;
    quantity?: number;
    materialFrom?: string;
    materialTo?: string;
    remark?: string;
    // ---- å›žæ˜¾å­—段 ----
    itemName?: string;
    measureType?: number;
    minutes?: number;
  }
  export interface ParamValue {
@@ -179,6 +214,8 @@
  taskId: number;
  paramValues?: MesProFeedbackApi.ParamValue[];
  feedbackQuantity: number;
  /** æœ¬æ¬¡æŠ¥å·¥å·¥æ—¶ï¼ˆå°æ—¶ï¼‰ï¼Œè®¡æ—¶è®¡è–ªçš„工序必填 */
  workHours?: number;
  feedbackTime?: string;
  remark?: string;
}
src/api/mes/pro/mps/index.ts
@@ -29,6 +29,8 @@
    mergeFlag?: number; // æ˜¯å¦åˆå¹¶ä¸‹å‘(0-否,1-是)
    mergeBatchNo?: string; // åˆå¹¶æ‰¹æ¬¡å·
    remark?: string; // å¤‡æ³¨
    creator?: string; // è®¡åˆ’创建人(销售订单计划取销售订单创建人)
    canIssue?: boolean; // å½“前登录用户是否可下发
    createTime?: number; // åˆ›å»ºæ—¶é—´
  }
src/api/mes/pro/process/index.ts
@@ -9,6 +9,26 @@
    code?: string;
    name?: string;
    attention?: string;
    /** è®¡è´¹ç±»åž‹ï¼š1-计时 2-计件 */
    billingType?: number;
    /** å·¥èµ„定额:计时为元/小时,计件为元/ä»¶ */
    wageRate?: number;
    /** è¾…助工时小时工资(元/小时),辅助项按工序计薪时的单价 */
    auxHourlyRate?: number;
    /** æ ‡å‡†å·¥æ—¶è®¡ç®—方式:0-未启用 1-转速×节距 2-线速度直取 3-满速百分比×节距 */
    stdTimeMode?: number;
    /** æ ‡å‡†å·¥æ—¶æŠ˜ç®—系数(束丝 1.8,其余 1) */
    stdTimeFactor?: number;
    /** æœºå°æ»¡è½¬é€Ÿï¼ˆr/min) */
    maxRpm?: number;
    /** è½¬é€Ÿå‚数编码 */
    stdRpmParamCode?: string;
    /** èŠ‚è·å‚æ•°ç¼–ç  */
    stdPitchParamCode?: string;
    /** çº¿é€Ÿåº¦ï¼ˆç±³/分)参数编码 */
    stdSpeedParamCode?: string;
    /** è½¬é€Ÿç™¾åˆ†æ¯”参数编码 */
    stdPercentParamCode?: string;
    status?: number;
    remark?: string;
    createTime?: Date;
@@ -39,7 +59,7 @@
/** æ–°å¢žç”Ÿäº§å·¥åº */
export function createProcess(data: MesProProcessApi.Process) {
  return requestClient.post('/mes/pro/process/create', data);
  return requestClient.post<number>('/mes/pro/process/create', data);
}
/** ä¿®æ”¹ç”Ÿäº§å·¥åº */
src/api/mes/pro/wage/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,191 @@
import type { MesProFeedbackApi } from '#/api/mes/pro/feedback';
import { requestClient } from '#/api/request';
export namespace MesProWageApi {
  /** å·¥åºå·¥èµ„字段 */
  export interface WageField {
    id?: number;
    processId?: number;
    /** å­—段编码 */
    fieldCode?: string;
    /** å­—段名称 */
    fieldName?: string;
    /** å­—段类型:1-数值 2-文本 3-下拉 4-布尔 */
    fieldType?: number;
    /** å•位 */
    unit?: string;
    /** å·¥åºçº§å›ºå®šå€¼ */
    fieldValue?: string;
    /** è®¡ç®—角色:1-系数 2-加减金额 3-计价数量 4-仅记录 */
    calcRole?: number;
    /** è®¡ä»·æ•°é‡å•ä»· */
    unitPrice?: number;
    sort?: number;
    /** çŠ¶æ€ï¼š0-启用 1-禁用 */
    status?: number;
    remark?: string;
  }
  /** å·¥åºå·¥èµ„单价规则 */
  export interface WageRule {
    id?: number;
    processId?: number;
    /** å·¥è‰ºè·¯çº¿ç¼–号,空=不限 */
    routeId?: number;
    /** è®¾å¤‡ç¼–号,空=不限 */
    machineryId?: number;
    /** è®¡è´¹ç±»åž‹ï¼š1-计时 2-计件 */
    billingType?: number;
    /** å·¥èµ„单价:计时=元/小时,计件=元/ä»¶ */
    wageRate?: number;
    /** ç”Ÿæ•ˆå¼€å§‹æ—¥æœŸ */
    effectiveFrom?: string;
    /** ç”Ÿæ•ˆç»“束日期 */
    effectiveTo?: string;
    /** ä¼˜å…ˆçº§ï¼Œè¶Šå¤§è¶Šä¼˜å…ˆ */
    priority?: number;
    status?: number;
    remark?: string;
  }
  /** è´¨é‡å·¥èµ„规则 */
  export interface QualityRule {
    id?: number;
    processId?: number;
    /** å·¥è‰ºè·¯çº¿ç¼–号,空=不限 */
    routeId?: number;
    /** è§„则类型:1-合格率系数 2-不合格扣款 */
    ruleType?: number;
    /** åˆæ ¼çŽ‡ä¸‹é™ï¼ˆ%,含) */
    minRate?: number;
    /** åˆæ ¼çŽ‡ä¸Šé™ï¼ˆ%,不含) */
    maxRate?: number;
    /** å·¥èµ„系数(规则类型=1) */
    coefficient?: number;
    /** æ¯ä»¶ä¸åˆæ ¼æ‰£æ¬¾é‡‘额(规则类型=2) */
    deductionPerUnit?: number;
    priority?: number;
    status?: number;
    remark?: string;
  }
}
/** æŸ¥è¯¢å·¥åºå·¥èµ„字段列表 */
export function getWageFieldList(processId: number) {
  return requestClient.get<MesProWageApi.WageField[]>(
    '/mes/pro/process-wage-field/list',
    { params: { processId } },
  );
}
/** ä¿å­˜å·¥åºå·¥èµ„字段(全量覆盖) */
export function saveWageFieldList(
  processId: number,
  list: MesProWageApi.WageField[],
) {
  return requestClient.post('/mes/pro/process-wage-field/save-list', {
    processId,
    list,
  });
}
/** æŸ¥è¯¢å·¥åºå·¥èµ„单价规则列表 */
export function getWageRuleList(processId: number) {
  return requestClient.get<MesProWageApi.WageRule[]>(
    '/mes/pro/process-wage-rule/list',
    { params: { processId } },
  );
}
/** æ–°å¢žå·¥åºå·¥èµ„单价规则 */
export function createWageRule(data: MesProWageApi.WageRule) {
  return requestClient.post('/mes/pro/process-wage-rule/create', data);
}
/** ä¿®æ”¹å·¥åºå·¥èµ„单价规则 */
export function updateWageRule(data: MesProWageApi.WageRule) {
  return requestClient.put('/mes/pro/process-wage-rule/update', data);
}
/** åˆ é™¤å·¥åºå·¥èµ„单价规则 */
export function deleteWageRule(id: number) {
  return requestClient.delete(`/mes/pro/process-wage-rule/delete?id=${id}`);
}
/** æŸ¥è¯¢è´¨é‡å·¥èµ„规则列表 */
export function getQualityRuleList(processId: number) {
  return requestClient.get<MesProWageApi.QualityRule[]>(
    '/mes/pro/quality-wage-rule/list',
    { params: { processId } },
  );
}
/** æ–°å¢žè´¨é‡å·¥èµ„规则 */
export function createQualityRule(data: MesProWageApi.QualityRule) {
  return requestClient.post('/mes/pro/quality-wage-rule/create', data);
}
/** ä¿®æ”¹è´¨é‡å·¥èµ„规则 */
export function updateQualityRule(data: MesProWageApi.QualityRule) {
  return requestClient.put('/mes/pro/quality-wage-rule/update', data);
}
/** åˆ é™¤è´¨é‡å·¥èµ„规则 */
export function deleteQualityRule(id: number) {
  return requestClient.delete(`/mes/pro/quality-wage-rule/delete?id=${id}`);
}
/** æŠ¥å·¥å·¥èµ„试算(不落库) */
export namespace MesProFeedbackWagePreviewApi {
  export interface FieldSnapshot {
    code?: string;
    name?: string;
    role?: number;
    value?: string;
    unitPrice?: number;
    amount?: number;
  }
  export interface Quality {
    passRate?: number;
    coefficient?: number;
    deduction?: number;
  }
  export interface PreviewReq {
    taskId: number;
    machineryId?: number;
    workHours?: number;
    feedbackQuantity?: number;
    qualifiedQuantity?: number;
    productionLength?: number;
    feedbackDate?: string;
    auxList?: MesProFeedbackApi.AuxValue[];
  }
  export interface PreviewResp {
    billingType?: number;
    wageRate?: number;
    wageRuleId?: number;
    matchLevel?: string;
    workHours?: number;
    pieceQuantity?: number;
    mainWage?: number;
    auxPaidHours?: number;
    auxWage?: number;
    fields?: FieldSnapshot[];
    preQualityWage?: number;
    quality?: Quality;
    finalWage?: number;
    warnings?: string[];
  }
}
/** æŠ¥å·¥å·¥èµ„试算 */
export function previewFeedbackWage(data: MesProFeedbackWagePreviewApi.PreviewReq) {
  return requestClient.post<MesProFeedbackWagePreviewApi.PreviewResp>(
    '/mes/pro/feedback/preview-wage',
    data,
  );
}
src/api/mes/pro/worktime/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,158 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export namespace MesProWorktimeApi {
  /** è¾…助工时项目 */
  export interface AuxItem {
    id?: number;
    processId?: number;
    processName?: string;
    workstationId?: number;
    workstationName?: string;
    itemCode?: string;
    itemName?: string;
    /** è®¡é‡æ–¹å¼ï¼š1-按次 2-按个 3-按盘 4-按米折算 */
    measureType?: number;
    /** è®¡è–ªæ–¹å¼ï¼š0-不计薪 1-按工序辅助小时工资 2-按本项单价 */
    wageMode?: number;
    /** è®¡è–ªå•价(元/小时),计薪方式=2 æ—¶ä½¿ç”¨ */
    wageRate?: number;
    durationMinutes?: number;
    speedMPerMin?: number;
    materialFrom?: string;
    materialTo?: string;
    status?: number;
    sort?: number;
    remark?: string;
    createTime?: Date;
  }
  /** è¾…助工时试算入参 */
  export interface AuxInput {
    auxItemId: number;
    quantity?: number;
    materialFrom?: string;
    materialTo?: string;
    remark?: string;
  }
  export interface CalcReq {
    taskId: number;
    productionLength?: number;
    feedbackQuantity?: number;
    auxList?: AuxInput[];
  }
  export interface AuxResult {
    auxItemId?: number;
    itemName?: string;
    measureType?: number;
    quantity?: number;
    materialFrom?: string;
    materialTo?: string;
    minutes?: number;
    remark?: string;
  }
  export interface CalcResp {
    productionLength?: number;
    standardWorkHours?: number;
    primaryHours?: number;
    auxHours?: number;
    note?: string;
    missingReasons?: string[];
    auxList?: AuxResult[];
  }
  export interface ReportRow {
    dimKey?: string;
    dimName?: string;
    feedbackCount?: number;
    productionLength?: number;
    actualHours?: number;
    standardHours?: number;
    workHoursDiff?: number;
    workHoursDiffRate?: number;
    wageAmount?: number;
  }
  export interface ReportResp {
    summary?: ReportRow;
    rows?: ReportRow[];
  }
  export interface ReportParams {
    beginTime: string;
    endTime: string;
    /** 1-按人 2-按工序 3-按机台 4-按日期 */
    dimType: number;
    feedbackUserId?: number;
    processId?: number;
    workstationId?: number;
  }
  export interface PageParams extends PageParam {
    processId?: number;
    workstationId?: number;
    itemName?: string;
    status?: number;
  }
}
/** è¾…助工时项目分页 */
export function getWorktimeAuxPage(params: MesProWorktimeApi.PageParams) {
  return requestClient.get<PageResult<MesProWorktimeApi.AuxItem>>(
    '/mes/pro/worktime-aux/page',
    { params },
  );
}
/** è¾…助工时项目详情 */
export function getWorktimeAux(id: number) {
  return requestClient.get<MesProWorktimeApi.AuxItem>(
    `/mes/pro/worktime-aux/get?id=${id}`,
  );
}
/** æŸ¥è¯¢å·¥åº + æœºå°å¯ç”¨çš„辅助工时项目 */
export function getWorktimeAuxApplicable(
  processId: number,
  workstationId?: number,
) {
  return requestClient.get<MesProWorktimeApi.AuxItem[]>(
    '/mes/pro/worktime-aux/list-applicable',
    { params: { processId, workstationId } },
  );
}
/** æ–°å¢žè¾…助工时项目 */
export function createWorktimeAux(data: MesProWorktimeApi.AuxItem) {
  return requestClient.post<number>('/mes/pro/worktime-aux/create', data);
}
/** ä¿®æ”¹è¾…助工时项目 */
export function updateWorktimeAux(data: MesProWorktimeApi.AuxItem) {
  return requestClient.put('/mes/pro/worktime-aux/update', data);
}
/** åˆ é™¤è¾…助工时项目 */
export function deleteWorktimeAux(id: number) {
  return requestClient.delete(`/mes/pro/worktime-aux/delete?id=${id}`);
}
/** è¯•算标准工时(不落库) */
export function calculateWorktime(data: MesProWorktimeApi.CalcReq) {
  return requestClient.post<MesProWorktimeApi.CalcResp>(
    '/mes/pro/worktime/calculate',
    data,
  );
}
/** å·¥æ—¶è€ƒæ ¸æŠ¥è¡¨ */
export function getWorktimeReport(params: MesProWorktimeApi.ReportParams) {
  return requestClient.get<MesProWorktimeApi.ReportResp>(
    '/mes/pro/worktime/report',
    { params },
  );
}
src/api/mes/qc/ipqc/index.ts
@@ -56,6 +56,78 @@
    createList?: string[]; // æ–°å¢žæˆåŠŸçš„æ£€éªŒå•ç¼–å·
    failureList?: Record<string, string>; // å¯¼å…¥å¤±è´¥æ˜Žç»†ï¼škey ä¸ºã€Œæ£€éªŒå•编号 X」或「第 N è¡Œã€
  }
  /** å·¥åºé»˜è®¤è´¨æ£€æ–¹æ¡ˆåŠæ£€æµ‹æŒ‡æ ‡ */
  export interface TemplateResolve {
    processId?: number; // å·¥åºç¼–号
    qcType?: number; // è´¨æ£€ç§ç±»
    templateId?: number; // è´¨æ£€æ–¹æ¡ˆç¼–号,空=该工序未绑定方案
    templateCode?: string; // è´¨æ£€æ–¹æ¡ˆç¼–码
    templateName?: string; // è´¨æ£€æ–¹æ¡ˆåç§°
    indicators?: TemplateIndicator[]; // æ£€æµ‹æŒ‡æ ‡å­—段
  }
  /** æ£€æµ‹æŒ‡æ ‡å­—段 */
  export interface TemplateIndicator {
    templateIndicatorId?: number; // æ–¹æ¡ˆæŒ‡æ ‡é¡¹ç¼–号
    indicatorId?: number; // æ£€æµ‹æŒ‡æ ‡ç¼–号
    indicatorCode?: string; // æ£€æµ‹æŒ‡æ ‡ç¼–码
    indicatorName?: string; // æ£€æµ‹æŒ‡æ ‡åç§°
    itemType?: number; // æŒ‡æ ‡é¡¹ç›®ç±»åž‹
    resultType?: number; // ç»“果值类型
    resultSpecification?: string; // ç»“果规格
    unitText?: string; // å•位文本
    tool?: string; // æ£€æµ‹å·¥å…·
    checkMethod?: string; // æ£€æµ‹æ–¹æ³•
    standardValue?: number; // æ ‡å‡†å€¼
    unitMeasureId?: number; // è®¡é‡å•位编号
    thresholdMax?: number; // ä¸Šé™
    thresholdMin?: number; // ä¸‹é™
  }
  /** è¿‡ç¨‹æ£€éªŒå•工资字段(工序自定义工资字段) */
  export interface WageField {
    fieldId?: number; // å·¥åºå·¥èµ„字段编号
    fieldCode?: string; // å­—段编码
    fieldName?: string; // å­—段名称
    fieldType?: number; // å­—段类型:1-数值 2-文本 3-下拉 4-布尔
    unit?: string; // å•位
    calcRole?: number; // è®¡ç®—角色:1-系数 2-加减金额 3-计价数量 4-仅记录
    unitPrice?: number; // è®¡ä»·æ•°é‡å•ä»·
    sort?: number; // æŽ’序
    defaultValue?: string; // å·¥åºå›ºå®šå€¼ï¼ˆæœªå¡«å†™æ—¶å…œåº•)
    value?: string; // è´¨æ£€å¡«å†™çš„值
    remark?: string; // å¤‡æ³¨
  }
  /** å·¥èµ„字段值保存项 */
  export interface WageFieldValueItem {
    fieldId: number; // å·¥åºå·¥èµ„字段编号
    value?: string; // è´¨æ£€å¡«å†™çš„值
    remark?: string; // å¤‡æ³¨
  }
}
/** æŒ‰å·¥åºèŽ·å–è¿‡ç¨‹æ£€éªŒå•å·¥èµ„å­—æ®µï¼ˆæ–°å»ºæ—¶åªä¼  processId,编辑时再传 ipqcId å¸¦å‡ºå·²å¡«å†™çš„值) */
export function getIpqcWageFieldList(params: {
  ipqcId?: number;
  processId?: number;
}) {
  return requestClient.get<MesQcIpqcApi.WageField[]>(
    '/mes/qc/ipqc/wage-field/list',
    { params },
  );
}
/** ä¿å­˜è¿‡ç¨‹æ£€éªŒå•工资字段值 */
export function saveIpqcWageFieldValues(
  ipqcId: number,
  list: MesQcIpqcApi.WageFieldValueItem[],
) {
  return requestClient.post('/mes/qc/ipqc/wage-field/save', {
    ipqcId,
    list,
  });
}
/** æŸ¥è¯¢è¿‡ç¨‹æ£€éªŒå•分页 */
@@ -70,6 +142,14 @@
  return requestClient.get<MesQcIpqcApi.Ipqc>(`/mes/qc/ipqc/get?id=${id}`);
}
/** æŒ‰å·¥åºèŽ·å–é»˜è®¤è´¨æ£€æ–¹æ¡ˆåŠæ£€æµ‹æŒ‡æ ‡å­—æ®µ */
export function getTemplateByProcess(processId: number, type?: number) {
  return requestClient.get<MesQcIpqcApi.TemplateResolve>(
    '/mes/qc/ipqc/template-by-process',
    { params: { processId, type } },
  );
}
/** æ–°å¢žè¿‡ç¨‹æ£€éªŒå• */
export function createIpqc(data: MesQcIpqcApi.Ipqc) {
  return requestClient.post('/mes/qc/ipqc/create', data);
src/api/mes/qc/ncr/index.ts
@@ -37,6 +37,7 @@
    closeDate?: string;
    closeOpinion?: string;
    status?: number;
    processInstanceId?: string;
    remark?: string;
    createTime?: string;
  }
@@ -75,10 +76,22 @@
}
/** å¤„置不合格品处理单 */
export function disposeNcr(data: { handleResult?: string; handlerUserId?: number; id: number }) {
export function disposeNcr(data: {
  disposition: number;
  handleResult?: string;
  handlerUserId?: number;
  id: number;
}) {
  return requestClient.put('/mes/qc/ncr/dispose', data);
}
/** æäº¤ä¸åˆæ ¼å“å¤„理单审批 */
export function submitNcrApproval(id: number) {
  return requestClient.put('/mes/qc/ncr/submit-approval', null, {
    params: { id },
  });
}
/** å…³é—­ä¸åˆæ ¼å“å¤„理单 */
export function closeNcr(data: { closeOpinion?: string; id: number }) {
  return requestClient.put('/mes/qc/ncr/close', data);
src/api/mes/qc/patrol/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,58 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
import { normalizeQcDateFilter } from '../date-filter';
export namespace MesQcPatrolApi {
  /** MES è´¨é‡å·¡æ£€è®°å½• */
  export interface Patrol {
    id?: number; // ç¼–号
    code?: string; // å·¡æ£€å•编号
    name?: string; // å·¡æ£€å•名称
    itemId?: number; // äº§å“ç‰©æ–™ç¼–号
    itemCode?: string; // äº§å“ç‰©æ–™ç¼–码(关联查询)
    itemName?: string; // äº§å“ç‰©æ–™åç§°ï¼ˆå…³è”查询)
    itemSpecification?: string; // è§„格型号(关联查询)
    unitName?: string; // å•位名称(关联查询)
    reelNo?: string; // ç›˜å·
    batchCode?: string; // æ‰¹æ¬¡å·
    qualifiedQuantity?: number; // åˆæ ¼æ•°é‡
    unqualifiedQuantity?: number; // ä¸åˆæ ¼æ•°é‡
    inspectDate?: string; // å·¡æ£€æ—¥æœŸ
    inspectorUserId?: number; // å·¡æ£€äººå‘˜ç¼–号
    inspectorNickname?: string; // å·¡æ£€äººå‘˜æ˜µç§°ï¼ˆå…³è”查询)
    remark?: string; // å¤‡æ³¨
    createTime?: string; // åˆ›å»ºæ—¶é—´
  }
}
/** æŸ¥è¯¢è´¨é‡å·¡æ£€è®°å½•分页 */
export function getPatrolPage(params: PageParam) {
  return requestClient.get<PageResult<MesQcPatrolApi.Patrol>>(
    '/mes/qc/patrol/page',
    { params: normalizeQcDateFilter(params) },
  );
}
/** æŸ¥è¯¢è´¨é‡å·¡æ£€è®°å½•详情 */
export function getPatrol(id: number) {
  return requestClient.get<MesQcPatrolApi.Patrol>(
    `/mes/qc/patrol/get?id=${id}`,
  );
}
/** æ–°å¢žè´¨é‡å·¡æ£€è®°å½• */
export function createPatrol(data: MesQcPatrolApi.Patrol) {
  return requestClient.post<number>('/mes/qc/patrol/create', data);
}
/** ä¿®æ”¹è´¨é‡å·¡æ£€è®°å½• */
export function updatePatrol(data: MesQcPatrolApi.Patrol) {
  return requestClient.put('/mes/qc/patrol/update', data);
}
/** åˆ é™¤è´¨é‡å·¡æ£€è®°å½• */
export function deletePatrol(id: number) {
  return requestClient.delete(`/mes/qc/patrol/delete?id=${id}`);
}
src/api/mes/qc/statistics/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,65 @@
import { requestClient } from '#/api/request';
export namespace MesQcStatisticsApi {
  /** æŒ‰æ£€éªŒç±»åž‹ç»Ÿè®¡ */
  export interface TypeStat {
    qcType: string; // æ£€éªŒç±»åž‹ç¼–码:IQC / IPQC / OQC / RQC
    qcTypeName: string; // æ£€éªŒç±»åž‹åç§°
    qcCount: number; // è´¨æ£€å•数量
    checkQuantity: number; // æ£€æµ‹æ€»æ•°
    qualifiedQuantity: number; // åˆæ ¼æ•°
    unqualifiedQuantity: number; // ä¸åˆæ ¼æ•°
    qualifiedRate: number; // åˆæ ¼çŽ‡ï¼ˆç™¾åˆ†æ¯”ï¼‰
    unqualifiedRate: number; // ä¸åˆæ ¼çŽ‡ï¼ˆç™¾åˆ†æ¯”ï¼‰
  }
  /** æ•´ä½“统计 */
  export interface Overall {
    totalQcCount: number; // è´¨æ£€å•总数
    checkQuantity: number; // æ£€æµ‹æ€»æ•°
    qualifiedQuantity: number; // åˆæ ¼æ•°
    unqualifiedQuantity: number; // ä¸åˆæ ¼æ•°
    qualifiedRate: number; // åˆæ ¼çŽ‡ï¼ˆç™¾åˆ†æ¯”ï¼‰
    unqualifiedRate: number; // ä¸åˆæ ¼çŽ‡ï¼ˆç™¾åˆ†æ¯”ï¼‰
    types: TypeStat[]; // å„检验类型统计
  }
  /** æŒ‰å·¥åºç»Ÿè®¡ï¼ˆä»… IPQC) */
  export interface ByProcess {
    processId?: number; // å·¥åºç¼–号
    processName?: string; // å·¥åºåç§°
    checkQuantity: number; // æ£€æµ‹æ€»æ•°
    qualifiedQuantity: number; // åˆæ ¼æ•°
    unqualifiedQuantity: number; // ä¸åˆæ ¼æ•°
    qualifiedRate: number; // åˆæ ¼çŽ‡ï¼ˆç™¾åˆ†æ¯”ï¼‰
  }
  /** æŒ‰ç”Ÿäº§å·¥å•统计(仅 IPQC) */
  export interface ByWorkOrder {
    workOrderId?: number; // ç”Ÿäº§å·¥å•编号
    workOrderCode?: string; // å·¥å•号
    productName?: string; // äº§å“åç§°
    checkQuantity: number; // æ£€æµ‹æ€»æ•°
    qualifiedQuantity: number; // åˆæ ¼æ•°
    unqualifiedQuantity: number; // ä¸åˆæ ¼æ•°
    qualifiedRate: number; // åˆæ ¼çŽ‡ï¼ˆç™¾åˆ†æ¯”ï¼‰
  }
  /** è´¨é‡ç»Ÿè®¡æ±‡æ€» */
  export interface Summary {
    overall: Overall;
    byProcess: ByProcess[];
    byWorkOrder: ByWorkOrder[];
  }
}
/** æŸ¥è¯¢è´¨é‡ç»Ÿè®¡æ±‡æ€»ï¼ˆbeginTime / endTime å¯é€‰ï¼Œæ ¼å¼ yyyy-MM-dd HH:mm:ss) */
export function getQcStatisticsSummary(params?: {
  beginTime?: string;
  endTime?: string;
}) {
  return requestClient.get<MesQcStatisticsApi.Summary>(
    '/mes/qc/statistics/summary',
    { params },
  );
}
src/api/mes/qc/template-process/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,46 @@
import { requestClient } from '#/api/request';
export namespace MesQcTemplateProcessApi {
  /** è´¨æ£€æ–¹æ¡ˆ-工序绑定 */
  export interface Binding {
    id?: number;
    processId?: number;
    processName?: string;
    templateId?: number;
    templateCode?: string;
    templateName?: string;
    /** è´¨æ£€ç§ç±»ï¼š1-来料检验 2-过程检验 3-出货检验 4-退货检验 */
    qcType?: number;
    /** æ˜¯å¦é»˜è®¤æ–¹æ¡ˆ */
    defaultFlag?: boolean;
    sort?: number;
    status?: number;
    remark?: string;
  }
  /** æŒ‰æ–¹æ¡ˆä¿å­˜é€‚用工序的入参 */
  export interface SaveByTemplateReq {
    templateId: number;
    qcType: number;
    list: Array<{
      processId: number;
      defaultFlag?: boolean;
      sort?: number;
    }>;
  }
}
/** æŸ¥è¯¢è´¨æ£€æ–¹æ¡ˆé€‚用的工序 */
export function getTemplateProcessListByTemplate(templateId: number) {
  return requestClient.get<MesQcTemplateProcessApi.Binding[]>(
    '/mes/qc/template-process/list-by-template',
    { params: { templateId } },
  );
}
/** ä¿å­˜è´¨æ£€æ–¹æ¡ˆé€‚用的工序(差异增删) */
export function saveTemplateProcessByTemplate(
  data: MesQcTemplateProcessApi.SaveByTemplateReq,
) {
  return requestClient.post('/mes/qc/template-process/save-by-template', data);
}
src/api/mes/wm/productsales/index.ts
@@ -94,3 +94,10 @@
    params,
  });
}
/** å¯¼å‡ºæˆå“å‡ºåº“单 Word(带电子章,可手填签字后归档) */
export function exportProductSalesWord(id: number) {
  return requestClient.download('/mes/wm/product-sales/export-word', {
    params: { id },
  });
}
src/api/mes/wm/salesnotice/index.ts
@@ -16,10 +16,40 @@
    recipientName?: string; // æ”¶è´§äºº
    recipientTelephone?: string; // è”系方式
    recipientAddress?: string; // æ”¶è´§åœ°å€
    logisticsCompany?: string; // ç‰©æµå…¬å¸
    expressFee?: number; // å¿«é€’è´¹
    invoiceStatus?: number; // å¼€ç¥¨çŠ¶æ€ï¼š0-未开票,1-部分开票,2-已开票
    status?: number; // å•据状态
    outStatus?: number; // å‡ºåº“状态:0-未出库,1-部分出库,2-全部出库
    remark?: string; // å¤‡æ³¨
    createTime?: Date; // åˆ›å»ºæ—¶é—´
  }
  /** å‘货台账(按产品平铺,一行 = ä¸€ä¸ªäº§å“æ˜Žç»†ï¼‰ */
  export interface SalesNoticeLedger {
    noticeId: number; // å‘货通知单编号
    noticeCode: string; // å‘货单号
    saleOrderCode?: string; // é”€å”®è®¢å•号
    clientId?: number; // å®¢æˆ·ç¼–号
    clientName?: string; // å®¢æˆ·åç§°
    salesDate?: number; // å‘货日期
    recipientName?: string; // æ”¶è´§äºº
    recipientAddress?: string; // æ”¶è´§åœ°å€
    logisticsCompany?: string; // ç‰©æµå…¬å¸
    expressFee?: number; // å¿«é€’è´¹
    invoiceStatus?: number; // å¼€ç¥¨çŠ¶æ€
    status?: number; // å•据状态
    outStatus?: number; // å‡ºåº“状态
    lineId: number; // æ˜Žç»†è¡Œç¼–号
    itemId?: number; // äº§å“ç¼–号
    itemCode?: string; // äº§å“ç¼–码
    itemName?: string; // äº§å“åç§°
    itemSpecification?: string; // è§„格型号
    unitName?: string; // å•位
    quantity?: number; // å‘货数量
    outCount?: number; // å·²å‡ºåº“数量
    batchCode?: string; // æ‰¹æ¬¡å·
    lineRemark?: string; // æ˜Žç»†å¤‡æ³¨
  }
}
@@ -27,6 +57,14 @@
export function getSalesNoticePage(params: PageParam) {
  return requestClient.get<PageResult<MesWmSalesNoticeApi.SalesNotice>>(
    '/mes/wm/sales-notice/page',
    { params },
  );
}
/** æŸ¥è¯¢å‘货台账分页(按产品平铺) */
export function getSalesNoticeLedgerPage(params: PageParam) {
  return requestClient.get<PageResult<MesWmSalesNoticeApi.SalesNoticeLedger>>(
    '/mes/wm/sales-notice/ledger-page',
    { params },
  );
}
@@ -54,8 +92,17 @@
}
/** æäº¤å‘货通知单 */
export function submitSalesNotice(id: number) {
  return requestClient.put(`/mes/wm/sales-notice/submit?id=${id}`);
export function submitSalesNotice(id: number, processDefinitionKey?: string) {
  return requestClient.put('/mes/wm/sales-notice/submit', null, {
    params: { id, processDefinitionKey },
  });
}
/** èŽ·å–å‘è´§å®¡æ‰¹æµç¨‹åˆ—è¡¨ */
export function getSalesNoticeApproveProcessList() {
  return requestClient.get<
    Array<{ id: string; key: string; name: string; version: number }>
  >('/mes/wm/sales-notice/approve-process-list');
}
/** å¯¼å‡ºå‘货通知单 */
@@ -65,6 +112,13 @@
  });
}
/** å¯¼å‡ºå‘货单 Word(带电子章,可手填签字后归档) */
export function exportSalesNoticeWord(id: number) {
  return requestClient.download('/mes/wm/sales-notice/export-word', {
    params: { id },
  });
}
/** ä»Žé”€å”®è®¢å•生成发货通知单 */
export function generateFromSaleOrder(saleOrderId: number) {
  return requestClient.post<number>('/mes/wm/sales-notice/generate-from-sale-order', {
src/api/mes/wm/transaction/index.ts
@@ -11,6 +11,8 @@
    bizType?: number; // ä¸šåŠ¡ç±»åž‹ç¼–ç 
    bizTypeName?: string; // ä¸šåŠ¡ç±»åž‹åç§°
    bizCode?: string; // ä¸šåŠ¡å•å·
    workOrderId?: number; // ç”Ÿäº§è®¢å•(工单)ID
    workOrderCode?: string; // ç”Ÿäº§è®¢å•号
    itemId?: number; // ç‰©æ–™ID
    itemCode?: string; // ç‰©æ–™ç¼–码
    itemName?: string; // ç‰©æ–™åç§°
src/api/srm/inquiry/index.ts
@@ -183,9 +183,10 @@
export function confirmInquiry(
  id: number,
  selections: SrmInquiryApi.Selection[],
  paymentDate: string,
) {
  return requestClient.post<SrmInquiryApi.ConfirmResult>(
    '/srm/inquiry/confirm',
    { id, selections },
    { id, selections, paymentDate },
  );
}
src/components/quality/engine/report-evaluator.ts
@@ -232,9 +232,11 @@
  const total = items.length;
  /** åˆæ ¼çŽ‡çš„åˆ†æ¯åªç®—ã€Œèƒ½åˆ¤å®šã€çš„é¡¹ï¼Œæ— åˆ¤å®šè§„åˆ™çš„é¡¹æ—¢ä¸åŠ åˆ†ä¹Ÿä¸å‡åˆ† */
  const decidableCount = total - undecidableCount;
  const hasPendingItem = items.some((item) => item.resultText === PENDING_TEXT);
  let reportResult: QualityResult | undefined;
  if (reportRules.length > 0) {
  // ç¼ºå°‘检验依据时,即使报告级规则能算出布尔值,也不能生成确定结论。
  if (!hasPendingItem && reportRules.length > 0) {
    let passed = true;
    let failed = false;
    reportRules.forEach((rule) => {
@@ -249,7 +251,7 @@
      }
    });
    reportResult = failed ? undefined : passed ? QUALITY_RESULT.PASS : QUALITY_RESULT.FAIL;
  } else if (decidableCount > 0) {
  } else if (!hasPendingItem && decidableCount > 0) {
    // åªæœ‰ã€Œå…¨éƒ¨å¯åˆ¤å®šé¡¹éƒ½åˆæ ¼ã€æ‰ç»™åˆæ ¼ç»“论。无判定规则的项不参与,它们没有对错可言。
    // å­˜åœ¨å¾…判定项(规则跑挂)时不给结论:没验过不能说合格,没证据也不能说不合格,
    // å¦åˆ™ä¼šå‡ºçŽ°ã€Œç»“è®ºåˆæ ¼ã€åˆæ ¼çŽ‡ 0%」这种自相矛盾的报告。
src/components/quality/engine/rule-engine.ts
@@ -399,6 +399,15 @@
  return leftText === rightText ? 0 : leftText > rightText ? 1 : -1;
}
function compareNumbers(left: unknown, right: unknown): number {
  const leftNumber = toNumber(left);
  const rightNumber = toNumber(right);
  if (!Number.isFinite(leftNumber) || !Number.isFinite(rightNumber)) {
    throw new RuleRuntimeError('数值比较缺少有效实测值或规格限值');
  }
  return leftNumber === rightNumber ? 0 : leftNumber > rightNumber ? 1 : -1;
}
/** åˆ¤å®šç»“果类取值统一成 PASS / FAIL:兼容中文、布尔与数字 1/0 */
const PASS_TOKENS = new Set(['PASS', 'TRUE', '合格', 'OK', '1', '是']);
const FAIL_TOKENS = new Set(['FAIL', 'FALSE', '不合格', 'NG', '0', '否']);
@@ -566,8 +575,8 @@
    case 'between': {
      const value = evaluateNode(node.value, context);
      const hit =
        compare(value, evaluateNode(node.lower, context)) >= 0 &&
        compare(value, evaluateNode(node.upper, context)) <= 0;
        compareNumbers(value, evaluateNode(node.lower, context)) >= 0 &&
        compareNumbers(value, evaluateNode(node.upper, context)) <= 0;
      return node.negated ? !hit : hit;
    }
    case 'in': {
@@ -608,16 +617,16 @@
      return compare(left, right) !== 0;
    }
    case '>': {
      return compare(left, right) > 0;
      return compareNumbers(left, right) > 0;
    }
    case '>=': {
      return compare(left, right) >= 0;
      return compareNumbers(left, right) >= 0;
    }
    case '<': {
      return compare(left, right) < 0;
      return compareNumbers(left, right) < 0;
    }
    case '<=': {
      return compare(left, right) <= 0;
      return compareNumbers(left, right) <= 0;
    }
    case '+': {
      const leftNumber = toNumber(left);
@@ -708,4 +717,4 @@
  visit(node);
  // ç»Ÿè®¡å‡½æ•°ä½œç”¨åœ¨æ•°ç»„路径上,这里去掉末端字段只保留数组本身
  return [...new Set(paths.map((path) => parsePath(path).join('.')))];
}
}
src/packages/constants/src/biz-mes-enum.ts
@@ -41,33 +41,6 @@
  MAINTENANCE: 3,
} as const;
/** MES å‡æœŸç±»åž‹æžšä¸¾ */
export const HolidayType = {
  WORKDAY: 1,
  HOLIDAY: 2,
} as const;
/** MES æŽ’班计划状态枚举 */
export const MesCalPlanStatusEnum = {
  PREPARE: 0,
  CONFIRMED: 1,
} as const;
/** MES è½®ç­æ–¹å¼æžšä¸¾ */
export const MesCalShiftTypeEnum = {
  SINGLE: 1,
  TWO: 2,
  THREE: 3,
} as const;
/** MES å€’班方式枚举 */
export const MesCalShiftMethodEnum = {
  QUARTER: 1,
  MONTH: 2,
  WEEK: 3,
  DAY: 4,
} as const;
/** MES ç‚¹æ£€ä¿å…»é¡¹ç›®ç±»åž‹æžšä¸¾ */
export const MesDvSubjectTypeEnum = {
  CHECK: 1,
@@ -171,6 +144,7 @@
  QC_IPQC_CODE: 'QC_IPQC_CODE',
  QC_OQC_CODE: 'QC_OQC_CODE',
  QC_RQC_CODE: 'QC_RQC_CODE',
  QC_PATROL_CODE: 'QC_PATROL_CODE',
  QC_TEMPLATE_CODE: 'QC_TEMPLATE_CODE',
  TM_TOOL_TYPE_CODE: 'TM_TOOL_TYPE_CODE',
  TM_TOOL_CODE: 'TM_TOOL_CODE',
@@ -397,6 +371,7 @@
/** MES å‘货通知单状态枚举 */
export const MesWmSalesNoticeStatusEnum = {
  PREPARE: MesOrderStatusConstants.DRAFT,
  APPROVING: MesOrderStatusConstants.APPROVING,
  APPROVED: MesOrderStatusConstants.APPROVED,
} as const;
src/packages/constants/src/dict-enum.ts
@@ -144,6 +144,7 @@
  ERP_STOCK_RECORD_BIZ_TYPE: 'erp_stock_record_biz_type', // åº“存明细的业务类型
  ERP_PURCHASE_TYPE: 'erp_purchase_type', // é‡‡è´­å°è´¦-采购类型:1生产 2办公用品 3其他
  ERP_PAYMENT_METHOD: 'erp_payment_method', // é‡‡è´­å°è´¦-付款方式:1电汇 2汇票 3现金 4手机支付
  ERP_SALE_TYPE: 'erp_sale_type', // é”€å”®å°è´¦-销售类型:1内销 2外销 3样品 4其他
} as const;
/** ========== FINANCE - è´¢åŠ¡ç®¡ç†æ¨¡å— ========== */
src/utils/contract-document.ts
@@ -1,8 +1,15 @@
import { downloadFileFromBlobPart } from '@vben/utils';
import { downloadContractDocument } from '#/api/crm/contract';
import { downloadSaleQuotationDocument } from '#/api/crm/saleQuotation';
export async function downloadContractWord(id: number, no?: string) {
  const source = await downloadContractDocument(id);
  const suffix = (no || String(id)).replaceAll(/[\\/:*?"<>|]/g, '_');
  downloadFileFromBlobPart({ fileName: `购销合同-${suffix}.docx`, source });
}
export async function downloadSaleQuotationWord(id: number, no?: string) {
  const source = await downloadSaleQuotationDocument(id);
  const suffix = (no || String(id)).replaceAll(/[\\/:*?"<>|]/g, '_');
  downloadFileFromBlobPart({ fileName: `报价单-${suffix}.docx`, source });
}
src/views/crm/backlog/modules/receivable-audit-list.vue
@@ -5,31 +5,22 @@
import { useRouter } from 'vue-router';
import { Button, message } from 'ant-design-vue';
import { Button } from 'ant-design-vue';
import { TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { getReceivablePage, updateReceivableStatus } from '#/api/crm/receivable';
import { getReceivablePage } from '#/api/crm/receivable';
import { useGridColumns } from '#/views/crm/receivable/data';
import { AUDIT_STATUS } from '../data';
const { push } = useRouter();
const emit = defineEmits<{ refresh: [] }>();
/** å®¡æ ¸ */
async function handleAudit(row: CrmReceivableApi.Receivable) {
  const hideLoading = message.loading({
    content: '审核中...',
    duration: 0,
/** æŸ¥çœ‹å®¡æ‰¹è¿›åº¦ */
function handleProcessDetail(row: CrmReceivableApi.Receivable) {
  push({
    name: 'BpmProcessInstanceDetail',
    query: { id: row.processInstanceId, viewOnly: '1' },
  });
  try {
    await updateReceivableStatus(row.id!, 20);
    message.success('审核成功');
    await gridApi.query();
    emit('refresh');
  } finally {
    hideLoading();
  }
}
/** æ‰“开回款详情 */
@@ -51,7 +42,7 @@
  if (row.invoiceId) push({ name: 'CrmInvoiceDetail', params: { id: row.invoiceId } });
}
const [Grid, gridApi] = useVbenVxeGrid({
const [Grid] = useVbenVxeGrid({
  formOptions: {
    schema: [
      {
@@ -121,11 +112,10 @@
    </template>
    <template #actions="{ row }">
      <TableAction :actions="[{
        label: '审核',
        label: '查看审批进度',
        type: 'link',
        auth: ['crm:receivable:update'],
        ifShow: row.auditStatus === 10,
        onClick: handleAudit.bind(null, row),
        ifShow: row.auditStatus === 10 && !!row.processInstanceId,
        onClick: handleProcessDetail.bind(null, row),
      }]" />
    </template>
  </Grid>
src/views/crm/customer/detail/index.vue
@@ -32,6 +32,7 @@
import Form from '../modules/form.vue';
import { useDetailSchema } from './data';
import AddressList from './modules/address-list.vue';
import DistributeForm from './modules/distribute-form.vue';
import Info from './modules/info.vue';
@@ -261,6 +262,9 @@
          <ReceivablePlanDetailsList :customer-id="customerId" />
          <ReceivableDetailsList :customer-id="customerId" />
        </Tabs.TabPane>
        <Tabs.TabPane tab="收货地址" key="9" :force-render="true">
          <AddressList :customer-id="customerId" />
        </Tabs.TabPane>
        <Tabs.TabPane tab="操作日志" key="8" :force-render="true">
          <OperateLog :log-list="logList" date-format="YYYY-MM-DD" />
        </Tabs.TabPane>
src/views/crm/customer/detail/modules/address-list.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,213 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { CrmCustomerAddressApi } from '#/api/crm/customerAddress';
import { onMounted, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Button, message, Tag } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
  createCustomerAddress,
  deleteCustomerAddress,
  getCustomerAddressList,
  updateCustomerAddress,
} from '#/api/crm/customerAddress';
import { $t } from '#/locales';
defineOptions({ name: 'CustomerAddressList' });
const props = defineProps<{ customerId: number }>();
const editingRow = ref<CrmCustomerAddressApi.CustomerAddress>();
const [Form, formApi] = useVbenForm({
  commonConfig: { formItemClass: 'col-span-1', labelWidth: 100 },
  layout: 'horizontal',
  schema: [
    {
      fieldName: 'receiverName',
      label: '收货人',
      component: 'Input',
      rules: 'required',
      componentProps: { placeholder: '请输入收货人姓名', maxlength: 64 },
    },
    {
      fieldName: 'receiverMobile',
      label: '联系电话',
      component: 'Input',
      rules: 'required',
      componentProps: { placeholder: '请输入收货人手机号', maxlength: 32 },
    },
    {
      fieldName: 'detailAddress',
      label: '收货地址',
      component: 'Textarea',
      rules: 'required',
      componentProps: {
        placeholder: '请输入省、市、区及详细地址',
        rows: 2,
        maxlength: 255,
      },
    },
    {
      fieldName: 'defaultFlag',
      label: '默认地址',
      component: 'Switch',
      defaultValue: false,
    },
    {
      fieldName: 'remark',
      label: '备注',
      component: 'Textarea',
      componentProps: { placeholder: '请输入备注', rows: 2, maxlength: 500 },
    },
  ],
  showDefaultActions: false,
  wrapperClass: 'grid-cols-1',
});
const [FormModal, formModalApi] = useVbenModal({
  async onConfirm() {
    const { valid } = await formApi.validate();
    if (!valid) {
      return;
    }
    const values =
      (await formApi.getValues()) as CrmCustomerAddressApi.CustomerAddress;
    formModalApi.lock();
    try {
      if (editingRow.value?.id) {
        await updateCustomerAddress({
          ...values,
          id: editingRow.value.id,
          customerId: props.customerId,
        });
      } else {
        await createCustomerAddress({
          ...values,
          customerId: props.customerId,
        });
      }
      message.success($t('ui.actionMessage.operationSuccess'));
      await formModalApi.close();
      await refresh();
    } finally {
      formModalApi.unlock();
    }
  },
  async onOpenChange(isOpen: boolean) {
    if (!isOpen) {
      editingRow.value = undefined;
      return;
    }
    await formApi.resetForm();
    if (editingRow.value) {
      await formApi.setValues(editingRow.value);
    }
  },
});
const [Grid, gridApi] = useVbenVxeGrid({
  gridOptions: {
    columns: [
      { field: 'receiverName', title: '收货人', width: 120 },
      { field: 'receiverMobile', title: '联系电话', width: 150 },
      { field: 'detailAddress', title: '收货地址', minWidth: 260 },
      {
        field: 'defaultFlag',
        title: '默认地址',
        width: 100,
        slots: { default: 'defaultFlag' },
      },
      { field: 'remark', title: '备注', minWidth: 160 },
      { title: '操作', width: 160, fixed: 'right', slots: { default: 'actions' } },
    ],
    height: 'auto',
    keepSource: true,
    pagerConfig: { enabled: false },
    rowConfig: { keyField: 'id', isHover: true },
    toolbarConfig: { enabled: false },
  } as VxeTableGridOptions<CrmCustomerAddressApi.CustomerAddress>,
});
/** åŠ è½½åœ°å€åˆ—è¡¨ */
async function refresh() {
  const list = await getCustomerAddressList(props.customerId);
  gridApi.setGridOptions({ data: list });
}
/** æ–°å¢ž */
function handleCreate() {
  editingRow.value = undefined;
  formModalApi.setState({ title: '新增收货地址' });
  void formModalApi.open();
}
/** ç¼–辑 */
function handleEdit(row: CrmCustomerAddressApi.CustomerAddress) {
  editingRow.value = row;
  formModalApi.setState({ title: '编辑收货地址' });
  void formModalApi.open();
}
/** åˆ é™¤ */
async function handleDelete(row: CrmCustomerAddressApi.CustomerAddress) {
  await deleteCustomerAddress(row.id!);
  message.success($t('ui.actionMessage.deleteSuccess', [row.receiverName]));
  await refresh();
}
onMounted(refresh);
</script>
<template>
  <div class="mb-2 flex justify-end">
    <TableAction
      :actions="[
        {
          label: '新增收货地址',
          type: 'primary',
          icon: ACTION_ICON.ADD,
          auth: ['crm:customer:update'],
          onClick: handleCreate,
        },
      ]"
    />
  </div>
  <Grid table-title="收货地址列表">
    <template #defaultFlag="{ row }">
      <Tag v-if="row.defaultFlag" color="green">默认</Tag>
      <span v-else>-</span>
    </template>
    <template #actions="{ row }">
      <TableAction
        :actions="[
          {
            label: $t('common.edit'),
            type: 'link',
            icon: ACTION_ICON.EDIT,
            auth: ['crm:customer:update'],
            onClick: handleEdit.bind(null, row),
          },
          {
            label: $t('common.delete'),
            type: 'link',
            danger: true,
            icon: ACTION_ICON.DELETE,
            auth: ['crm:customer:update'],
            popConfirm: {
              title: $t('ui.actionMessage.deleteConfirm', [row.receiverName]),
              confirm: handleDelete.bind(null, row),
            },
          },
        ]"
      />
    </template>
  </Grid>
  <FormModal class="w-[560px]">
    <Form class="mx-4" />
  </FormModal>
</template>
src/views/crm/customerStatement/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,447 @@
<script lang="ts" setup>
import type { Dayjs } from 'dayjs';
import type { CrmFinanceReportApi } from '#/api/crm/financeReport';
import { computed, onMounted, ref } from 'vue';
import { Page } from '@vben/common-ui';
import { DICT_TYPE } from '@vben/constants';
import { getDictLabel } from '@vben/hooks';
import {
  Button,
  Card,
  Checkbox,
  Col,
  Empty,
  Input,
  RangePicker,
  Row,
  Spin,
  Table,
} from 'ant-design-vue';
import {
  getCustomerStatement,
  getCustomerStatementSummaryList,
} from '#/api/crm/financeReport';
defineOptions({ name: 'CrmCustomerStatement' });
const dateRange = ref<[Dayjs, Dayjs]>();
const includeUnapproved = ref(false);
const keyword = ref('');
/** å·¦ä¾§ï¼šå„客户往来汇总 */
const summaries = ref<CrmFinanceReportApi.CustomerStatementSummary[]>([]);
const listLoading = ref(false);
/** å½“前选中的客户 */
const selectedId = ref<number>();
/** å³ä¾§ï¼šå½“前客户往来明细 */
const statement = ref<CrmFinanceReportApi.CustomerStatement>();
const detailLoading = ref(false);
const filteredSummaries = computed(() => {
  const kw = keyword.value.trim().toLowerCase();
  if (!kw) {
    return summaries.value;
  }
  return summaries.value.filter((item) =>
    (item.customerName ?? '').toLowerCase().includes(kw),
  );
});
const selectedSummary = computed(() =>
  summaries.value.find((item) => item.customerId === selectedId.value),
);
const totalUncollected = computed(() =>
  summaries.value.reduce(
    (sum, item) => sum + (item.uncollectedAmount ?? 0),
    0,
  ),
);
const orderColumns = [
  { title: '销售订单号', dataIndex: 'saleOrderNo', key: 'saleOrderNo', width: 170 },
  { title: '下单时间', dataIndex: 'orderTime', key: 'orderTime', width: 150 },
  { title: '订单金额', dataIndex: 'orderAmount', key: 'orderAmount', width: 120 },
  {
    title: '已开票金额',
    dataIndex: 'invoicedAmount',
    key: 'invoicedAmount',
    width: 120,
  },
  {
    title: '未开票金额',
    dataIndex: 'uninvoicedAmount',
    key: 'uninvoicedAmount',
    width: 120,
  },
  { title: '状态', dataIndex: 'status', key: 'status', width: 100 },
];
const invoiceColumns = [
  { title: '开票单号', dataIndex: 'no', key: 'no', width: 160 },
  { title: '发票号', dataIndex: 'invoiceNo', key: 'invoiceNo', width: 140 },
  { title: '开票时间', dataIndex: 'invoiceTime', key: 'invoiceTime', width: 150 },
  {
    title: '开票金额',
    dataIndex: 'invoiceAmount',
    key: 'invoiceAmount',
    width: 120,
  },
  {
    title: '已回款',
    dataIndex: 'collectedAmount',
    key: 'collectedAmount',
    width: 120,
  },
  {
    title: '剩余应收',
    dataIndex: 'remainingReceivable',
    key: 'remainingReceivable',
    width: 120,
  },
  {
    title: '销售订单',
    dataIndex: 'saleOrderNo',
    key: 'saleOrderNo',
    width: 160,
  },
];
const receiptColumns = [
  { title: '回款单号', dataIndex: 'no', key: 'no', width: 160 },
  { title: '回款时间', dataIndex: 'returnTime', key: 'returnTime', width: 150 },
  { title: '回款金额', dataIndex: 'amount', key: 'amount', width: 120 },
  { title: '关联发票', dataIndex: 'invoiceNo', key: 'invoiceNo', width: 150 },
  { title: '状态', dataIndex: 'status', key: 'status', width: 100 },
];
function formatMoney(value?: number) {
  if (value === undefined || value === null) {
    return '-';
  }
  return Number(value).toLocaleString('zh-CN', {
    maximumFractionDigits: 2,
    minimumFractionDigits: 2,
  });
}
function getOrderStatusLabel(status?: number) {
  if (status === undefined || status === null) {
    return '-';
  }
  return getDictLabel(DICT_TYPE.ERP_AUDIT_STATUS, status) || '-';
}
function getAuditStatusLabel(status?: number) {
  if (status === undefined || status === null) {
    return '-';
  }
  return getDictLabel(DICT_TYPE.CRM_AUDIT_STATUS, status) || '-';
}
/** ç»„装时间范围参数 */
function buildTimeParams() {
  const params: {
    beginTime?: string;
    endTime?: string;
  } = {};
  if (dateRange.value?.length === 2) {
    params.beginTime = dateRange.value[0]!
      .startOf('day')
      .format('YYYY-MM-DD HH:mm:ss');
    params.endTime = dateRange.value[1]!
      .endOf('day')
      .format('YYYY-MM-DD HH:mm:ss');
  }
  return params;
}
/** åŠ è½½å·¦ä¾§å®¢æˆ·æ±‡æ€»ï¼Œé»˜è®¤é€‰ä¸­ç¬¬ä¸€ä¸ªå®¢æˆ· */
async function loadSummaries() {
  listLoading.value = true;
  try {
    summaries.value =
      (await getCustomerStatementSummaryList({
        ...buildTimeParams(),
        includeUnapprovedOrder: includeUnapproved.value,
      })) ?? [];
    const stillExists = summaries.value.some(
      (item) => item.customerId === selectedId.value,
    );
    if (!stillExists) {
      selectedId.value = summaries.value[0]?.customerId;
    }
    const targetId = selectedId.value;
    if (targetId === undefined) {
      statement.value = undefined;
      return;
    }
    await loadDetail(targetId);
  } finally {
    listLoading.value = false;
  }
}
/** ç‚¹å‡»å·¦ä¾§å®¢æˆ·ï¼ŒåŠ è½½å³ä¾§æ˜Žç»† */
async function handleSelect(customerId: number) {
  if (selectedId.value === customerId && statement.value) {
    return;
  }
  selectedId.value = customerId;
  await loadDetail(customerId);
}
/** åŠ è½½æŒ‡å®šå®¢æˆ·çš„å¾€æ¥æ˜Žç»† */
async function loadDetail(customerId: number) {
  detailLoading.value = true;
  try {
    statement.value = await getCustomerStatement({
      ...buildTimeParams(),
      includeUnapprovedOrder: includeUnapproved.value,
      customerId,
    });
  } finally {
    detailLoading.value = false;
  }
}
onMounted(loadSummaries);
</script>
<template>
  <Page auto-content-height>
    <div class="flex h-full gap-4">
      <!-- å·¦ä¾§ï¼šå®¢æˆ·æ€»è§ˆï¼Œç‚¹å‡»æŸ¥çœ‹å³ä¾§æ˜Žç»† -->
      <div class="flex w-[360px] shrink-0 flex-col rounded-lg border">
        <div class="border-b p-3">
          <Input v-model:value="keyword" allow-clear placeholder="搜索客户名称" />
          <div class="mt-2 flex items-center gap-2">
            <RangePicker
              v-model:value="dateRange"
              class="min-w-0 flex-1"
              size="small"
            />
          </div>
          <div class="mt-2 flex items-center justify-between">
            <Checkbox v-model:checked="includeUnapproved">
              å«æœªå®¡æ ¸é”€å”®è®¢å•
            </Checkbox>
            <Button
              size="small"
              type="primary"
              :loading="listLoading"
              @click="loadSummaries"
            >
              æŸ¥è¯¢
            </Button>
          </div>
          <div class="mt-2 text-xs text-gray-500">
            å…± {{ filteredSummaries.length }} ä¸ªå®¢æˆ·ï¼Œæœªæ”¶åˆè®¡
            <span class="font-medium text-red-600">
              Â¥{{ formatMoney(totalUncollected) }}
            </span>
          </div>
        </div>
        <div class="min-h-0 flex-1 overflow-y-auto p-2">
          <Spin :spinning="listLoading">
            <div
              v-if="filteredSummaries.length === 0"
              class="p-6 text-center text-sm text-gray-500"
            >
              {{
                summaries.length > 0
                  ? '没有匹配的客户'
                  : '所选时间范围内没有客户往来'
              }}
            </div>
            <div
              v-for="item in filteredSummaries"
              :key="item.customerId"
              class="mb-2 cursor-pointer rounded-lg border p-3 transition-colors"
              :class="
                item.customerId === selectedId
                  ? 'border-blue-500 bg-blue-50'
                  : 'hover:border-blue-300 hover:bg-gray-50'
              "
              @click="handleSelect(item.customerId)"
            >
              <div class="flex items-start justify-between gap-2">
                <div class="min-w-0">
                  <div class="truncate text-sm font-medium">
                    {{ item.customerName }}
                  </div>
                  <div class="text-xs text-gray-400">
                    è®¢å• {{ item.orderCount }} å• / å¼€ç¥¨
                    {{ item.invoiceCount }} å¼  / å›žæ¬¾ {{ item.receiptCount }} ç¬”
                  </div>
                </div>
                <div class="shrink-0 text-right">
                  <div class="text-xs text-gray-500">未收余额</div>
                  <div
                    class="text-sm font-semibold"
                    :class="
                      (item.uncollectedAmount ?? 0) > 0
                        ? 'text-red-600'
                        : 'text-gray-700'
                    "
                  >
                    {{ formatMoney(item.uncollectedAmount) }}
                  </div>
                </div>
              </div>
              <div class="mt-2 grid grid-cols-3 gap-1 text-xs text-gray-500">
                <span>订单 {{ formatMoney(item.totalOrderAmount) }}</span>
                <span>开票 {{ formatMoney(item.totalInvoiceAmount) }}</span>
                <span>回款 {{ formatMoney(item.totalCollectedAmount) }}</span>
              </div>
            </div>
          </Spin>
        </div>
      </div>
      <!-- å³ä¾§ï¼šå½“前客户往来明细 -->
      <div class="min-w-0 flex-1 overflow-y-auto">
        <Empty
          v-if="!selectedId"
          class="mt-20"
          description="请在左侧选择客户,查看往来明细"
        />
        <Spin v-else :spinning="detailLoading">
          <div class="mb-4 flex flex-wrap items-center gap-2">
            <span class="text-lg font-semibold">
              {{ statement?.customerName ?? selectedSummary?.customerName }}
            </span>
            <span class="text-xs text-gray-400">
              è®¢å• {{ selectedSummary?.orderCount ?? 0 }} å• / å¼€ç¥¨
              {{ selectedSummary?.invoiceCount ?? 0 }} å¼  / å›žæ¬¾
              {{ selectedSummary?.receiptCount ?? 0 }} ç¬”
            </span>
          </div>
          <Row :gutter="16" class="mb-4">
            <Col :span="6">
              <Card size="small">
                <div class="text-gray-500">累计订单额</div>
                <div class="text-2xl font-semibold">
                  {{ formatMoney(statement?.orderTotalAmount) }}
                </div>
              </Card>
            </Col>
            <Col :span="6">
              <Card size="small">
                <div class="text-gray-500">累计开票额</div>
                <div class="text-2xl font-semibold text-blue-600">
                  {{ formatMoney(statement?.invoicedAmount) }}
                </div>
              </Card>
            </Col>
            <Col :span="6">
              <Card size="small">
                <div class="text-gray-500">累计回款额</div>
                <div class="text-2xl font-semibold text-green-600">
                  {{ formatMoney(statement?.collectedAmount) }}
                </div>
              </Card>
            </Col>
            <Col :span="6">
              <Card size="small">
                <div class="text-gray-500">未收余额(开票 - å›žæ¬¾ï¼‰</div>
                <div class="text-2xl font-semibold text-red-600">
                  {{ formatMoney(statement?.outstandingAmount) }}
                </div>
              </Card>
            </Col>
          </Row>
          <Row :gutter="16">
            <Col :span="12">
              <Card title="销售订单流水(含开票金额)" size="small">
                <Table
                  :columns="orderColumns"
                  :data-source="statement?.orders ?? []"
                  :pagination="false"
                  bordered
                  size="small"
                  :scroll="{ x: 800 }"
                >
                  <template #bodyCell="{ column, record }">
                    <template v-if="column.key === 'orderAmount'">
                      {{ formatMoney(record.orderAmount) }}
                    </template>
                    <template v-else-if="column.key === 'invoicedAmount'">
                      <span class="text-blue-600">
                        {{ formatMoney(record.invoicedAmount) }}
                      </span>
                    </template>
                    <template v-else-if="column.key === 'uninvoicedAmount'">
                      <span class="text-red-600">
                        {{ formatMoney(record.uninvoicedAmount) }}
                      </span>
                    </template>
                    <template v-else-if="column.key === 'status'">
                      {{ getOrderStatusLabel(record.status) }}
                    </template>
                  </template>
                </Table>
              </Card>
            </Col>
            <Col :span="12">
              <Card title="开票流水(开票金额 / å·²å›žæ¬¾ / å‰©ä½™åº”收)" size="small">
                <Table
                  :columns="invoiceColumns"
                  :data-source="statement?.invoices ?? []"
                  :pagination="false"
                  bordered
                  size="small"
                  :scroll="{ x: 900 }"
                >
                  <template #bodyCell="{ column, record }">
                    <template v-if="column.key === 'invoiceAmount'">
                      <span class="text-blue-600">
                        {{ formatMoney(record.invoiceAmount) }}
                      </span>
                    </template>
                    <template v-else-if="column.key === 'collectedAmount'">
                      {{ formatMoney(record.collectedAmount) }}
                    </template>
                    <template v-else-if="column.key === 'remainingReceivable'">
                      <span class="text-red-600">
                        {{ formatMoney(record.remainingReceivable) }}
                      </span>
                    </template>
                  </template>
                </Table>
              </Card>
              <Card class="mt-4" size="small" title="回款流水">
                <Table
                  :columns="receiptColumns"
                  :data-source="statement?.receipts ?? []"
                  :pagination="false"
                  bordered
                  size="small"
                  :scroll="{ x: 700 }"
                >
                  <template #bodyCell="{ column, record }">
                    <template v-if="column.key === 'amount'">
                      <span class="text-green-600">
                        {{ formatMoney(record.amount) }}
                      </span>
                    </template>
                    <template v-else-if="column.key === 'status'">
                      {{ getAuditStatusLabel(record.status) }}
                    </template>
                  </template>
                </Table>
              </Card>
            </Col>
          </Row>
        </Spin>
      </div>
    </div>
  </Page>
</template>
src/views/crm/invoice/data.ts
@@ -6,8 +6,12 @@
import { useUserStore } from '@vben/stores';
import { getContractSimpleList } from '#/api/crm/contract';
import { getSaleOrderPage } from '#/api/erp/sale/order';
import { getSimpleUserList } from '#/api/system/user';
import CrmCustomerSelect from '#/components/crm-customer-select.vue';
import PaymentNodesEditor from './modules/payment-nodes-editor.vue';
import SaleOutSelect from './modules/sale-out-select.vue';
import { markRaw } from 'vue';
@@ -146,14 +150,37 @@
    {
      fieldName: 'saleOrderId',
      label: '销售订单号',
      component: 'Input',
      componentProps: { placeholder: '请输入或关联销售订单号' },
      component: 'ApiSelect',
      componentProps: (_values, form) => ({
        placeholder: '请选择已审核的销售订单',
        allowClear: true,
        showSearch: true,
        api: async () => {
          const result = await getSaleOrderPage({
            pageNo: 1,
            pageSize: 200,
            status: 20,
            customerId: form.getValues().customerId,
          });
          return result.list;
        },
        labelField: 'no',
        valueField: 'id',
        onChange: () => {
          form.setFieldValue('saleOutId', undefined);
        },
      }),
    },
    {
      fieldName: 'saleOutId',
      label: '出库单号',
      component: 'Input',
      componentProps: { placeholder: '请输入或关联出库单号' },
      component: markRaw(SaleOutSelect),
      help: '选择销售订单后,从该订单已完成出库的单据中选择;不选也可保存',
      componentProps: (values) => ({
        saleOrderId: values.saleOrderId,
        id: values.id,
        placeholder: '请选择出库单',
      }),
    },
    {
      fieldName: 'contractAmount',
@@ -178,6 +205,13 @@
      label: '验收款金额',
      component: 'InputNumber',
      componentProps: { class: '!w-full', min: 0, precision: 2 },
    },
    {
      fieldName: 'paymentNodes',
      label: '款项时间节点',
      component: markRaw(PaymentNodesEditor),
      formItemClass: 'md:col-span-2',
      help: '按合同约定填写各阶段计划金额与计划回款时间,录入发票时还能补填已回款情况',
    },
    {
      fieldName: 'invoiceTime',
@@ -286,6 +320,12 @@
      minWidth: 150,
    },
    {
      title: '发票录入',
      field: 'entryStatus',
      minWidth: 110,
      slots: { default: 'entryStatus' },
    },
    {
      title: '开票金额(元)',
      field: 'price',
      minWidth: 150,
src/views/crm/invoice/index.vue
@@ -21,6 +21,7 @@
import { automaticApproval } from '#/store/audit-policy';
import { useGridColumns, useGridFormSchema } from './data';
import EntryForm from './modules/entry-form.vue';
import Form from './modules/form.vue';
import ImportForm from './modules/import-form.vue';
@@ -32,6 +33,11 @@
  destroyOnClose: true,
});
const [EntryModal, entryModalApi] = useVbenModal({
  connectedComponent: EntryForm,
  destroyOnClose: true,
});
const [ImportModal, importModalApi] = useVbenModal({
  connectedComponent: ImportForm,
  destroyOnClose: true,
@@ -40,6 +46,11 @@
/** æ‰“开导入开票弹窗 */
function handleImport() {
  importModalApi.open();
}
/** å½•入发票 */
function handleEntry(row: CrmInvoiceApi.Invoice) {
  entryModalApi.setData({ invoice: row }).open();
}
/** åˆ·æ–°è¡¨æ ¼ */
@@ -161,6 +172,7 @@
<template>
  <Page auto-content-height>
    <FormModal @success="handleRefresh" />
    <EntryModal @success="handleRefresh" />
    <ImportModal @success="handleRefresh" />
    <Grid>
      <template #toolbar-actions>
@@ -230,6 +242,10 @@
          å·²ä¸Šä¼ 
        </Tag>
      </template>
      <template #entryStatus="{ row }">
        <Tag v-if="row.entryStatus === 10" color="success">已录入</Tag>
        <Tag v-else color="default">待录入</Tag>
      </template>
      <template #auditStatus="{ row }">
        <Tag v-if="row.auditStatus === 0" color="default">未提交</Tag>
        <Tag v-if="row.auditStatus === 10" color="warning">审批中</Tag>
@@ -255,6 +271,13 @@
              ifShow: row.auditStatus === 0,
            },
            {
              label: '录入发票',
              type: 'link',
              auth: ['crm:invoice:update'],
              onClick: handleEntry.bind(null, row),
              ifShow: row.auditStatus === 20 && row.entryStatus !== 10,
            },
            {
              label: '查看审批',
              type: 'link',
              auth: ['crm:invoice:update'],
src/views/crm/invoice/modules/entry-form.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,154 @@
<script lang="ts" setup>
import type { CrmInvoiceApi } from '#/api/crm/invoice';
import { computed, ref } from 'vue';
import { useVbenForm, useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { entryInvoice, getInvoice } from '#/api/crm/invoice';
import PaymentNodesEditor from './payment-nodes-editor.vue';
const emit = defineEmits(['success']);
const invoice = ref<CrmInvoiceApi.Invoice>();
const getTitle = computed(() =>
  invoice.value
    ? `录入发票 - ${invoice.value.no ?? ''}`
    : '录入发票',
);
const [Form, formApi] = useVbenForm({
  commonConfig: {
    componentProps: { class: 'w-full' },
    labelWidth: 120,
  },
  wrapperClass: 'grid-cols-2',
  layout: 'horizontal',
  showDefaultActions: false,
  schema: [
    {
      fieldName: 'invoiceNo',
      label: '发票号码',
      component: 'Input',
      rules: 'required',
      componentProps: {
        placeholder: '请输入发票上的真实发票号码',
        allowClear: true,
      },
    },
    {
      fieldName: 'invoiceTime',
      label: '开票日期',
      component: 'DatePicker',
      rules: 'required',
      componentProps: {
        placeholder: '请选择发票上的开票日期',
        showTime: false,
        valueFormat: 'YYYY-MM-DD',
        format: 'YYYY-MM-DD',
      },
    },
    {
      fieldName: 'invoiceTitle',
      label: '发票抬头',
      component: 'Input',
      componentProps: {
        placeholder: '请输入发票抬头',
        allowClear: true,
      },
    },
    {
      fieldName: 'blobIds',
      label: '发票附件',
      component: 'FileUpload',
      componentProps: {
        valueKey: 'id',
        maxNumber: 10,
        multiple: true,
        accept: ['jpg', 'jpeg', 'png', 'pdf'],
        helpText: '支持jpg、jpeg、png、pdf格式,单个文件不超过50MB',
      },
    },
    {
      fieldName: 'paymentNodes',
      label: '款项时间节点',
      component: PaymentNodesEditor,
      formItemClass: 'md:col-span-2',
      help: '按合同约定填写计划金额、计划回款时间;已有回款计划会带出已回款金额',
    },
    {
      fieldName: 'remark',
      label: '备注',
      component: 'Textarea',
      componentProps: { placeholder: '请输入备注', rows: 3 },
      formItemClass: 'md:col-span-2',
    },
  ],
});
const [Modal, modalApi] = useVbenModal({
  async onConfirm() {
    const { valid } = await formApi.validate();
    if (!valid || !invoice.value?.id) {
      return;
    }
    modalApi.lock();
    try {
      const data = await formApi.getValues();
      await entryInvoice({
        id: invoice.value.id,
        invoiceNo: data.invoiceNo as string,
        invoiceTime: data.invoiceTime as string,
        invoiceTitle: data.invoiceTitle as string | undefined,
        blobIds: data.blobIds as number[] | undefined,
        paymentNodes: data.paymentNodes as CrmInvoiceApi.PaymentNode[],
        remark: data.remark as string | undefined,
      });
      await modalApi.close();
      emit('success');
      message.success('发票信息已录入');
    } finally {
      modalApi.unlock();
    }
  },
  async onOpenChange(isOpen: boolean) {
    if (!isOpen) {
      invoice.value = undefined;
      return;
    }
    await formApi.resetForm();
    const data = modalApi.getData() as
      | { invoice?: Partial<CrmInvoiceApi.Invoice> }
      | undefined;
    if (!data?.invoice?.id) {
      return;
    }
    modalApi.lock();
    try {
      invoice.value = await getInvoice(data.invoice.id);
      const values: Record<string, unknown> = {
        invoiceNo: invoice.value.invoiceNo,
        invoiceTime: invoice.value.invoiceTime,
        invoiceTitle: invoice.value.invoiceTitle,
        paymentNodes: invoice.value.paymentNodes ?? [],
        remark: invoice.value.remark,
      };
      if (invoice.value.attachmentList?.length) {
        values.blobIds = invoice.value.attachmentList;
      }
      await formApi.setValues(values);
    } finally {
      modalApi.unlock();
    }
  },
});
</script>
<template>
  <Modal :title="getTitle" class="w-3/5">
    <Form class="mx-4" />
  </Modal>
</template>
src/views/crm/invoice/modules/payment-nodes-editor.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,117 @@
<script lang="ts" setup>
import type { CrmInvoiceApi } from '#/api/crm/invoice';
import { Button, DatePicker, Input, InputNumber, Select } from 'ant-design-vue';
import { DeleteOutlined, PlusOutlined } from '@ant-design/icons-vue';
defineOptions({ name: 'CrmInvoicePaymentNodesEditor' });
const props = withDefaults(
  defineProps<{
    disabled?: boolean;
    /** æ˜¯å¦åªå±•示已回款信息(详情/录入发票时) */
    readonly?: boolean;
  }>(),
  { disabled: false, readonly: false },
);
const nodes = defineModel<CrmInvoiceApi.PaymentNode[]>({ default: () => [] });
const STAGE_OPTIONS = [
  { label: '合同款', value: 1 },
  { label: '预付款', value: 2 },
  { label: '到货款', value: 3 },
  { label: '验收款', value: 4 },
  { label: '质保金', value: 5 },
];
function addNode() {
  nodes.value = [
    ...(nodes.value ?? []),
    { stage: 1, amount: undefined, returnTime: undefined, remark: '' },
  ];
}
function removeNode(index: number) {
  const list = [...(nodes.value ?? [])];
  list.splice(index, 1);
  nodes.value = list;
}
function updateNode(
  index: number,
  key: keyof CrmInvoiceApi.PaymentNode,
  value: unknown,
) {
  const list = [...(nodes.value ?? [])];
  list[index] = { ...list[index], [key]: value } as CrmInvoiceApi.PaymentNode;
  nodes.value = list;
}
</script>
<template>
  <div class="w-full">
    <div v-if="!nodes?.length" class="mb-2 text-sm text-gray-400">
      æš‚未设置款项时间节点,可点击下方「新增节点」按合同约定填写
    </div>
    <div
      v-for="(node, index) in nodes"
      :key="index"
      class="mb-2 flex items-center gap-2"
    >
      <Select
        :value="node.stage"
        :options="STAGE_OPTIONS"
        :disabled="props.disabled || props.readonly"
        class="w-32"
        placeholder="阶段"
        @change="(value) => updateNode(index, 'stage', value)"
      />
      <InputNumber
        :value="node.amount"
        :disabled="props.disabled || props.readonly"
        :min="0"
        :precision="2"
        class="w-40"
        placeholder="计划金额"
        @change="(value) => updateNode(index, 'amount', value)"
      />
      <DatePicker
        :value="node.returnTime"
        :disabled="props.disabled || props.readonly"
        class="w-44"
        format="YYYY-MM-DD"
        value-format="YYYY-MM-DD HH:mm:ss"
        placeholder="计划回款时间"
        @change="(value) => updateNode(index, 'returnTime', value)"
      />
      <Input
        :value="node.remark"
        :disabled="props.disabled || props.readonly"
        class="flex-1"
        placeholder="备注"
        @change="(e) => updateNode(index, 'remark', e.target.value)"
      />
      <div v-if="node.receiptedPrice != null" class="w-40 text-sm text-gray-500">
        å·²å›žæ¬¾ï¼š{{ Number(node.receiptedPrice).toFixed(2) }}
      </div>
      <Button
        v-if="!props.disabled && !props.readonly"
        type="text"
        danger
        @click="removeNode(index)"
      >
        <DeleteOutlined />
      </Button>
    </div>
    <Button
      v-if="!props.disabled && !props.readonly"
      type="dashed"
      block
      @click="addNode"
    >
      <PlusOutlined />
      æ–°å¢žèŠ‚ç‚¹
    </Button>
  </div>
</template>
src/views/crm/invoice/modules/sale-out-select.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,174 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { CrmInvoiceApi } from '#/api/crm/invoice';
import { computed, ref, watch } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { Input, message, Modal } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getInvoiceSaleOutList } from '#/api/crm/invoice';
defineOptions({ name: 'CrmInvoiceSaleOutSelect' });
const props = withDefaults(
  defineProps<{
    disabled?: boolean;
    /** ç¼–辑时传入开票单编号,用于排除自身已占用金额 */
    id?: number;
    modelValue?: number;
    placeholder?: string;
    saleOrderId?: number;
  }>(),
  {
    disabled: false,
    id: undefined,
    modelValue: undefined,
    placeholder: '请选择出库单',
    saleOrderId: undefined,
  },
);
const emit = defineEmits<{
  change: [saleOut: CrmInvoiceApi.SaleOut | undefined];
  'update:modelValue': [value: number | undefined];
}>();
const open = ref(false);
const saleOut = ref<CrmInvoiceApi.SaleOut>();
const displayCode = computed(() => saleOut.value?.code ?? '');
watch(
  () => props.saleOrderId,
  () => {
    saleOut.value = undefined;
  },
);
watch(
  () => props.modelValue,
  async (value) => {
    if (!value || !props.saleOrderId) {
      if (!value) {
        saleOut.value = undefined;
      }
      return;
    }
    if (saleOut.value?.id === value) {
      return;
    }
    const list = await getInvoiceSaleOutList(props.saleOrderId, props.id);
    saleOut.value = list.find((item) => item.id === value);
  },
  { immediate: true },
);
const [Grid, gridApi] = useVbenVxeGrid({
  gridOptions: {
    columns: [
      { type: 'radio', width: 50, fixed: 'left' },
      { field: 'code', title: '出库单号', width: 200, fixed: 'left' },
      {
        field: 'salesDate',
        title: '出库日期',
        width: 150,
        formatter: 'formatDate',
      },
      {
        field: 'totalPrice',
        title: '出库金额',
        formatter: 'formatAmount2',
        minWidth: 120,
      },
      {
        field: 'invoicedPrice',
        title: '已开票金额',
        formatter: 'formatAmount2',
        minWidth: 120,
      },
      {
        field: 'remainInvoicePrice',
        title: '剩余可开票金额',
        formatter: 'formatAmount2',
        minWidth: 140,
      },
    ],
    height: 460,
    keepSource: true,
    proxyConfig: {
      ajax: {
        query: async () => {
          if (!props.saleOrderId) {
            return { list: [], total: 0 };
          }
          const list = await getInvoiceSaleOutList(
            props.saleOrderId,
            props.id,
          );
          return { list, total: list.length };
        },
      },
    },
    rowConfig: { keyField: 'id', isHover: true },
    radioConfig: { trigger: 'row', highlight: true },
    toolbarConfig: { refresh: false, search: false },
  } as VxeTableGridOptions<CrmInvoiceApi.SaleOut>,
  gridEvents: {
    radioChange: ({ row }: { row: CrmInvoiceApi.SaleOut }) => {
      saleOut.value = row;
    },
  },
});
function handleOpen() {
  if (props.disabled) {
    return;
  }
  if (!props.saleOrderId) {
    message.warning('请先选择销售订单,再选择对应的出库单');
    return;
  }
  open.value = true;
  gridApi.query();
}
function handleOk() {
  if (!saleOut.value?.id) {
    message.warning('请选择出库单');
    return;
  }
  emit('update:modelValue', saleOut.value.id);
  emit('change', saleOut.value);
  open.value = false;
}
</script>
<template>
  <div>
    <Input
      readonly
      :disabled="disabled || !saleOrderId"
      :placeholder="saleOrderId ? placeholder : '请先选择销售订单'"
      :value="displayCode"
      @click="handleOpen"
    >
      <template #suffix>
        <IconifyIcon
          class="size-4"
          :style="{ cursor: disabled || !saleOrderId ? 'not-allowed' : 'pointer' }"
          icon="lucide:search"
        />
      </template>
    </Input>
    <Modal
      v-model:open="open"
      title="选择销售出库单(按销售订单过滤)"
      width="70%"
      @cancel.stop="open = false"
      @ok.stop="handleOk"
    >
      <Grid table-title="销售出库单列表" />
    </Modal>
  </div>
</template>
src/views/crm/receivable/data.ts
@@ -321,6 +321,15 @@
      },
    },
    {
      title: '审批状态',
      field: 'auditStatus',
      minWidth: 110,
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.CRM_AUDIT_STATUS },
      },
    },
    {
      title: '备注',
      field: 'remark',
      minWidth: 150,
src/views/crm/receivable/detail/data.ts
@@ -31,6 +31,15 @@
      render: (val) => erpPriceInputFormatter(val),
    },
    {
      field: 'auditStatus',
      label: '审批状态',
      render: (val) =>
        h(DictTag, {
          type: DICT_TYPE.CRM_AUDIT_STATUS,
          value: val,
        }),
    },
    {
      field: 'ownerUserName',
      label: '负责人',
    },
src/views/crm/receivable/detail/index.vue
@@ -1,4 +1,5 @@
<script setup lang="ts">
import type { BpmProcessInstanceApi } from '#/api/bpm/processInstance';
import type { CrmReceivableApi } from '#/api/crm/receivable';
import type { SystemOperateLogApi } from '#/api/system/operate-log';
@@ -10,6 +11,7 @@
import { Card, Tabs } from 'ant-design-vue';
import { getApprovalDetail } from '#/api/bpm/processInstance';
import { getOperateLogPage } from '#/api/crm/operateLog';
import { BizTypeEnum } from '#/api/crm/permission';
import { getReceivable } from '#/api/crm/receivable';
@@ -18,6 +20,7 @@
import { ACTION_ICON, TableAction } from '#/components/table-action';
import { $t } from '#/locales';
import { PermissionList } from '#/views/crm/permission';
import ProcessInstanceTimeline from '#/views/bpm/processInstance/detail/modules/time-line.vue';
import ReceivableForm from '../modules/form.vue';
import { useDetailSchema } from './data';
@@ -35,6 +38,7 @@
  {} as CrmReceivableApi.Receivable,
); // å›žæ¬¾è¯¦æƒ…
const logList = ref<SystemOperateLogApi.OperateLog[]>([]); // æ“ä½œæ—¥å¿—
const activityNodes = ref<BpmProcessInstanceApi.ApprovalNodeInfo[]>([]); // å®¡æ‰¹è®°å½•
const permissionListRef = ref<InstanceType<typeof PermissionList>>(); // å›¢é˜Ÿæˆå‘˜åˆ—表 Ref
const [Descriptions] = useDescription({
@@ -59,9 +63,32 @@
      bizId: receivableId.value,
    });
    logList.value = res.list;
    // å®¡æ‰¹è®°å½•:已提交审批时加载 BPM å®¡æ‰¹èŠ‚ç‚¹
    activityNodes.value = [];
    if (receivable.value.processInstanceId) {
      try {
        const approval = await getApprovalDetail({
          processInstanceId: receivable.value.processInstanceId,
        });
        activityNodes.value = approval?.activityNodes ?? [];
      } catch {
        activityNodes.value = [];
      }
    }
  } finally {
    loading.value = false;
  }
}
/** æŸ¥çœ‹å®¡æ‰¹è¯¦æƒ… */
function handleProcessDetail() {
  if (!receivable.value.processInstanceId) {
    return;
  }
  router.push({
    name: 'BpmProcessInstanceDetail',
    query: { id: receivable.value.processInstanceId, viewOnly: '1' },
  });
}
/** è¿”回列表页 */
@@ -95,11 +122,20 @@
            onClick: handleBack,
          },
          {
            label: '查看审批',
            type: 'default',
            icon: ACTION_ICON.VIEW,
            ifShow: !!receivable.processInstanceId,
            onClick: handleProcessDetail,
          },
          {
            label: $t('ui.actionTitle.edit'),
            type: 'primary',
            icon: ACTION_ICON.EDIT,
            auth: ['crm:receivable:update'],
            ifShow: permissionListRef?.validateWrite,
            ifShow:
              permissionListRef?.validateWrite &&
              (receivable.auditStatus === 0 || receivable.auditStatus === 30),
            onClick: handleEdit,
          },
        ]"
@@ -113,6 +149,15 @@
        <Tabs.TabPane tab="详细资料" key="1" :force-render="true">
          <Info :receivable="receivable" />
        </Tabs.TabPane>
        <Tabs.TabPane tab="审批记录" key="4" :force-render="true">
          <div
            v-if="!receivable.processInstanceId"
            class="py-8 text-center text-muted-foreground"
          >
            è¯¥å›žæ¬¾å°šæœªæäº¤å®¡æ‰¹
          </div>
          <ProcessInstanceTimeline v-else :activity-nodes="activityNodes" />
        </Tabs.TabPane>
        <Tabs.TabPane tab="操作日志" key="2" :force-render="true">
          <OperateLog :log-list="logList" date-format="YYYY-MM-DD" />
        </Tabs.TabPane>
src/views/crm/receivable/index.vue
@@ -15,6 +15,7 @@
  deleteReceivable,
  exportReceivable,
  getReceivablePage,
  submitReceivable,
  updateReceivableStatus,
} from '#/api/crm/receivable';
import { $t } from '#/locales';
@@ -76,7 +77,22 @@
  }
}
/** æ›´æ–°å›žæ¬¾çŠ¶æ€ï¼ˆå®¡æ ¸/反审核) */
/** æäº¤å®¡æ‰¹ */
async function handleSubmit(row: CrmReceivableApi.Receivable) {
  const hideLoading = message.loading({
    content: '提交审批中...',
    duration: 0,
  });
  try {
    await submitReceivable(row.id!);
    message.success('提交审批成功');
    handleRefresh();
  } finally {
    hideLoading();
  }
}
/** åå®¡æ ¸ */
async function handleUpdateStatus(
  row: CrmReceivableApi.Receivable,
  status: number,
@@ -92,6 +108,14 @@
  } finally {
    hideLoading();
  }
}
/** æŸ¥çœ‹å®¡æ‰¹è¿›åº¦ */
function handleProcessDetail(row: CrmReceivableApi.Receivable) {
  push({
    name: 'BpmProcessInstanceDetail',
    query: { id: row.processInstanceId, viewOnly: '1' },
  });
}
/** æŸ¥çœ‹å›žæ¬¾è¯¦æƒ… */
@@ -216,16 +240,23 @@
              type: 'link',
              icon: ACTION_ICON.EDIT,
              auth: ['crm:receivable:update'],
              ifShow: row.auditStatus === 10,
              ifShow: row.auditStatus === 0 || row.auditStatus === 30,
              onClick: handleEdit.bind(null, row),
            },
            {
              label: '审核',
              label: '提交审批',
              type: 'link',
              icon: ACTION_ICON.AUDIT,
              auth: ['crm:receivable:update'],
              ifShow: row.auditStatus === 10,
              onClick: handleUpdateStatus.bind(null, row, 20),
              ifShow: row.auditStatus === 0 || row.auditStatus === 30,
              onClick: handleSubmit.bind(null, row),
            },
            {
              label: '查看审批进度',
              type: 'link',
              icon: ACTION_ICON.VIEW,
              ifShow: row.auditStatus === 10 && !!row.processInstanceId,
              onClick: handleProcessDetail.bind(null, row),
            },
            {
              label: '反审核',
@@ -233,7 +264,8 @@
              icon: ACTION_ICON.VIEW,
              auth: ['crm:receivable:update'],
              ifShow: row.auditStatus === 20,
              onClick: handleUpdateStatus.bind(null, row, 10),
              // åå®¡æ ¸å›žåˆ°ã€Œæœªæäº¤ã€ï¼Œä¾¿äºŽä¿®æ”¹åŽé‡æ–°æäº¤ BPM å®¡æ‰¹
              onClick: handleUpdateStatus.bind(null, row, 0),
            },
            {
              label: $t('common.delete'),
src/views/crm/saleQuotation/data.ts
@@ -15,6 +15,10 @@
/** æŠ¥ä»·å•状态 */
export const QUOTATION_STATUS = {
  DRAFT: 0, // è‰ç¨¿
  PROCESS: 1, // å®¡æ‰¹ä¸­
  APPROVE: 2, // å®¡æ‰¹é€šè¿‡
  REJECT: 3, // å®¡æ‰¹ä¸é€šè¿‡
  CANCEL: 4, // å·²å–消
  CONVERTED: 10, // å·²è½¬åˆåŒ
};
src/views/crm/saleQuotation/index.vue
@@ -6,16 +6,22 @@
import { Page, useVbenModal } from '#/packages/effects/common-ui/src';
import { Button, message, Modal, Tag } from 'ant-design-vue';
import { Button, message, Modal, Select, Tag } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
  convertSaleQuotationToContract,
  deleteSaleQuotation,
  getSaleQuotationApproveProcessList,
  getSaleQuotationPage,
  submitSaleQuotation,
} from '#/api/crm/saleQuotation';
import { $t } from '#/locales';
import { downloadContractWord } from '#/utils/contract-document';
import { automaticApproval } from '#/store/audit-policy';
import {
  downloadContractWord,
  downloadSaleQuotationWord,
} from '#/utils/contract-document';
import type { CrmSaleQuotationAiApi } from '#/api/crm/saleQuotation/ai';
@@ -99,6 +105,51 @@
function handleContractDetail(row: CrmSaleQuotationApi.SaleQuotation) {
  if (row.contractId) {
    push({ name: 'CrmContractDetail', params: { id: row.contractId } });
  }
}
/** æäº¤å®¡æ‰¹å¼¹çª— */
const submitModalVisible = ref(false);
const currentRow = ref<CrmSaleQuotationApi.SaleQuotation>();
const processList = ref<CrmSaleQuotationApi.ApproveProcess[]>([]);
const selectedProcessKey = ref<string>();
const submitLoading = ref(false);
async function handleSubmit(row: CrmSaleQuotationApi.SaleQuotation) {
  const res = automaticApproval.value ? [] : await getSaleQuotationApproveProcessList();
  processList.value = res;
  currentRow.value = row;
  selectedProcessKey.value = undefined;
  if (automaticApproval.value) {
    selectedProcessKey.value = 'sale_quotation_approve';
    await confirmSubmit();
    return;
  }
  submitModalVisible.value = true;
}
async function confirmSubmit() {
  if (!selectedProcessKey.value) {
    message.warning('请选择审批流程');
    return;
  }
  submitLoading.value = true;
  try {
    await submitSaleQuotation(currentRow.value!.id!, selectedProcessKey.value);
    message.success(automaticApproval.value ? '已提交生效' : '提交审批成功');
    submitModalVisible.value = false;
    handleRefresh();
  } finally {
    submitLoading.value = false;
  }
}
/** ä¸‹è½½å¸¦ç« æŠ¥ä»·å• */
async function handleDownloadQuotation(row: CrmSaleQuotationApi.SaleQuotation) {
  try {
    await downloadSaleQuotationWord(row.id!, row.no);
  } catch {
    message.error('报价单下载失败,请稍后重试');
  }
}
@@ -205,6 +256,10 @@
      </template>
      <template #status="{ row }">
        <Tag v-if="row.status === QUOTATION_STATUS.DRAFT" color="default">草稿</Tag>
        <Tag v-if="row.status === QUOTATION_STATUS.PROCESS" color="processing">审批中</Tag>
        <Tag v-if="row.status === QUOTATION_STATUS.APPROVE" color="success">审批通过</Tag>
        <Tag v-if="row.status === QUOTATION_STATUS.REJECT" color="error">审批不通过</Tag>
        <Tag v-if="row.status === QUOTATION_STATUS.CANCEL" color="warning">已取消</Tag>
        <Tag v-if="row.status === QUOTATION_STATUS.CONVERTED" color="success">已转合同</Tag>
      </template>
      <template #actions="{ row }">
@@ -219,11 +274,31 @@
              ifShow: row.status === QUOTATION_STATUS.DRAFT,
            },
            {
              label: '提交审批',
              type: 'link',
              auth: ['crm:sale-quotation:update'],
              onClick: handleSubmit.bind(null, row),
              ifShow: row.status === QUOTATION_STATUS.DRAFT,
            },
            {
              label: '转合同',
              type: 'link',
              auth: ['crm:sale-quotation:update'],
              onClick: handleConvertContract.bind(null, row),
              ifShow: row.status === QUOTATION_STATUS.DRAFT && !row.contractId,
              ifShow:
                !row.contractId &&
                [QUOTATION_STATUS.DRAFT, QUOTATION_STATUS.APPROVE].includes(
                  row.status,
                ),
            },
            {
              label: '下载报价单',
              type: 'link',
              auth: ['crm:sale-quotation:export'],
              onClick: handleDownloadQuotation.bind(null, row),
              ifShow: [QUOTATION_STATUS.APPROVE, QUOTATION_STATUS.CONVERTED].includes(
                row.status,
              ),
            },
            {
              label: '查看合同',
@@ -253,5 +328,22 @@
        />
      </template>
    </Grid>
    <Modal
      v-model:open="submitModalVisible"
      title="选择审批流程"
      width="400px"
      :confirm-loading="submitLoading"
      @ok="confirmSubmit"
    >
      <Select
        v-model:value="selectedProcessKey"
        placeholder="请选择审批流程"
        class="w-full"
      >
        <Select.Option v-for="item in processList" :key="item.key" :value="item.key">
          {{ item.name }}
        </Select.Option>
      </Select>
    </Modal>
  </Page>
</template>
src/views/crm/saleQuotation/modules/ocr-upload.vue
@@ -52,18 +52,17 @@
    step.value = 1;
    processingTip.value = '正在进行报价单识别,请稍候...';
    const ocrData = await ocrSaleQuotation(blobId);
    // æ£€æŸ¥é”™è¯¯
    if (ocrData.rawText && !ocrData.name && !ocrData.customerName && ocrData.items?.length === 0) {
      // æ‰€æœ‰å­—段为 null,rawText åŒ…含错误提示
    const hasData = !!(
      ocrData.name || ocrData.customerName || ocrData.quotationTime
      || ocrData.validUntil || ocrData.taxRate != null || ocrData.discountPercent != null
      || ocrData.remark || ocrData.items?.length
    );
    if (ocrData.rawText && !hasData) {
      processingTip.value = '';
      step.value = 0;
      message.error(ocrData.rawText);
      return;
    }
    // æ£€æŸ¥æ˜¯å¦å…¨éƒ¨ä¸ºç©º
    const hasData = ocrData.name || ocrData.customerName || ocrData.quotationTime
      || ocrData.validUntil || ocrData.taxRate || ocrData.discountPercent
      || ocrData.remark || (ocrData.items && ocrData.items.length > 0);
    if (!hasData) {
      processingTip.value = '';
      step.value = 0;
src/views/erp/finance/reconciliation/data.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,122 @@
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { CrmFinanceReportApi } from '#/api/crm/financeReport';
/** é”€å”®å¯¹è´¦-订单明细列 */
export function useOrderColumns(): VxeTableGridOptions<CrmFinanceReportApi.CustomerStatementOrderRow>['columns'] {
  return [
    { field: 'saleOrderNo', title: '销售订单号', minWidth: 160 },
    { field: 'orderTime', title: '下单时间', width: 160, formatter: 'formatDate' },
    {
      field: 'orderAmount',
      title: '订单金额',
      width: 130,
      align: 'right',
      formatter: ({ cellValue }) => formatAmount(cellValue),
    },
    {
      field: 'shippedAmount',
      title: '出库金额',
      width: 130,
      align: 'right',
      formatter: ({ cellValue }) => formatAmount(cellValue),
    },
    {
      field: 'invoicedAmount',
      title: '开票金额',
      width: 130,
      align: 'right',
      formatter: ({ cellValue }) => formatAmount(cellValue),
    },
    {
      field: 'collectedAmount',
      title: '回款金额',
      width: 130,
      align: 'right',
      formatter: ({ cellValue }) => formatAmount(cellValue),
    },
    {
      field: 'shippedNotInvoicedAmount',
      title: '已发货未开票',
      width: 140,
      align: 'right',
      formatter: ({ cellValue }) => formatAmount(cellValue),
    },
    {
      field: 'outstandingAmount',
      title: '未核销应收',
      width: 130,
      align: 'right',
      formatter: ({ cellValue }) => formatAmount(cellValue),
    },
  ];
}
/** é”€å”®å¯¹è´¦-已发货未开票列 */
export function useShippedColumns(): VxeTableGridOptions<CrmFinanceReportApi.ShippedNotInvoiced>['columns'] {
  return [
    { field: 'saleOutCode', title: '销售出库单号', minWidth: 160 },
    { field: 'salesDate', title: '出库日期', width: 160, formatter: 'formatDate' },
    { field: 'saleOrderNo', title: '销售订单号', minWidth: 150 },
    { field: 'customerName', title: '客户名称', minWidth: 160 },
    {
      field: 'outAmount',
      title: '出库金额',
      width: 130,
      align: 'right',
      formatter: ({ cellValue }) => formatAmount(cellValue),
    },
    {
      field: 'invoicedAmount',
      title: '已开票金额',
      width: 130,
      align: 'right',
      formatter: ({ cellValue }) => formatAmount(cellValue),
    },
    {
      field: 'remainInvoiceAmount',
      title: '未开票金额',
      width: 130,
      align: 'right',
      formatter: ({ cellValue }) => formatAmount(cellValue),
    },
  ];
}
/** æ ¸é”€æ˜Žç»†åˆ— */
export function useWriteOffColumns(): VxeTableGridOptions<CrmFinanceReportApi.UnwrittenOff>['columns'] {
  return [
    { field: 'invoiceNo', title: '发票号', minWidth: 150 },
    { field: 'taxInvoiceNo', title: '税务发票号', minWidth: 150 },
    { field: 'customerName', title: '客户名称', minWidth: 160 },
    { field: 'invoiceTime', title: '开票时间', width: 160, formatter: 'formatDate' },
    {
      field: 'invoiceAmount',
      title: '开票金额',
      width: 130,
      align: 'right',
      formatter: ({ cellValue }) => formatAmount(cellValue),
    },
    {
      field: 'collectedAmount',
      title: '已回款金额',
      width: 130,
      align: 'right',
      formatter: ({ cellValue }) => formatAmount(cellValue),
    },
    {
      field: 'remainAmount',
      title: '未核销金额',
      width: 130,
      align: 'right',
      formatter: ({ cellValue }) => formatAmount(cellValue),
    },
    { field: 'agingDays', title: '账龄(天)', width: 100, align: 'right' },
    { field: 'agingBucket', title: '账龄区间', width: 110 },
  ];
}
/** é‡‘额格式化 */
export function formatAmount(value?: null | number | string) {
  const num = Number(value ?? 0);
  return Number.isNaN(num) ? '0.00' : num.toFixed(2);
}
src/views/erp/finance/reconciliation/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,119 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { CrmFinanceReportApi } from '#/api/crm/financeReport';
import { ref } from 'vue';
import { Page } from '@vben/common-ui';
import { Button, Card, message, Tabs } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import {
  getCustomerStatement,
  getShippedNotInvoicedList,
} from '#/api/crm/financeReport';
import CrmCustomerSelect from '#/components/crm-customer-select.vue';
import {
  formatAmount,
  useOrderColumns,
  useShippedColumns,
} from './data';
defineOptions({ name: 'FinSaleReconciliation' });
const customerId = ref<number>();
const loading = ref(false);
const activeTab = ref('statement');
const statement = ref<CrmFinanceReportApi.CustomerStatement>();
const [OrderGrid, orderGridApi] = useVbenVxeGrid({
  gridOptions: {
    columns: useOrderColumns(),
    height: 'auto',
    keepSource: true,
    pagerConfig: { enabled: false },
    rowConfig: { keyField: 'saleOrderId', isHover: true },
    toolbarConfig: { enabled: false },
  } as VxeTableGridOptions<CrmFinanceReportApi.CustomerStatementOrderRow>,
});
const [ShippedGrid, shippedGridApi] = useVbenVxeGrid({
  gridOptions: {
    columns: useShippedColumns(),
    height: 'auto',
    keepSource: true,
    pagerConfig: { enabled: false },
    rowConfig: { keyField: 'saleOutId', isHover: true },
    toolbarConfig: { enabled: false },
  } as VxeTableGridOptions<CrmFinanceReportApi.ShippedNotInvoiced>,
});
/** æŸ¥è¯¢å¯¹è´¦æ•°æ® */
async function handleQuery() {
  loading.value = true;
  try {
    if (activeTab.value === 'statement') {
      if (!customerId.value) {
        message.warning('请先选择客户');
        return;
      }
      statement.value = await getCustomerStatement({
        customerId: customerId.value,
      });
      orderGridApi.setGridOptions({ data: statement.value?.orders ?? [] });
    } else {
      const list = await getShippedNotInvoicedList(customerId.value);
      shippedGridApi.setGridOptions({ data: list });
    }
  } finally {
    loading.value = false;
  }
}
/** é¡µç­¾åˆ‡æ¢æ—¶æ¸…空另一页签的旧数据,避免看到上一客户的数据 */
function handleTabChange(key: any) {
  activeTab.value = key;
  void handleQuery();
}
</script>
<template>
  <Page auto-content-height>
    <Card class="mb-3">
      <div class="flex flex-wrap items-center gap-3">
        <span class="text-sm">客户</span>
        <CrmCustomerSelect
          v-model="customerId"
          class="w-[280px]"
          placeholder="请选择客户(已发货未开票可不选)"
        />
        <Button type="primary" :loading="loading" @click="handleQuery">
          æŸ¥è¯¢
        </Button>
        <template v-if="statement">
          <span class="text-muted-foreground text-sm">
            è®¢å•总额 {{ formatAmount(statement.orderTotalAmount) }} å…ƒ /
            æœªæ ¸é”€åº”æ”¶
            <span class="text-red-500">
              {{ formatAmount(statement.outstandingAmount) }}
            </span>
            å…ƒ
          </span>
        </template>
      </div>
    </Card>
    <Card size="small">
      <Tabs v-model:active-key="activeTab" @change="handleTabChange">
        <Tabs.TabPane key="statement" tab="客户对账">
          <OrderGrid v-if="statement" table-title="客户对账明细" />
        </Tabs.TabPane>
        <Tabs.TabPane key="shipped" tab="已发货未开票">
          <ShippedGrid table-title="已发货未开票明细" />
        </Tabs.TabPane>
      </Tabs>
    </Card>
  </Page>
</template>
src/views/erp/finance/statistics/index.vue
@@ -9,6 +9,10 @@
import { Button, Card, DatePicker, Col, Row, Spin, Tag } from 'ant-design-vue';
import {
  getShippedNotInvoicedList,
  getUnwrittenOffList,
} from '#/api/crm/financeReport';
import {
  getStatisticsOverview,
  getStatisticsTrend,
} from '#/api/erp/finance/statistics';
@@ -21,6 +25,23 @@
const loading = ref(false);
const overview = ref<FinStatisticsApi.StatisticsOverview>();
const trendList = ref<FinStatisticsApi.StatisticsTrend[]>([]);
/** åº”收风险:已发货未开票、未核销应收 */
const shippedNotInvoicedAmount = ref(0);
const unwrittenOffAmount = ref(0);
const receivableRiskCards = computed(() => [
  {
    title: '已发货未开票',
    value: shippedNotInvoicedAmount.value,
    color: '#f59e0b',
    tip: '销售已出库但尚未开票的金额合计',
  },
  {
    title: '未核销应收',
    value: unwrittenOffAmount.value,
    color: '#d03050',
    tip: '审批通过开票中尚未回款的金额合计',
  },
]);
/** æŸ¥è¯¢æ¡ä»¶ï¼šé»˜è®¤æœ¬å¹´ 1 æœˆè‡³å½“前月 */
const startMonth = ref<string>();
@@ -131,12 +152,23 @@
      startMonth: startMonth.value,
      endMonth: endMonth.value,
    };
    const [overviewData, trendData] = await Promise.all([
      getStatisticsOverview(params),
      getStatisticsTrend(params),
    ]);
    const [overviewData, trendData, shippedList, unwrittenList] =
      await Promise.all([
        getStatisticsOverview(params),
        getStatisticsTrend(params),
        getShippedNotInvoicedList(),
        getUnwrittenOffList(),
      ]);
    overview.value = overviewData;
    trendList.value = trendData ?? [];
    shippedNotInvoicedAmount.value = (shippedList ?? []).reduce(
      (sum, row) => sum + Number(row.remainInvoiceAmount ?? 0),
      0,
    );
    unwrittenOffAmount.value = (unwrittenList ?? []).reduce(
      (sum, row) => sum + Number(row.remainAmount ?? 0),
      0,
    );
  } finally {
    loading.value = false;
  }
@@ -264,6 +296,28 @@
          </Col>
        </Row>
        <!-- åº”收风险 -->
        <div>
          <div class="mb-2 text-muted-foreground">应收风险(时点数)</div>
          <div class="grid grid-cols-2 gap-4 max-sm:grid-cols-1">
            <Card
              v-for="item in receivableRiskCards"
              :key="item.title"
              :body-style="{ padding: '12px 16px 16px' }"
              class="h-full shadow-sm"
              :style="{ borderTop: `3px solid ${item.color}` }"
            >
              <div class="mb-2 text-muted-foreground">{{ item.title }}</div>
              <div class="text-2xl font-bold" :style="{ color: item.color }">
                {{ formatAmount(item.value) }}
              </div>
              <div class="mt-2 text-xs text-muted-foreground">
                {{ item.tip }}
              </div>
            </Card>
          </div>
        </div>
        <!-- æœˆåº¦è¶‹åŠ¿ -->
        <TrendChart :list="trendList" :loading="loading" />
      </div>
src/views/erp/finance/writeoff/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,92 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { CrmFinanceReportApi } from '#/api/crm/financeReport';
import { computed, onMounted, ref } from 'vue';
import { Page } from '@vben/common-ui';
import { Button, Card, message } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getUnwrittenOffList } from '#/api/crm/financeReport';
import CrmCustomerSelect from '#/components/crm-customer-select.vue';
import { formatAmount, useWriteOffColumns } from '../reconciliation/data';
defineOptions({ name: 'FinWriteOff' });
const customerId = ref<number>();
const loading = ref(false);
const list = ref<CrmFinanceReportApi.UnwrittenOff[]>([]);
/** æ±‡æ€» */
const summary = computed(() => {
  const invoiceAmount = list.value.reduce(
    (sum, row) => sum + Number(row.invoiceAmount ?? 0),
    0,
  );
  const collectedAmount = list.value.reduce(
    (sum, row) => sum + Number(row.collectedAmount ?? 0),
    0,
  );
  const remainAmount = list.value.reduce(
    (sum, row) => sum + Number(row.remainAmount ?? 0),
    0,
  );
  return { invoiceAmount, collectedAmount, remainAmount, count: list.value.length };
});
const [Grid, gridApi] = useVbenVxeGrid({
  gridOptions: {
    columns: useWriteOffColumns(),
    height: 'auto',
    keepSource: true,
    pagerConfig: { enabled: false },
    rowConfig: { keyField: 'invoiceId', isHover: true },
    toolbarConfig: { enabled: false },
  } as VxeTableGridOptions<CrmFinanceReportApi.UnwrittenOff>,
});
/** æŸ¥è¯¢æœªæ ¸é”€åº”æ”¶ */
async function handleQuery() {
  loading.value = true;
  try {
    list.value = await getUnwrittenOffList(customerId.value);
    gridApi.setGridOptions({ data: list.value });
    if (list.value.length === 0) {
      message.info('当前筛选条件下没有未核销的应收');
    }
  } finally {
    loading.value = false;
  }
}
onMounted(handleQuery);
</script>
<template>
  <Page auto-content-height>
    <Card class="mb-3">
      <div class="flex flex-wrap items-center gap-3">
        <span class="text-sm">客户</span>
        <CrmCustomerSelect
          v-model="customerId"
          class="w-[280px]"
          placeholder="不选则统计全部客户"
        />
        <Button type="primary" :loading="loading" @click="handleQuery">
          æŸ¥è¯¢
        </Button>
        <span class="text-muted-foreground text-sm">
          å…± {{ summary.count }} å¼ å‘票,开票
          {{ formatAmount(summary.invoiceAmount) }} å…ƒï¼Œå·²å›žæ¬¾
          {{ formatAmount(summary.collectedAmount) }} å…ƒï¼Œæœªæ ¸é”€
          <span class="text-red-500">{{ formatAmount(summary.remainAmount) }}</span>
          å…ƒ
        </span>
      </div>
    </Card>
    <Grid table-title="未核销应收明细" />
  </Page>
</template>
src/views/erp/purchase/invoice/data.ts
@@ -8,6 +8,7 @@
import { markRaw } from 'vue';
import ItemReceiptSelect from './modules/item-receipt-select.vue';
import PurchaseOrderSelect from './modules/purchase-order-select.vue';
/** è¡¨å•类型 */
@@ -52,6 +53,7 @@
            | undefined,
        ) => {
          form.setFieldValue('supplierName', order?.supplierName);
          form.setFieldValue('itemReceiptId', undefined);
          if (orderRemainInvoicePrice) {
            orderRemainInvoicePrice.value = order?.remainInvoicePrice;
          }
@@ -68,10 +70,19 @@
      },
    },
    {
      fieldName: 'arrivalNoticeId',
      fieldName: 'itemReceiptId',
      label: '入库单',
      component: 'Input',
      componentProps: { placeholder: '请输入或关联入库单号' },
      component: markRaw(ItemReceiptSelect),
      help: '先选采购订单,再从该订单已入库、未取消的入库单中选择;不选也可保存,仅表示本次来票不绑定具体入库单',
      dependencies: {
        triggerFields: ['id'],
        disabled: (values) => !!values.id,
      },
      componentProps: (values) => ({
        purchaseOrderId: values.purchaseOrderId,
        id: values.id,
        placeholder: '请选择入库单',
      }),
    },
    {
      fieldName: 'invoiceNo',
@@ -217,6 +228,11 @@
      slots: { default: 'orderNo' },
    },
    {
      title: '入库单号',
      field: 'itemReceiptCode',
      minWidth: 160,
    },
    {
      title: '发票号码',
      field: 'invoiceNo',
      minWidth: 150,
src/views/erp/purchase/invoice/modules/item-receipt-select.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,186 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { ErpPurchaseInvoiceApi } from '#/api/erp/purchase/invoice';
import { computed, ref, watch } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { Input, message, Modal } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getItemReceiptList } from '#/api/erp/purchase/invoice';
defineOptions({ name: 'ErpPurchaseInvoiceItemReceiptSelect' });
const props = withDefaults(
  defineProps<{
    disabled?: boolean;
    id?: number;
    modelValue?: number;
    placeholder?: string;
    purchaseOrderId?: number;
  }>(),
  {
    disabled: false,
    modelValue: undefined,
    placeholder: '请选择入库单',
    purchaseOrderId: undefined,
  },
);
const emit = defineEmits<{
  change: [receipt: ErpPurchaseInvoiceApi.ItemReceipt | undefined];
  'update:modelValue': [value: number | undefined];
}>();
const open = ref(false);
const receipt = ref<ErpPurchaseInvoiceApi.ItemReceipt>();
const displayCode = computed(() => receipt.value?.code ?? '');
/** é‡‡è´­è®¢å•变化后清空已选入库单 */
watch(
  () => props.purchaseOrderId,
  () => {
    receipt.value = undefined;
  },
);
watch(
  () => props.modelValue,
  async (value) => {
    if (!value) {
      receipt.value = undefined;
      return;
    }
    if (receipt.value?.id === value) {
      return;
    }
    if (!props.purchaseOrderId) {
      return;
    }
    const list = await getItemReceiptList(props.purchaseOrderId, props.id);
    receipt.value = list.find((item) => item.id === value);
  },
  { immediate: true },
);
const [Grid, gridApi] = useVbenVxeGrid({
  gridOptions: {
    columns: [
      { type: 'radio', width: 50, fixed: 'left' },
      { field: 'code', title: '入库单号', width: 200, fixed: 'left' },
      { field: 'name', title: '入库单名称', minWidth: 160 },
      {
        field: 'receiptDate',
        title: '入库日期',
        width: 160,
        formatter: 'formatDate',
      },
      {
        field: 'totalPrice',
        title: '入库金额',
        formatter: 'formatAmount2',
        minWidth: 120,
      },
      {
        field: 'invoicedPrice',
        title: '已来票金额',
        formatter: 'formatAmount2',
        minWidth: 120,
      },
      {
        field: 'remainInvoicePrice',
        title: '可来票金额',
        formatter: 'formatAmount2',
        minWidth: 120,
      },
    ],
    height: 480,
    keepSource: true,
    proxyConfig: {
      ajax: {
        query: async () => {
          if (!props.purchaseOrderId) {
            return { list: [], total: 0 };
          }
          const list = await getItemReceiptList(
            props.purchaseOrderId,
            props.id,
          );
          return { list, total: list.length };
        },
      },
    },
    rowConfig: {
      keyField: 'id',
      isHover: true,
    },
    radioConfig: {
      trigger: 'row',
      highlight: true,
    },
    toolbarConfig: {
      refresh: false,
      search: false,
    },
  } as VxeTableGridOptions<ErpPurchaseInvoiceApi.ItemReceipt>,
  gridEvents: {
    radioChange: ({ row }: { row: ErpPurchaseInvoiceApi.ItemReceipt }) => {
      receipt.value = row;
    },
  },
});
function handleOpen() {
  if (props.disabled || !props.purchaseOrderId) {
    if (!props.purchaseOrderId) {
      message.warning('请先选择采购订单,再选择对应的入库单');
    }
    return;
  }
  open.value = true;
  gridApi.query();
}
function handleOk() {
  if (!receipt.value?.id) {
    message.warning('请选择入库单');
    return;
  }
  emit('update:modelValue', receipt.value.id);
  emit('change', receipt.value);
  open.value = false;
}
</script>
<template>
  <div>
    <Input
      readonly
      :disabled="disabled || !purchaseOrderId"
      :placeholder="purchaseOrderId ? placeholder : '请先选择采购订单'"
      :value="displayCode"
      @click="handleOpen"
    >
      <template #suffix>
        <IconifyIcon
          class="size-4"
          icon="lucide:search"
          :style="{
            cursor:
              disabled || !purchaseOrderId ? 'not-allowed' : 'pointer',
          }"
        />
      </template>
    </Input>
    <Modal
      v-model:open="open"
      title="选择入库单(按所选采购订单过滤)"
      width="75%"
      @cancel.stop="open = false"
      @ok.stop="handleOk"
    >
      <Grid table-title="入库单列表" />
    </Modal>
  </div>
</template>
src/views/erp/sale/order/data.ts
@@ -1,6 +1,7 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { DICT_TYPE, getDictOptions } from '@vben/constants';
import { erpPriceInputFormatter } from '@vben/utils';
import { z } from '#/adapter/form';
@@ -107,6 +108,26 @@
        allowClear: true,
      },
      rules: 'required',
    },
    {
      fieldName: 'saleType',
      label: '销售类型',
      component: 'Select',
      componentProps: {
        placeholder: '请选择销售类型',
        allowClear: true,
        options: getDictOptions(DICT_TYPE.ERP_SALE_TYPE, 'number'),
      },
    },
    {
      fieldName: 'contractNo',
      label: '合同号',
      component: 'Input',
      componentProps: {
        placeholder: '可手工填写合同号(关联 CRM åˆåŒæ—¶è‡ªåŠ¨å¸¦å‡ºï¼‰',
        maxlength: 64,
        allowClear: true,
      },
    },
    {
      fieldName: 'saleUserId',
@@ -388,6 +409,25 @@
      },
    },
    {
      fieldName: 'contractNo',
      label: '合同号',
      component: 'Input',
      componentProps: {
        placeholder: '请输入合同号',
        allowClear: true,
      },
    },
    {
      fieldName: 'saleType',
      label: '销售类型',
      component: 'Select',
      componentProps: {
        options: getDictOptions(DICT_TYPE.ERP_SALE_TYPE, 'number'),
        placeholder: '请选择销售类型',
        allowClear: true,
      },
    },
    {
      fieldName: 'creator',
      label: '创建人',
      component: 'ApiSelect',
@@ -485,11 +525,32 @@
    },
    {
      field: 'contractNo',
      title: '关联合同',
      title: '合同号',
      minWidth: 150,
      slots: { default: 'contractNo' },
    },
    {
      field: 'saleType',
      title: '销售类型',
      minWidth: 100,
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.ERP_SALE_TYPE },
      },
    },
    {
      field: 'productCodes',
      title: '产品编号',
      minWidth: 140,
      showOverflow: 'tooltip',
    },
    {
      field: 'producedCount',
      title: '生产数量',
      minWidth: 110,
      formatter: 'formatAmount3',
    },
    {
      field: 'orderTime',
      title: '订单时间',
      width: 160,
src/views/erp/sale/order/index.vue
@@ -328,7 +328,7 @@
        <a v-if="row.contractId" class="text-primary cursor-pointer" @click="handleViewContract(row)">
          {{ row.contractNo }}
        </a>
        <span v-else>-</span>
        <span v-else>{{ row.contractNo || '-' }}</span>
      </template>
      <template #receiptDate="{ row }">
        <Tooltip
src/views/hrm/employee/modules/detail.vue
@@ -94,8 +94,8 @@
      <Tabs.TabPane key="emergencyContact" tab="紧急联系人">
        <EmergencyContactList form-type="detail" :employee-id="employee.id" />
      </Tabs.TabPane>
      <Tabs.TabPane key="socialSecurity" tab="社保公积金档案">
        <SocialSecurityList form-type="detail" :employee-id="employee.id" :user-id="employee.userId" />
      <Tabs.TabPane key="socialSecurity" tab="部门社保公积金档案">
        <SocialSecurityList :dept-id="employee.deptId" />
      </Tabs.TabPane>
      <Tabs.TabPane key="contract" tab="员工合同">
        <ContractList :employee-id="employee.id" />
src/views/hrm/employee/modules/social-security-form.vue
ÎļþÒÑɾ³ý
src/views/hrm/employee/modules/social-security-list.vue
@@ -1,94 +1,36 @@
<script lang="ts" setup>
import type { FormType } from './form.vue';
import type { HrmEmployeeSocialSecurityApi } from '#/api/hrm/salary/employee-social-security';
import { ref, computed, watch } from 'vue';
import { ref, watch } from 'vue';
import { useVbenModal } from '#/packages/effects/common-ui/src';
import { Alert, Table, Tag } from 'ant-design-vue';
import { message, Table, Tag, Button } from 'ant-design-vue';
import { ACTION_ICON, TableAction } from '#/adapter/vxe-table';
import {
  createEmployeeSocialSecurity,
  deleteEmployeeSocialSecurity,
  getEmployeeSocialSecurityListByUser,
  updateEmployeeSocialSecurity,
} from '#/api/hrm/salary/employee-social-security';
import { getSocialSecuritySchemeList } from '#/api/hrm/salary/social-security-scheme';
import { $t } from '#/locales';
import SocialSecurityForm from './social-security-form.vue';
import { getEmployeeSocialSecurityListByDept } from '#/api/hrm/salary/employee-social-security';
const props = defineProps<{
  employeeId?: number;
  userId?: number;
  formType?: FormType;
  deptId?: number;
}>();
const emit = defineEmits(['refresh']);
const list = ref<HrmEmployeeSocialSecurityApi.EmployeeSocialSecurity[]>([]);
const schemeList = ref<any[]>([]);
const loading = ref(false);
const [FormModal, formModalApi] = useVbenModal({
  connectedComponent: SocialSecurityForm,
  destroyOnClose: true,
});
/** åˆ·æ–°åˆ—表 */
/** åŠ è½½å‘˜å·¥æ‰€åœ¨éƒ¨é—¨çš„ç¤¾ä¿å…¬ç§¯é‡‘æ¡£æ¡ˆï¼ˆæŒ‰éƒ¨é—¨å…±ç”¨ï¼Œæ­¤å¤„åªè¯»ï¼‰ */
async function handleRefresh() {
  if (!props.userId) return;
  if (!props.deptId) {
    list.value = [];
    return;
  }
  loading.value = true;
  try {
    list.value = await getEmployeeSocialSecurityListByUser(props.userId);
    list.value = await getEmployeeSocialSecurityListByDept(props.deptId);
  } finally {
    loading.value = false;
  }
  emit('refresh');
}
/** æ–°å¢žæ¡£æ¡ˆ */
function handleCreate() {
  formModalApi.setData({
    formType: 'create',
    userId: props.userId,
    schemeList: schemeList.value,
  }).open();
}
/** ç¼–辑档案 */
function handleEdit(row: HrmEmployeeSocialSecurityApi.EmployeeSocialSecurity) {
  formModalApi.setData({
    formType: 'update',
    id: row.id,
    userId: props.userId,
    schemeList: schemeList.value,
  }).open();
}
/** åˆ é™¤æ¡£æ¡ˆ */
async function handleDelete(row: HrmEmployeeSocialSecurityApi.EmployeeSocialSecurity) {
  await deleteEmployeeSocialSecurity(row.id!);
  message.success($t('ui.actionMessage.deleteSuccess', ['社保公积金档案']));
  handleRefresh();
}
/** èŽ·å–æ–¹æ¡ˆåˆ—è¡¨ */
async function getSchemeList() {
  schemeList.value = await getSocialSecuritySchemeList({ status: 0 });
}
/** ç›‘听 userId å˜åŒ– */
watch(
  () => props.userId,
  (val) => {
    if (val) {
      handleRefresh();
      getSchemeList();
    }
  },
  () => props.deptId,
  () => handleRefresh(),
  { immediate: true },
);
@@ -103,29 +45,18 @@
    width: 80,
    key: 'status',
  },
  {
    title: '操作',
    key: 'action',
    width: 150,
    fixed: 'right',
  },
];
const isDetail = computed(() => props.formType === 'detail');
</script>
<template>
  <div class="social-security-list">
    <FormModal @success="handleRefresh" />
    <Alert
      message="社保公积金档案按部门维护,部门下所有员工共用最新生效的一份;如需调整,请到「薪酬管理 - éƒ¨é—¨ç¤¾ä¿å…¬ç§¯é‡‘档案」修改。"
      type="info"
      show-icon
      style="margin-bottom: 12px"
    />
    <!-- å·¥å…·æ  -->
    <div v-if="!isDetail" class="mb-3">
      <Button type="primary" @click="handleCreate">
        æ–°å¢žç¤¾ä¿å…¬ç§¯é‡‘档案
      </Button>
    </div>
    <!-- è¡¨æ ¼ -->
    <Table
      :columns="columns"
      :data-source="list"
@@ -140,28 +71,6 @@
            {{ record.status === 0 ? '启用' : '禁用' }}
          </Tag>
        </template>
        <template v-else-if="column.key === 'action' && !isDetail">
          <TableAction
            :actions="[
              {
                label: $t('common.edit'),
                type: 'link',
                icon: ACTION_ICON.EDIT,
                onClick: handleEdit.bind(null, record),
              },
              {
                label: $t('common.delete'),
                type: 'link',
                danger: true,
                icon: ACTION_ICON.DELETE,
                popConfirm: {
                  title: $t('ui.actionMessage.deleteConfirm', ['社保公积金档案']),
                  confirm: handleDelete.bind(null, record),
                },
              },
            ]"
          />
        </template>
      </template>
    </Table>
  </div>
@@ -171,4 +80,4 @@
.social-security-list {
  padding: 0;
}
</style>
</style>
src/views/hrm/resignation/index.vue
@@ -140,14 +140,14 @@
              type: 'link',
              icon: ACTION_ICON.EDIT,
              auth: ['hrm:resignation:update'],
              ifShow: row.status === 0,
              ifShow: row.status === 0 || row.status === 30,
              onClick: handleEdit.bind(null, row),
            },
            {
              label: '提交',
              type: 'link',
              auth: ['hrm:resignation:submit'],
              ifShow: row.status === 0,
              ifShow: row.status === 0 || row.status === 30,
              onClick: handleSubmit.bind(null, row),
            },
            {
@@ -185,4 +185,4 @@
      </template>
    </Grid>
  </Page>
</template>
</template>
src/views/hrm/salary/calculation/data.ts
@@ -81,6 +81,14 @@
    { field: 'mealAllowance', title: '餐补', width: 80, formatter: 'formatAmount2' },
    { field: 'transportAllowance', title: '交通补贴', width: 80, formatter: 'formatAmount2' },
    { field: 'otherAllowance', title: '其他补贴', width: 80, formatter: 'formatAmount2' },
    {
      field: 'workWage',
      title: '报工工资',
      width: 130,
      slots: {
        default: 'workWage',
      },
    },
    { field: 'socialSecurityDeduction', title: '社保扣款', width: 100, formatter: 'formatAmount2' },
    { field: 'housingFundDeduction', title: '公积金扣款', width: 100, formatter: 'formatAmount2' },
    { field: 'taxDeduction', title: '个税扣款', width: 100, formatter: 'formatAmount2' },
@@ -121,6 +129,7 @@
    { title: '餐补', dataIndex: 'mealAllowance', width: 100 },
    { title: '交通补贴', dataIndex: 'transportAllowance', width: 100 },
    { title: '其他补贴', dataIndex: 'otherAllowance', width: 100 },
    { title: '报工工资', dataIndex: 'workWage', width: 140 },
    { title: '社保扣款', dataIndex: 'socialSecurityDeduction', width: 110 },
    { title: '公积金扣款', dataIndex: 'housingFundDeduction', width: 110 },
    { title: '个税扣款', dataIndex: 'taxDeduction', width: 110 },
src/views/hrm/salary/calculation/index.vue
@@ -2,23 +2,23 @@
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { HrmSalaryCalculationApi } from '#/api/hrm/salary/calculation';
import { ref } from 'vue';
import { computed, ref, watch } from 'vue';
import { Page } from '#/packages/effects/common-ui/src';
import { downloadFileFromBlobPart } from '#/packages/utils/src';
import { message, Modal, Form, FormItem, DatePicker, TreeSelect, Table, InputNumber, Alert, Tag } from 'ant-design-vue';
import { Alert, Button, DatePicker, Form, FormItem, InputNumber, message, Modal, Table, Tag, TreeSelect } from 'ant-design-vue';
import { DICT_TYPE } from '#/packages/constants/src';
import { getDictLabel } from '#/packages/effects/hooks/src';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
  calculateSalary,
  confirmSalary,
  deleteSalaryCalculation,
  exportSalaryCalculation,
  getSalaryCalculationPage,
  getWorkWageDetail,
  previewSalaryCalculation,
  revokeSalaryCalculation,
  saveSalaryCalculation,
@@ -37,14 +37,34 @@
const previewLoading = ref(false);
const previewData = ref<HrmSalaryCalculationApi.SalaryCalculation[]>([]);
const submitLoading = ref(false);
const calculateLoading = ref(false);
/** å·²å®Œæˆé¢„览的条件(周期+部门);为空表示还没预览或条件已变化 */
const previewSignature = ref('');
const canSubmit = computed(
  () => previewSignature.value !== '' && previewData.value.length > 0,
);
function buildQuerySignature() {
  return `${calculatePeriod.value ?? ''}|${calculateDeptId.value ?? ''}`;
}
/** æ¸…空预览结果,强制重新预览后才能保存 */
function invalidatePreview() {
  previewData.value = [];
  previewSignature.value = '';
}
watch([calculatePeriod, calculateDeptId], () => {
  if (buildQuerySignature() !== previewSignature.value) {
    invalidatePreview();
  }
});
// æ‰“开核算弹窗
async function openCalculateModal() {
  const formValues = await gridApi.formApi.getValues();
  calculatePeriod.value = formValues.period;
  calculateDeptId.value = formValues.deptId;
  previewData.value = [];
  invalidatePreview();
  // åŠ è½½éƒ¨é—¨æ ‘
  const data = await getDeptList();
  deptTreeData.value = handleTree(data);
@@ -56,43 +76,122 @@
  gridApi.query();
}
/** æ‰§è¡Œè–ªé…¬æ ¸ç®—(自动计算并保存) */
async function handleCalculate() {
  if (!calculatePeriod.value) {
    message.warning('请选择核算周期');
    return;
  }
  calculateLoading.value = true;
  try {
    await calculateSalary({
      period: calculatePeriod.value,
      deptId: calculateDeptId.value,
    });
    message.success('薪酬核算执行成功');
    calculateVisible.value = false;
    handleRefresh();
  } finally {
    calculateLoading.value = false;
  }
}
/** é¢„览核算数据 */
/** é¢„览核算数据:保存前必须先完成预览 */
async function handlePreview() {
  if (!calculatePeriod.value) {
    message.warning('请选择核算周期');
    message.warning('请先选择核算周期,再预览核算数据');
    return;
  }
  const signature = buildQuerySignature();
  previewLoading.value = true;
  try {
    previewData.value = await previewSalaryCalculation({
    const list = await previewSalaryCalculation({
      period: calculatePeriod.value,
      deptId: calculateDeptId.value,
    });
    // é¢„览期间条件被修改时丢弃本次结果,避免用旧条件的数据保存
    if (signature !== buildQuerySignature()) {
      return;
    }
    previewData.value = list ?? [];
    if (previewData.value.length === 0) {
      message.info('该条件下没有需要核算的员工');
      previewSignature.value = '';
      message.info('该周期/部门下没有需要核算的员工,请调整条件后重新预览');
    } else {
      previewSignature.value = signature;
      message.success(`预览完成,共 ${previewData.value.length} äººï¼Œè¯·æ ¸å¯¹åŽä¿å­˜`);
    }
  } finally {
    previewLoading.value = false;
  }
}
// ==================== æŠ¥å·¥å·¥èµ„明细 ====================
const wageDetailVisible = ref(false);
const wageDetailLoading = ref(false);
const wageDetailList = ref<HrmSalaryCalculationApi.WorkWageDetail[]>([]);
/** é»˜è®¤å±•开所有报工行,直接展示自定义工资字段明细 */
const wageDetailExpandedKeys = ref<Array<number | string>>([]);
const wageDetailTitle = ref('');
const wageDetailPeriod = ref('');
/** å½“前核算行上的「报工工资」,用于和明细合计比对 */
const wageDetailExpected = ref(0);
/** è®¡ç®—角色文案:与工序工资字段的计算角色保持一致 */
const WAGE_ROLE_LABELS: Record<number, string> = {
  1: '系数',
  2: '加减金额',
  3: '计价数量',
  4: '仅记录',
};
const WAGE_ROLE_COLORS: Record<number, string> = {
  1: 'blue',
  2: 'orange',
  3: 'green',
  4: 'default',
};
const wageDetailColumns = [
  { title: '报工单号', dataIndex: 'feedbackNo', width: 170 },
  { title: '工序', dataIndex: 'processName', width: 150 },
  { title: '报工时间', dataIndex: 'feedbackTime', width: 160 },
  { title: '报工数量', dataIndex: 'feedbackQuantity', width: 100 },
  { title: '合格数量', dataIndex: 'qualifiedQuantity', width: 100 },
  { title: '主工资', dataIndex: 'mainWage', width: 100 },
  { title: '辅助工资', dataIndex: 'auxWage', width: 100 },
  { title: '字段调整', dataIndex: 'fieldWage', width: 100 },
  { title: '质量调整', dataIndex: 'qualityAdjustment', width: 100 },
  { title: '最终工资', dataIndex: 'finalWage', width: 110 },
];
const wageFieldColumns = [
  { title: '字段编码', dataIndex: 'code', width: 140 },
  { title: '字段名称', dataIndex: 'name', width: 160 },
  { title: '计算角色', dataIndex: 'role', width: 110 },
  { title: '字段值', dataIndex: 'value', width: 120 },
  { title: '单价', dataIndex: 'unitPrice', width: 110 },
  { title: '计入金额', dataIndex: 'amount', width: 110 },
];
/** é‡‘额展示,空值显示 0.00 */
function fmtAmount(value?: null | number) {
  return Number(value ?? 0).toFixed(2);
}
/** æ˜Žç»†åˆè®¡ï¼ˆå„报工单最终工资之和) */
const wageDetailTotal = computed(() =>
  wageDetailList.value.reduce((sum, item) => sum + Number(item.finalWage ?? 0), 0),
);
/**
 * æ‰“开报工工资明细
 * @param record æ ¸ç®—行(来自预览弹窗或列表)
 */
async function openWageDetail(record: HrmSalaryCalculationApi.SalaryCalculation) {
  const period = record.period || calculatePeriod.value;
  if (!period) {
    message.warning('缺少核算周期,无法查看报工工资明细,请先选择核算周期后重试');
    return;
  }
  if (!record.userId) {
    message.warning('缺少员工信息,无法查看报工工资明细,请刷新列表后重试');
    return;
  }
  wageDetailTitle.value = `${record.userName ?? ''} ${period} æŠ¥å·¥å·¥èµ„明细`;
  wageDetailPeriod.value = period;
  wageDetailExpected.value = Number(record.workWage ?? 0);
  wageDetailList.value = [];
  wageDetailExpandedKeys.value = [];
  wageDetailVisible.value = true;
  wageDetailLoading.value = true;
  try {
    wageDetailList.value = (await getWorkWageDetail({ period, userId: record.userId })) ?? [];
    // é»˜è®¤å±•开全部报工单,字段明细无需再点开
    wageDetailExpandedKeys.value = wageDetailList.value
      .map((item) => item.feedbackId)
      .filter((id): id is number => id !== undefined && id !== null);
  } finally {
    wageDetailLoading.value = false;
  }
}
@@ -103,7 +202,8 @@
    (record.overtimePay || 0) +
    (record.mealAllowance || 0) +
    (record.transportAllowance || 0) +
    (record.otherAllowance || 0);
    (record.otherAllowance || 0) +
    (record.workWage || 0);
  const deduction = (record.socialSecurityDeduction || 0) +
    (record.housingFundDeduction || 0) +
    (record.taxDeduction || 0) +
@@ -124,8 +224,8 @@
    message.warning('请选择核算周期');
    return;
  }
  if (previewData.value.length === 0) {
    message.warning('请先预览核算数据');
  if (!canSubmit.value) {
    message.warning('请先点击“预览核算”生成数据,核对无误后再保存');
    return;
  }
  submitLoading.value = true;
@@ -145,7 +245,7 @@
/** å–消弹窗 */
function handleCancel() {
  calculateVisible.value = false;
  previewData.value = [];
  invalidatePreview();
}
/** ç¡®è®¤è–ªé…¬æ ¸ç®— */
@@ -269,12 +369,12 @@
    <Modal
      v-model:open="calculateVisible"
      title="薪酬核算"
      width="1200px"
      width="1800px"
      :footer="null"
      :maskClosable="false"
    >
      <Alert
        message="两种核算方式:1. æ‰§è¡Œæ ¸ç®— - è‡ªåŠ¨è®¡ç®—å¹¶ä¿å­˜ï¼›2. é¢„览数据 - æ‰‹åŠ¨è°ƒæ•´åŽä¿å­˜"
        message="操作步骤:选择核算周期(和部门)→ ç‚¹å‡»â€œé¢„览核算”生成数据 â†’ æ ¸å¯¹å¹¶è°ƒæ•´ â†’ ä¿å­˜æ ¸ç®—。未预览或修改条件后不能保存。"
        type="info"
        show-icon
        style="margin-bottom: 16px"
@@ -303,14 +403,14 @@
          />
        </FormItem>
        <FormItem>
          <a-button type="primary" :loading="calculateLoading" @click="handleCalculate">
            æ‰§è¡Œæ ¸ç®—
          </a-button>
          <Button type="primary" :loading="previewLoading" @click="handlePreview">
            é¢„览核算
          </Button>
        </FormItem>
        <FormItem>
          <a-button :loading="previewLoading" @click="handlePreview">
            é¢„览数据
          </a-button>
        <FormItem v-if="canSubmit">
          <span class="text-xs text-green-600">
            å·²æŒ‰å½“前条件预览 {{ previewData.length }} äººï¼Œå¯è°ƒæ•´åŽä¿å­˜
          </span>
        </FormItem>
      </Form>
@@ -319,7 +419,7 @@
        :columns="usePreviewColumns()"
        :data-source="previewData"
        :pagination="{ pageSize: 20, showSizeChanger: true, showTotal: (t) => `共 ${t} äºº` }"
        :scroll="{ x: 1800, y: 400 }"
        :scroll="{ x: 1920, y: 420 }"
        size="small"
        row-key="userId"
        bordered
@@ -385,6 +485,19 @@
              style="width: 80px"
              @change="(val: number) => updateFieldValue(record, 'otherAllowance', val)"
            />
          </template>
          <template v-else-if="column.dataIndex === 'workWage'">
            <div class="flex items-center gap-1">
              <InputNumber
                v-model:value="record.workWage"
                :min="0"
                :precision="2"
                size="small"
                style="width: 82px"
                @change="(val) => updateFieldValue(record, 'workWage', Number(val ?? 0))"
              />
              <a class="text-xs whitespace-nowrap" @click="openWageDetail(record)">明细</a>
            </div>
          </template>
          <template v-else-if="column.dataIndex === 'socialSecurityDeduction'">
            <InputNumber
@@ -461,12 +574,144 @@
        </template>
      </Table>
      <div class="flex justify-end gap-2" v-if="previewData.length > 0">
        <a-button @click="handleCancel">取消</a-button>
        <a-button type="primary" :loading="submitLoading" @click="handleSubmit">
      <div class="flex items-center justify-end gap-2">
        <span v-if="!canSubmit" class="mr-auto text-xs text-gray-500">
          è¯·å…ˆé€‰æ‹©æ ¸ç®—周期并点击“预览核算”,预览完成后才能保存
        </span>
        <Button @click="handleCancel">取消</Button>
        <Button
          type="primary"
          :loading="submitLoading"
          :disabled="!canSubmit"
          @click="handleSubmit"
        >
          ä¿å­˜æ ¸ç®—
        </a-button>
        </Button>
      </div>
    </Modal>
    <!-- æŠ¥å·¥å·¥èµ„明细弹窗 -->
    <Modal
      v-model:open="wageDetailVisible"
      :title="`报工工资明细 - ${wageDetailTitle}`"
      width="1200px"
      :footer="null"
    >
      <Alert
        type="info"
        show-icon
        class="mb-4"
        :message="`统计口径:核算周期 ${wageDetailPeriod} å†…,该员工名下所有已审核(未审核/已审核)和已完成的报工单工资。金额构成 = ä¸»å·¥èµ„ + è¾…助工资 + è‡ªå®šä¹‰å·¥èµ„字段调整 + è´¨é‡è°ƒæ•´ã€‚`"
      />
      <div class="mb-4 flex flex-wrap items-center gap-x-8 gap-y-2 text-sm">
        <span>
          æŠ¥å·¥å•:
          <span class="font-bold">{{ wageDetailList.length }}</span> æ¡
        </span>
        <span>
          æ˜Žç»†åˆè®¡ï¼š
          <span class="font-bold text-green-600">Â¥{{ fmtAmount(wageDetailTotal) }}</span>
        </span>
        <span>
          æ ¸ç®—中的报工工资:
          <span class="font-bold">Â¥{{ fmtAmount(wageDetailExpected) }}</span>
        </span>
      </div>
      <Alert
        v-if="!wageDetailLoading && Math.abs(wageDetailTotal - wageDetailExpected) > 0.005"
        type="warning"
        show-icon
        class="mb-4"
        :message="`明细合计 Â¥${fmtAmount(wageDetailTotal)} ä¸Žæ ¸ç®—中的报工工资 Â¥${fmtAmount(wageDetailExpected)} ä¸ä¸€è‡´ï¼Œè¯¥å€¼å¯èƒ½å·²äººå·¥è°ƒæ•´ï¼›å¦‚需恢复系统计算值,请重新预览核算。`"
      />
      <Table
        v-model:expanded-row-keys="wageDetailExpandedKeys"
        :columns="wageDetailColumns"
        :data-source="wageDetailList"
        :loading="wageDetailLoading"
        :pagination="{ pageSize: 10, showSizeChanger: true, showTotal: (t) => `共 ${t} æ¡` }"
        :scroll="{ x: 1200 }"
        size="small"
        row-key="feedbackId"
        bordered
      >
        <template #bodyCell="{ column, record }">
          <template v-if="column.dataIndex === 'mainWage'">
            {{ fmtAmount(record.mainWage) }}
          </template>
          <template v-else-if="column.dataIndex === 'auxWage'">
            {{ fmtAmount(record.auxWage) }}
          </template>
          <template v-else-if="column.dataIndex === 'fieldWage'">
            <span :class="Number(record.fieldWage ?? 0) === 0 ? 'text-gray-400' : ''">
              {{ fmtAmount(record.fieldWage) }}
            </span>
          </template>
          <template v-else-if="column.dataIndex === 'qualityAdjustment'">
            <span
              :class="Number(record.qualityAdjustment ?? 0) < 0 ? 'text-red-500' : 'text-gray-500'"
            >
              {{
                Number(record.qualityAdjustment ?? 0) === 0
                  ? '-'
                  : fmtAmount(record.qualityAdjustment)
              }}
            </span>
          </template>
          <template v-else-if="column.dataIndex === 'finalWage'">
            <span class="font-bold text-green-600">{{ fmtAmount(record.finalWage) }}</span>
          </template>
        </template>
        <template #expandedRowRender="{ record }">
          <div class="px-2 py-1">
            <div class="mb-2 text-xs text-gray-500">
              è‡ªå®šä¹‰å·¥èµ„字段(值优先取过程检验填写值,其次取工序固定值)
            </div>
            <Table
              v-if="record.fields && record.fields.length > 0"
              :columns="wageFieldColumns"
              :data-source="record.fields"
              :pagination="false"
              size="small"
              bordered
            >
              <template #bodyCell="{ column, record: field }">
                <template v-if="column.dataIndex === 'role'">
                  <Tag :color="WAGE_ROLE_COLORS[field.role] ?? 'default'">
                    {{ WAGE_ROLE_LABELS[field.role] ?? '未设置' }}
                  </Tag>
                </template>
                <template v-else-if="column.dataIndex === 'unitPrice'">
                  {{
                    field.unitPrice === undefined || field.unitPrice === null
                      ? '-'
                      : fmtAmount(field.unitPrice)
                  }}
                </template>
                <template v-else-if="column.dataIndex === 'amount'">
                  <span :class="Number(field.amount ?? 0) < 0 ? 'text-red-500' : ''">
                    {{ fmtAmount(field.amount) }}
                  </span>
                </template>
                <template v-else-if="column.dataIndex === 'value'">
                  {{
                    field.value === undefined || field.value === null || field.value === ''
                      ? '-'
                      : field.value
                  }}
                </template>
              </template>
            </Table>
            <div v-else class="text-xs text-gray-400">
              è¯¥æŠ¥å·¥å•的工序没有配置自定义工资字段
            </div>
          </div>
        </template>
      </Table>
    </Modal>
    <Grid table-title="薪酬核算列表">
@@ -507,6 +752,12 @@
          {{ getDictLabel(DICT_TYPE.HRM_SALARY_STATUS, row.status) }}
        </Tag>
      </template>
      <template #workWage="{ row }">
        <div class="flex items-center gap-1">
          <span>{{ fmtAmount(row.workWage) }}</span>
          <a class="text-xs text-blue-600" @click="openWageDetail(row)">明细</a>
        </div>
      </template>
      <template #actions="{ row }">
        <TableAction
          :actions="[
src/views/hrm/salary/employee-social-security/data.ts
@@ -3,14 +3,13 @@
import type { HrmEmployeeSocialSecurityApi } from '#/api/hrm/salary/employee-social-security';
import { getDeptList } from '#/api/system/dept';
import { getEmployeeSimpleList } from '#/api/hrm/employee';
import { getSocialSecuritySchemeList } from '#/api/hrm/salary/social-security-scheme';
import { handleTree } from '#/packages/utils/src';
/** è¡¨å•类型 */
export type FormType = 'create' | 'update';
/** æ–°å¢ž/修改员工社保公积金档案的表单 */
/** æ–°å¢ž/修改部门社保公积金档案的表单 */
export function useFormSchema(): VbenFormSchema[] {
  return [
    {
@@ -22,17 +21,22 @@
      },
    },
    {
      fieldName: 'userId',
      label: '员工',
      component: 'ApiSelect',
      fieldName: 'deptId',
      label: '部门',
      component: 'ApiTreeSelect',
      componentProps: {
        api: getEmployeeSimpleList,
        api: async () => {
          const data = await getDeptList();
          return handleTree(data);
        },
        labelField: 'name',
        valueField: 'id',
        childrenField: 'children',
        showSearch: true,
        filterOption: (input: string, option: any) =>
          option.label?.toLowerCase().includes(input.toLowerCase()),
        placeholder: '请选择员工',
        allowClear: true,
        treeDefaultExpandAll: true,
        placeholder: '请选择部门',
        style: { width: '100%' },
      },
      rules: 'selectRequired',
      formItemClass: 'col-span-2',
@@ -119,15 +123,6 @@
export function useGridFormSchema(): VbenFormSchema[] {
  return [
    {
      fieldName: 'userName',
      label: '员工姓名',
      component: 'Input',
      componentProps: {
        allowClear: true,
        placeholder: '请输入员工姓名',
      },
    },
    {
      fieldName: 'deptId',
      label: '部门',
      component: 'ApiTreeSelect',
@@ -150,8 +145,7 @@
/** åˆ—表的字段 */
export function useGridColumns(): VxeTableGridOptions<HrmEmployeeSocialSecurityApi.EmployeeSocialSecurity>['columns'] {
  return [
    { field: 'userName', title: '员工姓名', minWidth: 100 },
    { field: 'deptName', title: '部门', minWidth: 120 },
    { field: 'deptName', title: '部门', minWidth: 160 },
    { field: 'schemeName', title: '社保方案', minWidth: 180 },
    { field: 'socialSecurityBase', title: '社保基数', width: 100, formatter: 'formatAmount2' },
    { field: 'housingFundBase', title: '公积金基数', width: 100, formatter: 'formatAmount2' },
src/views/hrm/salary/employee-social-security/index.vue
@@ -44,13 +44,13 @@
/** æŸ¥çœ‹åŽ†å² */
function handleHistory(row: HrmEmployeeSocialSecurityApi.EmployeeSocialSecurity) {
  historyModalApi.setData({ userId: row.userId, userName: row.userName }).open();
  historyModalApi.setData({ deptId: row.deptId, deptName: row.deptName }).open();
}
/** åˆ é™¤æ¡£æ¡ˆ */
async function handleDelete(row: HrmEmployeeSocialSecurityApi.EmployeeSocialSecurity) {
  await deleteEmployeeSocialSecurity(row.id!);
  message.success($t('ui.actionMessage.deleteSuccess', ['员工社保公积金档案']));
  message.success($t('ui.actionMessage.deleteSuccess', ['部门社保公积金档案']));
  handleRefresh();
}
@@ -88,12 +88,12 @@
  <Page auto-content-height>
    <FormModal @success="handleRefresh" />
    <HistoryModal />
    <Grid table-title="员工社保公积金档案列表">
    <Grid table-title="部门社保公积金档案列表">
      <template #toolbar-tools>
        <TableAction
          :actions="[
            {
              label: $t('ui.actionTitle.create', ['员工社保公积金档案']),
              label: $t('ui.actionTitle.create', ['部门社保公积金档案']),
              type: 'primary',
              icon: ACTION_ICON.ADD,
              auth: ['hrm:employee-social-security:create'],
@@ -129,7 +129,7 @@
              icon: ACTION_ICON.DELETE,
              auth: ['hrm:employee-social-security:delete'],
              popConfirm: {
                title: $t('ui.actionMessage.deleteConfirm', ['员工社保公积金档案']),
                title: $t('ui.actionMessage.deleteConfirm', ['部门社保公积金档案']),
                confirm: handleDelete.bind(null, row),
              },
            },
src/views/hrm/salary/employee-social-security/modules/form.vue
@@ -22,7 +22,7 @@
const formType = ref<FormType>('create');
const getTitle = computed(() =>
  formType.value === 'update' ? '修改员工社保公积金档案' : '新增员工社保公积金档案',
  formType.value === 'update' ? '修改部门社保公积金档案' : '新增部门社保公积金档案',
);
const [Form, formApi] = useVbenForm({
src/views/hrm/salary/employee-social-security/modules/history.vue
@@ -7,10 +7,10 @@
import { Table, Tag } from 'ant-design-vue';
import { getEmployeeSocialSecurityListByUser } from '#/api/hrm/salary/employee-social-security';
import { getEmployeeSocialSecurityListByDept } from '#/api/hrm/salary/employee-social-security';
const historyList = ref<HrmEmployeeSocialSecurityApi.EmployeeSocialSecurity[]>([]);
const userName = ref<string>('');
const deptName = ref<string>('');
const columns = [
  { title: '社保方案', dataIndex: 'schemeName', width: 180 },
@@ -31,11 +31,11 @@
      historyList.value = [];
      return;
    }
    const data = modalApi.getData<{ userId: number; userName: string }>();
    userName.value = data.userName;
    const data = modalApi.getData<{ deptId: number; deptName: string }>();
    deptName.value = data.deptName;
    modalApi.lock();
    try {
      historyList.value = await getEmployeeSocialSecurityListByUser(data.userId);
      historyList.value = await getEmployeeSocialSecurityListByDept(data.deptId);
    } finally {
      modalApi.unlock();
    }
@@ -44,7 +44,7 @@
</script>
<template>
  <Modal :title="`${userName} - ç¤¾ä¿å…¬ç§¯é‡‘历史记录`" class="w-[600px]" :footer="null">
  <Modal :title="`${deptName} - ç¤¾ä¿å…¬ç§¯é‡‘历史记录`" class="w-[600px]" :footer="null">
    <Table
      :columns="columns"
      :data-source="historyList"
src/views/hrm/salary/payment/index.vue
@@ -7,7 +7,15 @@
import { Page, useVbenModal } from '#/packages/effects/common-ui/src';
import { downloadFileFromBlobPart } from '#/packages/utils/src';
import { message, Tag, DatePicker } from 'ant-design-vue';
import {
  Button,
  DatePicker,
  Form,
  FormItem,
  message,
  Modal,
  Tag,
} from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
@@ -131,10 +139,10 @@
        </FormItem>
      </Form>
      <template #footer>
        <a-button @click="generateVisible = false">取消</a-button>
        <a-button type="primary" :loading="generateLoading" @click="handleGenerate">
        <Button @click="generateVisible = false">取消</Button>
        <Button type="primary" :loading="generateLoading" @click="handleGenerate">
          ç¡®å®š
        </a-button>
        </Button>
      </template>
    </Modal>
src/views/hrm/salary/payment/modules/detail.vue
@@ -5,11 +5,9 @@
import { useVbenModal } from '#/packages/effects/common-ui/src';
import { message, Table, Tag, InputNumber, Alert } from 'ant-design-vue';
import { Alert, Button, message, Table, Tag } from 'ant-design-vue';
import { ACTION_ICON, TableAction } from '#/adapter/vxe-table';
import { getSalaryPaymentDetailList, paySalaryDetail } from '#/api/hrm/salary/payment';
import { $t } from '#/locales';
const emit = defineEmits(['success']);
@@ -23,21 +21,22 @@
const isPaid = computed(() => status.value === 10);
// åˆ—宽合计必须与 scroll.x(1400)一致,否则表格拉伸后 fixed åˆ—偏移会算错,竖线对不上
const columns = [
  { title: '员工姓名', dataIndex: 'userName', width: 100, fixed: 'left' },
  { title: '部门', dataIndex: 'deptName', width: 120 },
  { title: '基本工资', dataIndex: 'baseSalary', width: 100 },
  { title: '绩效工资', dataIndex: 'performanceSalary', width: 100 },
  { title: '社保扣款', dataIndex: 'socialSecurityDeduction', width: 100 },
  { title: '公积金扣款', dataIndex: 'housingFundDeduction', width: 100 },
  { title: '个税扣款', dataIndex: 'taxDeduction', width: 100 },
  { title: '请假扣款', dataIndex: 'leaveDeduction', width: 100 },
  { title: '发放时间', dataIndex: 'paymentTime', width: 160 },
  { title: '实发工资', dataIndex: 'actualSalary', width: 100, fixed: 'right' },
  { title: '员工姓名', dataIndex: 'userName', width: 110, fixed: 'left' },
  { title: '部门', dataIndex: 'deptName', width: 140 },
  { title: '基本工资', dataIndex: 'baseSalary', width: 120 },
  { title: '绩效工资', dataIndex: 'performanceSalary', width: 120 },
  { title: '社保扣款', dataIndex: 'socialSecurityDeduction', width: 120 },
  { title: '公积金扣款', dataIndex: 'housingFundDeduction', width: 130 },
  { title: '个税扣款', dataIndex: 'taxDeduction', width: 120 },
  { title: '请假扣款', dataIndex: 'leaveDeduction', width: 120 },
  { title: '发放时间', dataIndex: 'paymentTime', width: 200 },
  { title: '实发工资', dataIndex: 'actualSalary', width: 130, fixed: 'right' },
  {
    title: '状态',
    dataIndex: 'status',
    width: 80,
    width: 90,
    fixed: 'right',
    key: 'status',
  },
@@ -98,7 +97,7 @@
</script>
<template>
  <Modal :title="`${period} - è–ªèµ„发放明细`" class="w-[1100px]" :footer="null">
  <Modal :title="`${period} - è–ªèµ„发放明细`" class="w-[1424px]" :footer="null">
    <Alert
      v-if="!isPaid"
      message="勾选需要发放的员工明细,点击批量发放按钮进行发放"
@@ -108,9 +107,9 @@
    />
    <div v-if="!isPaid" class="mb-3">
      <a-button type="primary" :loading="payLoading" :disabled="selectedIds.length === 0" @click="handleBatchPay">
      <Button type="primary" :loading="payLoading" :disabled="selectedIds.length === 0" @click="handleBatchPay">
        æ‰¹é‡å‘放 (已选 {{ selectedIds.length }} æ¡)
      </a-button>
      </Button>
    </div>
    <Table
@@ -118,7 +117,7 @@
      :data-source="detailList"
      :loading="loading"
      :pagination="false"
      :scroll="{ x: 1200, y: 400 }"
      :scroll="{ x: 1400, y: 460 }"
      :row-selection="!isPaid ? { selectedRowKeys: selectedIds, onChange: onSelectChange } : undefined"
      size="small"
      row-key="id"
src/views/mes/cal/calendar/components/date-cell.vue
ÎļþÒÑɾ³ý
src/views/mes/cal/calendar/components/index.ts
ÎļþÒÑɾ³ý
src/views/mes/cal/calendar/components/legend.vue
ÎļþÒÑɾ³ý
src/views/mes/cal/calendar/components/panel.vue
ÎļþÒÑɾ³ý
src/views/mes/cal/calendar/components/use-calendar.ts
ÎļþÒÑɾ³ý
src/views/mes/cal/calendar/index.vue
ÎļþÒÑɾ³ý
src/views/mes/cal/calendar/modules/team-view.vue
ÎļþÒÑɾ³ý
src/views/mes/cal/calendar/modules/type-view.vue
ÎļþÒÑɾ³ý
src/views/mes/cal/calendar/modules/user-view.vue
ÎļþÒÑɾ³ý
src/views/mes/cal/holiday/data.ts
ÎļþÒÑɾ³ý
src/views/mes/cal/holiday/index.vue
ÎļþÒÑɾ³ý
src/views/mes/cal/holiday/modules/form.vue
ÎļþÒÑɾ³ý
src/views/mes/cal/plan/data.ts
ÎļþÒÑɾ³ý
src/views/mes/cal/plan/index.vue
ÎļþÒÑɾ³ý
src/views/mes/cal/plan/modules/form.vue
ÎļþÒÑɾ³ý
src/views/mes/cal/plan/modules/shift-list.vue
ÎļþÒÑɾ³ý
src/views/mes/cal/plan/modules/team-list.vue
ÎļþÒÑɾ³ý
src/views/mes/cal/team/components/index.ts
ÎļþÒÑɾ³ý
src/views/mes/cal/team/components/select-dialog.vue
ÎļþÒÑɾ³ý
src/views/mes/cal/team/components/select.vue
ÎļþÒÑɾ³ý
src/views/mes/cal/team/data.ts
ÎļþÒÑɾ³ý
src/views/mes/cal/team/modules/form.vue
ÎļþÒÑɾ³ý
src/views/mes/cal/team/modules/member-list.vue
ÎļþÒÑɾ³ý
src/views/mes/pd/project/index.vue
@@ -19,6 +19,7 @@
  deletePdProject,
  exportPdProject,
  exportPdProjectTrackingWord,
  getPdProjectApproveProcessList,
  getPdProjectPage,
  publishPdProject,
@@ -159,6 +160,14 @@
  }
}
async function handleExportTracking(row: { id?: number; taskCode?: string }) {
  const data = await exportPdProjectTrackingWord(row.id!);
  downloadFileFromBlobPart({
    fileName: `项目跟踪表-${row.taskCode ?? row.id}.docx`,
    source: data,
  });
}
async function handleExport() {
  const data = await exportPdProject(await gridApi.formApi.getValues());
  downloadFileFromBlobPart({ fileName: '设计任务.xls', source: data });
@@ -277,6 +286,13 @@
              },
            },
            {
              label: '下载跟踪表',
              type: 'link',
              icon: ACTION_ICON.DOWNLOAD,
              auth: ['mes:pd-project:export'],
              onClick: handleExportTracking.bind(null, row),
            },
            {
              label: $t('common.edit'),
              type: 'link',
              icon: ACTION_ICON.EDIT,
src/views/mes/pro/feedback/data.ts
@@ -16,7 +16,10 @@
import { Button } from 'ant-design-vue';
import { z } from '#/adapter/form';
import { getMachinerySimpleList } from '#/api/mes/dv/machinery';
import { generateAutoCode } from '#/api/mes/md/autocode/record';
import { getProcess } from '#/api/mes/pro/process';
import { getSimpleUserList } from '#/api/system/user';
import { loadParamDetails } from './param-state';
@@ -29,6 +32,17 @@
/** ç”Ÿäº§æŠ¥å·¥è¡¨å•类型 */
export type FormType = 'approve' | 'create' | 'detail' | 'submit' | 'update';
/** å·¥åºè®¡è´¹ç±»åž‹ï¼š1-计时 2-计件 */
const FEEDBACK_BILLING_TYPE_OPTIONS = [
  { label: '计时(按工时)', value: 1 },
  { label: '计件(按数量)', value: 2 },
];
/** å·¥èµ„定额单位 */
function getWageRateUnit(billingType?: number) {
  return billingType === 1 ? '元/小时' : '元/ä»¶';
}
/** åˆ—表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
@@ -143,6 +157,41 @@
    { field: 'itemSpecification', title: '规格型号', width: 120 },
    { field: 'unitMeasureName', title: '单位', width: 80 },
    { field: 'feedbackQuantity', title: '报工数量', width: 100 },
    {
      field: 'billingType',
      title: '计费类型',
      width: 100,
      formatter: ({ cellValue }) =>
        FEEDBACK_BILLING_TYPE_OPTIONS.find((item) => item.value === cellValue)
          ?.label ?? '-',
    },
    { field: 'workHours', title: '报工工时(h)', width: 110 },
    { field: 'productionLength', title: '生产长度(m)', width: 120 },
    { field: 'standardWorkHours', title: '标准工时(h)', width: 110 },
    {
      field: 'workHoursDiff',
      title: '工时差异(h)',
      width: 110,
      formatter: ({ cellValue }) =>
        cellValue == null
          ? '-'
          : `${Number(cellValue) > 0 ? '+' : ''}${cellValue}`,
    },
    {
      field: 'workHoursDiffRate',
      title: '差异率',
      width: 100,
      formatter: ({ cellValue, row }) =>
        cellValue == null
          ? '-'
          : `${Number(cellValue) > 0 ? '+' : ''}${cellValue}%`,
    },
    {
      field: 'wageAmount',
      title: '报工工资',
      width: 110,
      formatter: 'formatAmount2',
    },
    { field: 'qualifiedQuantity', title: '质检合格数量', width: 120 },
    { field: 'unqualifiedQuantity', title: '质检不合格数量', width: 130 },
    { field: 'feedbackUserNickname', title: '报工人', width: 100 },
@@ -180,11 +229,45 @@
 * æŠ¥å·¥åªå¡«å†™"报工数量";合格品/不良品数量由质检确定,报工环节不采集。
 * æŠ¥å·¥å®¡æ‰¹åŽäº§å‡ºå‡å¾…检验,合格品经检验后入库。
 */
/** æŠ¥å·¥è¡¨å•与工时试算组件的联动回调 */
export interface FeedbackFormHooks {
  /** ä»»åŠ¡é€‰æ‹©å˜åŒ–ï¼šæŠŠä»»åŠ¡ä¿¡æ¯åŒæ­¥ç»™è¾…åŠ©å·¥æ—¶å½•å…¥ç»„ä»¶ */
  onTaskChange?: (task?: MesProTaskApi.Task) => void;
  /** ç”Ÿäº§é•¿åº¦å˜åŒ–:用于辅助工时试算 */
  onProductionLengthChange?: (value?: number) => void;
  /** å®žé™…工时变化:用于实时展示工时差异 */
  onWorkHoursChange?: (value?: number) => void;
  /** è¡¨å•关键字段变化:触发后端工资试算(含辅助工资与质量调整) */
  onPreviewWage?: () => void;
}
export function useFormSchema(
  formType: FormType,
  formApi?: VbenFormApi,
  hooks?: FeedbackFormHooks,
): VbenFormSchema[] {
  const isHeaderReadonly = ['approve', 'detail', 'submit'].includes(formType);
  /**
   * å‰ç«¯æŒ‰å·¥åºå¿«ç…§é¢„览报工工资:计时=工时×定额,计件=报工数量×定额。
   * ä»¥åŽç«¯ä¿å­˜/审批时重算的结果为准,这里只用于录入时即时展示。
   */
  const recalculateWagePreview = async () => {
    if (!formApi) {
      return;
    }
    const values = await formApi.getValues();
    const wageRate = Number(values?.wageRate ?? 0);
    const quantity = Number(values?.feedbackQuantity ?? 0);
    const hours = Number(values?.workHours ?? 0);
    const wage = values?.billingType === 1 ? hours * wageRate : quantity * wageRate;
    await formApi.setFieldValue(
      'wageAmount',
      Number.isFinite(wage) ? Math.round(wage * 100) / 100 : 0,
    );
    hooks?.onPreviewWage?.();
  };
  return [
    {
      fieldName: 'id',
@@ -299,6 +382,15 @@
            workstationId: task.workstationId,
            reportableQuantity: task.reportableQuantity,
          });
          // å¸¦å‡ºå·¥åºçš„计费类型与工资定额,用于展示和即时预览报工工资
          if (task.processId) {
            const process = await getProcess(task.processId);
            const billingType = process?.billingType ?? 2;
            const wageRate = process?.wageRate ?? 0;
            await formApi?.setValues({ billingType, wageRate });
            await recalculateWagePreview();
          }
          hooks?.onTaskChange?.(task);
        },
      },
      rules: 'selectRequired',
@@ -312,6 +404,40 @@
        placeholder: '请选择工作站',
      },
      rules: 'selectRequired',
    },
    {
      fieldName: 'machineryId',
      label: '使用设备',
      component: 'ApiSelect',
      componentProps: {
        allowClear: true,
        api: getMachinerySimpleList,
        disabled: isHeaderReadonly,
        labelField: 'name',
        placeholder: '本次报工主用设备(影响工资单价)',
        showSearch: true,
        filterOption: true,
        valueField: 'id',
        onChange: () => recalculateWagePreview(),
      },
      dependencies: {
        triggerFields: ['taskId', 'workstationId'],
        componentProps: (values) => ({
          allowClear: true,
          // åªåˆ—出当前任务工作站已绑定的设备,避免误选其他工序的设备
          api: () => getMachinerySimpleList(values.workstationId),
          disabled: isHeaderReadonly,
          labelField: 'name',
          placeholder: values.workstationId
            ? '本次报工主用设备(仅显示该工作站绑定的设备)'
            : '请先选择工作站',
          showSearch: true,
          filterOption: true,
          valueField: 'id',
          onChange: () => recalculateWagePreview(),
        }),
        show: (values) => !!values.taskId,
      },
    },
    {
      fieldName: 'itemCode',
@@ -384,8 +510,101 @@
        min: 0,
        precision: 2,
        placeholder: '请输入报工数量',
        onChange: () => recalculateWagePreview(),
      },
      rules: 'required',
    },
    {
      fieldName: 'productionLength',
      label: '生产长度(ç±³)',
      component: 'InputNumber',
      componentProps: {
        class: 'w-full',
        min: 0,
        precision: 2,
        placeholder: '用于标准工时计算;单位为米时默认等于报工数量',
        onChange: (value?: number) =>
          hooks?.onProductionLengthChange?.(value),
      },
      dependencies: {
        triggerFields: ['taskId'],
        show: (values) =>
          !!values.taskId && ['create', 'update'].includes(formType),
      },
    },
    {
      fieldName: 'billingType',
      label: '计费类型',
      component: 'Select',
      componentProps: {
        disabled: true,
        options: FEEDBACK_BILLING_TYPE_OPTIONS,
      },
      dependencies: {
        triggerFields: ['taskId'],
        show: (values) => !!values.taskId,
      },
    },
    {
      fieldName: 'wageRate',
      label: '工资定额',
      component: 'InputNumber',
      componentProps: {
        class: 'w-full',
        disabled: true,
        precision: 2,
      },
      dependencies: {
        triggerFields: ['taskId', 'billingType'],
        show: (values) => !!values.taskId,
        componentProps: (values) => ({
          addonAfter: getWageRateUnit(values.billingType),
        }),
      },
    },
    {
      fieldName: 'workHours',
      label: '报工工时(h)',
      component: 'InputNumber',
      componentProps: {
        class: 'w-full',
        min: 0,
        precision: 2,
        placeholder: '请输入本次报工工时',
        onChange: (value?: number) => {
          hooks?.onWorkHoursChange?.(value);
          recalculateWagePreview();
        },
      },
      dependencies: {
        triggerFields: ['taskId', 'billingType'],
        show: (values) => !!values.taskId && values.billingType === 1,
        rules: (values) =>
          values.billingType === 1
            ? z.number().min(0.01, '计时计薪的工序必须填写大于 0 çš„æŠ¥å·¥å·¥æ—¶')
            : null,
      },
    },
    {
      fieldName: 'wageAmount',
      label: '报工工资',
      component: 'InputNumber',
      componentProps: {
        class: 'w-full',
        disabled: true,
        precision: 2,
      },
      dependencies: {
        triggerFields: ['taskId', 'billingType'],
        show: (values) => !!values.taskId,
        componentProps: (values) => ({
          addonAfter: '元',
          placeholder:
            values.billingType === 1
              ? '按工时 Ã— å·¥èµ„定额自动计算'
              : '按报工数量 Ã— å·¥èµ„定额自动计算',
        }),
      },
    },
    {
      fieldName: 'feedbackUserId',
@@ -456,12 +675,33 @@
    { field: 'workOrderId', label: '生产订单', render: (_val, data) => data?.workOrderCode },
    { field: 'taskId', label: '生产任务', render: (_val, data) => data?.taskCode },
    { field: 'workstationName', label: '工作站' },
    { field: 'machineryName', label: '使用设备' },
    { field: 'itemCode', label: '产品编码' },
    { field: 'itemName', label: '产品名称' },
    { field: 'unitMeasureName', label: '单位' },
    { field: 'itemSpecification', label: '规格' },
    { field: 'reportableQuantity', label: '可报工数量' },
    { field: 'feedbackQuantity', label: '报工数量' },
    {
      field: 'billingType',
      label: '计费类型',
      render: (val) =>
        FEEDBACK_BILLING_TYPE_OPTIONS.find((item) => item.value === val)
          ?.label ?? '-',
    },
    { field: 'wageRate', label: '工资定额' },
    { field: 'workHours', label: '报工工时(h)' },
    { field: 'auxPaidHours', label: '辅助计薪工时(h)' },
    { field: 'wageAmount', label: '报工工资(含质量调整)' },
    { field: 'auxWageAmount', label: '辅助工资' },
    { field: 'qualityPassRate', label: '过程检验合格率(%)' },
    { field: 'productionLength', label: '生产长度(ç±³)' },
    { field: 'standardWorkHours', label: '标准工时(h)' },
    { field: 'standardPrimaryHours', label: '标准主工时(h)' },
    { field: 'standardAuxHours', label: '标准辅助工时(h)' },
    { field: 'workHoursDiff', label: '工时差异(h)' },
    { field: 'workHoursDiffRate', label: '工时差异率(%)' },
    { field: 'stdCalcNote', label: '标准工时计算依据' },
    { field: 'qualifiedQuantity', label: '合格品数量' },
    { field: 'unqualifiedQuantity', label: '不良品数量' },
    { field: 'laborScrapQuantity', label: '工废数量' },
src/views/mes/pro/feedback/modules/form.vue
@@ -1,7 +1,9 @@
<script lang="ts" setup>
import type { FormType } from '../data';
import type { FormType, FeedbackFormHooks } from '../data';
import type { MesProFeedbackApi } from '#/api/mes/pro/feedback';
import type { MesProTaskApi } from '#/api/mes/pro/task';
import type { MesProFeedbackWagePreviewApi } from '#/api/mes/pro/wage';
import { computed, ref } from 'vue';
@@ -11,7 +13,7 @@
import { MesAutoCodeRuleCode, MesProFeedbackStatusEnum } from '@vben/constants';
import { useUserStore } from '@vben/stores';
import { Button, Input, message, Popconfirm, Tabs } from 'ant-design-vue';
import { Alert, Button, Input, message, Popconfirm, Tabs } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { generateAutoCode } from '#/api/mes/md/autocode/record';
@@ -25,6 +27,7 @@
  updateFeedback,
} from '#/api/mes/pro/feedback';
import { getWorkOrder } from '#/api/mes/pro/workorder';
import { previewFeedbackWage } from '#/api/mes/pro/wage';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
@@ -32,6 +35,7 @@
import BatchInputDialog from './batch-input-dialog.vue';
import ItemConsumeList from './item-consume-list.vue';
import ProductProduceList from './product-produce-list.vue';
import WorktimeAuxInput from './worktime-aux-input.vue';
const emit = defineEmits(['success']);
const formType = ref<FormType>('create');
@@ -40,6 +44,65 @@
const userStore = useUserStore();
const subTabsName = ref('itemConsume');
const originalSnapshot = ref(''); // è¡¨å•原始数据快照,用于 submit æ—¶è·³è¿‡æœªå˜æ›´çš„保存请求
/** å½“前选中的生产任务,用于辅助工时组件按工序/机台加载配置 */
const currentTask = ref<Partial<MesProTaskApi.Task>>();
const productionLength = ref<number>();
const workHours = ref<number>();
const auxRef = ref<InstanceType<typeof WorktimeAuxInput>>();
/** åŽç«¯å·¥èµ„试算结果(含辅助工资、质量调整) */
const wagePreview = ref<MesProFeedbackWagePreviewApi.PreviewResp>();
let previewTimer: ReturnType<typeof setTimeout> | undefined;
/** è°ƒç”¨åŽç«¯è¯•算工资:未指定设备时按工序默认单价,指定设备按设备维度规则 */
async function refreshWagePreview() {
  if (!isEditable.value) {
    wagePreview.value = undefined;
    return;
  }
  const values = (await formApi.getValues()) as MesProFeedbackApi.Feedback;
  if (!values.taskId) {
    wagePreview.value = undefined;
    return;
  }
  try {
    wagePreview.value = await previewFeedbackWage({
      taskId: values.taskId,
      machineryId: values.machineryId,
      workHours: values.workHours,
      feedbackQuantity: values.feedbackQuantity,
      productionLength: values.productionLength,
      auxList: auxRef.value?.getAuxList(),
    });
  } catch {
    // è¯•算失败不阻断录入,保存时后端会给出精确校验提示
    wagePreview.value = undefined;
  }
}
/** é˜²æŠ–触发工资试算,避免连续录入时频繁请求 */
function schedulePreview() {
  if (previewTimer) {
    clearTimeout(previewTimer);
  }
  previewTimer = setTimeout(() => {
    refreshWagePreview();
  }, 500);
}
const formHooks: FeedbackFormHooks = {
  onTaskChange: (task) => {
    currentTask.value = task;
    productionLength.value = undefined;
    workHours.value = undefined;
  },
  onProductionLengthChange: (value) => {
    productionLength.value = value;
  },
  onWorkHoursChange: (value) => {
    workHours.value = value;
  },
  onPreviewWage: () => schedulePreview(),
};
const isEditable = computed(() =>
  ['create', 'submit', 'update'].includes(formType.value),
@@ -101,6 +164,10 @@
  const data = (await formApi.getValues()) as MesProFeedbackApi.Feedback;
  syncParamValues(data);
  alignQuantity(data);
  // è¾…助项次数由工时组件维护;标准工时以后端保存时重算为准
  if (isEditable.value && auxRef.value) {
    data.auxList = auxRef.value.getAuxList();
  }
  return data;
}
@@ -124,7 +191,9 @@
        status: MesProFeedbackStatusEnum.PREPARE,
      };
      formType.value = 'update';
      formApi.setState({ schema: useFormSchema(formType.value, formApi) });
      formApi.setState({
        schema: useFormSchema(formType.value, formApi, formHooks),
      });
      await formApi.setFieldValue('id', id);
      message.success($t('common.createSuccess'));
    } else {
@@ -305,13 +374,23 @@
      subTabsName.value = 'itemConsume';
      originalSnapshot.value = '';
      paramDetails.value = [];
      currentTask.value = undefined;
      productionLength.value = undefined;
      workHours.value = undefined;
      wagePreview.value = undefined;
      if (previewTimer) {
        clearTimeout(previewTimer);
        previewTimer = undefined;
      }
      return;
    }
    // åŠ è½½æ•°æ®
    const data = modalApi.getData<{ formType: FormType; id?: number; showSubmitButton?: boolean }>();
    formType.value = data.formType;
    showSubmitButton.value = data.showSubmitButton !== false;
    formApi.setState({ schema: useFormSchema(formType.value, formApi) });
    formApi.setState({
        schema: useFormSchema(formType.value, formApi, formHooks),
      });
    // å®¡æ‰¹/详情整表禁用,避免审核人误改未提交的字段
    formApi.setDisabled(
      formType.value === 'approve' || formType.value === 'detail',
@@ -342,8 +421,17 @@
      await loadParamDetails(formData.value.taskId);
      // è®¾ç½®åˆ° values
      await formApi.setValues({ ...formData.value });
      // åŒæ­¥å·¥æ—¶ç»„件所需的上下文,编辑时按已有次数回显
      currentTask.value = {
        id: formData.value.taskId,
        processId: formData.value.processId,
        workstationId: formData.value.workstationId,
      };
      productionLength.value = formData.value.productionLength;
      workHours.value = formData.value.workHours;
      // è®°å½•初始快照(含数量对齐结果),submit æ—¶ç”¨äºŽè·³è¿‡æ— å˜æ›´çš„保存请求
      originalSnapshot.value = JSON.stringify(await getAlignedData());
      schedulePreview();
    } finally {
      modalApi.unlock();
    }
@@ -353,7 +441,86 @@
<template>
  <Modal :title="getTitle" class="w-3/5">
    <Alert
      v-if="formData?.standardWarnFlag && formData?.standardWarnMessage"
      class="mx-4 mb-3"
      :description="formData.standardWarnMessage"
      message="工时超出考核阈值"
      show-icon
      type="warning"
    />
    <Form class="mx-4" />
    <div
      v-if="isEditable && wagePreview"
      class="mx-4 mt-4 rounded border border-gray-200 p-4"
    >
      <div class="mb-2 flex items-center justify-between">
        <div class="font-medium">
          å·¥èµ„试算
          <span class="ml-2 text-xs font-normal text-gray-400">
            å«è¾…助工资与质量调整,保存/审批时以系统重算结果为准
          </span>
        </div>
        <Button size="small" @click="refreshWagePreview">刷新试算</Button>
      </div>
      <div class="flex flex-wrap gap-x-6 gap-y-1 text-sm">
        <span>
          è®¡è–ªæ–¹å¼ï¼š<b>{{ wagePreview.billingType === 1 ? '计时' : '计件' }}</b>
        </span>
        <span>
          å•价:<b>{{ wagePreview.wageRate ?? 0 }}</b>
          {{ wagePreview.billingType === 1 ? '元/小时' : '元/ä»¶' }}
        </span>
        <span v-if="wagePreview.billingType === 1">
          å·¥æ—¶ï¼š<b>{{ wagePreview.workHours ?? 0 }}</b> å°æ—¶
        </span>
        <span v-else>
          è®¡ä»¶æ•°é‡ï¼š<b>{{ wagePreview.pieceQuantity ?? 0 }}</b>
        </span>
        <span>主工资:<b>{{ wagePreview.mainWage ?? 0 }}</b> å…ƒ</span>
        <span>
          è¾…助工资:<b>{{ wagePreview.auxWage ?? 0 }}</b> å…ƒ
          <span class="text-xs text-gray-400">
            ï¼ˆ{{ wagePreview.auxPaidHours ?? 0 }} å°æ—¶ï¼‰
          </span>
        </span>
        <span v-if="wagePreview.quality?.passRate !== undefined">
          åˆæ ¼çŽ‡ï¼š<b>{{ wagePreview.quality?.passRate }}</b>%
        </span>
        <span>
          æœ€ç»ˆå·¥èµ„:<b class="text-blue-600">{{ wagePreview.finalWage ?? 0 }}</b> å…ƒ
        </span>
      </div>
      <div
        v-if="wagePreview.fields?.length"
        class="mt-2 text-xs text-gray-500"
      >
        å·¥èµ„字段:
        <span v-for="(field, index) in wagePreview.fields" :key="field.code">
          {{ index > 0 ? ';' : '' }}{{ field.name }}={{ field.value ?? '-' }}
          <template v-if="field.amount !== undefined">
            ï¼ˆ{{ field.amount }} å…ƒï¼‰
          </template>
        </span>
      </div>
      <Alert
        v-if="wagePreview.warnings?.length"
        class="mt-2"
        :message="wagePreview.warnings.join(';')"
        show-icon
        type="warning"
      />
    </div>
    <WorktimeAuxInput
      v-if="isEditable && (currentTask?.id || formData?.taskId)"
      ref="auxRef"
      :initial-aux-list="formData?.auxList"
      :process-id="currentTask?.processId ?? formData?.processId"
      :production-length="productionLength ?? formData?.productionLength"
      :task-id="currentTask?.id ?? formData?.taskId"
      :work-hours="workHours ?? formData?.workHours"
      :workstation-id="currentTask?.workstationId ?? formData?.workstationId"
    />
    <div v-if="paramDetails.length" class="mx-4 mt-4 rounded border border-gray-200 p-4">
      <div class="mb-3 font-medium">工艺参数</div>
      <div class="grid grid-cols-2 gap-3">
src/views/mes/pro/feedback/modules/worktime-aux-input.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,276 @@
<script lang="ts" setup>
import type { MesProFeedbackApi } from '#/api/mes/pro/feedback';
import type { MesProWorktimeApi } from '#/api/mes/pro/worktime';
import { computed, ref, watch } from 'vue';
import { Button, Input, InputNumber, message } from 'ant-design-vue';
import {
  calculateWorktime,
  getWorktimeAuxApplicable,
} from '#/api/mes/pro/worktime';
const props = withDefaults(
  defineProps<{
    taskId?: number;
    processId?: number;
    workstationId?: number;
    productionLength?: number;
    workHours?: number;
    initialAuxList?: MesProFeedbackApi.AuxValue[];
  }>(),
  {
    taskId: undefined,
    processId: undefined,
    workstationId: undefined,
    productionLength: undefined,
    workHours: undefined,
    initialAuxList: () => [],
  },
);
interface AuxRow extends MesProWorktimeApi.AuxItem {
  quantity?: number;
  materialFrom?: string;
  materialTo?: string;
  minutes?: number;
}
const items = ref<AuxRow[]>([]);
const loading = ref(false);
const calculating = ref(false);
const calcResult = ref<MesProWorktimeApi.CalcResp>();
let calcTimer: ReturnType<typeof setTimeout> | undefined;
const measureUnit = (measureType?: number) => {
  if (measureType === 2) return '个';
  if (measureType === 3) return '盘';
  if (measureType === 4) return 'ç±³';
  return '次';
};
function mergeInitial(quantity: boolean) {
  const initialMap = new Map(
    (props.initialAuxList ?? []).map((item) => [item.auxItemId, item]),
  );
  items.value.forEach((item) => {
    if (item.id === undefined) return;
    const initial = initialMap.get(item.id);
    if (!initial) return;
    if (quantity && item.quantity === undefined) {
      item.quantity = initial.quantity ?? 0;
    }
    if (!item.materialFrom && initial.materialFrom) {
      item.materialFrom = initial.materialFrom;
    }
    if (!item.materialTo && initial.materialTo) {
      item.materialTo = initial.materialTo;
    }
  });
}
async function loadItems() {
  if (!props.processId) {
    items.value = [];
    calcResult.value = undefined;
    return;
  }
  loading.value = true;
  try {
    const list = await getWorktimeAuxApplicable(
      props.processId,
      props.workstationId,
    );
    items.value = (list ?? []).map((item) => ({
      ...item,
      quantity: 0,
      materialFrom: item.materialFrom,
      materialTo: item.materialTo,
    }));
    mergeInitial(true);
    if (items.value.length > 0) {
      await calculate();
    }
  } finally {
    loading.value = false;
  }
}
async function calculate() {
  if (!props.taskId || items.value.length === 0) {
    calcResult.value = undefined;
    return;
  }
  calculating.value = true;
  try {
    const result = await calculateWorktime({
      taskId: props.taskId,
      productionLength: props.productionLength,
      auxList: getAuxList(),
    });
    calcResult.value = result;
    const minuteMap = new Map(
      (result?.auxList ?? []).map((aux) => [aux.auxItemId, aux.minutes]),
    );
    items.value.forEach((item) => {
      item.minutes = item.id ? minuteMap.get(item.id) : undefined;
    });
  } catch {
    // è¯•算失败不阻断报工保存,后端保存时会再次计算并给出说明
    message.warning('标准工时试算失败,可直接保存,系统会在保存时重算');
  } finally {
    calculating.value = false;
  }
}
function scheduleCalculate() {
  if (calcTimer) {
    clearTimeout(calcTimer);
  }
  calcTimer = setTimeout(() => {
    calculate();
  }, 400);
}
/** æäº¤ç»™åŽç«¯çš„辅助项(只提交录入了次数的项) */
function getAuxList(): MesProFeedbackApi.AuxValue[] {
  return items.value
    .filter((item) => item.id && Number(item.quantity ?? 0) > 0)
    .map((item) => ({
      auxItemId: item.id!,
      quantity: item.quantity,
      materialFrom: item.materialFrom,
      materialTo: item.materialTo,
    }));
}
const standardHours = computed(() => calcResult.value?.standardWorkHours ?? 0);
const actualHours = computed(() => Number(props.workHours ?? 0));
const diffHours = computed(() => {
  if (!calcResult.value?.standardWorkHours) return undefined;
  return Math.round((actualHours.value - standardHours.value) * 100) / 100;
});
const diffRate = computed(() => {
  if (!calcResult.value?.standardWorkHours) return undefined;
  return (
    Math.round(
      ((actualHours.value - standardHours.value) / standardHours.value) *
        100 *
        100,
    ) / 100
  );
});
const warnFlag = computed(
  () => diffRate.value !== undefined && diffRate.value > 20,
);
watch(
  () => [props.processId, props.workstationId],
  () => loadItems(),
  { immediate: true },
);
watch(
  () => props.initialAuxList,
  () => mergeInitial(false),
);
watch(
  () => props.productionLength,
  () => scheduleCalculate(),
);
watch(
  () => props.taskId,
  () => scheduleCalculate(),
);
defineExpose({ calculate, getAuxList });
</script>
<template>
  <div
    v-if="items.length > 0"
    class="mx-4 mt-4 rounded border border-gray-200 p-4"
  >
    <div class="mb-3 flex items-center justify-between">
      <div class="font-medium">
        è¾…助工时
        <span class="ml-2 text-xs font-normal text-gray-400">
          æŒ‰å®žé™…发生的次数录入,用于计算标准工时(不影响工资)
        </span>
      </div>
      <Button :loading="calculating || loading" size="small" @click="calculate">
        è¯•算标准工时
      </Button>
    </div>
    <div class="space-y-2">
      <div
        v-for="item in items"
        :key="item.id"
        class="flex flex-wrap items-center gap-3"
      >
        <span class="w-52 text-right text-sm text-gray-600">
          {{ item.itemName }}
          <span class="text-xs text-gray-400">
            ï¼ˆ{{ item.measureType === 4 ? '按米折算' : `按${measureUnit(item.measureType)}` }})
          </span>
        </span>
        <InputNumber
          v-model:value="item.quantity"
          :min="0"
          :precision="2"
          class="!w-28"
          :addon-after="measureUnit(item.measureType)"
          @change="scheduleCalculate"
        />
        <template v-if="item.materialFrom || item.materialTo">
          <Input
            v-model:value="item.materialFrom"
            class="!w-32"
            placeholder="原色/材料"
            @change="scheduleCalculate"
          />
          <span class="text-gray-400">→</span>
          <Input
            v-model:value="item.materialTo"
            class="!w-32"
            placeholder="目标色/材料"
            @change="scheduleCalculate"
          />
        </template>
        <span v-if="item.minutes !== undefined" class="text-xs text-gray-400">
          æŠ˜ç®— {{ item.minutes }} åˆ†é’Ÿ
        </span>
      </div>
    </div>
    <div
      v-if="calcResult"
      class="mt-3 flex flex-wrap items-center gap-x-6 gap-y-1 border-t border-gray-100 pt-2 text-sm"
    >
      <span>
        æ ‡å‡†å·¥æ—¶ï¼š<b>{{ standardHours }}</b> å°æ—¶
        <span class="text-xs text-gray-400">
          ï¼ˆä¸» {{ calcResult.primaryHours ?? 0 }} + è¾…
          {{ calcResult.auxHours ?? 0 }})
        </span>
      </span>
      <span v-if="props.workHours !== undefined">
        å®žé™…工时:<b>{{ actualHours }}</b> å°æ—¶
      </span>
      <span v-if="diffHours !== undefined" :class="warnFlag ? 'text-red-500' : ''">
        å·®å¼‚:<b>{{ diffHours > 0 ? '+' : '' }}{{ diffHours }}</b> å°æ—¶
        <template v-if="diffRate !== undefined">
          ï¼ˆ{{ diffRate > 0 ? '+' : '' }}{{ diffRate }}%)
        </template>
      </span>
    </div>
    <div v-if="calcResult?.note" class="mt-1 text-xs text-gray-400">
      {{ calcResult.note }}
    </div>
    <div
      v-if="calcResult?.missingReasons?.length"
      class="mt-1 text-xs text-orange-500"
    >
      {{ calcResult.missingReasons.join(';') }}
    </div>
  </div>
</template>
src/views/mes/pro/mps/index.vue
@@ -108,6 +108,15 @@
    message.error('请选择草稿状态的生产计划');
    return;
  }
  const forbidden = checkedRows.value.filter((row) => row.canIssue !== true);
  if (forbidden.length > 0) {
    message.error(
      `以下计划不是由你创建,不能下发:${forbidden
        .map((row) => row.code)
        .join('、')}。请由计划创建人操作,或联系管理员。`,
    );
    return;
  }
  const hideLoading = message.loading({
    content: '批量下发中...',
    duration: 0,
@@ -224,7 +233,9 @@
            {
              label: '批量下发',
              type: 'primary',
              disabled: isEmpty(checkedIds),
              disabled:
                isEmpty(checkedIds) ||
                !checkedRows.every((row) => row.canIssue),
              icon: ACTION_ICON.AUDIT,
              auth: ['mes:pro-mps:issue'],
              popConfirm: {
@@ -282,7 +293,9 @@
              type: 'link',
              icon: ACTION_ICON.AUDIT,
              auth: ['mes:pro-mps:issue'],
              ifShow: () => row.status === MpsStatusEnum.DRAFT,
              // ä»…计划创建人(销售计划取订单创建人)或超管可见,后端同样强校验
              ifShow: () =>
                row.status === MpsStatusEnum.DRAFT && row.canIssue === true,
              popConfirm: {
                title: `确认下发${row.code}吗?`,
                confirm: handleIssue.bind(null, row),
src/views/mes/pro/process/data.ts
@@ -20,6 +20,25 @@
/** è¡¨å•类型 */
export type FormType = 'create' | 'detail' | 'update';
/** è®¡è´¹ç±»åž‹ï¼š1-计时 2-计件 */
export const PROCESS_BILLING_TYPE_OPTIONS = [
  { label: '计时(元/小时)', value: 1 },
  { label: '计件(元/件)', value: 2 },
];
/** è®¡è´¹ç±»åž‹åç§° */
export function getBillingTypeLabel(billingType?: number) {
  return (
    PROCESS_BILLING_TYPE_OPTIONS.find((item) => item.value === billingType)
      ?.label ?? '-'
  );
}
/** å·¥èµ„定额单位 */
export function getWageRateUnit(billingType?: number) {
  return billingType === 1 ? '元/小时' : '元/ä»¶';
}
/** æ–°å¢ž/修改生产工序的表单 */
export function useFormSchema(formApi?: VbenFormApi): VbenFormSchema[] {
  return [
@@ -75,6 +94,37 @@
        options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
      },
      rules: z.number().default(CommonStatusEnum.ENABLE),
    },
    {
      fieldName: 'billingType',
      label: '计费类型',
      component: 'Select',
      componentProps: {
        options: PROCESS_BILLING_TYPE_OPTIONS,
        placeholder: '请选择计费类型',
      },
      rules: z.number().default(2),
    },
    {
      fieldName: 'wageRate',
      label: '工资定额',
      component: 'InputNumber',
      dependencies: {
        triggerFields: ['billingType'],
        componentProps: (values: Record<string, unknown>) => ({
          addonAfter: getWageRateUnit(values.billingType as number),
          placeholder:
            values.billingType === 1
              ? '请输入小时工资定额'
              : '请输入计件单价',
        }),
      },
      componentProps: {
        class: 'w-full',
        min: 0,
        precision: 2,
      },
      rules: z.number().min(0, '工资定额不能小于 0'),
    },
    {
      fieldName: 'attention',
@@ -148,6 +198,21 @@
    },
    { field: 'name', title: '工序名称', minWidth: 180 },
    {
      field: 'billingType',
      title: '计费类型',
      width: 110,
      formatter: ({ cellValue }) => getBillingTypeLabel(cellValue),
    },
    {
      field: 'wageRate',
      title: '工资定额',
      width: 140,
      formatter: ({ cellValue, row }) =>
        cellValue == null
          ? '-'
          : `${Number(cellValue).toFixed(2)} ${getWageRateUnit(row.billingType)}`,
    },
    {
      field: 'status',
      title: '状态',
      width: 100,
src/views/mes/pro/route/data.ts
@@ -284,16 +284,6 @@
      rules: z.boolean().default(false),
    },
    {
      fieldName: 'checkFlag',
      label: '是否质检确认',
      component: 'Switch',
      componentProps: {
        checkedChildren: '是',
        unCheckedChildren: '否',
      },
      rules: z.boolean().default(false),
    },
    {
      fieldName: 'backflushFlag',
      label: '报工自动扣料',
      component: 'Switch',
@@ -378,15 +368,6 @@
      field: 'keyFlag',
      title: '关键工序',
      width: 90,
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.INFRA_BOOLEAN_STRING },
      },
    },
    {
      field: 'checkFlag',
      title: '质检确认',
      width: 100,
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.INFRA_BOOLEAN_STRING },
src/views/mes/pro/task/data.ts
@@ -637,15 +637,6 @@
    { field: 'workstationCode', title: '工作站编码', width: 140 },
    { field: 'workstationName', title: '工作站名称', width: 140 },
    { field: 'processName', title: '工序', width: 120 },
    {
      field: 'checkFlag',
      title: '是否质检',
      width: 100,
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.INFRA_BOOLEAN_STRING },
      },
    },
    { field: 'itemCode', title: '物料编码', width: 140 },
    { field: 'itemName', title: '物料名称', width: 140 },
    { field: 'itemSpecification', title: '规格型号', width: 120 },
src/views/mes/pro/workorder-center/modules/quick-feedback.vue
@@ -10,6 +10,8 @@
import dayjs from 'dayjs';
import { quickCreateFeedback } from '#/api/mes/pro/feedback';
import { getProcess } from '#/api/mes/pro/process';
import { getTask } from '#/api/mes/pro/task';
import {
  loadParamDetails,
  paramDetails,
@@ -40,14 +42,18 @@
      selectedTask.value = undefined;
      formData.value = {
        feedbackQuantity: undefined,
        workHours: undefined,
        feedbackTime: dayjs().format('YYYY-MM-DD HH:mm:ss'),
        remark: '',
      };
      // é¢„设任务时加载其工艺参数;否则等待选择任务后再加载
      // é¢„设任务时加载其工艺参数与工序计费方式;否则等待选择任务后再加载
      if (props.taskId) {
        loadParamDetails(props.taskId);
        loadProcessByTask(props.taskId);
      } else {
        paramDetails.value = [];
        billingType.value = undefined;
        wageRate.value = 0;
      }
    }
  },
@@ -58,10 +64,34 @@
const selectedTask = ref<MesProTaskApi.Task>();
const formData = ref({
  feedbackQuantity: undefined as number | undefined,
  workHours: undefined as number | undefined,
  feedbackTime: dayjs().format('YYYY-MM-DD HH:mm:ss'),
  remark: '',
});
const submitting = ref(false);
/** å·¥åºè®¡è´¹ç±»åž‹ï¼š1-计时(需录工时) 2-计件 */
const billingType = ref<number>();
const wageRate = ref(0);
/** æ ¹æ®ä»»åŠ¡å¸¦å‡ºå·¥åºçš„è®¡è´¹æ–¹å¼ï¼šè®¡æ—¶å·¥åºéœ€è¦ç”¨æˆ·è¡¥å½•å·¥æ—¶ */
async function loadProcessByTask(taskId?: number) {
  billingType.value = undefined;
  wageRate.value = 0;
  if (!taskId) {
    return;
  }
  try {
    const task = await getTask(taskId);
    if (!task?.processId) {
      return;
    }
    const process = await getProcess(task.processId);
    billingType.value = process?.billingType ?? 2;
    wageRate.value = process?.wageRate ?? 0;
  } catch {
    // å·¥åºä¿¡æ¯åŠ è½½å¤±è´¥ä¸é˜»æ–­æŠ¥å·¥ï¼Œä¿å­˜æ—¶åŽç«¯ä¼šå†æ¬¡æ ¡éªŒå¹¶ç»™å‡ºæ˜Žç¡®æç¤º
  }
}
const hasPredefinedTask = computed(() => !!props.taskId);
@@ -80,6 +110,7 @@
  selectedTask.value = task;
  selectedTaskId.value = task?.id;
  loadParamDetails(task?.id);
  loadProcessByTask(task?.id);
}
async function handleSubmit() {
@@ -92,11 +123,16 @@
    message.warning('请输入报工数量');
    return;
  }
  if (billingType.value === 1 && wageRate.value > 0 && !formData.value.workHours) {
    message.warning('当前工序按计时计薪,请输入本次报工工时(小时)');
    return;
  }
  submitting.value = true;
  try {
    await quickCreateFeedback({
      taskId,
      feedbackQuantity: formData.value.feedbackQuantity,
      workHours: formData.value.workHours,
      feedbackTime: formData.value.feedbackTime || undefined,
      paramValues: paramDetails.value.map((detail) => ({
        detailId: detail.id,
@@ -140,6 +176,19 @@
          class="!w-full"
        />
      </a-form-item>
      <a-form-item
        v-if="billingType === 1"
        label="报工工时(h)"
        :required="wageRate > 0"
      >
        <a-input-number
          v-model:value="formData.workHours"
          :min="0.01"
          :precision="2"
          placeholder="请输入本次报工工时(小时)"
          class="!w-full"
        />
      </a-form-item>
      <a-form-item label="报工时间">
        <a-date-picker
          v-model:value="formData.feedbackTime"
src/views/mes/pro/workorder/data.ts
@@ -745,12 +745,6 @@
      slots: { default: 'keyFlag' },
    },
    {
      field: 'checkFlag',
      title: '质检工序',
      width: 80,
      slots: { default: 'checkFlag' },
    },
    {
      field: 'backflushFlag',
      title: '倒冲',
      width: 80,
src/views/mes/pro/workorder/modules/process-form.vue
@@ -203,18 +203,6 @@
      defaultValue: false,
    },
    {
      fieldName: 'checkFlag',
      label: '是否质检工序',
      component: 'RadioGroup',
      componentProps: {
        options: [
          { label: '否', value: false },
          { label: '是', value: true },
        ],
      },
      defaultValue: false,
    },
    {
      fieldName: 'backflushFlag',
      label: '是否倒冲',
      component: 'RadioGroup',
src/views/mes/pro/workorder/modules/process-list.vue
@@ -396,10 +396,6 @@
        <Tag v-if="row.keyFlag" color="red">关键</Tag>
        <span v-else>-</span>
      </template>
      <template #checkFlag="{ row }">
        <Tag v-if="row.checkFlag" color="blue">质检</Tag>
        <span v-else>-</span>
      </template>
      <template #backflushFlag="{ row }">
        <Tag v-if="row.backflushFlag" color="green">是</Tag>
        <Tag v-else color="default">否</Tag>
src/views/mes/pro/worktime-aux/data.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,338 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MesProWorktimeApi } from '#/api/mes/pro/worktime';
import { CommonStatusEnum, DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { z } from '#/adapter/form';
import { getProcessSimpleList } from '#/api/mes/pro/process';
import { getWorkstationPage } from '#/api/mes/md/workstation';
/** è®¡é‡æ–¹å¼ */
export const MEASURE_TYPE_OPTIONS = [
  { label: '按次', value: 1 },
  { label: '按个', value: 2 },
  { label: '按盘', value: 3 },
  { label: '按米折算', value: 4 },
];
export function getMeasureTypeLabel(measureType?: number) {
  return (
    MEASURE_TYPE_OPTIONS.find((item) => item.value === measureType)?.label ?? '-'
  );
}
/** è®¡è–ªæ–¹å¼ï¼š0-不计薪 1-按工序辅助小时工资 2-按本项单价 */
export const WAGE_MODE_OPTIONS = [
  { label: '不计薪', value: 0 },
  { label: '按工序辅助小时工资', value: 1 },
  { label: '按本项单价', value: 2 },
];
export function getWageModeLabel(wageMode?: number) {
  return (
    WAGE_MODE_OPTIONS.find((item) => item.value === wageMode)?.label ?? '不计薪'
  );
}
const loadProcessOptions = async () => {
  const list = await getProcessSimpleList();
  return (list ?? []).map((item) => ({
    label: item.name ?? '',
    value: item.id ?? 0,
  }));
};
const loadWorkstationOptions = async () => {
  const result = await getWorkstationPage({ pageNo: 1, pageSize: 500 });
  return (result?.list ?? []).map((item) => ({
    label: item.name ?? '',
    value: item.id ?? 0,
  }));
};
/** æ–°å¢ž/修改辅助工时项目 */
export function useFormSchema(): VbenFormSchema[] {
  return [
    {
      fieldName: 'id',
      component: 'Input',
      dependencies: { triggerFields: [''], show: () => false },
    },
    {
      fieldName: 'processId',
      label: '工序',
      component: 'ApiSelect',
      componentProps: {
        api: loadProcessOptions,
        labelField: 'label',
        valueField: 'value',
        placeholder: '请选择工序',
        showSearch: true,
        filterOption: true,
      },
      rules: 'selectRequired',
    },
    {
      fieldName: 'workstationId',
      label: '适用机台',
      component: 'ApiSelect',
      componentProps: {
        allowClear: true,
        api: loadWorkstationOptions,
        labelField: 'label',
        valueField: 'value',
        placeholder: '不选表示不限机台',
        showSearch: true,
        filterOption: true,
      },
    },
    {
      fieldName: 'itemCode',
      label: '项目编码',
      component: 'Input',
      componentProps: {
        maxLength: 64,
        placeholder: '如 AUX_REEL_CHANGE',
      },
      rules: z.string().min(1, '项目编码不能为空').max(64),
    },
    {
      fieldName: 'itemName',
      label: '项目名称',
      component: 'Input',
      componentProps: {
        maxLength: 100,
        placeholder: '如 ä¸Šä¸‹æ”¾çº¿ç›˜',
      },
      rules: z.string().min(1, '项目名称不能为空').max(100),
    },
    {
      fieldName: 'measureType',
      label: '计量方式',
      component: 'Select',
      componentProps: {
        options: MEASURE_TYPE_OPTIONS,
        placeholder: '请选择计量方式',
      },
      rules: z.number().default(1),
    },
    {
      fieldName: 'wageMode',
      label: '计薪方式',
      component: 'Select',
      componentProps: {
        options: WAGE_MODE_OPTIONS,
        placeholder: '请选择辅助工时计薪方式',
      },
      rules: z.number().default(1),
    },
    {
      fieldName: 'wageRate',
      label: '计薪单价',
      component: 'InputNumber',
      dependencies: {
        triggerFields: ['wageMode'],
        show: (values) => values.wageMode === 2,
      },
      componentProps: {
        addonAfter: '元/小时',
        class: 'w-full',
        min: 0,
        precision: 4,
        placeholder: '本项辅助工时的小时工资',
      },
      rules: z.number().min(0).optional(),
    },
    {
      fieldName: 'durationMinutes',
      label: '单项分钟数',
      component: 'InputNumber',
      dependencies: {
        triggerFields: ['measureType'],
        show: (values) => values.measureType !== 4,
      },
      componentProps: {
        class: 'w-full',
        min: 0,
        precision: 2,
        placeholder: '单次/单个/单盘的耗时分钟数',
      },
      rules: z.number().min(0).optional(),
    },
    {
      fieldName: 'speedMPerMin',
      label: '折算速度',
      component: 'InputNumber',
      dependencies: {
        triggerFields: ['measureType'],
        show: (values) => values.measureType === 4,
      },
      componentProps: {
        class: 'w-full',
        addonAfter: 'ç±³/分',
        min: 0,
        precision: 2,
        placeholder: '按米折算时:时间 = ç±³æ•° Ã· é€Ÿåº¦',
      },
      rules: z.number().min(0).optional(),
    },
    {
      fieldName: 'materialFrom',
      label: '原色/材料',
      component: 'Input',
      componentProps: {
        maxLength: 100,
        placeholder: '换色场景填写起始材料,如 PVC',
      },
    },
    {
      fieldName: 'materialTo',
      label: '目标色/材料',
      component: 'Input',
      componentProps: {
        maxLength: 100,
        placeholder: '换色场景填写目标材料,如 LSZH',
      },
    },
    {
      fieldName: 'status',
      label: '状态',
      component: 'RadioGroup',
      componentProps: {
        buttonStyle: 'solid',
        optionType: 'button',
        options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
      },
      rules: z.number().default(CommonStatusEnum.ENABLE),
    },
    {
      fieldName: 'sort',
      label: '排序',
      component: 'InputNumber',
      componentProps: {
        class: 'w-full',
        min: 0,
        precision: 0,
      },
      rules: z.number().default(0),
    },
    {
      fieldName: 'remark',
      label: '备注',
      component: 'Textarea',
      formItemClass: 'col-span-2',
      componentProps: {
        maxLength: 250,
        placeholder: '请输入备注',
        rows: 2,
      },
    },
  ];
}
/** åˆ—表搜索 */
export function useGridFormSchema(): VbenFormSchema[] {
  return [
    {
      fieldName: 'processId',
      label: '工序',
      component: 'ApiSelect',
      componentProps: {
        allowClear: true,
        api: loadProcessOptions,
        labelField: 'label',
        valueField: 'value',
        placeholder: '请选择工序',
        showSearch: true,
        filterOption: true,
      },
    },
    {
      fieldName: 'itemName',
      label: '项目名称',
      component: 'Input',
      componentProps: {
        allowClear: true,
        placeholder: '请输入项目名称',
      },
    },
    {
      fieldName: 'status',
      label: '状态',
      component: 'Select',
      componentProps: {
        allowClear: true,
        options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
        placeholder: '请选择状态',
      },
    },
  ];
}
/** åˆ—表字段 */
export function useGridColumns(): VxeTableGridOptions<MesProWorktimeApi.AuxItem>['columns'] {
  return [
    { field: 'processName', title: '工序', minWidth: 150 },
    {
      field: 'workstationName',
      title: '适用机台',
      minWidth: 150,
      formatter: ({ cellValue }) => cellValue || '不限机台',
    },
    { field: 'itemName', title: '辅助项目', minWidth: 160 },
    { field: 'itemCode', title: '项目编码', minWidth: 150 },
    {
      field: 'measureType',
      title: '计量方式',
      width: 110,
      formatter: ({ cellValue }) => getMeasureTypeLabel(cellValue),
    },
    {
      field: 'wageMode',
      title: '计薪方式',
      width: 150,
      formatter: ({ cellValue }) => getWageModeLabel(cellValue),
    },
    {
      field: 'wageRate',
      title: '计薪单价',
      width: 120,
      formatter: ({ cellValue, row }) =>
        row.wageMode === 2 && cellValue != null ? `${cellValue} å…ƒ/小时` : '-',
    },
    { field: 'durationMinutes', title: '单项分钟数', width: 110 },
    {
      field: 'speedMPerMin',
      title: '折算速度(m/分)',
      width: 130,
      formatter: ({ cellValue }) => cellValue || '-',
    },
    {
      field: 'materialFrom',
      title: '换色材料',
      minWidth: 160,
      formatter: ({ row }) =>
        row.materialFrom || row.materialTo
          ? `${row.materialFrom || '-'} â†’ ${row.materialTo || '-'}`
          : '-',
    },
    {
      field: 'status',
      title: '状态',
      width: 90,
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.COMMON_STATUS },
      },
    },
    { field: 'sort', title: '排序', width: 80 },
    {
      title: '操作',
      width: 160,
      fixed: 'right',
      slots: { default: 'actions' },
    },
  ];
}
src/views/mes/pro/worktime-aux/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,124 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MesProWorktimeApi } from '#/api/mes/pro/worktime';
import { Page, useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
  deleteWorktimeAux,
  getWorktimeAuxPage,
} from '#/api/mes/pro/worktime';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
const [FormModal, formModalApi] = useVbenModal({
  connectedComponent: Form,
  destroyOnClose: true,
});
function handleRefresh() {
  gridApi.query();
}
function handleCreate() {
  formModalApi.setData({ formType: 'create' }).open();
}
function handleEdit(row: MesProWorktimeApi.AuxItem) {
  formModalApi.setData({ id: row.id, formType: 'update' }).open();
}
async function handleDelete(row: MesProWorktimeApi.AuxItem) {
  const hideLoading = message.loading({
    content: $t('ui.actionMessage.deleting', [row.itemName]),
    duration: 0,
  });
  try {
    await deleteWorktimeAux(row.id!);
    message.success($t('ui.actionMessage.deleteSuccess', [row.itemName]));
    handleRefresh();
  } finally {
    hideLoading();
  }
}
const [Grid, gridApi] = useVbenVxeGrid({
  formOptions: {
    schema: useGridFormSchema(),
  },
  gridOptions: {
    columns: useGridColumns(),
    height: 'auto',
    keepSource: true,
    proxyConfig: {
      ajax: {
        query: async ({ page }, formValues) => {
          return await getWorktimeAuxPage({
            pageNo: page.currentPage,
            pageSize: page.pageSize,
            ...formValues,
          });
        },
      },
    },
    rowConfig: {
      isHover: true,
      keyField: 'id',
    },
    toolbarConfig: {
      refresh: true,
      search: true,
    },
  } as VxeTableGridOptions<MesProWorktimeApi.AuxItem>,
});
</script>
<template>
  <Page auto-content-height>
    <FormModal @success="handleRefresh" />
    <Grid table-title="辅助工时配置">
      <template #toolbar-tools>
        <TableAction
          :actions="[
            {
              label: $t('ui.actionTitle.create', ['辅助工时项目']),
              type: 'primary',
              icon: ACTION_ICON.ADD,
              auth: ['mes:pro-worktime-aux:create'],
              onClick: handleCreate,
            },
          ]"
        />
      </template>
      <template #actions="{ row }">
        <TableAction
          :actions="[
            {
              label: $t('common.edit'),
              type: 'link',
              icon: ACTION_ICON.EDIT,
              auth: ['mes:pro-worktime-aux:update'],
              onClick: handleEdit.bind(null, row),
            },
            {
              label: $t('common.delete'),
              type: 'link',
              danger: true,
              icon: ACTION_ICON.DELETE,
              auth: ['mes:pro-worktime-aux:delete'],
              popConfirm: {
                title: $t('ui.actionMessage.deleteConfirm', [row.itemName]),
                confirm: handleDelete.bind(null, row),
              },
            },
          ]"
        />
      </template>
    </Grid>
  </Page>
</template>
src/views/mes/pro/worktime-aux/modules/form.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,81 @@
<script lang="ts" setup>
import type { MesProWorktimeApi } from '#/api/mes/pro/worktime';
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import {
  createWorktimeAux,
  getWorktimeAux,
  updateWorktimeAux,
} from '#/api/mes/pro/worktime';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formType = ref<'create' | 'update'>('create');
const formData = ref<MesProWorktimeApi.AuxItem>();
const [Form, formApi] = useVbenForm({
  commonConfig: {
    componentProps: { class: 'w-full' },
    formItemClass: 'col-span-2',
    labelWidth: 110,
  },
  layout: 'horizontal',
  schema: [],
  showDefaultActions: false,
  wrapperClass: 'grid-cols-2',
});
const [Modal, modalApi] = useVbenModal({
  async onConfirm() {
    const { valid } = await formApi.validate();
    if (!valid) {
      return;
    }
    modalApi.lock();
    try {
      const data = (await formApi.getValues()) as MesProWorktimeApi.AuxItem;
      await (formData.value?.id
        ? updateWorktimeAux(data)
        : createWorktimeAux(data));
      await modalApi.close();
      emit('success');
      message.success($t('ui.actionMessage.operationSuccess'));
    } finally {
      modalApi.unlock();
    }
  },
  async onOpenChange(isOpen: boolean) {
    if (!isOpen) {
      formData.value = undefined;
      return;
    }
    formApi.setState({ schema: useFormSchema() });
    const data = modalApi.getData<{ formType: 'create' | 'update'; id?: number }>();
    formType.value = data.formType;
    if (!data?.id) {
      return;
    }
    modalApi.lock();
    try {
      formData.value = await getWorktimeAux(data.id);
      await formApi.setValues(formData.value);
    } finally {
      modalApi.unlock();
    }
  },
});
</script>
<template>
  <Modal :title="formType === 'create' ? '新增辅助工时项目' : '编辑辅助工时项目'">
    <Form class="mx-4" />
  </Modal>
</template>
src/views/mes/pro/worktime-report/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,220 @@
<script lang="ts" setup>
import type { EchartsUIType } from '@vben/plugins/echarts';
import type { MesProWorktimeApi } from '#/api/mes/pro/worktime';
import type { Dayjs } from 'dayjs';
import { onMounted, ref } from 'vue';
import { Page } from '@vben/common-ui';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
import dayjs from 'dayjs';
import {
  Alert,
  Button,
  Card,
  DatePicker,
  message,
  Select,
  Statistic,
  Table,
} from 'ant-design-vue';
import { getWorktimeReport } from '#/api/mes/pro/worktime';
defineOptions({ name: 'MesProWorktimeReport' });
const { RangePicker } = DatePicker;
const DIM_OPTIONS = [
  { label: '按人', value: 1 },
  { label: '按工序', value: 2 },
  { label: '按机台', value: 3 },
  { label: '按日期', value: 4 },
];
interface ReportRow extends MesProWorktimeApi.ReportRow {
  actualHoursText?: string;
  standardHoursText?: string;
  diffText?: string;
  diffRateText?: string;
}
const range = ref<[Dayjs, Dayjs]>([
  dayjs().subtract(29, 'day').startOf('day'),
  dayjs().endOf('day'),
]);
const dimType = ref(1);
const loading = ref(false);
const rows = ref<ReportRow[]>([]);
const summary = ref<ReportRow>({});
const chartRef = ref<EchartsUIType>();
const { renderEcharts } = useEcharts(chartRef);
const columns = [
  { dataIndex: 'dimName', key: 'dimName', title: '分组', width: 180 },
  { dataIndex: 'feedbackCount', key: 'feedbackCount', title: '报工单数', width: 100 },
  {
    dataIndex: 'productionLength',
    key: 'productionLength',
    title: '生产长度(ç±³)',
    width: 130,
  },
  {
    dataIndex: 'actualHoursText',
    key: 'actualHoursText',
    title: '实际工时(h)',
    width: 120,
  },
  {
    dataIndex: 'standardHoursText',
    key: 'standardHoursText',
    title: '标准工时(h)',
    width: 120,
  },
  { dataIndex: 'diffText', key: 'diffText', title: '工时差异(h)', width: 120 },
  { dataIndex: 'diffRateText', key: 'diffRateText', title: '差异率', width: 100 },
  { dataIndex: 'wageAmount', key: 'wageAmount', title: '报工工资(元)', width: 130 },
];
function formatRow(row: MesProWorktimeApi.ReportRow): ReportRow {
  const diff = row.workHoursDiff ?? 0;
  const rate = row.workHoursDiffRate;
  return {
    ...row,
    actualHoursText: Number(row.actualHours ?? 0).toFixed(2),
    standardHoursText: Number(row.standardHours ?? 0).toFixed(2),
    diffText: `${diff > 0 ? '+' : ''}${diff.toFixed(2)}`,
    diffRateText: rate == null ? '-' : `${rate > 0 ? '+' : ''}${rate}%`,
  };
}
async function loadData() {
  if (!range.value?.[0] || !range.value?.[1]) {
    message.warning('请选择统计周期');
    return;
  }
  loading.value = true;
  try {
    const result = await getWorktimeReport({
      beginTime: range.value[0].format('YYYY-MM-DD HH:mm:ss'),
      endTime: range.value[1].format('YYYY-MM-DD HH:mm:ss'),
      dimType: dimType.value,
    });
    rows.value = (result?.rows ?? []).map(formatRow);
    summary.value = formatRow(result?.summary ?? {});
    await renderChart();
  } finally {
    loading.value = false;
  }
}
async function renderChart() {
  const names = rows.value.map((row) => row.dimName ?? '');
  await renderEcharts({
    grid: { bottom: 60, left: 50, right: 20, top: 40 },
    legend: { data: ['实际工时', '标准工时'] },
    series: [
      {
        barMaxWidth: 28,
        data: rows.value.map((row) => Number(row.actualHours ?? 0)),
        name: '实际工时',
        type: 'bar',
        itemStyle: { color: '#1677ff' },
      },
      {
        barMaxWidth: 28,
        data: rows.value.map((row) => Number(row.standardHours ?? 0)),
        name: '标准工时',
        type: 'bar',
        itemStyle: { color: '#52c41a' },
      },
    ],
    tooltip: { trigger: 'axis' },
    xAxis: {
      axisLabel: { rotate: names.length > 6 ? 30 : 0 },
      data: names,
      type: 'category',
    },
    yAxis: { name: '小时', type: 'value' },
  });
}
onMounted(() => {
  loadData();
});
</script>
<template>
  <Page auto-content-height>
    <div class="flex h-full flex-col gap-3">
      <Card size="small">
        <div class="flex flex-wrap items-center gap-3">
          <span class="text-sm text-gray-500">统计周期</span>
          <RangePicker v-model:value="range" show-time />
          <span class="text-sm text-gray-500">统计维度</span>
          <Select v-model:value="dimType" class="w-32" :options="DIM_OPTIONS" />
          <Button type="primary" :loading="loading" @click="loadData">
            æŸ¥è¯¢
          </Button>
        </div>
      </Card>
      <div class="grid grid-cols-2 gap-3 md:grid-cols-5">
        <Card size="small">
          <Statistic title="报工单数" :value="summary.feedbackCount ?? 0" />
        </Card>
        <Card size="small">
          <Statistic
            title="实际工时(h)"
            :value="Number(summary.actualHours ?? 0)"
            :precision="2"
          />
        </Card>
        <Card size="small">
          <Statistic
            title="标准工时(h)"
            :value="Number(summary.standardHours ?? 0)"
            :precision="2"
          />
        </Card>
        <Card size="small">
          <Statistic
            title="工时差异(h)"
            :value="Number(summary.workHoursDiff ?? 0)"
            :precision="2"
            :value-style="{
              color: Number(summary.workHoursDiff ?? 0) > 0 ? '#cf1322' : '#3f8600',
            }"
          />
        </Card>
        <Card size="small">
          <Statistic
            title="报工工资(元)"
            :value="Number(summary.wageAmount ?? 0)"
            :precision="2"
          />
        </Card>
      </div>
      <Alert
        v-if="summary.workHoursDiffRate != null && summary.workHoursDiffRate > 20"
        message="整体工时高于标准工时超过 20%"
        :description="`本周期实际工时 ${summary.actualHoursText} å°æ—¶ï¼Œæ ‡å‡†å·¥æ—¶ ${summary.standardHoursText} å°æ—¶ï¼Œå·®å¼‚ ${summary.diffText} å°æ—¶ï¼ˆ${summary.diffRateText})。建议按分组下钻核对应报工工时与辅助项次数。`"
        show-icon
        type="warning"
      />
      <Card class="flex-1" size="small" title="实际工时 vs æ ‡å‡†å·¥æ—¶">
        <EchartsUI ref="chartRef" class="h-[260px] w-full" />
      </Card>
      <Card class="flex-1" size="small" title="分组明细">
        <Table
          :columns="columns"
          :data-source="rows"
          :loading="loading"
          :pagination="false"
          row-key="dimKey"
          size="small"
        />
      </Card>
    </div>
  </Page>
</template>
src/views/mes/process-design/process/data.ts
@@ -20,6 +20,38 @@
/** è¡¨å•类型 */
export type FormType = 'create' | 'detail' | 'update';
/** è®¡è´¹ç±»åž‹ï¼š1-计时 2-计件 */
export const PROCESS_BILLING_TYPE_OPTIONS = [
  { label: '计时(元/小时)', value: 1 },
  { label: '计件(元/件)', value: 2 },
];
/** è®¡è´¹ç±»åž‹åç§° */
export function getBillingTypeLabel(billingType?: number) {
  return (
    PROCESS_BILLING_TYPE_OPTIONS.find((item) => item.value === billingType)
      ?.label ?? '-'
  );
}
/** å·¥èµ„定额单位 */
export function getWageRateUnit(billingType?: number) {
  return billingType === 1 ? '元/小时' : '元/ä»¶';
}
/** æ ‡å‡†å·¥æ—¶è®¡ç®—方式 */
export const STD_TIME_MODE_OPTIONS = [
  { label: '未启用', value: 0 },
  { label: '转速 Ã— èŠ‚è·', value: 1 },
  { label: '线速度直取', value: 2 },
  { label: '满速百分比 Ã— èŠ‚è·', value: 3 },
];
/** æ ‡å‡†å·¥æ—¶è®¡ç®—方式名称 */
export function getStdTimeModeLabel(mode?: number) {
  return STD_TIME_MODE_OPTIONS.find((item) => item.value === mode)?.label ?? '-';
}
/** æ–°å¢ž/修改生产工序的表单 */
export function useFormSchema(formApi?: VbenFormApi): VbenFormSchema[] {
  return [
@@ -75,6 +107,146 @@
        options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
      },
      rules: z.number().default(CommonStatusEnum.ENABLE),
    },
    {
      fieldName: 'billingType',
      label: '计费类型',
      component: 'Select',
      componentProps: {
        options: PROCESS_BILLING_TYPE_OPTIONS,
        placeholder: '请选择计费类型',
      },
      rules: z.number().default(2),
    },
    {
      fieldName: 'wageRate',
      label: '工资定额',
      component: 'InputNumber',
      dependencies: {
        triggerFields: ['billingType'],
        componentProps: (values: Record<string, unknown>) => ({
          addonAfter: getWageRateUnit(values.billingType as number),
          placeholder:
            values.billingType === 1
              ? '请输入小时工资定额'
              : '请输入计件单价',
        }),
      },
      componentProps: {
        class: 'w-full',
        min: 0,
        precision: 2,
      },
      rules: z.number().min(0, '工资定额不能小于 0'),
    },
    {
      fieldName: 'auxHourlyRate',
      label: '辅助小时工资',
      component: 'InputNumber',
      componentProps: {
        addonAfter: '元/小时',
        class: 'w-full',
        min: 0,
        precision: 4,
        placeholder: '辅助工时按工序计薪时的单价,如换线',
      },
      rules: z.number().min(0, '辅助小时工资不能小于 0').default(0),
    },
    {
      fieldName: 'stdTimeMode',
      label: '工时计算方式',
      component: 'Select',
      componentProps: {
        options: STD_TIME_MODE_OPTIONS,
        placeholder: '请选择标准工时计算方式',
      },
      rules: z.number().default(0),
    },
    {
      fieldName: 'stdTimeFactor',
      label: '折算系数',
      component: 'InputNumber',
      dependencies: {
        triggerFields: ['stdTimeMode'],
        show: (values: Record<string, unknown>) =>
          values.stdTimeMode === 1 || values.stdTimeMode === 3,
      },
      componentProps: {
        min: 0,
        precision: 4,
        placeholder: '束丝填 1.8,其余填 1',
      },
      rules: z.number().min(0).default(1),
    },
    {
      fieldName: 'maxRpm',
      label: '机台满转速',
      component: 'InputNumber',
      dependencies: {
        triggerFields: ['stdTimeMode'],
        show: (values: Record<string, unknown>) => values.stdTimeMode === 3,
      },
      componentProps: {
        addonAfter: 'r/min',
        min: 0,
        precision: 0,
        placeholder: '请输入机台满转速',
      },
      rules: z.number().min(0).optional().default(0),
    },
    {
      fieldName: 'stdRpmParamCode',
      label: '转速参数编码',
      component: 'Input',
      dependencies: {
        triggerFields: ['stdTimeMode'],
        show: (values: Record<string, unknown>) =>
          values.stdTimeMode === 1 || values.stdTimeMode === 3,
      },
      componentProps: {
        maxLength: 64,
        placeholder: '填写“工序参数管理”里绑定的参数编码,如 RPM',
      },
    },
    {
      fieldName: 'stdPitchParamCode',
      label: '节距参数编码',
      component: 'Input',
      dependencies: {
        triggerFields: ['stdTimeMode'],
        show: (values: Record<string, unknown>) =>
          values.stdTimeMode === 1 || values.stdTimeMode === 3,
      },
      componentProps: {
        maxLength: 64,
        placeholder: '填写参数编码,如 PITCH',
      },
    },
    {
      fieldName: 'stdSpeedParamCode',
      label: '线速度参数编码',
      component: 'Input',
      dependencies: {
        triggerFields: ['stdTimeMode'],
        show: (values: Record<string, unknown>) => values.stdTimeMode === 2,
      },
      componentProps: {
        maxLength: 64,
        placeholder: '填写参数编码(米/分),如 SPEED',
      },
    },
    {
      fieldName: 'stdPercentParamCode',
      label: '转速百分比编码',
      component: 'Input',
      dependencies: {
        triggerFields: ['stdTimeMode'],
        show: (values: Record<string, unknown>) => values.stdTimeMode === 3,
      },
      componentProps: {
        maxLength: 64,
        placeholder: '填写参数编码(%),如 RPM_PERCENT',
      },
    },
    {
      fieldName: 'attention',
@@ -148,6 +320,32 @@
    },
    { field: 'name', title: '工序名称', minWidth: 180 },
    {
      field: 'billingType',
      title: '计费类型',
      width: 120,
      formatter: ({ cellValue }) => getBillingTypeLabel(cellValue),
    },
    {
      field: 'wageRate',
      title: '工资定额',
      width: 120,
      formatter: ({ cellValue, row }) =>
        cellValue == null ? '-' : `${cellValue} ${getWageRateUnit(row.billingType)}`,
    },
    {
      field: 'auxHourlyRate',
      title: '辅助小时工资',
      width: 130,
      formatter: ({ cellValue }) =>
        cellValue == null ? '-' : `${cellValue} å…ƒ/小时`,
    },
    {
      field: 'stdTimeMode',
      title: '工时计算方式',
      width: 150,
      formatter: ({ cellValue }) => getStdTimeModeLabel(cellValue),
    },
    {
      field: 'status',
      title: '状态',
      width: 100,
src/views/mes/process-design/process/index.vue
@@ -18,6 +18,9 @@
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
import ParamManage from './modules/param-manage.vue';
import QualityRuleManage from './modules/quality-rule-manage.vue';
import WageFieldManage from './modules/wage-field-manage.vue';
import WageRuleManage from './modules/wage-rule-manage.vue';
const [FormModal, formModalApi] = useVbenModal({
  connectedComponent: Form,
@@ -29,6 +32,21 @@
  destroyOnClose: true,
});
const [WageFieldModal, wageFieldModalApi] = useVbenModal({
  connectedComponent: WageFieldManage,
  destroyOnClose: true,
});
const [WageRuleModal, wageRuleModalApi] = useVbenModal({
  connectedComponent: WageRuleManage,
  destroyOnClose: true,
});
const [QualityRuleModal, qualityRuleModalApi] = useVbenModal({
  connectedComponent: QualityRuleManage,
  destroyOnClose: true,
});
/** åˆ·æ–°è¡¨æ ¼ */
function handleRefresh() {
  gridApi.query();
@@ -37,6 +55,31 @@
/** ç®¡ç†å·¥åºç»‘定的工艺参数 */
function handleParamManage(row: MesProProcessApi.Process) {
  paramManageModalApi
    .setData({ processId: row.id, processName: row.name })
    .open();
}
/** ç®¡ç†å·¥åºå·¥èµ„字段 */
function handleWageFieldManage(row: MesProProcessApi.Process) {
  wageFieldModalApi
    .setData({ processId: row.id, processName: row.name })
    .open();
}
/** ç®¡ç†å·¥åºå·¥èµ„单价规则 */
function handleWageRuleManage(row: MesProProcessApi.Process) {
  wageRuleModalApi
    .setData({
      processId: row.id,
      processName: row.name,
      billingType: row.billingType,
    })
    .open();
}
/** ç®¡ç†è´¨é‡å·¥èµ„规则 */
function handleQualityRuleManage(row: MesProProcessApi.Process) {
  qualityRuleModalApi
    .setData({ processId: row.id, processName: row.name })
    .open();
}
@@ -111,6 +154,9 @@
<template>
  <Page auto-content-height><FormModal @success="handleRefresh" />
    <ParamManageModal @success="handleRefresh" />
    <WageFieldModal @success="handleRefresh" />
    <WageRuleModal @success="handleRefresh" />
    <QualityRuleModal @success="handleRefresh" />
    <Grid table-title="生产工序列表">
      <template #toolbar-tools>
        <TableAction
@@ -164,6 +210,20 @@
              },
            },
          ]"
          :drop-down-actions="[
            {
              label: '工资字段',
              onClick: handleWageFieldManage.bind(null, row),
            },
            {
              label: '单价规则',
              onClick: handleWageRuleManage.bind(null, row),
            },
            {
              label: '质量规则',
              onClick: handleQualityRuleManage.bind(null, row),
            },
          ]"
        />
      </template>
    </Grid>
src/views/mes/process-design/process/modules/form.vue
@@ -2,6 +2,7 @@
import type { FormType } from '../data';
import type { MesProProcessApi } from '#/api/mes/pro/process';
import type { MesProWageApi } from '#/api/mes/pro/wage';
import { computed, ref } from 'vue';
@@ -15,14 +16,18 @@
  getProcess,
  updateProcess,
} from '#/api/mes/pro/process';
import { getWageFieldList, saveWageFieldList } from '#/api/mes/pro/wage';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
import ContentList from './content-list.vue';
import WageFieldEditor from './wage-field-editor.vue';
const emit = defineEmits(['success']);
const formType = ref<FormType>('create'); // è¡¨å•模式
const formData = ref<MesProProcessApi.Process>();
/** æ–°å¢ž/修改时随工序一起维护的自定义工资字段 */
const wageFields = ref<MesProWageApi.WageField[]>([]);
const isDetail = computed(() => formType.value === 'detail'); // æ˜¯å¦æŸ¥çœ‹æ¨¡å¼
const getTitle = computed(() => {
@@ -62,7 +67,45 @@
    // æäº¤è¡¨å•
    const data = (await formApi.getValues()) as MesProProcessApi.Process;
    try {
      await (formData.value?.id ? updateProcess(data) : createProcess(data));
      // å·¥èµ„字段先做前端校验,避免后端整表保存时报一条笼统错误
      for (const [index, item] of wageFields.value.entries()) {
        if (!item.fieldCode?.trim()) {
          message.warning(`工资字段第 ${index + 1} è¡Œï¼šå­—段编码不能为空`);
          return;
        }
        if (!item.fieldName?.trim()) {
          message.warning(`工资字段第 ${index + 1} è¡Œï¼šå­—段名称不能为空`);
          return;
        }
      }
      const isUpdate = Boolean(formData.value?.id);
      const id = formData.value?.id ?? (await createProcess(data));
      if (isUpdate) {
        await updateProcess(data);
      } else {
        // å·¥åºå·²åˆ›å»ºæˆåŠŸï¼šè®°ä¸‹ç¼–å·ï¼Œå·¥èµ„å­—æ®µä¿å­˜å¤±è´¥æ—¶é‡è¯•èµ°ä¿®æ”¹ï¼Œé¿å…é‡å¤å»ºå·¥åº
        formData.value = { ...data, id };
      }
      // ä¿®æ”¹æ—¶å…¨é‡è¦†ç›–;新增时只有填了字段才保存
      if (id && (isUpdate || wageFields.value.length > 0)) {
        await saveWageFieldList(
          id,
          wageFields.value.map((item, index) => ({
            id: item.id,
            processId: id,
            fieldCode: item.fieldCode?.trim(),
            fieldName: item.fieldName?.trim(),
            fieldType: item.fieldType ?? 1,
            unit: item.unit,
            fieldValue: item.fieldValue,
            calcRole: item.calcRole ?? 1,
            unitPrice: item.calcRole === 3 ? item.unitPrice : undefined,
            sort: item.sort ?? index + 1,
            status: item.status ?? 0,
            remark: item.remark,
          })),
        );
      }
      // å…³é—­å¹¶æç¤º
      await modalApi.close();
      emit('success');
@@ -74,9 +117,11 @@
  async onOpenChange(isOpen: boolean) {
    if (!isOpen) {
      formData.value = undefined;
      wageFields.value = [];
      return;
    }
    formApi.setState({ schema: useFormSchema(formApi) });
    wageFields.value = [];
    // åŠ è½½æ•°æ®
    const data = modalApi.getData<{ formType: FormType; id?: number }>();
    formType.value = data.formType;
@@ -90,6 +135,8 @@
      formData.value = await getProcess(data.id);
      // è®¾ç½®åˆ° values
      await formApi.setValues(formData.value);
      // å¸¦å‡ºå·²é…ç½®çš„工序工资字段
      wageFields.value = (await getWageFieldList(data.id)) ?? [];
    } finally {
      modalApi.unlock();
    }
@@ -101,8 +148,12 @@
</script>
<template>
  <Modal :title="getTitle" class="w-3/5">
  <Modal :title="getTitle" class="w-4/5">
    <Form class="mx-4" />
    <Divider class="!my-3" orientation="left">工资字段</Divider>
    <div class="mx-4">
      <WageFieldEditor :disabled="isDetail" :rows="wageFields" />
    </div>
    <!-- ç¼–辑/详情模式下展示工序操作步骤子表,新增模式下隐藏 -->
    <template v-if="processId">
      <Divider class="!my-3" orientation="left">操作步骤</Divider>
src/views/mes/process-design/process/modules/quality-rule-manage.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,279 @@
<script lang="ts" setup>
import type { MesProRouteApi } from '#/api/mes/pro/route';
import type { MesProWageApi } from '#/api/mes/pro/wage';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Button, Input, InputNumber, message, Select } from 'ant-design-vue';
import { getRouteSimpleList } from '#/api/mes/pro/route';
import {
  createQualityRule,
  deleteQualityRule,
  getQualityRuleList,
  updateQualityRule,
} from '#/api/mes/pro/wage';
const emit = defineEmits(['success']);
/** è§„则类型:1-合格率系数 2-不合格扣款 */
const RULE_TYPE_OPTIONS = [
  { label: '合格率系数', value: 1 },
  { label: '不合格扣款', value: 2 },
];
const processId = ref<number>();
const processName = ref<string>('');
const loading = ref(false);
const list = ref<MesProWageApi.QualityRule[]>([]);
const originalIds = ref<number[]>([]);
const routeOptions = ref<Array<{ label: string; value: number }>>([]);
const getTitle = computed(() => `质量工资规则 - ${processName.value || ''}`);
/** åŠ è½½è§„åˆ™ä¸Žè·¯çº¿ */
async function loadData() {
  if (!processId.value) {
    return;
  }
  loading.value = true;
  try {
    const [rules, routes] = await Promise.all([
      getQualityRuleList(processId.value),
      getRouteSimpleList(),
    ]);
    list.value = rules;
    originalIds.value = rules
      .map((item) => item.id)
      .filter((id): id is number => id !== undefined);
    routeOptions.value = (routes as MesProRouteApi.Route[]).map((item) => ({
      label: `${item.code ?? ''} ${item.name ?? ''}`.trim(),
      value: item.id!,
    }));
  } finally {
    loading.value = false;
  }
}
/** æ–°å¢žä¸€æ¡è§„则 */
function addRow() {
  list.value.push({
    processId: processId.value,
    ruleType: 1,
    minRate: 0,
    maxRate: 100,
    coefficient: 1,
    priority: 0,
    status: 0,
  });
}
/** åˆ é™¤ä¸€æ¡è§„则 */
function removeRow(index: number) {
  list.value.splice(index, 1);
}
const [Modal, modalApi] = useVbenModal({
  async onConfirm() {
    if (processId.value === undefined) {
      return;
    }
    for (const [index, item] of list.value.entries()) {
      if (item.minRate === undefined || item.maxRate === undefined) {
        message.warning(`第 ${index + 1} è¡Œï¼šåˆæ ¼çŽ‡åŒºé—´ä¸èƒ½ä¸ºç©º`);
        return;
      }
      if (item.minRate >= item.maxRate) {
        message.warning(`第 ${index + 1} è¡Œï¼šåˆæ ¼çŽ‡ä¸‹é™å¿…é¡»å°äºŽä¸Šé™`);
        return;
      }
      if (item.ruleType === 1 && (!item.coefficient || item.coefficient <= 0)) {
        message.warning(`第 ${index + 1} è¡Œï¼šåˆæ ¼çŽ‡ç³»æ•°å¿…é¡»å¤§äºŽ 0`);
        return;
      }
      if (
        item.ruleType === 2 &&
        (item.deductionPerUnit === undefined || item.deductionPerUnit === null)
      ) {
        message.warning(`第 ${index + 1} è¡Œï¼šæ¯ä»¶ä¸åˆæ ¼æ‰£æ¬¾ä¸èƒ½ä¸ºç©º`);
        return;
      }
    }
    modalApi.lock();
    try {
      const keepIds = list.value
        .map((item) => item.id)
        .filter((id): id is number => id !== undefined);
      const removedIds = originalIds.value.filter((id) => !keepIds.includes(id));
      for (const id of removedIds) {
        await deleteQualityRule(id);
      }
      for (const item of list.value) {
        const payload: MesProWageApi.QualityRule = {
          id: item.id,
          processId: processId.value,
          routeId: item.routeId,
          ruleType: item.ruleType ?? 1,
          minRate: item.minRate,
          maxRate: item.maxRate,
          coefficient: item.ruleType === 1 ? item.coefficient : undefined,
          deductionPerUnit:
            item.ruleType === 2 ? item.deductionPerUnit : undefined,
          priority: item.priority ?? 0,
          status: item.status ?? 0,
          remark: item.remark,
        };
        if (item.id) {
          await updateQualityRule(payload);
        } else {
          await createQualityRule(payload);
        }
      }
      message.success('保存成功');
      await modalApi.close();
      emit('success');
    } finally {
      modalApi.unlock();
    }
  },
  async onOpenChange(isOpen: boolean) {
    if (!isOpen) {
      processId.value = undefined;
      list.value = [];
      originalIds.value = [];
      return;
    }
    const data = modalApi.getData<{ processId: number; processName?: string }>();
    processId.value = data.processId;
    processName.value = data.processName || '';
    await loadData();
  },
});
</script>
<template>
  <Modal :title="getTitle" class="w-4/5">
    <div class="mx-4">
      <div class="mb-3 rounded bg-blue-50 p-3 text-xs text-gray-600">
        è´¨æ£€å·¥åºå®¡æ‰¹æ—¶æŒ‰è¿‡ç¨‹æ£€éªŒåˆæ ¼çŽ‡åŒ¹é…è§„åˆ™ï¼šåˆæ ¼çŽ‡ â‰¥ ä¸‹é™ä¸” &lt; ä¸Šé™å³å‘½ä¸­ã€‚
        ç³»æ•°è§„则按「工资 Ã— ç³»æ•°ã€è®¡ç®—,扣款规则按「工资 âˆ’ ä¸åˆæ ¼æ•°é‡ Ã— æ¯ä»¶æ‰£æ¬¾ã€è®¡ç®—ï¼›
        åŒä¸€åˆæ ¼çŽ‡å‘½ä¸­å¤šæ¡æ—¶å–ä¼˜å…ˆçº§å¤§çš„ï¼Œè·¯çº¿ä¸ºç©ºè§†ä¸ºé€šç”¨è§„åˆ™ã€‚
      </div>
      <div class="mb-2 flex items-center justify-between">
        <span class="text-sm font-medium">质量工资规则</span>
        <Button type="primary" size="small" @click="addRow">新增规则</Button>
      </div>
      <div v-if="list.length > 0" class="rounded border border-gray-200">
        <div
          class="grid grid-cols-24 gap-2 border-b border-gray-200 bg-gray-50 p-2 text-xs font-medium"
        >
          <div class="col-span-3">规则类型</div>
          <div class="col-span-3">工艺路线</div>
          <div class="col-span-2">下限(%)</div>
          <div class="col-span-2">上限(%)</div>
          <div class="col-span-3">系数 / æ¯ä»¶æ‰£æ¬¾</div>
          <div class="col-span-1">优先级</div>
          <div class="col-span-2">状态</div>
          <div class="col-span-4">备注</div>
          <div class="col-span-2 text-center">操作</div>
        </div>
        <div
          v-for="(item, index) in list"
          :key="item.id ?? `new-${index}`"
          class="grid grid-cols-24 items-center gap-2 border-b border-gray-100 p-2"
        >
          <div class="col-span-3">
            <Select
              v-model:value="item.ruleType"
              :options="RULE_TYPE_OPTIONS"
              size="small"
              class="w-full"
            />
          </div>
          <div class="col-span-3">
            <Select
              v-model:value="item.routeId"
              :allow-clear="true"
              :options="routeOptions"
              option-filter-prop="label"
              placeholder="通用"
              show-search
              size="small"
              class="w-full"
            />
          </div>
          <div class="col-span-2">
            <InputNumber
              v-model:value="item.minRate"
              :max="100"
              :min="0"
              :precision="2"
              size="small"
              class="w-full"
            />
          </div>
          <div class="col-span-2">
            <InputNumber
              v-model:value="item.maxRate"
              :max="100"
              :min="0"
              :precision="2"
              size="small"
              class="w-full"
            />
          </div>
          <div class="col-span-3">
            <InputNumber
              v-if="item.ruleType === 1"
              v-model:value="item.coefficient"
              :min="0"
              :precision="4"
              placeholder="系数,如 0.92"
              size="small"
              class="w-full"
            />
            <InputNumber
              v-else
              v-model:value="item.deductionPerUnit"
              :min="0"
              :precision="4"
              placeholder="元/ä»¶"
              size="small"
              class="w-full"
            />
          </div>
          <div class="col-span-1">
            <InputNumber v-model:value="item.priority" :min="0" size="small" class="w-full" />
          </div>
          <div class="col-span-2">
            <Select
              v-model:value="item.status"
              :options="[
                { label: '启用', value: 0 },
                { label: '禁用', value: 1 },
              ]"
              size="small"
              class="w-full"
            />
          </div>
          <div class="col-span-4">
            <Input v-model:value="item.remark" placeholder="备注" size="small" />
          </div>
          <div class="col-span-2 flex items-center justify-center">
            <Button type="text" danger size="small" @click="removeRow(index)">
              åˆ é™¤
            </Button>
          </div>
        </div>
      </div>
      <div
        v-else
        class="rounded border border-dashed border-gray-300 p-4 text-center text-gray-400"
      >
        {{ loading ? '加载中…' : '未配置规则时质检工序不做质量工资调整' }}
      </div>
    </div>
  </Modal>
</template>
src/views/mes/process-design/process/modules/wage-field-editor.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,212 @@
<script lang="ts" setup>
import type { MesProWageApi } from '#/api/mes/pro/wage';
import { Button, Input, InputNumber, Select } from 'ant-design-vue';
/**
 * å·¥åºå·¥èµ„字段编辑器(受控:直接修改传入的 rows æ•°ç»„)
 * æ–°å¢ž/修改工序表单与独立的「工资字段」弹框共用同一套列
 */
const props = withDefaults(
  defineProps<{
    disabled?: boolean;
    rows: MesProWageApi.WageField[];
  }>(),
  {
    disabled: false,
  },
);
/** å­—段类型:1-数值 2-文本 3-下拉 4-布尔 */
const FIELD_TYPE_OPTIONS = [
  { label: '数值', value: 1 },
  { label: '文本', value: 2 },
  { label: '下拉', value: 3 },
  { label: '布尔', value: 4 },
];
/** è®¡ç®—角色:1-系数 2-加减金额 3-计价数量 4-仅记录 */
const CALC_ROLE_OPTIONS = [
  { label: '系数(连乘)', value: 1 },
  { label: '加减金额', value: 2 },
  { label: '计价数量', value: 3 },
  { label: '仅记录', value: 4 },
];
/** æ–°å¢žä¸€è¡Œå·¥èµ„字段 */
function addRow() {
  props.rows.push({
    fieldCode: '',
    fieldName: '',
    fieldType: 1,
    calcRole: 1,
    unitPrice: undefined,
    sort: props.rows.length + 1,
    status: 0,
  });
}
/** åˆ é™¤ä¸€è¡Œ */
function removeRow(index: number) {
  props.rows.splice(index, 1);
}
/** è®¡ç®—角色为「计价数量」时才需要单价 */
function needUnitPrice(calcRole?: number) {
  return calcRole === 3;
}
</script>
<template>
  <div>
    <div class="mb-2 flex items-center justify-between">
      <span class="text-sm font-medium">工序工资字段(用户自定义)</span>
      <Button
        v-if="!disabled"
        type="primary"
        size="small"
        @click="addRow"
      >
        æ–°å¢žå­—段
      </Button>
    </div>
    <div class="mb-2 text-xs text-gray-500">
      ç³»æ•°ä¸Žä¸»å·¥èµ„、辅助工资连乘(留空按 1);加减金额直接加到工资;计价数量 = å­—段值 Ã— å•价(需填单价)。
      å›ºå®šå€¼å¯ç•™ç©ºï¼Œç”±è¿‡ç¨‹æ£€éªŒæ—¶è´¨æ£€å¡«å†™ï¼Œå¡«å†™å€¼ä¼˜å…ˆäºŽå›ºå®šå€¼ã€‚
    </div>
    <div v-if="rows.length > 0" class="rounded border border-gray-200">
      <div
        class="grid grid-cols-24 gap-2 border-b border-gray-200 bg-gray-50 p-2 text-xs font-medium"
      >
        <div class="col-span-3">字段编码</div>
        <div class="col-span-3">字段名称</div>
        <div class="col-span-2">类型</div>
        <div class="col-span-2">单位</div>
        <div class="col-span-3">计算角色</div>
        <div class="col-span-2">固定值</div>
        <div class="col-span-2">单价</div>
        <div class="col-span-2">排序</div>
        <div class="col-span-2">状态</div>
        <div class="col-span-2">备注</div>
        <div class="col-span-1 text-center">操作</div>
      </div>
      <div
        v-for="(item, index) in rows"
        :key="item.id ?? `new-${index}`"
        class="grid grid-cols-24 items-center gap-2 border-b border-gray-100 p-2"
      >
        <div class="col-span-3">
          <Input
            v-model:value="item.fieldCode"
            :disabled="disabled"
            placeholder="如 TEMP_COEF"
            size="small"
          />
        </div>
        <div class="col-span-3">
          <Input
            v-model:value="item.fieldName"
            :disabled="disabled"
            placeholder="如 é«˜æ¸©ç³»æ•°"
            size="small"
          />
        </div>
        <div class="col-span-2">
          <Select
            v-model:value="item.fieldType"
            :disabled="disabled"
            :options="FIELD_TYPE_OPTIONS"
            class="w-full"
            size="small"
          />
        </div>
        <div class="col-span-2">
          <Input
            v-model:value="item.unit"
            :disabled="disabled"
            placeholder="单位"
            size="small"
          />
        </div>
        <div class="col-span-3">
          <Select
            v-model:value="item.calcRole"
            :disabled="disabled"
            :options="CALC_ROLE_OPTIONS"
            class="w-full"
            size="small"
          />
        </div>
        <div class="col-span-2">
          <Input
            v-model:value="item.fieldValue"
            :disabled="disabled"
            placeholder="固定值"
            size="small"
          />
        </div>
        <div class="col-span-2">
          <InputNumber
            v-model:value="item.unitPrice"
            :disabled="disabled || !needUnitPrice(item.calcRole)"
            :min="0"
            :precision="4"
            class="w-full"
            placeholder="单价"
            size="small"
          />
        </div>
        <div class="col-span-2">
          <InputNumber
            v-model:value="item.sort"
            :disabled="disabled"
            :min="0"
            class="w-full"
            size="small"
          />
        </div>
        <div class="col-span-2">
          <Select
            v-model:value="item.status"
            :disabled="disabled"
            :options="[
              { label: '启用', value: 0 },
              { label: '禁用', value: 1 },
            ]"
            class="w-full"
            size="small"
          />
        </div>
        <div class="col-span-2">
          <Input
            v-model:value="item.remark"
            :disabled="disabled"
            placeholder="备注"
            size="small"
          />
        </div>
        <div class="col-span-1 flex items-center justify-center">
          <Button
            v-if="!disabled"
            danger
            size="small"
            type="text"
            @click="removeRow(index)"
          >
            åˆ é™¤
          </Button>
        </div>
      </div>
    </div>
    <div
      v-else
      class="rounded border border-dashed border-gray-300 p-3 text-center text-xs text-gray-400"
    >
      {{
        disabled
          ? '未配置工资字段'
          : '暂未配置工资字段,如需按工序取自定义参数(如高温系数、设备效率),点击「新增字段」添加'
      }}
    </div>
  </div>
</template>
src/views/mes/process-design/process/modules/wage-field-manage.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,242 @@
<script lang="ts" setup>
import type { MesProWageApi } from '#/api/mes/pro/wage';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import {
  Button,
  Input,
  InputNumber,
  message,
  Select,
} from 'ant-design-vue';
import {
  getWageFieldList,
  saveWageFieldList,
} from '#/api/mes/pro/wage';
const emit = defineEmits(['success']);
/** å­—段类型:1-数值 2-文本 3-下拉 4-布尔 */
const FIELD_TYPE_OPTIONS = [
  { label: '数值', value: 1 },
  { label: '文本', value: 2 },
  { label: '下拉', value: 3 },
  { label: '布尔', value: 4 },
];
/** è®¡ç®—角色:1-系数 2-加减金额 3-计价数量 4-仅记录 */
const CALC_ROLE_OPTIONS = [
  { label: '系数(连乘)', value: 1 },
  { label: '加减金额', value: 2 },
  { label: '计价数量', value: 3 },
  { label: '仅记录', value: 4 },
];
const processId = ref<number>();
const processName = ref<string>('');
const loading = ref(false);
const list = ref<MesProWageApi.WageField[]>([]);
const getTitle = computed(() => `工序工资字段 - ${processName.value || ''}`);
/** åŠ è½½å·¥åºå·¥èµ„å­—æ®µ */
async function loadData() {
  if (!processId.value) {
    return;
  }
  loading.value = true;
  try {
    list.value = await getWageFieldList(processId.value);
  } finally {
    loading.value = false;
  }
}
/** æ–°å¢žä¸€è¡Œå·¥èµ„字段 */
function addRow() {
  list.value.push({
    processId: processId.value,
    fieldCode: '',
    fieldName: '',
    fieldType: 1,
    calcRole: 1,
    unitPrice: 0,
    sort: list.value.length + 1,
    status: 0,
  });
}
/** åˆ é™¤ä¸€è¡Œ */
function removeRow(index: number) {
  list.value.splice(index, 1);
}
/** è®¡ç®—角色为「计价数量」时才需要单价 */
function needUnitPrice(calcRole?: number) {
  return calcRole === 3;
}
const [Modal, modalApi] = useVbenModal({
  async onConfirm() {
    if (processId.value === undefined) {
      return;
    }
    // å‰ç«¯å…ˆåšå¿…填校验,避免后端整表保存时报一条笼统错误
    for (const [index, item] of list.value.entries()) {
      if (!item.fieldCode?.trim()) {
        message.warning(`第 ${index + 1} è¡Œï¼šå­—段编码不能为空`);
        return;
      }
      if (!item.fieldName?.trim()) {
        message.warning(`第 ${index + 1} è¡Œï¼šå­—段名称不能为空`);
        return;
      }
    }
    modalApi.lock();
    try {
      await saveWageFieldList(
        processId.value,
        list.value.map((item, index) => ({
          id: item.id,
          processId: processId.value,
          fieldCode: item.fieldCode?.trim(),
          fieldName: item.fieldName?.trim(),
          fieldType: item.fieldType ?? 1,
          unit: item.unit,
          fieldValue: item.fieldValue,
          calcRole: item.calcRole ?? 1,
          unitPrice: needUnitPrice(item.calcRole) ? item.unitPrice : undefined,
          sort: item.sort ?? index + 1,
          status: item.status ?? 0,
          remark: item.remark,
        })),
      );
      message.success('保存成功');
      await modalApi.close();
      emit('success');
    } finally {
      modalApi.unlock();
    }
  },
  async onOpenChange(isOpen: boolean) {
    if (!isOpen) {
      processId.value = undefined;
      list.value = [];
      return;
    }
    const data = modalApi.getData<{ processId: number; processName?: string }>();
    processId.value = data.processId;
    processName.value = data.processName || '';
    await loadData();
  },
});
</script>
<template>
  <Modal :title="getTitle" class="w-4/5">
    <div class="mx-4">
      <div class="mb-3 rounded bg-blue-50 p-3 text-xs text-gray-600">
        ç³»æ•°ï¼šä¸Žä¸»å·¥èµ„、辅助工资连乘(未填或≤0 æŒ‰ 1 å¤„理);加减金额:直接加到工资上;
        è®¡ä»·æ•°é‡ï¼šå­—段值 Ã— å•价后加进工资;仅记录:只随报工快照保存,不参与计算。
      </div>
      <div class="mb-2 flex items-center justify-between">
        <span class="text-sm font-medium">工序工资字段</span>
        <Button type="primary" size="small" @click="addRow">新增字段</Button>
      </div>
      <div v-if="list.length > 0" class="rounded border border-gray-200">
        <div
          class="grid grid-cols-24 gap-2 border-b border-gray-200 bg-gray-50 p-2 text-xs font-medium"
        >
          <div class="col-span-3">字段编码</div>
          <div class="col-span-3">字段名称</div>
          <div class="col-span-2">类型</div>
          <div class="col-span-2">单位</div>
          <div class="col-span-3">计算角色</div>
          <div class="col-span-2">固定值</div>
          <div class="col-span-2">单价</div>
          <div class="col-span-2">排序</div>
          <div class="col-span-2">状态</div>
          <div class="col-span-2">备注</div>
          <div class="col-span-1 text-center">操作</div>
        </div>
        <div
          v-for="(item, index) in list"
          :key="item.id ?? `new-${index}`"
          class="grid grid-cols-24 items-center gap-2 border-b border-gray-100 p-2"
        >
          <div class="col-span-3">
            <Input v-model:value="item.fieldCode" placeholder="如 TEMP_COEF" size="small" />
          </div>
          <div class="col-span-3">
            <Input v-model:value="item.fieldName" placeholder="如 é«˜æ¸©ç³»æ•°" size="small" />
          </div>
          <div class="col-span-2">
            <Select
              v-model:value="item.fieldType"
              :options="FIELD_TYPE_OPTIONS"
              size="small"
              class="w-full"
            />
          </div>
          <div class="col-span-2">
            <Input v-model:value="item.unit" placeholder="单位" size="small" />
          </div>
          <div class="col-span-3">
            <Select
              v-model:value="item.calcRole"
              :options="CALC_ROLE_OPTIONS"
              size="small"
              class="w-full"
            />
          </div>
          <div class="col-span-2">
            <Input v-model:value="item.fieldValue" placeholder="固定值" size="small" />
          </div>
          <div class="col-span-2">
            <InputNumber
              v-model:value="item.unitPrice"
              :disabled="!needUnitPrice(item.calcRole)"
              :min="0"
              :precision="4"
              placeholder="单价"
              size="small"
              class="w-full"
            />
          </div>
          <div class="col-span-2">
            <InputNumber v-model:value="item.sort" :min="0" size="small" class="w-full" />
          </div>
          <div class="col-span-2">
            <Select
              v-model:value="item.status"
              :options="[
                { label: '启用', value: 0 },
                { label: '禁用', value: 1 },
              ]"
              size="small"
              class="w-full"
            />
          </div>
          <div class="col-span-2">
            <Input v-model:value="item.remark" placeholder="备注" size="small" />
          </div>
          <div class="col-span-1 flex items-center justify-center">
            <Button type="text" danger size="small" @click="removeRow(index)">
              åˆ é™¤
            </Button>
          </div>
        </div>
      </div>
      <div
        v-else
        class="rounded border border-dashed border-gray-300 p-4 text-center text-gray-400"
      >
        {{ loading ? '加载中…' : '暂未配置工资字段,点击「新增字段」添加' }}
      </div>
    </div>
  </Modal>
</template>
src/views/mes/process-design/process/modules/wage-rule-manage.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,309 @@
<script lang="ts" setup>
import type { Dayjs } from 'dayjs';
import type { MesDvMachineryApi } from '#/api/mes/dv/machinery';
import type { MesProRouteApi } from '#/api/mes/pro/route';
import type { MesProWageApi } from '#/api/mes/pro/wage';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import {
  Button,
  DatePicker,
  InputNumber,
  Input,
  message,
  Select,
} from 'ant-design-vue';
import dayjs from 'dayjs';
import { getMachinerySimpleList } from '#/api/mes/dv/machinery';
import { getRouteSimpleList } from '#/api/mes/pro/route';
import {
  createWageRule,
  deleteWageRule,
  getWageRuleList,
  updateWageRule,
} from '#/api/mes/pro/wage';
const emit = defineEmits(['success']);
interface RuleRow extends Omit<MesProWageApi.WageRule, 'effectiveFrom' | 'effectiveTo'> {
  effectiveFrom?: Dayjs | string;
  effectiveTo?: Dayjs | string;
}
const BILLING_TYPE_OPTIONS = [
  { label: '计时(元/小时)', value: 1 },
  { label: '计件(元/件)', value: 2 },
];
const processId = ref<number>();
const processName = ref<string>('');
const processBillingType = ref<number>(2);
const loading = ref(false);
const list = ref<RuleRow[]>([]);
const originalIds = ref<number[]>([]);
const routeOptions = ref<Array<{ label: string; value: number }>>([]);
const machineryOptions = ref<Array<{ label: string; value: number }>>([]);
const getTitle = computed(() => `工序工资单价规则 - ${processName.value || ''}`);
/** å•价单位 */
function getRateUnit(billingType?: number) {
  return billingType === 1 ? '元/小时' : '元/ä»¶';
}
/** åŠ è½½è§„åˆ™ã€è·¯çº¿ã€è®¾å¤‡ */
async function loadData() {
  if (!processId.value) {
    return;
  }
  loading.value = true;
  try {
    const [rules, routes, machineries] = await Promise.all([
      getWageRuleList(processId.value),
      getRouteSimpleList(),
      getMachinerySimpleList(),
    ]);
    list.value = rules as RuleRow[];
    originalIds.value = rules
      .map((item) => item.id)
      .filter((id): id is number => id !== undefined);
    routeOptions.value = (routes as MesProRouteApi.Route[]).map((item) => ({
      label: `${item.code ?? ''} ${item.name ?? ''}`.trim(),
      value: item.id!,
    }));
    machineryOptions.value = (machineries as MesDvMachineryApi.Machinery[]).map(
      (item) => ({
        label: `${item.code ?? ''} ${item.name ?? ''}`.trim(),
        value: item.id!,
      }),
    );
  } finally {
    loading.value = false;
  }
}
/** æ–°å¢žä¸€æ¡è§„则 */
function addRow() {
  list.value.push({
    processId: processId.value,
    billingType: processBillingType.value,
    wageRate: undefined,
    priority: 0,
    status: 0,
  });
}
/** åˆ é™¤ä¸€æ¡è§„则 */
function removeRow(index: number) {
  list.value.splice(index, 1);
}
/** æ—¥æœŸè½¬å­—符串 */
function formatDate(value?: Dayjs | string) {
  if (!value) {
    return undefined;
  }
  return dayjs.isDayjs(value) ? value.format('YYYY-MM-DD') : value;
}
/** ç»„装提交数据 */
function buildPayload(item: RuleRow): MesProWageApi.WageRule {
  return {
    id: item.id,
    processId: processId.value,
    routeId: item.routeId,
    machineryId: item.machineryId,
    billingType: item.billingType ?? processBillingType.value,
    wageRate: item.wageRate,
    effectiveFrom: formatDate(item.effectiveFrom),
    effectiveTo: formatDate(item.effectiveTo),
    priority: item.priority ?? 0,
    status: item.status ?? 0,
    remark: item.remark,
  };
}
const [Modal, modalApi] = useVbenModal({
  async onConfirm() {
    if (processId.value === undefined) {
      return;
    }
    for (const [index, item] of list.value.entries()) {
      if (item.wageRate === undefined || item.wageRate === null) {
        message.warning(`第 ${index + 1} è¡Œï¼šå·¥èµ„单价不能为空`);
        return;
      }
    }
    modalApi.lock();
    try {
      const keepIds = list.value
        .map((item) => item.id)
        .filter((id): id is number => id !== undefined);
      // ç§»é™¤çš„行直接删除
      const removedIds = originalIds.value.filter((id) => !keepIds.includes(id));
      for (const id of removedIds) {
        await deleteWageRule(id);
      }
      // æ–°å¢žä¸Žä¿®æ”¹é€è¡Œæäº¤
      for (const item of list.value) {
        const payload = buildPayload(item);
        if (item.id) {
          await updateWageRule(payload);
        } else {
          await createWageRule(payload);
        }
      }
      message.success('保存成功');
      await modalApi.close();
      emit('success');
    } finally {
      modalApi.unlock();
    }
  },
  async onOpenChange(isOpen: boolean) {
    if (!isOpen) {
      processId.value = undefined;
      list.value = [];
      originalIds.value = [];
      return;
    }
    const data = modalApi.getData<{
      processId: number;
      processName?: string;
      billingType?: number;
    }>();
    processId.value = data.processId;
    processName.value = data.processName || '';
    processBillingType.value = data.billingType ?? 2;
    await loadData();
  },
});
</script>
<template>
  <Modal :title="getTitle" class="w-4/5">
    <div class="mx-4">
      <div class="mb-3 rounded bg-blue-50 p-3 text-xs text-gray-600">
        æŒ‰ã€Œå·¥è‰ºè·¯çº¿ + è®¾å¤‡ã€è¦†ç›–工序默认工资定额。匹配顺序:路线+设备 â†’ è·¯çº¿ â†’ è®¾å¤‡ â†’ å·¥åºé»˜è®¤ï¼›
        åŒåˆ†æ—¶ä¼˜å…ˆçº§å¤§çš„优先,再按生效开始日期新的优先。路线/设备留空表示不限。
      </div>
      <div class="mb-2 flex items-center justify-between">
        <span class="text-sm font-medium">工资单价规则</span>
        <Button type="primary" size="small" @click="addRow">新增规则</Button>
      </div>
      <div v-if="list.length > 0" class="rounded border border-gray-200">
        <div
          class="grid grid-cols-24 gap-2 border-b border-gray-200 bg-gray-50 p-2 text-xs font-medium"
        >
          <div class="col-span-4">工艺路线</div>
          <div class="col-span-4">设备</div>
          <div class="col-span-2">计费类型</div>
          <div class="col-span-3">工资单价</div>
          <div class="col-span-2">生效开始</div>
          <div class="col-span-2">生效结束</div>
          <div class="col-span-1">优先级</div>
          <div class="col-span-2">状态</div>
          <div class="col-span-2">备注</div>
          <div class="col-span-2 text-center">操作</div>
        </div>
        <div
          v-for="(item, index) in list"
          :key="item.id ?? `new-${index}`"
          class="grid grid-cols-24 items-center gap-2 border-b border-gray-100 p-2"
        >
          <div class="col-span-4">
            <Select
              v-model:value="item.routeId"
              :allow-clear="true"
              :options="routeOptions"
              placeholder="不限路线"
              show-search
              option-filter-prop="label"
              size="small"
              class="w-full"
            />
          </div>
          <div class="col-span-4">
            <Select
              v-model:value="item.machineryId"
              :allow-clear="true"
              :options="machineryOptions"
              placeholder="不限设备"
              show-search
              option-filter-prop="label"
              size="small"
              class="w-full"
            />
          </div>
          <div class="col-span-2">
            <Select
              v-model:value="item.billingType"
              :options="BILLING_TYPE_OPTIONS"
              size="small"
              class="w-full"
            />
          </div>
          <div class="col-span-3">
            <InputNumber
              v-model:value="item.wageRate"
              :addon-after="getRateUnit(item.billingType)"
              :min="0"
              :precision="4"
              size="small"
              class="w-full"
            />
          </div>
          <div class="col-span-2">
            <DatePicker
              v-model:value="item.effectiveFrom"
              placeholder="不限"
              size="small"
              class="w-full"
            />
          </div>
          <div class="col-span-2">
            <DatePicker
              v-model:value="item.effectiveTo"
              placeholder="不限"
              size="small"
              class="w-full"
            />
          </div>
          <div class="col-span-1">
            <InputNumber v-model:value="item.priority" :min="0" size="small" class="w-full" />
          </div>
          <div class="col-span-2">
            <Select
              v-model:value="item.status"
              :options="[
                { label: '启用', value: 0 },
                { label: '禁用', value: 1 },
              ]"
              size="small"
              class="w-full"
            />
          </div>
          <div class="col-span-2">
            <Input v-model:value="item.remark" placeholder="备注" size="small" />
          </div>
          <div class="col-span-2 flex items-center justify-center">
            <Button type="text" danger size="small" @click="removeRow(index)">
              åˆ é™¤
            </Button>
          </div>
        </div>
      </div>
      <div
        v-else
        class="rounded border border-dashed border-gray-300 p-4 text-center text-gray-400"
      >
        {{ loading ? '加载中…' : '未配置规则时直接使用工序工资定额' }}
      </div>
    </div>
  </Modal>
</template>
src/views/mes/process-design/route/data.ts
@@ -253,16 +253,6 @@
      rules: z.boolean().default(false),
    },
    {
      fieldName: "checkFlag",
      label: "是否质检确认",
      component: "Switch",
      componentProps: {
        checkedChildren: "是",
        unCheckedChildren: "否",
      },
      rules: z.boolean().default(false),
    },
    {
      fieldName: "backflushFlag",
      label: "是否倒冲",
      component: "Switch",
src/views/mes/qc/ipqc/data.ts
@@ -1,5 +1,6 @@
import type { VbenFormApi, VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MesMdWorkstationApi } from '#/api/mes/md/workstation';
import type { MesQcIpqcApi } from '#/api/mes/qc/ipqc';
import type { MesQcIpqcLineApi } from '#/api/mes/qc/ipqc/line';
@@ -8,21 +9,80 @@
import {
  DICT_TYPE,
  MesAutoCodeRuleCode,
  MesQcTypeEnum,
} from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { Button } from 'ant-design-vue';
import { generateAutoCode } from '#/api/mes/md/autocode/record';
import { getTemplatePage } from '#/api/mes/qc/template';
import { getSimpleUserList } from '#/api/system/user';
import { getDateRangePickerDefaultProps } from '#/utils';
import { MdItemSelect } from '#/views/mes/md/item/components';
import { MdWorkstationSelect } from '#/views/mes/md/workstation/components';
import { getWorkOrderProcessListByWorkOrderId } from '#/api/mes/pro/workorder/process';
import { ProTaskSelect } from '#/views/mes/pro/task/components';
import { ProWorkOrderSelect } from '#/views/mes/pro/workorder/components';
/** æŒ‰å·¥å•带出可选工序(工单工艺路线上的工序) */
async function getWorkOrderProcessOptions(workOrderId?: number) {
  if (!workOrderId) {
    return [];
  }
  const list = await getWorkOrderProcessListByWorkOrderId(workOrderId);
  return (list ?? [])
    .filter((item) => item.processId)
    .map((item) => ({
      label: item.processName || item.processCode || `工序 ${item.processId}`,
      value: item.processId!,
    }));
}
/** æŒ‰å·¥å•带出可选的半成品(各工序的产出物料) */
async function getWorkOrderOutputItemOptions(workOrderId?: number) {
  if (!workOrderId) {
    return [];
  }
  const list = await getWorkOrderProcessListByWorkOrderId(workOrderId);
  const options = new Map<number, { label: string; value: number }>();
  (list ?? []).forEach((process) => {
    const ids = process.outputItemIds?.length
      ? process.outputItemIds
      : process.outputItemId
        ? [process.outputItemId]
        : [];
    const names = (process.outputItemNames || process.outputItemName || '')
      .split(',')
      .map((name) => name.trim())
      .filter(Boolean);
    ids.forEach((id, index) => {
      if (!options.has(id)) {
        options.set(id, {
          label:
            names[index] ||
            (ids.length === 1 ? names[0] : undefined) ||
            `半成品 ${id}`,
          value: id,
        });
      }
    });
  });
  return [...options.values()];
}
/** è¡¨å•类型 */
export type FormType = 'create' | 'detail' | 'update';
/** æ–°å¢ž/修改表单的交互钩子 */
export interface IpqcFormHooks {
  /** å·¥ä½å˜æ›´ï¼šå¸¦å‡ºå·¥ä½æ‰€å±žå·¥åºï¼Œç”¨äºŽæŒ‰å·¥åºè§£æžé»˜è®¤è´¨æ£€æ–¹æ¡ˆ */
  onWorkstationChange?: (
    item: MesMdWorkstationApi.Workstation | undefined,
  ) => void;
  /** æ£€éªŒå·¥åºå˜æ›´ï¼šæŒ‰å·¥åºé‡æ–°è§£æžé»˜è®¤è´¨æ£€æ–¹æ¡ˆä¸Žå¯å¡«å†™æ£€éªŒé¡¹ */
  onProcessChange?: (processId?: number) => void;
}
/** æŠŠä¸åˆæ ¼å“æ•°é‡åŒæ­¥ä¸ºå·¥åºŸ + æ–™åºŸ + å…¶ä»–废品之和 */
function syncUnqualified(formApi?: VbenFormApi) {
@@ -39,10 +99,21 @@
  })();
}
/** è¿‡ç¨‹æ£€éªŒå¯é€‰çš„质检方案:仅启用中的过程检验方案 */
function getIpqcTemplateQuery() {
  return {
    pageNo: 1,
    pageSize: 100,
    type: MesQcTypeEnum.IPQC,
    status: 0,
  };
}
/** æ–°å¢ž/修改的表单 */
export function useFormSchema(
  formType: FormType,
  formApi?: VbenFormApi,
  hooks?: IpqcFormHooks,
): VbenFormSchema[] {
  return [
    {
@@ -154,8 +225,12 @@
      component: markRaw(ProWorkOrderSelect),
      componentProps: {
        placeholder: '请选择生产订单',
        // è®¢å•变更时清空生产任务
        onChange: () => formApi?.setFieldValue('taskId', undefined),
        // è®¢å•变更时清空生产任务、工序与半成品
        onChange: () => {
          void formApi?.setFieldValue('taskId', undefined);
          void formApi?.setFieldValue('processId', undefined);
          void formApi?.setFieldValue('itemId', undefined);
        },
      },
      rules: 'selectRequired',
      dependencies: {
@@ -163,8 +238,84 @@
        componentProps: (values) => ({
          disabled: !!values.sourceDocId,
          placeholder: '请选择生产订单',
          onChange: () => formApi?.setFieldValue('taskId', undefined),
          onChange: () => {
            void formApi?.setFieldValue('taskId', undefined);
            void formApi?.setFieldValue('processId', undefined);
            void formApi?.setFieldValue('itemId', undefined);
          },
        }),
      },
    },
    {
      fieldName: 'processId',
      label: '检验工序',
      component: 'ApiSelect',
      componentProps: {
        allowClear: true,
        api: () => getWorkOrderProcessOptions(),
        labelField: 'label',
        valueField: 'value',
        placeholder: '请先选择生产订单',
        showSearch: true,
        filterOption: true,
        onChange: (processId?: number) =>
          hooks?.onProcessChange?.(processId),
      },
      rules: 'selectRequired',
      dependencies: {
        triggerFields: ['sourceDocId', 'workOrderId'],
        componentProps: (values) => ({
          allowClear: true,
          api: () => getWorkOrderProcessOptions(values.workOrderId),
          disabled: !!values.sourceDocId || !values.workOrderId,
          labelField: 'label',
          valueField: 'value',
          placeholder: values.workOrderId
            ? '请选择本工单的检验工序'
            : '请先选择生产订单',
          showSearch: true,
          filterOption: true,
          onChange: (processId?: number) =>
            hooks?.onProcessChange?.(processId),
        }),
      },
    },
    {
      fieldName: 'itemId',
      label: '半成品',
      component: 'ApiSelect',
      componentProps: {
        allowClear: true,
        api: () => getWorkOrderOutputItemOptions(),
        labelField: 'label',
        valueField: 'value',
        placeholder: '请先选择生产订单',
        showSearch: true,
        filterOption: true,
      },
      dependencies: {
        triggerFields: ['sourceDocId', 'workOrderId'],
        componentProps: (values) => ({
          allowClear: true,
          api: () => getWorkOrderOutputItemOptions(values.workOrderId),
          disabled: !!values.sourceDocId || !values.workOrderId,
          labelField: 'label',
          valueField: 'value',
          placeholder: values.workOrderId
            ? '请选择要检验的半成品(工单工序产出)'
            : '请先选择生产订单',
          showSearch: true,
          filterOption: true,
        }),
      },
    },
    {
      fieldName: 'reelNo',
      label: '半成品盘号',
      component: 'Input',
      componentProps: {
        placeholder: '请输入本次检验的半成品盘号',
        allowClear: true,
      },
    },
    {
@@ -172,14 +323,18 @@
      label: '工位',
      component: markRaw(MdWorkstationSelect),
      componentProps: {
        placeholder: '请选择工位',
        placeholder: '可选;不填则按检验工序记录',
        onChange: (item?: MesMdWorkstationApi.Workstation) =>
          hooks?.onWorkstationChange?.(item),
      },
      rules: 'selectRequired',
      help: '工位仅用于追溯;现场没有固定工位时可不填,直接选择检验工序。',
      dependencies: {
        triggerFields: ['sourceDocId'],
        componentProps: (values) => ({
          disabled: !!values.sourceDocId,
          placeholder: '请选择工位',
          placeholder: '可选;不填则按检验工序记录',
          onChange: (item?: MesMdWorkstationApi.Workstation) =>
            hooks?.onWorkstationChange?.(item),
        }),
      },
    },
@@ -201,6 +356,36 @@
      },
    },
    {
      fieldName: 'templateId',
      label: '质检方案',
      component: 'ApiSelect',
      componentProps: {
        allowClear: true,
        api: () => getTemplatePage(getIpqcTemplateQuery()),
        labelField: 'name',
        placeholder: '留空则按工序默认绑定方案带出',
        resultField: 'list',
        showSearch: true,
        optionFilterProp: 'label',
        valueField: 'id',
      },
      help: '留空保存时按工位所属工序的默认绑定方案生成检验项;手工选择则以所选方案为准。',
      dependencies: {
        triggerFields: ['sourceDocId'],
        componentProps: (values) => ({
          allowClear: true,
          api: () => getTemplatePage(getIpqcTemplateQuery()),
          disabled: !!values.sourceDocId,
          labelField: 'name',
          placeholder: '留空则按工序默认绑定方案带出',
          resultField: 'list',
          showSearch: true,
          optionFilterProp: 'label',
          valueField: 'id',
        }),
      },
    },
    {
      fieldName: 'checkQuantity',
      label: '检测数量',
      component: 'InputNumber',
src/views/mes/qc/ipqc/modules/form.vue
@@ -1,17 +1,53 @@
<script lang="ts" setup>
import type { FormType } from '../data';
import type { MesMdWorkstationApi } from '#/api/mes/md/workstation';
import type { MesQcIndicatorResultApi } from '#/api/mes/qc/indicatorresult';
import type { MesQcIpqcApi } from '#/api/mes/qc/ipqc';
import { computed, ref } from 'vue';
import { confirm, useVbenModal } from '@vben/common-ui';
import { MesQcStatusEnum } from '@vben/constants';
import {
  MesQcResultValueType,
  MesQcStatusEnum,
  MesQcTypeEnum,
} from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { Button, message, Tabs } from 'ant-design-vue';
import {
  Alert,
  Button,
  Empty,
  Input,
  InputNumber,
  message,
  Popconfirm,
  Select,
  Table,
  Tabs,
  Upload,
} from 'ant-design-vue';
import { requestClient } from '#/api/request';
import { uploadFile, type SystemStorageApi } from '#/api/system/storage';
import { useVbenForm } from '#/adapter/form';
import { createIpqc, finishIpqc, getIpqc, updateIpqc } from '#/api/mes/qc/ipqc';
import {
  createIndicatorResult,
  getIndicatorResultDetail,
  getIndicatorResultPage,
  updateIndicatorResult,
} from '#/api/mes/qc/indicatorresult';
import {
  createIpqc,
  finishIpqc,
  getIpqc,
  getIpqcWageFieldList,
  getTemplateByProcess,
  saveIpqcWageFieldValues,
  updateIpqc,
} from '#/api/mes/qc/ipqc';
import { $t } from '#/locales';
import QcAttachments from '../../components/qc-attachments.vue';
@@ -24,6 +60,12 @@
const subTabsName = ref('attachments');
const originalSnapshot = ref(''); // è¡¨å•原始数据快照,用于 finish æ—¶è·³è¿‡æœªå˜æ›´çš„保存请求
const isDetail = computed(() => formType.value === 'detail');
/** å·¥ä½æ‰€å±žå·¥åºçš„默认质检方案及检测指标(选工位后自动带出) */
const resolvedScheme = ref<MesQcIpqcApi.TemplateResolve>();
const resolvingScheme = ref(false);
const showSchemeCard = computed(
  () => resolvingScheme.value || resolvedScheme.value?.processId !== undefined,
);
const canFinish = computed(
  () =>
    formType.value === 'update' &&
@@ -37,6 +79,447 @@
    ? $t('ui.actionTitle.edit', ['过程检验单'])
    : $t('ui.actionTitle.create', ['过程检验单']);
});
/** æ¸…空已解析的方案(关闭或重开弹窗时避免残留) */
function resetResolvedScheme() {
  resolvedScheme.value = undefined;
}
/**
 * æŒ‰å·¥åºè§£æžé»˜è®¤è´¨æ£€æ–¹æ¡ˆï¼Œå¹¶æŠŠæ–¹æ¡ˆå›žå¡«åˆ°è¡¨å•
 *
 * @param processId å·¥åºç¼–号
 * @param force true=以解析结果覆盖已选方案(工位变更时);false=仅在未选方案时回填(打开已有单据时)
 */
async function resolveScheme(processId?: number, force = false) {
  if (!processId) {
    resolvedScheme.value = undefined;
    if (force) {
      await formApi.setFieldValue('templateId', undefined);
    }
    return;
  }
  resolvingScheme.value = true;
  try {
    const resp = await getTemplateByProcess(processId, MesQcTypeEnum.IPQC);
    resolvedScheme.value = resp;
    const current = await formApi.getValues();
    if (force ? true : !current.templateId) {
      await formApi.setFieldValue('templateId', resp?.templateId);
    }
  } catch {
    resolvedScheme.value = undefined;
  } finally {
    resolvingScheme.value = false;
  }
}
/** å·¥ä½å˜æ›´ï¼šå¸¦å‡ºå·¥ä½æ‰€å±žå·¥åºï¼Œå¹¶è§£æžé»˜è®¤è´¨æ£€æ–¹æ¡ˆä¸Žæ£€æµ‹æŒ‡æ ‡ */
async function handleWorkstationChange(
  item?: MesMdWorkstationApi.Workstation,
) {
  if (!formData.value?.id) {
    // æ–°å»ºå•据换工序:上一次的检测值不再适用
    indicatorResultId.value = undefined;
  }
  if (item?.processId) {
    await formApi.setFieldValue('processId', item.processId);
  }
  await handleProcessChange(item?.processId);
}
/** æ£€éªŒå·¥åºå˜æ›´ï¼šé‡æ–°è§£æžé»˜è®¤è´¨æ£€æ–¹æ¡ˆä¸Žæ£€éªŒé¡¹ */
async function handleProcessChange(processId?: number) {
  if (!formData.value?.id) {
    indicatorResultId.value = undefined;
  }
  await resolveScheme(processId, true);
  buildRowsFromScheme();
  // å·¥åºå˜äº†ï¼šæŒ‰æ–°å·¥åºé‡æ–°å¸¦å‡ºå·¥èµ„字段
  await loadWageFields(processId, formData.value?.id);
}
/** ä»Žå·¥åºæ¨¡æ¿ä¸­è¿½åŠ ä¸€ä¸ªå°šæœªä½¿ç”¨çš„æ£€éªŒé¡¹ */
function handleAddIndicator() {
  const used = new Set(indicatorRows.value.map((row) => row.indicatorId));
  const next = (resolvedScheme.value?.indicators ?? []).find(
    (indicator) => !used.has(indicator.indicatorId),
  );
  if (!next) {
    message.warning('该工序的检验项已全部添加,没有可新增的检验项');
    return;
  }
  indicatorRows.value.push({
    indicatorId: next.indicatorId,
    indicatorName: next.indicatorName,
    valueType: next.resultType,
    valueSpecification: next.resultSpecification,
    standardValue: next.standardValue,
    unitText: next.unitText,
    thresholdMin: next.thresholdMin,
    thresholdMax: next.thresholdMax,
    blobIds: [],
    files: [],
    attachmentTouched: false,
  });
}
/** åˆ é™¤ä¸€è¡Œæ£€éªŒé¡¹ï¼ˆè‡³å°‘保留一行) */
function handleRemoveIndicator(index: number) {
  if (indicatorRows.value.length <= 1) {
    message.warning('至少保留一行检验项');
    return;
  }
  indicatorRows.value.splice(index, 1);
}
/** è¯»å–某条检验行已上传的附件 */
async function loadRowAttachments(row: IndicatorValueRow) {
  if (!row.detailId) {
    return;
  }
  const files = await requestClient.get<SystemStorageApi.StorageBlob[]>(
    '/mes/qc/attachment/ipqc-detail/list',
    { params: { detailId: row.detailId } },
  );
  row.files = files ?? [];
  row.blobIds = row.files.map((file) => file.id);
  row.attachmentTouched = false;
}
/** ä¸Šä¼ æŸè¡Œæ£€éªŒé™„件(仅传附件,不填检测值) */
async function handleRowUpload(row: IndicatorValueRow, file: File) {
  const uploaded = await uploadFile([file]);
  const blob = uploaded?.[0];
  if (!blob?.id) {
    throw new Error('上传结果缺少文件编号');
  }
  row.files = [...(row.files ?? []), blob];
  row.blobIds = [...(row.blobIds ?? []), blob.id];
  row.attachmentTouched = true;
  message.success('附件已上传,保存检验单后生效');
  return Upload.LIST_IGNORE;
}
/** ç§»é™¤æŸè¡Œé™„件:已保存的立即解绑,未保存的只从本地移除 */
async function handleRowRemoveFile(row: IndicatorValueRow, blobId: number) {
  if (row.detailId) {
    await requestClient.delete('/mes/qc/attachment/ipqc-detail/remove', {
      params: { detailId: row.detailId, blobId },
    });
  }
  row.files = (row.files ?? []).filter((file) => file.id !== blobId);
  row.blobIds = (row.blobIds ?? []).filter((id) => id !== blobId);
  row.attachmentTouched = true;
  message.success('附件已移除');
}
/** ä¿å­˜å„行附件绑定(需在检验结果明细生成后调用) */
async function saveRowAttachments() {
  for (const row of indicatorRows.value) {
    if (!row.detailId || !row.attachmentTouched) {
      continue;
    }
    await requestClient.post('/mes/qc/attachment/ipqc-detail/save', {
      detailId: row.detailId,
      blobIds: row.blobIds ?? [],
    });
    row.attachmentTouched = false;
  }
}
/** å·¥èµ„字段(工序自定义字段,由质检填写后参与工资计算) */
interface WageFieldRow {
  fieldId?: number;
  fieldCode?: string;
  fieldName?: string;
  fieldType?: number;
  unit?: string;
  calcRole?: number;
  unitPrice?: number;
  defaultValue?: string;
  value?: string;
  valueNumber?: number;
  remark?: string;
}
/** å·¥èµ„字段类型:1-数值 2-文本 3-下拉 4-布尔 */
const WAGE_FIELD_TYPE_NUMBER = 1;
const WAGE_FIELD_TYPE_BOOLEAN = 4;
/** å·¥èµ„字段计算角色文案 */
const WAGE_CALC_ROLE_LABEL: Record<number, string> = {
  1: '系数',
  2: '加减金额',
  3: '计价数量',
  4: '仅记录',
};
const wageFields = ref<WageFieldRow[]>([]);
/** æŒ‰å·¥åºå¸¦å‡ºå·¥èµ„字段,并回填该检验单已填写的值 */
async function loadWageFields(processId?: number, ipqcId?: number) {
  if (!processId && !ipqcId) {
    wageFields.value = [];
    return;
  }
  const list = await getIpqcWageFieldList({ processId, ipqcId });
  wageFields.value = (list ?? []).map((field) => ({
    ...field,
    valueNumber:
      field.fieldType === WAGE_FIELD_TYPE_NUMBER &&
      field.value !== undefined &&
      field.value !== null &&
      String(field.value).trim() !== '' &&
      Number.isFinite(Number(field.value))
        ? Number(field.value)
        : undefined,
  }));
}
/** ä¿å­˜è´¨æ£€å¡«å†™çš„工资字段值 */
async function saveWageFieldValues(ipqcId: number) {
  if (wageFields.value.length === 0) {
    return;
  }
  await saveIpqcWageFieldValues(
    ipqcId,
    wageFields.value
      .filter((field) => field.fieldId)
      .map((field) => ({
        fieldId: field.fieldId!,
        value:
          field.fieldType === WAGE_FIELD_TYPE_NUMBER
            ? field.valueNumber === undefined || field.valueNumber === null
              ? ''
              : String(field.valueNumber)
            : field.value || '',
        remark: field.remark,
      })),
  );
}
/** è¯¦æƒ…页工资字段值展示 */
function getWageFieldDisplay(row: WageFieldRow) {
  if (row.fieldType === WAGE_FIELD_TYPE_BOOLEAN && row.value) {
    return row.value === '1' ? '是' : '否';
  }
  return row.value || '未填写';
}
/** æ£€éªŒé¡¹å¡«å†™è¡Œï¼ˆç”±å·¥åºé»˜è®¤æ–¹æ¡ˆçš„æ£€æµ‹æŒ‡æ ‡ç”Ÿæˆï¼‰ */
interface IndicatorValueRow {
  detailId?: number;
  indicatorId?: number;
  indicatorName?: string;
  valueType?: number;
  valueSpecification?: string;
  standardValue?: number;
  unitText?: string;
  thresholdMin?: number;
  thresholdMax?: number;
  value?: string;
  valueNumber?: number;
  remark?: string;
  /** æœ¬æ¬¡æ–°å¢žã€å°šæœªç»‘定到检验行明细的附件 blobId */
  blobIds?: number[];
  /** æ£€éªŒè¡Œé™„件(已保存或本次上传的展示数据) */
  files?: SystemStorageApi.StorageBlob[];
  /** é™„件是否有变更,保存时需重新绑定 */
  attachmentTouched?: boolean;
}
/** æ£€éªŒé¡¹å¡«å†™ï¼ˆæ£€æµ‹å€¼ï¼‰ï¼Œéšæ£€éªŒå•一起保存为「检验结果」 */
const indicatorRows = ref<IndicatorValueRow[]>([]);
/** å·²ä¿å­˜çš„æ£€éªŒç»“果编号;空=尚未生成 */
const indicatorResultId = ref<number>();
const indicatorResultCode = ref('默认');
/** æ•°å€¼ç±»æ£€æµ‹é¡¹ï¼ˆæµ®ç‚¹/整数)走数字输入 */
function isNumericType(valueType?: number) {
  return (
    valueType === MesQcResultValueType.FLOAT ||
    valueType === MesQcResultValueType.INTEGER
  );
}
/** å­—典类检测项的选项(valueSpecification ä¸ºç³»ç»Ÿå­—典类型名) */
function getDictValueOptions(dictType?: string) {
  return dictType
    ? getDictOptions(dictType, 'string').map(({ label, value }) => ({
        label,
        value: String(value),
      }))
    : [];
}
/** æŒ‰å·¥åºé»˜è®¤æ–¹æ¡ˆç”Ÿæˆéœ€è¦å¡«å†™çš„æ£€éªŒé¡¹å­—段,保留已录入的值 */
function buildRowsFromScheme() {
  const previous = new Map(
    indicatorRows.value.map((row) => [row.indicatorId, row]),
  );
  indicatorRows.value = (resolvedScheme.value?.indicators ?? []).map(
    (indicator) => {
      const old = previous.get(indicator.indicatorId);
      return {
        detailId: old?.detailId,
        indicatorId: indicator.indicatorId,
        indicatorName: indicator.indicatorName,
        valueType: indicator.resultType,
        valueSpecification: indicator.resultSpecification,
        standardValue: indicator.standardValue,
        unitText: indicator.unitText,
        thresholdMin: indicator.thresholdMin,
        thresholdMax: indicator.thresholdMax,
        value: old?.value,
        valueNumber: old?.valueNumber,
        remark: old?.remark,
        blobIds: old?.blobIds ?? [],
        files: old?.files ?? [],
        attachmentTouched: old?.attachmentTouched,
      };
    },
  );
}
/** åŠ è½½æ£€éªŒå•å·²ä¿å­˜çš„æ£€æµ‹å€¼ */
async function loadIndicatorValues(ipqcId: number) {
  indicatorRows.value = [];
  indicatorResultId.value = undefined;
  indicatorResultCode.value = '默认';
  const page = await getIndicatorResultPage({
    pageNo: 1,
    pageSize: 20,
    qcId: ipqcId,
    qcType: MesQcTypeEnum.IPQC,
  });
  const target =
    (page.list ?? []).find(
      (item) => !item.code || item.code === indicatorResultCode.value,
    ) ?? page.list?.[0];
  if (!target?.id) {
    return;
  }
  const detail = await getIndicatorResultDetail(
    ipqcId,
    MesQcTypeEnum.IPQC,
    target.id,
  );
  indicatorResultId.value = detail.id;
  indicatorResultCode.value = detail.code || '默认';
  indicatorRows.value = (detail.items ?? []).map((item) => ({
    detailId: item.id,
    indicatorId: item.indicatorId,
    indicatorName: item.indicatorName,
    valueType: item.valueType,
    valueSpecification: item.valueSpecification,
    value: item.value,
    valueNumber:
      isNumericType(item.valueType) &&
      item.value !== undefined &&
      item.value !== null &&
      String(item.value).trim() !== '' &&
      Number.isFinite(Number(item.value))
        ? Number(item.value)
        : undefined,
    blobIds: [],
    files: [],
    attachmentTouched: false,
  }));
  // é€è¡ŒåŠ è½½å·²ä¸Šä¼ çš„æ£€éªŒé™„ä»¶
  await Promise.all(indicatorRows.value.map((row) => loadRowAttachments(row)));
}
/** ç”¨å·¥åºæ–¹æ¡ˆè¡¥é½æ£€éªŒè¡Œçš„名称、标准值与单位(不改变已保存的行顺序和增删结果) */
function enrichRowsFromScheme() {
  const indicatorMap = new Map(
    (resolvedScheme.value?.indicators ?? []).map((indicator) => [
      indicator.indicatorId,
      indicator,
    ]),
  );
  indicatorRows.value.forEach((row) => {
    const indicator = row.indicatorId
      ? indicatorMap.get(row.indicatorId)
      : undefined;
    if (!indicator) {
      return;
    }
    row.indicatorName ??= indicator.indicatorName;
    row.valueType ??= indicator.resultType;
    row.valueSpecification ??= indicator.resultSpecification;
    row.standardValue ??= indicator.standardValue;
    row.unitText ??= indicator.unitText;
    row.thresholdMin ??= indicator.thresholdMin;
    row.thresholdMax ??= indicator.thresholdMax;
  });
}
/** æ”¶é›†å½“前检验行。质检只传附件,检测值统一留空,由附件承载检测结果。 */
function collectIndicatorItems() {
  return indicatorRows.value
    .filter((row) => row.indicatorId)
    .map((row) => ({
      id: row.detailId,
      indicatorId: row.indicatorId!,
      value: '',
      remark: row.remark,
    }));
}
/** ä¿å­˜æ£€éªŒè¡Œï¼ˆå«æ‰‹å·¥å¢žåˆ çš„行),并把生成的明细编号回填到行上 */
async function saveIndicatorValues(ipqcId: number) {
  const items = collectIndicatorItems();
  if (items.length === 0) {
    return;
  }
  const payload: MesQcIndicatorResultApi.IndicatorResult = {
    code: indicatorResultCode.value || '默认',
    qcId: ipqcId,
    qcType: MesQcTypeEnum.IPQC,
    items,
  };
  if (indicatorResultId.value) {
    payload.id = indicatorResultId.value;
    await updateIndicatorResult(payload);
  } else {
    indicatorResultId.value = (await createIndicatorResult(
      payload,
    )) as number;
  }
  // ä¿å­˜åŽæŒ‰æŒ‡æ ‡å›žå¡«æ˜Žç»†ç¼–号,供每行附件绑定使用
  const detail = await getIndicatorResultDetail(
    ipqcId,
    MesQcTypeEnum.IPQC,
    indicatorResultId.value,
  );
  const detailMap = new Map(
    (detail.items ?? []).map((item) => [item.indicatorId, item.id]),
  );
  indicatorRows.value.forEach((row) => {
    const detailId = row.indicatorId
      ? detailMap.get(row.indicatorId)
      : undefined;
    if (detailId) {
      row.detailId = detailId;
    }
  });
  await saveRowAttachments();
}
/** å­—典类检测值的展示文案 */
function getIndicatorDisplay(row: IndicatorValueRow) {
  if (!row.value) {
    return '未填写';
  }
  if (row.valueType === MesQcResultValueType.DICT) {
    return (
      getDictValueOptions(row.valueSpecification).find(
        (option) => option.value === String(row.value),
      )?.label ?? row.value
    );
  }
  return row.value;
}
const [Form, formApi] = useVbenForm({
  commonConfig: {
@@ -73,6 +556,9 @@
    await formApi.setFieldValue('status', formData.value.status);
    formType.value = 'update';
  }
  // æ£€æµ‹å€¼ä¸Žå·¥èµ„字段随检验单保存
  await saveIndicatorValues(formData.value.id!);
  await saveWageFieldValues(formData.value.id!);
  // åˆ·æ–°å¿«ç…§ï¼ˆå·²å¸¦ä¸Š id / status),下次 finish ç”¨äºŽåˆ¤æ–­æ˜¯å¦éœ€è¦å†ä¿å­˜
  originalSnapshot.value = JSON.stringify(await formApi.getValues());
  return true;
@@ -114,6 +600,9 @@
      formData.value = { ...formData.value, ...data };
      originalSnapshot.value = current;
    }
    // æ£€æµ‹å€¼/工资字段可能只改了明细,不体现在主表单快照里
    await saveIndicatorValues(id);
    await saveWageFieldValues(id);
    await finishIpqc(id);
    message.success('完成成功');
    await modalApi.close();
@@ -148,9 +637,17 @@
    if (!isOpen) {
      formData.value = undefined;
      originalSnapshot.value = '';
      resetResolvedScheme();
      indicatorRows.value = [];
      indicatorResultId.value = undefined;
      wageFields.value = [];
      return;
    }
    subTabsName.value = 'attachments';
    resetResolvedScheme();
    indicatorRows.value = [];
    indicatorResultId.value = undefined;
    wageFields.value = [];
    // åŠ è½½æ•°æ®
    const data = modalApi.getData<{
      formType: FormType;
@@ -158,7 +655,12 @@
      prefill?: MesQcIpqcApi.Ipqc;
    }>();
    formType.value = data.formType;
    formApi.setState({ schema: useFormSchema(formType.value, formApi) });
    formApi.setState({
      schema: useFormSchema(formType.value, formApi, {
        onWorkstationChange: handleWorkstationChange,
        onProcessChange: handleProcessChange,
      }),
    });
    formApi.setDisabled(formType.value === 'detail');
    modalApi.setState({ showConfirmButton: formType.value !== 'detail' });
    if (data?.id) {
@@ -167,6 +669,15 @@
        formData.value = await getIpqc(data.id);
        // è®¾ç½®åˆ° values
        await formApi.setValues(formData.value);
        // ç¼–辑/详情:按已保存的工序带出默认质检方案与检测指标
        await resolveScheme(formData.value.processId);
        // å·²ä¿å­˜çš„æ£€æµ‹å€¼ä¼˜å…ˆå±•示;再按方案补齐指标名称、标准值与单位
        await loadIndicatorValues(data.id);
        if ((resolvedScheme.value?.indicators?.length ?? 0) > 0) {
          enrichRowsFromScheme();
        }
        // å·¥èµ„字段:按工序带出,并回填已填写的值
        await loadWageFields(formData.value.processId, data.id);
      } finally {
        modalApi.unlock();
      }
@@ -175,6 +686,9 @@
      formData.value = { ...data.prefill };
      // è®¾ç½®åˆ° values
      await formApi.setValues(data.prefill);
      await resolveScheme(data.prefill.processId);
      buildRowsFromScheme();
      await loadWageFields(data.prefill.processId);
    }
    originalSnapshot.value = JSON.stringify(await formApi.getValues());
  },
@@ -184,6 +698,197 @@
<template>
  <Modal :title="getTitle" class="w-4/5">
    <Form class="mx-4" />
    <div v-if="showSchemeCard" class="mx-4 mt-2">
      <div class="rounded border border-gray-200 p-3">
        <div class="mb-2 flex flex-wrap items-center gap-2 text-sm font-medium">
          è´¨æ£€æ–¹æ¡ˆ
          <span v-if="resolvedScheme?.templateName" class="text-gray-500">
            {{ resolvedScheme?.templateCode }} {{ resolvedScheme?.templateName }}
          </span>
          <span v-else-if="resolvingScheme" class="text-xs text-gray-400">
            æ­£åœ¨æŒ‰å·¥ä½æ‰€å±žå·¥åºè§£æžâ€¦
          </span>
        </div>
        <Alert
          v-if="!resolvedScheme?.indicators?.length"
          type="warning"
          show-icon
          :message="
            resolvedScheme?.templateId
              ? '该工序的默认质检方案未维护检测指标,保存后不会生成检验项。'
              : '该工序未绑定默认质检方案,保存后不会生成检验项;请到「质量管理-质检方案 â†’ é€‚用工序」中维护。'
          "
        />
        <div class="mt-2 text-xs text-gray-400">
          é»˜è®¤æ–¹æ¡ˆç”±å·¥ä½æ‰€å±žå·¥åºè‡ªåŠ¨å¸¦å‡ºï¼›å¦‚éœ€æ›´æ¢ï¼Œå¯åœ¨ã€Œè´¨æ£€æ–¹æ¡ˆã€ä¸­æ‰‹å·¥é€‰æ‹©ï¼Œä¿å­˜åŽæŒ‰æ‰€é€‰æ–¹æ¡ˆç”Ÿæˆæ£€éªŒé¡¹ã€‚
        </div>
      </div>
    </div>
    <div v-if="wageFields.length > 0" class="mx-4 mt-3">
      <div class="rounded border border-gray-200 p-3">
        <div class="mb-2 flex flex-wrap items-center gap-2 text-sm font-medium">
          å·¥èµ„字段
          <span class="text-xs font-normal text-gray-400">
            ï¼ˆå·¥åºè‡ªå®šä¹‰å­—段,由质检填写,保存后参与该工序报工工资核算)
          </span>
        </div>
        <div class="grid grid-cols-1 gap-x-4 gap-y-3 md:grid-cols-2">
          <div
            v-for="row in wageFields"
            :key="row.fieldId"
            class="flex items-start gap-2"
          >
            <div class="w-40 shrink-0 pt-1 text-xs">
              <div class="text-gray-700">{{ row.fieldName }}</div>
              <div class="text-gray-400">
                <span v-if="row.unit">单位 {{ row.unit }}</span>
                <span v-if="row.calcRole" class="ml-1">
                  Â· {{ WAGE_CALC_ROLE_LABEL[row.calcRole] }}
                </span>
              </div>
            </div>
            <div class="flex-1">
              <div v-if="isDetail" class="pt-1 text-sm">
                {{ getWageFieldDisplay(row) }}
              </div>
              <InputNumber
                v-else-if="row.fieldType === WAGE_FIELD_TYPE_NUMBER"
                v-model:value="row.valueNumber"
                :precision="4"
                class="!w-full"
                placeholder="请输入"
              />
              <Select
                v-else-if="row.fieldType === WAGE_FIELD_TYPE_BOOLEAN"
                v-model:value="row.value"
                allow-clear
                :options="[
                  { label: '是', value: '1' },
                  { label: '否', value: '0' },
                ]"
                placeholder="请选择"
                class="w-full"
              />
              <Input v-else v-model:value="row.value" placeholder="请输入" />
            </div>
          </div>
        </div>
        <div class="mt-2 text-xs text-gray-400">
          æœªå¡«å†™çš„字段按工序上配置的固定值计算;填写后随检验单保存,报工审批时用于重算工资。
        </div>
      </div>
    </div>
    <div class="mx-4 mt-3">
      <div class="rounded border border-gray-200 p-3">
        <div class="mb-2 flex flex-wrap items-center gap-2 text-sm font-medium">
          æ£€éªŒä¿¡æ¯
          <span class="text-xs font-normal text-gray-400">
            ï¼ˆå¯æ‰‹å·¥å¢žåˆ æ£€éªŒè¡Œï¼›è¿‡ç¨‹æ£€éªŒåªä¸Šä¼ é™„件,不填写检测值)
          </span>
          <Button
            v-if="!isDetail"
            class="ml-auto"
            size="small"
            type="primary"
            ghost
            @click="handleAddIndicator"
          >
            æ·»åŠ æ£€éªŒé¡¹
          </Button>
        </div>
        <Table
          v-if="indicatorRows.length > 0"
          :data-source="indicatorRows"
          :pagination="false"
          bordered
          row-key="indicatorId"
          size="small"
        >
          <Table.Column title="检验项" key="indicatorName" :width="180">
            <template #default="{ record }">
              <div class="text-gray-700">{{ record.indicatorName || '-' }}</div>
              <div class="text-xs text-gray-400">
                <template
                  v-if="
                    record.standardValue !== undefined &&
                    record.standardValue !== null
                  "
                >
                  æ ‡å‡† {{ record.standardValue }}{{ record.unitText || '' }}
                </template>
                <template v-else-if="record.unitText">
                  å•位 {{ record.unitText }}
                </template>
              </div>
            </template>
          </Table.Column>
          <Table.Column title="检验附件" key="attachments">
            <template #default="{ record }">
              <div class="flex flex-col gap-1">
                <div
                  v-for="file in record.files || []"
                  :key="file.id"
                  class="flex items-center gap-2 text-xs"
                >
                  <a
                    :href="file.previewURL || file.downloadURL"
                    target="_blank"
                    rel="noopener noreferrer"
                  >
                    {{ file.name }}
                  </a>
                  <Popconfirm
                    v-if="!isDetail"
                    title="确认移除该检验行附件?"
                    ok-text="移除"
                    cancel-text="取消"
                    @confirm="handleRowRemoveFile(record, file.id)"
                  >
                    <a class="text-red-500">移除</a>
                  </Popconfirm>
                </div>
                <Upload
                  v-if="!isDetail"
                  :show-upload-list="false"
                  :before-upload="(file: File) => handleRowUpload(record, file)"
                >
                  <Button size="small">上传附件</Button>
                </Upload>
                <span
                  v-if="isDetail && (record.files || []).length === 0"
                  class="text-xs text-gray-400"
                >
                  æœªä¸Šä¼ é™„ä»¶
                </span>
              </div>
            </template>
          </Table.Column>
          <Table.Column
            v-if="!isDetail"
            title="操作"
            key="actions"
            :width="80"
          >
            <template #default="{ index }">
              <a class="text-red-500" @click="handleRemoveIndicator(index)">
                åˆ é™¤
              </a>
            </template>
          </Table.Column>
          <template #emptyText>
            <Empty description="暂无检验项,请点击「添加检验项」" />
          </template>
        </Table>
        <Empty
          v-else
          :description="
            resolvedScheme?.indicators?.length
              ? '暂无检验项,请点击「添加检验项」'
              : '该工序未配置检验方案,无法添加检验项'
          "
        />
      </div>
    </div>
    <Tabs
      v-if="formData?.id"
      v-model:active-key="subTabsName"
src/views/mes/qc/ncr/data.ts
@@ -13,20 +13,73 @@
import { DeptTreeSelect } from '#/views/system/dept/components';
import { MdItemSelect } from '#/views/mes/md/item/components';
/** NCR çŠ¶æ€æžšä¸¾ */
/** NCR çŠ¶æ€æžšä¸¾ï¼ˆä¸ŽåŽç«¯ MesNcrStatusEnum ä¿æŒä¸€è‡´ï¼‰ */
export const NCR_STATUS = {
  PENDING_REVIEW: 0,
  REVIEWED: 1,
  DISPOSED: 2,
  CLOSED: 3,
  APPROVING: 2,
  REVIEWED: 6,
  DISPOSED: 7,
  CLOSED: 8,
} as const;
export const NCR_STATUS_OPTIONS = [
  { label: '待评审', value: 0 },
  { label: '已评审', value: 1 },
  { label: '已处置', value: 2 },
  { label: '已关闭', value: 3 },
  { label: '待评审', value: NCR_STATUS.PENDING_REVIEW },
  { label: '审批中', value: NCR_STATUS.APPROVING },
  { label: '已评审', value: NCR_STATUS.REVIEWED },
  { label: '已处置', value: NCR_STATUS.DISPOSED },
  { label: '已关闭', value: NCR_STATUS.CLOSED },
];
/** NCR å¤„置方式(与后端 MesNcrDispositionEnum ä¿æŒä¸€è‡´ï¼Œç”¨äºŽåŽ†å²æ•°æ®å±•ç¤ºï¼‰ */
export const NCR_DISPOSITION_OPTIONS = [
  { label: '退货', value: 1 },
  { label: '报废', value: 2 },
  { label: '返工', value: 3 },
  { label: '让步放行', value: 4 },
  { label: '降级使用', value: 5 },
  { label: '返修', value: 6 },
];
/** è´¨é‡éƒ¨æœ€ç»ˆå¤„置可选项(后端仅接受这四种) */
export const NCR_DISPOSAL_OPTIONS = [
  { label: '报废', value: 2 },
  { label: '返工', value: 3 },
  { label: '让步放行', value: 4 },
  { label: '返修', value: 6 },
];
/** çŠ¶æ€å€¼è½¬æ˜¾ç¤ºæ–‡æ¡ˆ */
export function getNcrStatusLabel(status?: number): string {
  return NCR_STATUS_OPTIONS.find((o) => o.value === status)?.label || '';
}
/** çŠ¶æ€å€¼è½¬ Tag é¢œè‰² */
export function getNcrStatusColor(status?: number): string {
  switch (status) {
    case NCR_STATUS.APPROVING: {
      return 'processing';
    }
    case NCR_STATUS.REVIEWED: {
      return 'blue';
    }
    case NCR_STATUS.DISPOSED: {
      return 'success';
    }
    case NCR_STATUS.CLOSED: {
      return 'default';
    }
    default: {
      return 'warning';
    }
  }
}
/** å¤„置方式值转显示文案 */
export function getNcrDispositionLabel(disposition?: number): string {
  return (
    NCR_DISPOSITION_OPTIONS.find((o) => o.value === disposition)?.label || ''
  );
}
/** æ£€éªŒç±»åž‹æžšä¸¾ */
export const QC_TYPE_OPTIONS = [
@@ -202,7 +255,7 @@
      component: 'Select',
      componentProps: {
        allowClear: true,
        options: getDictOptions(DICT_TYPE.MES_NCR_DISPOSITION, 'number'),
        options: NCR_DISPOSAL_OPTIONS,
        placeholder: '请选择处置方式',
      },
      rules: 'selectRequired',
@@ -247,6 +300,17 @@
        triggerFields: [''],
        show: () => false,
      },
    },
    {
      fieldName: 'disposition',
      label: '处置方式',
      component: 'Select',
      componentProps: {
        allowClear: true,
        options: NCR_DISPOSAL_OPTIONS,
        placeholder: '请选择处置方式(让步放行/返修/返工/报废)',
      },
      rules: 'selectRequired',
    },
    {
      fieldName: 'handlerUserId',
@@ -417,19 +481,13 @@
      field: 'disposition',
      title: '处置方式',
      width: 110,
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.MES_NCR_DISPOSITION },
      },
      slots: { default: 'disposition' },
    },
    {
      field: 'status',
      title: '状态',
      width: 90,
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.MES_NCR_STATUS },
      },
      width: 100,
      slots: { default: 'status' },
    },
    {
      field: 'createTime',
@@ -439,7 +497,7 @@
    },
    {
      title: '操作',
      width: 220,
      width: 280,
      fixed: 'right',
      slots: { default: 'actions' },
    },
src/views/mes/qc/ncr/index.vue
@@ -2,6 +2,8 @@
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MesQcNcrApi } from '#/api/mes/qc/ncr';
import { useRouter } from 'vue-router';
import { Page, useVbenModal } from '@vben/common-ui';
import { downloadFileFromBlobPart } from '@vben/utils';
@@ -15,66 +17,30 @@
  disposeNcr,
  exportNcr,
  getNcrPage,
  reviewNcr,
  submitNcrApproval,
} from '#/api/mes/qc/ncr';
import { $t } from '#/locales';
import {
  getNcrDispositionLabel,
  getNcrStatusColor,
  getNcrStatusLabel,
  NCR_STATUS,
  QC_TYPE_OPTIONS,
  useCloseFormSchema,
  useDisposeFormSchema,
  useGridColumns,
  useGridFormSchema,
  useReviewFormSchema,
} from './data';
import Form from './modules/form.vue';
defineOptions({ name: 'NcrIndex' });
const router = useRouter();
const [FormModal, formModalApi] = useVbenModal({
  connectedComponent: Form,
  destroyOnClose: true,
});
/** è¯„审弹窗 */
const [ReviewForm, reviewFormApi] = useVbenForm({
  commonConfig: {
    componentProps: { class: 'w-full' },
    formItemClass: 'col-span-2',
    labelWidth: 120,
  },
  layout: 'horizontal',
  schema: useReviewFormSchema(),
  showDefaultActions: false,
});
const [ReviewModal, reviewModalApi] = useVbenModal({
  async onConfirm() {
    const { valid } = await reviewFormApi.validate();
    if (!valid) return;
    reviewModalApi.lock();
    try {
      await reviewNcr(
        (await reviewFormApi.getValues()) as {
          defectLevel?: number;
          disposition: number;
          id: number;
          responsibleDeptId?: number;
          reviewOpinion?: string;
        },
      );
      await reviewModalApi.close();
      message.success('评审成功');
      handleRefresh();
    } finally {
      reviewModalApi.unlock();
    }
  },
  onOpenChange(isOpen: boolean) {
    if (!isOpen) return;
    const data = reviewModalApi.getData<MesQcNcrApi.Ncr>();
    reviewFormApi.setValues({ id: data?.id });
  },
});
/** å¤„置弹窗 */
@@ -96,6 +62,7 @@
    try {
      await disposeNcr(
        (await disposeFormApi.getValues()) as {
          disposition: number;
          handleResult?: string;
          handlerUserId?: number;
          id: number;
@@ -108,8 +75,10 @@
      disposeModalApi.unlock();
    }
  },
  onOpenChange(isOpen: boolean) {
  async onOpenChange(isOpen: boolean) {
    if (!isOpen) return;
    // æ¯æ¬¡æ‰“开重置,保证处置方式默认不选中(必须由质量部重新选择)
    await disposeFormApi.resetForm();
    const data = disposeModalApi.getData<MesQcNcrApi.Ncr>();
    disposeFormApi.setValues({ id: data?.id });
  },
@@ -186,9 +155,35 @@
  }
}
/** è¯„审 */
function handleReview(row: MesQcNcrApi.Ncr) {
  reviewModalApi.setData(row).open();
/** æäº¤å®¡æ‰¹ */
async function handleSubmitApproval(row: MesQcNcrApi.Ncr) {
  const hideLoading = message.loading({
    content: `正在提交 ${row.code} å®¡æ‰¹...`,
    duration: 0,
  });
  try {
    await submitNcrApproval(row.id!);
    message.success(
      `不合格品处理单 ${row.code} å·²æäº¤å®¡æ‰¹ï¼Œå¯åœ¨ã€ŒæŸ¥çœ‹å®¡æ‰¹ã€ä¸­è·Ÿè¸ªè¿›åº¦`,
    );
    handleRefresh();
  } finally {
    hideLoading();
  }
}
/** æŸ¥çœ‹å®¡æ‰¹è®°å½•(复用 BPM æµç¨‹å®žä¾‹è¯¦æƒ…页的时间线) */
function handleViewApproval(row: MesQcNcrApi.Ncr) {
  if (!row.processInstanceId) {
    message.warning(
      `不合格品处理单 ${row.code} æ²¡æœ‰æµç¨‹å®žä¾‹ï¼šå¯èƒ½å¼€å¯äº†å…¨å±€å…å®¡æ‰¹ï¼Œæˆ–尚未提交审批`,
    );
    return;
  }
  router.push({
    name: 'BpmProcessInstanceDetail',
    query: { id: row.processInstanceId, viewOnly: '1' },
  });
}
/** å¤„ç½® */
@@ -241,9 +236,6 @@
<template>
  <Page auto-content-height>
    <FormModal @success="handleRefresh" />
    <ReviewModal title="评审不合格品处理单" class="w-2/5">
      <ReviewForm class="mx-4" />
    </ReviewModal>
    <DisposeModal title="处置不合格品处理单" class="w-2/5">
      <DisposeForm class="mx-4" />
    </DisposeModal>
@@ -277,16 +269,38 @@
          {{ getQcTypeLabel(row.qcType) }}
        </Tag>
      </template>
      <template #disposition="{ row }">
        <span v-if="row.disposition !== undefined && row.disposition !== null">
          {{ getNcrDispositionLabel(row.disposition) }}
        </span>
        <span v-else class="text-gray-400">--</span>
      </template>
      <template #status="{ row }">
        <Tag :color="getNcrStatusColor(row.status)">
          {{ getNcrStatusLabel(row.status) }}
        </Tag>
      </template>
      <template #actions="{ row }">
        <TableAction
          v-if="row.status === NCR_STATUS.PENDING_REVIEW"
          :actions="[
            {
              label: $t('common.edit'),
              type: 'link',
              icon: ACTION_ICON.EDIT,
              auth: ['mes:qc-ncr:update'],
              ifShow: row.status === NCR_STATUS.PENDING_REVIEW,
              onClick: handleEdit.bind(null, row),
            },
            {
              label: '提交审批',
              type: 'link',
              icon: 'lucide:send',
              auth: ['mes:qc-ncr:update'],
              ifShow: row.status === NCR_STATUS.PENDING_REVIEW,
              popConfirm: {
                title: `确认提交不合格品处理单 ${row.code} å®¡æ‰¹ï¼Ÿ`,
                confirm: handleSubmitApproval.bind(null, row),
              },
            },
            {
              label: $t('common.delete'),
@@ -294,45 +308,37 @@
              danger: true,
              icon: ACTION_ICON.DELETE,
              auth: ['mes:qc-ncr:delete'],
              ifShow: row.status === NCR_STATUS.PENDING_REVIEW,
              popConfirm: {
                title: $t('ui.actionMessage.deleteConfirm', [row.name]),
                confirm: handleDelete.bind(null, row),
              },
            },
            {
              label: '评审',
              type: 'link',
              icon: 'lucide:clipboard-check',
              auth: ['mes:qc-ncr:review'],
              onClick: handleReview.bind(null, row),
            },
          ]"
        />
        <TableAction
          v-else-if="row.status === NCR_STATUS.REVIEWED"
          :actions="[
            {
              label: '处置',
              type: 'link',
              icon: 'lucide:play',
              auth: ['mes:qc-ncr:dispose'],
              ifShow: row.status === NCR_STATUS.REVIEWED,
              onClick: handleDispose.bind(null, row),
            },
          ]"
        />
        <TableAction
          v-else-if="row.status === NCR_STATUS.DISPOSED"
          :actions="[
            {
              label: '关闭',
              type: 'link',
              icon: 'lucide:check-circle',
              auth: ['mes:qc-ncr:close'],
              ifShow: row.status === NCR_STATUS.DISPOSED,
              onClick: handleClose.bind(null, row),
            },
            {
              label: '查看审批',
              type: 'link',
              icon: 'lucide:history',
              ifShow: !!row.processInstanceId,
              onClick: handleViewApproval.bind(null, row),
            },
          ]"
        />
        <span v-else class="text-gray-400">--</span>
      </template>
    </Grid>
  </Page>
src/views/mes/qc/patrol/data.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,293 @@
import type { VbenFormApi, VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MesQcPatrolApi } from '#/api/mes/qc/patrol';
import { h, markRaw } from 'vue';
import { MesAutoCodeRuleCode } from '@vben/constants';
import { Button } from 'ant-design-vue';
import { generateAutoCode } from '#/api/mes/md/autocode/record';
import { getSimpleUserList } from '#/api/system/user';
import { getDateRangePickerDefaultProps } from '#/utils';
import { MdmItemSelect } from '#/views/basicData/mdm/components';
/** è¡¨å•类型 */
export type FormType = 'create' | 'detail' | 'update';
/** æ–°å¢ž/修改的表单 */
export function useFormSchema(
  formType: FormType,
  formApi?: VbenFormApi,
): VbenFormSchema[] {
  return [
    {
      fieldName: 'id',
      component: 'Input',
      dependencies: {
        triggerFields: [''],
        show: () => false,
      },
    },
    {
      fieldName: 'code',
      label: '巡检单编号',
      component: 'Input',
      componentProps: {
        placeholder: '请输入巡检单编号',
      },
      rules: 'required',
      suffix: () =>
        h(
          Button,
          {
            disabled: formType === 'detail',
            type: 'default',
            onClick: async () => {
              const code = await generateAutoCode(
                MesAutoCodeRuleCode.QC_PATROL_CODE,
              );
              await formApi?.setFieldValue('code', code);
            },
          },
          { default: () => '生成' },
        ),
    },
    {
      fieldName: 'name',
      label: '巡检单名称',
      component: 'Input',
      componentProps: {
        placeholder: '请输入巡检单名称',
      },
      rules: 'required',
    },
    {
      fieldName: 'itemId',
      label: '产品物料',
      component: markRaw(MdmItemSelect),
      componentProps: {
        placeholder: '请选择产品物料',
      },
      rules: 'selectRequired',
    },
    {
      fieldName: 'reelNo',
      label: '盘号',
      component: 'Input',
      componentProps: {
        placeholder: '请输入盘号',
      },
    },
    {
      fieldName: 'batchCode',
      label: '批次号',
      component: 'Input',
      componentProps: {
        placeholder: '请输入批次号',
      },
    },
    {
      fieldName: 'qualifiedQuantity',
      label: '合格数量',
      component: 'InputNumber',
      componentProps: {
        class: '!w-full',
        min: 0,
        placeholder: '请输入',
        precision: 2,
      },
      rules: 'required',
    },
    {
      fieldName: 'unqualifiedQuantity',
      label: '不合格数量',
      component: 'InputNumber',
      componentProps: {
        class: '!w-full',
        min: 0,
        placeholder: '请输入',
        precision: 2,
      },
      rules: 'required',
    },
    {
      fieldName: 'inspectDate',
      label: '巡检日期',
      component: 'DatePicker',
      componentProps: {
        class: '!w-full',
        format: 'YYYY-MM-DD',
        placeholder: '请选择巡检日期',
        showTime: false,
        valueFormat: 'YYYY-MM-DD HH:mm:ss',
      },
      rules: 'required',
    },
    {
      fieldName: 'inspectorUserId',
      label: '巡检人员',
      component: 'ApiSelect',
      componentProps: {
        allowClear: true,
        api: getSimpleUserList,
        labelField: 'nickname',
        placeholder: '请选择巡检人员',
        valueField: 'id',
      },
      rules: 'selectRequired',
    },
    {
      fieldName: 'remark',
      label: '备注',
      component: 'Textarea',
      formItemClass: 'col-span-2',
      componentProps: {
        placeholder: '请输入备注',
        rows: 3,
      },
    },
  ];
}
/** åˆ—表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
  return [
    {
      fieldName: 'code',
      label: '巡检单编号',
      component: 'Input',
      componentProps: {
        allowClear: true,
        placeholder: '请输入巡检单编号',
      },
    },
    {
      fieldName: 'name',
      label: '巡检单名称',
      component: 'Input',
      componentProps: {
        allowClear: true,
        placeholder: '请输入巡检单名称',
      },
    },
    {
      fieldName: 'itemId',
      label: '产品物料',
      component: markRaw(MdmItemSelect),
      componentProps: {
        placeholder: '请选择产品物料',
      },
    },
    {
      fieldName: 'reelNo',
      label: '盘号',
      component: 'Input',
      componentProps: {
        allowClear: true,
        placeholder: '请输入盘号',
      },
    },
    {
      fieldName: 'inspectorUserId',
      label: '巡检人员',
      component: 'ApiSelect',
      componentProps: {
        allowClear: true,
        api: getSimpleUserList,
        labelField: 'nickname',
        placeholder: '请选择巡检人员',
        valueField: 'id',
      },
    },
    {
      fieldName: 'inspectDate',
      label: '巡检日期',
      component: 'RangePicker',
      componentProps: {
        ...getDateRangePickerDefaultProps(),
      },
    },
  ];
}
/** åˆ—表的字段 */
export function useGridColumns(): VxeTableGridOptions<MesQcPatrolApi.Patrol>['columns'] {
  return [
    {
      field: 'code',
      title: '巡检单编号',
      width: 170,
      slots: { default: 'code' },
    },
    {
      field: 'name',
      title: '巡检单名称',
      minWidth: 180,
    },
    {
      field: 'itemCode',
      title: '产品编号',
      width: 130,
    },
    {
      field: 'itemName',
      title: '产品名称',
      minWidth: 150,
    },
    {
      field: 'itemSpecification',
      title: '规格型号',
      width: 130,
    },
    {
      field: 'unitName',
      title: '单位',
      width: 80,
    },
    {
      field: 'reelNo',
      title: '盘号',
      width: 130,
    },
    {
      field: 'batchCode',
      title: '批次号',
      width: 130,
    },
    {
      field: 'qualifiedQuantity',
      title: '合格数量',
      width: 110,
    },
    {
      field: 'unqualifiedQuantity',
      title: '不合格数量',
      width: 110,
    },
    {
      field: 'inspectDate',
      title: '巡检日期',
      width: 160,
      formatter: 'formatDate',
    },
    {
      field: 'inspectorNickname',
      title: '巡检人员',
      width: 100,
    },
    {
      field: 'remark',
      title: '备注',
      minWidth: 180,
      showOverflow: 'tooltip',
    },
    {
      title: '操作',
      width: 200,
      fixed: 'right',
      slots: { default: 'actions' },
    },
  ];
}
src/views/mes/qc/patrol/index.vue
ÎļþÃû´Ó src/views/mes/cal/team/index.vue ÐÞ¸Ä
@@ -1,14 +1,13 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MesCalTeamApi } from '#/api/mes/cal/team';
import type { MesQcPatrolApi } from '#/api/mes/qc/patrol';
import { Page, useVbenModal } from '@vben/common-ui';
import { downloadFileFromBlobPart } from '@vben/utils';
import { Button, message } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { deleteTeam, exportTeam, getTeamPage } from '#/api/mes/cal/team';
import { deletePatrol, getPatrolPage } from '#/api/mes/qc/patrol';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
@@ -24,40 +23,34 @@
  gridApi.query();
}
/** åˆ›å»ºç­ç»„ */
/** åˆ›å»ºè´¨é‡å·¡æ£€å• */
function handleCreate() {
  formModalApi.setData({ formType: 'create' }).open();
}
/** æŸ¥çœ‹ç­ç»„ */
function handleDetail(row: MesCalTeamApi.Team) {
  formModalApi.setData({ id: row.id, formType: 'detail' }).open();
/** æŸ¥çœ‹è´¨é‡å·¡æ£€å• */
function handleDetail(row: MesQcPatrolApi.Patrol) {
  formModalApi.setData({ formType: 'detail', id: row.id }).open();
}
/** ç¼–辑班组 */
function handleEdit(row: MesCalTeamApi.Team) {
  formModalApi.setData({ id: row.id, formType: 'update' }).open();
/** ç¼–辑质量巡检单 */
function handleEdit(row: MesQcPatrolApi.Patrol) {
  formModalApi.setData({ formType: 'update', id: row.id }).open();
}
/** åˆ é™¤ç­ç»„ */
async function handleDelete(row: MesCalTeamApi.Team) {
/** åˆ é™¤è´¨é‡å·¡æ£€å• */
async function handleDelete(row: MesQcPatrolApi.Patrol) {
  const hideLoading = message.loading({
    content: $t('ui.actionMessage.deleting', [row.name]),
    content: $t('ui.actionMessage.deleting', [row.code]),
    duration: 0,
  });
  try {
    await deleteTeam(row.id!);
    message.success($t('ui.actionMessage.deleteSuccess', [row.name]));
    await deletePatrol(row.id!);
    message.success($t('ui.actionMessage.deleteSuccess', [row.code]));
    handleRefresh();
  } finally {
    hideLoading();
  }
}
/** å¯¼å‡ºç­ç»„ */
async function handleExport() {
  const data = await exportTeam(await gridApi.formApi.getValues());
  downloadFileFromBlobPart({ fileName: '班组.xls', source: data });
}
const [Grid, gridApi] = useVbenVxeGrid({
@@ -71,7 +64,7 @@
    proxyConfig: {
      ajax: {
        query: async ({ page }, formValues) => {
          return await getTeamPage({
          return await getPatrolPage({
            pageNo: page.currentPage,
            pageSize: page.pageSize,
            ...formValues,
@@ -87,35 +80,31 @@
      refresh: true,
      search: true,
    },
  } as VxeTableGridOptions<MesCalTeamApi.Team>,
  } as VxeTableGridOptions<MesQcPatrolApi.Patrol>,
});
</script>
<template>
  <Page auto-content-height><FormModal @success="handleRefresh" />
    <Grid table-title="班组列表">
  <Page auto-content-height>
    <FormModal @success="handleRefresh" />
    <Grid table-title="质量巡检单列表">
      <template #toolbar-tools>
        <TableAction
          :actions="[
            {
              label: $t('ui.actionTitle.create', ['班组']),
              label: $t('ui.actionTitle.create', ['质量巡检单']),
              type: 'primary',
              icon: ACTION_ICON.ADD,
              auth: ['mes:cal-team:create'],
              auth: ['mes:qc-patrol:create'],
              onClick: handleCreate,
            },
            {
              label: $t('ui.actionTitle.export'),
              type: 'primary',
              icon: ACTION_ICON.DOWNLOAD,
              auth: ['mes:cal-team:export'],
              onClick: handleExport,
            },
          ]"
        />
      </template>
      <template #code="{ row }">
        <Button type="link" @click="handleDetail(row)">{{ row.code }}</Button>
        <Button type="link" @click="handleDetail(row)">
          {{ row.code }}
        </Button>
      </template>
      <template #actions="{ row }">
        <TableAction
@@ -124,7 +113,7 @@
              label: $t('common.edit'),
              type: 'link',
              icon: ACTION_ICON.EDIT,
              auth: ['mes:cal-team:update'],
              auth: ['mes:qc-patrol:update'],
              onClick: handleEdit.bind(null, row),
            },
            {
@@ -132,12 +121,18 @@
              type: 'link',
              danger: true,
              icon: ACTION_ICON.DELETE,
              auth: ['mes:cal-team:delete'],
              auth: ['mes:qc-patrol:delete'],
              popConfirm: {
                title: $t('ui.actionMessage.deleteConfirm', [row.name]),
                title: $t('ui.actionMessage.deleteConfirm', [row.code]),
                confirm: handleDelete.bind(null, row),
              },
            },
            {
              label: $t('common.detail'),
              type: 'link',
              icon: ACTION_ICON.VIEW,
              onClick: handleDetail.bind(null, row),
            },
          ]"
        />
      </template>
src/views/mes/qc/patrol/modules/form.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,129 @@
<script lang="ts" setup>
import type { FormType } from '../data';
import type { MesQcPatrolApi } from '#/api/mes/qc/patrol';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message, Tabs } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import {
  createPatrol,
  getPatrol,
  updatePatrol,
} from '#/api/mes/qc/patrol';
import { $t } from '#/locales';
import QcAttachments from '../../components/qc-attachments.vue';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formType = ref<FormType>('create');
const formData = ref<MesQcPatrolApi.Patrol>();
const subTabsName = ref('attachments');
const isDetail = computed(() => formType.value === 'detail');
const getTitle = computed(() => {
  if (formType.value === 'detail') {
    return $t('ui.actionTitle.view', ['质量巡检单']);
  }
  return formType.value === 'update'
    ? $t('ui.actionTitle.edit', ['质量巡检单'])
    : $t('ui.actionTitle.create', ['质量巡检单']);
});
const [Form, formApi] = useVbenForm({
  commonConfig: {
    componentProps: {
      class: 'w-full',
    },
    formItemClass: 'col-span-1',
    labelWidth: 110,
  },
  wrapperClass: 'grid-cols-2',
  layout: 'horizontal',
  schema: [],
  showDefaultActions: false,
});
/** æäº¤è¡¨å• */
async function handleSubmit(): Promise<boolean> {
  const { valid } = await formApi.validate();
  if (!valid) {
    return false;
  }
  const data = (await formApi.getValues()) as MesQcPatrolApi.Patrol;
  if (formData.value?.id) {
    await updatePatrol({ ...data, id: formData.value.id });
    formData.value = { ...formData.value, ...data };
  } else {
    const id = await createPatrol(data);
    formData.value = { ...data, id: id as unknown as number };
    await formApi.setFieldValue('id', formData.value.id);
    formType.value = 'update';
  }
  return true;
}
const [Modal, modalApi] = useVbenModal({
  async onConfirm() {
    if (isDetail.value) {
      await modalApi.close();
      return;
    }
    modalApi.lock();
    try {
      const ok = await handleSubmit();
      if (!ok) {
        return;
      }
      message.success($t('ui.actionMessage.operationSuccess'));
      emit('success');
      await modalApi.close();
    } finally {
      modalApi.unlock();
    }
  },
  async onOpenChange(isOpen: boolean) {
    if (!isOpen) {
      formData.value = undefined;
      return;
    }
    subTabsName.value = 'attachments';
    const data = modalApi.getData<{ formType: FormType; id?: number }>();
    formType.value = data.formType;
    formApi.setState({ schema: useFormSchema(formType.value, formApi) });
    formApi.setDisabled(formType.value === 'detail');
    modalApi.setState({
      confirmText: formType.value === 'detail' ? undefined : '保存',
      showConfirmButton: formType.value !== 'detail',
    });
    if (data?.id) {
      modalApi.lock();
      try {
        formData.value = await getPatrol(data.id);
        await formApi.setValues(formData.value);
      } finally {
        modalApi.unlock();
      }
    }
  },
});
</script>
<template>
  <Modal :title="getTitle" class="w-[820px]">
    <Form class="mx-4" />
    <Tabs
      v-if="formData?.id"
      v-model:active-key="subTabsName"
      class="mx-4 mt-4"
    >
      <Tabs.TabPane key="attachments" tab="巡检附件">
        <QcAttachments qc-type="patrol" :qc-id="formData!.id!" />
      </Tabs.TabPane>
    </Tabs>
  </Modal>
</template>
src/views/mes/qc/statistics/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,294 @@
<script lang="ts" setup>
import type { MesQcStatisticsApi } from '#/api/mes/qc/statistics';
import type { Dayjs } from 'dayjs';
import { computed, onMounted, ref } from 'vue';
import { Page } from '@vben/common-ui';
import dayjs from 'dayjs';
import {
  Button,
  Card,
  DatePicker,
  Statistic,
  Table,
} from 'ant-design-vue';
import { getQcStatisticsSummary } from '#/api/mes/qc/statistics';
defineOptions({ name: 'MesQcStatistics' });
const { RangePicker } = DatePicker;
/** æŒ‰å·¥åºè¡¨æ ¼è¡Œï¼ˆè¡¥å……稳定的 rowKey) */
interface ProcessRow extends MesQcStatisticsApi.ByProcess {
  _key: string;
}
/** æŒ‰å·¥å•表格行(补充稳定的 rowKey) */
interface WorkOrderRow extends MesQcStatisticsApi.ByWorkOrder {
  _key: string;
}
const loading = ref(false);
const range = ref<[Dayjs, Dayjs]>([
  dayjs().subtract(29, 'day').startOf('day'),
  dayjs().endOf('day'),
]);
/** ç©ºç»Ÿè®¡ç»“果,避免首屏渲染时取不到字段 */
function createEmptySummary(): MesQcStatisticsApi.Summary {
  return {
    overall: {
      totalQcCount: 0,
      checkQuantity: 0,
      qualifiedQuantity: 0,
      unqualifiedQuantity: 0,
      qualifiedRate: 0,
      unqualifiedRate: 0,
      types: [],
    },
    byProcess: [],
    byWorkOrder: [],
  };
}
const summary = ref<MesQcStatisticsApi.Summary>(createEmptySummary());
const processRows = ref<ProcessRow[]>([]);
const workOrderRows = ref<WorkOrderRow[]>([]);
const typeRows = computed(() => summary.value.overall.types ?? []);
const typeColumns = [
  { dataIndex: 'qcTypeName', key: 'qcTypeName', title: '检验类型', width: 120 },
  { dataIndex: 'qcCount', key: 'qcCount', title: '质检单数量', width: 110 },
  { dataIndex: 'checkQuantity', key: 'checkQuantity', title: '检测总数', width: 120 },
  { dataIndex: 'qualifiedQuantity', key: 'qualifiedQuantity', title: '合格数', width: 110 },
  { dataIndex: 'unqualifiedQuantity', key: 'unqualifiedQuantity', title: '不合格数', width: 110 },
  { dataIndex: 'qualifiedRate', key: 'qualifiedRate', title: '合格率', width: 100 },
  { dataIndex: 'unqualifiedRate', key: 'unqualifiedRate', title: '不合格率', width: 100 },
];
const processColumns = [
  { dataIndex: 'processName', key: 'processName', title: '工序名称', width: 180 },
  { dataIndex: 'checkQuantity', key: 'checkQuantity', title: '检测数', width: 120 },
  { dataIndex: 'qualifiedQuantity', key: 'qualifiedQuantity', title: '合格数', width: 110 },
  { dataIndex: 'unqualifiedQuantity', key: 'unqualifiedQuantity', title: '不合格数', width: 110 },
  { dataIndex: 'qualifiedRate', key: 'qualifiedRate', title: '合格率', width: 100 },
];
const workOrderColumns = [
  { dataIndex: 'workOrderCode', key: 'workOrderCode', title: '工单号', width: 180 },
  { dataIndex: 'productName', key: 'productName', title: '产品名称', width: 220 },
  { dataIndex: 'checkQuantity', key: 'checkQuantity', title: '检测数', width: 120 },
  { dataIndex: 'qualifiedQuantity', key: 'qualifiedQuantity', title: '合格数', width: 110 },
  { dataIndex: 'unqualifiedQuantity', key: 'unqualifiedQuantity', title: '不合格数', width: 110 },
  { dataIndex: 'qualifiedRate', key: 'qualifiedRate', title: '合格率', width: 100 },
];
/** æ•°é‡å±•示:千分位 + æœ€å¤šä¸¤ä½å°æ•° */
function formatQuantity(value?: number) {
  return Number(value ?? 0).toLocaleString('zh-CN', {
    maximumFractionDigits: 2,
  });
}
/** æ¯”率展示:统一两位小数并带百分号 */
function formatRate(value?: number) {
  return `${Number(value ?? 0).toFixed(2)}%`;
}
/** åŠ è½½è´¨é‡ç»Ÿè®¡æ•°æ® */
async function loadData() {
  loading.value = true;
  try {
    const params: { beginTime?: string; endTime?: string } = {};
    if (range.value?.[0]) {
      params.beginTime = range.value[0].format('YYYY-MM-DD 00:00:00');
    }
    if (range.value?.[1]) {
      params.endTime = range.value[1].format('YYYY-MM-DD 23:59:59');
    }
    const data = await getQcStatisticsSummary(params);
    summary.value = data;
    processRows.value = (data.byProcess ?? []).map((row, index) => ({
      ...row,
      _key:
        row.processId === null || row.processId === undefined
          ? `process-none-${index}`
          : `process-${row.processId}`,
    }));
    workOrderRows.value = (data.byWorkOrder ?? []).map((row, index) => ({
      ...row,
      _key:
        row.workOrderId === null || row.workOrderId === undefined
          ? `work-order-none-${index}`
          : `work-order-${row.workOrderId}`,
    }));
  } finally {
    loading.value = false;
  }
}
onMounted(() => {
  loadData();
});
</script>
<template>
  <Page auto-content-height>
    <div class="flex h-full flex-col gap-3">
      <!-- ç­›é€‰ -->
      <Card size="small">
        <div class="flex flex-wrap items-center gap-3">
          <span class="text-muted-foreground text-sm">检测日期</span>
          <RangePicker v-model:value="range" />
          <Button type="primary" :loading="loading" @click="loadData">
            æŸ¥è¯¢
          </Button>
          <span class="text-muted-foreground text-xs">
            ä»…统计已完成检验单;不选日期则统计全部
          </span>
        </div>
      </Card>
      <!-- æ¦‚览卡片 -->
      <div class="grid grid-cols-2 gap-3 md:grid-cols-3 xl:grid-cols-6">
        <Card size="small">
          <Statistic
            title="质检单总数"
            :value="summary.overall.totalQcCount"
          />
        </Card>
        <Card size="small">
          <Statistic
            title="总检测数"
            :value="Number(summary.overall.checkQuantity ?? 0)"
            :precision="2"
          />
        </Card>
        <Card size="small">
          <Statistic
            title="合格数"
            :value="Number(summary.overall.qualifiedQuantity ?? 0)"
            :precision="2"
            :value-style="{ color: '#3f8600' }"
          />
        </Card>
        <Card size="small">
          <Statistic
            title="不合格数"
            :value="Number(summary.overall.unqualifiedQuantity ?? 0)"
            :precision="2"
            :value-style="{ color: '#cf1322' }"
          />
        </Card>
        <Card size="small">
          <Statistic
            title="合格率"
            :value="Number(summary.overall.qualifiedRate ?? 0)"
            :precision="2"
            suffix="%"
            :value-style="{ color: '#3f8600' }"
          />
        </Card>
        <Card size="small">
          <Statistic
            title="不合格率"
            :value="Number(summary.overall.unqualifiedRate ?? 0)"
            :precision="2"
            suffix="%"
            :value-style="{ color: '#cf1322' }"
          />
        </Card>
      </div>
      <!-- å„检验类型 -->
      <Card size="small" title="各检验类型统计">
        <Table
          :columns="typeColumns"
          :data-source="typeRows"
          :loading="loading"
          :pagination="false"
          row-key="qcType"
          size="small"
        >
          <template #bodyCell="{ column, record }">
            <template v-if="column.key === 'checkQuantity'">
              {{ formatQuantity(record.checkQuantity) }}
            </template>
            <template v-else-if="column.key === 'qualifiedQuantity'">
              {{ formatQuantity(record.qualifiedQuantity) }}
            </template>
            <template v-else-if="column.key === 'unqualifiedQuantity'">
              {{ formatQuantity(record.unqualifiedQuantity) }}
            </template>
            <template v-else-if="column.key === 'qualifiedRate'">
              {{ formatRate(record.qualifiedRate) }}
            </template>
            <template v-else-if="column.key === 'unqualifiedRate'">
              {{ formatRate(record.unqualifiedRate) }}
            </template>
          </template>
        </Table>
      </Card>
      <!-- æŒ‰å·¥åº -->
      <Card size="small" title="按工序统计(过程检验 IPQC)">
        <Table
          :columns="processColumns"
          :data-source="processRows"
          :loading="loading"
          :pagination="false"
          row-key="_key"
          size="small"
          :scroll="{ x: 720 }"
        >
          <template #bodyCell="{ column, record }">
            <template v-if="column.key === 'checkQuantity'">
              {{ formatQuantity(record.checkQuantity) }}
            </template>
            <template v-else-if="column.key === 'qualifiedQuantity'">
              {{ formatQuantity(record.qualifiedQuantity) }}
            </template>
            <template v-else-if="column.key === 'unqualifiedQuantity'">
              {{ formatQuantity(record.unqualifiedQuantity) }}
            </template>
            <template v-else-if="column.key === 'qualifiedRate'">
              {{ formatRate(record.qualifiedRate) }}
            </template>
          </template>
        </Table>
      </Card>
      <!-- æŒ‰è®¢å• -->
      <Card size="small" title="按生产工单统计(过程检验 IPQC)">
        <Table
          :columns="workOrderColumns"
          :data-source="workOrderRows"
          :loading="loading"
          :pagination="false"
          row-key="_key"
          size="small"
          :scroll="{ x: 860 }"
        >
          <template #bodyCell="{ column, record }">
            <template v-if="column.key === 'checkQuantity'">
              {{ formatQuantity(record.checkQuantity) }}
            </template>
            <template v-else-if="column.key === 'qualifiedQuantity'">
              {{ formatQuantity(record.qualifiedQuantity) }}
            </template>
            <template v-else-if="column.key === 'unqualifiedQuantity'">
              {{ formatQuantity(record.unqualifiedQuantity) }}
            </template>
            <template v-else-if="column.key === 'qualifiedRate'">
              {{ formatRate(record.qualifiedRate) }}
            </template>
          </template>
        </Table>
      </Card>
    </div>
  </Page>
</template>
src/views/mes/qc/template/index.vue
@@ -19,9 +19,15 @@
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
import ProcessBind from './modules/process-bind.vue';
const [FormModal, formModalApi] = useVbenModal({
  connectedComponent: Form,
  destroyOnClose: true,
});
const [ProcessBindModal, processBindModalApi] = useVbenModal({
  connectedComponent: ProcessBind,
  destroyOnClose: true,
});
@@ -43,6 +49,17 @@
/** ç¼–辑质检方案 */
function handleEdit(row: MesQcTemplateApi.Template) {
  formModalApi.setData({ formType: 'update', id: row.id }).open();
}
/** ç»´æŠ¤æ–¹æ¡ˆé€‚用的工序 */
function handleProcessBind(row: MesQcTemplateApi.Template) {
  processBindModalApi
    .setData({
      templateId: row.id,
      templateName: row.name,
      types: row.types,
    })
    .open();
}
/** åˆ é™¤è´¨æ£€æ–¹æ¡ˆ */
@@ -99,6 +116,7 @@
<template>
  <Page auto-content-height><FormModal @success="handleRefresh" />
    <ProcessBindModal @success="handleRefresh" />
    <Grid table-title="质检方案列表">
      <template #toolbar-tools>
@@ -139,6 +157,13 @@
        <TableAction
          :actions="[
            {
              label: '适用工序',
              type: 'link',
              icon: ACTION_ICON.EDIT,
              auth: ['mes:qc-template:update'],
              onClick: handleProcessBind.bind(null, row),
            },
            {
              label: $t('common.edit'),
              type: 'link',
              icon: ACTION_ICON.EDIT,
src/views/mes/qc/template/modules/process-bind.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,237 @@
<script lang="ts" setup>
import type { MesQcTemplateProcessApi } from '#/api/mes/qc/template-process';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import {
  Button,
  InputNumber,
  message,
  Select,
  Switch,
} from 'ant-design-vue';
import { getProcessSimpleList } from '#/api/mes/pro/process';
import {
  getTemplateProcessListByTemplate,
  saveTemplateProcessByTemplate,
} from '#/api/mes/qc/template-process';
import { DictTag } from '#/components/dict-tag';
const emit = defineEmits(['success']);
const QC_TYPE_OPTIONS: Array<{ label: string; value: number }> = getDictOptions(
  DICT_TYPE.MES_QC_TYPE,
  'number',
).map((item) => ({ label: item.label, value: Number(item.value) }));
const templateId = ref<number>();
const templateName = ref<string>('');
const templateTypes = ref<number[]>([]);
const qcType = ref<number>();
const loading = ref(false);
/** æ–¹æ¡ˆä¸‹å…¨éƒ¨ç»‘定(按检测种类过滤展示) */
const allList = ref<MesQcTemplateProcessApi.Binding[]>([]);
const list = computed(() =>
  allList.value.filter((item) => (item.qcType ?? qcType.value) === qcType.value),
);
const processOptions = ref<Array<{ label: string; value: number }>>([]);
const selectedProcessId = ref<number>();
const getTitle = computed(() => `适用工序 - ${templateName.value || ''}`);
/** æ–¹æ¡ˆè‡ªèº«æ”¯æŒçš„æ£€æµ‹ç§ç±» */
const qcTypeOptions = computed(() =>
  templateTypes.value.length > 0
    ? QC_TYPE_OPTIONS.filter((item) => templateTypes.value.includes(item.value))
    : QC_TYPE_OPTIONS,
);
/** åŠ è½½ç»‘å®šä¸Žå·¥åºé€‰é¡¹ */
async function loadData() {
  if (!templateId.value) {
    return;
  }
  loading.value = true;
  try {
    const [bindings, processes] = await Promise.all([
      getTemplateProcessListByTemplate(templateId.value),
      getProcessSimpleList(),
    ]);
    allList.value = bindings;
    processOptions.value = (processes ?? []).map((item) => ({
      label: `${item.code ?? ''} ${item.name ?? ''}`.trim(),
      value: item.id!,
    }));
  } finally {
    loading.value = false;
  }
}
/** è¿½åŠ ä¸€ä¸ªå·¥åº */
function addProcess() {
  if (!selectedProcessId.value) {
    message.warning('请先选择要添加的工序');
    return;
  }
  if (list.value.some((item) => item.processId === selectedProcessId.value)) {
    message.warning('该工序已在适用列表中');
    return;
  }
  const process = processOptions.value.find(
    (item) => item.value === selectedProcessId.value,
  );
  allList.value.push({
    processId: selectedProcessId.value,
    processName: process?.label,
    qcType: qcType.value,
    defaultFlag: list.value.length === 0,
    sort: list.value.length + 1,
  });
  selectedProcessId.value = undefined;
}
/** ç§»é™¤å·¥åº */
function removeProcess(index: number) {
  const target = list.value[index];
  if (!target) {
    return;
  }
  const allIndex = allList.value.indexOf(target);
  if (allIndex >= 0) {
    allList.value.splice(allIndex, 1);
  }
}
/** åªèƒ½è®¾ç½®ä¸€ä¸ªé»˜è®¤æ–¹æ¡ˆï¼Œåˆ‡æ¢æ—¶å–消其他 */
function setDefault(index: number, checked: boolean) {
  if (checked) {
    list.value.forEach((item, itemIndex) => {
      item.defaultFlag = itemIndex === index;
    });
  } else {
    list.value[index]!.defaultFlag = false;
  }
}
const [Modal, modalApi] = useVbenModal({
  async onConfirm() {
    if (!templateId.value || !qcType.value) {
      return;
    }
    modalApi.lock();
    try {
      await saveTemplateProcessByTemplate({
        templateId: templateId.value,
        qcType: qcType.value,
        list: list.value.map((item, index) => ({
          processId: item.processId!,
          defaultFlag: item.defaultFlag,
          sort: item.sort ?? index + 1,
        })),
      });
      message.success('保存成功');
      await modalApi.close();
      emit('success');
    } finally {
      modalApi.unlock();
    }
  },
  async onOpenChange(isOpen: boolean) {
    if (!isOpen) {
      templateId.value = undefined;
      templateTypes.value = [];
      qcType.value = undefined;
      allList.value = [];
      selectedProcessId.value = undefined;
      return;
    }
    const data = modalApi.getData<{
      templateId: number;
      templateName?: string;
      types?: number[];
    }>();
    templateId.value = data.templateId;
    templateName.value = data.templateName || '';
    templateTypes.value = data.types ?? [];
    qcType.value = templateTypes.value[0] ?? 2;
    await loadData();
  },
});
</script>
<template>
  <Modal :title="getTitle" class="w-3/5">
    <div class="mx-4">
      <div class="mb-3 flex items-center gap-3">
        <span class="text-sm text-gray-600">检测种类</span>
        <Select
          v-model:value="qcType"
          :options="qcTypeOptions"
          class="!w-48"
          placeholder="请选择检测种类"
        />
        <span class="text-xs text-gray-400">
          è¿‡ç¨‹æ£€éªŒï¼ˆIPQC)选工序时按此处绑定带出方案
        </span>
      </div>
      <div class="mb-2 flex items-center gap-2">
        <Select
          v-model:value="selectedProcessId"
          :options="processOptions"
          allow-clear
          option-filter-prop="label"
          placeholder="选择要添加的工序"
          show-search
          class="!w-64"
        />
        <Button type="primary" size="small" @click="addProcess">添加工序</Button>
      </div>
      <div v-if="list.length > 0" class="rounded border border-gray-200">
        <div
          class="grid grid-cols-12 gap-2 border-b border-gray-200 bg-gray-50 p-2 text-xs font-medium"
        >
          <div class="col-span-5">工序</div>
          <div class="col-span-2">检测种类</div>
          <div class="col-span-2 text-center">默认方案</div>
          <div class="col-span-1">排序</div>
          <div class="col-span-2 text-center">操作</div>
        </div>
        <div
          v-for="(item, index) in list"
          :key="item.processId"
          class="grid grid-cols-12 items-center gap-2 border-b border-gray-100 p-2"
        >
          <div class="col-span-5 text-sm">{{ item.processName || item.processId }}</div>
          <div class="col-span-2">
            <DictTag :type="DICT_TYPE.MES_QC_TYPE" :value="item.qcType ?? qcType" />
          </div>
          <div class="col-span-2 flex items-center justify-center">
            <Switch
              :checked="item.defaultFlag"
              size="small"
              @change="(checked) => setDefault(index, checked === true)"
            />
          </div>
          <div class="col-span-1">
            <InputNumber v-model:value="item.sort" :min="0" size="small" class="w-full" />
          </div>
          <div class="col-span-2 flex items-center justify-center">
            <Button type="text" danger size="small" @click="removeProcess(index)">
              ç§»é™¤
            </Button>
          </div>
        </div>
      </div>
      <div
        v-else
        class="rounded border border-dashed border-gray-300 p-4 text-center text-gray-400"
      >
        {{ loading ? '加载中…' : '该方案暂未绑定适用工序' }}
      </div>
    </div>
  </Modal>
</template>
src/views/mes/workbench/components/tabs/TaskDetailTab.vue
@@ -82,10 +82,6 @@
        <Descriptions.Item label="合格品数量">{{ task.qualifyQuantity ?? 0 }}</Descriptions.Item>
        <Descriptions.Item label="不良品数量">{{ task.unqualifyQuantity ?? 0 }}</Descriptions.Item>
        <Descriptions.Item label="客户名称">{{ task.clientName ?? '—' }}</Descriptions.Item>
        <Descriptions.Item label="是否质检工序">
          <Tag v-if="task.checkFlag" color="orange">是</Tag>
          <Tag v-else>否</Tag>
        </Descriptions.Item>
        <Descriptions.Item label="需求日期" :span="2">
          {{ task.requestDate ?? '—' }}
        </Descriptions.Item>
src/views/srm/inquiry/index.vue
@@ -4,7 +4,8 @@
import { Page, useVbenModal } from '@vben/common-ui';
import { message, Tag } from 'ant-design-vue';
import { DatePicker, message, Modal, Tag } from 'ant-design-vue';
import { h } from 'vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
@@ -52,15 +53,38 @@
  compareModalApi.setData({ id: row.id }).open();
}
async function handleConfirm(row: SrmInquiryApi.Inquiry) {
  const result = await confirmInquiry(row.id!, []);
  const orders = result.orders ?? [];
  message.success(
    `询价单 ${row.no} å·²ç¡®è®¤ä¸­æ ‡ï¼Œç”Ÿæˆ ${orders.length} å¼ é‡‡è´­è®¢å•草稿:${orders
      .map((item) => item.purchaseOrderNo)
      .join('、')}(需在采购订单页提交审批)`,
  );
  handleRefresh();
function handleConfirm(row: SrmInquiryApi.Inquiry) {
  let paymentDate = '';
  Modal.confirm({
    title: `确认询价单 ${row.no} çš„中标结果`,
    content: h('div', { class: 'mt-3' }, [
      h('p', '确认后将按中标供应商生成采购订单草稿,询价单不可再修改。'),
      h('label', { class: 'mb-2 block', for: 'inquiry-payment-date' }, '采购订单付款日期'),
      h(DatePicker, {
        id: 'inquiry-payment-date',
        class: 'w-full',
        format: 'YYYY-MM-DD',
        placeholder: '请选择付款日期',
        onChange: (_date: unknown, value: string | string[]) => {
          paymentDate = typeof value === 'string' ? value : '';
        },
      }),
    ]),
    async onOk() {
      if (!paymentDate) {
        message.error('请选择采购订单付款日期');
        return Promise.reject(new Error('付款日期不能为空'));
      }
      const result = await confirmInquiry(row.id!, [], paymentDate);
      const orders = result.orders ?? [];
      message.success(
        `询价单 ${row.no} å·²ç¡®è®¤ä¸­æ ‡ï¼Œç”Ÿæˆ ${orders.length} å¼ é‡‡è´­è®¢å•草稿:${orders
          .map((item) => item.purchaseOrderNo)
          .join('、')}(需在采购订单页提交审批)`,
      );
      handleRefresh();
    },
  });
}
async function handleCancel(row: SrmInquiryApi.Inquiry) {
@@ -151,10 +175,7 @@
              type: 'link',
              auth: ['srm:inquiry:confirm'],
              ifShow: () => row.status === 0 || row.status === 10,
              popConfirm: {
                title: `确认询价单 ${row.no} çš„中标结果并生成采购订单草稿吗?生成后不可再修改该询价单。`,
                confirm: handleConfirm.bind(null, row),
              },
              onClick: handleConfirm.bind(null, row),
            },
            {
              label: '取消',
src/views/srm/supplier/data.ts
@@ -64,6 +64,12 @@
      formItemClass: 'col-span-2',
    },
    {
      fieldName: 'companyPhone',
      label: '公司电话',
      component: 'Input',
      componentProps: { placeholder: '选填,请输入公司电话' },
    },
    {
      fieldName: 'taxNo',
      label: '税号',
      component: 'Input',
@@ -134,6 +140,7 @@
      minWidth: 80,
      cellRender: { name: 'CellTag', props: SUPPLIER_STATUS_CELLTAG },
    },
    { field: 'companyPhone', title: '公司电话', minWidth: 120 },
    { field: 'contactName', title: '联系人', minWidth: 100 },
    { field: 'contactMobile', title: '联系电话', minWidth: 120 },
    { field: 'contactEmail', title: '邮箱', minWidth: 150 },
src/views/wls/productsales/index.vue
@@ -13,6 +13,7 @@
  cancelProductSales,
  deleteProductSales,
  exportProductSales,
  exportProductSalesWord,
  getProductSalesPage,
} from '#/api/mes/wm/productsales';
import { $t } from '#/locales';
@@ -80,6 +81,12 @@
  await cancelProductSales(row.id!);
  message.success('取消成功');
  handleRefresh();
}
/** ä¸‹è½½æˆå“å‡ºåº“单 Word */
async function handleDownloadWord(row: MesWmProductSalesApi.ProductSales) {
  const source = await exportProductSalesWord(row.id!);
  downloadFileFromBlobPart({ fileName: `成品出库单-${row.code}.docx`, source });
}
/** å¯¼å‡ºè¡¨æ ¼ */
@@ -193,6 +200,16 @@
              onClick: handleFinish.bind(null, row),
            },
            {
              label: '下载出库单',
              type: 'link',
              icon: ACTION_ICON.DOWNLOAD,
              auth: ['mes:wm-product-sales:export'],
              ifShow:
                row.status === MesWmProductSalesStatusEnum.APPROVED ||
                row.status === MesWmProductSalesStatusEnum.FINISHED,
              onClick: handleDownloadWord.bind(null, row),
            },
            {
              label: '取消',
              type: 'link',
              danger: true,
src/views/wls/salesnotice/data.ts
@@ -143,6 +143,40 @@
      },
    },
    {
      fieldName: 'logisticsCompany',
      label: '物流公司',
      component: 'Input',
      componentProps: {
        placeholder: '请输入物流公司,如:顺丰速运',
      },
    },
    {
      fieldName: 'expressFee',
      label: '快递费',
      component: 'InputNumber',
      defaultValue: 0,
      componentProps: {
        class: '!w-full',
        min: 0,
        precision: 2,
        placeholder: '请输入快递费',
      },
    },
    {
      fieldName: 'invoiceStatus',
      label: '开票状态',
      component: 'Select',
      defaultValue: 0,
      componentProps: {
        class: '!w-full',
        options: [
          { label: '未开票', value: 0 },
          { label: '部分开票', value: 1 },
          { label: '已开票', value: 2 },
        ],
      },
    },
    {
      fieldName: 'remark',
      label: '备注',
      component: 'Textarea',
@@ -242,6 +276,23 @@
      minWidth: 200,
    },
    {
      field: 'logisticsCompany',
      title: '物流公司',
      minWidth: 120,
    },
    {
      field: 'expressFee',
      title: '快递费',
      width: 100,
      align: 'right',
    },
    {
      field: 'invoiceStatus',
      title: '开票状态',
      width: 100,
      slots: { default: 'invoiceStatus' },
    },
    {
      field: 'status',
      title: '单据状态',
      minWidth: 100,
@@ -265,6 +316,121 @@
  ];
}
/** å‘货台账(按产品平铺)的字段 */
export function useLedgerGridColumns(): VxeTableGridOptions<MesWmSalesNoticeApi.SalesNoticeLedger>['columns'] {
  return [
    {
      field: 'noticeCode',
      title: '发货单号',
      minWidth: 160,
      fixed: 'left',
    },
    {
      field: 'saleOrderCode',
      title: '销售订单号',
      minWidth: 140,
    },
    {
      field: 'clientName',
      title: '客户名称',
      minWidth: 120,
    },
    {
      field: 'salesDate',
      title: '发货日期',
      width: 120,
      formatter: 'formatDate',
    },
    {
      field: 'itemCode',
      title: '产品编号',
      minWidth: 120,
    },
    {
      field: 'itemName',
      title: '产品名称',
      minWidth: 140,
    },
    {
      field: 'itemSpecification',
      title: '规格型号',
      minWidth: 120,
    },
    {
      field: 'batchCode',
      title: '批次号',
      minWidth: 120,
    },
    {
      field: 'quantity',
      title: '发货数量',
      width: 100,
      align: 'right',
      formatter: 'formatAmount2',
    },
    {
      field: 'unitName',
      title: '单位',
      width: 80,
    },
    {
      field: 'outCount',
      title: '已出库数量',
      width: 110,
      align: 'right',
      formatter: 'formatAmount2',
    },
    {
      field: 'logisticsCompany',
      title: '物流公司',
      minWidth: 120,
    },
    {
      field: 'expressFee',
      title: '快递费',
      width: 100,
      align: 'right',
      formatter: 'formatAmount2',
    },
    {
      field: 'invoiceStatus',
      title: '开票状态',
      width: 100,
      slots: { default: 'invoiceStatus' },
    },
    {
      field: 'outStatus',
      title: '出库状态',
      width: 100,
      slots: { default: 'outStatus' },
    },
    {
      field: 'status',
      title: '单据状态',
      width: 100,
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.MES_WM_SALES_NOTICE_STATUS },
      },
    },
    {
      field: 'recipientName',
      title: '收货人',
      minWidth: 100,
    },
    {
      field: 'recipientAddress',
      title: '收货地址',
      minWidth: 200,
    },
    {
      field: 'lineRemark',
      title: '明细备注',
      minWidth: 120,
    },
  ];
}
/** é€šçŸ¥å•行子表的字段 */
export function useLineGridColumns(
  editable: boolean,
src/views/wls/salesnotice/index.vue
@@ -6,7 +6,9 @@
import { MesWmSalesNoticeOutStatusEnum, MesWmSalesNoticeStatusEnum } from '@vben/constants';
import { downloadFileFromBlobPart } from '@vben/utils';
import { Button, message, Tag } from 'ant-design-vue';
import { Button, message, RadioButton, RadioGroup, Tag } from 'ant-design-vue';
import { ref } from 'vue';
import { useRouter } from 'vue-router';
@@ -14,12 +16,14 @@
import {
  deleteSalesNotice,
  exportSalesNotice,
  exportSalesNoticeWord,
  generateProductSales,
  getSalesNoticeLedgerPage,
  getSalesNoticePage,
} from '#/api/mes/wm/salesnotice';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import { useGridColumns, useGridFormSchema, useLedgerGridColumns } from './data';
import Form from './modules/form.vue';
const [FormModal, formModalApi] = useVbenModal({
@@ -28,6 +32,9 @@
});
const router = useRouter();
/** åˆ—表视图:按发货单 / æŒ‰äº§å“å¹³é“º */
const viewMode = ref<'notice' | 'product'>('notice');
const outStatusLabelMap: Record<number, string> = {
  [MesWmSalesNoticeOutStatusEnum.NONE]: '未出库',
@@ -72,6 +79,12 @@
  } catch (e) {
    console.error(e);
  }
}
/** ä¸‹è½½å‘货单 Word */
async function handleDownloadWord(row: MesWmSalesNoticeApi.SalesNotice) {
  const source = await exportSalesNoticeWord(row.id!);
  downloadFileFromBlobPart({ fileName: `发货单-${row.code}.docx`, source });
}
/** åˆ é™¤å‘货通知单 */
@@ -124,12 +137,51 @@
    },
  } as VxeTableGridOptions<MesWmSalesNoticeApi.SalesNotice>,
});
const [LedgerGrid] = useVbenVxeGrid({
  formOptions: {
    schema: useGridFormSchema(),
  },
  gridOptions: {
    columns: useLedgerGridColumns(),
    height: 'auto',
    keepSource: true,
    proxyConfig: {
      ajax: {
        query: async ({ page }, formValues) => {
          return await getSalesNoticeLedgerPage({
            pageNo: page.currentPage,
            pageSize: page.pageSize,
            ...formValues,
          });
        },
      },
    },
    rowConfig: {
      keyField: 'lineId',
      isHover: true,
    },
    toolbarConfig: {
      refresh: true,
      search: true,
    },
  } as VxeTableGridOptions<MesWmSalesNoticeApi.SalesNoticeLedger>,
});
</script>
<template>
  <Page auto-content-height><FormModal @success="handleRefresh" />
    <Grid table-title="发货通知单列表">
    <div class="flex h-full flex-col gap-2">
      <div class="flex justify-end">
        <RadioGroup v-model:value="viewMode" button-style="solid" size="small">
          <RadioButton value="notice">按发货单</RadioButton>
          <RadioButton value="product">按产品平铺</RadioButton>
        </RadioGroup>
      </div>
      <div class="min-h-0 flex-1">
    <Grid v-if="viewMode === 'notice'" table-title="发货通知单列表">
      <template #toolbar-tools>
        <TableAction
          :actions="[
@@ -154,6 +206,11 @@
        <Button type="link" @click="handleDetail(row)">
          {{ row.code }}
        </Button>
      </template>
      <template #invoiceStatus="{ row }">
        <Tag v-if="row.invoiceStatus === 2" color="success">已开票</Tag>
        <Tag v-else-if="row.invoiceStatus === 1" color="processing">部分开票</Tag>
        <Tag v-else color="default">未开票</Tag>
      </template>
      <template #outStatus="{ row }">
        <Tag :color="outStatusColorMap[row.outStatus ?? 0]">{{ outStatusLabelMap[row.outStatus ?? 0] }}</Tag>
@@ -182,6 +239,14 @@
              },
            },
            {
              label: '下载发货单',
              type: 'link',
              icon: ACTION_ICON.DOWNLOAD,
              auth: ['mes:wm-sales-notice:export'],
              ifShow: row.status === MesWmSalesNoticeStatusEnum.APPROVED,
              onClick: handleDownloadWord.bind(null, row),
            },
            {
              label: '生成出库单',
              type: 'link',
              icon: 'ant-design:file-protect-outlined',
@@ -193,5 +258,18 @@
        />
      </template>
    </Grid>
        <LedgerGrid v-else table-title="发货台账(按产品平铺)">
          <template #invoiceStatus="{ row }">
            <Tag v-if="row.invoiceStatus === 2" color="success">已开票</Tag>
            <Tag v-else-if="row.invoiceStatus === 1" color="processing">部分开票</Tag>
            <Tag v-else color="default">未开票</Tag>
          </template>
          <template #outStatus="{ row }">
            <Tag :color="outStatusColorMap[row.outStatus ?? 0]">{{ outStatusLabelMap[row.outStatus ?? 0] }}</Tag>
          </template>
        </LedgerGrid>
      </div>
    </div>
  </Page>
</template>
src/views/wls/salesnotice/modules/form.vue
@@ -9,15 +9,17 @@
import { useVbenModal } from '@vben/common-ui';
import { MesWmSalesNoticeStatusEnum } from '@vben/constants';
import { Button, Divider, message, Popconfirm } from 'ant-design-vue';
import { Button, Divider, message, Modal as AModal, Popconfirm, Select } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import {
  createSalesNotice,
  getSalesNotice,
  getSalesNoticeApproveProcessList,
  submitSalesNotice,
  updateSalesNotice,
} from '#/api/mes/wm/salesnotice';
import { automaticApproval } from '#/store/audit-policy';
import { createSalesNoticeLine } from '#/api/mes/wm/salesnotice/line';
import { getSaleOrderItemListByOrderId } from '#/api/erp/sale/order';
import { $t } from '#/locales';
@@ -62,7 +64,34 @@
  wrapperClass: 'grid-cols-3',
});
/** æäº¤é€šçŸ¥å•:表单有修改时先保存,再调用提交接口 */
/** æäº¤å®¡æ‰¹å¼¹çª— */
const submitModalVisible = ref(false);
const processList = ref<Array<{ key: string; name: string }>>([]);
const selectedProcessKey = ref<string>();
const submitting = ref(false);
async function doSubmit(processDefinitionKey: string) {
  submitting.value = true;
  try {
    await submitSalesNotice(formData.value!.id!, processDefinitionKey);
    message.success(automaticApproval.value ? '已提交生效' : '提交审批成功');
    submitModalVisible.value = false;
    await modalApi.close();
    emit('success');
  } finally {
    submitting.value = false;
  }
}
async function confirmSubmit() {
  if (!selectedProcessKey.value) {
    message.warning('请选择审批流程');
    return;
  }
  await doSubmit(selectedProcessKey.value);
}
/** æäº¤é€šçŸ¥å•:表单有修改时先保存,再提交审批 */
async function handleSubmit() {
  const { valid } = await formApi.validate();
  if (!valid || !formData.value?.id) {
@@ -77,10 +106,18 @@
      await updateSalesNotice({ ...formData.value, ...data });
      originalSnapshot.value = current;
    }
    await submitSalesNotice(formData.value.id);
    message.success('提交成功');
    await modalApi.close();
    emit('success');
    if (automaticApproval.value) {
      await doSubmit('sales_delivery_approve');
      return;
    }
    const list = await getSalesNoticeApproveProcessList();
    if (list.length === 1) {
      await doSubmit(list[0]!.key);
      return;
    }
    processList.value = list;
    selectedProcessKey.value = undefined;
    submitModalVisible.value = true;
  } finally {
    modalApi.unlock();
  }
@@ -196,5 +233,22 @@
        </Popconfirm>
      </div>
    </template>
    <AModal
      v-model:open="submitModalVisible"
      title="选择审批流程"
      :confirm-loading="submitting"
      width="400px"
      @ok="confirmSubmit"
    >
      <Select
        v-model:value="selectedProcessKey"
        class="w-full"
        placeholder="请选择发货审批流程"
      >
        <Select.Option v-for="item in processList" :key="item.key" :value="item.key">
          {{ item.name }}
        </Select.Option>
      </Select>
    </AModal>
  </Modal>
</template>
src/views/wls/transaction/data.ts
@@ -164,6 +164,12 @@
      minWidth: 150,
    },
    {
      field: 'workOrderCode',
      title: '生产订单号',
      minWidth: 150,
      showOverflow: 'tooltip',
    },
    {
      field: 'itemCode',
      title: '物料编码',
      width: 120,