Files
dealerhub/backend/apps/finance/services.py
T

1539 lines
54 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""完整财务 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"))),
}