gaoluyang
2 天以前 98312d84f785c09574bfd5add730ea448fb0cbd5
feat: 财务
1.惠牧添加财务模块
已添加25个文件
已修改2个文件
4417 ■■■■■ 文件已修改
src/api/erp/finance/fixed-asset/index.ts 93 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/erp/finance/intangible-asset/index.ts 94 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/erp/finance/ledger/index.ts 54 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/erp/finance/statistics/index.ts 60 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/erp/finance/subject/index.ts 77 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/erp/finance/voucher/index.ts 101 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/constants/src/dict-enum.ts 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/finance/fixed-asset/data.ts 389 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/finance/fixed-asset/index.vue 217 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/finance/fixed-asset/modules/form.vue 89 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/finance/intangible-asset/data.ts 341 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/finance/intangible-asset/index.vue 217 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/finance/intangible-asset/modules/form.vue 90 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/finance/ledger/data.ts 214 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/finance/ledger/detail/index.vue 100 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/finance/ledger/general/index.vue 103 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/finance/receipt/modules/item-form.vue 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/finance/statistics/index.vue 272 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/finance/statistics/modules/trend-chart.vue 101 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/finance/subject/data.ts 210 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/finance/subject/index.vue 224 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/finance/subject/modules/form.vue 96 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/finance/voucher/data.ts 297 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/finance/voucher/index.vue 298 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/finance/voucher/modules/account-select.vue 150 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/finance/voucher/modules/entry-table.vue 361 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/finance/voucher/modules/form.vue 157 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/erp/finance/fixed-asset/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,93 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export namespace FinFixedAssetApi {
  /** å›ºå®šèµ„产信息 */
  export interface FixedAsset {
    id?: number; // èµ„产编号
    assetCode?: string; // èµ„产编码
    assetName?: string; // èµ„产名称
    category?: string; // èµ„产类别
    specification?: string; // è§„格型号
    purchaseDate?: string; // è´­ç½®æ—¥æœŸ yyyy-MM-dd
    originalValue?: number; // èµ„产原值
    usefulLife?: number; // ä½¿ç”¨å¹´é™ï¼ˆå¹´ï¼‰
    residualRate?: number; // æ®‹å€¼çŽ‡ï¼ˆ%)
    monthlyDepreciation?: number; // æœˆæŠ˜æ—§é¢ï¼ˆåŽç«¯è®¡ç®—,只读)
    accumulatedDepreciation?: number; // ç´¯è®¡æŠ˜æ—§
    netValue?: number; // å‡€å€¼
    location?: string; // å­˜æ”¾åœ°ç‚¹
    departmentId?: number; // ä½¿ç”¨éƒ¨é—¨ç¼–号(system_dept.id,表单提交用)
    departmentName?: string; // ä½¿ç”¨éƒ¨é—¨åç§°ï¼ˆåŽç«¯è§£æžï¼Œåªè¯»ï¼‰
    keeperId?: number; // ä¿ç®¡äººç¼–号(system_users.id,表单提交用)
    keeperName?: string; // ä¿ç®¡äººæ˜µç§°ï¼ˆåŽç«¯è§£æžï¼Œåªè¯»ï¼‰
    status?: number; // çŠ¶æ€ï¼š1在用 2闲置 3维修中 4已报废
    remark?: string; // å¤‡æ³¨
    createTime?: Date; // åˆ›å»ºæ—¶é—´
  }
  /** è®¡ææŠ˜æ—§ç»“æžœ */
  export interface DepreciateResult {
    processedCount: number; // æœ¬æ¬¡è®¡æçš„资产数量
    totalMonthlyDepreciation: number; // æœ¬æ¬¡è®¡ææŠ˜æ—§åˆè®¡
  }
}
/** æŸ¥è¯¢å›ºå®šèµ„产分页 */
export function getFixedAssetPage(params: PageParam) {
  return requestClient.get<PageResult<FinFixedAssetApi.FixedAsset>>(
    '/finance/fixed-asset/page',
    { params },
  );
}
/** æŸ¥è¯¢å›ºå®šèµ„产列表(不分页,用于统计) */
export function getFixedAssetList() {
  return requestClient.get<FinFixedAssetApi.FixedAsset[]>(
    '/finance/fixed-asset/list',
  );
}
/** æŸ¥è¯¢å›ºå®šèµ„产详情 */
export function getFixedAsset(id: number) {
  return requestClient.get<FinFixedAssetApi.FixedAsset>(
    `/finance/fixed-asset/get?id=${id}`,
  );
}
/** æ–°å¢žå›ºå®šèµ„产 */
export function createFixedAsset(data: FinFixedAssetApi.FixedAsset) {
  return requestClient.post('/finance/fixed-asset/create', data);
}
/** ä¿®æ”¹å›ºå®šèµ„产 */
export function updateFixedAsset(data: FinFixedAssetApi.FixedAsset) {
  return requestClient.put('/finance/fixed-asset/update', data);
}
/** åˆ é™¤å›ºå®šèµ„产 */
export function deleteFixedAsset(id: number) {
  return requestClient.delete(`/finance/fixed-asset/delete?id=${id}`);
}
/** æ‰¹é‡åˆ é™¤å›ºå®šèµ„产 */
export function deleteFixedAssetList(ids: number[]) {
  return requestClient.delete(
    `/finance/fixed-asset/delete-list?ids=${ids.join(',')}`,
  );
}
/** è®¡ææŠ˜æ—§ï¼ˆids ä¸ºç©ºè¡¨ç¤ºå…¨éƒ¨è®¡æï¼‰ */
export function depreciateFixedAssetList(ids: number[]) {
  return requestClient.post<FinFixedAssetApi.DepreciateResult>(
    `/finance/fixed-asset/depreciate?ids=${ids.join(',')}`,
  );
}
/** å¯¼å‡ºå›ºå®šèµ„产 Excel */
export function exportFixedAsset(params: any) {
  return requestClient.download('/finance/fixed-asset/export-excel', {
    params,
  });
}
src/api/erp/finance/intangible-asset/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,94 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export namespace FinIntangibleAssetApi {
  /** æ— å½¢èµ„产信息 */
  export interface IntangibleAsset {
    id?: number; // èµ„产编号
    assetCode?: string; // èµ„产编码
    assetName?: string; // èµ„产名称
    category?: string; // èµ„产类别
    certificateNo?: string; // è¯ä¹¦ç¼–号
    acquisitionDate?: string; // å–得日期 yyyy-MM-dd
    originalValue?: number; // èµ„产原值
    amortizationPeriod?: number; // æ‘Šé”€å¹´é™ï¼ˆå¹´ï¼‰
    residualRate?: number; // æ®‹å€¼çŽ‡ï¼ˆ%)
    monthlyAmortization?: number; // æœˆæ‘Šé”€é¢ï¼ˆåŽç«¯è®¡ç®—,只读)
    accumulatedAmortization?: number; // ç´¯è®¡æ‘Šé”€
    netValue?: number; // å‡€å€¼
    validityDate?: string; // æœ‰æ•ˆæœŸè‡³ yyyy-MM-dd
    status?: number; // çŠ¶æ€ï¼š1在用 2已失效 3已摊销完
    description?: string; // èµ„产描述
    remark?: string; // å¤‡æ³¨
    createTime?: Date; // åˆ›å»ºæ—¶é—´
  }
  /** è®¡ææ‘Šé”€ç»“æžœ */
  export interface AmortizeResult {
    processedCount: number; // æœ¬æ¬¡è®¡æçš„资产数量
    totalMonthlyAmortization: number; // æœ¬æ¬¡è®¡ææ‘Šé”€åˆè®¡
  }
}
/** æŸ¥è¯¢æ— å½¢èµ„产分页 */
export function getIntangibleAssetPage(params: PageParam) {
  return requestClient.get<PageResult<FinIntangibleAssetApi.IntangibleAsset>>(
    '/finance/intangible-asset/page',
    { params },
  );
}
/** æŸ¥è¯¢æ— å½¢èµ„产列表(不分页,用于统计) */
export function getIntangibleAssetList() {
  return requestClient.get<FinIntangibleAssetApi.IntangibleAsset[]>(
    '/finance/intangible-asset/list',
  );
}
/** æŸ¥è¯¢æ— å½¢èµ„产详情 */
export function getIntangibleAsset(id: number) {
  return requestClient.get<FinIntangibleAssetApi.IntangibleAsset>(
    `/finance/intangible-asset/get?id=${id}`,
  );
}
/** æ–°å¢žæ— å½¢èµ„产 */
export function createIntangibleAsset(
  data: FinIntangibleAssetApi.IntangibleAsset,
) {
  return requestClient.post('/finance/intangible-asset/create', data);
}
/** ä¿®æ”¹æ— å½¢èµ„产 */
export function updateIntangibleAsset(
  data: FinIntangibleAssetApi.IntangibleAsset,
) {
  return requestClient.put('/finance/intangible-asset/update', data);
}
/** åˆ é™¤æ— å½¢èµ„产 */
export function deleteIntangibleAsset(id: number) {
  return requestClient.delete(`/finance/intangible-asset/delete?id=${id}`);
}
/** æ‰¹é‡åˆ é™¤æ— å½¢èµ„产 */
export function deleteIntangibleAssetList(ids: number[]) {
  return requestClient.delete(
    `/finance/intangible-asset/delete-list?ids=${ids.join(',')}`,
  );
}
/** è®¡ææ‘Šé”€ï¼ˆids ä¸ºç©ºè¡¨ç¤ºå…¨éƒ¨è®¡æï¼‰ */
export function amortizeIntangibleAssetList(ids: number[]) {
  return requestClient.post<FinIntangibleAssetApi.AmortizeResult>(
    `/finance/intangible-asset/amortize?ids=${ids.join(',')}`,
  );
}
/** å¯¼å‡ºæ— å½¢èµ„产 Excel */
export function exportIntangibleAsset(params: any) {
  return requestClient.download('/finance/intangible-asset/export-excel', {
    params,
  });
}
src/api/erp/finance/ledger/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,54 @@
import { requestClient } from '#/api/request';
export namespace FinLedgerApi {
  /** æ€»è´¦ / æ˜Žç»†è´¦æŸ¥è¯¢å‚æ•° */
  export interface LedgerParams {
    subjectCode: string; // ç§‘目编码
    startMonth?: string; // èµ·å§‹æœˆä»½ yyyy-MM
    endMonth?: string; // ç»“束月份 yyyy-MM
    includeChildren?: boolean; // æ˜¯å¦åŒ…含下级科目
  }
  /** æ€»è´¦ / æ˜Žç»†è´¦è¡Œ */
  export interface LedgerRow {
    rowType: string; // è¡Œç±»åž‹ï¼šopening期初 entry明细 monthly_total月合计 yearly_total年合计
    period?: string; // æœŸé—´  yyyy-MM
    date?: string; // æ—¥æœŸ
    voucherNo?: string; // å‡­è¯å­—号
    subjectCode?: string; // ç§‘目编码
    subjectName?: string; // ç§‘目名称
    summary?: string; // æ‘˜è¦
    debit?: number; // å€Ÿæ–¹é‡‘额
    credit?: number; // è´·æ–¹é‡‘额
    direction?: number; // ä½™é¢æ–¹å‘:1借 2è´·
    balance?: number; // ä½™é¢
  }
}
/** æŸ¥è¯¢æ€»è´¦ */
export function getGeneralLedger(params: FinLedgerApi.LedgerParams) {
  return requestClient.get<FinLedgerApi.LedgerRow[]>('/finance/ledger/general', {
    params,
  });
}
/** æŸ¥è¯¢æ˜Žç»†è´¦ */
export function getDetailLedger(params: FinLedgerApi.LedgerParams) {
  return requestClient.get<FinLedgerApi.LedgerRow[]>('/finance/ledger/detail', {
    params,
  });
}
/** å¯¼å‡ºæ€»è´¦ Excel */
export function exportGeneralLedger(params: FinLedgerApi.LedgerParams) {
  return requestClient.download('/finance/ledger/general/export-excel', {
    params,
  });
}
/** å¯¼å‡ºæ˜Žç»†è´¦ Excel */
export function exportDetailLedger(params: FinLedgerApi.LedgerParams) {
  return requestClient.download('/finance/ledger/detail/export-excel', {
    params,
  });
}
src/api/erp/finance/statistics/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,60 @@
import { requestClient } from '#/api/request';
export namespace FinStatisticsApi {
  /** ä¼šè®¡æ ¸ç®—看板查询参数 */
  export interface StatisticsParams {
    startMonth?: string; // èµ·å§‹æœˆä»½ yyyy-MM,留空默认本年 1 æœˆ
    endMonth?: string; // ç»“束月份 yyyy-MM,留空默认当前月
  }
  /** ä¼šè®¡æ ¸ç®—看板总览 */
  export interface StatisticsOverview {
    startDate: string; // å®žé™…统计起始日期
    endDate: string; // å®žé™…统计结束日期
    receiptPrice: number; // æ”¶æ¬¾é‡‘额(ERP å·²å®¡æ ¸æ”¶æ¬¾å•)
    paymentPrice: number; // ä»˜æ¬¾é‡‘额(ERP å·²å®¡æ ¸ä»˜æ¬¾å•)
    netProfit: number; // å‡€åˆ©æ¶¦ = æ”¶æ¬¾ - ä»˜æ¬¾
    saleOrderPrice: number; // é”€å”®è®¢å•金额(已审核)
    purchaseOrderPrice: number; // é‡‡è´­è®¢å•金额(已审核)
    fixedAssetOriginalValue: number; // å›ºå®šèµ„产原值合计
    fixedAssetAccumulatedDepreciation: number; // å›ºå®šèµ„产累计折旧合计
    fixedAssetNetValue: number; // å›ºå®šèµ„产净值合计
    intangibleAssetOriginalValue: number; // æ— å½¢èµ„产原值合计
    intangibleAssetAccumulatedAmortization: number; // æ— å½¢èµ„产累计摊销合计
    intangibleAssetNetValue: number; // æ— å½¢èµ„产净值合计
    totalAssetNetValue: number; // èµ„产净值合计
    subjectCount: number; // ä¼šè®¡ç§‘目数量
    voucherCount: number; // å‡­è¯æ€»æ•°
    voucherUnpostedCount: number; // æœªè¿‡è´¦å‡­è¯æ•°é‡
    voucherPostedCount: number; // å·²è¿‡è´¦å‡­è¯æ•°é‡
    voucherCancelledCount: number; // å·²ä½œåºŸå‡­è¯æ•°é‡
  }
  /** æœˆåº¦è¶‹åŠ¿ */
  export interface StatisticsTrend {
    month: string; // æœˆä»½ yyyy-MM
    receiptPrice: number; // æ”¶æ¬¾é‡‘额
    paymentPrice: number; // ä»˜æ¬¾é‡‘额
    netProfit: number; // å‡€åˆ©æ¶¦
    saleOrderPrice: number; // é”€å”®è®¢å•金额
    purchaseOrderPrice: number; // é‡‡è´­è®¢å•金额
  }
}
/** èŽ·å–ä¼šè®¡æ ¸ç®—çœ‹æ¿æ€»è§ˆ */
export function getStatisticsOverview(params?: FinStatisticsApi.StatisticsParams) {
  return requestClient.get<FinStatisticsApi.StatisticsOverview>(
    '/finance/statistics/overview',
    { params },
  );
}
/** èŽ·å–æœˆåº¦æ”¶æ”¯è¶‹åŠ¿ï¼ˆæœŸé—´å†…æ— æ•°æ®çš„æœˆä»½è¡¥ 0) */
export function getStatisticsTrend(params?: FinStatisticsApi.StatisticsParams) {
  return requestClient.get<FinStatisticsApi.StatisticsTrend[]>(
    '/finance/statistics/trend',
    { params },
  );
}
src/api/erp/finance/subject/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,77 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export namespace FinAccountSubjectApi {
  /** ä¼šè®¡ç§‘目信息 */
  export interface AccountSubject {
    id?: number; // ç§‘目编号
    parentId?: number; // ä¸Šçº§ç§‘目编号,空表示一级科目
    subjectCode: string; // ç§‘目编码
    subjectName: string; // ç§‘目名称
    subjectType: number; // ç§‘目类型:1资产类 2负债类 3所有者权益类 4成本类 5损益类
    balanceDirection: number; // ä½™é¢æ–¹å‘:1借 2è´·
    status: number; // çŠ¶æ€ï¼š0启用 1禁用
    remark?: string; // å¤‡æ³¨
    createTime?: Date; // åˆ›å»ºæ—¶é—´
    children?: AccountSubject[]; // å­ç§‘目(树形返回)
  }
}
/** æŸ¥è¯¢ä¼šè®¡ç§‘目分页 */
export function getAccountSubjectPage(params: PageParam) {
  return requestClient.get<PageResult<FinAccountSubjectApi.AccountSubject>>(
    '/finance/account-subject/page',
    { params },
  );
}
/** æŸ¥è¯¢ä¼šè®¡ç§‘目录(树形)列表 */
export function getAccountSubjectList(params?: any) {
  return requestClient.get<FinAccountSubjectApi.AccountSubject[]>(
    '/finance/account-subject/list',
    { params },
  );
}
/** æŸ¥è¯¢ä¼šè®¡ç§‘目精简列表(不分页、平铺) */
export function getAccountSubjectSimpleList() {
  return requestClient.get<FinAccountSubjectApi.AccountSubject[]>(
    '/finance/account-subject/simple-list',
  );
}
/** æŸ¥è¯¢ä¼šè®¡ç§‘目详情 */
export function getAccountSubject(id: number) {
  return requestClient.get<FinAccountSubjectApi.AccountSubject>(
    `/finance/account-subject/get?id=${id}`,
  );
}
/** æ–°å¢žä¼šè®¡ç§‘ç›® */
export function createAccountSubject(
  data: FinAccountSubjectApi.AccountSubject,
) {
  return requestClient.post('/finance/account-subject/create', data);
}
/** ä¿®æ”¹ä¼šè®¡ç§‘ç›® */
export function updateAccountSubject(
  data: FinAccountSubjectApi.AccountSubject,
) {
  return requestClient.put('/finance/account-subject/update', data);
}
/** åˆ é™¤ä¼šè®¡ç§‘目(支持批量,含下级科目) */
export function deleteAccountSubject(ids: number[]) {
  return requestClient.delete(
    `/finance/account-subject/delete?ids=${ids.join(',')}`,
  );
}
/** å¯¼å‡ºä¼šè®¡ç§‘ç›® Excel */
export function exportAccountSubject(params: any) {
  return requestClient.download('/finance/account-subject/export-excel', {
    params,
  });
}
src/api/erp/finance/voucher/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,101 @@
import type { PageParam, PageResult } from '@vben/request';
import type { SystemStorageApi } from '#/api/system/storage';
import { requestClient } from '#/api/request';
export namespace FinVoucherApi {
  /** å‡­è¯åˆ†å½• */
  export interface VoucherEntry {
    id?: number; // åˆ†å½•编号
    rowNo?: number; // è¡Œå·
    subjectCode?: string; // ç§‘目编码
    subjectName?: string; // ç§‘目名称
    summary?: string; // æ‘˜è¦
    debit?: number; // å€Ÿæ–¹é‡‘额
    credit?: number; // è´·æ–¹é‡‘额
    auxiliaryType?: number; // è¾…助核算类型
    auxiliaryId?: string; // è¾…助核算对象编号
    auxiliaryName?: string; // è¾…助核算对象名称
  }
  /** è®°è´¦å‡­è¯ */
  export interface Voucher {
    id?: number; // å‡­è¯ç¼–号
    voucherNo?: string; // å‡­è¯å­—号,留空时后端自动生成
    voucherDate: string; // å‡­è¯æ—¥æœŸ
    summary?: string; // æ‘˜è¦
    debit?: number; // å€Ÿæ–¹åˆè®¡
    credit?: number; // è´·æ–¹åˆè®¡
    status?: number; // å‡­è¯çŠ¶æ€ï¼š0未过账 10已过账 20已作废
    creator?: string; // åˆ¶å•人(用户编号,仅用于搜索条件)
    creatorName?: string; // åˆ¶å•人昵称(列表、导出展示用)
    attachmentCount?: number; // é™„ä»¶å¼ æ•°
    remark?: string; // å¤‡æ³¨
    createTime?: Date; // åˆ›å»ºæ—¶é—´
    entries?: VoucherEntry[]; // å‡­è¯åˆ†å½•
    attachmentList?: SystemStorageApi.StorageBlob[]; // é™„件列表
    hasAttachment?: boolean; // æ˜¯å¦æœ‰é™„ä»¶
    blobIds?: number[]; // é™„ä»¶ blobId åˆ—表(提交用)
  }
}
/** æŸ¥è¯¢è®°è´¦å‡­è¯åˆ†é¡µ */
export function getVoucherPage(params: PageParam) {
  return requestClient.get<PageResult<FinVoucherApi.Voucher>>(
    '/finance/voucher/page',
    { params },
  );
}
/** æŸ¥è¯¢è®°è´¦å‡­è¯åˆ†é¡µï¼ˆå«åˆ†å½•,用于凭证列表展开与打印) */
export function getVoucherPageWithEntry(params: PageParam) {
  return requestClient.get<PageResult<FinVoucherApi.Voucher>>(
    '/finance/voucher/page-with-entry',
    { params },
  );
}
/** æŸ¥è¯¢è®°è´¦å‡­è¯è¯¦æƒ…(含分录与附件) */
export function getVoucher(id: number) {
  return requestClient.get<FinVoucherApi.Voucher>(
    `/finance/voucher/get?id=${id}`,
  );
}
/** æ–°å¢žè®°è´¦å‡­è¯ */
export function createVoucher(data: FinVoucherApi.Voucher) {
  return requestClient.post('/finance/voucher/create', data);
}
/** ä¿®æ”¹è®°è´¦å‡­è¯ */
export function updateVoucher(data: FinVoucherApi.Voucher) {
  return requestClient.put('/finance/voucher/update', data);
}
/** åˆ é™¤è®°è´¦å‡­è¯ */
export function deleteVoucher(id: number) {
  return requestClient.delete(`/finance/voucher/delete?id=${id}`);
}
/** æ‰¹é‡åˆ é™¤è®°è´¦å‡­è¯ */
export function deleteVoucherList(ids: number[]) {
  return requestClient.delete(
    `/finance/voucher/delete-list?ids=${ids.join(',')}`,
  );
}
/** å‡­è¯è¿‡è´¦ */
export function postVoucher(id: number) {
  return requestClient.put('/finance/voucher/post', null, { params: { id } });
}
/** å‡­è¯ä½œåºŸ */
export function cancelVoucher(id: number) {
  return requestClient.put('/finance/voucher/cancel', null, { params: { id } });
}
/** å¯¼å‡ºè®°è´¦å‡­è¯ Excel */
export function exportVoucher(params: any) {
  return requestClient.download('/finance/voucher/export-excel', { params });
}
src/packages/constants/src/dict-enum.ts
@@ -144,6 +144,16 @@
  ERP_STOCK_RECORD_BIZ_TYPE: 'erp_stock_record_biz_type', // åº“存明细的业务类型
} as const;
/** ========== FINANCE - è´¢åŠ¡ç®¡ç†æ¨¡å— ========== */
const FINANCE_DICT = {
  FINANCE_SUBJECT_TYPE: 'finance_subject_type', // ä¼šè®¡ç§‘目类型
  FINANCE_BALANCE_DIRECTION: 'finance_balance_direction', // ç§‘目余额方向
  FINANCE_AUXILIARY_TYPE: 'finance_auxiliary_type', // è¾…助核算类型
  FINANCE_VOUCHER_STATUS: 'finance_voucher_status', // è®°è´¦å‡­è¯çŠ¶æ€
  FINANCE_FIXED_ASSET_STATUS: 'finance_fixed_asset_status', // å›ºå®šèµ„产状态
  FINANCE_INTANGIBLE_ASSET_STATUS: 'finance_intangible_asset_status', // æ— å½¢èµ„产状态
} as const;
/** ========== AI - äººå·¥æ™ºèƒ½æ¨¡å— ========== */
const AI_DICT = {
  AI_GENERATE_MODE: 'ai_generate_mode', // AI ç”Ÿæˆæ¨¡å¼
@@ -351,6 +361,7 @@
  ...BPM_DICT,
  ...CRM_DICT,
  ...ERP_DICT,
  ...FINANCE_DICT,
  ...HRM_DICT,
  ...INFRA_DICT,
  ...IM_DICT,
src/views/erp/finance/fixed-asset/data.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,389 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { FinFixedAssetApi } from '#/api/erp/finance/fixed-asset';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { handleTree } from '@vben/utils';
import { z } from '#/adapter/form';
import { getSimpleDeptList } from '#/api/system/dept';
import { getSimpleUserList } from '#/api/system/user';
import { getRangePickerDefaultProps } from '#/utils';
import dayjs from 'dayjs';
/**
 * ä½¿ç”¨éƒ¨é—¨ä¸‹æ‹‰ï¼šç³»ç»Ÿéƒ¨é—¨æ ‘(value = éƒ¨é—¨ç¼–号)
 * ä¸Ž CRM ç»Ÿè®¡é¡µçš„「归属部门」写法保持一致
 */
function departmentSelectProps() {
  return {
    allowClear: true,
    class: 'w-full',
    api: async () => handleTree(await getSimpleDeptList()),
    labelField: 'name',
    valueField: 'id',
    childrenField: 'children',
    placeholder: '请选择使用部门',
    treeDefaultExpandAll: true,
  };
}
/**
 * ä¿ç®¡äººä¸‹æ‹‰ï¼šç³»ç»Ÿç”¨æˆ·åˆ—表(value = ç”¨æˆ·ç¼–号,label = æ˜µç§°ï¼‰
 * ä¸Ž WMS å•据里选用户的写法保持一致
 */
function keeperSelectProps() {
  return {
    allowClear: true,
    class: 'w-full',
    api: getSimpleUserList,
    labelField: 'nickname',
    valueField: 'id',
    placeholder: '请选择保管人',
    showSearch: true,
  };
}
/** èµ„产状态:1在用 2闲置 3维修中 4已报废 */
export const FIXED_ASSET_STATUS_IN_USE = 1;
export const FIXED_ASSET_STATUS_IDLE = 2;
export const FIXED_ASSET_STATUS_REPAIRING = 3;
export const FIXED_ASSET_STATUS_SCRAPPED = 4;
/** å¯è®¡ææŠ˜æ—§çš„状态(已报废的资产不再计提) */
export const DEPRECIABLE_FIXED_ASSET_STATUS = [
  FIXED_ASSET_STATUS_IN_USE,
  FIXED_ASSET_STATUS_IDLE,
  FIXED_ASSET_STATUS_REPAIRING,
];
/** æ–°å¢ž/修改的表单 */
export function useFormSchema(): VbenFormSchema[] {
  return [
    {
      component: 'Input',
      fieldName: 'id',
      dependencies: {
        triggerFields: [''],
        show: () => false,
      },
    },
    {
      fieldName: 'assetCode',
      label: '资产编码',
      component: 'Input',
      componentProps: {
        placeholder: '请输入资产编码,如 GD2026100001',
        allowClear: true,
      },
      rules: 'required',
    },
    {
      fieldName: 'assetName',
      label: '资产名称',
      component: 'Input',
      componentProps: {
        placeholder: '请输入资产名称',
        allowClear: true,
      },
      rules: 'required',
    },
    {
      fieldName: 'category',
      label: '资产类别',
      component: 'Input',
      componentProps: {
        placeholder: '请输入资产类别,如 ç”Ÿäº§è®¾å¤‡',
        allowClear: true,
      },
    },
    {
      fieldName: 'specification',
      label: '规格型号',
      component: 'Input',
      componentProps: {
        placeholder: '请输入规格型号',
        allowClear: true,
      },
    },
    {
      fieldName: 'purchaseDate',
      label: '购置日期',
      component: 'DatePicker',
      componentProps: {
        placeholder: '请选择购置日期',
        showTime: false,
        valueFormat: 'YYYY-MM-DD',
        format: 'YYYY-MM-DD',
        class: 'w-full',
      },
      rules: 'required',
      defaultValue: dayjs().format('YYYY-MM-DD'),
    },
    {
      fieldName: 'originalValue',
      label: '资产原值',
      component: 'InputNumber',
      componentProps: {
        class: 'w-full',
        placeholder: '请输入资产原值',
        min: 0,
        precision: 2,
      },
      rules: z.number().min(0).default(0),
    },
    {
      fieldName: 'usefulLife',
      label: '使用年限(年)',
      component: 'InputNumber',
      componentProps: {
        class: 'w-full',
        placeholder: '请输入使用年限',
        min: 1,
        precision: 0,
      },
      defaultValue: 10,
      rules: z.number().min(1).default(10),
    },
    {
      fieldName: 'residualRate',
      label: '残值率(%)',
      component: 'InputNumber',
      componentProps: {
        class: 'w-full',
        placeholder: '请输入残值率',
        min: 0,
        max: 100,
        precision: 2,
      },
      defaultValue: 5,
      rules: z.number().min(0).max(100).default(5),
      help: '月折旧额 =(原值 Ã—(1 - æ®‹å€¼çŽ‡ï¼‰ï¼‰Ã·ï¼ˆä½¿ç”¨å¹´é™ Ã— 12)',
    },
    {
      fieldName: 'location',
      label: '存放地点',
      component: 'Input',
      componentProps: {
        placeholder: '请输入存放地点',
        allowClear: true,
      },
    },
    {
      fieldName: 'departmentId',
      label: '使用部门',
      component: 'ApiTreeSelect',
      componentProps: departmentSelectProps(),
    },
    {
      fieldName: 'keeperId',
      label: '保管人',
      component: 'ApiSelect',
      componentProps: keeperSelectProps(),
    },
    {
      fieldName: 'status',
      label: '资产状态',
      component: 'RadioGroup',
      componentProps: {
        options: getDictOptions(
          DICT_TYPE.FINANCE_FIXED_ASSET_STATUS,
          'number',
        ),
        buttonStyle: 'solid',
        optionType: 'button',
      },
      defaultValue: FIXED_ASSET_STATUS_IN_USE,
      rules: 'selectRequired',
    },
    {
      fieldName: 'remark',
      label: '备注',
      component: 'Textarea',
      componentProps: {
        placeholder: '请输入备注',
        rows: 3,
      },
      formItemClass: 'md:col-span-2',
    },
  ];
}
/** åˆ—表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
  return [
    {
      fieldName: 'assetCode',
      label: '资产编码',
      component: 'Input',
      componentProps: {
        placeholder: '请输入资产编码',
        allowClear: true,
      },
    },
    {
      fieldName: 'assetName',
      label: '资产名称',
      component: 'Input',
      componentProps: {
        placeholder: '请输入资产名称',
        allowClear: true,
      },
    },
    {
      fieldName: 'category',
      label: '资产类别',
      component: 'Input',
      componentProps: {
        placeholder: '请输入资产类别',
        allowClear: true,
      },
    },
    {
      fieldName: 'purchaseDate',
      label: '购置日期',
      component: 'RangePicker',
      componentProps: {
        ...getRangePickerDefaultProps(),
        showTime: false,
        format: 'YYYY-MM-DD',
        valueFormat: 'YYYY-MM-DD',
        allowClear: true,
      },
    },
    {
      fieldName: 'departmentId',
      label: '使用部门',
      component: 'ApiTreeSelect',
      componentProps: departmentSelectProps(),
    },
    {
      fieldName: 'keeperId',
      label: '保管人',
      component: 'ApiSelect',
      componentProps: keeperSelectProps(),
    },
    {
      fieldName: 'status',
      label: '资产状态',
      component: 'Select',
      componentProps: {
        options: getDictOptions(
          DICT_TYPE.FINANCE_FIXED_ASSET_STATUS,
          'number',
        ),
        placeholder: '请选择资产状态',
        allowClear: true,
      },
    },
  ];
}
/** åˆ—表的字段 */
export function useGridColumns(): VxeTableGridOptions<FinFixedAssetApi.FixedAsset>['columns'] {
  return [
    { type: 'checkbox', width: 40 },
    {
      field: 'assetCode',
      title: '资产编码',
      minWidth: 150,
      fixed: 'left',
    },
    {
      field: 'assetName',
      title: '资产名称',
      minWidth: 160,
    },
    {
      field: 'category',
      title: '资产类别',
      minWidth: 120,
    },
    {
      field: 'specification',
      title: '规格型号',
      minWidth: 140,
      showOverflow: 'tooltip',
    },
    {
      field: 'purchaseDate',
      title: '购置日期',
      minWidth: 120,
      formatter: 'formatDate',
    },
    {
      field: 'originalValue',
      title: '资产原值',
      minWidth: 130,
      align: 'right',
      formatter: 'formatAmount2',
    },
    {
      field: 'usefulLife',
      title: '使用年限',
      minWidth: 100,
      align: 'right',
    },
    {
      field: 'residualRate',
      title: '残值率(%)',
      minWidth: 110,
      align: 'right',
    },
    {
      field: 'monthlyDepreciation',
      title: '月折旧额',
      minWidth: 130,
      align: 'right',
      formatter: 'formatAmount2',
    },
    {
      field: 'accumulatedDepreciation',
      title: '累计折旧',
      minWidth: 130,
      align: 'right',
      formatter: 'formatAmount2',
    },
    {
      field: 'netValue',
      title: '净值',
      minWidth: 130,
      align: 'right',
      formatter: 'formatAmount2',
    },
    {
      // åŽç«¯ departmentId å­˜çš„æ˜¯éƒ¨é—¨ç¼–号,列表展示用后端解析好的 departmentName
      field: 'departmentName',
      title: '使用部门',
      minWidth: 120,
    },
    {
      field: 'keeperName',
      title: '保管人',
      minWidth: 100,
    },
    {
      field: 'status',
      title: '资产状态',
      minWidth: 110,
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.FINANCE_FIXED_ASSET_STATUS },
      },
    },
    {
      field: 'createTime',
      title: '创建时间',
      minWidth: 180,
      formatter: 'formatDateTime',
    },
    {
      title: '操作',
      width: 200,
      fixed: 'right',
      slots: { default: 'actions' },
    },
  ];
}
src/views/erp/finance/fixed-asset/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,217 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { FinFixedAssetApi } from '#/api/erp/finance/fixed-asset';
import { ref } from 'vue';
import { Page, useVbenModal } from '@vben/common-ui';
import { downloadFileFromBlobPart, isEmpty } from '@vben/utils';
import { message } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
  deleteFixedAsset,
  deleteFixedAssetList,
  depreciateFixedAssetList,
  exportFixedAsset,
  getFixedAssetPage,
} from '#/api/erp/finance/fixed-asset';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
/** å›ºå®šèµ„产列表 */
defineOptions({ name: 'FinFixedAsset' });
const [FormModal, formModalApi] = useVbenModal({
  connectedComponent: Form,
  destroyOnClose: true,
});
/** åˆ·æ–°è¡¨æ ¼ */
function handleRefresh() {
  gridApi.query();
}
/** å¯¼å‡ºè¡¨æ ¼ */
async function handleExport() {
  const data = await exportFixedAsset(await gridApi.formApi.getValues());
  downloadFileFromBlobPart({ fileName: '固定资产.xls', source: data });
}
/** æ–°å¢žå›ºå®šèµ„产 */
function handleCreate() {
  formModalApi.setData(null).open();
}
/** ç¼–辑固定资产 */
function handleEdit(row: FinFixedAssetApi.FixedAsset) {
  formModalApi.setData(row).open();
}
/** åˆ é™¤å›ºå®šèµ„产 */
async function handleDelete(ids: number[]) {
  const hideLoading = message.loading({
    content: $t('ui.actionMessage.deleting'),
    duration: 0,
  });
  try {
    await (ids.length === 1
      ? deleteFixedAsset(ids[0]!)
      : deleteFixedAssetList(ids));
    checkedIds.value = [];
    message.success($t('ui.actionMessage.deleteSuccess'));
    handleRefresh();
  } finally {
    hideLoading();
  }
}
/** è®¡ææŠ˜æ—§ */
async function handleDepreciate(ids: number[]) {
  const hideLoading = message.loading({
    content: '计提折旧中...',
    duration: 0,
  });
  try {
    const result = await depreciateFixedAssetList(ids);
    message.success(
      `本次计提 ${result.processedCount} é¡¹èµ„产,折旧合计 ${result.totalMonthlyDepreciation} å…ƒ`,
    );
    handleRefresh();
  } finally {
    hideLoading();
  }
}
const checkedIds = ref<number[]>([]);
function handleRowCheckboxChange({
  records,
}: {
  records: FinFixedAssetApi.FixedAsset[];
}) {
  checkedIds.value = records.map((item) => item.id!);
}
const [Grid, gridApi] = useVbenVxeGrid({
  formOptions: {
    schema: useGridFormSchema(),
  },
  gridOptions: {
    columns: useGridColumns(),
    height: 'auto',
    keepSource: true,
    proxyConfig: {
      ajax: {
        query: async ({ page }, formValues) => {
          return await getFixedAssetPage({
            pageNo: page.currentPage,
            pageSize: page.pageSize,
            ...formValues,
          });
        },
      },
    },
    rowConfig: {
      keyField: 'id',
      isHover: true,
    },
    toolbarConfig: {
      refresh: true,
      search: true,
    },
  } as VxeTableGridOptions<FinFixedAssetApi.FixedAsset>,
  gridEvents: {
    checkboxAll: handleRowCheckboxChange,
    checkboxChange: handleRowCheckboxChange,
  },
});
</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: ['finance:fixed-asset:create'],
              onClick: handleCreate,
            },
            {
              label: '计提折旧',
              type: 'primary',
              icon: ACTION_ICON.AUDIT,
              auth: ['finance:fixed-asset:depreciate'],
              popConfirm: {
                title: isEmpty(checkedIds)
                  ? '未勾选资产,将对全部未报废资产计提一个月折旧,确认继续吗?'
                  : `确认对已勾选的 ${checkedIds.length} é¡¹èµ„产计提一个月折旧吗?`,
                confirm: handleDepreciate.bind(null, checkedIds),
              },
            },
            {
              label: $t('ui.actionTitle.export'),
              type: 'primary',
              icon: ACTION_ICON.DOWNLOAD,
              auth: ['finance:fixed-asset:export'],
              onClick: handleExport,
            },
            {
              label: '批量删除',
              type: 'primary',
              danger: true,
              disabled: isEmpty(checkedIds),
              icon: ACTION_ICON.DELETE,
              auth: ['finance:fixed-asset:delete'],
              popConfirm: {
                title: '是否删除所选中数据?',
                confirm: handleDelete.bind(null, checkedIds),
              },
            },
          ]"
        />
      </template>
      <template #actions="{ row }">
        <TableAction
          :actions="[
            {
              label: $t('common.edit'),
              type: 'link',
              icon: ACTION_ICON.EDIT,
              auth: ['finance:fixed-asset:update'],
              onClick: handleEdit.bind(null, row),
            },
            {
              label: '计提折旧',
              type: 'link',
              icon: ACTION_ICON.AUDIT,
              auth: ['finance:fixed-asset:depreciate'],
              ifShow: () => row.status !== 4,
              popConfirm: {
                title: `确认对资产【${row.assetName}】计提一个月折旧吗?`,
                confirm: handleDepreciate.bind(null, [row.id!]),
              },
            },
            {
              label: $t('common.delete'),
              type: 'link',
              danger: true,
              icon: ACTION_ICON.DELETE,
              auth: ['finance:fixed-asset:delete'],
              popConfirm: {
                title: $t('ui.actionMessage.deleteConfirm', [row.assetName]),
                confirm: handleDelete.bind(null, [row.id!]),
              },
            },
          ]"
        />
      </template>
    </Grid>
  </Page>
