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

451 lines
22 KiB
Python
Raw Permalink 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 # 日志模块,用于调试输出
import re # 正则表达式模块,用于解析收费区间字符串
from odoo import api, models # Odoo 核心 API:api 装饰器、models 模型基类
from .report_utils import _id_list # 导入 ID 列表解析工具函数,支持多选参数
_logger = logging.getLogger(__name__) # 获取当前模块的日志记录器
class PenaltyYearCumReport(models.Model):
_inherit = 'property.accounts.receivable' # 继承 property.accounts.receivable 模型,扩展报表方法
@api.model
def get_penalty_year_cum_report(self, params):
"""
违约金本年累计报表
两路数据源:
来源A:property.accounts.receivable 中收款类型为违约金 且 is_batch=True 的记录
通过 source_receivable_id 关联租金
来源B:receivable.modify.line 中 is_penalty=True 的记录
通过其 receivable_id(违约金应收单)的 charging_range 反推租金的收款日期,
在 receivable.modify.line 中找到匹配的租金记录(同承租方+同物业+同合同+同收款日期)
本年累计:来源A取 reconciliation_date 在查询年内的 modify.line;
来源B取 is_penalty 且 reconciliation_date 在查询年内的 modify.line
"""
# ========== 一、参数解析 ==========
date_start = params.get("date_start")
date_end = params.get("date_end")
summary_only = params.get("summary_only", False)
# 没有时间范围则无数据
if not date_start or not date_end:
return {"rows": [], "stats": [{'name': '记录总数', 'amount': 0}, {'name': '实收违约金', 'amount': 0}]}
# 从参数中解析年份,确定本年的完整时间范围
de_raw = datetime.datetime.fromisoformat(date_end).date()
year = de_raw.year
ds = datetime.date(year, 1, 1) # 当年1月1日
de = datetime.date(year, 12, 31) # 当年12月31日
# 其他筛选条件参数
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'))
property_id = params.get('property_id')
lessee_id = params.get('lessee_id')
contract_id = params.get('contract_id')
# ========== 辅助函数:内存过滤 ==========
def _match_filters(rec, property_name_str):
"""对 property.accounts.receivable 记录做内存过滤"""
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
# ========== 辅助函数:modify.line 内存过滤(来源B用)==========
def _match_filters_ml(ml, prop_name_str):
"""对 receivable.modify.line 记录做内存过滤"""
if keyword:
kw = keyword.lower()
match = False
if ml.contract_id and ml.contract_id.contract_code and kw in ml.contract_id.contract_code.lower():
match = True
if prop_name_str and kw in prop_name_str.lower():
match = True
if ml.lessee_id and ml.lessee_id.name and kw in ml.lessee_id.name.lower():
match = True
if not match:
return False
if partner_id and ml.precinct_id and ml.precinct_id.id not in partner_id: # modify.line 片区过滤(多选支持)
return False
if company_id and ml.company_id and ml.company_id.id not in company_id:
return False
if lessee_id and (not ml.lessee_id or ml.lessee_id.id != int(lessee_id)):
return False
if contract_id and (not ml.contract_id or ml.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 _parse_charging_range(range_str):
"""
解析收费区间字符串,提取起止日期
格式示例:"2025-10-01 到 2025-11-25共56"
返回:(start_date, end_date) 或 (None, None)
"""
if not range_str:
return None, None
# 匹配 YYYY-MM-DD 到 YYYY-MM-DD 共NN 格式
pattern = r'(\d{4}-\d{2}-\d{2})\s*到\s*(\d{4}-\d{2}-\d{2})'
match = re.search(pattern, range_str)
if match:
try:
start_d = datetime.datetime.strptime(match.group(1), '%Y-%m-%d').date()
end_d = datetime.datetime.strptime(match.group(2), '%Y-%m-%d').date()
return start_d, end_d
except ValueError:
pass
return None, None
# ========== 初始化 ==========
rows = [] # 报表行数据列表
total_collected = 0.0 # 实收违约金合计
processed_keys = set() # 去重集合
# ========== 二、来源A:property.accounts.receivable 中 is_batch=True 的违约金 ==========
# 查出 is_batch=True 且收款类型为违约金的记录
penalty_recs = self.sudo().search([
('management_type_id.name', '=', '违约金'), # 收款类型为违约金
('is_batch', '=', True), # 标记为批量修复的违约金数据
('is_invalid', '!=', True), # 排除已作废
])
# 查出这些违约金记录在查询年内的 modify.line
if penalty_recs:
modify_lines = self.env['receivable.modify.line'].sudo().search([
('receivable_id', 'in', penalty_recs.ids), # 关联到 is_batch 违约金记录
('reconciliation_date', '!=', False), # 有对账日期
('reconciliation_date', '>=', ds), # 对账日期 >= 当年1月1日
('reconciliation_date', '<=', de), # 对账日期 <= 当年12月31日
])
for ml in modify_lines:
pay_amount = ml.amount or 0.0 # 实收金额
if pay_amount <= 0:
continue
penalty_rec = ml.receivable_id # 这就是 is_batch 违约金记录
rent_rec = penalty_rec.source_receivable_id # 通过 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', ml.id)
if key in processed_keys:
continue
processed_keys.add(key)
# --- 构建租金信息 ---
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 = "未收"
# --- 构建违约金信息 ---
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)
# 累计统计
total_collected += pay_amount
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 "",
"实收金额": round(pay_amount, 2),
"收款日期": ml.reconciliation_date.strftime("%Y-%m-%d") if ml.reconciliation_date else "",
"收款备注": ml.remake1 or "",
"收费区间": 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 的记录 ==========
# 核心关系:违约金的 due_date(应收开始)= 关联租金的 payout_date(收款日期)
# 租金的 due_date +1个月 = 违约金收费区间的开始月份
# 反查逻辑:通过违约金的 due_date 直接在 property.accounts.receivable 中查找
# payout_date 等于违约金 due_date 的租金记录
source_b_domain = [
('is_penalty', '=', True), # 标记为违约金付款
('reconciliation_date', '!=', False), # 有对账日期
('reconciliation_date', '>=', ds), # 对账日期 >= 当年1月1日
('reconciliation_date', '<=', de), # 对账日期 <= 当年12月31日
]
penalty_modify_lines = self.env['receivable.modify.line'].sudo().search(source_b_domain)
# ========== 优化:批量查询所有潜在匹配的租金,消除N+1 ==========
# 收集所有需要的 penalty_due 日期
penalty_due_set = set()
for pml in penalty_modify_lines:
penalty_rec = pml.receivable_id
if not penalty_rec or not penalty_rec.due_date:
continue
penalty_due = penalty_rec.due_date.date() if hasattr(penalty_rec.due_date, 'date') else penalty_rec.due_date
penalty_due_set.add(penalty_due)
# 批量查询所有潜在匹配的租金
if penalty_due_set:
batch_rent_domain = [('payout_date', 'in', list(penalty_due_set))]
batch_rent_recs = self.sudo().search(batch_rent_domain)
# 按 (payout_date, lessee_id.id, contract_id.id) 分组
rent_by_key = {}
for rent in batch_rent_recs:
key = (
rent.payout_date.date() if hasattr(rent.payout_date, 'date') else rent.payout_date,
rent.lessee_id.id if rent.lessee_id else None,
rent.contract_id.id if rent.contract_id else None,
)
if key not in rent_by_key:
rent_by_key[key] = []
rent_by_key[key].append(rent)
else:
rent_by_key = {}
# =====================================================================
for pml in penalty_modify_lines:
# 取违约金金额(delivered1 字段)
pay_amount = round(pml.delivered1 or 0.0, 2)
if pay_amount <= 0:
continue
# 获取违约金对应的应收单(property.accounts.receivable)
penalty_rec = pml.receivable_id
if not penalty_rec:
continue
# --- 通过违约金的 due_date 反查关联租金 ---
# 关键关系:违约金的 due_date = 租金的 payout_date
penalty_due_date = penalty_rec.due_date
if not penalty_due_date:
# 违约金没有应收开始日期,无法关联租金
continue
# 统一转为 date 对象用于搜索
penalty_due = penalty_due_date.date() if hasattr(penalty_due_date, 'date') else penalty_due_date
# 从批量预分组字典中查找匹配租金,消除N+1(第262-289行已批量查询并分组)
key = (
penalty_due,
pml.lessee_id.id if pml.lessee_id else None,
pml.contract_id.id if pml.contract_id else None,
)
rent_recs = rent_by_key.get(key, [])
if not rent_recs:
continue
# --- 多候选时用收费区间开始月辅助定位 ---
# 租金 due_date +1个月 ≈ 违约金收费区间开始月
# 选 due_date 与 charging_range 开始日期最接近的租金
rent_receivable = None
if len(rent_recs) == 1:
# 只有1个候选,直接使用
rent_receivable = rent_recs[0]
else:
# 多个候选:解析 charging_range 开始日期,找 due_date 最接近的租金
charging_range = penalty_rec.charging_range or ""
range_start, _range_end = _parse_charging_range(charging_range)
best_rec = None
best_diff = None
for cand in rent_recs:
if not cand.due_date:
continue
cand_due = cand.due_date.date() if hasattr(cand.due_date, 'date') else cand.due_date
if range_start:
# 用 charging_range 开始日期与租金 due_date 比较
diff = abs((cand_due - range_start).days)
else:
# 无 charging_range,无法精确定位,取第一个
diff = 0
if best_diff is None or diff < best_diff:
best_diff = diff
best_rec = cand
if best_rec:
rent_receivable = best_rec
if not rent_receivable:
continue
# 收费区间(从违约金应收单获取)
charging_range = penalty_rec.charging_range or ""
# 物业名称(优先从违约金应收单取)
prop_name = penalty_rec.property_name or (rent_receivable.property_name if rent_receivable else "")
# 内存过滤(用违约金 modify.line 的信息过滤)
if not _match_filters_ml(pml, prop_name):
continue
# 去重 key
key = ('B', pml.id)
if key in processed_keys:
continue
processed_keys.add(key)
# --- 构建租金信息 ---
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_receivable:
# 租金金额直接取 original_amount(原始金额)
rent_amount_val = round(rent_receivable.original_amount or 0.0, 2)
rent_collected_amt = round(rent_receivable.delivered1 or 0.0, 2)
rent_uncollected_amt = round(rent_receivable.submitted or 0.0, 2)
rent_due_date_str = rent_receivable.due_date.strftime("%Y-%m-%d") if rent_receivable.due_date else ""
rent_payout_date_str = rent_receivable.payout_date.strftime("%Y-%m-%d") if rent_receivable.payout_date else ""
if rent_receivable.deposit_state == 'paid':
rent_status = "已收"
elif rent_receivable.deposit_state == 'no_paid':
rent_status = "收部分"
else:
rent_status = "未收"
# --- 构建违约金信息 ---
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)
# 累计统计
total_collected += pay_amount
contract = penalty_rec.contract_id or pml.contract_id
lessee_obj = penalty_rec.lessee_id or pml.lessee_id
company_obj = penalty_rec.company_id or pml.company_id
if not summary_only:
rows.append({
"来源": "B",
"租金ID": rent_receivable.id if rent_receivable 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 "",
"实收金额": pay_amount,
"收款日期": pml.reconciliation_date.strftime("%Y-%m-%d") if pml.reconciliation_date else "",
"收款备注": pml.remake1 or "",
"收费区间": charging_range,
"备注": 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': lessee_obj.id,
'display_name': lessee_obj.name,
} if lessee_obj else "",
"所属公司": company_obj.name if company_obj else "",
})
# ========== 四、构建统计汇总数据 ==========
stats = [
{'name': '记录总数', 'amount': len(rows)},
{'name': '实收违约金', 'amount': round(total_collected, 2)},
]
_logger.info("[PENALTY_YEAR_CUM] year=%d, ds=%s, de=%s, source_a_rows=%d, source_b_rows=%d, total=%.2f",
year, ds, de, sum(1 for r in rows if r.get('来源') == 'A'),
sum(1 for r in rows if r.get('来源') == 'B'), total_collected)
return {"rows": rows, "stats": stats}