Files
gzth/yuthon_report/models/penalty_arrears_report.py
T
2026-06-17 18:01:58 +08:00

537 lines
26 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.
import datetime # 日期时间处理模块,用于日期解析、计算、格式化
import logging # 日志模块,用于调试输出
from odoo import api, models # Odoo 核心 API:api 装饰器、models 模型基类
from .report_utils import _id_list # 导入 ID 列表解析工具函数,支持多选参数
_logger = logging.getLogger(__name__) # 获取当前模块的日志记录器
class PenaltyArrearsReport(models.Model):
_inherit = 'property.accounts.receivable' # 继承 property.accounts.receivable 模型,扩展报表方法
@api.model
def get_penalty_arrears_report(self, params):
"""
违约金累计欠收报表
两路数据源:
来源A:property.accounts.receivable 中收款类型为违约金 且 is_batch=True 的记录
通过 source_receivable_id 关联租金
来源B:receivable.modify.line 中 is_penalty=True 的记录
通过 receivable_id 关联租金(receivable_id 指向的就是那笔租金应收记录)
累计欠收逻辑:
- 来源A(is_batch违约金记录):取 deposit_state in [unpaid, no_paid] 的记录,
展示违约金金额、已收、未收
- 来源B(modify.line is_penalty):按租金分组,取 reconciliation_date <= 查询日期 的实收,
与违约金记录关联,展示未收部分
计算规则(用于动态违约金计算,无 source_receivable_id 时):
a) 应收月的次月1号开始计算违约金
b) 违约金按每日千分之0.1%(0.001)计算
c) 租金部分支付后,剩余部分从前一次缴费日期次日开始重新计算
"""
# ========== 一、参数解析 ==========
date_end = params.get("date_end") # 查询截止日期(如 "2026-01-01 23:59:59")
summary_only = params.get("summary_only", False)
query_date = None # 查询日期 date 对象,初始化为空
if date_end: # 如果有截止日期参数
query_date = datetime.datetime.fromisoformat(date_end).date() # 将字符串转为 date 对象
# ========== 二、构建公共筛选条件 ==========
keyword = params.get("keyword") # 关键字搜索
condition = params.get('condition') # 租赁类型
partner_id = _id_list(params.get('partner_id')) # 解析片区 ID 为整数列表(多选支持)
company_id = _id_list(params.get('company_id')) # 公司 ID
property_id = params.get('property_id') # 物业 ID
lessee_id = params.get('lessee_id') # 承租方 ID
contract_id = params.get('contract_id') # 合同 ID
# ========== 辅助函数:内存过滤(关键字、条件等) ==========
def _match_filters(rec, property_name_str):
"""对 property.accounts.receivable 记录做内存过滤,返回 True 表示通过"""
if keyword:
kw = keyword.lower()
match = False
if rec.contract_id and rec.contract_id.contract_code and kw in rec.contract_id.contract_code.lower():
match = True
if property_name_str and kw in property_name_str.lower():
match = True
if rec.lessee_id and rec.lessee_id.name and kw in rec.lessee_id.name.lower():
match = True
if not match:
return False
if condition and rec.tenure_type != condition:
return False
if partner_id and rec.precinct_id.id not in partner_id: # 片区过滤(多选支持)
return False
if company_id and rec.company_id.id not in company_id:
return False
if property_id and int(property_id) not in (rec.property_ids.ids or []):
return False
if lessee_id and (not rec.lessee_id or rec.lessee_id.id != int(lessee_id)):
return False
if contract_id and (not rec.contract_id or rec.contract_id.id != int(contract_id)):
return False
return True
# ========== 辅助函数:构建物业名称显示(穿透对象) ==========
def _build_property_display(contract_rec, property_name_str):
"""构建物业名称穿透对象"""
if contract_rec and contract_rec.lease_ids and contract_rec.lease_ids[0].address_id:
return {
'model': 'yuthon.property',
'id': contract_rec.lease_ids[0].address_id.id,
'display_name': property_name_str or "",
}
return property_name_str or ""
# ========== 辅助函数:获取违约金计算比例 ==========
def _get_penalty_rate(contract_rec):
"""从合同配置中获取违约金日利率,默认 0.001"""
if contract_rec and contract_rec.percentage_liquidated_damages:
try:
rate_val = float(contract_rec.percentage_liquidated_damages.name)
if rate_val > 0:
return rate_val
except (ValueError, TypeError):
pass
return 0.001
# ========== 初始化报表数据 ==========
rows = [] # 报表行数据列表
total_penalty = 0.0 # 违约金总额累计
total_collected = 0.0 # 违约金已收累计
total_uncollected = 0.0 # 违约金未收累计
# 用于去重:记录已经处理过的(来源, 主键)组合
processed_keys = set()
# ========== 三、来源A:property.accounts.receivable 中 is_batch=True 的违约金 ==========
# 搜索 is_batch=True 且收款类型为违约金的未收/收部分记录
source_a_domain = [
('management_type_id.name', '=', '违约金'), # 收款类型为违约金
('is_batch', '=', True), # 标记为批量修复的违约金数据
('deposit_state', 'in', ['unpaid', 'no_paid']), # 未收或收部分
('is_invalid', '!=', True), # 排除已作废
]
if query_date:
source_a_domain.append(('due_date', '<=', query_date)) # due_date <= 查询日期
penalty_a_recs = self.sudo().search(source_a_domain, order="contract_id asc, due_date asc")
for penalty_rec in penalty_a_recs:
# 通过 source_receivable_id 关联租金
rent_rec = penalty_rec.source_receivable_id
# 内存过滤(用违约金记录本身的字段)
prop_name = penalty_rec.property_name or (rent_rec.property_name if rent_rec else "")
if not _match_filters(penalty_rec, prop_name):
continue
# 去重 key
key = ('A', penalty_rec.id)
if key in processed_keys:
continue
processed_keys.add(key)
# 违约金金额、已收、未收
penalty_amount = round(penalty_rec.original_amount or 0.0, 2)
penalty_collected_amt = round(penalty_rec.delivered1 or 0.0, 2)
penalty_uncollected_amt = round(penalty_rec.submitted or 0.0, 2)
# 违约金状态
penalty_status = dict(penalty_rec._fields['deposit_state'].selection).get(
penalty_rec.deposit_state, penalty_rec.deposit_state)
# 租金信息
rent_amount_val = 0.0
rent_collected_amt = 0.0
rent_uncollected_amt = 0.0
rent_status = ""
rent_due_date_str = ""
rent_payout_date_str = ""
if rent_rec:
rent_amount_val = round((rent_rec.original_amount or 0.0) - (rent_rec.discount_amount or 0.0), 2)
rent_collected_amt = round(rent_rec.delivered1 or 0.0, 2)
rent_uncollected_amt = round(rent_rec.submitted or 0.0, 2)
rent_due_date_str = rent_rec.due_date.strftime("%Y-%m-%d") if rent_rec.due_date else ""
rent_payout_date_str = rent_rec.payout_date.strftime("%Y-%m-%d") if rent_rec.payout_date else ""
if rent_rec.deposit_state == 'paid':
rent_status = "已收"
elif rent_rec.deposit_state == 'no_paid':
rent_status = "收部分"
else:
rent_status = "未收"
# 累计统计
total_penalty += penalty_amount
total_collected += penalty_collected_amt
total_uncollected += penalty_uncollected_amt
contract = penalty_rec.contract_id
if not summary_only:
rows.append({
"来源": "A",
"租金ID": rent_rec.id if rent_rec else "",
"租金应收日": rent_due_date_str,
"租金收款日期": rent_payout_date_str,
"租金金额": rent_amount_val,
"租金已收": rent_collected_amt,
"租金未收": rent_uncollected_amt,
"租金状态": rent_status,
"违约金金额": penalty_amount,
"违约金已收": penalty_collected_amt,
"违约金未收": penalty_uncollected_amt,
"违约金状态": penalty_status,
"违约金应收开始": penalty_rec.due_date.strftime("%Y-%m-%d") if penalty_rec.due_date else "",
"收费区间": penalty_rec.charging_range or "",
"备注": penalty_rec.remark or "",
"合同编号": {
'model': 'property.lease.contract',
'id': contract.id,
'display_name': contract.contract_code,
} if contract else "",
"物业名称": _build_property_display(contract, prop_name),
"承租方名称": {
'model': 'property.tenant.information',
'id': penalty_rec.lessee_id.id,
'display_name': penalty_rec.lessee_id.name,
} if penalty_rec.lessee_id else "",
"所属公司": penalty_rec.company_id.name if penalty_rec.company_id else "",
})
# ========== 四、来源B:receivable.modify.line 中 is_penalty=True 的记录 ==========
# 取 is_penalty=True 的 modify.line,其 receivable_id 就是关联的那笔租金
# 累计欠收:需要找出有未收违约金的租金记录
# 思路:通过 is_penalty=True 的 modify.line 找到租金记录,
# 再查该租金对应的违约金收款情况(已收 vs 应收)
# 搜索所有 is_penalty=True 的 modify.line(不限日期,用于统计累计已收)
source_b_domain = [
('is_penalty', '=', True), # 标记为违约金付款
('receivable_id', '!=', False), # 必须关联应收记录
]
if query_date:
source_b_domain.append(('reconciliation_date', '<=', query_date)) # 截止查询日期
modify_b_lines = self.env['receivable.modify.line'].sudo().search(source_b_domain)
# 按 receivable_id(租金记录)分组,统计每笔租金已收到的违约金
# rent_id → 已收违约金金额合计
rent_penalty_collected = {} # rent_id → float
rent_modify_lines = {} # rent_id → [modify.line]
for ml in modify_b_lines:
rid = ml.receivable_id.id
if rid not in rent_penalty_collected:
rent_penalty_collected[rid] = 0.0
rent_modify_lines[rid] = []
rent_penalty_collected[rid] += (ml.amount or 0.0)
rent_modify_lines[rid].append(ml)
# 对这些租金记录,动态计算违约金总额,判断是否有未收
# 先查出涉及的租金记录
rent_ids_b = list(rent_penalty_collected.keys())
# ========== 优化:批量查询所有租金对应的非违约金收款行,消除N+1 ==========
all_modify_lines = self.env['receivable.modify.line'].sudo().search([
('receivable_id', 'in', rent_ids_b),
('reconciliation_date', '!=', False),
('is_penalty', '=', False),
], order='receivable_id, reconciliation_date asc')
# 按 receivable_id 分组
modify_lines_by_rent = {}
for ml in all_modify_lines:
rid = ml.receivable_id.id
if rid not in modify_lines_by_rent:
modify_lines_by_rent[rid] = []
modify_lines_by_rent[rid].append(ml)
# ======================================================================
rent_recs_b = self.sudo().browse(rent_ids_b) if rent_ids_b else self.sudo().browse([])
for rent_rec in rent_recs_b:
# 基础检查:排除已作废记录
if rent_rec.is_invalid:
continue
# 内存过滤
prop_name = rent_rec.property_name or ""
if not _match_filters(rent_rec, prop_name):
continue
due_date = rent_rec.due_date
if not due_date:
continue
# 内存时间过滤:due_date <= 查询日期
if query_date and due_date > query_date:
continue
# --- 计算违约金起始日期:应收月的次月1号 ---
if due_date.month == 12:
penalty_start = datetime.date(due_date.year + 1, 1, 1)
else:
penalty_start = datetime.date(due_date.year, due_date.month + 1, 1)
# 违约金起始日期 > 查询日期,还没开始产生违约金
if query_date and penalty_start > query_date:
continue
# --- 获取违约金计算比例 ---
penalty_rate = _get_penalty_rate(rent_rec.contract_id)
# --- 获取该租金的支付历史(非违约金的 modify lines) ---
# 从批量预分组字典中取,消除N+1(第233-245行已批量查询并分组)
modify_lines = modify_lines_by_rent.get(rent_rec.id, [])
# --- 分段计算违约金总额 ---
rent_amount = (rent_rec.original_amount or 0.0) - (rent_rec.discount_amount or 0.0)
current_principal = rent_amount
current_start = penalty_start
total_calc_penalty = 0.0
calc_range_parts = []
for line in modify_lines:
pay_date = line.reconciliation_date
pay_amount = line.amount or 0.0
if pay_date < current_start:
current_principal = max(0, current_principal - pay_amount)
continue
days = (pay_date - current_start).days + 1
if days > 0 and current_principal > 0:
segment_penalty = current_principal * days * penalty_rate
total_calc_penalty += segment_penalty
calc_range_parts.append(
f"{current_start.strftime('%Y-%m-%d')}至{pay_date.strftime('%Y-%m-%d')} "
f"({days}天×{current_principal:.2f}×{penalty_rate})"
)
current_principal = max(0, current_principal - pay_amount)
current_start = pay_date + datetime.timedelta(days=1)
# 最后一段:从最后一次支付(或起始日)到查询日期
if current_principal > 0 and query_date and current_start <= query_date:
days = (query_date - current_start).days + 1
if days > 0:
segment_penalty = current_principal * days * penalty_rate
total_calc_penalty += segment_penalty
calc_range_parts.append(
f"{current_start.strftime('%Y-%m-%d')}至{query_date.strftime('%Y-%m-%d')} "
f"({days}天×{current_principal:.2f}×{penalty_rate})"
)
total_calc_penalty = round(total_calc_penalty, 2)
if total_calc_penalty <= 0:
continue
# 已收违约金(来自 modify.line is_penalty=True 的金额合计)
penalty_collected_amt = round(rent_penalty_collected.get(rent_rec.id, 0.0), 2)
penalty_uncollected_amt = round(max(0, total_calc_penalty - penalty_collected_amt), 2)
# 仅展示有未收违约金的记录(累计欠收报表)
if penalty_uncollected_amt <= 0:
continue
# 去重 key(避免与来源A重复)
key = ('B', rent_rec.id)
if key in processed_keys:
continue
processed_keys.add(key)
# 租金信息
rent_collected_amt = round(rent_rec.delivered1 or 0.0, 2)
rent_uncollected_amt = round(rent_rec.submitted or 0.0, 2)
rent_status = ""
if rent_rec.deposit_state == 'paid':
rent_status = "已收"
elif rent_rec.deposit_state == 'no_paid':
rent_status = "收部分"
else:
rent_status = "未收"
# 累计统计
total_penalty += total_calc_penalty
total_collected += penalty_collected_amt
total_uncollected += penalty_uncollected_amt
contract = rent_rec.contract_id
if not summary_only:
rows.append({
"来源": "B",
"租金ID": rent_rec.id,
"租金应收日": due_date.strftime("%Y-%m-%d"),
"租金收款日期": rent_rec.payout_date.strftime("%Y-%m-%d") if rent_rec.payout_date else "",
"租金金额": round(rent_amount, 2),
"租金已收": rent_collected_amt,
"租金未收": rent_uncollected_amt,
"租金状态": rent_status,
"违约金金额": total_calc_penalty,
"违约金已收": penalty_collected_amt,
"违约金未收": penalty_uncollected_amt,
"违约金状态": "动态" if not rent_rec.payout_date else "未收",
"违约金应收开始": penalty_start.strftime("%Y-%m-%d"),
"收费区间": "\n".join(calc_range_parts) if calc_range_parts else "",
"备注": "",
"合同编号": {
'model': 'property.lease.contract',
'id': contract.id,
'display_name': contract.contract_code,
} if contract else "",
"物业名称": _build_property_display(contract, prop_name),
"承租方名称": {
'model': 'property.tenant.information',
'id': rent_rec.lessee_id.id,
'display_name': rent_rec.lessee_id.name,
} if rent_rec.lessee_id else "",
"所属公司": rent_rec.company_id.name if rent_rec.company_id else "",
})
# ========== 五、来源C:从未收/收部分租金直接出发,动态计算违约金 ==========
# 覆盖场景:租金一直没交钱,既没有 is_batch 违约金应收单(来源A),
# 也没有任何 modify.line 收款记录(来源B),但产生了动态违约金
# 入口:property.accounts.receivable 中 deposit_state in [unpaid, no_paid] 的租金记录
# 且不在来源A、来源B中出现过的
# --- 5.1 构建已处理的租金ID集合 ---
# 来源A中关联到的租金ID
source_a_rent_ids = set()
for penalty_rec in penalty_a_recs:
if penalty_rec.source_receivable_id:
source_a_rent_ids.add(penalty_rec.source_receivable_id.id)
# 来源B中涉及到的租金ID(即 rent_penalty_collected 的 key 集合)
source_b_rent_ids = set(rent_penalty_collected.keys())
# --- 5.2 查询未收/收部分的租金记录(排除已处理过的)---
# 收款类型为"租金"的记录(非违约金),且状态是未收或收部分
rent_domain = [
('management_type_id.name', '!=', '违约金'), # 收款类型非违约金(即租金)
('deposit_state', 'in', ['unpaid', 'no_paid']), # 未收或收部分
('is_invalid', '!=', True), # 排除作废
]
if query_date:
rent_domain.append(('due_date', '<=', query_date)) # 应收日已过,才开始产生违约金
unpaid_rent_recs = self.sudo().search(rent_domain, order="contract_id asc, due_date asc")
for rent_rec in unpaid_rent_recs:
# 跳过已经在来源A、B中处理过的租金
if rent_rec.id in source_a_rent_ids or rent_rec.id in source_b_rent_ids:
continue
# 内存过滤
prop_name = rent_rec.property_name or ""
if not _match_filters(rent_rec, prop_name):
continue
due_date = rent_rec.due_date
if not due_date:
continue
# --- 计算违约金起始日期:应收月的次月1号 ---
if due_date.month == 12:
penalty_start = datetime.date(due_date.year + 1, 1, 1)
else:
penalty_start = datetime.date(due_date.year, due_date.month + 1, 1)
# 违约金起始日期 > 查询日期,还没开始产生违约金
if query_date and penalty_start > query_date:
continue
# --- 获取违约金计算比例 ---
penalty_rate = _get_penalty_rate(rent_rec.contract_id)
# --- 动态计算违约金总额 ---
# 这笔租金没有任何 modify.line 收款记录,所以本金就是全额
rent_amount = (rent_rec.original_amount or 0.0) - (rent_rec.discount_amount or 0.0)
current_principal = rent_amount
total_calc_penalty = 0.0
calc_range_parts = []
# 从 penalty_start 到查询日期,整段计算
if current_principal > 0 and query_date and penalty_start <= query_date:
days = (query_date - penalty_start).days + 1
if days > 0:
segment_penalty = current_principal * days * penalty_rate
total_calc_penalty += segment_penalty
calc_range_parts.append(
f"{penalty_start.strftime('%Y-%m-%d')}至{query_date.strftime('%Y-%m-%d')} "
f"({days}天×{current_principal:.2f}×{penalty_rate})"
)
total_calc_penalty = round(total_calc_penalty, 2)
if total_calc_penalty <= 0:
continue
# 来源C:没有收到过任何钱,所以已收=0,未收=总额
penalty_collected_amt = 0.0
penalty_uncollected_amt = total_calc_penalty
# 去重 key
key = ('C', rent_rec.id)
if key in processed_keys:
continue
processed_keys.add(key)
# 租金信息
rent_collected_amt = round(rent_rec.delivered1 or 0.0, 2)
rent_uncollected_amt = round(rent_rec.submitted or 0.0, 2)
rent_status = ""
if rent_rec.deposit_state == 'paid':
rent_status = "已收"
elif rent_rec.deposit_state == 'no_paid':
rent_status = "收部分"
else:
rent_status = "未收"
# 累计统计
total_penalty += total_calc_penalty
total_uncollected += penalty_uncollected_amt
contract = rent_rec.contract_id
if not summary_only:
rows.append({
"来源": "C",
"租金ID": rent_rec.id,
"租金应收日": due_date.strftime("%Y-%m-%d"),
"租金收款日期": rent_rec.payout_date.strftime("%Y-%m-%d") if rent_rec.payout_date else "",
"租金金额": round(rent_amount, 2),
"租金已收": rent_collected_amt,
"租金未收": rent_uncollected_amt,
"租金状态": rent_status,
"违约金金额": total_calc_penalty,
"违约金已收": penalty_collected_amt,
"违约金未收": penalty_uncollected_amt,
"违约金状态": "动态", # 纯动态计算,无任何收款记录
"违约金应收开始": penalty_start.strftime("%Y-%m-%d"),
"收费区间": "\n".join(calc_range_parts) if calc_range_parts else "",
"备注": "未收租金,动态计算违约金",
"合同编号": {
'model': 'property.lease.contract',
'id': contract.id,
'display_name': contract.contract_code,
} if contract else "",
"物业名称": _build_property_display(contract, prop_name),
"承租方名称": {
'model': 'property.tenant.information',
'id': rent_rec.lessee_id.id,
'display_name': rent_rec.lessee_id.name,
} if rent_rec.lessee_id else "",
"所属公司": rent_rec.company_id.name if rent_rec.company_id else "",
})
# ========== 六、构建统计汇总数据 ==========
stats = [
{'name': '记录总数', 'amount': len(rows)},
{'name': '违约金总额', 'amount': round(total_penalty, 2)},
{'name': '违约金已收', 'amount': round(total_collected, 2)},
{'name': '违约金未收', 'amount': round(total_uncollected, 2)},
]
_logger.info("[PENALTY_ARREARS] query_date=%s, source_a=%d, source_b_rents=%d, source_c_rents=%d, total_rows=%d",
query_date, len(penalty_a_recs), len(rent_recs_b), len(unpaid_rent_recs), len(rows))
return {"rows": rows, "stats": stats}