</template>
src/views/erp/finance/fixed-asset/modules/form.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,89 @@
<script lang="ts" setup>
import type { FinFixedAssetApi } from '#/api/erp/finance/fixed-asset';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { $t } from '@vben/locales';
import { message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import {
  createFixedAsset,
  getFixedAsset,
  updateFixedAsset,
} from '#/api/erp/finance/fixed-asset';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<FinFixedAssetApi.FixedAsset>();
const getTitle = computed(() => {
  return formData.value?.id
    ? $t('ui.actionTitle.edit', ['固定资产'])
    : $t('ui.actionTitle.create', ['固定资产']);
});
const [Form, formApi] = useVbenForm({
  commonConfig: {
    componentProps: {
      class: 'w-full',
    },
    labelWidth: 120,
  },
  wrapperClass: 'grid-cols-2',
  layout: 'horizontal',
  schema: useFormSchema(),
  showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
  async onConfirm() {
    const { valid } = await formApi.validate();
    if (!valid) {
      return;
    }
    modalApi.lock();
    const data = (await formApi.getValues()) as FinFixedAssetApi.FixedAsset;
    if (formData.value?.id) {
      data.id = formData.value.id;
    }
    try {
      await (formData.value?.id
        ? updateFixedAsset(data)
        : createFixedAsset(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;
    }
    formData.value = undefined;
    await formApi.resetForm();
    const data = modalApi.getData<FinFixedAssetApi.FixedAsset>();
    if (!data?.id) {
      return;
    }
    modalApi.lock();
    try {
      formData.value = await getFixedAsset(data.id);
      await formApi.setValues(formData.value, false);
    } finally {
      modalApi.unlock();
    }
  },
});
</script>
<template>
  <Modal class="w-1/2" :title="getTitle">
    <Form class="mx-4" />
  </Modal>
</template>
src/views/erp/finance/intangible-asset/data.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,341 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { FinIntangibleAssetApi } from '#/api/erp/finance/intangible-asset';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { z } from '#/adapter/form';
import { getRangePickerDefaultProps } from '#/utils';
import dayjs from 'dayjs';
/** èµ„产状态:1在用 2已失效 3已摊销完 */
export const INTANGIBLE_STATUS_IN_USE = 1;
export const INTANGIBLE_STATUS_INVALID = 2;
export const INTANGIBLE_STATUS_AMORTIZED = 3;
/** å¯è®¡ææ‘Šé”€çš„状态(仅「在用」的资产摊销,已失效/已摊完不再计提) */
export const AMORTIZABLE_INTANGIBLE_STATUS = [INTANGIBLE_STATUS_IN_USE];
/** æ–°å¢ž/修改的表单 */
export function useFormSchema(): VbenFormSchema[] {
  return [
    {
      component: 'Input',
      fieldName: 'id',
      dependencies: {
        triggerFields: [''],
        show: () => false,
      },
    },
    {
      fieldName: 'assetCode',
      label: '资产编码',
      component: 'Input',
      componentProps: {
        placeholder: '请输入资产编码,如 WX2026100001',
        allowClear: true,
      },
      rules: 'required',
    },
    {
      fieldName: 'assetName',
      label: '资产名称',
      component: 'Input',
      componentProps: {
        placeholder: '请输入资产名称',
        allowClear: true,
      },
      rules: 'required',
    },
    {
      fieldName: 'category',
      label: '资产类别',
      component: 'Input',
      componentProps: {
        placeholder: '请输入资产类别,如 è½¯ä»¶è‘—作权',
        allowClear: true,
      },
    },
    {
      fieldName: 'certificateNo',
      label: '证书编号',
      component: 'Input',
      componentProps: {
        placeholder: '请输入证书编号',
        allowClear: true,
      },
    },
    {
      fieldName: 'acquisitionDate',
      label: '取得日期',
      component: 'DatePicker',
      componentProps: {
        placeholder: '请选择取得日期',
        showTime: false,
        valueFormat: 'YYYY-MM-DD',
        format: 'YYYY-MM-DD',
        class: 'w-full',
      },
      rules: 'required',
      defaultValue: dayjs().format('YYYY-MM-DD'),
    },
    {
      fieldName: 'validityDate',
      label: '有效期至',
      component: 'DatePicker',
      componentProps: {
        placeholder: '请选择有效期截止日期',
        showTime: false,
        valueFormat: 'YYYY-MM-DD',
        format: 'YYYY-MM-DD',
        class: 'w-full',
      },
    },
    {
      fieldName: 'originalValue',
      label: '资产原值',
      component: 'InputNumber',
      componentProps: {
        class: 'w-full',
        placeholder: '请输入资产原值',
        min: 0,
        precision: 2,
      },
      rules: z.number().min(0).default(0),
    },
    {
      fieldName: 'amortizationPeriod',
      label: '摊销年限(年)',
      component: 'InputNumber',
      componentProps: {
        class: 'w-full',
        placeholder: '请输入摊销年限',
        min: 1,
        precision: 0,
      },
      defaultValue: 10,
      rules: z.number().min(1).default(10),
    },
    {
      fieldName: 'residualRate',
      label: '残值率(%)',
      component: 'InputNumber',
      componentProps: {
        class: 'w-full',
        placeholder: '请输入残值率',
        min: 0,
        max: 100,
        precision: 2,
      },
      defaultValue: 0,
      rules: z.number().min(0).max(100).default(0),
      help: '月摊销额 =(原值 Ã—(1 - æ®‹å€¼çŽ‡ï¼‰ï¼‰Ã·ï¼ˆæ‘Šé”€å¹´é™ Ã— 12)',
    },
    {
      fieldName: 'status',
      label: '资产状态',
      component: 'RadioGroup',
      componentProps: {
        options: getDictOptions(
          DICT_TYPE.FINANCE_INTANGIBLE_ASSET_STATUS,
          'number',
        ),
        buttonStyle: 'solid',
        optionType: 'button',
      },
      defaultValue: INTANGIBLE_STATUS_IN_USE,
      rules: 'selectRequired',
    },
    {
      fieldName: 'description',
      label: '资产描述',
      component: 'Textarea',
      componentProps: {
        placeholder: '请输入资产描述',
        rows: 2,
      },
      formItemClass: 'md:col-span-2',
    },
    {
      fieldName: 'remark',
      label: '备注',
      component: 'Textarea',
      componentProps: {
        placeholder: '请输入备注',
        rows: 2,
      },
      formItemClass: 'md:col-span-2',
    },
  ];
}
/** åˆ—表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
  return [
    {
      fieldName: 'assetCode',
      label: '资产编码',
      component: 'Input',
      componentProps: {
        placeholder: '请输入资产编码',
        allowClear: true,
      },
    },
    {
      fieldName: 'assetName',
      label: '资产名称',
      component: 'Input',
      componentProps: {
        placeholder: '请输入资产名称',
        allowClear: true,
      },
    },
    {
      fieldName: 'category',
      label: '资产类别',
      component: 'Input',
      componentProps: {
        placeholder: '请输入资产类别',
        allowClear: true,
      },
    },
    {
      fieldName: 'certificateNo',
      label: '证书编号',
      component: 'Input',
      componentProps: {
        placeholder: '请输入证书编号',
        allowClear: true,
      },
    },
    {
      fieldName: 'acquisitionDate',
      label: '取得日期',
      component: 'RangePicker',
      componentProps: {
        ...getRangePickerDefaultProps(),
        showTime: false,
        format: 'YYYY-MM-DD',
        valueFormat: 'YYYY-MM-DD',
        allowClear: true,
      },
    },
    {
      fieldName: 'status',
      label: '资产状态',
      component: 'Select',
      componentProps: {
        options: getDictOptions(
          DICT_TYPE.FINANCE_INTANGIBLE_ASSET_STATUS,
          'number',
        ),
        placeholder: '请选择资产状态',
        allowClear: true,
      },
    },
  ];
}
/** åˆ—表的字段 */
export function useGridColumns(): VxeTableGridOptions<FinIntangibleAssetApi.IntangibleAsset>['columns'] {
  return [
    { type: 'checkbox', width: 40 },
    {
      field: 'assetCode',
      title: '资产编码',
      minWidth: 150,
      fixed: 'left',
    },
    {
      field: 'assetName',
      title: '资产名称',
      minWidth: 160,
    },
    {
      field: 'category',
      title: '资产类别',
      minWidth: 120,
    },
    {
      field: 'certificateNo',
      title: '证书编号',
      minWidth: 140,
      showOverflow: 'tooltip',
    },
    {
      field: 'acquisitionDate',
      title: '取得日期',
      minWidth: 120,
      formatter: 'formatDate',
    },
    {
      field: 'validityDate',
      title: '有效期至',
      minWidth: 120,
      formatter: 'formatDate',
    },
    {
      field: 'originalValue',
      title: '资产原值',
      minWidth: 130,
      align: 'right',
      formatter: 'formatAmount2',
    },
    {
      field: 'amortizationPeriod',
      title: '摊销年限',
      minWidth: 100,
      align: 'right',
    },
    {
      field: 'residualRate',
      title: '残值率(%)',
      minWidth: 110,
      align: 'right',
    },
    {
      field: 'monthlyAmortization',
      title: '月摊销额',
      minWidth: 130,
      align: 'right',
      formatter: 'formatAmount2',
    },
    {
      field: 'accumulatedAmortization',
      title: '累计摊销',
      minWidth: 130,
      align: 'right',
      formatter: 'formatAmount2',
    },
    {
      field: 'netValue',
      title: '净值',
      minWidth: 130,
      align: 'right',
      formatter: 'formatAmount2',
    },
    {
      field: 'status',
      title: '资产状态',
      minWidth: 110,
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.FINANCE_INTANGIBLE_ASSET_STATUS },
      },
    },
    {
      field: 'createTime',
      title: '创建时间',
      minWidth: 180,
      formatter: 'formatDateTime',
    },
    {
      title: '操作',
      width: 200,
      fixed: 'right',
      slots: { default: 'actions' },
    },
  ];
}
src/views/erp/finance/intangible-asset/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,217 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { FinIntangibleAssetApi } from '#/api/erp/finance/intangible-asset';
import { ref } from 'vue';
import { Page, useVbenModal } from '@vben/common-ui';
import { downloadFileFromBlobPart, isEmpty } from '@vben/utils';
import { message } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
  amortizeIntangibleAssetList,
  deleteIntangibleAsset,
  deleteIntangibleAssetList,
  exportIntangibleAsset,
  getIntangibleAssetPage,
} from '#/api/erp/finance/intangible-asset';
import { $t } from '#/locales';
import { INTANGIBLE_STATUS_IN_USE, useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
/** æ— å½¢èµ„产列表 */
defineOptions({ name: 'FinIntangibleAsset' });
const [FormModal, formModalApi] = useVbenModal({
  connectedComponent: Form,
  destroyOnClose: true,
});
/** åˆ·æ–°è¡¨æ ¼ */
function handleRefresh() {
  gridApi.query();
}
/** å¯¼å‡ºè¡¨æ ¼ */
async function handleExport() {
  const data = await exportIntangibleAsset(await gridApi.formApi.getValues());
  downloadFileFromBlobPart({ fileName: '无形资产.xls', source: data });
}
/** æ–°å¢žæ— å½¢èµ„产 */
function handleCreate() {
  formModalApi.setData(null).open();
}
/** ç¼–辑无形资产 */
function handleEdit(row: FinIntangibleAssetApi.IntangibleAsset) {
  formModalApi.setData(row).open();
}
/** åˆ é™¤æ— å½¢èµ„产 */
async function handleDelete(ids: number[]) {
  const hideLoading = message.loading({
    content: $t('ui.actionMessage.deleting'),
    duration: 0,
  });
  try {
    await (ids.length === 1
      ? deleteIntangibleAsset(ids[0]!)
      : deleteIntangibleAssetList(ids));
    checkedIds.value = [];
    message.success($t('ui.actionMessage.deleteSuccess'));
    handleRefresh();
  } finally {
    hideLoading();
  }
}
/** è®¡ææ‘Šé”€ */
async function handleAmortize(ids: number[]) {
  const hideLoading = message.loading({
    content: '计提摊销中...',
    duration: 0,
  });
  try {
    const result = await amortizeIntangibleAssetList(ids);
    message.success(
      `本次计提 ${result.processedCount} é¡¹èµ„产,摊销合计 ${result.totalMonthlyAmortization} å…ƒ`,
    );
    handleRefresh();
  } finally {
    hideLoading();
  }
}
const checkedIds = ref<number[]>([]);
function handleRowCheckboxChange({
  records,
}: {
  records: FinIntangibleAssetApi.IntangibleAsset[];
}) {
  checkedIds.value = records.map((item) => item.id!);
}
const [Grid, gridApi] = useVbenVxeGrid({
  formOptions: {
    schema: useGridFormSchema(),
  },
  gridOptions: {
    columns: useGridColumns(),
    height: 'auto',
    keepSource: true,
    proxyConfig: {
      ajax: {
        query: async ({ page }, formValues) => {
          return await getIntangibleAssetPage({
            pageNo: page.currentPage,
            pageSize: page.pageSize,
            ...formValues,
          });
        },
      },
    },
    rowConfig: {
      keyField: 'id',
      isHover: true,
    },
    toolbarConfig: {
      refresh: true,
      search: true,
    },
  } as VxeTableGridOptions<FinIntangibleAssetApi.IntangibleAsset>,
  gridEvents: {
    checkboxAll: handleRowCheckboxChange,
    checkboxChange: handleRowCheckboxChange,
  },
});
</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: ['finance:intangible-asset:create'],
              onClick: handleCreate,
            },
            {
              label: '计提摊销',
              type: 'primary',
              icon: ACTION_ICON.AUDIT,
              auth: ['finance:intangible-asset:amortize'],
              popConfirm: {
                title: isEmpty(checkedIds)
                  ? '未勾选资产,将对全部「在用」且未摊完的无形资产计提一个月摊销,确认继续吗?'
                  : `确认对已勾选的 ${checkedIds.length} é¡¹èµ„产计提一个月摊销吗?`,
                confirm: handleAmortize.bind(null, checkedIds),
              },
            },
            {
              label: $t('ui.actionTitle.export'),
              type: 'primary',
              icon: ACTION_ICON.DOWNLOAD,
              auth: ['finance:intangible-asset:export'],
              onClick: handleExport,
            },
            {
              label: '批量删除',
              type: 'primary',
              danger: true,
              disabled: isEmpty(checkedIds),
              icon: ACTION_ICON.DELETE,
              auth: ['finance:intangible-asset:delete'],
              popConfirm: {
                title: '是否删除所选中数据?',
                confirm: handleDelete.bind(null, checkedIds),
              },
            },
          ]"
        />
      </template>
      <template #actions="{ row }">
        <TableAction
          :actions="[
            {
              label: $t('common.edit'),
              type: 'link',
              icon: ACTION_ICON.EDIT,
              auth: ['finance:intangible-asset:update'],
              onClick: handleEdit.bind(null, row),
            },
            {
              label: '计提摊销',
              type: 'link',
              icon: ACTION_ICON.AUDIT,
              auth: ['finance:intangible-asset:amortize'],
              ifShow: () => row.status === INTANGIBLE_STATUS_IN_USE,
              popConfirm: {
                title: `确认对资产【${row.assetName}】计提一个月摊销吗?`,
                confirm: handleAmortize.bind(null, [row.id!]),
              },
            },
            {
              label: $t('common.delete'),
              type: 'link',
              danger: true,
              icon: ACTION_ICON.DELETE,
              auth: ['finance:intangible-asset:delete'],
              popConfirm: {
                title: $t('ui.actionMessage.deleteConfirm', [row.assetName]),
                confirm: handleDelete.bind(null, [row.id!]),
              },
            },
          ]"
        />
      </template>
    </Grid>
  </Page>
