428 lines
14 KiB
Python
428 lines
14 KiB
Python
"""完整财务模型:
|
||
第一部分:业务往来(应收、应付、收款、付款、核销明细)
|
||
第二部分:总账系统(会计科目、会计期间、记账凭证、凭证分录)
|
||
|
||
设计原则:
|
||
- 新会计准则,借贷记账法
|
||
- 业务与财务一体化:销售/采购/收付款过账时,自动或按需生成对应记账凭证
|
||
- 辅助核算:支持客户、供应商等辅助维度
|
||
- 凭证支持草稿、已过账、已作废状态机管理
|
||
"""
|
||
|
||
from decimal import Decimal
|
||
from django.conf import settings
|
||
from django.db import models
|
||
|
||
from apps.core.base_models import TenantScopedModel
|
||
|
||
|
||
# ============================================================
|
||
# 第一部分:应收应付与核销(AR / AP)
|
||
# ============================================================
|
||
|
||
class Receivable(TenantScopedModel):
|
||
"""应收单(销售/其他应收)。"""
|
||
|
||
STATUS_CHOICES = [
|
||
("open", "未收"),
|
||
("partial", "部分已收"),
|
||
("paid", "已结清"),
|
||
("cancelled", "已取消"),
|
||
]
|
||
|
||
customer = models.ForeignKey(
|
||
"partner.Customer",
|
||
on_delete=models.PROTECT,
|
||
related_name="receivables",
|
||
)
|
||
bill_no = models.CharField(max_length=64, help_text="应收单号(系统生成)")
|
||
source_type = models.CharField(max_length=32, default="sale", help_text="来源:sale/manual/...")
|
||
source_ref = models.CharField(max_length=64, blank=True, default="")
|
||
bill_date = models.DateField()
|
||
due_date = models.DateField(null=True, blank=True)
|
||
total_amount = models.DecimalField(max_digits=18, decimal_places=4, default=0)
|
||
paid_amount = models.DecimalField(max_digits=18, decimal_places=4, default=0)
|
||
status = models.CharField(max_length=16, choices=STATUS_CHOICES, default="open")
|
||
remark = models.TextField(blank=True, default="")
|
||
|
||
class Meta:
|
||
db_table = "finance_receivable"
|
||
unique_together = [("tenant", "bill_no")]
|
||
verbose_name_plural = "应收单"
|
||
indexes = [
|
||
models.Index(fields=["tenant", "customer", "status"]),
|
||
models.Index(fields=["tenant", "bill_date"]),
|
||
]
|
||
ordering = ["-bill_date", "-id"]
|
||
|
||
@property
|
||
def balance(self) -> Decimal:
|
||
return self.total_amount - self.paid_amount
|
||
|
||
def __str__(self):
|
||
return f"{self.bill_no} ({self.customer.name}) {self.total_amount}"
|
||
|
||
|
||
class Payable(TenantScopedModel):
|
||
"""应付单(采购/其他应付)。"""
|
||
|
||
STATUS_CHOICES = [
|
||
("open", "未付"),
|
||
("partial", "部分已付"),
|
||
("paid", "已结清"),
|
||
("cancelled", "已取消"),
|
||
]
|
||
|
||
supplier = models.ForeignKey(
|
||
"partner.Supplier",
|
||
on_delete=models.PROTECT,
|
||
related_name="payables",
|
||
)
|
||
bill_no = models.CharField(max_length=64)
|
||
source_type = models.CharField(max_length=32, default="purchase")
|
||
source_ref = models.CharField(max_length=64, blank=True, default="")
|
||
bill_date = models.DateField()
|
||
due_date = models.DateField(null=True, blank=True)
|
||
total_amount = models.DecimalField(max_digits=18, decimal_places=4, default=0)
|
||
paid_amount = models.DecimalField(max_digits=18, decimal_places=4, default=0)
|
||
status = models.CharField(max_length=16, choices=STATUS_CHOICES, default="open")
|
||
remark = models.TextField(blank=True, default="")
|
||
|
||
class Meta:
|
||
db_table = "finance_payable"
|
||
unique_together = [("tenant", "bill_no")]
|
||
verbose_name_plural = "应付单"
|
||
indexes = [
|
||
models.Index(fields=["tenant", "supplier", "status"]),
|
||
models.Index(fields=["tenant", "bill_date"]),
|
||
]
|
||
ordering = ["-bill_date", "-id"]
|
||
|
||
@property
|
||
def balance(self) -> Decimal:
|
||
return self.total_amount - self.paid_amount
|
||
|
||
def __str__(self):
|
||
return f"{self.bill_no} ({self.supplier.name}) {self.total_amount}"
|
||
|
||
|
||
class Receipt(TenantScopedModel):
|
||
"""收款单。"""
|
||
|
||
STATUS_CHOICES = [
|
||
("draft", "草稿"),
|
||
("posted", "已过账"),
|
||
("cancelled", "已取消"),
|
||
]
|
||
|
||
customer = models.ForeignKey(
|
||
"partner.Customer",
|
||
on_delete=models.PROTECT,
|
||
related_name="receipts",
|
||
)
|
||
bill_no = models.CharField(max_length=64)
|
||
bill_date = models.DateField()
|
||
amount = models.DecimalField(max_digits=18, decimal_places=4)
|
||
method = models.CharField(max_length=32, blank=True, default="", help_text="现金/银行/微信/支付宝")
|
||
status = models.CharField(max_length=16, choices=STATUS_CHOICES, default="draft")
|
||
remark = models.TextField(blank=True, default="")
|
||
|
||
class Meta:
|
||
db_table = "finance_receipt"
|
||
unique_together = [("tenant", "bill_no")]
|
||
verbose_name_plural = "收款单"
|
||
ordering = ["-bill_date", "-id"]
|
||
|
||
def __str__(self):
|
||
return f"{self.bill_no} {self.customer.name} {self.amount}"
|
||
|
||
|
||
class Payment(TenantScopedModel):
|
||
"""付款单。"""
|
||
|
||
STATUS_CHOICES = [
|
||
("draft", "草稿"),
|
||
("posted", "已过账"),
|
||
("cancelled", "已取消"),
|
||
]
|
||
|
||
supplier = models.ForeignKey(
|
||
"partner.Supplier",
|
||
on_delete=models.PROTECT,
|
||
related_name="payments",
|
||
)
|
||
bill_no = models.CharField(max_length=64)
|
||
bill_date = models.DateField()
|
||
amount = models.DecimalField(max_digits=18, decimal_places=4)
|
||
method = models.CharField(max_length=32, blank=True, default="")
|
||
status = models.CharField(max_length=16, choices=STATUS_CHOICES, default="draft")
|
||
remark = models.TextField(blank=True, default="")
|
||
|
||
class Meta:
|
||
db_table = "finance_payment"
|
||
unique_together = [("tenant", "bill_no")]
|
||
verbose_name_plural = "付款单"
|
||
ordering = ["-bill_date", "-id"]
|
||
|
||
def __str__(self):
|
||
return f"{self.bill_no} {self.supplier.name} {self.amount}"
|
||
|
||
|
||
class Allocation(TenantScopedModel):
|
||
"""核销明细。"""
|
||
|
||
KIND_CHOICES = [
|
||
("receipt", "收款核销"),
|
||
("payment", "付款核销"),
|
||
]
|
||
|
||
receivable = models.ForeignKey(
|
||
Receivable,
|
||
null=True,
|
||
blank=True,
|
||
on_delete=models.CASCADE,
|
||
related_name="allocations",
|
||
)
|
||
payable = models.ForeignKey(
|
||
Payable,
|
||
null=True,
|
||
blank=True,
|
||
on_delete=models.CASCADE,
|
||
related_name="allocations",
|
||
)
|
||
receipt = models.ForeignKey(
|
||
Receipt,
|
||
null=True,
|
||
blank=True,
|
||
on_delete=models.CASCADE,
|
||
related_name="allocations",
|
||
)
|
||
payment = models.ForeignKey(
|
||
Payment,
|
||
null=True,
|
||
blank=True,
|
||
on_delete=models.CASCADE,
|
||
related_name="allocations",
|
||
)
|
||
kind = models.CharField(max_length=16, choices=KIND_CHOICES)
|
||
amount = models.DecimalField(max_digits=18, decimal_places=4)
|
||
|
||
class Meta:
|
||
db_table = "finance_allocation"
|
||
verbose_name_plural = "核销明细"
|
||
indexes = [
|
||
models.Index(fields=["tenant", "receivable"]),
|
||
models.Index(fields=["tenant", "payable"]),
|
||
]
|
||
|
||
|
||
# ============================================================
|
||
# 第二部分:总账系统(科目、期间、凭证、分录)
|
||
# ============================================================
|
||
|
||
class Account(TenantScopedModel):
|
||
"""会计科目。
|
||
|
||
类别(5 大类):
|
||
- asset 资产
|
||
- liability 负债
|
||
- equity 所有者权益
|
||
- revenue 收入
|
||
- expense 费用
|
||
|
||
余额方向:
|
||
- debit 借方余额(资产/费用)
|
||
- credit 贷方余额(负债/权益/收入)
|
||
"""
|
||
|
||
CATEGORY_CHOICES = [
|
||
("asset", "资产"),
|
||
("liability", "负债"),
|
||
("equity", "所有者权益"),
|
||
("revenue", "收入"),
|
||
("expense", "费用"),
|
||
]
|
||
BALANCE_TYPE_CHOICES = [
|
||
("debit", "借方余额"),
|
||
("credit", "贷方余额"),
|
||
]
|
||
|
||
code = models.CharField(max_length=32, help_text="科目编号(GB/T 标准 4-2-2)")
|
||
name = models.CharField(max_length=64)
|
||
category = models.CharField(max_length=16, choices=CATEGORY_CHOICES)
|
||
balance_type = models.CharField(max_length=8, choices=BALANCE_TYPE_CHOICES)
|
||
parent = models.ForeignKey(
|
||
"self",
|
||
null=True,
|
||
blank=True,
|
||
on_delete=models.SET_NULL,
|
||
related_name="children",
|
||
)
|
||
is_active = models.BooleanField(default=True)
|
||
is_system = models.BooleanField(default=False, help_text="系统预置科目(不可删)")
|
||
aux_customer = models.BooleanField(default=False, help_text="是否需要客户辅助核算")
|
||
aux_supplier = models.BooleanField(default=False, help_text="是否需要供应商辅助核算")
|
||
remark = models.CharField(max_length=255, blank=True, default="")
|
||
|
||
class Meta:
|
||
db_table = "finance_account"
|
||
unique_together = [("tenant", "code")]
|
||
verbose_name_plural = "会计科目"
|
||
ordering = ["code"]
|
||
|
||
def __str__(self):
|
||
return f"{self.code} {self.name}"
|
||
|
||
|
||
class Period(TenantScopedModel):
|
||
"""会计期间(月度)。"""
|
||
|
||
STATUS_CHOICES = [
|
||
("open", "未结"),
|
||
("closed", "已结账"),
|
||
]
|
||
|
||
# YYYY-MM
|
||
code = models.CharField(max_length=16, help_text="期间代码,如 2026-09")
|
||
start_date = models.DateField()
|
||
end_date = models.DateField()
|
||
status = models.CharField(max_length=16, choices=STATUS_CHOICES, default="open")
|
||
closed_at = models.DateTimeField(null=True, blank=True)
|
||
closed_by = models.ForeignKey(
|
||
settings.AUTH_USER_MODEL,
|
||
null=True,
|
||
blank=True,
|
||
on_delete=models.SET_NULL,
|
||
related_name="+",
|
||
)
|
||
|
||
class Meta:
|
||
db_table = "finance_period"
|
||
unique_together = [("tenant", "code")]
|
||
verbose_name_plural = "会计期间"
|
||
ordering = ["-code"]
|
||
|
||
def __str__(self):
|
||
return f"{self.code} ({self.get_status_display()})"
|
||
|
||
|
||
class Voucher(TenantScopedModel):
|
||
"""记账凭证。"""
|
||
|
||
STATUS_CHOICES = [
|
||
("draft", "草稿"),
|
||
("posted", "已过账"),
|
||
("cancelled", "已作废"),
|
||
]
|
||
|
||
SOURCE_CHOICES = [
|
||
("manual", "手工"),
|
||
("sale", "销售单"),
|
||
("sale_cost", "销售成本"),
|
||
("purchase", "采购单"),
|
||
("receipt", "收款"),
|
||
("payment", "付款"),
|
||
("inventory_adjust", "库存调整"),
|
||
("period_close", "期末结转"),
|
||
]
|
||
|
||
bill_no = models.CharField(max_length=64, help_text="凭证号 V{YYYYMM}{seq:04d}")
|
||
period = models.ForeignKey(
|
||
Period,
|
||
on_delete=models.PROTECT,
|
||
related_name="vouchers",
|
||
)
|
||
source_type = models.CharField(max_length=24, choices=SOURCE_CHOICES, default="manual")
|
||
source_ref = models.CharField(max_length=64, blank=True, default="")
|
||
voucher_date = models.DateField()
|
||
|
||
total_debit = models.DecimalField(max_digits=18, decimal_places=4, default=0)
|
||
total_credit = models.DecimalField(max_digits=18, decimal_places=4, default=0)
|
||
status = models.CharField(max_length=16, choices=STATUS_CHOICES, default="draft")
|
||
|
||
summary = models.CharField(max_length=255, blank=True, default="", help_text="摘要")
|
||
posted_at = models.DateTimeField(null=True, blank=True)
|
||
posted_by = models.ForeignKey(
|
||
settings.AUTH_USER_MODEL,
|
||
null=True,
|
||
blank=True,
|
||
on_delete=models.SET_NULL,
|
||
related_name="+",
|
||
)
|
||
|
||
class Meta:
|
||
db_table = "finance_voucher"
|
||
unique_together = [("tenant", "bill_no")]
|
||
verbose_name_plural = "记账凭证"
|
||
indexes = [
|
||
models.Index(fields=["tenant", "period", "-voucher_date"]),
|
||
models.Index(fields=["tenant", "source_type", "source_ref"]),
|
||
]
|
||
ordering = ["-voucher_date", "-id"]
|
||
|
||
def __str__(self):
|
||
return f"{self.bill_no} ({self.voucher_date}) 借/贷:{self.total_debit}/{self.total_credit} [{self.status}]"
|
||
|
||
|
||
class VoucherEntry(TenantScopedModel):
|
||
"""凭证分录(一条 = 一借或一贷)。"""
|
||
|
||
voucher = models.ForeignKey(
|
||
Voucher,
|
||
on_delete=models.CASCADE,
|
||
related_name="entries",
|
||
)
|
||
account = models.ForeignKey(
|
||
Account,
|
||
on_delete=models.PROTECT,
|
||
related_name="voucher_entries",
|
||
)
|
||
# 辅助核算
|
||
customer = models.ForeignKey(
|
||
"partner.Customer",
|
||
null=True,
|
||
blank=True,
|
||
on_delete=models.PROTECT,
|
||
related_name="voucher_entries",
|
||
)
|
||
supplier = models.ForeignKey(
|
||
"partner.Supplier",
|
||
null=True,
|
||
blank=True,
|
||
on_delete=models.PROTECT,
|
||
related_name="voucher_entries",
|
||
)
|
||
summary = models.CharField(max_length=255, blank=True, default="")
|
||
|
||
debit = models.DecimalField(max_digits=18, decimal_places=4, default=0, help_text="借方")
|
||
credit = models.DecimalField(max_digits=18, decimal_places=4, default=0, help_text="贷方")
|
||
|
||
class Meta:
|
||
db_table = "finance_voucher_entry"
|
||
verbose_name_plural = "凭证分录"
|
||
ordering = ["id"]
|
||
|
||
def __str__(self):
|
||
return f"{self.voucher.bill_no} #{self.id} {self.account.code} 借:{self.debit} 贷:{self.credit}"
|
||
|
||
|
||
class StatementShare(TenantScopedModel):
|
||
"""对账单公开分享链接(token 化匿名访问)。"""
|
||
|
||
customer = models.ForeignKey(
|
||
"partner.Customer", on_delete=models.CASCADE, related_name="statement_shares"
|
||
)
|
||
token = models.UUIDField(unique=True, db_index=True)
|
||
date_from = models.DateField()
|
||
date_to = models.DateField()
|
||
expires_at = models.DateTimeField(null=True, blank=True)
|
||
revoked = models.BooleanField(default=False)
|
||
|
||
class Meta:
|
||
db_table = "finance_statement_share"
|
||
verbose_name_plural = "对账单分享"
|
||
ordering = ["-created_at"]
|
||
|
||
def __str__(self):
|
||
return f"share:{self.token} {self.customer.code} {self.date_from}~{self.date_to}"
|