人财事物信息化 - regional

一、regional模块作用机制分析

ERPNext的 regional 模块用于适配不同国家/地区的本地化需求,包含:

  • 地址模板( address_template ):定义符合当地格式的地址字段。
  • 凭证类型/报表( doctype/report ):配置会计凭证、税务报表等业务文档结构。
  • 打印格式( print_format ):规范票据、报表的打印样式。
  • 国家特定目录:如 italy 、 united_states 等目录存储对应国家的本地化配置文件。

二、按中国规范生成 china 目录及文件

  1. 目录结构
erpnext/regional/china/
├── address_template       # 地址模板(含中国行政区划层级)
├── doctype                # 会计凭证及税务相关单据类型
│   ├── 记账凭证.json      # 符合《会计基础工作规范》的凭证结构
│   ├── 增值税发票.json    # 依据发票管理办法的电子发票字段
│   └── 纳税申报表.json    # 适配增值税、企业所得税等报表格式
├── print_format           # 打印模板(含电子签章位置、含税/不含税标识)
│   ├── 凭证打印格式.xml 
│   └── 发票打印格式.xml 
├── report                 # 税务及财务报表
│   ├── 资产负债表.py      # 依据《企业会计准则》的报表生成逻辑
│   └── 增值税申报表.py    # 对接税务系统的申报数据结构
└── __init__.py             # 初始化文件(注册中国本地化配置)
  1. 核心文件内容示例
  • 记账凭证(doctype/记账凭证.json)
{
  "fields": [
    {"fieldname": "凭证编号", "fieldtype": "Data", "required": true},
    {"fieldname": "日期", "fieldtype": "Date", "default": "CURRENT_DATE"},
    {"fieldname": "摘要", "fieldtype": "Small Text"},
    {"fieldname": "会计科目", "fieldtype": "Link", "options": "会计科目表"},
    {"fieldname": "借方金额", "fieldtype": "Currency", "options": "CNY"},
    {"fieldname": "贷方金额", "fieldtype": "Currency", "options": "CNY"}
  ],
  "validate": "检查借贷方金额平衡"
}
  • 增值税申报表(report/增值税申报表.py)
def generate_vat_report(fiscal_period):
    # 依据《增值税暂行条例》计算销项税额、进项税额、应纳税额
    data = {
        "纳税人识别号": "XXX",
        "所属期": fiscal_period,
        "销项税额": calculate_output_tax(),
        "进项税额": calculate_input_tax(),
        "应纳税额": "销项税额 - 进项税额"
    }
    return format_for_tax_authority(data)  # 适配税务系统接口格式
}

三、关键合规点说明

  1. 会计凭证规范
  • 遵循《会计基础工作规范》,凭证需包含日期、编号、摘要、科目、金额等要素,且借贷方金额必须相等。
  • 支持电子凭证存储,符合《电子会计档案管理办法》。
  1. 税务报表对接
  • 增值税报表需区分一般计税和简易计税,包含销售额、税额、价税合计等字段。
  • 企业所得税报表需关联成本费用、收入明细,符合《企业所得税法》汇算清缴要求。
  1. 地址模板 采用“省-市-区-街道-门牌号”五级结构,支持“中国境内地址”和“港澳台地址”分类管理。

如需进一步细化某个文件内容或补充特定法规条款,请提供具体需求。


以下是为 regional 目录新增 china 子目录的完整文件结构及代码,遵循现有项目的组织逻辑:

regional/china 目录结构

regional/
└── china/
    ├── address_template/
    │   └── china_address.html
    ├── doctype/
    │   ├── china_tax_info/
    │   │   ├── china_tax_info.doctype.json
    │   │   └── __init__.py
    │   └── __init__.py
    ├── print_format/
    │   ├── chinese_invoice.html
    │   ├── chinese_invoice.js
    │   └── __init__.py
    ├── report/
    │   ├── chinese_tax_report/
    │   │   ├── chinese_tax_report.py
    │   │   └── __init__.py
    │   └── __init__.py
    └── __init__.py

各文件代码实现

  1. regional/china/init.py
# 中国区域模块初始化文件
from __future__ import unicode_literals
  1. regional/china/addresstemplate/chinaaddress.html
<!-- 中国地址模板 -->
{% set address_format = "{% if address_line1 %}{{ address_line1 }}<br>{% endif %}{% if address_line2 %}{{ address_line2 }}<br>{% endif %}{{ city_name }}<br>{{ province_name }}<br>{{ pincode }}<br>{{ country_name }}" %}
{{ address_format }}
  1. regional/china/doctype/chinataxinfo/chinataxinfo.doctype.json
{
  "name": "China Tax Info",
  "module": "ERPNext",
  "doctype": "China Tax Info",
  "is_table": 0,
  "fields": [
    {
      "fieldname": "taxpayer_identification_number",
      "label": "纳税人识别号",
      "fieldtype": "Data",
      "reqd": 1,
      "length": 20
    },
    {
      "fieldname": "tax_office",
      "label": "主管税务机关",
      "fieldtype": "Data",
      "length": 100
    },
    {
      "fieldname": "tax_office_code",
      "label": "税务机关代码",
      "fieldtype": "Data",
      "length": 11
    }
  ],
  "indexes": [],
  "permissions": [],
  "custom": 0,
  "menu_label": "中国税务信息",
  "route": "China Tax Info",
  "has_quick_entry": 0
}
  1. regional/china/doctype/chinataxinfo/init.py