</template>
src/views/erp/finance/intangible-asset/modules/form.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,90 @@
<script lang="ts" setup>
import type { FinIntangibleAssetApi } from '#/api/erp/finance/intangible-asset';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { $t } from '@vben/locales';
import { message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import {
  createIntangibleAsset,
  getIntangibleAsset,
  updateIntangibleAsset,
} from '#/api/erp/finance/intangible-asset';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<FinIntangibleAssetApi.IntangibleAsset>();
const getTitle = computed(() => {
  return formData.value?.id
    ? $t('ui.actionTitle.edit', ['无形资产'])
    : $t('ui.actionTitle.create', ['无形资产']);
});
const [Form, formApi] = useVbenForm({
  commonConfig: {
    componentProps: {
      class: 'w-full',
    },
    labelWidth: 120,
  },
  wrapperClass: 'grid-cols-2',
  layout: 'horizontal',
  schema: useFormSchema(),
  showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
  async onConfirm() {
    const { valid } = await formApi.validate();
    if (!valid) {
      return;
    }
    modalApi.lock();
    const data =
      (await formApi.getValues()) as FinIntangibleAssetApi.IntangibleAsset;
    if (formData.value?.id) {
      data.id = formData.value.id;
    }
    try {
      await (formData.value?.id
        ? updateIntangibleAsset(data)
        : createIntangibleAsset(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;
    }
    formData.value = undefined;
    await formApi.resetForm();
    const data = modalApi.getData<FinIntangibleAssetApi.IntangibleAsset>();
    if (!data?.id) {
      return;
    }
    modalApi.lock();
    try {
      formData.value = await getIntangibleAsset(data.id);
      await formApi.setValues(formData.value, false);
    } finally {
      modalApi.unlock();
    }
  },
});
</script>
<template>
  <Modal class="w-1/2" :title="getTitle">
    <Form class="mx-4" />
  </Modal>
</template>
src/views/erp/finance/ledger/data.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,214 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { FinLedgerApi } from '#/api/erp/finance/ledger';
import { DICT_TYPE } from '@vben/constants';
import { z } from '#/adapter/form';
import { getAccountSubjectList } from '#/api/erp/finance/subject';
import dayjs from 'dayjs';
/** è¡Œç±»åž‹ -> å±•示文案 */
export const LEDGER_ROW_TYPE_LABEL: Record<string, string> = {
  opening: '期初余额',
  entry: '明细',
  monthly_total: '本月合计',
  yearly_total: '本年累计',
};
/** è¡Œç±»åž‹ -> Tag é¢œè‰² */
export const LEDGER_ROW_TYPE_COLOR: Record<string, string> = {
  opening: 'blue',
  entry: 'default',
  monthly_total: 'orange',
  yearly_total: 'purple',
};
/**
 * è´¦é¡µæŸ¥è¯¢è¡¨å•
 *
 * è¯´æ˜Žï¼šè´¦é¡µåªç»Ÿè®¡ã€Œå·²è¿‡è´¦ã€çš„凭证,未过账、已作废的凭证不计入。
 */
export function useLedgerFormSchema(): VbenFormSchema[] {
  return [
    {
      fieldName: 'subjectCode',
      label: '会计科目',
      component: 'ApiTreeSelect',
      componentProps: {
        api: getAccountSubjectList,
        labelField: 'subjectName',
        valueField: 'subjectCode',
        childrenField: 'children',
        placeholder: '请选择会计科目',
        treeDefaultExpandAll: true,
        showSearch: true,
        treeNodeFilterProp: 'subjectName',
      },
      rules: 'selectRequired',
    },
    {
      fieldName: 'startMonth',
      label: '起始月份',
      component: 'DatePicker',
      componentProps: {
        picker: 'month',
        allowClear: true,
        valueFormat: 'YYYY-MM',
        format: 'YYYY-MM',
        style: { width: '100%' },
        placeholder: '选择起始月份',
      },
      defaultValue: dayjs().startOf('year').format('YYYY-MM'),
    },
    {
      fieldName: 'endMonth',
      label: '结束月份',
      component: 'DatePicker',
      componentProps: {
        picker: 'month',
        allowClear: true,
        valueFormat: 'YYYY-MM',
        format: 'YYYY-MM',
        style: { width: '100%' },
        placeholder: '选择结束月份',
      },
      defaultValue: dayjs().format('YYYY-MM'),
    },
    {
      fieldName: 'includeChildren',
      label: '包含下级科目',
      component: 'Switch',
      componentProps: {
        checkedChildren: '是',
        unCheckedChildren: '否',
      },
      defaultValue: false,
      rules: z.boolean().default(false).optional(),
      help: '开启后,所选科目及其全部下级科目的发生额与余额合并统计',
    },
  ];
}
/** æ€»è´¦åˆ—:期初 + é€æœˆåˆè®¡ + æœ¬å¹´ç´¯è®¡ */
export function useGeneralColumns(): VxeTableGridOptions<FinLedgerApi.LedgerRow>['columns'] {
  return [
    {
      field: 'rowType',
      title: '行类型',
      width: 110,
      fixed: 'left',
      slots: { default: 'rowType' },
    },
    {
      field: 'period',
      title: '期间',
      width: 110,
    },
    {
      field: 'subjectCode',
      title: '科目编码',
      minWidth: 120,
    },
    {
      field: 'subjectName',
      title: '科目名称',
      minWidth: 160,
    },
    {
      field: 'debit',
      title: '借方发生额',
      minWidth: 140,
      align: 'right',
      formatter: 'formatAmount2',
    },
    {
      field: 'credit',
      title: '贷方发生额',
      minWidth: 140,
      align: 'right',
      formatter: 'formatAmount2',
    },
    {
      field: 'direction',
      title: '方向',
      width: 80,
      align: 'center',
      slots: { default: 'direction' },
    },
    {
      field: 'balance',
      title: '期末余额',
      minWidth: 140,
      align: 'right',
      formatter: 'formatAmount2',
    },
  ];
}
/** æ˜Žç»†è´¦åˆ—:期初 + é€ç¬”分录 + ç»“转合计 */
export function useDetailColumns(): VxeTableGridOptions<FinLedgerApi.LedgerRow>['columns'] {
  return [
    {
      field: 'rowType',
      title: '行类型',
      width: 110,
      fixed: 'left',
      slots: { default: 'rowType' },
    },
    {
      field: 'date',
      title: '日期',
      width: 120,
      formatter: 'formatDate',
    },
    {
      field: 'voucherNo',
      title: '凭证字号',
      minWidth: 140,
    },
    {
      field: 'subjectCode',
      title: '科目编码',
      minWidth: 120,
    },
    {
      field: 'summary',
      title: '摘要',
      minWidth: 200,
      showOverflow: 'tooltip',
    },
    {
      field: 'debit',
      title: '借方金额',
      minWidth: 140,
      align: 'right',
      formatter: 'formatAmount2',
    },
    {
      field: 'credit',
      title: '贷方金额',
      minWidth: 140,
      align: 'right',
      formatter: 'formatAmount2',
    },
    {
      field: 'direction',
      title: '方向',
      width: 80,
      align: 'center',
      slots: { default: 'direction' },
    },
    {
      field: 'balance',
      title: '余额',
      minWidth: 140,
      align: 'right',
      formatter: 'formatAmount2',
    },
  ];
}
/** ä½™é¢æ–¹å‘字典类型,供页面渲染方向标签使用 */
export const LEDGER_DIRECTION_DICT = DICT_TYPE.FINANCE_BALANCE_DIRECTION;
src/views/erp/finance/ledger/detail/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,100 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { FinLedgerApi } from '#/api/erp/finance/ledger';
import { Page } from '@vben/common-ui';
import { downloadFileFromBlobPart } from '@vben/utils';
import { Tag } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { exportDetailLedger, getDetailLedger } from '#/api/erp/finance/ledger';
import { DictTag } from '#/components/dict-tag';
import { $t } from '#/locales';
import {
  LEDGER_DIRECTION_DICT,
  LEDGER_ROW_TYPE_COLOR,
  LEDGER_ROW_TYPE_LABEL,
  useDetailColumns,
  useLedgerFormSchema,
} from '../data';
/** æ˜Žç»†è´¦ */
defineOptions({ name: 'FinDetailLedger' });
/** å¯¼å‡ºè¡¨æ ¼ */
async function handleExport() {
  const data = await exportDetailLedger({
    ...((await gridApi.formApi.getValues()) as FinLedgerApi.LedgerParams),
  });
  downloadFileFromBlobPart({ fileName: '明细账.xls', source: data });
}
const [Grid, gridApi] = useVbenVxeGrid({
  formOptions: {
    schema: useLedgerFormSchema(),
  },
  gridOptions: {
    columns: useDetailColumns(),
    height: 'auto',
    keepSource: true,
    pagerConfig: {
      enabled: false,
    },
    proxyConfig: {
      ajax: {
        query: async (_params, formValues) => {
          // ç§‘目为必填,未选科目时直接返回空表,避免无意义的脏查询
          if (!formValues.subjectCode) {
            return [];
          }
          return await getDetailLedger(formValues as FinLedgerApi.LedgerParams);
        },
      },
    },
    rowConfig: {
      isHover: true,
    },
    toolbarConfig: {
      refresh: true,
      search: true,
    },
  } as VxeTableGridOptions<FinLedgerApi.LedgerRow>,
});
</script>
<template>
  <Page auto-content-height>
    <Grid table-title="明细账(仅统计已过账凭证,按凭证逐笔结转余额)">
      <template #toolbar-tools>
        <TableAction
          :actions="[
            {
              label: $t('ui.actionTitle.export'),
              type: 'primary',
              icon: ACTION_ICON.DOWNLOAD,
              auth: ['finance:ledger:export'],
              onClick: handleExport,
            },
          ]"
        />
      </template>
      <template #rowType="{ row }">
        <Tag :color="LEDGER_ROW_TYPE_COLOR[row.rowType] ?? 'default'">
          {{ LEDGER_ROW_TYPE_LABEL[row.rowType] ?? row.rowType }}
        </Tag>
      </template>
      <template #direction="{ row }">
        <DictTag
          v-if="row.direction"
          :type="LEDGER_DIRECTION_DICT"
          :value="row.direction"
        />
        <span v-else>--</span>
      </template>
    </Grid>
  </Page>
</template>
src/views/erp/finance/ledger/general/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,103 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { FinLedgerApi } from '#/api/erp/finance/ledger';
import { Page } from '@vben/common-ui';
import { downloadFileFromBlobPart } from '@vben/utils';
import { Tag } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
  exportGeneralLedger,
  getGeneralLedger,
} from '#/api/erp/finance/ledger';
import { DictTag } from '#/components/dict-tag';
import { $t } from '#/locales';
import {
  LEDGER_DIRECTION_DICT,
  LEDGER_ROW_TYPE_COLOR,
  LEDGER_ROW_TYPE_LABEL,
  useGeneralColumns,
  useLedgerFormSchema,
} from '../data';
/** æ€»è´¦ */
defineOptions({ name: 'FinGeneralLedger' });
/** å¯¼å‡ºè¡¨æ ¼ */
async function handleExport() {
  const data = await exportGeneralLedger({
    ...((await gridApi.formApi.getValues()) as FinLedgerApi.LedgerParams),
  });
  downloadFileFromBlobPart({ fileName: '总账.xls', source: data });
}
const [Grid, gridApi] = useVbenVxeGrid({
  formOptions: {
    schema: useLedgerFormSchema(),
  },
  gridOptions: {
    columns: useGeneralColumns(),
    height: 'auto',
    keepSource: true,
    pagerConfig: {
      enabled: false,
    },
    proxyConfig: {
      ajax: {
        query: async (_params, formValues) => {
          // ç§‘目为必填,未选科目时直接返回空表,避免无意义的脏查询
          if (!formValues.subjectCode) {
            return [];
          }
          return await getGeneralLedger(formValues as FinLedgerApi.LedgerParams);
        },
      },
    },
    rowConfig: {
      isHover: true,
    },
    toolbarConfig: {
      refresh: true,
      search: true,
    },
  } as VxeTableGridOptions<FinLedgerApi.LedgerRow>,
});
</script>
<template>
  <Page auto-content-height>
    <Grid table-title="总账(仅统计已过账凭证)">
      <template #toolbar-tools>
        <TableAction
          :actions="[
            {
              label: $t('ui.actionTitle.export'),
              type: 'primary',
              icon: ACTION_ICON.DOWNLOAD,
              auth: ['finance:ledger:export'],
              onClick: handleExport,
            },
          ]"
        />
      </template>
      <template #rowType="{ row }">
        <Tag :color="LEDGER_ROW_TYPE_COLOR[row.rowType] ?? 'default'">
          {{ LEDGER_ROW_TYPE_LABEL[row.rowType] ?? row.rowType }}
        </Tag>
      </template>
      <template #direction="{ row }">
        <DictTag
          v-if="row.direction"
          :type="LEDGER_DIRECTION_DICT"
          :value="row.direction"
        />
        <span v-else>--</span>
      </template>
    </Grid>
  </Page>
