在多个软件对账的时候需要看到特定期间的科目账目变动统计情况,而GnuCash官方并未提供类似报表,故进行报表设计并使用WorkBuddy实现,经人工测试,该报表在GnuCash 5.16显示正常,其他版本未测试。

该报表每行包含单个科目的科目代码、科目名称、期初余额、当期借合计、当期贷合计、期末余额,如有需要请自取。

报表文件

文件名称:account-change-summary.scm

;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; 科目余额变动表  /  Account Change Summary
;; 
;; 一个符合 GnuCash 报表规范的自定义报表(适配 GnuCash 5.x,已在 5.16 验证 API)。
;; 针对用户在选项中选定的【会计期间】与【科目】,逐科目汇总:
;;   期初余额 (opening balance)    —— 期间开始前该科目的余额
;;   当期借方 (period debit)       —— 期间内该科目的借方发生额
;;   当期贷方 (period credit)      —— 期间内该科目的贷方发生额
;;   期末余额 (closing balance)    —— 期初余额 + 当期借 - 当期贷(按科目性质自动处理)
;;
;; 会计恒等式(自动满足):
;;   期末余额 = 期初余额 + 当期借 - 当期贷
;; (资产/费用类科目“借”为增加;负债/权益/收入类科目“贷”为增加,报表已据此正确归类)
;;
;; 与早期版本相比,本版使用的均为 GnuCash 5.16 仍然支持且推荐的封装:
;;   - gnc:options-add-date-interval!              (会计期间起止日)
;;   - gnc-register-account-list-option            (科目多选,5.16 推荐直接注册)
;;   - gnc:make-commodity-collector + 'getmonetary (按货币汇总,5.x 惯用取值方式)
;;   - xaccAccountGetSplitList / xaccSplitGetAmount / xaccTransGetDate
;;                                            (稳定的引擎层封装,用于拆出借/贷)
;;   - qof-print-date                             (5.16 内置报表实际使用的日期格式化函数;
;;                                                 注意 gnc-print-date 在 5.16 并不存在,勿用)
;;
;; copyright 2026 ocsxxi (with WorkBuddy in Hy3 & Hy4preview 2026.10.1 11:07)
;;
;; This program is free software; you can redistribute it and/or    
;; modify it under the terms of the GNU General Public License as   
;; published by the Free Software Foundation; either version 2 of   
;; the License, or (at your option) any later version.              
;;                                                                  
;; This program is distributed in the hope that it will be useful,  
;; but WITHOUT ANY WARRANTY; without even the implied warranty of   
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the    
;; GNU General Public License for more details.                     
;;                                                                  
;; You should have received a copy of the GNU General Public License
;; along with this program; if not, contact:
;;
;; Free Software Foundation           Voice:  +1-617-542-5942
;; 51 Franklin Street, Fifth Floor    Fax:    +1-617-542-2652
;; Boston, MA  02110-1301,  USA       gnu@gnu.org
;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

(define-module (gnucash reports custom account-change-summary))
(use-modules (gnucash engine))
(use-modules (gnucash utilities))
(use-modules (gnucash core-utils))
(use-modules (gnucash app-utils))
(use-modules (gnucash report))
(use-modules (gnucash html))
(use-modules (ice-9 format))
(use-modules (srfi srfi-1))

;; ---- 选项名(字符串) ----
(define optname-startdate      (N_ "Start Date"))
(define optname-enddate        (N_ "End Date"))
(define optname-accounts       (N_ "Accounts"))
(define optname-show-full-name (N_ "Show full account name"))
(define optname-only-active    (N_ "Only show accounts with activity"))

;; ---- 辅助函数 ----

;; 判断科目类型是否为“贷正常”(负债 / 权益 / 收入及其子类)
(define (credit-normal-type? type)
  (or (eq? type ACCT-TYPE-LIABILITY)
      (eq? type ACCT-TYPE-CREDIT)
      (eq? type ACCT-TYPE-PAYABLE)
      (eq? type ACCT-TYPE-EQUITY)
      (eq? type ACCT-TYPE-INCOME)))

