Files
gzth/yuthon_report/models/penalty_arrears_report.py
T
2026-06-30 18:31:57 +08:00

352 lines
17 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 关联租金,直接取字段值(不动态计算)
字段映射(来源B):
- 违约金金额 = delivered1(当期金额)
- 违约金已收 = amount(已全金额)
- 违约金未收 = no_amount(未收)
"""
# ========== 一、参数解析 ==========
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 ""
# ========== 辅助函数:获取违约金计算比例 ==========
# (已废弃,来源B改为直接取 modify.line 字段值,保留函数体避免调用方报错)
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 的记录 ==========
# 严格按 receivable_number(应收账款ID)分组汇总,不动态计算,直接取 modify.line 字段值
#
# 同一笔违约金应收(receivable_number 相同)可能分多次付款(多条 modify.line):
# 违约金金额 = 该 receivable_number 下所有 modify.line.delivered1 的最大值
# 违约金已收 = 该 receivable_number 下所有 modify.line.amount 的合计
# 违约金未收 = 金额 - 已收
#
# 时间过滤:reconciliation_date <= query_date,截止查询日期只汇总日期范围内的付款
# 例:一笔违约金分两次交,第一次在查询日期前(金额99.68,未收0.32),
# 第二次也在查询日期前(金额0.32,未收0) → 合计已收100,金额100,未收0 → 不展示
# 同一笔违约金,第二次在查询日期后 → 只合计第一次的金额99.68,
# 未收=100-99.68=0.32 → 展示未收0.32
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_number(应收账款ID)分组汇总
# receivable_number → {'amount_max': 违约金金额, 'collected_sum': 已收合计,
# 'lines': [modify.lines], 'rent_rec': 租金记录}
from collections import defaultdict
penalty_groups = defaultdict(lambda: {
'amount_max': 0.0,
'collected_sum': 0.0,
'lines': [],
'rent_rec': None,
})
for ml in modify_b_lines:
rnum = ml.receivable_number
if not rnum: # 应收账款ID为空,跳过
continue
group = penalty_groups[rnum]
# 违约金金额 = delivered1 最大值(同一 receivable_number 各条 modify.line 的 delivered1 应一致)
d1 = ml.delivered1 or 0.0
if d1 > group['amount_max']:
group['amount_max'] = d1
# 已收 = amount 合计
group['collected_sum'] += (ml.amount or 0.0)
group['lines'].append(ml)
# 取租金记录(同一个 receivable_number 的 modify.line 的 receivable_id 应一致)
if group['rent_rec'] is None:
group['rent_rec'] = ml.receivable_id
# --- 遍历 removable_number 分组,生成报表行 ---
for rnum, group in penalty_groups.items():
rent_rec = group['rent_rec']
if not rent_rec:
continue
# 排除已作废的租金记录
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
# 内存时间过滤:租金应收日 <= 查询日期
if query_date and due_date > query_date:
continue
# 汇总计算(按 receivable_number 分组的汇总值)
penalty_amount = round(group['amount_max'], 2) # 违约金金额 = delivered1 最大值
penalty_collected_amt = round(group['collected_sum'], 2) # 违约金已收 = amount 合计
penalty_uncollected_amt = round(max(0.0, penalty_amount - penalty_collected_amt), 2) # 未收 = 金额 - 已收
# 累计欠收报表:只展示有未收违约金的记录
if penalty_uncollected_amt <= 0:
continue
# 去重 key(按 receivable_number,避免同一笔违约金应收重复展示)
key = ('B', rnum)
if key in processed_keys:
continue
processed_keys.add(key)
# 取该分组中最近一条 modify.line(用于显示收款日期等)
last_ml = max(group['lines'], key=lambda m: m.reconciliation_date or datetime.date(1900, 1, 1))
# 租金信息
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_status = ""
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 = 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 "",
"租金金额": rent_amount_val,
"租金已收": rent_collected_amt,
"租金未收": rent_uncollected_amt,
"租金状态": rent_status,
"违约金金额": penalty_amount,
"违约金已收": penalty_collected_amt,
"违约金未收": penalty_uncollected_amt,
"违约金状态": "未收" if penalty_uncollected_amt > 0 else "",
"违约金应收开始": last_ml.reconciliation_date.strftime("%Y-%m-%d") if last_ml.reconciliation_date 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_groups=%d, total_rows=%d",
query_date, len(penalty_a_recs), len(penalty_groups), len(rows))
return {"rows": rows, "stats": stats}