1539 lines
54 KiB
Python
1539 lines
54 KiB
Python
"""完整财务 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"))),
|
||
}
|