451 lines
22 KiB
Python
451 lines
22 KiB
Python
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}
|