"""完整财务 services: 第一部分:业务往来管理(应收、应付、收款、付款、核销) 第二部分:总账系统(科目表、会计期间、凭证管理、借贷校验、过账与反过账) 第三部分:业财一体化自动凭证(销售应收/销售成本、采购应付、收款冲应收、付款冲应付) 第四部分:期末处理(损益结转、结账、反结账) 第五部分:财务报表(科目余额表、资产负债表、利润表、明细分类账) """ from __future__ import annotations from datetime import date, datetime, timezone, timedelta from decimal import Decimal from typing import Iterable, Optional from django.db import transaction from django.db.models import Sum, Q, F from .models import ( Account, Period, Voucher, VoucherEntry, Receivable, Payable, Receipt, Payment, Allocation, ) # ============================================================ # 0. 辅助工具与异常 # ============================================================ class VoucherError(Exception): """凭证与财务业务异常。""" pass def _last_day_of_month(d: date) -> date: """计算当月最后一天。""" if d.month == 12: return d.replace(day=31) return d.replace(month=d.month + 1, day=1) - timedelta(days=1) def is_chart_ready(tenant) -> bool: """检查租户是否已初始化基础会计科目。""" required_codes = ["1001", "1002", "1122", "1403", "2202", "4103", "6001", "6401"] existing = Account.objects.filter(tenant=tenant, code__in=required_codes, is_active=True).count() return existing >= len(required_codes) # ============================================================ # 1. 业务往来服务(应收 / 应付 / 收款 / 付款 / 核销) # ============================================================ def _generate_ar_ap_no(tenant, prefix: str, bill_date: date) -> str: """取候选应收/应付单号,如 RC202609070001。 唯一性由调用方的 `create_with_unique_bill_no` 重试保证。 """ from apps.core.services import next_bill_no Model = Receivable if prefix == "RC" else Payable return next_bill_no(tenant, prefix, Model, date_str=bill_date.strftime("%Y%m%d")) @transaction.atomic def create_receivable_from_sale( *, tenant, customer, total_amount: Decimal, source_ref: str = "", bill_date: Optional[date] = None, due_date: Optional[date] = None, remark: str = "", tax_amount: Decimal | None = None, # D4:销项税额(价内拆出) ) -> Receivable: """销售单过账时生成应收单。若财务科目已就绪,自动生成并过账记账凭证。""" bill_date = bill_date or date.today() bill_no = _generate_ar_ap_no(tenant, "RC", bill_date) recv = Receivable.objects.create( tenant=tenant, customer=customer, bill_no=bill_no, source_type="sale", source_ref=source_ref, bill_date=bill_date, due_date=due_date, total_amount=total_amount, paid_amount=Decimal("0"), status="open", remark=remark, ) # 业财一体化:若财务科目已就绪,自动生成记账凭证 if is_chart_ready(tenant): try: voucher_for_receivable(recv, auto_post=True, tax_amount=tax_amount) except Exception: pass # 保证主业务流程不因凭证异常中断 return recv @transaction.atomic def create_payable_from_purchase( *, tenant, supplier, total_amount: Decimal, source_ref: str = "", bill_date: Optional[date] = None, due_date: Optional[date] = None, remark: str = "", tax_amount: Decimal | None = None, # D4:进项税额(价内拆出) ) -> Payable: """采购入库时生成应付单。若财务科目已就绪,自动生成并过账记账凭证。""" bill_date = bill_date or date.today() bill_no = _generate_ar_ap_no(tenant, "PY", bill_date) pay = Payable.objects.create( tenant=tenant, supplier=supplier, bill_no=bill_no, source_type="purchase", source_ref=source_ref, bill_date=bill_date, due_date=due_date, total_amount=total_amount, paid_amount=Decimal("0"), status="open", remark=remark, ) if is_chart_ready(tenant): try: voucher_for_payable(pay, auto_post=True, tax_amount=tax_amount) except Exception: pass return pay def _validate_receipt_allocation_party(receipt: Receipt, receivable: Receivable) -> None: """核销前确保收款单、应收单及客户处于同一租户且客户一致。""" if receipt.tenant_id != receivable.tenant_id: raise ValueError("收款单与应收单必须属于同一租户") if receipt.customer_id != receivable.customer_id: raise ValueError("收款单客户与应收单客户不一致") def _validate_payment_allocation_party(payment: Payment, payable: Payable) -> None: """核销前确保付款单、应付单及供应商处于同一租户且供应商一致。""" if payment.tenant_id != payable.tenant_id: raise ValueError("付款单与应付单必须属于同一租户") if payment.supplier_id != payable.supplier_id: raise ValueError("付款单供应商与应付单供应商不一致") @transaction.atomic def allocate_receipt( receipt: Receipt, allocations: list[tuple[Receivable, Decimal]], ) -> list[Allocation]: """收款单核销:核销一张或多张同租户、同客户的应收单。""" if not allocations: raise ValueError("核销列表不能为空") # 锁定收款单,避免并发核销使用过期金额或状态。 receipt = Receipt.objects.select_for_update().get(pk=receipt.pk) for recv, _ in allocations: _validate_receipt_allocation_party(receipt, recv) total_alloc = sum(Decimal(str(amt)) for _, amt in allocations) if total_alloc != receipt.amount: raise ValueError( f"核销总金额 ({total_alloc}) 与收款单金额 ({receipt.amount}) 不一致" ) created_allocations = [] for recv, amt in allocations: amt = Decimal(str(amt)) if amt <= 0: raise ValueError("核销金额必须大于 0") # 重新取回并锁定应收单,同时再次检查租户/客户(防止对象在事务外被替换)。 recv = Receivable.objects.select_for_update().get(pk=recv.pk) _validate_receipt_allocation_party(receipt, recv) if amt > recv.balance: raise ValueError( f"核销金额 {amt} 超过应收单 {recv.bill_no} 剩余未结金额 {recv.balance}" ) alloc = Allocation.objects.create( tenant_id=receipt.tenant_id, receipt=receipt, receivable=recv, kind="receipt", amount=amt, ) created_allocations.append(alloc) recv.paid_amount += amt recv.status = "paid" if recv.paid_amount >= recv.total_amount else "partial" recv.save(update_fields=["paid_amount", "status", "updated_at"]) receipt.status = "posted" receipt.save(update_fields=["status", "updated_at"]) if is_chart_ready(receipt.tenant): try: voucher_for_receipt(receipt, auto_post=True) except Exception: pass return created_allocations @transaction.atomic def allocate_payment( payment: Payment, allocations: list[tuple[Payable, Decimal]], ) -> list[Allocation]: """付款单核销:核销一张或多张同租户、同供应商的应付单。""" if not allocations: raise ValueError("核销列表不能为空") payment = Payment.objects.select_for_update().get(pk=payment.pk) for pay, _ in allocations: _validate_payment_allocation_party(payment, pay) total_alloc = sum(Decimal(str(amt)) for _, amt in allocations) if total_alloc != payment.amount: raise ValueError( f"核销总金额 ({total_alloc}) 与付款单金额 ({payment.amount}) 不一致" ) created_allocations = [] for pay, amt in allocations: amt = Decimal(str(amt)) if amt <= 0: raise ValueError("核销金额必须大于 0") pay = Payable.objects.select_for_update().get(pk=pay.pk) _validate_payment_allocation_party(payment, pay) if amt > pay.balance: raise ValueError( f"核销金额 {amt} 超过应付单 {pay.bill_no} 剩余未结金额 {pay.balance}" ) alloc = Allocation.objects.create( tenant_id=payment.tenant_id, payment=payment, payable=pay, kind="payment", amount=amt, ) created_allocations.append(alloc) pay.paid_amount += amt pay.status = "paid" if pay.paid_amount >= pay.total_amount else "partial" pay.save(update_fields=["paid_amount", "status", "updated_at"]) payment.status = "posted" payment.save(update_fields=["status", "updated_at"]) if is_chart_ready(payment.tenant): try: voucher_for_payment(payment, auto_post=True) except Exception: pass return created_allocations # ============================================================ # 2. 会计科目与期间管理 # ============================================================ DEFAULT_CHART = [ # (code, name, category, balance_type, aux_customer, aux_supplier) # --- 资产类 (借方正常余额) --- ("1001", "现金", "asset", "debit", False, False), ("1002", "银行存款", "asset", "debit", False, False), ("1122", "应收账款", "asset", "debit", True, False), ("1403", "库存商品", "asset", "debit", False, False), ("1405", "在途物资", "asset", "debit", False, True), # --- 负债类 (贷方正常余额) --- ("2202", "应付账款", "liability", "credit", False, True), ("2211", "应付职工薪酬", "liability", "credit", False, False), ("2221", "应交税费-销项", "liability", "credit", False, False), ("2222", "应交税费-进项", "liability", "credit", False, False), # --- 所有者权益 (贷方正常余额) --- ("4001", "实收资本", "equity", "credit", False, False), ("4002", "资本公积", "equity", "credit", False, False), ("4101", "盈余公积", "equity", "credit", False, False), ("4103", "本年利润", "equity", "credit", False, False), ("4104", "利润分配", "equity", "credit", False, False), # --- 收入类 (贷方发生额) --- ("6001", "主营业务收入", "revenue", "credit", False, False), ("6051", "其他业务收入", "revenue", "credit", False, False), ("6301", "营业外收入", "revenue", "credit", False, False), # --- 费用/成本类 (借方发生额) --- ("6401", "主营业务成本", "expense", "debit", False, False), ("6601", "销售费用", "expense", "debit", False, False), ("6602", "管理费用", "expense", "debit", False, False), ("6603", "财务费用", "expense", "debit", False, False), ("6711", "营业外支出", "expense", "debit", False, False), ] @transaction.atomic def init_chart_of_accounts(tenant) -> int: """初始化预置会计科目。幂等操作,返回本次新增数量。""" created_count = 0 for code, name, cat, bal, aux_c, aux_s in DEFAULT_CHART: obj, was_created = Account.objects.get_or_create( tenant=tenant, code=code, defaults={ "name": name, "category": cat, "balance_type": bal, "is_system": True, "aux_customer": aux_c, "aux_supplier": aux_s, }, ) if was_created: created_count += 1 return created_count def get_account(tenant, code: str) -> Account: """根据编码获取租户的科目。""" return Account.objects.get(tenant=tenant, code=code, is_active=True) def period_of(tenant, day: date) -> Period: """获取指定日期所在的会计期间(若不存在则自动按月创建)。""" code = day.strftime("%Y-%m") period, _ = Period.objects.get_or_create( tenant=tenant, code=code, defaults={ "start_date": day.replace(day=1), "end_date": _last_day_of_month(day), "status": "open", }, ) return period def current_period(tenant) -> Period: """获取当天所在期间。""" return period_of(tenant, date.today()) def ensure_periods(tenant, from_month: date, to_month: date) -> int: """批量初始化或补齐期间范围。""" created = 0 cur = from_month.replace(day=1) end = to_month.replace(day=1) while cur <= end: p, was = Period.objects.get_or_create( tenant=tenant, code=cur.strftime("%Y-%m"), defaults={ "start_date": cur, "end_date": _last_day_of_month(cur), "status": "open", }, ) if was: created += 1 if cur.month == 12: cur = cur.replace(year=cur.year + 1, month=1) else: cur = cur.replace(month=cur.month + 1) return created # ============================================================ # 3. 凭证核心服务(录入、校验、过账、作废) # ============================================================ def next_voucher_no(tenant, period: Period) -> str: """生成下一个凭证号:V{YYYYMM}{seq:04d}。按已有最大编号自增,防止删除产生重复。""" prefix = f"V{period.code.replace('-', '')}" latest = ( Voucher.objects.filter(tenant=tenant, bill_no__startswith=prefix) .order_by("-bill_no") .first() ) if latest and latest.bill_no[len(prefix):].isdigit(): seq = int(latest.bill_no[len(prefix):]) + 1 else: seq = Voucher.objects.filter(tenant=tenant, period=period).count() + 1 return f"{prefix}{seq:04d}" @transaction.atomic def create_voucher( *, tenant, voucher_date: date, entries: list[dict], source_type: str = "manual", source_ref: str = "", summary: str = "", bill_no: str = "", user=None, auto_post: bool = False, ) -> Voucher: """创建记账凭证(支持 draft 或直接 auto_post)。 entries 格式示例: [ {"account_code": "1002", "debit": 1000, "credit": 0, "summary": "收到货款"}, {"account_code": "1122", "debit": 0, "credit": 1000, "summary": "冲减应收", "customer_id": 1}, ] """ if not entries or len(entries) < 2: raise VoucherError("凭证至少需要 2 条分录(借贷平衡)") # 1. 借贷平衡与数值校验 total_debit = Decimal("0") total_credit = Decimal("0") parsed_entries = [] for idx, e in enumerate(entries, 1): code = str(e.get("account_code") or "").strip() if not code: raise VoucherError(f"第 {idx} 条分录未指定会计科目") try: account = Account.objects.get(tenant=tenant, code=code, is_active=True) except Account.DoesNotExist: raise VoucherError(f"科目 {code} 不存在或已停用") debit = Decimal(str(e.get("debit", 0) or 0)) credit = Decimal(str(e.get("credit", 0) or 0)) if debit < 0 or credit < 0: raise VoucherError(f"分录 {code} 金额不能为负数") if debit > 0 and credit > 0: raise VoucherError(f"分录 {code} 借贷不能同时有值") if debit == 0 and credit == 0: raise VoucherError(f"分录 {code} 借贷不能同时为 0") # 辅助核算校验 customer_id = e.get("customer_id") or (e.get("customer").id if e.get("customer") and hasattr(e.get("customer"), "id") else None) supplier_id = e.get("supplier_id") or (e.get("supplier").id if e.get("supplier") and hasattr(e.get("supplier"), "id") else None) if account.aux_customer and not customer_id: raise VoucherError(f"科目 {account.code} ({account.name}) 需要关联客户辅助核算") if account.aux_supplier and not supplier_id: raise VoucherError(f"科目 {account.code} ({account.name}) 需要关联供应商辅助核算") total_debit += debit total_credit += credit parsed_entries.append({ "account": account, "customer_id": customer_id, "supplier_id": supplier_id, "summary": e.get("summary") or summary, "debit": debit, "credit": credit, }) if total_debit != total_credit: raise VoucherError(f"借贷不平衡:借方合计 {total_debit},贷方合计 {total_credit}") if total_debit <= 0: raise VoucherError("凭证借贷金额必须大于 0") period = period_of(tenant, voucher_date) if period.status == "closed": raise VoucherError(f"期间 {period.code} 已结账,禁止在该期间创建或修改凭证") bill_no = bill_no or next_voucher_no(tenant, period) # 凭证号并发安全:候选号 + 唯一约束重试 # (`_generate_voucher_no` 已用 MAX 而非 count,但并发下仍可能撞号) from django.db import IntegrityError, transaction voucher = None last_exc = None candidate = bill_no for _attempt in range(20): try: with transaction.atomic(): # 保存点:冲突只回滚本次尝试 voucher = Voucher.objects.create( tenant=tenant, bill_no=candidate, period=period, source_type=source_type, source_ref=source_ref, voucher_date=voucher_date, total_debit=total_debit, total_credit=total_credit, status="draft", summary=summary, created_by=user, ) break except IntegrityError as exc: last_exc = exc # 换号重试(重新按 MAX+1 取) candidate = _generate_voucher_no(tenant, period=period) continue if voucher is None: raise RuntimeError( f"生成唯一凭证号失败(尝试 20 次):period={period.code} · {last_exc}" ) for item in parsed_entries: VoucherEntry.objects.create( tenant=tenant, voucher=voucher, account=item["account"], customer_id=item["customer_id"], supplier_id=item["supplier_id"], summary=item["summary"], debit=item["debit"], credit=item["credit"], ) if auto_post: post_voucher(voucher, user=user) return voucher @transaction.atomic def post_voucher(voucher: Voucher, *, user=None) -> Voucher: """凭证过账:draft → posted。已结账期间禁止过账。""" if voucher.status != "draft": raise VoucherError(f"凭证 {voucher.bill_no} 当前状态为 {voucher.status},只能对草稿凭证过账") if voucher.period.status == "closed": raise VoucherError(f"期间 {voucher.period.code} 已结账,不能过账凭证") if not (voucher.period.start_date <= voucher.voucher_date <= voucher.period.end_date): raise VoucherError( f"凭证日期 {voucher.voucher_date} 超出期间 {voucher.period.code} 范围 ({voucher.period.start_date} ~ {voucher.period.end_date})" ) voucher.status = "posted" voucher.posted_at = datetime.now(timezone.utc) voucher.posted_by = user voucher.save(update_fields=["status", "posted_at", "posted_by", "updated_at"]) return voucher @transaction.atomic def cancel_voucher(voucher: Voucher, *, user=None) -> Voucher: """作废凭证:posted/draft → cancelled。已结账期间禁止直接作废。""" if voucher.status == "cancelled": return voucher if voucher.period.status == "closed": raise VoucherError(f"期间 {voucher.period.code} 已结账,不能作废其凭证,需先反结账") voucher.status = "cancelled" voucher.save(update_fields=["status", "updated_at"]) return voucher # ============================================================ # 4. 业财一体化自动凭证(销售 / 采购 / 收款 / 付款 / 销售成本) # ============================================================ @transaction.atomic def voucher_for_receivable(recv: Receivable, *, auto_post: bool = True, tax_amount: Decimal | None = None) -> Optional[Voucher]: """销售应收确认凭证: 借:应收账款 (1122, 挂客户) —— 含税全额 贷:主营业务收入 (6001) —— 不含税净额 贷:应交税费-销项 (2221) —— 税额(D4;tax_amount>0 时才拆) """ if not is_chart_ready(recv.tenant): return None # 幂等检查 existing = Voucher.objects.filter( tenant=recv.tenant, source_type="sale", source_ref=recv.bill_no, status__in=["draft", "posted"], ).first() if existing: return existing tax = (Decimal(str(tax_amount)) if tax_amount else Decimal("0")) if tax < 0: tax = Decimal("0") if tax > recv.total_amount: tax = recv.total_amount net = recv.total_amount - tax entries = [ { "account_code": "1122", "debit": recv.total_amount, "credit": Decimal("0"), "summary": f"销售单 {recv.source_ref or recv.bill_no} 确认应收", "customer_id": recv.customer_id, }, { "account_code": "6001", "debit": Decimal("0"), "credit": net, "summary": f"销售单 {recv.source_ref or recv.bill_no} 确认收入(不含税)", }, ] if tax > 0: entries.append({ "account_code": "2221", "debit": Decimal("0"), "credit": tax, "summary": f"销售单 {recv.source_ref or recv.bill_no} 销项税额", }) return create_voucher( tenant=recv.tenant, voucher_date=recv.bill_date, entries=entries, source_type="sale", source_ref=recv.bill_no, summary=f"销售单 {recv.source_ref or recv.bill_no} 确认", auto_post=auto_post, ) @transaction.atomic def voucher_for_sale_cogs( *, tenant, sales_bill_no: str, bill_date: date, cost_amount: Decimal, auto_post: bool = True, ) -> Optional[Voucher]: """结转销售成本凭证: 借:主营业务成本 (6401) 贷:库存商品 (1403) """ if not is_chart_ready(tenant) or cost_amount <= 0: return None existing = Voucher.objects.filter( tenant=tenant, source_type="sale_cost", source_ref=sales_bill_no, status__in=["draft", "posted"], ).first() if existing: return existing entries = [ { "account_code": "6401", "debit": cost_amount, "credit": Decimal("0"), "summary": f"销售单 {sales_bill_no} 结转销售成本", }, { "account_code": "1403", "debit": Decimal("0"), "credit": cost_amount, "summary": f"销售单 {sales_bill_no} 商品出库成本", }, ] return create_voucher( tenant=tenant, voucher_date=bill_date, entries=entries, source_type="sale_cost", source_ref=sales_bill_no, summary=f"销售单 {sales_bill_no} 结转成本", auto_post=auto_post, ) @transaction.atomic def voucher_for_payable(pay: Payable, *, auto_post: bool = True, tax_amount: Decimal | None = None) -> Optional[Voucher]: """采购进货确认凭证: 借:库存商品 (1403) —— 不含税净额 借:应交税费-进项 (2222) —— 税额(D4;tax_amount>0 时才拆) 贷:应付账款 (2202, 挂供应商) —— 含税全额 """ if not is_chart_ready(pay.tenant): return None existing = Voucher.objects.filter( tenant=pay.tenant, source_type="purchase", source_ref=pay.bill_no, status__in=["draft", "posted"], ).first() if existing: return existing tax = (Decimal(str(tax_amount)) if tax_amount else Decimal("0")) if tax < 0: tax = Decimal("0") if tax > pay.total_amount: tax = pay.total_amount net = pay.total_amount - tax entries = [ { "account_code": "1403", "debit": net, "credit": Decimal("0"), "summary": f"采购单 {pay.source_ref or pay.bill_no} 入库增加商品(不含税)", }, ] if tax > 0: entries.append({ "account_code": "2222", "debit": tax, "credit": Decimal("0"), "summary": f"采购单 {pay.source_ref or pay.bill_no} 进项税额", }) entries.append({ "account_code": "2202", "debit": Decimal("0"), "credit": pay.total_amount, "summary": f"采购单 {pay.source_ref or pay.bill_no} 应付货款", "supplier_id": pay.supplier_id, }) return create_voucher( tenant=pay.tenant, voucher_date=pay.bill_date, entries=entries, source_type="purchase", source_ref=pay.bill_no, summary=f"采购单 {pay.source_ref or pay.bill_no} 确认", auto_post=auto_post, ) @transaction.atomic def voucher_for_receipt(receipt: Receipt, *, auto_post: bool = True) -> Optional[Voucher]: """收款过账凭证: 借:银行存款 (1002) 或 现金 (1001) 贷:应收账款 (1122, 按核销明细挂各客户) """ if not is_chart_ready(receipt.tenant): return None existing = Voucher.objects.filter( tenant=receipt.tenant, source_type="receipt", source_ref=receipt.bill_no, status__in=["draft", "posted"], ).first() if existing: return existing # 根据收款方式选择科目 debit_code = "1001" if "现金" in (receipt.method or "") else "1002" entries = [ { "account_code": debit_code, "debit": receipt.amount, "credit": Decimal("0"), "summary": f"收款单 {receipt.bill_no} ({receipt.method or '银行'})", } ] allocations = receipt.allocations.select_related("receivable").all() if allocations.exists(): for alloc in allocations: entries.append({ "account_code": "1122", "debit": Decimal("0"), "credit": alloc.amount, "summary": f"收款单 {receipt.bill_no} 冲减应收 {alloc.receivable.bill_no}", "customer_id": alloc.receivable.customer_id, }) else: # 无单据核销时,直接挂在当前收款单客户下 entries.append({ "account_code": "1122", "debit": Decimal("0"), "credit": receipt.amount, "summary": f"收款单 {receipt.bill_no} 客户预收款/清账", "customer_id": receipt.customer_id, }) return create_voucher( tenant=receipt.tenant, voucher_date=receipt.bill_date, entries=entries, source_type="receipt", source_ref=receipt.bill_no, summary=f"收款单 {receipt.bill_no} 过账", auto_post=auto_post, ) @transaction.atomic def voucher_for_payment(payment: Payment, *, auto_post: bool = True) -> Optional[Voucher]: """付款过账凭证: 借:应付账款 (2202, 按核销明细挂各供应商) 贷:银行存款 (1002) 或 现金 (1001) """ if not is_chart_ready(payment.tenant): return None existing = Voucher.objects.filter( tenant=payment.tenant, source_type="payment", source_ref=payment.bill_no, status__in=["draft", "posted"], ).first() if existing: return existing credit_code = "1001" if "现金" in (payment.method or "") else "1002" entries = [] allocations = payment.allocations.select_related("payable").all() if allocations.exists(): for alloc in allocations: entries.append({ "account_code": "2202", "debit": alloc.amount, "credit": Decimal("0"), "summary": f"付款单 {payment.bill_no} 冲减应付 {alloc.payable.bill_no}", "supplier_id": alloc.payable.supplier_id, }) else: entries.append({ "account_code": "2202", "debit": payment.amount, "credit": Decimal("0"), "summary": f"付款单 {payment.bill_no} 付款预付/清账", "supplier_id": payment.supplier_id, }) entries.append({ "account_code": credit_code, "debit": Decimal("0"), "credit": payment.amount, "summary": f"付款单 {payment.bill_no} ({payment.method or '银行'})", }) return create_voucher( tenant=payment.tenant, voucher_date=payment.bill_date, entries=entries, source_type="payment", source_ref=payment.bill_no, summary=f"付款单 {payment.bill_no} 过账", auto_post=auto_post, ) # ============================================================ # 5. 期末结转损益与结账管理 # ============================================================ @transaction.atomic def close_period(period: Period, *, user=None) -> Optional[Voucher]: """期末结账(结转本期损益): 1. 将本期所有收入类科目发生额转出: 借:收入类科目 (6001, 6051, 6301) 2. 将本期所有成本/费用类科目发生额转出: 贷:成本/费用类科目 (6401, 6601, 6602, 6603, 6711) 3. 差额转入 本年利润 (4103): 若收入 > 费用 (盈利):贷记 本年利润 (4103) 若费用 > 收入 (亏损):借记 本年利润 (4103) 4. 生成结转凭证并过账 5. 将期间状态置为 closed """ if period.status == "closed": raise VoucherError(f"会计期间 {period.code} 已经是结账状态") entries = [] total_revenue_cleared = Decimal("0") total_expense_cleared = Decimal("0") # 1. 收入类:期末贷方发生额 - 借方发生额 = 净收入 revenue_accounts = Account.objects.filter( tenant=period.tenant, category="revenue", is_active=True ) for acc in revenue_accounts: agg = VoucherEntry.objects.filter( account=acc, voucher__period=period, voucher__status="posted", ).exclude(voucher__source_type="period_close").aggregate(d=Sum("debit"), c=Sum("credit")) d = agg["d"] or Decimal("0") c = agg["c"] or Decimal("0") net_revenue = c - d if net_revenue != 0: # 结转收入:借记收入科目 if net_revenue > 0: entries.append({ "account_code": acc.code, "debit": net_revenue, "credit": Decimal("0"), "summary": f"期末结转损益:{acc.name}", }) total_revenue_cleared += net_revenue else: entries.append({ "account_code": acc.code, "debit": Decimal("0"), "credit": -net_revenue, "summary": f"期末结转损益(红字冲回):{acc.name}", }) total_revenue_cleared += net_revenue # 2. 费用与成本类:借方发生额 - 贷方发生额 = 净费用 expense_accounts = Account.objects.filter( tenant=period.tenant, category="expense", is_active=True ) for acc in expense_accounts: agg = VoucherEntry.objects.filter( account=acc, voucher__period=period, voucher__status="posted", ).exclude(voucher__source_type="period_close").aggregate(d=Sum("debit"), c=Sum("credit")) d = agg["d"] or Decimal("0") c = agg["c"] or Decimal("0") net_expense = d - c if net_expense != 0: # 结转费用:贷记费用科目 if net_expense > 0: entries.append({ "account_code": acc.code, "debit": Decimal("0"), "credit": net_expense, "summary": f"期末结转损益:{acc.name}", }) total_expense_cleared += net_expense else: entries.append({ "account_code": acc.code, "debit": -net_expense, "credit": Decimal("0"), "summary": f"期末结转损益(红字冲回):{acc.name}", }) total_expense_cleared += net_expense voucher = None if entries: # 计算净利润并平账 net_profit = total_revenue_cleared - total_expense_cleared if net_profit > 0: # 盈利:贷记 本年利润 (4103) entries.append({ "account_code": "4103", "debit": Decimal("0"), "credit": net_profit, "summary": "结转本期净利润", }) elif net_profit < 0: # 亏损:借记 本年利润 (4103) entries.append({ "account_code": "4103", "debit": -net_profit, "credit": Decimal("0"), "summary": "结转本期净亏损", }) voucher = create_voucher( tenant=period.tenant, voucher_date=period.end_date, entries=entries, source_type="period_close", source_ref=period.code, summary=f"{period.code} 期末结转损益", user=user, auto_post=True, ) period.status = "closed" period.closed_at = datetime.now(timezone.utc) period.closed_by = user period.save(update_fields=["status", "closed_at", "closed_by", "updated_at"]) return voucher @transaction.atomic def reopen_period(period: Period, *, user=None) -> bool: """反结账:将已结账期间重新开放为 open,并自动作废该期间的损益结转凭证。""" if period.status == "open": return True # 检查是否存在更晚且已结账的期间 later_closed = Period.objects.filter( tenant=period.tenant, start_date__gt=period.start_date, status="closed", ).exists() if later_closed: raise VoucherError("存在更晚的已结账期间,必须从最晚期间依次反结账") # 先将期间状态重置为 open,以便作废凭证 period.status = "open" period.closed_at = None period.closed_by = None period.save(update_fields=["status", "closed_at", "closed_by", "updated_at"]) # 作废本期期末结转凭证 close_vouchers = Voucher.objects.filter( tenant=period.tenant, period=period, source_type="period_close", status="posted", ) for v in close_vouchers: cancel_voucher(v, user=user) return True # ============================================================ # 6. 财务报表服务(科目余额表、资产负债表、利润表、明细分类账) # ============================================================ def get_account_net_balance( tenant, account: Account, *, start_date: Optional[date] = None, end_date: Optional[date] = None, ) -> Decimal: """计算科目在时间区间内的净发生额(按科目正常方向:借方科目为借-贷,贷方科目为贷-借)。""" qs = VoucherEntry.objects.filter( account=account, voucher__tenant=tenant, voucher__status="posted", ) if start_date: qs = qs.filter(voucher__voucher_date__gte=start_date) if end_date: qs = qs.filter(voucher__voucher_date__lte=end_date) agg = qs.aggregate(d=Sum("debit"), c=Sum("credit")) d = agg["d"] or Decimal("0") c = agg["c"] or Decimal("0") if account.balance_type == "debit": return d - c return c - d def trial_balance(tenant, period: Optional[Period] = None) -> dict: """科目余额表(期初、本期借贷发生、期末)。 严格遵循试算平衡: 期初借方总计 == 期初贷方总计 本期借方发生总计 == 本期贷方发生总计 期末借方总计 == 期末贷方总计 """ accounts = Account.objects.filter(tenant=tenant, is_active=True).order_by("code") total_opening_debit = Decimal("0") total_opening_credit = Decimal("0") total_period_debit = Decimal("0") total_period_credit = Decimal("0") total_ending_debit = Decimal("0") total_ending_credit = Decimal("0") rows = [] for acc in accounts: # 1. 期初:如果在指定期间,则计算该期间起始日之前所有已过账凭证 if period: open_agg = VoucherEntry.objects.filter( account=acc, voucher__tenant=tenant, voucher__status="posted", voucher__voucher_date__lt=period.start_date, ).aggregate(d=Sum("debit"), c=Sum("credit")) od = open_agg["d"] or Decimal("0") oc = open_agg["c"] or Decimal("0") if acc.balance_type == "debit": net_open = od - oc open_debit = net_open if net_open >= 0 else Decimal("0") open_credit = -net_open if net_open < 0 else Decimal("0") else: net_open = oc - od open_credit = net_open if net_open >= 0 else Decimal("0") open_debit = -net_open if net_open < 0 else Decimal("0") # 2. 本期发生额 curr_agg = VoucherEntry.objects.filter( account=acc, voucher__tenant=tenant, voucher__status="posted", voucher__period=period, ).aggregate(d=Sum("debit"), c=Sum("credit")) pd = curr_agg["d"] or Decimal("0") pc = curr_agg["c"] or Decimal("0") # 3. 期末余额 = 期初 + 本期 end_d = od + pd end_c = oc + pc if acc.balance_type == "debit": net_end = end_d - end_c end_debit = net_end if net_end >= 0 else Decimal("0") end_credit = -net_end if net_end < 0 else Decimal("0") else: net_end = end_c - end_d end_credit = net_end if net_end >= 0 else Decimal("0") end_debit = -net_end if net_end < 0 else Decimal("0") else: # 全部累计 open_debit = Decimal("0") open_credit = Decimal("0") all_agg = VoucherEntry.objects.filter( account=acc, voucher__tenant=tenant, voucher__status="posted", ).aggregate(d=Sum("debit"), c=Sum("credit")) pd = all_agg["d"] or Decimal("0") pc = all_agg["c"] or Decimal("0") if acc.balance_type == "debit": net = pd - pc end_debit = net if net >= 0 else Decimal("0") end_credit = -net if net < 0 else Decimal("0") else: net = pc - pd end_credit = net if net >= 0 else Decimal("0") end_debit = -net if net < 0 else Decimal("0") # 仅展示有数据变动的科目(或全部科目) if open_debit == 0 and open_credit == 0 and pd == 0 and pc == 0 and end_debit == 0 and end_credit == 0: continue rows.append({ "account_code": acc.code, "account_name": acc.name, "category": acc.category, "balance_type": acc.balance_type, "opening_debit": open_debit, "opening_credit": open_credit, "period_debit": pd, "period_credit": pc, "ending_debit": end_debit, "ending_credit": end_credit, }) total_opening_debit += open_debit total_opening_credit += open_credit total_period_debit += pd total_period_credit += pc total_ending_debit += end_debit total_ending_credit += end_credit return { "period": period.code if period else "all", "rows": rows, "totals": { "opening_debit": total_opening_debit, "opening_credit": total_opening_credit, "period_debit": total_period_debit, "period_credit": total_period_credit, "ending_debit": total_ending_debit, "ending_credit": total_ending_credit, }, "is_balanced": ( total_opening_debit == total_opening_credit and total_period_debit == total_period_credit and total_ending_debit == total_ending_credit ), } def balance_sheet(tenant, period: Optional[Period] = None) -> dict: """资产负债表(截至指定期间期末的累计报表)。 会计等式: 资产总计 = 负债总计 + 所有者权益总计 (+ 未结转本期损益) """ cutoff_date = period.end_date if period else date.today() asset_items = [] liability_items = [] equity_items = [] asset_total = Decimal("0") liability_total = Decimal("0") equity_total = Decimal("0") # 资产类:借方余额 for acc in Account.objects.filter(tenant=tenant, category="asset", is_active=True).order_by("code"): agg = VoucherEntry.objects.filter( account=acc, voucher__tenant=tenant, voucher__status="posted", voucher__voucher_date__lte=cutoff_date, ).aggregate(d=Sum("debit"), c=Sum("credit")) bal = (agg["d"] or Decimal("0")) - (agg["c"] or Decimal("0")) if bal != 0: asset_items.append({"code": acc.code, "name": acc.name, "amount": bal}) asset_total += bal # 负债类:贷方余额 for acc in Account.objects.filter(tenant=tenant, category="liability", is_active=True).order_by("code"): agg = VoucherEntry.objects.filter( account=acc, voucher__tenant=tenant, voucher__status="posted", voucher__voucher_date__lte=cutoff_date, ).aggregate(d=Sum("debit"), c=Sum("credit")) bal = (agg["c"] or Decimal("0")) - (agg["d"] or Decimal("0")) if bal != 0: liability_items.append({"code": acc.code, "name": acc.name, "amount": bal}) liability_total += bal # 所有者权益类:贷方余额 for acc in Account.objects.filter(tenant=tenant, category="equity", is_active=True).order_by("code"): agg = VoucherEntry.objects.filter( account=acc, voucher__tenant=tenant, voucher__status="posted", voucher__voucher_date__lte=cutoff_date, ).aggregate(d=Sum("debit"), c=Sum("credit")) bal = (agg["c"] or Decimal("0")) - (agg["d"] or Decimal("0")) if bal != 0: equity_items.append({"code": acc.code, "name": acc.name, "amount": bal}) equity_total += bal # 未结转损益计算(若尚未期末结账,本期净利润仍在损益科目上) rev_agg = VoucherEntry.objects.filter( account__category="revenue", voucher__tenant=tenant, voucher__status="posted", voucher__voucher_date__lte=cutoff_date, ).aggregate(d=Sum("debit"), c=Sum("credit")) exp_agg = VoucherEntry.objects.filter( account__category="expense", voucher__tenant=tenant, voucher__status="posted", voucher__voucher_date__lte=cutoff_date, ).aggregate(d=Sum("debit"), c=Sum("credit")) net_unclosed_revenue = (rev_agg["c"] or Decimal("0")) - (rev_agg["d"] or Decimal("0")) net_unclosed_expense = (exp_agg["d"] or Decimal("0")) - (exp_agg["c"] or Decimal("0")) unclosed_profit = net_unclosed_revenue - net_unclosed_expense total_liab_and_equity = liability_total + equity_total + unclosed_profit balanced = (asset_total == total_liab_and_equity) return { "period": period.code if period else "all", "as_of_date": str(cutoff_date), "asset_total": asset_total, "liability_total": liability_total, "equity_total": equity_total, "unclosed_profit": unclosed_profit, "total_liabilities_and_equity": total_liab_and_equity, "balanced": balanced, "assets": asset_items, "liabilities": liability_items, "equity": equity_items, } def income_statement(tenant, period: Period) -> dict: """利润表(期间损益发生额)。 净利润 = 主营业务收入 + 其他业务收入 + 营业外收入 - 主营业务成本 - 期间费用 - 营业外支出 """ revenue_items = [] expense_items = [] total_revenue = Decimal("0") total_expense = Decimal("0") cogs = Decimal("0") # 收入明细(排除结转凭证本身,统计真实业务发生额) for acc in Account.objects.filter(tenant=tenant, category="revenue", is_active=True).order_by("code"): agg = VoucherEntry.objects.filter( account=acc, voucher__tenant=tenant, voucher__status="posted", voucher__period=period, ).exclude(voucher__source_type="period_close").aggregate(d=Sum("debit"), c=Sum("credit")) amt = (agg["c"] or Decimal("0")) - (agg["d"] or Decimal("0")) if amt != 0: revenue_items.append({"code": acc.code, "name": acc.name, "amount": amt}) total_revenue += amt # 费用明细 for acc in Account.objects.filter(tenant=tenant, category="expense", is_active=True).order_by("code"): agg = VoucherEntry.objects.filter( account=acc, voucher__tenant=tenant, voucher__status="posted", voucher__period=period, ).exclude(voucher__source_type="period_close").aggregate(d=Sum("debit"), c=Sum("credit")) amt = (agg["d"] or Decimal("0")) - (agg["c"] or Decimal("0")) if amt != 0: expense_items.append({"code": acc.code, "name": acc.name, "amount": amt}) total_expense += amt if acc.code == "6401": cogs += amt gross_profit = total_revenue - cogs net_profit = total_revenue - total_expense return { "period": period.code, "start_date": str(period.start_date), "end_date": str(period.end_date), "total_revenue": total_revenue, "total_expense": total_expense, "cogs": cogs, "gross_profit": gross_profit, "net_profit": net_profit, "revenue_details": revenue_items, "expense_details": expense_items, } def account_ledger( tenant, account: Account, period: Optional[Period] = None, ) -> dict: """明细分类账(特定科目流水及连续余额)。""" opening_balance = Decimal("0") if period: open_agg = VoucherEntry.objects.filter( account=account, voucher__tenant=tenant, voucher__status="posted", voucher__voucher_date__lt=period.start_date, ).aggregate(d=Sum("debit"), c=Sum("credit")) d = open_agg["d"] or Decimal("0") c = open_agg["c"] or Decimal("0") opening_balance = (d - c) if account.balance_type == "debit" else (c - d) qs = VoucherEntry.objects.filter( account=account, voucher__tenant=tenant, voucher__status="posted", ).select_related("voucher", "customer", "supplier").order_by("voucher__voucher_date", "voucher__id", "id") if period: qs = qs.filter(voucher__period=period) running_bal = opening_balance lines = [] period_debit_sum = Decimal("0") period_credit_sum = Decimal("0") for e in qs: period_debit_sum += e.debit period_credit_sum += e.credit if account.balance_type == "debit": running_bal += (e.debit - e.credit) else: running_bal += (e.credit - e.debit) aux_name = "" if e.customer: aux_name = f"客户:{e.customer.name}" elif e.supplier: aux_name = f"供应商:{e.supplier.name}" lines.append({ "id": e.id, "date": str(e.voucher.voucher_date), "voucher_no": e.voucher.bill_no, "summary": e.summary or e.voucher.summary, "aux": aux_name, "debit": e.debit, "credit": e.credit, "balance": running_bal, }) return { "account_code": account.code, "account_name": account.name, "balance_type": account.balance_type, "period": period.code if period else "all", "opening_balance": opening_balance, "lines": lines, "period_total_debit": period_debit_sum, "period_total_credit": period_credit_sum, "ending_balance": running_bal, } # ============================================================ # P0 #6:应收账龄分析 + 客户对账单 # ============================================================ AGING_BUCKETS = [ ("0-30", 0, 30), ("31-60", 31, 60), ("61-90", 61, 90), ("91-180", 91, 180), ("181-365", 181, 365), ("365+", 366, None), ] def receivable_aging(tenant, *, as_of: Optional[date] = None, customer=None) -> dict: """应收账龄分析:按账龄分桶统计未结余额。 - as_of:截止日(默认今天) - customer:可选,只算单个客户 返回 {buckets: [{bucket, amount, count}], total, as_of} """ from django.db.models import Sum from .models import Receivable as_of = as_of or date.today() qs = Receivable.objects.filter( tenant=tenant, status__in=["open", "partial"], is_deleted=False, bill_date__lte=as_of, ) if customer is not None: qs = qs.filter(customer=customer) buckets = [] grand = Decimal("0") for name, lo, hi in AGING_BUCKETS: start = as_of - timedelta(days=hi) if hi is not None else None end = as_of - timedelta(days=lo) q = qs if start is not None: q = q.filter(bill_date__gte=start) agg = q.filter(bill_date__lte=end).aggregate( amount=Sum("total_amount"), paid=Sum("paid_amount") ) amount = (agg["amount"] or Decimal("0")) - (agg["paid"] or Decimal("0")) amount = amount.quantize(Decimal("0.0001")) buckets.append({"bucket": name, "amount": str(amount)}) grand += amount return {"as_of": as_of.isoformat(), "buckets": buckets, "total": str(grand)} def customer_statement(tenant, customer, *, date_from: date, date_to: date) -> dict: """客户对账单:期初余额 + 区间应收(借)/收款核销(贷) + 期末余额。 明细行: - 应收单(bill_date 落在区间内,status != cancelled)→ debit - 收款核销(receipt.bill_date 落在区间内)→ credit(按核销明细汇总到收款单) """ from django.db.models import Sum from .models import Receivable, Allocation # 期初:区间开始前产生的应收中尚未结清部分。 # 注意:paid_amount 是当前实时值,其中可能包含"区间内核销"的金额—— # 那部分要在区间贷方行里体现,因此期初要把它加回来(期初 = 当前未结 + 区间内核销到旧账的金额)。 opening_qs = Receivable.objects.filter( tenant=tenant, customer=customer, status__in=["open", "partial"], is_deleted=False, bill_date__lt=date_from, ).aggregate(total=Sum("total_amount"), paid=Sum("paid_amount")) opening = (opening_qs["total"] or Decimal("0")) - (opening_qs["paid"] or Decimal("0")) opening += ( Allocation.objects.filter( tenant=tenant, kind="receipt", receivable__customer=customer, receivable__bill_date__lt=date_from, receipt__bill_date__gte=date_from, receipt__bill_date__lte=date_to, is_deleted=False, ).aggregate(s=Sum("amount"))["s"] or Decimal("0") ) lines = [] balance = opening receivables = Receivable.objects.filter( tenant=tenant, customer=customer, bill_date__gte=date_from, bill_date__lte=date_to, is_deleted=False, ).exclude(status="cancelled").order_by("bill_date", "id") for r in receivables: balance += r.total_amount lines.append({ "date": r.bill_date.isoformat(), "type": "receivable", "ref": r.bill_no, "description": f"销售应收(来源 {r.source_ref or '手工'})", "debit": str(r.total_amount), "credit": "0.0000", "balance": str(balance.quantize(Decimal("0.0001"))), }) # 区间内核销到该客户应收的收款 allocs = ( Allocation.objects.filter( tenant=tenant, kind="receipt", receivable__customer=customer, receipt__bill_date__gte=date_from, receipt__bill_date__lte=date_to, is_deleted=False, ) .select_related("receipt") .order_by("receipt__bill_date", "receipt_id") ) by_receipt = {} for a in allocs: by_receipt.setdefault(a.receipt_id, {"receipt": a.receipt, "amount": Decimal("0")}) by_receipt[a.receipt_id]["amount"] += a.amount for rid, item in sorted( by_receipt.items(), key=lambda kv: (kv[1]["receipt"].bill_date, kv[0]), ): rc = item["receipt"] balance -= item["amount"] lines.append({ "date": rc.bill_date.isoformat(), "type": "receipt", "ref": rc.bill_no, "description": f"收款核销({rc.method or '未注明方式'})", "debit": "0.0000", "credit": str(item["amount"].quantize(Decimal("0.0001"))), "balance": str(balance.quantize(Decimal("0.0001"))), }) closing = balance return { "customer": {"id": customer.id, "code": customer.code, "name": customer.name}, "date_from": date_from.isoformat(), "date_to": date_to.isoformat(), "opening_balance": str(opening.quantize(Decimal("0.0001"))), "lines": lines, "closing_balance": str(closing.quantize(Decimal("0.0001"))), }