# 中国税务信息文档类型初始化
from __future__ import unicode_literals
  1. regional/china/doctype/init.py
# 中国区域文档类型初始化
from __future__ import unicode_literals
  1. regional/china/printformat/chineseinvoice.html
<!-- 中文发票打印格式 -->
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>增值税发票</title>
    <style>
        /* 发票样式 */
        body { font-family: "SimSun", serif; font-size: 14px; }
        .invoice-header { text-align: center; border-bottom: 2px solid #000; padding-bottom: 10px; }
        .company-info { margin: 10px 0; }
        .table-container { width: 100%; border-collapse: collapse; margin: 15px 0; }
        .table-container th, .table-container td { border: 1px solid #000; padding: 8px; text-align: center; }
        .table-container th { background-color: #f2f2f2; }
        .total-row { font-weight: bold; }
        .footer { text-align: right; margin-top: 20px; }
    </style>
</head>
<body>
    <div class="invoice-header">
        <h1>增值税普通发票</h1>
        <p>发票代码:{{ doc.invoice_code }}</p>
        <p>发票号码:{{ doc.invoice_number }}</p>
    </div>

    <div class="company-info">
        <p><strong>销售方:</strong>{{ doc.seller_name }}</p>
        <p>纳税人识别号:{{ doc.seller_tax_id }}</p>
        <p>地址、电话:{{ doc.seller_address }} {{ doc.seller_phone }}</p>
        <p>开户行及账号:{{ doc.seller_bank }} {{ doc.seller_bank_account }}</p>
    </div>

    <div class="company-info">
        <p><strong>购买方:</strong>{{ doc.buyer_name }}</p>
        <p>纳税人识别号:{{ doc.buyer_tax_id }}</p>
        <p>地址、电话:{{ doc.buyer_address }} {{ doc.buyer_phone }}</p>
        <p>开户行及账号:{{ doc.buyer_bank }} {{ doc.buyer_bank_account }}</p>
    </div>

    <table class="table-container">
        <thead>
            <tr>
                <th>商品名称</th>
                <th>规格型号</th>
                <th>单位</th>
                <th>数量</th>
                <th>单价</th>
                <th>金额</th>
                <th>税率</th>
                <th>税额</th>
            </tr>
        </thead>
        <tbody>
            {% for item in doc.items %}
            <tr>
                <td>{{ item.item_name }}</td>
                <td>{{ item.specification }}</td>
                <td>{{ item.unit }}</td>
                <td>{{ item.quantity }}</td>
                <td>{{ item.rate }}</td>
                <td>{{ item.amount }}</td>
                <td>{{ item.tax_rate }}%</td>
                <td>{{ item.tax_amount }}</td>
            </tr>
            {% endfor %}
        </tbody>
        <tfoot>
            <tr class="total-row">
                <td colspan="5">合计</td>
                <td>{{ doc.total_amount }}</td>
                <td>--</td>
                <td>{{ doc.total_tax }}</td>
            </tr>
            <tr class="total-row">
                <td colspan="7">价税合计(大写)</td>
                <td>{{ doc.total_in_words }}</td>
            </tr>
        </tfoot>
    </table>

    <div class="footer">
        <p>开票日期:{{ doc.posting_date }}</p>
        <p>备注:{{ doc.remarks }}</p>
    </div>
</body>
</html>
  1. regional/china/printformat/chineseinvoice.js
// 中文发票打印脚本
frappe.ui.form.on('Sales Invoice', {
    refresh: function(frm) {
        // 格式化中文金额大写
        frm.set_query('total_in_words', function() {
            return {
                query: 'erpnext.regional.china.doctype.china_tax_info.china_tax_info.get_chinese_amount_in_words',
                args: {
                    amount: frm.doc.total_amount + frm.doc.total_tax
                }
            };
        });

        // 打印时添加中文表头
        frm.add_custom_button(__('打印中文发票'), function() {
            frappe.route_options = {
                docname: frm.doc.name,
                print_format: 'Chinese Invoice'
            };
            frappe.set_route('printview');
        });
    }
});

// 金额转中文大写工具函数(简化版)
function numberToChinese(num) {
    const digits = ['零', '壹', '贰', '叁', '肆', '伍', '陆', '柒', '捌', '玖'];
    const units = ['', '拾', '佰', '仟', '万', '拾', '佰', '仟', '亿'];
    let chinese = '';
    let numStr = num.toString();
    let decimal = '';

    // 处理小数部分
    if (numStr.includes('.')) {
        const parts = numStr.split('.');
        numStr = parts[0];
        decimal = parts[1].substring(0, 2); // 保留两位小数
    }

    // 处理整数部分
    let unitIndex = 0;
    for (let i = numStr.length - 1; i >= 0; i--) {
        const digit = parseInt(numStr[i]);
        if (digit === 0) {
            // 连续零只显示一个
            if (numStr[i-1] && parseInt(numStr[i-1]) !== 0) {
                chinese = digits[digit] + chinese;
            }
        } else {
            chinese = digits[digit] + units[unitIndex] + chinese;
        }
        unitIndex++;
    }

    // 添加"元"和小数部分
    chinese = chinese || digits[0] + '元';
    if (decimal) {
        chinese += '整';
        if (parseInt(decimal[0]) > 0) {
            chinese += digits[parseInt(decimal[0])] + '角';
        }
        if (parseInt(decimal[1]) > 0) {
            chinese += digits[parseInt(decimal[1])] + '分';
        }
    } else {
        chinese += '整';
    }

    return chinese;
}
  1. regional/china/print_format/init.py
# 中国区域打印格式初始化
from __future__ import unicode_literals
  1. regional/china/report/chinesetaxreport/chinesetaxreport.py
# 中国税务报表
from __future__ import unicode_literals
import frappe
from frappe import _

def execute(filters=None):
    # 报表列定义
    columns = [
        {
            "label": _("日期"),
            "fieldname": "posting_date",
            "fieldtype": "Date",
            "width": 120
        },
        {
            "label": _("发票号码"),
            "fieldname": "invoice_number",
            "fieldtype": "Data",
            "width": 150
        },
        {
            "label": _("销售方"),
            "fieldname": "seller_name",
            "fieldtype": "Data",
            "width": 180
        },
        {
            "label": _("购买方"),
            "fieldname": "buyer_name",
            "fieldtype": "Data",
            "width": 180
        },
        {
            "label": _("应税项目"),
            "fieldname": "item_name",
            "fieldtype": "Data",
            "width": 200
        },
        {
            "label": _("金额"),
            "fieldname": "amount",
            "fieldtype": "Currency",
            "width": 120
        },
        {
            "label": _("税率"),
            "fieldname": "tax_rate",
            "fieldtype": "Percent",
            "width": 100
        },
        {
            "label": _("税额"),
            "fieldname": "tax_amount",
            "fieldtype": "Currency",
            "width": 120
        }
    ]

    # 报表数据查询
    conditions = "1=1"
    if filters.get("from_date"):
        conditions += " AND si.posting_date >= %(from_date)s"
    if filters.get("to_date"):
        conditions += " AND si.posting_date <= %(to_date)s"
    if filters.get("company"):
        conditions += " AND si.company = %(company)s"

    data = frappe.db.sql("""
        SELECT 
            si.posting_date, si.name as invoice_number, 
            si.seller_name, si.buyer_name, 
            sii.item_name, sii.amount, 
            tax.tax_rate, tax.tax_amount
        FROM 
            `tabSales Invoice` si
        JOIN 
            `tabSales Invoice Item` sii ON si.name = sii.parent
        JOIN 
            `tabSales Taxes and Charges` tax ON si.name = tax.parent 
            AND tax.category = 'Total'
        WHERE 
            {conditions}
        ORDER BY 
            si.posting_date DESC
    """.format(conditions=conditions), filters or {}, as_dict=1)

    # 计算合计
    total_amount = sum(item.amount for item in data)
    total_tax = sum(item.tax_amount for item in data)

    # 添加合计行
    data.append({
        "invoice_number": _("合计"),
        "amount": total_amount,
        "tax_amount": total_tax
    })

    return columns, data
  1. regional/china/report/chinesetaxreport/init.py
# 中国税务报表初始化
from __future__ import unicode_literals
  1. regional/china/report/init.py
# 中国区域报表初始化
from __future__ import unicode_literals

目录说明

  • address_template/:存放中国地址格式模板,符合国内地址书写规范。
  • doctype/:定义中国特有的税务信息文档类型(如纳税人识别号)。
  • print_format/:包含中文发票的打印格式HTML和脚本,支持金额大写转换。
  • report/:提供中国税务报表查询功能,可按日期、公司筛选税务数据。

以上代码遵循ERPNext现有国家模块的结构,如需进一步适配中国财务或税务规则,可在此基础上扩展功能。


全电发票入账归档代码实现

根据ERPNext系统架构,以下是全电发票(全面数字化电子发票)入账归档的完整代码实现,包含文档类型定义、入账逻辑和归档管理功能:

一、新增文档类型:全电发票入账记录

regional/china/doctype/etaxinvoicerecord/etaxinvoicerecord.doctype.json

{
  "name": "ETAX Invoice Record",
  "module": "ERPNext",
  "doctype": "ETAX Invoice Record",
  "is_table": 0,
  "fields": [
    {
      "fieldname": "invoice_code",
      "label": "发票代码",
      "fieldtype": "Data",
      "reqd": 1,
      "length": 20,
      "unique": 1,
      "description": "全电发票唯一代码"
    },
    {
      "fieldname": "invoice_number",
      "label": "发票号码",
      "fieldtype": "Data",
      "reqd": 1,
      "length": 20
    },
    {
      "fieldname": "invoice_date",
      "label": "开票日期",
      "fieldtype": "Date",
      "reqd": 1
    },
    {
      "fieldname": "amount",
      "label": "金额",
      "fieldtype": "Currency",
      "reqd": 1
    },
    {
      "fieldname": "tax_amount",
      "label": "税额",
      "fieldtype": "Currency",
      "reqd": 1
    },
    {
      "fieldname": "total_amount",
      "label": "价税合计",
      "fieldtype": "Currency",
      "reqd": 1,
      "calculated": 1,
      "formula": "amount + tax_amount"
    },
    {
      "fieldname": "invoice_pdf",
      "label": "发票PDF",
      "fieldtype": "Attach",
      "reqd": 1,
      "description": "上传全电发票PDF原件"
    },
    {
      "fieldname": "invoice_xml",
      "label": "发票XML",
      "fieldtype": "Attach",
      "description": "上传全电发票XML元数据"
    },
    {
      "fieldname": "status",
      "label": "状态",
      "fieldtype": "Select",
      "options": "待入账\n已入账\n已归档",
      "default": "待入账",
      "reqd": 1
    },
    {
      "fieldname": "booking_date",
      "label": "入账日期",
      "fieldtype": "Date",
      "depends_on": "eval:doc.status==\"已入账\""
    },
    {
      "fieldname": "archiving_date",
      "label": "归档日期",
      "fieldtype": "Date",
      "depends_on": "eval:doc.status==\"已归档\""
    },
    {
      "fieldname": "link_to_transaction",
      "label": "关联交易",
      "fieldtype": "Dynamic Link",
      "options": "DocType",
      "description": "关联采购/销售发票等交易记录"
    }
  ],
  "indexes": [
    ["invoice_code", "invoice_number", "unique", 1]
  ],
  "permissions": [
    {
      "role": "Accounts User",
      "read": 1
    },
    {
      "role": "Accounts Manager",
      "write": 1,
      "create": 1,
      "delete": 1
    }
  ],
  "custom": 0,
  "menu_label": "全电发票入账记录",
  "route": "ETAX Invoice Record",
  "has_quick_entry": 0
}

二、入账归档核心逻辑

regional/china/doctype/etaxinvoicerecord/etaxinvoicerecord.py

# 全电发票入账归档处理
from __future__ import unicode_literals
import frappe
from frappe import _
from frappe.utils import nowdate, get_files_path
import os
import shutil
from erpnext.accounts.doctype.sales_invoice.sales_invoice import SalesInvoice
from erpnext.accounts.doctype.purchase_invoice.purchase_invoice import PurchaseInvoice

class ETAXInvoiceRecord(frappe.Document):
    def before_save(self):
        # 自动计算价税合计
        if self.amount and self.tax_amount:
            self.total_amount = self.amount + self.tax_amount

    def on_submit(self):
        # 提交时自动入账
        self.book_invoice()

    def book_invoice(self):
        """全电发票入账处理"""
        if self.status != "待入账":
            frappe.throw(_("仅待入账状态的发票可执行入账操作"))

        # 1. 验证发票唯一性(通过发票代码+号码)
        existing_invoice = frappe.db.exists(
            "ETAX Invoice Record",
            {"invoice_code": self.invoice_code, "invoice_number": self.invoice_number}
        )
        if existing_invoice and existing_invoice != self.name:
            frappe.throw(_("该发票已存在于系统中,请勿重复入账"))

        # 2. 关联交易记录(如有)
        if self.link_to_transaction and self.link_to_transaction_doctype:
            self.validate_linked_transaction()

        # 3. 更新状态为"已入账"
        self.status = "已入账"
        self.booking_date = nowdate()
        self.save()

        # 4. 记录入账日志
        frappe.log_error(
            message=f"全电发票入账成功: {self.invoice_code}-{self.invoice_number}",
            title="ETAX Invoice Booking"
        )

    def validate_linked_transaction(self):
        """验证关联交易的金额一致性"""
        if self.link_to_transaction_doctype in ["Sales Invoice", "Purchase Invoice"]:
            transaction = frappe.get_doc(self.link_to_transaction_doctype, self.link_to_transaction)
            if self.total_amount != transaction.grand_total:
                frappe.throw(_("发票金额与关联交易金额不一致,请检查"))

    def archive_invoice(self):
        """全电发票归档处理"""
        if self.status != "已入账":
            frappe.throw(_("仅已入账状态的发票可执行归档操作"))

        # 1. 生成归档路径(按年-月-日结构)
        archive_path = os.path.join(
            get_files_path(),
            "etax_archive",
            nowdate().split("-")[0],  # 年
            nowdate().split("-")[1],  # 月
            nowdate().split("-")[2]   # 日
        )
        os.makedirs(archive_path, exist_ok=True)

        # 2. 复制发票文件到归档目录
        for file_field in ["invoice_pdf", "invoice_xml"]:
            if self.get(file_field):
                file_doc = frappe.get_doc("File", {"attached_to_name": self.name, "attached_to_field": file_field})
                if file_doc:
                    source_file = os.path.join(get_files_path(), file_doc.file_url.lstrip("/"))
                    target_file = os.path.join(archive_path, f"{self.invoice_code}_{self.invoice_number}_{file_field}{os.path.splitext(file_doc.file_url)[1]}")
                    shutil.copy2(source_file, target_file)

        # 3. 更新状态为"已归档"
        self.status = "已归档"
        self.archiving_date = nowdate()
        self.save()

        # 4. 记录归档日志
        frappe.log_error(
            message=f"全电发票归档成功: {self.invoice_code}-{self.invoice_number}",
            title="ETAX Invoice Archiving"
        )

    @frappe.whitelist()
    def auto_validate_invoice(self):
        """自动验证发票真伪(模拟接口,实际需对接税务平台)"""
        # 模拟税务平台验证逻辑
        # 实际应用中需调用税务总局电子发票查验接口
        validation_result = {
            "valid": True,
            "message": "发票验证通过",
            "verify_date": nowdate()
        }

        self.db_set("is_verified", 1)
        self.db_set("verification_message", validation_result["message"])
        self.db_set("verification_date", validation_result["verify_date"])

        return validation_result

三、全电发票批量处理脚本

regional/china/doctype/etaxinvoicebatch/etaxinvoicebatch.doctype.json

{
  "name": "ETAX Invoice Batch",
  "module": "ERPNext",
  "doctype": "ETAX Invoice Batch",
  "is_table": 0,
  "fields": [
    {
      "fieldname": "batch_name",
      "label": "批次名称",
      "fieldtype": "Data",
      "reqd": 1,
      "default": "自动批次-" + frappe.utils.nowdate(),
      "read_only": 1
    },
    {
      "fieldname": "batch_date",
      "label": "批次日期",
      "fieldtype": "Date",
      "reqd": 1,
      "default": frappe.utils.nowdate()
    },
    {
      "fieldname": "invoice_count",
      "label": "发票数量",
      "fieldtype": "Int",
      "read_only": 1
    },
    {
      "fieldname": "total_amount",
      "label": "总金额",
      "fieldtype": "Currency",
      "read_only": 1
    },
    {
      "fieldname": "etax_invoices",
      "label": "全电发票列表",
      "fieldtype": "Table",
      "options": "ETAX Invoice Record",
      "get_items": "get_etax_invoices"
    },
    {
      "fieldname": "status",
      "label": "批次状态",
      "fieldtype": "Select",
      "options": "待处理\n处理中\n已完成\n部分失败\n全部失败",
      "default": "待处理",
      "read_only": 1
    }
  ],
  "permissions": [
    {
      "role": "Accounts Manager",
      "read": 1,
      "write": 1,
      "create": 1
    }
  ],
  "custom": 0,
  "menu_label": "全电发票批次处理",
  "route": "ETAX Invoice Batch",
  "has_quick_entry": 0
}

regional/china/doctype/etaxinvoicebatch/etaxinvoicebatch.py

# 全电发票批量入账归档
from __future__ import unicode_literals
import frappe
from frappe import _
from frappe.utils import now, nowdate

class ETAXInvoiceBatch(frappe.Document):
    def get_etax_invoices(self):
        """获取待处理的全电发票列表"""
        if self.from_date and self.to_date:
            invoices = frappe.get_list(
                "ETAX Invoice Record",
                filters={
                    "status": "待入账",
                    "invoice_date": ("between", [self.from_date, self.to_date])
                },
                fields=["name", "invoice_code", "invoice_number", "amount", "tax_amount"]
            )
            self.set("etax_invoices", [{"etax_invoice": d.name} for d in invoices])
            self.invoice_count = len(invoices)
            self.total_amount = sum(d.amount + d.tax_amount for d in invoices)

    def process_batch(self):
        """批量处理全电发票"""
        if self.status != "待处理":
            frappe.throw(_("仅待处理状态的批次可执行处理操作"))

        self.status = "处理中"
        self.save()

        success_count = 0
        failure_logs = []

        for d in self.etax_invoices:
            try:
                invoice = frappe.get_doc("ETAX Invoice Record", d.etax_invoice)
                invoice.book_invoice()  # 入账
                invoice.archive_invoice()  # 归档
                success_count += 1
            except Exception as e:
                failure_logs.append(f"发票 {invoice.invoice_code}-{invoice.invoice_number} 处理失败: {str(e)}")

        # 更新批次状态
        if not failure_logs:
            self.status = "已完成"
        elif len(failure_logs) < len(self.etax_invoices):
            self.status = "部分失败"
        else:
            self.status = "全部失败"

        self.save()

        # 生成处理报告
        report = f"批次处理完成: {now()}\n总发票数: {len(self.etax_invoices)}\n成功: {success_count}\n失败: {len(failure_logs)}"
        if failure_logs:
            report += "\n失败详情:\n" + "\n".join(failure_logs)

        frappe.log_error(message=report, title="ETAX Invoice Batch Process")
        return report

四、自动归档后台任务

regional/china/utils/etaxarchivingscheduler.py

# 全电发票自动归档调度任务
from __future__ import unicode_literals
import frappe
from frappe.utils import nowdate, get_files_path
import os
import shutil
from erpnext.regional.china.doctype.etax_invoice_record.etax_invoice_record import ETAXInvoiceRecord

@frappe.whitelist()
def schedule_etax_archiving():
    """定时任务:自动归档已入账超过30天的全电发票"""
    # 获取已入账超过30天的发票
    cutoff_date = frappe.utils.add_days(nowdate(), -30)
    invoices = frappe.get_list(
        "ETAX Invoice Record",
        filters={
            "status": "已入账",
            "booking_date": ("<", cutoff_date)
        },
        fields=["name", "invoice_code", "invoice_number", "invoice_pdf", "invoice_xml"]
    )

    if not invoices:
        frappe.log_error(message="无待归档的全电发票", title="ETAX Archiving Scheduler")
        return

    # 执行归档
    archive_path = os.path.join(
        get_files_path(),
        "etax_archive_scheduled",
        nowdate().split("-")[0],
        nowdate().split("-")[1]
    )
    os.makedirs(archive_path, exist_ok=True)

    success_count = 0
    for invoice in invoices:
        try:
            doc = frappe.get_doc("ETAX Invoice Record", invoice.name)
            doc.archive_invoice()
            success_count += 1
        except Exception as e:
            frappe.log_error(
                message=f"发票 {invoice.invoice_code}-{invoice.invoice_number} 自动归档失败: {str(e)}",
                title="ETAX Archiving Error"
            )

    # 记录归档日志
    frappe.log_error(
        message=f"自动归档任务完成: 成功归档 {success_count} 张发票",
        title="ETAX Archiving Scheduler"
    )

五、注册后台调度任务

regional/china/init.py

# 中国区域模块初始化
from __future__ import unicode_literals
import frappe

# 注册全电发票归档调度任务(每天凌晨1点执行)
def setup_scheduler():
    frappe.clear_scheduled_jobs("schedule_etax_archiving")
    frappe.schedule_job(
        "erpnext.regional.china.utils.etax_archiving_scheduler.schedule_etax_archiving",
        title="全电发票自动归档",
        frequency="Daily",
        time="01:00:00"
    )

功能说明

1. 全电发票入账: - 支持手动/自动入账,验证发票唯一性和金额一致性 - 关联销售/采购发票等交易记录,确保财务数据闭环 2. 电子归档管理: - 自动生成按日期层级的归档路径(年/月/日) - 同时归档PDF原件和XML元数据,满足税务存储要求 - 归档后更新发票状态为"已归档",防止重复处理 3. 批量处理能力: - 通过批次功能一次性处理多张发票,提升财务效率 - 自动统计批次处理结果,记录失败日志便于追溯 4. 自动调度机制: - 定时任务每日自动归档已入账超30天的发票 - 支持手动触发归档,灵活应对特殊业务场景

部署说明

1. 将上述代码添加到 regional/china 目录下,确保目录结构正确 2. 在ERPNext后台执行"重新加载文档类型",生成ETAX Invoice Record和ETAX Invoice Batch 3. 手动触发一次 setup_scheduler() 函数,注册自动归档任务 4. 测试单张发票入账归档流程,再验证批量处理功能

此方案符合《电子会计凭证归档管理规范》要求,确保全电发票从入账到归档的全流程电子化管理。


电子回单与电子对账单入账归档及对账代码实现

针对企业财务数字化需求,以下是电子回单入账归档和电子对账单月结对账的完整代码实现,基于ERPNext系统架构设计:

一、电子回单入账归档模块

  1. 电子回单文档类型定义

regional/china/doctype/ereceipt/ereceipt.doctype.json

{
  "name": "Electronic Receipt",
  "module": "ERPNext",
  "doctype": "Electronic Receipt",
  "is_table": 0,
  "fields": [
    {
      "fieldname": "receipt_number",
      "label": "回单编号",
      "fieldtype": "Data",
      "reqd": 1,
      "unique": 1,
      "description": "银行电子回单唯一编号"
    },
    {
      "fieldname": "transaction_date",
      "label": "交易日期",
      "fieldtype": "Date",
      "reqd": 1
    },
    {
      "fieldname": "account_number",
      "label": "银行账号",
      "fieldtype": "Data",
      "reqd": 1
    },
    {
      "fieldname": "counterparty_name",
      "label": "对方户名",
      "fieldtype": "Data",
      "reqd": 1
    },
    {
      "fieldname": "counterparty_account",
      "label": "对方账号",
      "fieldtype": "Data",
      "description": "对方银行账号"
    },
    {
      "fieldname": "amount",
      "label": "金额",
      "fieldtype": "Currency",
      "reqd": 1
    },
    {
      "fieldname": "transaction_type",
      "label": "交易类型",
      "fieldtype": "Select",
      "options": "收入\n支出\n转账",
      "reqd": 1
    },
    {
      "fieldname": "bank_name",
      "label": "开户银行",
      "fieldtype": "Data",
      "reqd": 1
    },
    {
      "fieldname": "receipt_pdf",
      "label": "回单PDF",
      "fieldtype": "Attach",
      "reqd": 1,
      "description": "上传电子回单PDF"
    },
    {
      "fieldname": "status",
      "label": "状态",
      "fieldtype": "Select",
      "options": "待入账\n已入账\n已归档",
      "default": "待入账",
      "reqd": 1
    },
    {
      "fieldname": "booking_date",
      "label": "入账日期",
      "fieldtype": "Date",
      "depends_on": "eval:doc.status==\"已入账\""
    },
    {
      "fieldname": "archiving_date",
      "label": "归档日期",
      "fieldtype": "Date",
      "depends_on": "eval:doc.status==\"已归档\""
    },
    {
      "fieldname": "linked_transaction",
      "label": "关联交易",
      "fieldtype": "Dynamic Link",
      "options": "DocType",
      "description": "关联凭证、发票等交易记录"
    }
  ],
  "indexes": [["receipt_number", "unique", 1]],
  "permissions": [
    {"role": "Accounts User", "read": 1},
    {"role": "Accounts Manager", "write": 1, "create": 1, "delete": 1}
  ],
  "menu_label": "电子回单",
  "route": "Electronic Receipt",
  "has_quick_entry": 0
}
  1. 电子回单入账归档逻辑

regional/china/doctype/ereceipt/ereceipt.py

# 电子回单入账归档处理
from __future__ import unicode_literals
import frappe
from frappe import _
from frappe.utils import nowdate, get_files_path
import os
import shutil
from erpnext.accounts.doctype.journal_entry.journal_entry import JournalEntry

class ElectronicReceipt(frappe.Document):
    def before_save(self):
        # 自动验证交易类型与金额方向
        if self.transaction_type == "支出" and self.amount < 0:
            self.amount = abs(self.amount)
        elif self.transaction_type == "收入" and self.amount > 0:
            self.amount = abs(self.amount)

    def on_submit(self):
        # 提交时自动入账
        self.book_receipt()

    def book_receipt(self):
        """电子回单入账处理"""
        if self.status != "待入账":
            frappe.throw(_("仅待入账状态的回单可执行入账操作"))

        # 1. 验证回单唯一性
        existing_receipt = frappe.db.exists(
            "Electronic Receipt", {"receipt_number": self.receipt_number}
        )
        if existing_receipt and existing_receipt != self.name:
            frappe.throw(_("该回单已存在于系统中,请勿重复入账"))

        # 2. 关联交易记录(如有)
        if self.linked_transaction and self.linked_transaction_doctype:
            self.validate_linked_transaction()

        # 3. 创建会计凭证(简化示例,实际需根据科目配置生成)
        if self.transaction_type == "收入":
            self.create_income_jv()
        else:
            self.create_expense_jv()

        # 4. 更新状态为"已入账"
        self.status = "已入账"
        self.booking_date = nowdate()
        self.save()

    def validate_linked_transaction(self):
        """验证关联交易金额一致性"""
        if self.linked_transaction_doctype in ["Sales Invoice", "Purchase Invoice", "Journal Entry"]:
            transaction = frappe.get_doc(self.linked_transaction_doctype, self.linked_transaction)
            if hasattr(transaction, "grand_total") and self.amount != transaction.grand_total:
                frappe.throw(_("回单金额与关联交易金额不一致,请检查"))

    def create_income_jv(self):
        """生成收入类会计凭证"""
        jv = frappe.new_doc("Journal Entry")
        jv.voucher_type = "Bank Entry"
        jv.posting_date = self.transaction_date
        jv.company = frappe.defaults.get_defaults().company

        # 银行科目(借)
        bank_account = frappe.db.get_value(
            "Bank Account", {"account_number": self.account_number}, "account"
        )
        if not bank_account:
            frappe.throw(_("未找到对应的银行科目,请先配置"))

        jv.append("accounts", {
            "account": bank_account,
            "debit": self.amount,
            "debit_in_account_currency": self.amount,
            "reference_type": "Electronic Receipt",
            "reference_name": self.name
        })

        # 收入科目(贷)- 实际应用中应通过科目映射表获取
        income_account = frappe.db.get_value(
            "Company", frappe.defaults.get_defaults().company, "default_income_account"
        )
        jv.append("accounts", {
            "account": income_account,
            "credit": self.amount,
            "credit_in_account_currency": self.amount,
            "reference_type": "Electronic Receipt",
            "reference_name": self.name
        })

        jv.save(ignore_permissions=True)
        jv.submit()

        # 关联凭证
        self.linked_transaction = jv.name
        self.linked_transaction_doctype = "Journal Entry"

    def create_expense_jv(self):
        """生成支出类会计凭证(逻辑类似收入,略)"""
        pass

    def archive_receipt(self):
        """电子回单归档处理"""
        if self.status != "已入账":
            frappe.throw(_("仅已入账状态的回单可执行归档操作"))

        # 1. 生成归档路径
        archive_path = os.path.join(
            get_files_path(), "e_receipt_archive",
            self.transaction_date.split("-")[0],
            self.transaction_date.split("-")[1]
        )
        os.makedirs(archive_path, exist_ok=True)

        # 2. 复制回单文件到归档目录
        if self.receipt_pdf:
            file_doc = frappe.get_doc("File", {"attached_to_name": self.name, "attached_to_field": "receipt_pdf"})
            if file_doc:
                source_file = os.path.join(get_files_path(), file_doc.file_url.lstrip("/"))
                target_file = os.path.join(
                    archive_path, 
                    f"{self.receipt_number}_{self.transaction_date.replace('-', '')}.pdf"
                )
                shutil.copy2(source_file, target_file)

        # 3. 更新状态为"已归档"
        self.status = "已归档"
        self.archiving_date = nowdate()
        self.save()

二、电子对账单月结对账模块

  1. 电子对账单文档类型

regional/china/doctype/estatement/estatement.doctype.json

{
  "name": "Electronic Statement",
  "module": "ERPNext",
  "doctype": "Electronic Statement",
  "is_table": 0,
  "fields": [
    {
      "fieldname": "statement_month",
      "label": "对账单月份",
      "fieldtype": "Date",
      "reqd": 1,
      "format": "YYYY-MM",
      "default": frappe.utils.nowdate().split("-")[0] + "-" + frappe.utils.nowdate().split("-")[1]
    },
    {
      "fieldname": "account_number",
      "label": "银行账号",
      "fieldtype": "Data",
      "reqd": 1
    },
    {
      "fieldname": "bank_name",
      "label": "开户银行",
      "fieldtype": "Data",
      "reqd": 1
    },
    {
      "fieldname": "beginning_balance",
      "label": "期初余额",
      "fieldtype": "Currency",
      "reqd": 1
    },
    {
      "fieldname": "ending_balance",
      "label": "期末余额",
      "fieldtype": "Currency",
      "reqd": 1
    },
    {
      "fieldname": "statement_pdf",
      "label": "对账单PDF",
      "fieldtype": "Attach",
      "reqd": 1
    },
    {
      "fieldname": "reconciliation_status",
      "label": "对账状态",
      "fieldtype": "Select",
      "options": "未对账\n对账中\n已对账\n对账不平",
      "default": "未对账",
      "reqd": 1
    },
    {
      "fieldname": "reconciliation_date",
      "label": "对账日期",
      "fieldtype": "Date",
      "depends_on": "eval:doc.reconciliation_status!=\"未对账\""
    },
    {
      "fieldname": "reconciliation_notes",
      "label": "对账说明",
      "fieldtype": "Small Text"
    },
    {
      "fieldname": "statement_transactions",
      "label": "对账单交易明细",
      "fieldtype": "Table",
      "options": "E Statement Transaction"
    }
  ],
  "indexes": [["account_number", "statement_month", "unique", 1]],
  "permissions": [
    {"role": "Accounts User", "read": 1},
    {"role": "Accounts Manager", "write": 1, "create": 1, "delete": 1}
  ],
  "menu_label": "电子对账单",
  "route": "Electronic Statement",
  "has_quick_entry": 0
}
  1. 对账单交易明细子表

regional/china/doctype/estatementtransaction/estatementtransaction.doctype.json

{
  "name": "E Statement Transaction",
  "module": "ERPNext",
  "doctype": "E Statement Transaction",
  "is_table": 1,
  "fields": [
    {
      "fieldname": "transaction_date",
      "label": "交易日期",
      "fieldtype": "Date",
      "reqd": 1
    },
    {
      "fieldname": "transaction_number",
      "label": "交易流水号",
      "fieldtype": "Data"
    },
    {
      "fieldname": "counterparty_name",
      "label": "对方户名",
      "fieldtype": "Data"
    },
    {
      "fieldname": "amount",
      "label": "金额",
      "fieldtype": "Currency",
      "reqd": 1
    },
    {
      "fieldname": "transaction_type",
      "label": "交易类型",
      "fieldtype": "Data"
    },
    {
      "fieldname": "matched_with",
      "label": "匹配记录",
      "fieldtype": "Dynamic Link",
      "options": "DocType",
      "description": "关联系统中的交易记录"
    },
    {
      "fieldname": "is_matched",
      "label": "是否匹配",
      "fieldtype": "Check",
      "default": 0
    }
  ]
}
  1. 月结对账核心逻辑

regional/china/doctype/estatement/estatement.py

# 电子对账单月结对账处理
from __future__ import unicode_literals
import frappe
from frappe import _
from frappe.utils import add_months, nowdate
from erpnext.accounts.doctype.bank_reconciliation.bank_reconciliation import BankReconciliation
from erpnext.accounts.doctype.bank_account.bank_account import get_bank_account

class ElectronicStatement(frappe.Document):
    def before_save(self):
        # 自动计算期间发生额
        self.calculate_period_amounts()

    def calculate_period_amounts(self):
        """计算对账单期间收入/支出总额"""
        if not self.statement_transactions:
            return

        income = sum(t.amount for t in self.statement_transactions if t.amount > 0)
        expense = sum(abs(t.amount) for t in self.statement_transactions if t.amount < 0)

        self.total_income = income
        self.total_expense = expense

        # 验证余额计算正确性
        expected_ending_balance = self.beginning_balance + income - expense
        if abs(expected_ending_balance - self.ending_balance) > 0.01:
            frappe.msgprint(_("对账单余额计算不一致,请检查明细"), alert=True)

    def reconcile_statement(self):
        """执行对账单自动对账"""
        if self.reconciliation_status != "未对账":
            frappe.throw(_("仅未对账状态的对账单可执行对账操作"))

        self.reconciliation_status = "对账中"
        self.save()

        matched_count = 0
        unmatched_transactions = []

        # 1. 遍历对账单交易明细
        for trans in self.statement_transactions:
            if trans.is_matched:
                continue

            # 2. 查找系统中匹配的交易记录(按金额+日期+对方户名)
            match = self.find_matching_transaction(trans)
            if match:
                # 3. 标记为已匹配
                trans.matched_with = match.name
                trans.matched_with_doctype = match.doctype
                trans.is_matched = 1
                matched_count += 1
            else:
                unmatched_transactions.append({
                    "date": trans.transaction_date,
                    "amount": trans.amount,
                    "counterparty": trans.counterparty_name
                })

        # 4. 更新对账状态
        if not unmatched_transactions:
            self.reconciliation_status = "已对账"
        else:
            self.reconciliation_status = "对账不平"

        self.reconciliation_date = nowdate()
        self.save()

        # 5. 生成对账报告
        report = f"对账完成: {nowdate()}\n总交易数: {len(self.statement_transactions)}\n已匹配: {matched_count}\n未匹配: {len(unmatched_transactions)}"
        if unmatched_transactions:
            report += "\n未匹配明细:\n" + "\n".join([
                f"日期: {t['date']}, 金额: {t['amount']}, 对方: {t['counterparty']}" 
                for t in unmatched_transactions
            ])

        frappe.log_error(message=report, title="电子对账单对账报告")
        return report

    def find_matching_transaction(self, trans):
        """查找匹配的系统交易记录"""
        # 简化匹配逻辑:按金额、日期、对方户名模糊匹配
        # 实际应用中可增加流水号、账号等精确匹配条件

        # 收入交易匹配
        if trans.amount > 0:
            match = frappe.db.sql("""
                SELECT name, 'Sales Invoice' as doctype
                FROM `tab
Discard
Save
Review Changes ← Back to Content
Message Status Space Raised By Last update on