</template>
src/views/erp/finance/receipt/modules/item-form.vue
@@ -223,7 +223,6 @@
          v-model:value="row.receiptPrice"
          :precision="2"
          :disabled="disabled"
          :formatter="erpPriceInputFormatter"
          placeholder="请输入本次收款"
          @change="handleRowChange(row)"
        />
src/views/erp/finance/statistics/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,272 @@
<script lang="ts" setup>
import type { FinStatisticsApi } from '#/api/erp/finance/statistics';
import { computed, onMounted, ref } from 'vue';
import { Page } from '@vben/common-ui';
import { erpNumberFormatter } from '@vben/utils';
import { Button, Card, DatePicker, Col, Row, Spin, Tag } from 'ant-design-vue';
import {
  getStatisticsOverview,
  getStatisticsTrend,
} from '#/api/erp/finance/statistics';
import TrendChart from './modules/trend-chart.vue';
/** è´¢åŠ¡æŠ¥è¡¨ / ä¼šè®¡æ ¸ç®—看板 */
defineOptions({ name: 'FinStatistics' });
const loading = ref(false);
const overview = ref<FinStatisticsApi.StatisticsOverview>();
const trendList = ref<FinStatisticsApi.StatisticsTrend[]>([]);
/** æŸ¥è¯¢æ¡ä»¶ï¼šé»˜è®¤æœ¬å¹´ 1 æœˆè‡³å½“前月 */
const startMonth = ref<string>();
const endMonth = ref<string>();
/** é¡¶éƒ¨æ”¶æ”¯æ¦‚览卡片 */
const incomeCards = computed(() => [
  {
    title: '收款金额',
    value: overview.value?.receiptPrice ?? 0,
    color: '#18a058',
    tip: 'ERP å·²å®¡æ ¸æ”¶æ¬¾å•合计',
  },
  {
    title: '付款金额',
    value: overview.value?.paymentPrice ?? 0,
    color: '#d03050',
    tip: 'ERP å·²å®¡æ ¸ä»˜æ¬¾å•合计',
  },
  {
    title: '净利润',
    value: overview.value?.netProfit ?? 0,
    color: '#2f7df6',
    tip: '收款金额 - ä»˜æ¬¾é‡‘额',
  },
  {
    title: '销售订单金额',
    value: overview.value?.saleOrderPrice ?? 0,
    color: '#7c3aed',
    tip: 'ERP å·²å®¡æ ¸é”€å”®è®¢å•合计',
  },
  {
    title: '采购订单金额',
    value: overview.value?.purchaseOrderPrice ?? 0,
    color: '#f59e0b',
    tip: 'ERP å·²å®¡æ ¸é‡‡è´­è®¢å•合计',
  },
]);
/** èµ„产概览卡片 */
const assetCards = computed(() => [
  {
    title: '固定资产净值',
    value: overview.value?.fixedAssetNetValue ?? 0,
    sub: `原值 ${formatAmount(overview.value?.fixedAssetOriginalValue)} / ç´¯è®¡æŠ˜æ—§ ${formatAmount(overview.value?.fixedAssetAccumulatedDepreciation)}`,
    color: '#2f7df6',
  },
  {
    title: '无形资产净值',
    value: overview.value?.intangibleAssetNetValue ?? 0,
    sub: `原值 ${formatAmount(overview.value?.intangibleAssetOriginalValue)} / ç´¯è®¡æ‘Šé”€ ${formatAmount(overview.value?.intangibleAssetAccumulatedAmortization)}`,
    color: '#18a058',
  },
  {
    title: '资产净值合计',
    value: overview.value?.totalAssetNetValue ?? 0,
    sub: '固定资产净值 + æ— å½¢èµ„产净值',
    color: '#7c3aed',
  },
]);
/** å‡­è¯æ¦‚览 */
const voucherCards = computed(() => [
  {
    title: '会计科目',
    value: overview.value?.subjectCount ?? 0,
    color: '#2f7df6',
  },
  {
    title: '凭证总数',
    value: overview.value?.voucherCount ?? 0,
    color: '#7c3aed',
  },
  {
    title: '未过账',
    value: overview.value?.voucherUnpostedCount ?? 0,
    color: '#f59e0b',
  },
  {
    title: '已过账',
    value: overview.value?.voucherPostedCount ?? 0,
    color: '#18a058',
  },
  {
    title: '已作废',
    value: overview.value?.voucherCancelledCount ?? 0,
    color: '#d03050',
  },
]);
/** ç»Ÿè®¡æœŸé—´å±•示文案 */
const periodText = computed(() => {
  if (!overview.value) {
    return '';
  }
  return `${overview.value.startDate} ~ ${overview.value.endDate}`;
});
function formatAmount(value?: number) {
  return erpNumberFormatter(value ?? 0, 2);
}
/** åŠ è½½çœ‹æ¿æ•°æ® */
async function loadData() {
  loading.value = true;
  try {
    const params: FinStatisticsApi.StatisticsParams = {
      startMonth: startMonth.value,
      endMonth: endMonth.value,
    };
    const [overviewData, trendData] = await Promise.all([
      getStatisticsOverview(params),
      getStatisticsTrend(params),
    ]);
    overview.value = overviewData;
    trendList.value = trendData ?? [];
  } finally {
    loading.value = false;
  }
}
/** é‡ç½®ä¸ºé»˜è®¤æœŸé—´ï¼ˆæœ¬å¹´ 1 æœˆè‡³å½“前月) */
function handleReset() {
  startMonth.value = undefined;
  endMonth.value = undefined;
  loadData();
}
onMounted(loadData);
</script>
<template>
  <Page>
    <Spin :spinning="loading">
      <div class="flex flex-col gap-4">
        <!-- æŸ¥è¯¢æ¡ä»¶ -->
        <Card :body-style="{ padding: '12px 16px' }" class="shadow-sm">
          <div class="flex flex-wrap items-center gap-3">
            <span class="font-medium">统计期间:</span>
            <DatePicker
              v-model:value="startMonth"
              picker="month"
              format="YYYY-MM"
              value-format="YYYY-MM"
              placeholder="起始月份"
              style="width: 160px"
            />
            <span>~</span>
            <DatePicker
              v-model:value="endMonth"
              picker="month"
              format="YYYY-MM"
              value-format="YYYY-MM"
              placeholder="结束月份"
              style="width: 160px"
            />
            <Button type="primary" @click="loadData">查询</Button>
            <Button @click="handleReset">重置</Button>
            <Tag color="blue" class="ml-auto">
              æ•°æ®æ¥æºï¼šERP å·²å®¡æ ¸å•据 + æœ¬æ¨¡å—已过账凭证 / èµ„产
            </Tag>
          </div>
        </Card>
        <!-- æ”¶æ”¯æ¦‚览 -->
        <div>
          <div class="mb-2 text-muted-foreground">
            æ”¶æ”¯ä¸Žè´­é”€æ¦‚览
            <span v-if="periodText">({{ periodText }})</span>
          </div>
          <div
            class="grid grid-cols-5 gap-4 max-xl:grid-cols-3 max-sm:grid-cols-1"
          >
            <Card
              v-for="item in incomeCards"
              :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>
        <!-- èµ„产与凭证概览 -->
        <Row :gutter="16">
          <Col :lg="12" :md="24" :sm="24" :xs="24">
            <div class="mb-2 text-muted-foreground">资产概览(时点数)</div>
            <div class="grid grid-cols-3 gap-4 max-sm:grid-cols-1">
              <Card
                v-for="item in assetCards"
                :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.sub }}
                </div>
              </Card>
            </div>
          </Col>
          <Col :lg="12" :md="24" :sm="24" :xs="24">
            <div class="mb-2 text-muted-foreground">凭证概览</div>
            <div class="grid grid-cols-5 gap-4 max-sm:grid-cols-2">
              <Card
                v-for="item in voucherCards"
                :key="item.title"
                :body-style="{ padding: '12px 16px 16px' }"
                class="h-full shadow-sm"
                :style="{ borderTop: `3px solid ${item.color}` }"
              >
                <div class="mb-2 truncate text-muted-foreground">
                  {{ item.title }}
                </div>
                <div
                  class="text-2xl font-bold"
                  :style="{ color: item.color }"
                >
                  {{ item.value }}
                </div>
              </Card>
            </div>
          </Col>
        </Row>
        <!-- æœˆåº¦è¶‹åŠ¿ -->
        <TrendChart :list="trendList" :loading="loading" />
      </div>
    </Spin>
  </Page>
