一、概述
ERPNext 的会计模块基于复式记账法和树形账户结构(NestedSet),实现了完整的财务核算功能。核心设计理念是通过 GL Entry(总账凭证)记录所有交易,支持多维度分析和实时余额查询。
模块定位
┌─────────────────────────────────────────────────────────────────────┐│ ERPNext 会计模块 │├─────────────────────────────────────────────────────────────────────┤│ 基础架构层:Account(会计科目)、Chart of Accounts ││ 会计分录层:GL Entry(总账凭证)、Journal Entry(日记账) ││ 应收应付层:Sales Invoice、Purchase Invoice、Payment Entry ││ 税务层:Tax Rule、Tax Category、Taxes & Charges ││ 期末处理层:Period Closing Voucher、Account Closing Balance ││ 报表层:Balance Sheet、Profit & Loss、Trial Balance ││ 维度层:Accounting Dimension(会计维度)、Cost Center、Project │└─────────────────────────────────────────────────────────────────────┘
二、核心数据模型
2.1 Account(会计科目)
表结构
name | ||
account_name | ||
root_type | ||
report_type | ||
account_type | ||
is_group | ||
parent_account | ||
company | ||
lftrgt | ||
account_currency | ||
frozen_account |
账户类型说明
account_type: DF.Literal["","Bank", # 银行账户"Cash", # 现金账户"Receivable", # 应收款"Payable", # 应付款"Stock", # 存货科目"Cost of Goods Sold", # 主营业务成本"Income Account", # 收入科目"Expense Account", # 费用科目"Stock Received But Not Billed", # 暂估负债"Stock Delivered But Not Billed", # 发出商品"Stock Adjustment", # 库存调整"Fixed Asset", # 固定资产"Current Asset", # 流动资产"Current Liability", # 流动负债"Equity", # 权益"Tax", # 税费科目]
树形结构
资产 (Asset)├── 流动资产 (Current Asset)│ ├── 现金 (Cash)│ ├── 银行存款 (Bank)│ └── 应收账款 (Receivable)├── 固定资产 (Fixed Asset)│ └── 设备 (Equipment)└── 存货 (Stock)└── 原材料 (Raw Material)
NestedSet 实现
# 父级科目查询# lft <= 当前科目的lft AND rgt >= 当前科目的rgt# 子级科目查询# lft >= 当前科目的lft AND rgt <= 当前科目的rgt
2.2 Journal Entry(日记账)
主表结构
name | ||
company | ||
posting_date | ||
voucher_type | ||
is_opening | ||
is_system_generated | ||
total_debit | ||
total_credit | ||
difference | ||
multi_currency | ||
finance_book |
凭证类型
子表:Journal Entry Account
parent | ||
parenttype | ||
parentfield | ||
account | ||
account_currency | ||
debit | ||
debit_in_account_currency | ||
credit | ||
credit_in_account_currency | ||
exchange_rate | ||
party_type | ||
party | ||
cost_center | ||
project | ||
is_advance | ||
reference_type | ||
reference_name | ||
against_account |
核心验证逻辑
def validate(self):# 1. 设置期初标志if self.voucher_type == "Opening Entry":self.is_opening = "Yes"# 2. 验证交易方self.validate_party()# 3. 验证预付款self.validate_entries_for_advance()# 4. 验证多币种self.validate_multi_currency()# 5. 设置公司币种金额self.set_amounts_in_company_currency()# 6. 验证借贷平衡self.validate_debit_credit_amount()# 7. 设置合计金额self.set_total_debit_credit()# 8. 验证库存科目self.validate_stock_accounts()# 9. 验证引用单据JournalEntryReferenceValidator(self).validate()# 10. 设置对方科目self.set_against_account()# 11. 验证会计维度self.validate_company_in_accounting_dimension()# 12. 验证税务代扣JournalTaxWithholding(self).on_validate()
借贷平衡验证
def validate_debit_credit_amount(self):if not (self.multi_currency or self.is_opening == "Yes"):if flt(self.total_debit) != flt(self.total_credit):frappe.throw(_("Total Debit ({0}) must equal Total Credit ({1})").format(fmt_money(self.total_debit), fmt_money(self.total_credit)))
提交时生成 GL Entry
def on_submit(self):# 创建GL Entryfrom erpnext.accounts.doctype.gl_entry.gl_entry import make_gl_entriesgl_entries = []for d in self.accounts:gl_entry = {"account": d.account,"party_type": d.party_type,"party": d.party,"cost_center": d.cost_center,"project": d.project,"debit": flt(d.debit, self.precision("debit")),"credit": flt(d.credit, self.precision("credit")),# ... 其他字段}gl_entries.append(gl_entry)make_gl_entries(gl_entries, cancel=False)
2.3 GL Entry(总账凭证)
表结构
name | ||
account | ||
account_currency | ||
debit | ||
credit | ||
debit_in_account_currency | ||
credit_in_account_currency | ||
debit_in_transaction_currency | ||
credit_in_transaction_currency | ||
party_type | ||
party | ||
cost_center | ||
project | ||
voucher_type | ||
voucher_no | ||
voucher_detail_no | ||
against | ||
against_voucher_type | ||
against_voucher | ||
posting_date | ||
transaction_date | ||
transaction_currency | ||
transaction_exchange_rate | ||
reporting_currency_exchange_rate | ||
fiscal_year | ||
finance_book | ||
is_opening | ||
is_cancelled | ||
is_advance | ||
due_date | ||
remarks |
关键字段说明
多币种字段关系:
公司币种金额 (debit/credit)← 账户币种金额 (debit_in_account_currency/credit_in_account_currency)← 交易币种金额 (debit_in_transaction_currency/credit_in_transaction_currency)
凭证关联:
voucher_type + voucher_no → 原始单据(如Journal Entry、Sales Invoice)against_voucher_type + against_voucher → 对方单据(如付款关联的发票)
核心验证
def validate(self):self.flags.ignore_submit_comment = True# 1. 设置会计年度self.validate_and_set_fiscal_year()# 2. 损益类科目必须有成本中心self.pl_must_have_cost_center()# 3. 检查必填字段self.check_mandatory()# 4. 验证成本中心self.validate_cost_center()# 5. 验证损益类科目self.check_pl_account()# 6. 验证交易方self.validate_party()# 7. 验证币种self.validate_currency()# 8. 设置报告币种金额self.set_amount_in_reporting_currency()
损益类科目成本中心验证
def pl_must_have_cost_center(self):if self.cost_center or self.voucher_type == "Period Closing Voucher":returnreport_type = frappe.get_cached_value("Account", self.account, "report_type")if report_type == "Profit and Loss":frappe.throw(_("{0} {1}: Cost Center is required for 'Profit and Loss' account {2}.").format(self.voucher_type, self.voucher_no, self.account))
交易方验证
def check_mandatory(self):mandatory = ["account", "voucher_type", "voucher_no", "company"]for k in mandatory:if not self.get(k):frappe.throw(_("{0} is required").format(_(self.meta.get_label(k))))# 应收/应付科目必须有交易方account_type = frappe.get_cached_value("Account", self.account, "account_type")if not self.is_cancelled and not (self.party_type and self.party):if account_type == "Receivable":frappe.throw(_("{0} {1}: Customer is required against Receivable account {2}"))elif account_type == "Payable":frappe.throw(_("{0} {1}: Supplier is required against Payable account {2}"))
更新未结金额
def on_update(self):if self.voucher_type == "Journal Entry" and self.against_voucher_type in ["Journal Entry", "Sales Invoice", "Purchase Invoice", "Fees"] and self.against_voucher and self.flags.update_outstanding == "Yes":update_outstanding_amt(self.account,self.party_type,self.party,self.against_voucher_type,self.against_voucher,)
2.4 会计维度(Accounting Dimension)
设计理念
会计维度允许用户定义自定义分析维度(如部门、区域、项目等),这些维度会自动添加到相关单据和 GL Entry 中,实现精细化的财务分析。
核心思想:动态扩展分析维度,无需修改代码即可添加新的分析维度。
表结构
Accounting Dimension(主表):
name | ||
document_type | ||
fieldname | ||
label | ||
disabled |
Accounting Dimension Detail(子表):
company | ||
default_value |
工作原理
class AccountingDimension(Document):def on_update(self):# 异步在所有相关DocType中创建自定义字段frappe.enqueue(make_dimension_in_accounting_doctypes,doc=self,queue="long",enqueue_after_commit=True)def on_trash(self):# 删除所有相关DocType中的自定义字段frappe.enqueue(delete_accounting_dimension,doc=self,queue="long",enqueue_after_commit=True)
创建自定义字段的逻辑
def make_dimension_in_accounting_doctypes(doc, doclist=None):if not doclist:doclist = get_doctypes_with_dimensions() # 获取所有需要添加维度的DocTypefor doctype in doclist:df = {"fieldname": doc.fieldname,"label": doc.label,"fieldtype": "Link","options": doc.document_type,"insert_after": "accounting_dimensions_section","owner": "Administrator","allow_on_submit": 1 if doctype in repostable_doctypes else 0,}meta = frappe.get_meta(doctype, cached=False)fieldnames = [d.fieldname for d in meta.get("fields")]# 如果字段不存在,则创建自定义字段if df["fieldname"] not in fieldnames:create_custom_field(doctype, df, ignore_validate=True)frappe.clear_cache(doctype=doctype)
包含维度的 DocType
通过 hook 配置:
# hooks.pyaccounting_dimension_doctypes = ["GL Entry","Journal Entry","Journal Entry Account","Sales Invoice","Purchase Invoice","Payment Entry","Stock Entry","Purchase Order","Sales Order","Delivery Note","Purchase Receipt",# ... 更多]
内置维度
ERPNext 内置两个核心维度,不需要通过 Accounting Dimension 创建:
注意:项目和成本中心直接在 GL Entry 表中有固定字段,而自定义会计维度通过动态添加自定义字段实现。
字段名冲突处理
def validate_fieldname_conflict(self):conflicting_doctypes = []for doctype in get_doctypes_with_dimensions():meta = frappe.get_meta(doctype, cached=False)if any(f.fieldname == self.fieldname for f in meta.get("fields")):conflicting_doctypes.append(doctype)if conflicting_doctypes:# 如果字段已存在,则GL Entry会使用现有字段的值作为维度值frappe.msgprint(_("Fieldname {0} already exists in {1}. GL Entries will use the existing field value."),indicator="orange",)
维度传递机制
维度值从单据传递到 GL Entry 的流程:
1. 用户在 Journal Entry 中填写维度值(如 department)2. Journal Entry 提交时,从子表行获取维度值3. 创建 GL Entry 时,将维度值写入对应的字段4. GL Entry 验证时检查损益类科目是否必填维度
代码实现(base_gl_composer.py):
def get_gl_dict(self, args):gl_dict = frappe._dict({"account": args.account,"cost_center": args.cost_center,"project": args.project,# ... 其他字段})# 添加自定义会计维度for dimension inget_accounting_dimensions():if dimension.fieldname in args:gl_dict[dimension.fieldname] = args[dimension.fieldname]return gl_dict
维度验证规则
def validate_dimensions_for_pl_and_bs(self):account_type = frappe.get_cached_value("Account", self.account, "report_type")for dimension in get_checks_for_pl_and_bs_accounts():if (account_type == "Profit and Loss"and self.company == dimension.companyand dimension.mandatory_for_pland not self.is_cancelled):if not self.get(dimension.fieldname):frappe.throw(_("{0} is mandatory for Profit and Loss account").format(dimension.label))
三、科目余额计算
3.1 get_balance_on 函数
函数签名
def get_balance_on(account: str | None = None,date: DateTimeLikeObject | None = None,party_type: str | None = None,party: str | None = None,company: str | None = None,in_account_currency: bool = True,cost_center: str | None = None,ignore_account_permission: bool = False,account_type: str | None = None,start_date: str | None = None,finance_book: str | None = None,include_default_fb_balances: bool = False,):
核心逻辑
def get_balance_on(...):# 1. 构建查询条件cond = ["is_cancelled=0"]if start_date:cond.append("posting_date >= %s" % frappe.db.escape(cstr(start_date)))if date:cond.append("posting_date <= %s" % frappe.db.escape(cstr(date)))else:date = nowdate()# 2. 处理成本中心过滤(支持组成本中心)if cost_center and report_type == "Profit and Loss":cc = frappe.get_lazy_doc("Cost Center", cost_center)if cc.is_group:cond.append(f"""exists (select 1 from `tabCost Center` ccwhere cc.name = gle.cost_centerand cc.lft >= {cc.lft} and cc.rgt <= {cc.rgt})""")else:cond.append(f"""gle.cost_center = {frappe.db.escape(cost_center)} """)# 3. 处理科目过滤(支持组科目)if acc.is_group:cond.append(f"""exists (select name from `tabAccount` acwhere ac.name = gle.accountand ac.lft >= {acc.lft} and ac.rgt <= {acc.rgt})""")else:cond.append(f"""gle.account = {frappe.db.escape(account)} """)# 4. 查询借方和贷方合计gle = qb.DocType("GL Entry")result = (frappe.qb.from_(gle).select(Sum(gle.debit), Sum(gle.credit)).where(" and ".join(cond)).run(as_dict=True))# 5. 计算余额balance = flt(result[0].debit) - flt(result[0].credit)return balance
查询性能优化
# 组科目查询使用嵌套查询,避免JOIN# 组科目且币种与公司一致时,直接使用公司币种金额if acc.is_group and acc.account_currency == company_currency:in_account_currency = False# 使用缓存查询科目属性acc.check_permission("read") # 权限检查report_type = acc.report_type # 缓存读取
3.2 带会计维度的余额查询
def get_balance_with_dimensions(account, date=None, dimensions=None):"""带会计维度的余额查询Args:account: 科目代码date: 查询日期dimensions: 维度字典,如 {"department": "D001", "project": "P001"}Returns:余额金额"""cond = ["is_cancelled=0", f"posting_date <= {frappe.db.escape(date)}"]# 添加维度过滤条件if dimensions:for fieldname, value in dimensions.items():cond.append(f"{fieldname} = {frappe.db.escape(value)}")# 查询GL Entryresult = frappe.db.sql(f"""SELECT SUM(debit) as debit, SUM(credit) as creditFROM `tabGL Entry`WHERE {' AND '.join(cond)}""",as_dict=True)return flt(result[0].debit) - flt(result[0].credit)
3.3 Account Closing Balance(期末余额)
表结构
name | ||
account | ||
company | ||
fiscal_year | ||
closing_balance | ||
closing_balance_in_account_currency | ||
cost_center | ||
project | ||
period_closing_voucher |
生成逻辑
# Period Closing Voucher 提交时生成def on_submit(self):for account in self.get("accounts"):# 获取科目余额balance = get_balance_on(account=account.account,date=self.posting_date,company=self.company,)# 创建 Account Closing Balancefrappe.get_doc({"doctype": "Account Closing Balance","account": account.account,"company": self.company,"fiscal_year": self.fiscal_year,"closing_balance": balance,"period_closing_voucher": self.name,}).insert()
使用场景
1.期末报表:快速获取期末余额,避免全表扫描2.历史查询:查询历史期间的科目余额3.审计追溯:记录期末余额,便于审计
四、核心业务流程
4.1 采购流程(三单匹配)
Purchase Order → Purchase Receipt → Purchase Invoice(PO) (PR) (PI)1. Purchase Receipt(采购收货)├── 创建 Stock Ledger Entry(库存增加)└── 创建 GL Entry:├── 借: Stock In Hand(库存科目)└── 贷: Stock Received But Not Billed(暂估负债)2. Purchase Invoice(采购发票)├── 创建 GL Entry:│ ├── 借: Stock Received But Not Billed(冲销暂估)│ ├── 借: Purchase Taxes(进项税)│ └── 贷: Accounts Payable(应付账款)└── 处理差异:├── 价格差异 → Purchase Price Variance└── 汇率差异 → Exchange Gain/Loss
4.2 销售流程(三单匹配)
Sales Order → Delivery Note → Sales Invoice(SO) (DN) (SI)1. Delivery Note(销售发货)├── 创建 Stock Ledger Entry(库存减少)└── 创建 GL Entry:├── 借: Cost of Goods Sold(或 Stock Delivered But Not Billed)└── 贷: Stock In Hand(库存科目)2. Sales Invoice(销售发票)├── 创建 GL Entry:│ ├── 借: Accounts Receivable(应收账款)│ ├── 贷: Sales(销售收入)│ └── 贷: Sales Taxes(销项税)└── 如果启用发出商品:├── 借: Cost of Goods Sold└── 贷: Stock Delivered But Not Billed
4.3 付款流程
Payment Entry(付款凭证)├── 创建 GL Entry:│ ├── 借: Accounts Payable(冲销应付)│ └── 贷: Bank/Cash(银行/现金)└── 更新发票状态(Outstanding Amount)
4.4 收款流程
Payment Entry(收款凭证)├── 创建 GL Entry:│ ├── 借: Bank/Cash(银行/现金)│ └── 贷: Accounts Receivable(冲销应收)└── 更新发票状态(Outstanding Amount)
4.5 期末结账流程
Period Closing Voucher(期末结账)├── 创建 Account Closing Balance(科目期末余额)├── 生成 GL Entry(结转损益)│ ├── 借: Profit/Loss(本年利润)│ └── 贷: 所有损益类科目余额└── 更新下一年期初余额
4.6 日记账流程
Journal Entry(日记账)├── 用户填写科目行(account, debit/credit, party, cost_center等)├── 系统验证:│ ├── 借贷平衡│ ├── 损益类科目必须有成本中心│ ├── 应收/应付科目必须有交易方│ └── 会计维度验证├── 提交时创建 GL Entry:│ └── 每一行科目创建一条GL Entry记录└── 更新相关单据的未结金额
五、税务处理逻辑
5.1 Tax Rule(税务规则)
# 匹配优先级(按顺序匹配)1. Customer / Supplier2. Item / Item Group3. Company4. Country / State5. Tax Category6. 默认规则# 匹配流程- 根据交易方、物料、地区等条件匹配税务规则- 自动应用对应的税务模板- 支持多税率组合
5.2 Taxes & Charges Template(税务模板)
name | ||
company | ||
taxes |
5.3 Tax Category(税务类别)
用于对物料进行税务分类,便于税务规则匹配。
六、报表生成逻辑
6.1 Balance Sheet(资产负债表)
def get_balance_sheet(company, fiscal_year):# 1. 获取资产类科目asset_accounts = get_accounts_by_root_type(company, "Asset")# 2. 获取负债类科目liability_accounts = get_accounts_by_root_type(company, "Liability")# 3. 获取权益类科目equity_accounts = get_accounts_by_root_type(company, "Equity")# 4. 计算每个科目的余额for account in asset_accounts:balance = get_balance_on(account.name, date=fiscal_year_end_date)# 5. 按树形结构汇总# 资产 = 负债 + 权益
6.2 Profit and Loss(损益表)
def get_profit_and_loss(company, fiscal_year):# 1. 获取收入类科目income_accounts = get_accounts_by_root_type(company, "Income")# 2. 获取费用类科目expense_accounts = get_accounts_by_root_type(company, "Expense")# 3. 计算期间发生额for account in income_accounts:amount = get_balance_on(account.name,start_date=fiscal_year_start_date,date=fiscal_year_end_date,)# 4. 计算净利润# 净利润 = 总收入 - 总费用
6.3 Trial Balance(试算平衡表)
def get_trial_balance(company, date):# 查询所有科目的借方和贷方合计result = frappe.db.sql("""SELECTaccount,SUM(debit) as debit,SUM(credit) as credit,(SUM(debit) - SUM(credit)) as balanceFROM `tabGL Entry`WHERE company = %s AND posting_date <= %s AND is_cancelled = 0GROUP BY account""",(company, date),as_dict=True)# 验证借贷平衡total_debit = sum(r.debit for r in result)total_credit = sum(r.credit for r in result)assert total_debit == total_credit
夜雨聆风