352 lines
17 KiB
Python
352 lines
17 KiB
Python
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}
|