</template>
src/views/erp/finance/statistics/modules/trend-chart.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,101 @@
<script lang="ts" setup>
import type { EChartsOption, EchartsUIType } from '@vben/plugins/echarts';
import type { FinStatisticsApi } from '#/api/erp/finance/statistics';
import { ref, watch } from 'vue';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
import { Card } from 'ant-design-vue';
interface Props {
  list?: FinStatisticsApi.StatisticsTrend[];
  loading?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
  list: () => [],
  loading: false,
});
const chartRef = ref<EchartsUIType>();
const { renderEcharts } = useEcharts(chartRef);
/** æŸ±çŠ¶ç³»åˆ—å…¬å…±é…ç½®ï¼ˆtype éœ€ä¸ºå­—面量,故用 as const) */
const BAR_SERIES = { type: 'bar', barMaxWidth: 24 } as const;
/** æŠ˜çº¿ç³»åˆ—公共配置 */
const LINE_SERIES = { type: 'line', smooth: true, symbolSize: 6 } as const;
/** é‡‘额折线/柱状图配置(series åœ¨ watch ä¸­æŒ‰æœˆä»½æ•°æ®å¡«å……) */
const baseChartOptions: EChartsOption = {
  grid: {
    left: 20,
    right: 20,
    bottom: 20,
    top: 60,
    containLabel: true,
  },
  legend: {
    top: 15,
  },
  toolbox: {
    feature: {
      saveAsImage: { show: true, name: '收支趋势' },
    },
  },
  tooltip: {
    trigger: 'axis',
    axisPointer: { type: 'cross' },
    padding: [5, 10],
  },
  xAxis: {
    type: 'category',
    axisTick: { show: false },
    data: [],
  },
  yAxis: {
    type: 'value',
    axisTick: { show: false },
  },
};
watch(
  () => props.list,
  (list) => {
    if (!list) {
      return;
    }
    renderEcharts({
      ...baseChartOptions,
      xAxis: {
        ...baseChartOptions.xAxis,
        data: list.map((item) => item.month),
      },
      series: [
        {
          ...BAR_SERIES,
          name: '收款金额',
          data: list.map((item) => item.receiptPrice ?? 0),
        },
        {
          ...BAR_SERIES,
          name: '付款金额',
          data: list.map((item) => item.paymentPrice ?? 0),
        },
        {
          ...LINE_SERIES,
          name: '净利润',
          data: list.map((item) => item.netProfit ?? 0),
        },
      ],
    });
  },
  { immediate: true, deep: true },
);
</script>
<template>
  <Card title="月度收支趋势" class="h-full shadow-sm" :loading="loading">
    <EchartsUI ref="chartRef" height="360px" />
  </Card>