;; gnc-numeric 是否为负(5.16 用 gnc-numeric-negative-p,注意不是 gnc:gnc-numeric-num)
(define (amount-negative? amt)
  (gnc-numeric-negative-p amt))

;; 取 commodity-collector 中某货币的金额对象(gnc-monetary);
;; 5.16 的 'getmonetary 签名为 (getmonetary 商品 sign?),sign?=#f 表示不取负。
;; 若该货币未记录,getmonetary 自动返回该货币下的零金额对象,无需额外兜底。
(define (collector-monetary coll comm)
  (coll 'getmonetary comm #f))

;; 生成货币对象;若金额为负则取相反数,保证“借/贷”列始终为非负发生额
(define (non-negative-monetary comm amt)
  (let ((m (gnc:make-gnc-monetary comm amt)))
    (if (amount-negative? amt)
        (gnc:monetary-neg m)
        m)))

;; 取得账套中的所有科目(兜底:出错时返回空表,由渲染器提示用户手动选择)
(define (get-all-accounts)
  (catch #t
    (lambda ()
      (gnc-account-get-descendants
        (gnc-book-get-root-account (gnc-get-current-book))))
    (lambda (key . args) '())))

;; 取得科目编码;GnuCash 允许科目不设编码(此时返回 ""),统一兜底为空字符串,
;; 避免 #f 参与字符串比较导致排序/显示报错
(define (account-code account)
  (let ((c (xaccAccountGetCode account)))
    (if c c "")))

;; 字符串升序比较:从左向右逐字符比较字符码值(char->integer),
;; 遇到第一个不同字符即定序;若一个是另一个的前缀,则较短者排在前。
;; 例:"1001" < "1001.01" < "1002",且 "1000" 排在 "999" 之前(首位 '1' < '9')。
(define (string-code<? a b)
  (let ((la (string-length a))
        (lb (string-length b)))
    (let loop ((i 0))
      (cond
        ((and (>= i la) (>= i lb)) #f)  ; 完全相同:a 不小于 b
        ((>= i la) #t)                  ; a 是 b 的前缀:短者在前
        ((>= i lb) #f)                  ; b 是 a 的前缀
        (else
          (let ((va (char->integer (string-ref a i)))
                (vb (char->integer (string-ref b i))))
            (cond ((< va vb) #t)
                  ((> va vb) #f)
                  (else (loop (+ i 1))))))))))

;; 科目编码升序比较:编码一律按字符串处理,逐字符比较(不做数值转换)
(define (account-code<? a b)
  (string-code<? (account-code a) (account-code b)))

;; ---- 核心计算:单个科目的四列数据 ----
(define (compute-account-row account start-date end-date)
  (let* ((comm  (xaccAccountGetCommodity account))
         (type  (xaccAccountGetType account))
         (open-coll  (gnc:make-commodity-collector))
         (debit-coll (gnc:make-commodity-collector))
         (credit-coll (gnc:make-commodity-collector))
         (close-coll (gnc:make-commodity-collector)))
    (for-each
      (lambda (split)
        (let* ((txn  (xaccSplitGetParent split))
               (date (xaccTransGetDate txn))
               (amt  (xaccSplitGetAmount split)))
          (cond
            ;; 期间之前的凭证 -> 计入期初(同时计入期末累计)
            ((< date start-date)
             (open-coll  'add comm amt)
             (close-coll 'add comm amt))
            ;; 期间之内(含起止日)-> 拆为借/贷,并计入期末累计
            ((<= date end-date)
             (let* ((neg?   (amount-negative? amt))
                   (debit? (if (credit-normal-type? type) neg? (not neg?))))
               (if debit?
                   (debit-coll 'add comm amt)
                   (credit-coll 'add comm amt))
               (close-coll 'add comm amt)))
            (else #f))))                      ; 期间之后 -> 忽略
      (xaccAccountGetSplitList account))
    ;; 返回:(货币 期初 当期借 当期贷 期末 科目),均为 gnc-monetary 对象;末尾附科目便于渲染
    (list comm
          (collector-monetary open-coll comm)
          (non-negative-monetary comm
            (gnc:gnc-monetary-amount (collector-monetary debit-coll comm)))
          (non-negative-monetary comm
            (gnc:gnc-monetary-amount (collector-monetary credit-coll comm)))
          (collector-monetary close-coll comm)
          account)))

;; ---- 选项生成器 ----
(define (options-generator)
  (let ((options (gnc-new-optiondb)))
    ;; 会计期间(起止日)—— 5.x 推荐封装
    (gnc:options-add-date-interval!
      options gnc:pagename-general optname-startdate optname-enddate "a")
    ;; 科目选择
    (gnc-register-account-list-option
      options
      gnc:pagename-accounts optname-accounts "a"
      (N_ "Select the accounts to include in the report. Leave as-is to include all accounts.")
      (get-all-accounts))
    ;; 显示选项
    (gnc-register-simple-boolean-option
      options
      gnc:pagename-display optname-show-full-name "a"
      (N_ "Show the full account name (including parent accounts).") #t)
    (gnc-register-simple-boolean-option
      options
      gnc:pagename-display optname-only-active "b"
      (N_ "Only show accounts that had debit or credit activity in the period.")
      #f)
    (gnc:options-set-default-section options gnc:pagename-general)
    options))

;; ---- 渲染器 ----
(define (renderer report-obj)
  (define (get-op section name)
    (gnc:option-value (gnc:lookup-option (gnc:report-options report-obj) section name)))
  (let* ((start-date (gnc:time64-start-day-time
                      (gnc:date-option-absolute-time
                        (get-op gnc:pagename-general optname-startdate))))
         (end-date   (gnc:time64-end-day-time
                      (gnc:date-option-absolute-time
                        (get-op gnc:pagename-general optname-enddate))))
         (selected   (get-op gnc:pagename-accounts optname-accounts))
         (show-full? (get-op gnc:pagename-display optname-show-full-name))
         (only-active? (get-op gnc:pagename-display optname-only-active))
         (accounts   (if (null? selected) (get-all-accounts) selected))
         (document   (gnc:make-html-document)))

    (gnc:html-document-set-title!
      document (_ "科目余额变动表 / Account Change Summary"))
    (gnc:html-document-add-object!
      document
      (gnc:make-html-text
        (gnc:html-markup-p
          (format #f (_ "会计期间:~a 至 ~a    所选科目数:~d")
                  (qof-print-date start-date)
                  (qof-print-date end-date)
                  (length accounts)))))

    (if (null? accounts)
        ;; 未选择任何科目时的提示
        (gnc:html-document-add-object!
          document
          (gnc:make-html-text
            (gnc:html-markup-p
              (_ "未选择任何科目。请在报表选项的“科目”页中选择需要统计的科目,"
                 "或保持默认以统计全部科目。"))))
        ;; 正常构建表格
        (let* ((sorted (sort accounts
                             (lambda (a b) (account-code<? a b))))
               (rows   (map (lambda (a) (compute-account-row a start-date end-date))
                            sorted))
               (g-open   (gnc:make-commodity-collector))
               (g-debit  (gnc:make-commodity-collector))
               (g-credit (gnc:make-commodity-collector))
               (g-close  (gnc:make-commodity-collector))
               (comm-list '()))
          ;; 累计合计,并收集出现的不同货币
          (for-each
            (lambda (row)
              (let ((comm  (list-ref row 0))
                    (open  (list-ref row 1))
                    (deb   (list-ref row 2))
                    (cred  (list-ref row 3))
                    (close (list-ref row 4)))
                (g-open   'add comm (gnc:gnc-monetary-amount open))
                (g-debit  'add comm (gnc:gnc-monetary-amount deb))
                (g-credit 'add comm (gnc:gnc-monetary-amount cred))
                (g-close  'add comm (gnc:gnc-monetary-amount close))
                (if (not (any (lambda (c)
                                (string=? (gnc-commodity-get-mnemonic c)
                                          (gnc-commodity-get-mnemonic comm)))
                              comm-list))
                    (set! comm-list (cons comm comm-list)))))
            rows)

          (let ((table (gnc:make-html-table)))
            (gnc:html-table-set-col-headers!
              table
              (list (_ "科目编码")
                    (_ "科目")
                    (_ "期初余额")
                    (_ "当期借方")
                    (_ "当期贷方")
                    (_ "期末余额")))

            ;; 逐科目明细行
            (for-each
              (lambda (row)
                (let ((account (list-ref row 5))
                      (comm  (list-ref row 0))
                      (open  (list-ref row 1))
                      (deb   (list-ref row 2))
                      (cred  (list-ref row 3))
                      (close (list-ref row 4)))
                  (let ((deb-amt  (gnc:gnc-monetary-amount deb))
                        (cred-amt (gnc:gnc-monetary-amount cred)))
                    (when (or (not only-active?)
                              (not (and (gnc-numeric-zero-p deb-amt)
                                        (gnc-numeric-zero-p cred-amt))))
                      (gnc:html-table-append-row!
                        table
                        (list
                          (account-code account)
                          (if show-full?
                              (gnc-account-get-full-name account)
                              (xaccAccountGetName account))
                          (gnc:make-html-table-cell/markup "number-cell" open)
                          (gnc:make-html-table-cell/markup "number-cell" deb)
                          (gnc:make-html-table-cell/markup "number-cell" cred)
                          (gnc:make-html-table-cell/markup "number-cell" close)))))))
              rows)

            ;; 合计行(按货币分别汇总;单一货币时仅一行“合计”)
            (for-each
              (lambda (comm)
                (let ((open  (collector-monetary g-open comm))
                      (deb   (non-negative-monetary comm
                              (gnc:gnc-monetary-amount (collector-monetary g-debit comm))))
                      (cred  (non-negative-monetary comm
                              (gnc:gnc-monetary-amount (collector-monetary g-credit comm))))
                      (close (collector-monetary g-close comm))
                      (label (if (= (length comm-list) 1)
                                 (_ "合计")
                                 (format #f (_ "合计 (~a)")
                                         (gnc-commodity-get-mnemonic comm)))))
                  (gnc:html-table-append-row!
                    table
                    (list
                      ""
                      label
                      (gnc:make-html-table-cell/markup "number-cell" open)
                      (gnc:make-html-table-cell/markup "number-cell" deb)
                      (gnc:make-html-table-cell/markup "number-cell" cred)
                      (gnc:make-html-table-cell/markup "number-cell" close)))))
              comm-list)

            (gnc:html-document-add-object! document table))))

    document))

;; ---- 注册报表 ----
;; 注意:report-guid 必须为 32 位十六进制、不含连字符。
;; 建议用 GnuCash 自带 gnucash-make-guids 重新生成一个唯一 GUID 以防与其它报表冲突。
(gnc:define-report
  'version 1
  'name (N_ "科目余额变动表 / Account Change Summary")
  'report-guid "7e922f52d2b74401970da02a0ca91052"
  'menu-tip (N_ "Select an accounting period and accounts to summarise opening balance, period debits, period credits and closing balance.")
  'menu-path (list gnc:menuname-example)
  'options-generator options-generator
  'renderer renderer)

安装方法

  1. 将此文件复制到 GnuCash 用户数据目录: Linux : ~/.local/share/gnucash/ Windows : %APPDATA%\GnuCash\ macOS : ~/Library/Application Support/GnuCash/

  2. 在同目录下 config-user.scm 中追加一行(若该文件不存在,新建一个即可,内容如下):

文件:config-user.scm

(load (gnc-build-userdata-path "account-change-summary.scm"))
  1. 重启 GnuCash。报表出现在:报表 -> 示例 -> 科目余额变动表