</template>
src/views/erp/finance/subject/data.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,210 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { FinAccountSubjectApi } from '#/api/erp/finance/subject';
import { CommonStatusEnum, DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { handleTree } from '@vben/utils';
import { z } from '#/adapter/form';
import { getAccountSubjectList } from '#/api/erp/finance/subject';
/** æ–°å¢ž/修改的表单 */
export function useFormSchema(): VbenFormSchema[] {
  return [
    {
      component: 'Input',
      fieldName: 'id',
      dependencies: {
        triggerFields: [''],
        show: () => false,
      },
    },
    {
      fieldName: 'parentId',
      label: '上级科目',
      component: 'ApiTreeSelect',
      componentProps: {
        allowClear: true,
        api: async () => handleTree(await getAccountSubjectList()),
        labelField: 'subjectName',
        valueField: 'id',
        childrenField: 'children',
        placeholder: '不选择时创建为一级科目',
        treeDefaultExpandAll: true,
      },
      help: '只有末级科目可以被凭证引用;已存在下级科目时不可再改为他科目的下级',
      rules: z.number().optional(),
    },
    {
      fieldName: 'subjectCode',
      label: '科目编码',
      component: 'Input',
      componentProps: {
        placeholder: '请输入科目编码,如 1001',
        allowClear: true,
      },
      rules: 'required',
    },
    {
      fieldName: 'subjectName',
      label: '科目名称',
      component: 'Input',
      componentProps: {
        placeholder: '请输入科目名称,如 åº“存现金',
        allowClear: true,
      },
      rules: 'required',
    },
    {
      fieldName: 'subjectType',
      label: '科目类型',
      component: 'Select',
      componentProps: {
        options: getDictOptions(DICT_TYPE.FINANCE_SUBJECT_TYPE, 'number'),
        placeholder: '请选择科目类型',
      },
      rules: 'selectRequired',
    },
    {
      fieldName: 'balanceDirection',
      label: '余额方向',
      component: 'RadioGroup',
      componentProps: {
        options: getDictOptions(DICT_TYPE.FINANCE_BALANCE_DIRECTION, 'number'),
        buttonStyle: 'solid',
        optionType: 'button',
      },
      rules: 'selectRequired',
    },
    {
      fieldName: 'status',
      label: '状态',
      component: 'RadioGroup',
      componentProps: {
        options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
        buttonStyle: 'solid',
        optionType: 'button',
      },
      rules: z.number().default(CommonStatusEnum.ENABLE),
    },
    {
      fieldName: 'remark',
      label: '备注',
      component: 'Textarea',
      componentProps: {
        placeholder: '请输入备注',
        rows: 3,
      },
      formItemClass: 'md:col-span-2',
    },
  ];
}
/** åˆ—表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
  return [
    {
      fieldName: 'subjectCode',
      label: '科目编码',
      component: 'Input',
      componentProps: {
        placeholder: '请输入科目编码',
        allowClear: true,
      },
    },
    {
      fieldName: 'subjectName',
      label: '科目名称',
      component: 'Input',
      componentProps: {
        placeholder: '请输入科目名称',
        allowClear: true,
      },
    },
    {
      fieldName: 'subjectType',
      label: '科目类型',
      component: 'Select',
      componentProps: {
        options: getDictOptions(DICT_TYPE.FINANCE_SUBJECT_TYPE, 'number'),
        placeholder: '请选择科目类型',
        allowClear: true,
      },
    },
    {
      fieldName: 'status',
      label: '状态',
      component: 'Select',
      componentProps: {
        options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
        placeholder: '请选择状态',
        allowClear: true,
      },
    },
  ];
}
/** åˆ—表的字段 */
export function useGridColumns(): VxeTableGridOptions<FinAccountSubjectApi.AccountSubject>['columns'] {
  return [
    { type: 'checkbox', width: 40 },
    {
      field: 'subjectCode',
      title: '科目编码',
      minWidth: 140,
      fixed: 'left',
      treeNode: true,
    },
    {
      field: 'subjectName',
      title: '科目名称',
      minWidth: 180,
    },
    {
      field: 'subjectType',
      title: '科目类型',
      minWidth: 120,
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.FINANCE_SUBJECT_TYPE },
      },
    },
    {
      field: 'balanceDirection',
      title: '余额方向',
      minWidth: 100,
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.FINANCE_BALANCE_DIRECTION },
      },
    },
    {
      field: 'status',
      title: '状态',
      minWidth: 100,
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.COMMON_STATUS },
      },
    },
    {
      field: 'remark',
      title: '备注',
      minWidth: 150,
      showOverflow: 'tooltip',
    },
    {
      field: 'createTime',
      title: '创建时间',
      minWidth: 180,
      formatter: 'formatDateTime',
    },
    {
      title: '操作',
      width: 200,
      fixed: 'right',
      slots: { default: 'actions' },
    },
  ];
}
src/views/erp/finance/subject/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,224 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { FinAccountSubjectApi } from '#/api/erp/finance/subject';
import { ref } from 'vue';
import { confirm, Page, useVbenModal } from '@vben/common-ui';
import { downloadFileFromBlobPart, handleTree, isEmpty } from '@vben/utils';
import { message } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
  deleteAccountSubject,
  exportAccountSubject,
  getAccountSubjectPage,
} from '#/api/erp/finance/subject';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
/** è´¢åŠ¡ä¼šè®¡ç§‘ç›®åˆ—è¡¨ */
defineOptions({ name: 'FinAccountSubject' });
const [FormModal, formModalApi] = useVbenModal({
  connectedComponent: Form,
  destroyOnClose: true,
});
/** åˆ‡æ¢æ ‘形展开/收缩状态 */
const isExpanded = ref(true);
function handleExpand() {
  isExpanded.value = !isExpanded.value;
  gridApi.grid.setAllTreeExpand(isExpanded.value);
}
/** åˆ·æ–°è¡¨æ ¼ */
function handleRefresh() {
  gridApi.query();
}
/** å¯¼å‡ºè¡¨æ ¼ */
async function handleExport() {
  const data = await exportAccountSubject(await gridApi.formApi.getValues());
  downloadFileFromBlobPart({ fileName: '会计科目.xls', source: data });
}
/** åˆ›å»ºä¼šè®¡ç§‘ç›® */
function handleCreate() {
  formModalApi.setData({ parentId: undefined }).open();
}
/** æ–°å¢žä¸‹çº§ç§‘ç›® */
function handleAppend(row: FinAccountSubjectApi.AccountSubject) {
  formModalApi.setData({ parentId: row.id }).open();
}
/** ç¼–辑会计科目 */
function handleEdit(row: FinAccountSubjectApi.AccountSubject) {
  formModalApi.setData(row).open();
}
/** åˆ é™¤ä¼šè®¡ç§‘ç›® */
async function handleDelete(row: FinAccountSubjectApi.AccountSubject) {
  const hideLoading = message.loading({
    content: $t('ui.actionMessage.deleting', [row.subjectName]),
    duration: 0,
  });
  try {
    await deleteAccountSubject([row.id!]);
    message.success($t('ui.actionMessage.deleteSuccess', [row.subjectName]));
    handleRefresh();
  } finally {
    hideLoading();
  }
}
/** æ‰¹é‡åˆ é™¤ä¼šè®¡ç§‘ç›® */
async function handleDeleteBatch() {
  await confirm($t('ui.actionMessage.deleteBatchConfirm'));
  const hideLoading = message.loading({
    content: $t('ui.actionMessage.deletingBatch'),
    duration: 0,
  });
  try {
    await deleteAccountSubject(checkedIds.value);
    checkedIds.value = [];
    message.success($t('ui.actionMessage.deleteSuccess'));
    handleRefresh();
  } finally {
    hideLoading();
  }
}
const checkedIds = ref<number[]>([]);
function handleRowCheckboxChange({
  records,
}: {
  records: FinAccountSubjectApi.AccountSubject[];
}) {
  checkedIds.value = records.map((item) => item.id!);
}
const [Grid, gridApi] = useVbenVxeGrid({
  formOptions: {
    schema: useGridFormSchema(),
  },
  gridOptions: {
    columns: useGridColumns(),
    height: 'auto',
    pagerConfig: {
      enabled: false,
    },
    keepSource: true,
    proxyConfig: {
      ajax: {
        query: async (_params, formValues) => {
          // ä¸€æ¬¡æ€§æ‹‰å–全部命中的科目,再在内存里组装为树;分页对树形展示没有意义
          const page = await getAccountSubjectPage({
            pageNo: 1,
            pageSize: -1,
            ...formValues,
          });
          return handleTree(page.list);
        },
      },
    },
    rowConfig: {
      keyField: 'id',
      isHover: true,
    },
    toolbarConfig: {
      refresh: true,
      search: true,
    },
    treeConfig: {
      parentField: 'parentId',
      rowField: 'id',
      transform: true,
      expandAll: true,
      reserve: true,
    },
  } as VxeTableGridOptions<FinAccountSubjectApi.AccountSubject>,
  gridEvents: {
    checkboxAll: handleRowCheckboxChange,
    checkboxChange: handleRowCheckboxChange,
  },
});
</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: ['finance:account-subject:create'],
              onClick: handleCreate,
            },
            {
              label: $t('ui.actionTitle.export'),
              type: 'primary',
              icon: ACTION_ICON.DOWNLOAD,
              auth: ['finance:account-subject:export'],
              onClick: handleExport,
            },
            {
              label: isExpanded ? '收缩' : '展开',
              type: 'primary',
              onClick: handleExpand,
            },
            {
              label: $t('ui.actionTitle.deleteBatch'),
              type: 'primary',
              danger: true,
              icon: ACTION_ICON.DELETE,
              auth: ['finance:account-subject:delete'],
              disabled: isEmpty(checkedIds),
              onClick: handleDeleteBatch,
            },
          ]"
        />
      </template>
      <template #actions="{ row }">
        <TableAction
          :actions="[
            {
              label: '新增下级',
              type: 'link',
              icon: ACTION_ICON.ADD,
              auth: ['finance:account-subject:create'],
              onClick: handleAppend.bind(null, row),
            },
            {
              label: $t('common.edit'),
              type: 'link',
              icon: ACTION_ICON.EDIT,
              auth: ['finance:account-subject:update'],
              onClick: handleEdit.bind(null, row),
            },
            {
              label: $t('common.delete'),
              type: 'link',
              danger: true,
              icon: ACTION_ICON.DELETE,
              auth: ['finance:account-subject:delete'],
              disabled: !!(row.children && row.children.length > 0),
              popConfirm: {
                disabled: !!(row.children && row.children.length > 0),
                title: `确认要删除科目「${row.subjectCode} ${row.subjectName}」吗?存在下级科目或已被凭证引用时无法删除。`,
                confirm: handleDelete.bind(null, row),
              },
            },
          ]"
        />
      </template>
    </Grid>
  </Page>
</template>
src/views/erp/finance/subject/modules/form.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,96 @@
<script lang="ts" setup>
import type { FinAccountSubjectApi } from '#/api/erp/finance/subject';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import {
  createAccountSubject,
  getAccountSubject,
  updateAccountSubject,
} from '#/api/erp/finance/subject';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
type SubjectFormModalData = Partial<FinAccountSubjectApi.AccountSubject>;
const emit = defineEmits(['success']);
const formData = ref<FinAccountSubjectApi.AccountSubject>();
const getTitle = computed(() => {
  return formData.value?.id
    ? $t('ui.actionTitle.edit', ['会计科目'])
    : $t('ui.actionTitle.create', ['会计科目']);
});
const [Form, formApi] = useVbenForm({
  commonConfig: {
    componentProps: {
      class: 'w-full',
    },
    labelWidth: 100,
  },
  wrapperClass: 'grid-cols-2',
  layout: 'horizontal',
  schema: useFormSchema(),
  showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
  async onConfirm() {
    const { valid } = await formApi.validate();
    if (!valid) {
      return;
    }
    modalApi.lock();
    const data = (await formApi.getValues()) as FinAccountSubjectApi.AccountSubject;
    if (formData.value?.id) {
      data.id = formData.value.id;
    }
    try {
      await (formData.value?.id
        ? updateAccountSubject(data)
        : createAccountSubject(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;
    }
    formData.value = undefined;
    await formApi.resetForm();
    const data = modalApi.getData<SubjectFormModalData>();
    if (!data) {
      return;
    }
    // ç¼–辑:加载详情;新增下级:仅预填上级科目
    if (data.id) {
      modalApi.lock();
      try {
        formData.value = await getAccountSubject(data.id);
        await formApi.setValues(formData.value);
      } finally {
        modalApi.unlock();
      }
    } else if (data.parentId) {
      await formApi.setValues({ parentId: data.parentId });
    }
  },
});
</script>
<template>
  <Modal class="w-2/5" :title="getTitle">
    <Form class="mx-4" />
  </Modal>
</template>
src/views/erp/finance/voucher/data.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,297 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { FinVoucherApi } from '#/api/erp/finance/voucher';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { z } from '#/adapter/form';
import { getRangePickerDefaultProps } from '#/utils';
import dayjs from 'dayjs';
/** è¡¨å•类型 */
export type FormType = 'create' | 'detail' | 'edit';
/** æ–°å¢ž/修改的表单(分录由 entry-table å•独维护) */
export function useFormSchema(): VbenFormSchema[] {
  return [
    {
      component: 'Input',
      fieldName: 'id',
      dependencies: {
        triggerFields: [''],
        show: () => false,
      },
    },
    {
      fieldName: 'voucherNo',
      label: '凭证字号',
      component: 'Input',
      componentProps: {
        placeholder: '留空自动生成',
        allowClear: true,
      },
      help: '留空时由系统按【记 + å¹´æœˆ + 4 ä½æµæ°´ã€‘自动生成',
    },
    {
      fieldName: 'voucherDate',
      label: '凭证日期',
      component: 'DatePicker',
      componentProps: {
        placeholder: '请选择凭证日期',
        showTime: false,
        format: 'YYYY-MM-DD',
        valueFormat: 'YYYY-MM-DD',
        class: 'w-full',
      },
      rules: 'required',
      defaultValue: dayjs().format('YYYY-MM-DD'),
    },
    {
      // ä¸Žå‡­è¯å­—号、凭证日期并排一行,避免三列栅格里空出一格
      fieldName: 'attachmentCount',
      label: '附件张数',
      component: 'InputNumber',
      componentProps: {
        class: 'w-full',
        placeholder: '请输入附件张数',
        min: 0,
        precision: 0,
      },
      defaultValue: 0,
      rules: z.number().min(0).default(0),
    },
    {
      fieldName: 'summary',
      label: '凭证摘要',
      component: 'Input',
      componentProps: {
        placeholder: '请输入凭证摘要,如:支付 10 æœˆåŠžå…¬è´¹',
        allowClear: true,
        maxLength: 200,
      },
      formItemClass: 'col-span-3',
    },
    {
      fieldName: 'entries',
      label: '凭证分录',
      component: 'Input',
      formItemClass: 'col-span-3',
    },
    {
      fieldName: 'blobIds',
      label: '凭证附件',
      component: 'FileUpload',
      componentProps: {
        valueKey: 'id',
        maxNumber: 10,
        multiple: true,
        helpText: '支持图片、PDF、Excel ç­‰æ ¼å¼ï¼Œå•个文件不超过 50MB',
      },
      formItemClass: 'col-span-3',
    },
    {
      fieldName: 'remark',
      label: '备注',
      component: 'Textarea',
      componentProps: {
        placeholder: '请输入备注',
        rows: 3,
      },
      formItemClass: 'col-span-3',
    },
  ];
}
/** åˆ†å½•表格的字段 */
export function useEntryColumns(
  disabled: boolean,
): VxeTableGridOptions['columns'] {
  return [
    { type: 'seq', title: '序号', width: 55, fixed: 'left' },
    {
      field: 'summary',
      title: '摘要',
      minWidth: 140,
      slots: { default: 'summary' },
    },
    {
      field: 'subjectCode',
      title: '会计科目',
      minWidth: 180,
      slots: { default: 'subject' },
    },
    {
      field: 'debit',
      title: '借方金额',
      minWidth: 130,
      align: 'center',
      slots: { default: 'debit' },
    },
    {
      field: 'credit',
      title: '贷方金额',
      minWidth: 130,
      align: 'center',
      slots: { default: 'credit' },
    },
    {
      field: 'auxiliaryType',
      title: '辅助核算',
      minWidth: 120,
      slots: { default: 'auxiliaryType' },
    },
    {
      field: 'auxiliaryName',
      title: '辅助核算对象',
      minWidth: 150,
      slots: { default: 'auxiliaryName' },
    },
    {
      title: '操作',
      width: 70,
      fixed: 'right',
      slots: { default: 'actions' },
      visible: !disabled,
    },
  ];
}
/** åˆ—表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
  return [
    {
      fieldName: 'voucherNo',
      label: '凭证字号',
      component: 'Input',
      componentProps: {
        placeholder: '请输入凭证字号',
        allowClear: true,
      },
    },
    {
      fieldName: 'summary',
      label: '凭证摘要',
      component: 'Input',
      componentProps: {
        placeholder: '请输入凭证摘要',
        allowClear: true,
      },
    },
    {
      fieldName: 'voucherDate',
      label: '凭证日期',
      component: 'RangePicker',
      componentProps: {
        ...getRangePickerDefaultProps(),
        showTime: false,
        format: 'YYYY-MM-DD',
        valueFormat: 'YYYY-MM-DD',
        allowClear: true,
      },
    },
    {
      fieldName: 'creator',
      label: '制单人',
      component: 'Input',
      componentProps: {
        placeholder: '请输入制单人账号',
        allowClear: true,
      },
    },
    {
      fieldName: 'status',
      label: '凭证状态',
      component: 'Select',
      componentProps: {
        options: getDictOptions(DICT_TYPE.FINANCE_VOUCHER_STATUS, 'number'),
        placeholder: '请选择凭证状态',
        allowClear: true,
      },
    },
  ];
}
/** åˆ—表的字段 */
export function useGridColumns(): VxeTableGridOptions<FinVoucherApi.Voucher>['columns'] {
  return [
    {
      type: 'expand',
      width: 45,
      slots: { content: 'expand_content' },
    },
    { type: 'checkbox', width: 40 },
    {
      field: 'voucherNo',
      title: '凭证字号',
      minWidth: 150,
      fixed: 'left',
    },
    {
      field: 'voucherDate',
      title: '凭证日期',
      minWidth: 120,
      formatter: 'formatDate',
    },
    {
      field: 'summary',
      title: '摘要',
      minWidth: 200,
      showOverflow: 'tooltip',
    },
    {
      field: 'debit',
      title: '借方合计',
      minWidth: 130,
      align: 'right',
      formatter: 'formatAmount2',
    },
    {
      field: 'credit',
      title: '贷方合计',
      minWidth: 130,
      align: 'right',
      formatter: 'formatAmount2',
    },
    {
      field: 'status',
      title: '凭证状态',
      minWidth: 110,
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.FINANCE_VOUCHER_STATUS },
      },
    },
    {
      // åŽç«¯ creator å­˜çš„æ˜¯ç”¨æˆ· ID,直接展示是数字,列里跟导出都改用 creatorName(昵称);
      // æœç´¢æ¡†ä»æŒ‰ creator ä¼ å‚,语义不变
      field: 'creatorName',
      title: '制单人',
      minWidth: 120,
    },
    {
      field: 'attachmentCount',
      title: '附件张数',
      minWidth: 100,
    },
    {
      title: '附件',
      field: 'attachmentList',
      minWidth: 100,
      slots: { default: 'attachment' },
    },
    {
      field: 'createTime',
      title: '创建时间',
      minWidth: 180,
      formatter: 'formatDateTime',
    },
    {
      title: '操作',
      width: 260,
      fixed: 'right',
      slots: { default: 'actions' },
    },
  ];
}
src/views/erp/finance/voucher/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,298 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { FinVoucherApi } from '#/api/erp/finance/voucher';
import { ref } from 'vue';
import { confirm, Page, useVbenModal } from '@vben/common-ui';
import { downloadFileFromBlobPart, isEmpty } from '@vben/utils';
import { message, Tag } from 'ant-design-vue';
import {
  ACTION_ICON,
  TableAction,
  useVbenVxeGrid,
  VxeColumn,
  VxeTable,
} from '#/adapter/vxe-table';
import {
  cancelVoucher,
  deleteVoucherList,
  exportVoucher,
  getVoucherPageWithEntry,
  postVoucher,
} from '#/api/erp/finance/voucher';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
/** è®°è´¦å‡­è¯åˆ—表 */
defineOptions({ name: 'FinVoucher' });
/** å‡­è¯çŠ¶æ€ï¼š0未过账 10已过账 20已作废 */
const VOUCHER_STATUS_UNPOSTED = 0;
const VOUCHER_STATUS_POSTED = 10;
const VOUCHER_STATUS_CANCELLED = 20;
const [FormModal, formModalApi] = useVbenModal({
  connectedComponent: Form,
  destroyOnClose: true,
});
/** åˆ·æ–°è¡¨æ ¼ */
function handleRefresh() {
  gridApi.query();
}
/** å¯¼å‡ºè¡¨æ ¼ */
async function handleExport() {
  const data = await exportVoucher(await gridApi.formApi.getValues());
  downloadFileFromBlobPart({ fileName: '记账凭证.xls', source: data });
}
/** æ–°å¢žè®°è´¦å‡­è¯ */
function handleCreate() {
  formModalApi.setData({ formType: 'create' }).open();
}
/** ç¼–辑记账凭证 */
function handleEdit(row: FinVoucherApi.Voucher) {
  formModalApi.setData({ formType: 'edit', id: row.id }).open();
}
/** æŸ¥çœ‹è¯¦æƒ… */
function handleDetail(row: FinVoucherApi.Voucher) {
  formModalApi.setData({ formType: 'detail', id: row.id }).open();
}
/** åˆ é™¤è®°è´¦å‡­è¯ */
async function handleDelete(ids: number[]) {
  const hideLoading = message.loading({
    content: $t('ui.actionMessage.deleting'),
    duration: 0,
  });
  try {
    await deleteVoucherList(ids);
    checkedIds.value = [];
    message.success($t('ui.actionMessage.deleteSuccess'));
    handleRefresh();
  } finally {
    hideLoading();
  }
}
/** å‡­è¯è¿‡è´¦ */
async function handlePost(row: FinVoucherApi.Voucher) {
  const hideLoading = message.loading({ content: '过账中...', duration: 0 });
  try {
    await postVoucher(row.id!);
    message.success(`凭证【${row.voucherNo}】过账成功`);
    handleRefresh();
  } finally {
    hideLoading();
  }
}
/** å‡­è¯ä½œåºŸ */
async function handleCancel(row: FinVoucherApi.Voucher) {
  await confirm(
    `确认要作废凭证【${row.voucherNo}】吗?作废后该凭证不再计入总账与明细账,且不可恢复。`,
  );
  const hideLoading = message.loading({ content: '作废中...', duration: 0 });
  try {
    await cancelVoucher(row.id!);
    message.success(`凭证【${row.voucherNo}】已作废`);
    handleRefresh();
  } finally {
    hideLoading();
  }
}
const checkedIds = ref<number[]>([]);
function handleRowCheckboxChange({
  records,
}: {
  records: FinVoucherApi.Voucher[];
}) {
  checkedIds.value = records.map((item) => item.id!);
}
const [Grid, gridApi] = useVbenVxeGrid({
  formOptions: {
    schema: useGridFormSchema(),
  },
  gridOptions: {
    columns: useGridColumns(),
    height: 'auto',
    keepSource: true,
    proxyConfig: {
      ajax: {
        query: async ({ page }, formValues) => {
          return await getVoucherPageWithEntry({
            pageNo: page.currentPage,
            pageSize: page.pageSize,
            ...formValues,
          });
        },
      },
    },
    rowConfig: {
      keyField: 'id',
      isHover: true,
    },
    toolbarConfig: {
      refresh: true,
      search: true,
    },
  } as VxeTableGridOptions<FinVoucherApi.Voucher>,
  gridEvents: {
    checkboxAll: handleRowCheckboxChange,
    checkboxChange: handleRowCheckboxChange,
  },
});
</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: ['finance:voucher:create'],
              onClick: handleCreate,
            },
            {
              label: $t('ui.actionTitle.export'),
              type: 'primary',
              icon: ACTION_ICON.DOWNLOAD,
              auth: ['finance:voucher:export'],
              onClick: handleExport,
            },
            {
              label: '批量删除',
              type: 'primary',
              danger: true,
              disabled: isEmpty(checkedIds),
              icon: ACTION_ICON.DELETE,
              auth: ['finance:voucher:delete'],
              popConfirm: {
                title: '是否删除所选中数据?已过账的凭证不允许删除。',
                confirm: handleDelete.bind(null, checkedIds),
              },
            },
          ]"
        />
      </template>
      <template #expand_content="{ row }">
        <VxeTable
          :data="row.entries ?? []"
          border
          size="small"
          show-overflow
          class="mx-2"
        >
          <VxeColumn title="序号" width="60">
            <template #default="{ row: entry }">
              {{ entry.rowNo ?? '-' }}
            </template>
          </VxeColumn>
          <VxeColumn title="摘要" min-width="180">
            <template #default="{ row: entry }">
              {{ entry.summary || '-' }}
            </template>
          </VxeColumn>
          <VxeColumn title="会计科目" min-width="220">
            <template #default="{ row: entry }">
              {{ entry.subjectCode }} {{ entry.subjectName }}
            </template>
          </VxeColumn>
          <VxeColumn title="借方金额" align="right" width="140">
            <template #default="{ row: entry }">
              {{ entry.debit ? entry.debit.toFixed(2) : '-' }}
            </template>
          </VxeColumn>
          <VxeColumn title="贷方金额" align="right" width="140">
            <template #default="{ row: entry }">
              {{ entry.credit ? entry.credit.toFixed(2) : '-' }}
            </template>
          </VxeColumn>
          <VxeColumn title="辅助核算" min-width="150">
            <template #default="{ row: entry }">
              {{ entry.auxiliaryName || '-' }}
            </template>
          </VxeColumn>
        </VxeTable>
      </template>
      <template #attachment="{ row }">
        <Tag v-if="!row.hasAttachment" color="warning">未上传</Tag>
        <Tag v-else color="success">已上传</Tag>
      </template>
      <template #actions="{ row }">
        <TableAction
          :actions="[
            {
              label: $t('common.detail'),
              type: 'link',
              icon: ACTION_ICON.VIEW,
              auth: ['finance:voucher:query'],
              onClick: handleDetail.bind(null, row),
            },
            {
              label: $t('common.edit'),
              type: 'link',
              icon: ACTION_ICON.EDIT,
              auth: ['finance:voucher:update'],
              ifShow: () => row.status === VOUCHER_STATUS_UNPOSTED,
              onClick: handleEdit.bind(null, row),
            },
            {
              label: '过账',
              type: 'link',
              icon: ACTION_ICON.AUDIT,
              auth: ['finance:voucher:update-status'],
              ifShow: () => row.status === VOUCHER_STATUS_UNPOSTED,
              popConfirm: {
                title: `确认过账凭证【${row.voucherNo}】吗?过账后将计入总账与明细账。`,
                confirm: handlePost.bind(null, row),
              },
            },
            {
              label: '作废',
              type: 'link',
              danger: true,
              icon: ACTION_ICON.DELETE,
              auth: ['finance:voucher:update-status'],
              ifShow: () =>
                row.status === VOUCHER_STATUS_UNPOSTED ||
                row.status === VOUCHER_STATUS_POSTED,
              onClick: handleCancel.bind(null, row),
            },
            {
              label: $t('common.delete'),
              type: 'link',
              danger: true,
              icon: ACTION_ICON.DELETE,
              auth: ['finance:voucher:delete'],
              ifShow: () =>
                row.status === VOUCHER_STATUS_UNPOSTED ||
                row.status === VOUCHER_STATUS_CANCELLED,
              popConfirm: {
                title: $t('ui.actionMessage.deleteConfirm', [row.voucherNo]),
                confirm: handleDelete.bind(null, [row.id!]),
              },
            },
          ]"
        />
      </template>
    </Grid>
  </Page>
</template>
src/views/erp/finance/voucher/modules/account-select.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,150 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { FinAccountSubjectApi } from '#/api/erp/finance/subject';
import { nextTick, ref } from 'vue';
import { message, Modal } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getAccountSubjectPage } from '#/api/erp/finance/subject';
defineOptions({ name: 'FinAccountSelect' });
const emit = defineEmits<{
  selected: [subject: FinAccountSubjectApi.AccountSubject];
}>();
const open = ref(false); // å¼¹çª—是否打开
const selectedRow = ref<FinAccountSubjectApi.AccountSubject>(); // é€‰ä¸­çš„ç§‘ç›®
/** è¡¨æ ¼é…ç½® */
const [Grid, gridApi] = useVbenVxeGrid({
  formOptions: {
    schema: [
      {
        fieldName: 'subjectCode',
        label: '科目编码',
        component: 'Input',
        componentProps: { placeholder: '请输入科目编码', allowClear: true },
      },
      {
        fieldName: 'subjectName',
        label: '科目名称',
        component: 'Input',
        componentProps: { placeholder: '请输入科目名称', allowClear: true },
      },
    ],
  },
  gridOptions: {
    columns: [
      { type: 'radio', width: 50 },
      {
        field: 'subjectCode',
        title: '科目编码',
        minWidth: 140,
        treeNode: true,
      },
      { field: 'subjectName', title: '科目名称', minWidth: 200 },
      {
        field: 'subjectType',
        title: '科目类型',
        minWidth: 120,
        cellRender: {
          name: 'CellDict',
          props: { type: 'finance_subject_type' },
        },
      },
      {
        field: 'balanceDirection',
        title: '余额方向',
        minWidth: 100,
        cellRender: {
          name: 'CellDict',
          props: { type: 'finance_balance_direction' },
        },
      },
    ],
    height: 480,
    keepSource: true,
    radioConfig: {
      highlight: true,
      trigger: 'row',
    },
    proxyConfig: {
      ajax: {
        query: async (_params, formValues) => {
          // æ ‘形展示不适用分页,一次性拉取全部命中科目
          const page = await getAccountSubjectPage({
            pageNo: 1,
            pageSize: -1,
            status: 0,
            ...formValues,
          });
          return {
            list: page.list,
            total: page.list.length,
          };
        },
      },
    },
    rowConfig: {
      keyField: 'id',
      isHover: true,
    },
    toolbarConfig: {
      refresh: true,
      search: true,
    },
    treeConfig: {
      parentField: 'parentId',
      rowField: 'id',
      transform: true,
      expandAll: true,
      reserve: true,
    },
  } as VxeTableGridOptions<FinAccountSubjectApi.AccountSubject>,
  gridEvents: {
    radioChange: ({ row }: { row: FinAccountSubjectApi.AccountSubject }) => {
      selectedRow.value = row;
    },
  },
});
/** æ‰“开弹窗 */
async function openModal() {
  open.value = true;
  selectedRow.value = undefined;
  // ç‰¹æ®Šï¼šå¼¹çª—是 destroy-on-close,里面 Grid éšå¼¹çª—重建,必须等挂载完成后 gridApi æ‰æœ‰çœŸå®žä¾‹
  // ï¼ˆvben çš„ gridApi.grid åˆå§‹æ˜¯ç©ºå¯¹è±¡ï¼Œmount æ—¶æ‰èµ‹å€¼ï¼‰ï¼Œå¦åˆ™ clearRadioRow ä¼šæŠ¥ is not a function
  await nextTick();
  await gridApi.grid.clearRadioRow();
  await gridApi.formApi.resetForm();
  await gridApi.query();
}
/** ç¡®è®¤é€‰æ‹© */
function handleConfirm() {
  if (!selectedRow.value) {
    message.warning('请选择一条会计科目');
    return;
  }
  emit('selected', selectedRow.value);
  open.value = false;
}
defineExpose({ open: openModal });
</script>
<template>
  <Modal
    v-model:open="open"
    title="选择会计科目"
    width="70%"
    :destroy-on-close="true"
    @ok="handleConfirm"
    @cancel="open = false"
  >
    <Grid table-title="会计科目列表(仅启用状态的科目可被凭证引用)" />
  </Modal>
</template>
src/views/erp/finance/voucher/modules/entry-table.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,361 @@
<script lang="ts" setup>
import type { FinVoucherApi } from '#/api/erp/finance/voucher';
import { computed, nextTick, ref, watch } from 'vue';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { erpPriceInputFormatter } from '@vben/utils';
import { Input, InputNumber, Select } from 'ant-design-vue';
import { TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { useEntryColumns } from '../data';
import AccountSelect from './account-select.vue';
interface EntryRow extends FinVoucherApi.VoucherEntry {
  seq: number; // å‰ç«¯è¡Œæ ‡è¯†ï¼Œç”¨äºŽè¡¨æ ¼å®šä½
}
interface Props {
  entries?: FinVoucherApi.VoucherEntry[];
  disabled?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
  entries: () => [],
  disabled: false,
});
const emit = defineEmits(['update:entries']);
/** åˆ†å½•行,seq ç”¨äºŽè¡¨æ ¼è¡Œå®šä½ï¼ˆæ–°å¢žå‡­è¯æ—¶åˆ†å½•还没有 id) */
const tableData = ref<EntryRow[]>([]);
let seq = 0;
/** æŠŠåŽç«¯åˆ†å½•转为带 seq çš„表格行 */
function toRows(entries: FinVoucherApi.VoucherEntry[]): EntryRow[] {
  return entries.map((entry) => ({ ...entry, seq: ++seq }));
}
/** åˆè®¡ï¼šå€Ÿæ–¹ã€è´·æ–¹ã€å·®é¢ */
const summaries = computed(() => {
  const debit = tableData.value.reduce(
    (sum, item) => sum + (item.debit || 0),
    0,
  );
  const credit = tableData.value.reduce(
    (sum, item) => sum + (item.credit || 0),
    0,
  );
  return { debit, credit, diff: debit - credit };
});
/** æ˜¯å¦å€Ÿè´·å¹³è¡¡ */
const isBalanced = computed(
  () => summaries.value.debit > 0 && summaries.value.diff === 0,
);
/** è¡¨æ ¼é…ç½® */
const [Grid, gridApi] = useVbenVxeGrid({
  gridOptions: {
    columns: useEntryColumns(props.disabled),
    data: tableData.value,
    minHeight: 300,
    autoResize: true,
    border: true,
    rowConfig: {
      keyField: 'seq',
      isHover: true,
    },
    pagerConfig: {
      enabled: false,
    },
    toolbarConfig: {
      enabled: false,
    },
  },
});
/** ç›‘听外部传入的分录 */
watch(
  () => props.entries,
  async (entries) => {
    if (!entries) {
      return;
    }
    tableData.value = toRows(entries);
    await nextTick(); // ç‰¹æ®Šï¼šä¿è¯ gridApi å·²ç»åˆå§‹åŒ–
    await gridApi.grid.reloadData(tableData.value);
  },
  { immediate: true },
);
/** é€šçŸ¥çˆ¶ç»„件分录已变更 */
function emitChange() {
  const entries = tableData.value.map(({ seq: _seq, ...entry }) => entry);
  emit('update:entries', entries);
}
/** ä¿®æ”¹æŸè¡Œæ•°æ® */
function handleRowChange(row: EntryRow) {
  const index = tableData.value.findIndex((item) => item.seq === row.seq);
  if (index === -1) {
    tableData.value.push(row);
  } else {
    tableData.value[index] = row;
  }
  emitChange();
}
/** å½•入借方金额时清空贷方金额,反之亦然(同一行只能有一方有金额) */
function handleDebitChange(row: EntryRow) {
  if (row.debit && row.debit > 0) {
    row.credit = 0;
  }
  handleRowChange(row);
}
function handleCreditChange(row: EntryRow) {
  if (row.credit && row.credit > 0) {
    row.debit = 0;
  }
  handleRowChange(row);
}
/** é€‰æ‹©ä¼šè®¡ç§‘ç›® */
const accountSelectRef = ref();
const editingRow = ref<EntryRow>();
function handleOpenAccount(row: EntryRow) {
  if (props.disabled) {
    return;
  }
  editingRow.value = row;
  accountSelectRef.value?.open();
}
function handleSelectAccount(subject: {
  subjectCode: string;
  subjectName: string;
}) {
  const row = editingRow.value;
  if (!row) {
    return;
  }
  row.subjectCode = subject.subjectCode;
  row.subjectName = subject.subjectName;
  handleRowChange(row);
}
/** æ·»åŠ ä¸€è¡Œç©ºç™½åˆ†å½• */
function handleAdd() {
  const row: EntryRow = {
    seq: ++seq,
    subjectCode: undefined,
    subjectName: undefined,
    summary: '',
    debit: 0,
    credit: 0,
    auxiliaryType: undefined,
    auxiliaryId: '',
    auxiliaryName: '',
  };
  tableData.value.push(row);
  gridApi.grid.reloadData(tableData.value);
  emitChange();
}
/** åˆ é™¤ä¸€è¡Œåˆ†å½• */
function handleDelete(row: EntryRow) {
  const index = tableData.value.findIndex((item) => item.seq === row.seq);
  if (index !== -1) {
    tableData.value.splice(index, 1);
  }
  gridApi.grid.reloadData(tableData.value);
  emitChange();
}
/** è¡¨å•校验:与后端 FinVoucherServiceImpl çš„æ ¡éªŒå£å¾„保持一致 */
function validate() {
  const rows = tableData.value.filter(
    (item) => item.subjectCode || item.debit || item.credit,
  );
  if (rows.length === 0) {
    throw new Error('请至少录入一条凭证分录');
  }
  for (let i = 0; i < rows.length; i++) {
    const item = rows[i]!;
    if (!item.subjectCode) {
      throw new Error(`第 ${i + 1} è¡Œï¼šå·²å½•入金额但未选择会计科目`);
    }
    const debit = item.debit || 0;
    const credit = item.credit || 0;
    if (debit <= 0 && credit <= 0) {
      throw new Error(`第 ${i + 1} è¡Œï¼šå·²é€‰æ‹©ä¼šè®¡ç§‘目但未录入金额`);
    }
    if (debit > 0 && credit > 0) {
      throw new Error(`第 ${i + 1} è¡Œï¼šå€Ÿæ–¹ä¸Žè´·æ–¹é‡‘额不能同时大于 0`);
    }
  }
  if (summaries.value.debit <= 0) {
    throw new Error('凭证借贷合计必须大于 0');
  }
  if (summaries.value.diff !== 0) {
    throw new Error(
      `凭证借贷不平衡:借方合计 ${erpPriceInputFormatter(summaries.value.debit)},` +
        `贷方合计 ${erpPriceInputFormatter(summaries.value.credit)},` +
        `差额 ${erpPriceInputFormatter(summaries.value.diff)}`,
    );
  }
}
defineExpose({ validate });
</script>
<template>
  <div>
    <Grid class="w-full">
      <template #summary="{ row }">
        <Input
          v-model:value="row.summary"
          :disabled="disabled"
          placeholder="请输入摘要"
          @change="handleRowChange(row)"
        />
      </template>
      <template #subject="{ row }">
        <div
          class="flex cursor-pointer items-center"
          :class="{ 'text-primary': !disabled }"
          @click="handleOpenAccount(row)"
        >
          <span v-if="row.subjectCode">
            {{ row.subjectCode }} {{ row.subjectName }}
          </span>
          <span v-else class="text-muted-foreground">请选择会计科目</span>
        </div>
      </template>
      <template #debit="{ row }">
        <InputNumber
          v-model:value="row.debit"
          :precision="2"
          :min="0"
          :disabled="disabled"
          class="w-full"
          placeholder="借方金额"
          @change="handleDebitChange(row)"
        />
      </template>
      <template #credit="{ row }">
        <InputNumber
          v-model:value="row.credit"
          :precision="2"
          :min="0"
          :disabled="disabled"
          class="w-full"
          placeholder="贷方金额"
          @change="handleCreditChange(row)"
        />
      </template>
      <template #auxiliaryType="{ row }">
        <Select
          v-model:value="row.auxiliaryType"
          :disabled="disabled"
          allow-clear
          class="w-full"
          placeholder="无"
          @change="handleRowChange(row)"
        >
          <Select.Option
            v-for="dict in getDictOptions(
              DICT_TYPE.FINANCE_AUXILIARY_TYPE,
              'number',
            )"
            :key="dict.value"
            :value="dict.value"
          >
            {{ dict.label }}
          </Select.Option>
        </Select>
      </template>
      <template #auxiliaryName="{ row }">
        <Input
          v-model:value="row.auxiliaryName"
          :disabled="disabled"
          placeholder="请输入辅助核算对象"
          @change="handleRowChange(row)"
        />
      </template>
      <template #actions="{ row }">
        <TableAction
          :actions="[
            {
              label: '删除',
              type: 'link',
              danger: true,
              popConfirm: {
                title: '确认删除该分录吗?',
                confirm: handleDelete.bind(null, row),
              },
            },
          ]"
        />
      </template>
      <template #bottom>
        <div class="mt-2 rounded border border-border bg-muted p-2">
          <div class="flex justify-between text-sm">
            <span class="font-medium text-foreground">合计:</span>
            <div class="flex space-x-4">
              <span>
                å€Ÿæ–¹åˆè®¡ï¼š{{ erpPriceInputFormatter(summaries.debit) }}
              </span>
              <span>
                è´·æ–¹åˆè®¡ï¼š{{ erpPriceInputFormatter(summaries.credit) }}
              </span>
              <span :class="isBalanced ? 'text-green-600' : 'text-red-500'">
                å·®é¢ï¼š{{ erpPriceInputFormatter(summaries.diff) }}
                {{ isBalanced ? '(借贷平衡)' : '(借贷不平衡)' }}
              </span>
            </div>
          </div>
        </div>
        <TableAction
          v-if="!disabled"
          class="mt-2 flex justify-center"
          :actions="[
            {
              label: '添加分录',
              type: 'default',
              onClick: handleAdd,
            },
          ]"
        />
      </template>
    </Grid>
    <!-- ä¼šè®¡ç§‘目选择 -->
    <AccountSelect ref="accountSelectRef" @selected="handleSelectAccount" />
  </div>
</template>
<style scoped>
/*
 * å€Ÿæ–¹/贷方金额列居中。
 * vxe åˆ—çš„ align: 'center' åªä½œç”¨äºŽå•元格,而 ant çš„ InputNumber å†…部 <input>
 * ï¼ˆ.ant-input-number-input)自带 text-align: start,会把数字压回左边,导致表头居中、
 * æ•°å­—靠左的错位,所以这里连输入框内的文字一起覆盖。
 * æœ¬ç»„件只有这两个 InputNumber(借方、贷方),不受影响其它列。
 */
:deep(.ant-input-number .ant-input-number-input) {
  text-align: center;
}
</style>
src/views/erp/finance/voucher/modules/form.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,157 @@
<script lang="ts" setup>
import type { FinVoucherApi } from '#/api/erp/finance/voucher';
import type { FormType } from '../data';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { $t } from '@vben/locales';
import { message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import {
  createVoucher,
  getVoucher,
  updateVoucher,
} from '#/api/erp/finance/voucher';
import { useFormSchema } from '../data';
import EntryTable from './entry-table.vue';
const emit = defineEmits(['success']);
const formData = ref<FinVoucherApi.Voucher>();
const formType = ref<FormType>('create'); // è¡¨å•类型:'create' | 'edit' | 'detail'
const entryTableRef = ref<InstanceType<typeof EntryTable>>();
// åˆ†å½•的唯一数据源:必须用稳定的 ref,不能在模板里现造 []。
// å¦åˆ™çˆ¶ç»„件每次渲染都会给 EntryTable ä¼ ä¸€ä¸ªæ–°çš„空数组,触发其 watch é‡å»ºè¡¨æ ¼ï¼Œ
// æŠŠç”¨æˆ·åˆšã€Œæ·»åŠ åˆ†å½•ã€åŠ è¿›åŽ»çš„è¡Œå†²æŽ‰ï¼ˆæ–°å¢žæ¨¡å¼ä¸‹ formData ä¸º undefined æ—¶å¿…现)。
const entries = ref<FinVoucherApi.VoucherEntry[]>([]);
const getTitle = computed(() => {
  if (formType.value === 'create') {
    return $t('ui.actionTitle.create', ['记账凭证']);
  } else if (formType.value === 'edit') {
    return $t('ui.actionTitle.edit', ['记账凭证']);
  }
  return '记账凭证详情';
});
const [Form, formApi] = useVbenForm({
  commonConfig: {
    componentProps: {
      class: 'w-full',
    },
    labelWidth: 100,
  },
  // ä¸‰åˆ—栅格:凭证字号/凭证日期/附件张数 å æ»¡ä¸€è¡Œï¼Œå…¶ä½™å­—段整行铺开,
  // é¿å…ä¸¤åˆ—布局下摘要、附件张数右半边留空(与收款单、付款单弹窗保持一致)
  wrapperClass: 'grid-cols-3',
  layout: 'horizontal',
  schema: useFormSchema(),
  showDefaultActions: false,
});
/**
 * åŒæ­¥åˆ†å½•到表单值,随表单一起提交。
 * æ³¨æ„ï¼šè¿™é‡Œåªå†™è¡¨å•值,**不要把 value å›žå†™åˆ° entries**——
 * entries åªåœ¨æ‰“开弹窗时从服务端播种一次,之后的实时数据以 EntryTable å†…部表格为准。
 * ä¸€æ—¦å›žå†™ï¼Œprops.entries å˜åŒ–会触发 EntryTable çš„ watch é‡å»ºæ•´å¼ è¡¨æ ¼ï¼Œå¯¼è‡´æ¯æ•²ä¸€ä¸ª
 * æ•°å­—/选一次科目就重造一遍行(行标识变化、输入框失焦)。
 */
function handleUpdateEntries(value: FinVoucherApi.VoucherEntry[]) {
  formApi.setValues({ entries: value });
}
/** åˆ›å»ºæˆ–更新记账凭证 */
const [Modal, modalApi] = useVbenModal({
  async onConfirm() {
    const { valid } = await formApi.validate();
    if (!valid) {
      return;
    }
    // åˆ†å½•校验与后端 FinVoucherServiceImpl çš„口径一致,前端先拦一遍,避免无意义的请求
    const entryTableInstance = Array.isArray(entryTableRef.value)
      ? entryTableRef.value[0]
      : entryTableRef.value;
    try {
      entryTableInstance?.validate();
    } catch (error: any) {
      message.error(error.message || '凭证分录校验失败');
      return;
    }
    modalApi.lock();
    const data = (await formApi.getValues()) as FinVoucherApi.Voucher;
    if (formData.value?.id) {
      data.id = formData.value.id;
    }
    try {
      await (formType.value === 'create'
        ? createVoucher(data)
        : updateVoucher(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;
    }
    formData.value = undefined;
    entries.value = [];
    await formApi.resetForm();
    const data = modalApi.getData<{ formType: FormType; id?: number }>();
    formType.value = data?.formType ?? 'create';
    formApi.setDisabled(formType.value === 'detail');
    if (!data?.id) {
      return;
    }
    modalApi.lock();
    try {
      formData.value = await getVoucher(data.id);
      await formApi.setValues(formData.value, false);
      entries.value = formData.value?.entries ?? [];
      // ç”¨ attachmentList ä»£æ›¿ blobIds,让上传组件能展示文件名和预览链接
      if (formData.value?.attachmentList?.length) {
        const blobIds = formData.value.attachmentList.map((item) => ({
          uid: String(item.id),
          name: item.name || '',
          url: item.url || '',
          status: 'done',
          id: item.id,
        }));
        await formApi.setFieldValue('blobIds', blobIds);
      }
    } finally {
      modalApi.unlock();
    }
  },
});
</script>
<template>
  <Modal
    :title="getTitle"
    class="w-5/6"
    :close-on-click-modal="false"
    :show-confirm-button="formType !== 'detail'"
  >
    <Form class="mx-3">
      <template #entries>
        <EntryTable
          ref="entryTableRef"
          :entries="entries"
          :disabled="formType === 'detail'"
          class="w-full"
          @update:entries="handleUpdateEntries"
        />
      </template>
    </Form>
  </Modal>
</template>