This commit is contained in:
李鹏宇
2026-06-12 18:15:51 +08:00
parent 4392462e78
commit 6484091caf
21 changed files with 1979 additions and 561 deletions
@@ -1,3 +1,4 @@
import logging
from datetime import datetime, timedelta, date
from odoo.tools.float_utils import float_round
from numpy.ma.core import default_filler
@@ -664,6 +665,7 @@ class PropertyAccountsReceivable(models.Model):
is_refunded = fields.Boolean(string='是否已退款', compute='_compute_is_refunded', store=True)
penalty = fields.Float(string="违约金基数")
penalty_date = fields.Date(string="违约金日期")
penalty_ratio_value = fields.Float(string="违约金比例", digits=(16, 6)) # 从合同 percentage_liquidated_damages.name 带入,千分比需6位精度
invoice_type = fields.Selection(
[('ordinary_invoice', '普通发票'), ('vat_invoice', '增值税发票')], string='发票类型', default='ordinary_invoice')
# 报表新增字段
@@ -1165,6 +1167,23 @@ class PropertyAccountsReceivable(models.Model):
'is_reconciliation': True
})
def fill_penalty_ratio_value(self):
# 服务器动作调用,通过 active_ids 获取选中的记录集
records = self.env["property.accounts.receivable"].browse(
self._context.get('active_ids', self._context.get('active_id')))
# 如果 context 没有 active_ids(比如通过其他方式调用),回退到 self
if not records:
records = self
# 从关联合同 percentage_liquidated_damages.name 读取违约金比例并写入 penalty_ratio_value
updated = 0
for record in records:
if record.contract_id and record.contract_id.percentage_liquidated_damages:
record.penalty_ratio_value = float(record.contract_id.percentage_liquidated_damages.name)
updated += 1
# 调试日志:打印处理了多少条记录
_logger = logging.getLogger(__name__)
_logger.info('fill_penalty_ratio_value: 处理 %d 条记录,成功写入 %d 条', len(records), updated)
def all_penalty_date(self):
receivable_ids= self.env["property.accounts.receivable"].browse(self._context.get('active_ids', self._context.get('active_id')))
for receivable in receivable_ids:
@@ -393,6 +393,7 @@
</header>
</xpath>
<xpath expr="//button[@name='receivable_log_wizard']" position="before">
<field name="penalty_ratio_value" string="违约金比例" readonly="1" optional="show"/>
<button name="penalty_wizard" string="收款明细" type="object" class="btn btn-blue" invisible="is_penalty"/>
<button name="button_accounts_wizard" string="操作" type="object" class="btn btn-blue"/>
</xpath>
@@ -662,6 +663,13 @@
<field name="state">code</field>
<field name="code">action = model.update_damages()</field>
</record>
<record id="batch_fill_penalty_ratio_value" model="ir.actions.server">
<field name="name">批量写入违约金比例</field>
<field name="model_id" ref="yuthon_property.model_property_accounts_receivable"/>
<field name="binding_model_id" ref="yuthon_property.model_property_accounts_receivable"/>
<field name="state">code</field>
<field name="code">action = model.fill_penalty_ratio_value()</field>
</record>
<record id="update_compute_lessee_id" model="ir.actions.server">
<field name="name">同步出租方和承租方</field>
<field name="model_id" ref="yuthon_property.model_property_accounts_receivable"/>
+3
View File
@@ -26,6 +26,9 @@
'views/yuthon_contract_report_views.xml',
'views/monthly_precollection_report.xml',
'views/management_area_report.xml',
'views/rent_area_report.xml',
'views/leased_area_report.xml',
'views/rental_rate_report.xml',
'views/current_month_received_report.xml',
'views/parking_fee_yearly_report.xml',
'views/current_receivable_property_fee_report.xml',
+3
View File
@@ -6,6 +6,9 @@ from . import receivables_message_report
from . import yuthon_contract_report
from . import monthly_precollection_report
from . import management_area_report
from . import rent_area_report
from . import leased_area_report
from . import rental_rate_report
from . import current_month_received_report
from . import parking_fee_yearly_report
from . import current_receivable_property_fee_report
@@ -17,9 +17,9 @@ class DepositMonthReceivedReport(models.Model):
- 金额:original_amount - discount_amount
- 状态取数逻辑(放宽 domain + 内存过滤):
- 已收(paid):payout_date 存在且在查询月内(month_first <= payout_date <= month_last)
- 已退(yes):退款日期(has_date) > 查询月末 → 查询时还没退,应包含
退款日期(has_date) <= 查询月末 → 已退,不包含
(已退状态不受 payout_date 约束)
- 已退(yes):退款日期(has_date) > 查询月末 且 收款日期(payout_date)在查询月内
→ 与前期实收互补,前期实收取 payout_date < month_first,
本月实收取 payout_date 在查询月内,避免同一记录出现在两个报表
"""
date_start = params.get("date_start")
query_date = datetime.datetime.fromisoformat(date_start).date() if date_start else datetime.date.today()
@@ -65,12 +65,13 @@ class DepositMonthReceivedReport(models.Model):
# 内存过滤:按状态分别处理
# - 已收(paid):收款日期在查询月内(month_first <= payout_date <= month_last)
# - 已退(yes):退款日期 > 查询月末(查询时还没退,不受 payout_date 约束)
# - 已退(yes):退款日期 > 查询月末 且 收款日期在查询月内(与前期实收互补,避免重复)
lines = lines.filtered(
lambda l: (
l.deposit_state == 'paid' and l.payout_date and month_first <= l.payout_date <= month_last
) or (
l.deposit_state == 'yes' and l.has_date and l.has_date > month_last
and l.payout_date and month_first <= l.payout_date <= month_last
)
)
@@ -65,12 +65,13 @@ class DepositPreReceivedReport(models.Model):
# 内存过滤:按状态分别处理
# - 已收(paid):收款日期必须存在且 < 查询月初(前期实收 = 查询月之前收到)
# - 已退(yes):退款日期 > 查询月末(查询时还没退,不受 payout_date 约束)
# - 已退(yes):退款日期 > 查询月末 且 收款日期 < 查询月初(查询时还没退,且是前期收的)
lines = lines.filtered(
lambda l: (
l.deposit_state == 'paid' and l.payout_date and l.payout_date < month_first
) or (
l.deposit_state == 'yes' and l.has_date and l.has_date > month_last
and l.payout_date and l.payout_date < month_first
)
)
+207
View File
@@ -0,0 +1,207 @@
import datetime
import logging
from odoo import api, models
_logger = logging.getLogger(__name__)
class LeasedAreaReport(models.Model):
_name = 'leased.area.report'
_description = '已租面积报表'
_auto = False
@api.model
def get_leased_area_report(self, params):
"""已租面积报表数据源。
核心逻辑:
1. 数据源为 property.lease.contract.line(物业列表明细行)
2. 日期过滤:截止查询月合同管理状态还在执行中(contact_state='ing')
3. 物业已租面积:contract_line.property_area(从 manage.information.line.rent_area 取的 compute 字段)
4. 合同已租面积:contract.total_area(合同总面积)
5. 产权证号、产权地址从 title.information.line 取
6. 片区管理员从 manage.information.line 取(按查询日期匹配)
7. 支持按物业名称(property_id)、承租方(lessee_id)、合同编号(contract_id)搜索
"""
# ========== 一、参数解析 ==========
date_start = params.get("date_start")
date_end = params.get("date_end")
# 单日期模式,优先取 date_start
query_date_str = date_start or date_end
keyword = params.get("keyword")
condition = params.get('condition')
partner_id = params.get('partner_id')
company_id = params.get('company_id')
# 前端侧边栏下拉选择器传入的 ID
property_id = params.get('property_id')
lessee_id = params.get('lessee_id')
contract_id = params.get('contract_id')
# ========== 二、查询日期解析 ==========
query_date = None
if query_date_str:
try:
# 截断时间后缀
query_date = datetime.datetime.fromisoformat(query_date_str.split(' ')[0]).date()
except Exception:
_logger.warning("[LEASED_AREA] 日期解析失败: %s", query_date_str)
# ========== 三、查询合同明细行 ==========
# 关联路径:property.lease.contract.line → address_id(yuthon.property) → lease_id(property.lease.contract)
# 核心过滤:合同管理状态为执行中
domain = [
('address_id', '!=', False),
('lease_id', '!=', False),
('lease_id.contact_state', '=', 'ing'), # 只取执行中的合同
]
# 承租方过滤
if lessee_id:
domain.append(('lease_id.lessee_id', '=', int(lessee_id)))
# 合同编号过滤
if contract_id:
domain.append(('lease_id', '=', int(contract_id)))
# 物业名称下拉选择器
if property_id:
domain.append(('address_id', '=', int(property_id)))
# 查询合同明细行
contract_lines = self.env['property.lease.contract.line'].sudo().search(domain)
# ========== 四、批量预取管理信息(片区管理员) ==========
# 需要从 manage.information.line 取片区管理员,按日期匹配
property_ids = list({cl.address_id.id for cl in contract_lines if cl.address_id})
manage_map = {} # property_id → manage.information.line
if property_ids:
manage_domain = [('yuthon_property_id', 'in', property_ids)]
if query_date:
# 按查询日期匹配管理信息行
manage_domain += [
('start_time', '<=', query_date),
'|', ('end_date', '>=', query_date), ('end_date', '=', False),
]
manage_lines = self.env['manage.information.line'].sudo().search(manage_domain)
for ml in manage_lines:
if ml.yuthon_property_id:
# 同一物业取最新的管理信息
manage_map[ml.yuthon_property_id.id] = ml
# ========== 五、构建报表行 ==========
rows = []
total_property_area = 0.0 # 物业已租面积合计
total_contract_area = 0.0 # 合同已租面积合计
for idx, cl in enumerate(contract_lines, start=1):
property_rec = cl.address_id
contract = cl.lease_id
if not property_rec or not contract:
continue
# --- 5.1 合同信息 ---
contract_code = contract.contract_code or ""
contract_record_id = contract.id
lessee_name = contract.lessee_id.name if contract.lessee_id else ""
lessee_record_id = contract.lessee_id.id if contract.lessee_id else False
# --- 5.2 关键字过滤(物业名称、承租方、合同编号) ---
if keyword:
match = False
kw = keyword.lower()
# 物业名称
if property_rec.name and kw in property_rec.name.lower():
match = True
# 承租方
if lessee_name and kw in lessee_name.lower():
match = True
# 合同编号
if contract_code and kw in contract_code.lower():
match = True
if not match:
continue
# --- 5.3 权属类型过滤 ---
manage_line = manage_map.get(property_rec.id)
if condition:
if not manage_line or manage_line.tenure_type != condition:
continue
# --- 5.4 所属片区/公司过滤(通过管理信息) ---
if partner_id:
if not manage_line or manage_line.precinct_id.id != int(partner_id):
continue
if company_id:
if not manage_line or manage_line.company_id.id != int(company_id):
continue
# --- 5.5 取产权信息(产权证号、产权地址) ---
title_certificate = ""
property_address = ""
if property_rec.title_information_ids:
valid_titles = property_rec.title_information_ids
if query_date:
valid_titles = valid_titles.filtered(
lambda t: t.start_time and t.start_time <= query_date and
(not t.end_date or t.end_date >= query_date)
)
if valid_titles:
# 按生效日期降序取最新的
latest_title = valid_titles.sorted('start_time', reverse=True)[0]
title_certificate = latest_title.title_certificate or ""
if latest_title.property_message_id:
property_address = latest_title.property_message_id.name or ""
# --- 5.6 片区管理员 ---
admin_display = ""
if manage_line and manage_line.precinct_id:
admin_display = manage_line.precinct_id.display_name or ""
# --- 5.7 双面积字段 ---
# 物业已租面积:从 contract_line.property_area 取(compute 字段,取 address_id 最新管理信息的 rent_area)
property_area = cl.property_area or 0.0
# 合同已租面积:从 contract.total_area 取
contract_area = contract.total_area or 0.0
# 累计统计
total_property_area += property_area
total_contract_area += contract_area
# --- 5.8 构建行数据(物业名称、合同编号、承租方支持穿透) ---
rows.append({
"序号": idx,
"物业id": property_rec.id,
"物业名称": {
'model': 'yuthon.property',
'id': property_rec.id,
'display_name': property_rec.name or "",
} if property_rec.name else "",
"合同编号": {
'model': 'property.lease.contract',
'id': contract_record_id,
'display_name': contract_code,
} if contract_record_id else contract_code or "",
"承租方": {
'model': 'property.tenant.information',
'id': lessee_record_id,
'display_name': lessee_name,
} if lessee_record_id else lessee_name or "",
"产权证号": title_certificate,
"产权地址": property_address,
"物业已租面积": round(property_area, 4),
"合同已租面积": round(contract_area, 4),
"片区管理员": admin_display,
})
# ========== 六、构建统计汇总数据 ==========
stats = [
{'name': '记录总数', 'amount': len(rows)},
{'name': '物业已租面积合计', 'amount': round(total_property_area, 4)},
{'name': '合同已租面积合计', 'amount': round(total_contract_area, 4)},
]
_logger.info("[LEASED_AREA] query_date=%s, rows=%d, property_area=%.4f, contract_area=%.4f",
query_date, len(rows), total_property_area, total_contract_area)
return {"rows": rows, "stats": stats}
+188 -75
View File
@@ -1,7 +1,10 @@
import datetime
import logging
from odoo import api, models
_logger = logging.getLogger(__name__)
class ManagementAreaReport(models.Model):
_name = 'management.area.report'
@@ -10,117 +13,227 @@ class ManagementAreaReport(models.Model):
@api.model
def get_management_area_report(self, params):
"""管理面积报表数据源。"""
domain = []
"""管理面积报表数据源。
keyword = params.get("keyword")
# 单日期模式,前端传 date_start
核心逻辑:
1. 数据源为 manage.information.line(管理信息明细行)
2. 日期过滤:生效日期 <= 查询日期 <= 失效日期(失效日期为空表示至今有效)
3. 管理面积直接取 manage.information.line.management_area
4. 产权证号、产权地址从 title.information.line 取(也按日期范围匹配)
5. 片区管理员取 precinct_id.display_name(格式:片区名称+管理员姓名)
6. 合同编号、承租方从 property.lease.contract.line 反查合同获取
7. 支持按物业名称(property_id)、承租方(lessee_id)、合同编号(contract_id)搜索
"""
# ========== 一、参数解析 ==========
date_start = params.get("date_start")
date_end = params.get("date_end")
# 兼容单日期和日期范围,优先取 date_start
# 单日期模式,优先取 date_start
query_date_str = date_start or date_end
keyword = params.get("keyword")
condition = params.get('condition')
partner_id = params.get('partner_id')
company_id = params.get('company_id')
# 前端侧边栏下拉选择器传入的 ID
property_id = params.get('property_id')
lessee_id = params.get('lessee_id')
contract_id = params.get('contract_id')
# 构建管理信息明细行的查询域
domain = []
query_date_obj = None
# 处理日期过滤:失效日期晚于查询日期,或没有失效日期
# ========== 二、查询日期解析 ==========
query_date = None
if query_date_str:
try:
# 将带时间后缀的格式截断,如 "2024-11-01 00:00:00" -> "2024-11-01"
query_date_obj = datetime.datetime.fromisoformat(query_date_str.split(' ')[0]).date()
domain += [
'|', ('end_date', '>=', query_date_obj), ('end_date', '=', False)
]
# 截断时间后缀,如 "2024-11-01 00:00:00" -> "2024-11-01"
query_date = datetime.datetime.fromisoformat(query_date_str.split(' ')[0]).date()
except Exception:
_logger.warning("[MANAGEMENT_AREA] 日期解析失败: %s", query_date_str)
pass
# 没有查询日期时不做时间过滤(返回所有记录)
domain = [('yuthon_property_id', '!=', False)]
# 日期过滤:生效日期 <= 查询日期,且(失效日期 >= 查询日期 或 失效日期为空)
if query_date:
domain += [
('start_time', '<=', query_date),
'|', ('end_date', '>=', query_date), ('end_date', '=', False),
]
# 其他筛选条件
if condition:
domain.append(('tenure_type', '=', condition))
if partner_id:
domain.append(('precinct_id', '=', partner_id))
if company_id:
domain.append(('company_id', '=', company_id))
# 物业名称下拉选择器:直接过滤 yuthon_property_id
if property_id:
domain.append(('yuthon_property_id', '=', int(property_id)))
# 查询管理信息明细行(过滤掉没有关联物业的无效行)
domain.append(('yuthon_property_id', '!=', False))
manage_lines = self.env['manage.information.line'].search(domain)
# ========== 三、查询管理信息明细行 ==========
manage_lines = self.env['manage.information.line'].sudo().search(domain)
# ========== 四、批量预取合同信息 ==========
# 避免每条记录单独查询,一次性查出所有物业对应的合同
# 关联路径:manage.information.line → yuthon_property_id → yuthon.property
# ← property.lease.contract.line(address_id) → lease_id → property.lease.contract
property_ids = list({line.yuthon_property_id.id for line in manage_lines if line.yuthon_property_id})
# 合同映射:property_id → 合同信息(contract_code, lessee_name, contract_id)
contract_map = {}
if property_ids:
# 搜索所有关联的合同明细行
contract_lines = self.env['property.lease.contract.line'].sudo().search([
('address_id', 'in', property_ids),
])
for cl in contract_lines:
contract = cl.lease_id
if not contract:
continue
pid = cl.address_id.id
# 同一物业可能有多份合同,用列表存储
if pid not in contract_map:
contract_map[pid] = []
contract_map[pid].append({
'contract_id': contract.id,
'contract_code': contract.contract_code or "",
'lessee_id': contract.lessee_id.id if contract.lessee_id else False,
'lessee_name': contract.lessee_id.name if contract.lessee_id else "",
'actual_start_time': contract.actual_start_time,
'actual_end_time': contract.actual_end_time,
'is_contract': contract.is_contract,
})
# ========== 五、承租方/合同编号过滤 ==========
# 前端传入 lessee_id 或 contract_id 时,需要根据合同信息过滤
# 找出符合条件的物业 ID 集合,用于过滤 manage_lines
if lessee_id or contract_id:
allowed_property_ids = set()
for pid, contracts in contract_map.items():
for c in contracts:
# 承租方匹配
if lessee_id and c['lessee_id'] != int(lessee_id):
continue
# 合同编号匹配
if contract_id and c['contract_id'] != int(contract_id):
continue
allowed_property_ids.add(pid)
break
# 过滤 manage_lines,只保留符合条件的物业
manage_lines = manage_lines.filtered(
lambda l: l.yuthon_property_id.id in allowed_property_ids
)
# ========== 六、构建报表行 ==========
rows = []
total_management_area = 0.0
total_rent_area = 0.0
total_total_area = 0.0
for idx, line in enumerate(manage_lines, start=1):
# 获取物业信息
property_id = line.yuthon_property_id
if not property_id:
property_rec = line.yuthon_property_id
if not property_rec:
continue
# 搜索关键词过滤
# --- 6.1 取合同信息(查询日期范围内生效的合同) ---
contract_code = ""
contract_record_id = False
lessee_name = ""
lessee_record_id = False
contracts = contract_map.get(property_rec.id, [])
if contracts:
# 优先取查询日期范围内生效的主合同
active_contracts = contracts
if query_date:
# 过滤:合同生效日期 <= 查询日期 且 合同失效日期 >= 查询日期
active_contracts = [
c for c in contracts
if c['actual_start_time'] and c['actual_start_time'] <= query_date
and (not c['actual_end_time'] or c['actual_end_time'] >= query_date)
]
# 优先取主合同(is_contract=True)
main_contracts = [c for c in active_contracts if c['is_contract']]
target = main_contracts[0] if main_contracts else (active_contracts[0] if active_contracts else None)
if target:
contract_code = target['contract_code']
contract_record_id = target['contract_id']
lessee_name = target['lessee_name']
lessee_record_id = target['lessee_id']
# --- 6.2 关键字过滤(物业名称、承租方、合同编号) ---
if keyword:
if not (
(property_id.name and keyword in property_id.name) or
(property_id.title_deed and keyword in property_id.title_deed) or
(property_id.code and keyword in property_id.code)
):
match = False
kw = keyword.lower()
# 物业名称
if property_rec.name and kw in property_rec.name.lower():
match = True
# 承租方
if lessee_name and kw in lessee_name.lower():
match = True
# 合同编号
if contract_code and kw in contract_code.lower():
match = True
if not match:
continue
# 获取管理类型
management_type = line.purpose_id.name if line.purpose_id else ""
# --- 6.3 取产权信息(产权证号、产权地址) ---
# 产权信息也有生效/失效日期,按查询日期范围匹配
title_certificate = ""
property_address = ""
if property_rec.title_information_ids:
valid_titles = property_rec.title_information_ids
if query_date:
valid_titles = valid_titles.filtered(
lambda t: t.start_time and t.start_time <= query_date and
(not t.end_date or t.end_date >= query_date)
)
if valid_titles:
# 按生效日期降序取最新的
latest_title = valid_titles.sorted('start_time', reverse=True)[0]
title_certificate = latest_title.title_certificate or ""
if latest_title.property_message_id:
property_address = latest_title.property_message_id.name or ""
# 获取合同编号和总面积
contract_code = ""
total_area = 0.0
contract_domain = [('yuthon_property_id', '=', property_id.id)]
# --- 6.4 片区管理员 ---
# precinct_id.display_name 格式为 "片区名称管理员姓名",如 "二D梁嘉辉"
admin_display = ""
if line.precinct_id:
admin_display = line.precinct_id.display_name or ""
if query_date_str:
contract_domain += [
'|', ('end_date', '>=', query_date_obj), ('end_date', '=', False)
]
contract_lines = self.env['property.contract.line'].search(contract_domain, limit=1)
if contract_lines:
lease_contract = contract_lines.lease_contract_id
if lease_contract:
contract_code = lease_contract.contract_code or ""
total_area = lease_contract.total_area or 0.0
# 累计面积
total_management_area += line.management_area or 0.0
total_rent_area += line.rent_area or 0.0
total_total_area += total_area
# --- 6.5 累计统计 ---
management_area = line.management_area or 0.0
total_management_area += management_area
# --- 6.6 构建行数据(物业名称、合同编号、承租方支持穿透) ---
rows.append({
"序号": idx,
"物业id": property_id.id,
"产权证号": property_id.title_deed or "",
"物业名称": property_id.name or "",
"失效日期": line.end_date.strftime("%Y-%m-%d") if line.end_date else "",
"管理类型": management_type,
"管理面积": round(line.management_area or 0.0, 4),
"可租面积": round(line.rent_area or 0.0, 4),
"合同编号": contract_code,
"总面积": round(total_area, 4),
"物业id": property_rec.id,
"物业名称": {
'model': 'yuthon.property',
'id': property_rec.id,
'display_name': property_rec.name or "",
} if property_rec.name else "",
"合同编号": {
'model': 'property.lease.contract',
'id': contract_record_id,
'display_name': contract_code,
} if contract_record_id else contract_code or "",
"承租方": {
'model': 'property.tenant.information',
'id': lessee_record_id,
'display_name': lessee_name,
} if lessee_record_id else lessee_name or "",
"产权证号": title_certificate,
"产权地址": property_address,
"管理面积": round(management_area, 4),
"片区管理员": admin_display,
})
return {
"rows": rows,
"summary": {
"序号": False,
"物业id": "合计",
"产权证号": False,
"物业名称": False,
"失效日期": False,
"管理类型": False,
"管理面积": round(total_management_area, 4),
"可租面积": round(total_rent_area, 4),
"合同编号": False,
"总面积": round(total_total_area, 4),
}
}
# ========== 七、构建统计汇总数据 ==========
stats = [
{'name': '记录总数', 'amount': len(rows)},
{'name': '管理面积合计', 'amount': round(total_management_area, 4)},
]
_logger.info("[MANAGEMENT_AREA] query_date=%s, lines=%d, rows=%d, total_area=%.4f",
query_date, len(manage_lines), len(rows), total_management_area)
return {"rows": rows, "stats": stats}
+394 -202
View File
@@ -1,231 +1,423 @@
import datetime
import datetime # 日期时间处理模块,用于日期解析、计算、格式化
import logging # 日志模块,用于调试输出
from odoo import api, models
from odoo import api, models # Odoo 核心 API:api 装饰器、models 模型基类
_logger = logging.getLogger(__name__) # 获取当前模块的日志记录器
class PenaltyArrearsReport(models.Model):
_inherit = 'property.accounts.receivable'
_inherit = 'property.accounts.receivable' # 继承 property.accounts.receivable 模型,扩展报表方法
@api.model
def get_penalty_arrears_report(self, params):
"""
违约金累计欠收报表:
- 筛选违约金记录(is_penalty=True 或 management_type_id 含"违约")
- 只取未收款:submitted > 0
- 每条记录单独一行(不按合同分组)
- 金额字段与违约金明细报表一致
违约金累计欠收报表(动态计算版)
核心逻辑:
1. Track1 — 仍有欠款的租金记录 → 分段动态计算违约金到查询日期
2. Track2 — 已结清租金的未收违约金记录 → 取违约金记录的固定金额
3. 关联方式:反查 — 同一合同+承租方,违约金.due_date == 租金.payout_date
4. 计算规则:
a) 应收月的次月1号开始计算违约金
b) 违约金按每日千分之0.1%(0.001)计算
c) 租金部分支付后,剩余部分从前一次缴费日期次日开始重新计算
5. 无收款日期的租金 = 一直没交钱 → 动态违约金(实时计算到查询月)
"""
penalty_types = self.env['yuthon.management.type'].search([('name', 'ilike', '违约')])
domain = [
('submitted', '>', 0),
('is_invalid', '!=', True),
# ========== 一、参数解析 ==========
date_end = params.get("date_end") # 查询截止日期(如 "2026-01-01 23:59:59")
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 = params.get('partner_id') # 片区 ID
company_id = 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
# 公共 domain 片段生成函数(租金和违约金共用)
def _append_common_filters(domain):
"""将公共筛选条件追加到 domain 中"""
if keyword: # 如果有关键字
domain += ['|', '|', # Odoo 前缀表达式:两个 | 实现三条件 OR
('contract_id.contract_code', 'ilike', keyword), # 合同编号模糊匹配
('property_name', 'ilike', keyword), # 物业名称模糊匹配
('lessee_id.name', 'ilike', keyword), # 承租方名称模糊匹配
]
if condition: # 如果有租赁类型
domain.append(('tenure_type', '=', condition)) # 按租赁类型过滤
if partner_id: # 如果有片区
domain.append(('precinct_id', '=', partner_id)) # 按片区过滤
if company_id: # 如果有公司
domain.append(('company_id', '=', company_id)) # 按公司过滤
if property_id: # 如果有物业
domain.append(('property_ids', 'in', [property_id])) # 按物业过滤(Many2many 用 in)
if lessee_id: # 如果有承租方
domain.append(('lessee_id', '=', lessee_id)) # 按承租方过滤
if contract_id: # 如果有合同
domain.append(('contract_id', '=', contract_id)) # 按合同过滤
return domain # 返回追加条件后的 domain
# ========== 三、搜索仍有欠款的租金记录(Track1 数据源)==========
rent_domain = [
('management_type_id.name', '=', '租金'), # 费用类型为租金
('deposit_state', 'in', ['unpaid', 'no_paid']), # 状态为未收或收部分(仍有欠款)
('is_liquidated_damages', '=', True), # 允许生成违约金标记
('is_invalid', '!=', True), # 排除已作废记录
]
rent_domain = _append_common_filters(rent_domain) # 追加公共筛选条件
date_start = params.get("date_start")
date_end = params.get("date_end")
if date_start:
domain.append(('due_date', '>=', datetime.datetime.fromisoformat(date_start).date()))
if date_end:
domain.append(('due_date', '<=', datetime.datetime.fromisoformat(date_end).date()))
# 用 sudo() 提权搜索租金记录,按合同+应收日期升序排列
rent_lines = self.sudo().search(rent_domain, order="contract_id asc, due_date asc")
# 违约金过滤
if penalty_types:
domain += ['|', ('management_type_id', 'in', penalty_types.ids), ('is_penalty', '=', True)]
else:
domain.append(('is_penalty', '=', True))
# 内存时间过滤:只保留 due_date <= 查询日期的租金记录
if query_date: # 如果有查询日期
rent_lines = rent_lines.filtered(lambda l: l.due_date and l.due_date <= query_date) # 过滤:应收日期 <= 查询日期
keyword = params.get("keyword")
if keyword:
domain += [
'|', '|',
('contract_id.contract_code', 'ilike', keyword),
('property_name', 'ilike', keyword),
('lessee_id.name', 'ilike', keyword),
]
# ========== 四、预查违约金记录,建立关联索引 ==========
# 一次性查出所有非作废的违约金记录,避免在循环中逐条查询
all_penalties = self.sudo().search([
('management_type_id.name', '=', '违约金'), # 类型为违约金
('is_invalid', '!=', True), # 排除已作废
])
# 索引1:(contract_id, lessee_id, due_date) -> penalty_rec(用于无 source_receivable_id 时备用反查)
penalty_index = {} # 初始化索引字典
# 索引2:source_receivable_id -> penalty_rec(用于 Track1 快速关联)
penalty_by_source = {} # 按原始应收ID索引的违约金字典
for p in all_penalties: # 遍历所有违约金记录
key = (p.contract_id.id, p.lessee_id.id, p.due_date) # 构建索引键:合同ID+承租方ID+应收开始日期
if key not in penalty_index: # 如果键不存在(取第一条匹配,避免重复)
penalty_index[key] = p # 存入索引
# 如果违约金有 source_receivable_id(指向原始租金记录),建立反向索引
if p.source_receivable_id: # 如果违约金记录了原始应收(租金)ID
penalty_by_source[p.source_receivable_id.id] = p # 按租金ID索引
condition = params.get('condition')
partner_id = params.get('partner_id')
company_id = params.get('company_id')
# ========== 五、初始化报表数据 ==========
rows = [] # 报表行数据列表
total_penalty = 0.0 # 违约金总额累计
total_collected = 0.0 # 违约金已收累计
total_uncollected = 0.0 # 违约金未收累计
processed_penalty_ids = set() # 已处理的违约金记录 ID 集合(避免 Track2 重复计算)
if condition:
domain.append(('tenure_type', '=', condition))
if partner_id:
domain.append(('precinct_id', '=', partner_id))
if company_id:
domain.append(('company_id', '=', company_id))
# ========== 六、Track1:仍有欠款的租金记录 → 分段动态计算违约金 ==========
for rent_rec in rent_lines: # 遍历每条仍有欠款的租金记录
due_date = rent_rec.due_date # 租金应收日期
if not due_date: # 如果没有应收日期
continue # 跳过该记录
receivables = self.search(domain, order="contract_id asc, due_date asc")
# --- 6.1 计算违约金起始日期:应收月的次月1号 ---
if due_date.month == 12: # 12月特殊处理:次年1月1日
penalty_start = datetime.date(due_date.year + 1, 1, 1) # 次年1月1日
else: # 其他月份:当月+1月的1日
penalty_start = datetime.date(due_date.year, due_date.month + 1, 1) # 次月1日
rows = []
total_penalty = total_collected = total_uncollected = 0.0
# 如果违约金起始日期 > 查询日期,说明还没开始产生违约金
if query_date and penalty_start > query_date: # 尚未产生违约金
continue # 跳过该记录
for idx, rec in enumerate(receivables, start=1):
# 计算欠租金额(直接使用合同的rent字段,即月租金)
rent_owed = 0.0
if rec.contract_id:
rent_owed = rec.contract_id.rent or 0.0
# 如果合同中没有,则使用原始金额
rent_owed = rent_owed or rec.rent_owed or 0.0
# 计算每天违约金(欠租金额 × 违约金比例)
per_day = 0.0
if rent_owed > 0 and rec.contract_id and rec.contract_id.percentage_liquidated_damages:
# 获取违约金比例
penalty_ratio = rec.contract_id.percentage_liquidated_damages.name or "0"
try:
# 尝试将比例字符串转换为浮点数
penalty_ratio = float(penalty_ratio)
# 计算每天违约金
per_day = rent_owed * penalty_ratio
except ValueError:
# 如果转换失败,使用默认值
per_day = 0.0
# 如果无法计算,则使用现有字段
per_day = per_day or rec.difference_amount or rec.per_day or 0.0
# 计算违约金金额(每天违约金 × 违约天数)
penalty_amount = 0.0
# 尝试从charging_range字段获取时间区间
billing_period = rec.charging_range or ""
days_diff = 0
if billing_period:
# 尝试从charging_range中提取天数
try:
# 假设charging_range格式为"2026-02-01 ~ 2026-02-28"
date_parts = billing_period.split(" ~ ")
if len(date_parts) == 2:
start_date_str = date_parts[0].strip()
end_date_str = date_parts[1].strip()
start_date = datetime.datetime.strptime(start_date_str, "%Y-%m-%d").date()
end_date = datetime.datetime.strptime(end_date_str, "%Y-%m-%d").date()
days_diff = (end_date - start_date).days + 1
# 构建标准格式的时间区间字符串
billing_period = f"{days_diff} {start_date_str} ~ {end_date_str}"
except Exception:
# 如果解析失败,使用默认计算
if rec.due_date:
# 固定开始日期为当月第一天
start_date = rec.due_date.replace(day=1)
# 确定结束日期
if rec.payout_date:
# 已缴费,结束日期为缴费日期
end_date = rec.payout_date
else:
# 未缴费,结束日期为查询月的最后一天
if date_end:
end_date_obj = datetime.datetime.fromisoformat(date_end).date()
# 获取查询月的最后一天
if end_date_obj.month == 12:
last_day = datetime.date(end_date_obj.year, 12, 31)
else:
last_day = datetime.date(end_date_obj.year, end_date_obj.month + 1, 1) - datetime.timedelta(days=1)
end_date = last_day
else:
# 无查询日期,使用当前月最后一天
today = datetime.date.today()
if today.month == 12:
last_day = datetime.date(today.year, 12, 31)
else:
last_day = datetime.date(today.year, today.month + 1, 1) - datetime.timedelta(days=1)
end_date = last_day
# 计算违约天数
days_diff = (end_date - start_date).days + 1 # 包含开始和结束日期
# 构建时间区间字符串
billing_period = f"{days_diff} {start_date.strftime('%Y-%m-%d')} ~ {end_date.strftime('%Y-%m-%d')}"
else:
# 如果charging_range为空,使用默认计算
if rec.due_date:
# 固定开始日期为当月第一天
start_date = rec.due_date.replace(day=1)
# 确定结束日期
if rec.payout_date:
# 已缴费,结束日期为缴费日期
end_date = rec.payout_date
else:
# 未缴费,结束日期为查询月的最后一天
if date_end:
end_date_obj = datetime.datetime.fromisoformat(date_end).date()
# 获取查询月的最后一天
if end_date_obj.month == 12:
last_day = datetime.date(end_date_obj.year, 12, 31)
else:
last_day = datetime.date(end_date_obj.year, end_date_obj.month + 1, 1) - datetime.timedelta(days=1)
end_date = last_day
else:
# 无查询日期,使用当前月最后一天
today = datetime.date.today()
if today.month == 12:
last_day = datetime.date(today.year, 12, 31)
else:
last_day = datetime.date(today.year, today.month + 1, 1) - datetime.timedelta(days=1)
end_date = last_day
# 计算违约天数
days_diff = (end_date - start_date).days + 1 # 包含开始和结束日期
# 构建时间区间字符串
billing_period = f"{days_diff} {start_date.strftime('%Y-%m-%d')} ~ {end_date.strftime('%Y-%m-%d')}"
# 计算违约金金额
penalty_amount = days_diff * per_day
# 如果计算结果为0,使用现有字段
penalty_amount = penalty_amount or rec.penalty or rec.original_amount or 0.0
# --- 6.2 获取违约金计算比例 ---
# 优先使用合同配置的比例(percentage_liquidated_damages.name),否则使用 0.001
penalty_rate = 0.001 # 默认每日千分之一(0.1%)
contract = rent_rec.contract_id # 获取租金关联的合同
if contract and contract.percentage_liquidated_damages: # 如果合同有违约金比例配置
try: # 尝试读取合同配置的比例
rate_val = float(contract.percentage_liquidated_damages.name) # 将配置值转为浮点数
if rate_val > 0: # 比例值有效(大于0)
penalty_rate = rate_val # 使用合同配置的比例
except (ValueError, TypeError): # 转换失败时保持默认值
pass # 忽略异常,继续使用默认 0.001
amount = rec.original_amount or 0.0
collected = rec.delivered1 or 0.0
uncollected = rec.submitted or 0.0
# --- 6.3 获取租金的支付历史(非违约金的 modify lines) ---
modify_lines = self.env['receivable.modify.line'].sudo().search([
('receivable_id', '=', rent_rec.id), # 属于当前租金记录
('reconciliation_date', '!=', False), # 有对账日期(已确认付款)
('is_penalty', '=', False), # 非违约金支付(只取租金支付记录)
], order='reconciliation_date asc') # 按对账日期升序排列
total_penalty += penalty_amount
total_collected += collected
total_uncollected += uncollected
# --- 6.4 分段计算违约金 ---
# 核心算法:按支付日期分段,每段独立计算违约金
# 规则3:租金部分支付后,剩余部分从前一次缴费日期次日开始重新计算
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 # 跳到下一条支付记录
# 支付日期 == 当前计算起点:当天既算违约金最后一天,也是支付日
# 支付日期 > 当前计算起点:正常分段计算
# 两种情况统一处理:计算从 current_start 到 pay_date 的违约金(包含两端)
days = (pay_date - current_start).days + 1 # 逾期天数(含首尾两天)
if days > 0 and current_principal > 0: # 有逾期天数且本金大于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})"
)
# 规则3:支付后,剩余本金从次日重新计算
current_principal = max(0, current_principal - pay_amount) # 扣减已支付金额,得到剩余本金
current_start = pay_date + datetime.timedelta(days=1) # 新起点 = 本次支付日的次日
# --- 6.5 最后一段:从最后一次支付(或起始日)到查询日期 ---
# 对于未结清的租金,违约金持续计算到查询日期
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) # 四舍五入保留2位小数
if total_calc_penalty <= 0: # 如果计算出的违约金为0或负数
continue # 跳过该记录
# --- 6.6 关联违约金记录 ---
# 优先通过 source_receivable_id 直接关联(违约金记录中存储了指向原始租金的ID)
penalty_rec = None # 初始化为空
if rent_rec.id in penalty_by_source: # 如果该租金在违约金索引中有记录
penalty_rec = penalty_by_source[rent_rec.id] # 直接获取关联的违约金记录
if penalty_rec: # 如果找到了违约金记录
processed_penalty_ids.add(penalty_rec.id) # 标记为已处理,避免 Track2 重复计算
# --- 6.7 获取已收违约金 ---
penalty_collected = 0.0 # 初始化已收违约金金额
if penalty_rec: # 如果有违约金记录
# 查询违约金记录自身的收款明细
p_modify = self.env['receivable.modify.line'].sudo().search([
('receivable_id', '=', penalty_rec.id), # 属于该违约金记录
('reconciliation_date', '!=', False), # 有对账日期(已确认付款)
])
penalty_collected = round(sum(l.amount or 0.0 for l in p_modify), 2) # 已收金额 = 所有付款行金额之和
penalty_uncollected = round(max(0, total_calc_penalty - penalty_collected), 2) # 违约金未收 = 计算金额 - 已收(不低于0)
# --- 6.8 租金信息 ---
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.submitted == 0: # submitted 为 0 表示没有未收(即已全额收款)
rent_status = "已收"
elif rent_rec.delivered1 and rent_rec.delivered1 > 0: # delivered1 > 0 表示有部分收款
rent_status = "收部分"
else: # 既非已收也非收部分,则为未收
rent_status = "未收"
# --- 6.9 违约金状态 ---
penalty_status = "" # 初始化违约金状态文字
if penalty_rec: # 如果有违约金记录
# 从 deposit_state 字段的 selection 选项中获取中文显示名
penalty_status = dict(penalty_rec._fields['deposit_state'].selection).get(
penalty_rec.deposit_state, penalty_rec.deposit_state)
else: # 没有违约金记录(动态违约金)
penalty_status = "动态" # 标记为动态违约金
# --- 6.10 违约金应收开始日期 ---
if penalty_rec and penalty_rec.due_date: # 如果有违约金记录且有应收开始日期
penalty_due_date_str = penalty_rec.due_date.strftime("%Y-%m-%d") # 取违约金记录的应收开始
else: # 动态违约金,取计算出的起始日期
penalty_due_date_str = penalty_start.strftime("%Y-%m-%d") # 取次月1号作为应收开始
# --- 6.11 累计统计 ---
total_penalty += total_calc_penalty # 累加违约金总额
total_collected += penalty_collected # 累加违约金已收
total_uncollected += penalty_uncollected # 累加违约金未收
# --- 6.12 构建物业名称显示 ---
property_display = "" # 初始化物业名称
if contract and contract.lease_ids and contract.lease_ids[0].address_id: # 如果合同有租赁明细且有物业地址
property_display = { # 带跳转链接的物业名称
'model': 'yuthon.property', # 跳转目标模型
'id': contract.lease_ids[0].address_id.id, # 物业记录 ID
'display_name': rent_rec.property_name or "", # 显示名称
}
else: # 没有物业地址时
property_display = rent_rec.property_name or "" # 纯文本显示
# --- 6.13 构建收费区间显示 ---
# 动态违约金:显示分段计算明细;有违约金记录:优先使用记录的收费区间
if calc_range_parts: # 如果有动态计算明细
charging_range_display = "\n".join(calc_range_parts) # 用换行拼接各段明细
elif penalty_rec and penalty_rec.charging_range: # 如果有违约金记录的收费区间
charging_range_display = penalty_rec.charging_range # 使用记录的收费区间
else: # 都没有
charging_range_display = "" # 显示空
# --- 6.14 构建行数据 ---
rows.append({
"序号": idx,
"合同编号": {
'model': 'property.lease.contract',
'id': rec.contract_id.id,
'display_name': rec.contract_id.contract_code,
} if rec.contract_id else "",
"物业名称": {
'model': 'yuthon.property',
'id': rec.contract_id.lease_ids[0].address_id.id,
'display_name': rec.property_name,
} if rec.contract_id and rec.contract_id.lease_ids and rec.contract_id.lease_ids[0].address_id else rec.property_name or "",
"承租方名称": {
'model': 'property.tenant.information',
'id': rec.lessee_id.id,
'display_name': rec.lessee_id.name,
} if rec.lessee_id else "",
"金额": round(amount, 2),
"应收金额": round((rec.original_amount or 0.0) - (rec.discount_amount or 0.0), 2),
"违约计算时间(天)": billing_period,
"欠租金额": round(rent_owed, 2),
"每天违约金(元/天)": round(per_day, 2),
"违约金金额": round(penalty_amount, 2),
"总已收金额": round(collected, 2),
"总未收": round(uncollected, 2),
"当月收款时间": rec.payout_date.strftime("%Y-%m-%d") if rec.payout_date else "",
"当月收款金额": round(collected, 2),
"所属公司": rec.company_id.name if rec.company_id else "",
"产权证号": rec.title_deed or "",
"管理员": {
'model': 'res.users',
'id': rec.admin_id.id,
'display_name': rec.admin_id.name,
} if rec.admin_id else "",
"租金ID": rent_rec.id, # 租金记录 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, # 违约金已收金额
"违约金未收": penalty_uncollected, # 违约金未收金额
"违约金状态": penalty_status, # 违约金状态(动态/未收/收部分)
"违约金应收开始": penalty_due_date_str, # 违约金应收开始日期
"收费区间": charging_range_display, # 收费区间(动态计算明细或记录值)
"备注": penalty_rec.remark if penalty_rec else "", # 备注
"合同编号": { # 带跳转链接的合同编号
'model': 'property.lease.contract', # 跳转目标模型
'id': contract.id, # 合同记录 ID
'display_name': contract.contract_code, # 显示合同编号
} if contract else "", # 无合同时显示空
"物业名称": property_display, # 物业名称(带链接或纯文本)
"承租方名称": { # 带跳转链接的承租方名称
'model': 'property.tenant.information', # 跳转目标模型
'id': rent_rec.lessee_id.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 "", # 所属公司名称
})
# ========== 七、Track2:已结清租金的未收违约金记录 ==========
# 这些违约金对应的租金已经全额支付,但违约金本身还没收回来
# 对于已结清租金,违约金金额是固定的(取违约金记录的 original_amount)
penalty_domain_2 = [
('management_type_id.name', '=', '违约金'), # 类型为违约金
('deposit_state', 'in', ['unpaid', 'no_paid']), # 状态为未收或收部分
('is_invalid', '!=', True), # 排除已作废
]
penalty_domain_2 = _append_common_filters(penalty_domain_2) # 追加公共筛选条件
# 搜索未收违约金记录
uncollected_penalties = self.sudo().search(penalty_domain_2, order="contract_id asc, due_date asc")
# 内存时间过滤:只保留 due_date <= 查询日期的违约金记录
if query_date: # 如果有查询日期
uncollected_penalties = uncollected_penalties.filtered(
lambda l: l.due_date and l.due_date <= query_date) # 过滤:应收开始日期 <= 查询日期
for penalty_rec in uncollected_penalties: # 遍历每条未收违约金记录
if penalty_rec.id in processed_penalty_ids: # 如果已在 Track1 中处理过
continue # 跳过,避免重复计算
# --- 7.1 关联租金:通过 source_receivable_id 直接关联 ---
# 违约金记录中存储了 source_receivable_id 字段,指向产生该违约金的原始租金记录
rent_rec = penalty_rec.source_receivable_id # 直接取关联的租金记录
# 如果 rent_rec 仍有欠款,说明它应该已在 Track1(动态计算)中被处理
if rent_rec and rent_rec.deposit_state in ['unpaid', 'no_paid']: # 租金仍有欠款
continue # 跳过(Track1 已处理)
# --- 7.2 使用违约金记录的金额(固定违约金,因为租金已结清)---
penalty_amount = round(penalty_rec.original_amount or 0.0, 2) # 违约金金额取记录的 original_amount
# --- 7.3 获取已收违约金 ---
p_modify = self.env['receivable.modify.line'].sudo().search([
('receivable_id', '=', penalty_rec.id), # 属于该违约金记录
('reconciliation_date', '!=', False), # 有对账日期(已确认付款)
])
penalty_collected = round(sum(l.amount or 0.0 for l in p_modify), 2) # 已收金额 = 所有付款行金额之和
penalty_uncollected = round(max(0, penalty_amount - penalty_collected), 2) # 未收 = 金额 - 已收(不低于0)
# --- 7.4 租金信息 ---
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.submitted == 0: # submitted 为 0 表示已全额收款
rent_status = "已收"
elif rent_rec.delivered1 and rent_rec.delivered1 > 0: # delivered1 > 0 表示有部分收款
rent_status = "收部分"
else: # 既非已收也非收部分
rent_status = "未收"
# --- 7.5 违约金状态 ---
penalty_status = dict(penalty_rec._fields['deposit_state'].selection).get(
penalty_rec.deposit_state, penalty_rec.deposit_state) # 从 selection 取中文显示名
# --- 7.6 累计统计 ---
total_penalty += penalty_amount # 累加违约金总额
total_collected += penalty_collected # 累加违约金已收
total_uncollected += penalty_uncollected # 累加违约金未收
# --- 7.7 构建物业名称显示 ---
contract = penalty_rec.contract_id # 获取违约金记录关联的合同
property_display = "" # 初始化物业名称
if contract and contract.lease_ids and contract.lease_ids[0].address_id: # 如果合同有租赁明细且有物业地址
property_display = { # 带跳转链接的物业名称
'model': 'yuthon.property', # 跳转目标模型
'id': contract.lease_ids[0].address_id.id, # 物业记录 ID
'display_name': penalty_rec.property_name or "", # 显示名称
}
else: # 没有物业地址时
property_display = penalty_rec.property_name or "" # 纯文本显示
# --- 7.8 构建行数据 ---
rows.append({
"租金ID": rent_rec.id if rent_rec else 0, # 租金记录 ID,无则为 0
"租金应收日": rent_due_date_str, # 租金应收日期
"租金收款日期": rent_payout_date_str, # 租金收款日期
"租金金额": rent_amount_val, # 租金金额
"租金已收": rent_collected_amt, # 租金已收
"租金未收": rent_uncollected_amt, # 租金未收
"租金状态": rent_status, # 租金状态
"违约金金额": penalty_amount, # 固定违约金金额(取违约金记录值)
"违约金已收": penalty_collected, # 违约金已收
"违约金未收": penalty_uncollected, # 违约金未收
"违约金状态": 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, # 合同记录 ID
'display_name': contract.contract_code, # 显示合同编号
} if contract else "", # 无合同时显示空
"物业名称": property_display, # 物业名称(带链接或纯文本)
"承租方名称": { # 带跳转链接的承租方名称
'model': 'property.tenant.information', # 跳转目标模型
'id': penalty_rec.lessee_id.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 "", # 所属公司名称
})
# ========== 八、构建统计汇总数据 ==========
stats = [
{'name': '记录总数', 'amount': len(rows)},
{'name': '违约金总额', 'amount': round(total_penalty, 2)},
{'name': '总已收', 'amount': round(total_collected, 2)},
{'name': '总未收', 'amount': round(total_uncollected, 2)},
{'name': '记录总数', 'amount': len(rows)}, # 报表行数
{'name': '违约金总额', 'amount': round(total_penalty, 2)}, # 所有违约金金额之和
{'name': '违约金已收', 'amount': round(total_collected, 2)}, # 所有违约金已收之和
{'name': '违约金未收', 'amount': round(total_uncollected, 2)}, # 所有违约金未收之和
]
return {"rows": rows, "stats": stats}
# 输出调试日志,记录参数、记录数和统计结果
_logger.info("[PENALTY_ARREARS] params=%s, track1_rents=%s, track2_penalties=%s, total_rows=%s, stats=%s",
params, len(rent_lines), len(uncollected_penalties) if 'uncollected_penalties' in dir() else 0,
len(rows), stats)
return {"rows": rows, "stats": stats} # 返回报表数据(行 + 统计)
@@ -1,132 +1,236 @@
import datetime
import datetime # 日期时间处理模块,用于日期解析和计算
import logging # 日志模块,用于调试输出
from odoo import api, models
from odoo import api, models # Odoo 核心 API:api 装饰器、models 模型基类
_logger = logging.getLogger(__name__) # 获取当前模块的日志记录器
class PenaltyMonthReceivedReport(models.Model):
_inherit = 'property.accounts.receivable'
_inherit = 'property.accounts.receivable' # 继承 property.accounts.receivable 模型,扩展报表方法
@api.model
def get_penalty_month_received_report(self, params):
"""
违约金本月实收报表:
- 筛选违约金记录(is_penalty=True 或 management_type_id 含"违约")
- 本月内有收款:payout_date 在本月第一天到最后一天之间
- 每条记录单独一行,字段与违约金累计欠收一致
违约金本月实收报表
核心逻辑:
1. 数据源:receivable.modify.line(is_penalty=True, reconciliation_date在查询月内)
2. 每笔付款单独一行,不合并,展示每笔的收款日期和金额
3. 关联租金:反查模式(同合同+承租方,违约金.due_date == 租金.payout_date)
4. receivable可能是违约金记录或租金记录,两种都需要处理
"""
today = datetime.date.today()
this_month_first = today.replace(day=1)
if today.month == 12:
next_month_first = today.replace(year=today.year + 1, month=1, day=1)
else:
next_month_first = today.replace(month=today.month + 1, day=1)
this_month_last = next_month_first - datetime.timedelta(days=1)
penalty_types = self.env['yuthon.management.type'].search([('name', 'ilike', '违约')])
# ========== 一、参数解析 ==========
date_start = params.get("date_start") # 查询月开始日期(如 "2026-01-01 00:00:00")
date_end = params.get("date_end") # 查询月结束日期(如 "2026-01-31 23:59:59")
date_start = params.get("date_start")
date_end = params.get("date_end")
start_dt = datetime.datetime.fromisoformat(date_start).date() if date_start else this_month_first
end_dt = datetime.datetime.fromisoformat(date_end).date() if date_end else this_month_last
# 没有时间范围则无数据
if not date_start or not date_end:
return {"rows": [], "stats": [{'name': '记录总数', 'amount': 0}, {'name': '实收违约金', 'amount': 0}]}
domain = [
('payout_date', '>=', start_dt),
('payout_date', '<=', end_dt),
('is_invalid', '!=', True),
]
# 解析查询时间范围为 date 对象
ds = datetime.datetime.fromisoformat(date_start).date() # 查询月开始日期
de = datetime.datetime.fromisoformat(date_end).date() # 查询月结束日期
# 违约金过滤
if penalty_types:
domain += ['|', ('management_type_id', 'in', penalty_types.ids), ('is_penalty', '=', True)]
else:
domain.append(('is_penalty', '=', True))
# 其他筛选条件参数
keyword = params.get("keyword") # 关键字搜索
condition = params.get('condition') # 租赁类型
partner_id = params.get('partner_id') # 片区 ID
company_id = 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
keyword = params.get("keyword")
if keyword:
domain += [
'|', '|',
('contract_id.contract_code', 'ilike', keyword),
('property_name', 'ilike', keyword),
('lessee_id.name', 'ilike', keyword),
]
# ========== 二、搜索查询月内违约金付款记录 ==========
# 直接从 receivable.modify.line 查找本月实收的违约金
modify_lines = self.env['receivable.modify.line'].sudo().search([
('is_penalty', '=', True), # 标记为违约金付款
('reconciliation_date', '!=', False), # 有对账日期(已确认付款)
('reconciliation_date', '>=', ds), # 对账日期 >= 查询月开始
('reconciliation_date', '<=', de), # 对账日期 <= 查询月结束
])
condition = params.get('condition')
partner_id = params.get('partner_id')
company_id = params.get('company_id')
# 没有付款记录则直接返回空
if not modify_lines:
return {"rows": [], "stats": [{'name': '记录总数', 'amount': 0}, {'name': '实收违约金', 'amount': 0}]}
if condition:
domain.append(('tenure_type', '=', condition))
if partner_id:
domain.append(('precinct_id', '=', partner_id))
if company_id:
domain.append(('company_id', '=', company_id))
# ========== 三、构建关联索引 ==========
# 收集所有涉及的合同 ID,用于预查
contract_ids = set() # 合同 ID 集合
for line in modify_lines: # 遍历每条付款记录
rec = line.receivable_id # 获取关联的应收记录
if rec.contract_id: # 如果有关联合同
contract_ids.add(rec.contract_id.id) # 添加合同 ID 到集合
receivables = self.search(domain, order="contract_id asc, due_date asc")
# 预查所有相关违约金记录(用于 source_receivable_id 为空时备用反查)
all_penalties = self.sudo().search([
('management_type_id.name', '=', '违约金'), # 类型为违约金
('is_invalid', '!=', True), # 排除已作废
('contract_id', 'in', list(contract_ids)), # 在涉及的合同范围内
])
rows = []
total_penalty = total_collected = total_uncollected = 0.0
# ========== 四、构建报表行(每笔付款单独一行) ==========
rows = [] # 报表行数据列表
total_collected = 0.0 # 本月实收违约金总计
for idx, rec in enumerate(receivables, start=1):
per_day = rec.per_day or 0.0
penalty_amount = rec.penalty or 0.0
penalty_days = round(penalty_amount / per_day, 2) if per_day else 0.0
for line in modify_lines: # 遍历每条违约金付款记录(不合并)
receivable = line.receivable_id # 获取关联的应收记录
pay_amount = round(line.amount or 0.0, 2) # 本笔收款金额
pay_date = line.reconciliation_date # 本笔收款日期
amount = rec.original_amount or 0.0
rent_owed = rec.rent_owed or 0.0
collected = rec.delivered1 or 0.0
uncollected = rec.submitted or 0.0
# 收款金额为0或负数则跳过
if pay_amount <= 0:
continue
total_penalty += penalty_amount
total_collected += collected
total_uncollected += uncollected
# --- 4.1 内存过滤:应用关键字和条件筛选 ---
contract = receivable.contract_id # 获取关联合同
lessee = receivable.lessee_id # 获取关联承租方
billing_period = ""
if rec.due_date and rec.due_date_end:
billing_period = f"{rec.due_date.strftime('%Y-%m-%d')} ~ {rec.due_date_end.strftime('%Y-%m-%d')}"
elif rec.due_date:
billing_period = rec.due_date.strftime("%Y-%m-%d")
# 关键字过滤
if keyword: # 如果有关键字条件
kw = keyword.lower() # 转小写用于匹配
match = False # 匹配标志
if contract and contract.contract_code and kw in contract.contract_code.lower(): # 合同编号匹配
match = True
if receivable.property_name and kw in receivable.property_name.lower(): # 物业名称匹配
match = True
if lessee and lessee.name and kw in lessee.name.lower(): # 承租方名称匹配
match = True
if not match: # 三项都不匹配
continue # 跳过该记录
# 租赁类型过滤
if condition and receivable.tenure_type != condition: # 租赁类型不匹配
continue
# 片区过滤
if partner_id and receivable.precinct_id.id != partner_id: # 片区不匹配
continue
# 公司过滤
if company_id and receivable.company_id.id != company_id: # 公司不匹配
continue
# 物业过滤
if property_id and property_id not in (receivable.property_ids.ids or []): # 物业不匹配
continue
# 承租方过滤
if lessee_id and (not lessee or lessee.id != lessee_id): # 承租方不匹配
continue
# 合同过滤
if contract_id and (not contract or contract.id != contract_id): # 合同不匹配
continue
# --- 4.2 判断 receivable 类型并关联租金 ---
# receivable 可能是违约金记录或租金记录
is_penalty_rec = receivable.management_type_id.name == '违约金' # 是否为违约金记录
rent_rec = None # 关联的租金记录
penalty_rec = None # 关联的违约金记录
if is_penalty_rec: # 如果 receivable 本身就是违约金记录
penalty_rec = receivable # 直接使用
# 通过 source_receivable_id 直接关联到原始租金记录
rent_rec = penalty_rec.source_receivable_id # 直接取违约金中存储的原始应收(租金)记录
else: # receivable 是租金记录
rent_rec = receivable # 直接使用
# --- 4.3 租金信息 ---
rent_amount = 0.0 # 租金金额
rent_collected = 0.0 # 租金已收
rent_uncollected = 0.0 # 租金未收
rent_status = "" # 租金状态
rent_due_date = "" # 租金应收日
rent_payout_date = "" # 租金收款日期
if rent_rec: # 如果找到了关联租金记录
rent_amount = round((rent_rec.original_amount or 0.0) - (rent_rec.discount_amount or 0.0), 2) # 租金金额 = 原金额 - 折扣
rent_collected = round(rent_rec.delivered1 or 0.0, 2) # 租金已收
rent_uncollected = round(rent_rec.submitted or 0.0, 2) # 租金未收
rent_due_date = rent_rec.due_date.strftime("%Y-%m-%d") if rent_rec.due_date else "" # 租金应收日
rent_payout_date = 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 = "未收"
# --- 4.4 违约金信息 ---
penalty_amount = 0.0 # 违约金金额
penalty_collected = 0.0 # 违约金已收(全部历史)
penalty_uncollected = 0.0 # 违约金未收
penalty_status = "" # 违约金状态
penalty_start_date = "" # 违约金应收开始
if penalty_rec: # 如果有违约金记录
penalty_amount = round(penalty_rec.original_amount or 0.0, 2) # 违约金金额
penalty_collected = round(penalty_rec.delivered1 or 0.0, 2) # 违约金已收
penalty_uncollected = round(penalty_rec.submitted or 0.0, 2) # 违约金未收
penalty_start_date = penalty_rec.due_date.strftime("%Y-%m-%d") if penalty_rec.due_date else "" # 应收开始日期
# 违约金状态(从 selection 取中文显示名)
penalty_status = dict(penalty_rec._fields['deposit_state'].selection).get(
penalty_rec.deposit_state, penalty_rec.deposit_state)
# --- 4.5 累计统计 ---
total_collected += pay_amount # 累加本笔实收违约金
# --- 4.6 构建物业名称显示 ---
property_display = "" # 初始化物业名称
if contract and contract.lease_ids and contract.lease_ids[0].address_id: # 如果合同有租赁明细且有物业地址
property_display = { # 带跳转链接的物业名称
'model': 'yuthon.property', # 跳转目标模型
'id': contract.lease_ids[0].address_id.id, # 物业记录 ID
'display_name': receivable.property_name or "", # 显示名称
}
else: # 没有物业地址时
property_display = receivable.property_name or "" # 纯文本显示
# --- 4.7 构建行数据(每笔付款一行) ---
rows.append({
"序号": idx,
"合同编号": {
'model': 'property.lease.contract',
'id': rec.contract_id.id,
'display_name': rec.contract_id.contract_code,
} if rec.contract_id else "",
"物业名称": {
'model': 'yuthon.property',
'id': rec.contract_id.lease_ids[0].address_id.id,
'display_name': rec.property_name,
} if rec.contract_id and rec.contract_id.lease_ids and rec.contract_id.lease_ids[0].address_id else rec.property_name or "",
"承租方名称": {
'model': 'property.tenant.information',
'id': rec.lessee_id.id,
'display_name': rec.lessee_id.name,
} if rec.lessee_id else "",
"金额": round(amount, 2),
"应收金额": round((rec.original_amount or 0.0) - (rec.discount_amount or 0.0), 2),
"违约计算时间(天)": billing_period,
"欠租金额": round(rent_owed, 2),
"每天违约金(元/天)": round(per_day, 2),
"违约金金额": round(penalty_amount, 2),
"总已收金额": round(collected, 2),
"总未收": round(uncollected, 2),
"当月收款时间": rec.payout_date.strftime("%Y-%m-%d") if rec.payout_date else "",
"当月收款金额": round(collected, 2),
"所属公司": rec.company_id.name if rec.company_id else "",
"产权证号": rec.title_deed or "",
"管理员": {
'model': 'res.users',
'id': rec.admin_id.id,
'display_name': rec.admin_id.name,
} if rec.admin_id else "",
"租金ID": rent_rec.id if rent_rec else "", # 租金记录 ID
"租金应收日": rent_due_date, # 租金应收日期
"租金收款日期": rent_payout_date, # 租金收款日期
"租金金额": rent_amount, # 租金金额
"租金已收": rent_collected, # 租金已收
"租金未收": rent_uncollected, # 租金未收
"租金状态": rent_status, # 租金状态
"违约金金额": penalty_amount, # 违约金金额
"违约金已收": penalty_collected, # 违约金已收(全部历史)
"违约金未收": penalty_uncollected, # 违约金未收
"违约金状态": penalty_status, # 违约金状态
"违约金应收开始": penalty_start_date, # 违约金应收开始日期
"实收金额": pay_amount, # 本笔实收违约金金额
"收款日期": pay_date.strftime("%Y-%m-%d") if pay_date else "", # 本笔收款日期
"收款备注": line.remake1 or "", # 本笔收款备注
"收费区间": receivable.charging_range or "", # 收费区间
"备注": receivable.remark or "", # 备注
"合同编号": { # 带跳转链接的合同编号
'model': 'property.lease.contract', # 跳转目标模型
'id': contract.id, # 合同记录 ID
'display_name': contract.contract_code, # 显示合同编号
} if contract else "", # 无合同时显示空
"物业名称": property_display, # 物业名称
"承租方名称": { # 带跳转链接的承租方名称
'model': 'property.tenant.information', # 跳转目标模型
'id': lessee.id, # 承租方记录 ID
'display_name': lessee.name, # 显示承租方名称
} if lessee else "", # 无承租方时显示空
"所属公司": receivable.company_id.name if receivable.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)},
{'name': '记录总数', 'amount': len(rows)}, # 报表行数
{'name': '实收违约金', 'amount': round(total_collected, 2)}, # 本月实收违约金总额
]
return {"rows": rows, "stats": stats}
# 输出调试日志
_logger.info("[PENALTY_MONTH_RECEIVED] ds=%s, de=%s, rows=%d, total=%.2f",
ds, de, len(rows), total_collected)
return {"rows": rows, "stats": stats} # 返回报表数据(行 + 统计)
+213 -90
View File
@@ -1,116 +1,239 @@
import datetime
import datetime # 日期时间处理模块,用于日期解析和计算
import logging # 日志模块,用于调试输出
from odoo import api, models
from odoo import api, models # Odoo 核心 API:api 装饰器、models 模型基类
_logger = logging.getLogger(__name__) # 获取当前模块的日志记录器
class PenaltyYearCumReport(models.Model):
_inherit = 'property.accounts.receivable'
_inherit = 'property.accounts.receivable' # 继承 property.accounts.receivable 模型,扩展报表方法
@api.model
def get_penalty_year_cum_report(self, params):
"""
违约金本年累计报表:
- 筛选违约金记录(is_penalty=True 或 management_type_id 含"违约")
- 本年内有收款:payout_date 在本年第一天到最后一天之间
- 按合同分组,金额 = sum(penalty)
- 字段:序号/合同编号/物业名称/承租方名称/金额/所属公司/管理员
违约金本年累计报表
核心逻辑:
1. 数据源:receivable.modify.line(is_penalty=True, reconciliation_date在查询年内)
2. 查询年范围:当年1月1日 到 当年12月31日(整年)
3. 每笔付款单独一行,不合并,展示每笔的收款日期和金额
4. 关联租金:反查模式(同合同+承租方,违约金.due_date == 租金.payout_date)
"""
today = datetime.date.today()
year_first = today.replace(month=1, day=1)
year_last = today.replace(month=12, day=31)
penalty_types = self.env['yuthon.management.type'].search([('name', 'ilike', '违约')])
# ========== 一、参数解析 ==========
date_start = params.get("date_start") # 查询开始日期(如 "2026-01-01 00:00:00")
date_end = params.get("date_end") # 查询结束日期(如 "2026-01-31 23:59:59")
date_start = params.get("date_start")
date_end = params.get("date_end")
start_dt = datetime.datetime.fromisoformat(date_start).date() if date_start else year_first
end_dt = datetime.datetime.fromisoformat(date_end).date() if date_end else year_last
# 没有时间范围则无数据
if not date_start or not date_end:
return {"rows": [], "stats": [{'name': '记录总数', 'amount': 0}, {'name': '实收违约金', 'amount': 0}]}
domain = [
('payout_date', '>=', start_dt),
('payout_date', '<=', end_dt),
('is_invalid', '!=', True),
]
# 从参数中解析年份,确定本年的完整时间范围
de = datetime.datetime.fromisoformat(date_end).date() # 解析查询结束日期
year = de.year # 取查询年份
# 本年累计:1月1日 到 12月31日(整年)
ds = datetime.date(year, 1, 1) # 当年1月1日
de = datetime.date(year, 12, 31) # 当年12月31日
# 违约金过滤
if penalty_types:
domain += ['|', ('management_type_id', 'in', penalty_types.ids), ('is_penalty', '=', True)]
else:
domain.append(('is_penalty', '=', True))
# 其他筛选条件参数
keyword = params.get("keyword") # 关键字搜索
condition = params.get('condition') # 租赁类型
partner_id = params.get('partner_id') # 片区 ID
company_id = 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
keyword = params.get("keyword")
if keyword:
domain += [
'|', '|',
('contract_id.contract_code', 'ilike', keyword),
('property_name', 'ilike', keyword),
('lessee_id.name', 'ilike', keyword),
]
# ========== 二、搜索查询年内违约金付款记录 ==========
# 直接从 receivable.modify.line 查找本年实收的违约金
modify_lines = self.env['receivable.modify.line'].sudo().search([
('is_penalty', '=', True), # 标记为违约金付款
('reconciliation_date', '!=', False), # 有对账日期(已确认付款)
('reconciliation_date', '>=', ds), # 对账日期 >= 当年1月1日
('reconciliation_date', '<=', de), # 对账日期 <= 当年12月31日
])
condition = params.get('condition')
partner_id = params.get('partner_id')
company_id = params.get('company_id')
# 没有付款记录则直接返回空
if not modify_lines:
return {"rows": [], "stats": [{'name': '记录总数', 'amount': 0}, {'name': '实收违约金', 'amount': 0}]}
if condition:
domain.append(('tenure_type', '=', condition))
if partner_id:
domain.append(('precinct_id', '=', partner_id))
if company_id:
domain.append(('company_id', '=', company_id))
# ========== 三、构建关联索引 ==========
# 收集所有涉及的合同 ID,用于预查
contract_ids = set() # 合同 ID 集合
for line in modify_lines: # 遍历每条付款记录
rec = line.receivable_id # 获取关联的应收记录
if rec.contract_id: # 如果有关联合同
contract_ids.add(rec.contract_id.id) # 添加合同 ID 到集合
receivables = self.search(domain, order="contract_id asc, due_date asc")
# 预查所有相关违约金记录(用于 source_receivable_id 为空时备用反查)
all_penalties = self.sudo().search([
('management_type_id.name', '=', '违约金'), # 类型为违约金
('is_invalid', '!=', True), # 排除已作废
('contract_id', 'in', list(contract_ids)), # 在涉及的合同范围内
])
# 按合同分组,金额 = sum(penalty)
contract_map = {}
for rec in receivables:
cid = rec.contract_id.id if rec.contract_id else 0
if cid not in contract_map:
contract_map[cid] = {
'info': rec,
'total_penalty': 0.0,
'total_receivable': 0.0,
# ========== 四、构建报表行(每笔付款单独一行) ==========
rows = [] # 报表行数据列表
total_collected = 0.0 # 本年实收违约金总计
for line in modify_lines: # 遍历每条违约金付款记录(不合并)
receivable = line.receivable_id # 获取关联的应收记录
pay_amount = round(line.amount or 0.0, 2) # 本笔收款金额
pay_date = line.reconciliation_date # 本笔收款日期
# 收款金额为0或负数则跳过
if pay_amount <= 0:
continue
# --- 4.1 内存过滤:应用关键字和条件筛选 ---
contract = receivable.contract_id # 获取关联合同
lessee = receivable.lessee_id # 获取关联承租方
# 关键字过滤
if keyword: # 如果有关键字条件
kw = keyword.lower() # 转小写用于匹配
match = False # 匹配标志
if contract and contract.contract_code and kw in contract.contract_code.lower(): # 合同编号匹配
match = True
if receivable.property_name and kw in receivable.property_name.lower(): # 物业名称匹配
match = True
if lessee and lessee.name and kw in lessee.name.lower(): # 承租方名称匹配
match = True
if not match: # 三项都不匹配
continue # 跳过该记录
# 租赁类型过滤
if condition and receivable.tenure_type != condition: # 租赁类型不匹配
continue
# 片区过滤
if partner_id and receivable.precinct_id.id != partner_id: # 片区不匹配
continue
# 公司过滤
if company_id and receivable.company_id.id != company_id: # 公司不匹配
continue
# 物业过滤
if property_id and property_id not in (receivable.property_ids.ids or []): # 物业不匹配
continue
# 承租方过滤
if lessee_id and (not lessee or lessee.id != lessee_id): # 承租方不匹配
continue
# 合同过滤
if contract_id and (not contract or contract.id != contract_id): # 合同不匹配
continue
# --- 4.2 判断 receivable 类型并关联租金 ---
# receivable 可能是违约金记录或租金记录
is_penalty_rec = receivable.management_type_id.name == '违约金' # 是否为违约金记录
rent_rec = None # 关联的租金记录
penalty_rec = None # 关联的违约金记录
if is_penalty_rec: # 如果 receivable 本身就是违约金记录
penalty_rec = receivable # 直接使用
# 通过 source_receivable_id 直接关联到原始租金记录
rent_rec = penalty_rec.source_receivable_id # 直接取违约金中存储的原始应收(租金)记录
else: # receivable 是租金记录
rent_rec = receivable # 直接使用
# --- 4.3 租金信息 ---
rent_amount = 0.0 # 租金金额
rent_collected = 0.0 # 租金已收
rent_uncollected = 0.0 # 租金未收
rent_status = "" # 租金状态
rent_due_date = "" # 租金应收日
rent_payout_date = "" # 租金收款日期
if rent_rec: # 如果找到了关联租金记录
rent_amount = round((rent_rec.original_amount or 0.0) - (rent_rec.discount_amount or 0.0), 2) # 租金金额 = 原金额 - 折扣
rent_collected = round(rent_rec.delivered1 or 0.0, 2) # 租金已收
rent_uncollected = round(rent_rec.submitted or 0.0, 2) # 租金未收
rent_due_date = rent_rec.due_date.strftime("%Y-%m-%d") if rent_rec.due_date else "" # 租金应收日
rent_payout_date = 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 = "未收"
# --- 4.4 违约金信息 ---
penalty_amount = 0.0 # 违约金金额
penalty_collected = 0.0 # 违约金已收(全部历史)
penalty_uncollected = 0.0 # 违约金未收
penalty_status = "" # 违约金状态
penalty_start_date = "" # 违约金应收开始
if penalty_rec: # 如果有违约金记录
penalty_amount = round(penalty_rec.original_amount or 0.0, 2) # 违约金金额
penalty_collected = round(penalty_rec.delivered1 or 0.0, 2) # 违约金已收
penalty_uncollected = round(penalty_rec.submitted or 0.0, 2) # 违约金未收
penalty_start_date = penalty_rec.due_date.strftime("%Y-%m-%d") if penalty_rec.due_date else "" # 应收开始日期
# 违约金状态(从 selection 取中文显示名)
penalty_status = dict(penalty_rec._fields['deposit_state'].selection).get(
penalty_rec.deposit_state, penalty_rec.deposit_state)
# --- 4.5 累计统计 ---
total_collected += pay_amount # 累加本笔实收违约金
# --- 4.6 构建物业名称显示 ---
property_display = "" # 初始化物业名称
if contract and contract.lease_ids and contract.lease_ids[0].address_id: # 如果合同有租赁明细且有物业地址
property_display = { # 带跳转链接的物业名称
'model': 'yuthon.property', # 跳转目标模型
'id': contract.lease_ids[0].address_id.id, # 物业记录 ID
'display_name': receivable.property_name or "", # 显示名称
}
contract_map[cid]['total_penalty'] += rec.penalty or 0.0
contract_map[cid]['total_receivable'] += (rec.original_amount or 0.0) - (rec.discount_amount or 0.0)
rows = []
total_amount = 0.0
for idx, (cid, data) in enumerate(contract_map.items(), start=1):
rec = data['info']
contract = rec.contract_id
amount = round(data['total_penalty'], 2)
total_amount += amount
else: # 没有物业地址时
property_display = receivable.property_name or "" # 纯文本显示
# --- 4.7 构建行数据(每笔付款一行) ---
rows.append({
"序号": idx,
"合同编号": {
'model': 'property.lease.contract',
'id': contract.id,
'display_name': contract.contract_code,
} if contract else "",
"物业名称": {
'model': 'yuthon.property',
'id': rec.contract_id.lease_ids[0].address_id.id,
'display_name': rec.property_name,
} if rec.contract_id and rec.contract_id.lease_ids and rec.contract_id.lease_ids[0].address_id else rec.property_name or "",
"承租方名称": {
'model': 'property.tenant.information',
'id': rec.lessee_id.id,
'display_name': rec.lessee_id.name,
} if rec.lessee_id else "",
"金额": amount,
"应收金额": round(data['total_receivable'], 2),
"所属公司": rec.company_id.name if rec.company_id else "",
"管理员": {
'model': 'res.users',
'id': rec.admin_id.id,
'display_name': rec.admin_id.name,
} if rec.admin_id else "",
"租金ID": rent_rec.id if rent_rec else "", # 租金记录 ID
"租金应收日": rent_due_date, # 租金应收日期
"租金收款日期": rent_payout_date, # 租金收款日期
"租金金额": rent_amount, # 租金金额
"租金已收": rent_collected, # 租金已收
"租金未收": rent_uncollected, # 租金未收
"租金状态": rent_status, # 租金状态
"违约金金额": penalty_amount, # 违约金金额
"违约金已收": penalty_collected, # 违约金已收(全部历史)
"违约金未收": penalty_uncollected, # 违约金未收
"违约金状态": penalty_status, # 违约金状态
"违约金应收开始": penalty_start_date, # 违约金应收开始日期
"实收金额": pay_amount, # 本笔实收违约金金额
"收款日期": pay_date.strftime("%Y-%m-%d") if pay_date else "", # 本笔收款日期
"收款备注": line.remake1 or "", # 本笔收款备注
"收费区间": receivable.charging_range or "", # 收费区间
"备注": receivable.remark or "", # 备注
"合同编号": { # 带跳转链接的合同编号
'model': 'property.lease.contract', # 跳转目标模型
'id': contract.id, # 合同记录 ID
'display_name': contract.contract_code, # 显示合同编号
} if contract else "", # 无合同时显示空
"物业名称": property_display, # 物业名称
"承租方名称": { # 带跳转链接的承租方名称
'model': 'property.tenant.information', # 跳转目标模型
'id': lessee.id, # 承租方记录 ID
'display_name': lessee.name, # 显示承租方名称
} if lessee else "", # 无承租方时显示空
"所属公司": receivable.company_id.name if receivable.company_id else "", # 所属公司
})
# ========== 五、构建统计汇总数据 ==========
stats = [
{'name': '合同总数', 'amount': len(rows)},
{'name': '违约金总额', 'amount': round(total_amount, 2)},
{'name': '记录总数', 'amount': len(rows)}, # 报表行数
{'name': '实收违约金', 'amount': round(total_collected, 2)}, # 本年实收违约金总额
]
return {"rows": rows, "stats": stats}
# 输出调试日志
_logger.info("[PENALTY_YEAR_CUM] year=%d, ds=%s, de=%s, rows=%d, total=%.2f",
year, ds, de, len(rows), total_collected)
return {"rows": rows, "stats": stats} # 返回报表数据(行 + 统计)
+235
View File
@@ -0,0 +1,235 @@
import datetime
import logging
from odoo import api, models
_logger = logging.getLogger(__name__)
class RentAreaReport(models.Model):
_name = 'rent.area.report'
_description = '可租面积报表'
_auto = False
@api.model
def get_rent_area_report(self, params):
"""可租面积报表数据源。
核心逻辑:
1. 数据源为 manage.information.line(管理信息明细行)
2. 日期过滤:生效日期 <= 查询日期 <= 失效日期(失效日期为空表示至今有效)
3. 可租面积直接取 manage.information.line.rent_area
4. 产权证号、产权地址从 title.information.line 取(也按日期范围匹配)
5. 片区管理员取 precinct_id.display_name
6. 合同编号、承租方从 property.lease.contract.line 反查合同获取
7. 支持按物业名称(property_id)、承租方(lessee_id)、合同编号(contract_id)搜索
"""
# ========== 一、参数解析 ==========
date_start = params.get("date_start")
date_end = params.get("date_end")
# 单日期模式,优先取 date_start
query_date_str = date_start or date_end
keyword = params.get("keyword")
condition = params.get('condition')
partner_id = params.get('partner_id')
company_id = params.get('company_id')
# 前端侧边栏下拉选择器传入的 ID
property_id = params.get('property_id')
lessee_id = params.get('lessee_id')
contract_id = params.get('contract_id')
# ========== 二、查询日期解析 ==========
query_date = None
if query_date_str:
try:
# 截断时间后缀,如 "2024-11-01 00:00:00" -> "2024-11-01"
query_date = datetime.datetime.fromisoformat(query_date_str.split(' ')[0]).date()
except Exception:
_logger.warning("[RENT_AREA] 日期解析失败: %s", query_date_str)
# 没有查询日期时不做时间过滤(返回所有记录)
domain = [('yuthon_property_id', '!=', False)]
# 日期过滤:生效日期 <= 查询日期,且(失效日期 >= 查询日期 或 失效日期为空)
if query_date:
domain += [
('start_time', '<=', query_date),
'|', ('end_date', '>=', query_date), ('end_date', '=', False),
]
# 其他筛选条件
if condition:
domain.append(('tenure_type', '=', condition))
if partner_id:
domain.append(('precinct_id', '=', partner_id))
if company_id:
domain.append(('company_id', '=', company_id))
# 物业名称下拉选择器:直接过滤 yuthon_property_id
if property_id:
domain.append(('yuthon_property_id', '=', int(property_id)))
# ========== 三、查询管理信息明细行 ==========
manage_lines = self.env['manage.information.line'].sudo().search(domain)
# ========== 四、批量预取合同信息 ==========
# 避免每条记录单独查询,一次性查出所有物业对应的合同
# 关联路径:manage.information.line → yuthon_property_id → yuthon.property
# ← property.lease.contract.line(address_id) → lease_id → property.lease.contract
property_ids = list({line.yuthon_property_id.id for line in manage_lines if line.yuthon_property_id})
# 合同映射:property_id → 合同信息列表
contract_map = {}
if property_ids:
contract_lines = self.env['property.lease.contract.line'].sudo().search([
('address_id', 'in', property_ids),
])
for cl in contract_lines:
contract = cl.lease_id
if not contract:
continue
pid = cl.address_id.id
# 同一物业可能有多份合同,用列表存储
if pid not in contract_map:
contract_map[pid] = []
contract_map[pid].append({
'contract_id': contract.id,
'contract_code': contract.contract_code or "",
'lessee_id': contract.lessee_id.id if contract.lessee_id else False,
'lessee_name': contract.lessee_id.name if contract.lessee_id else "",
'actual_start_time': contract.actual_start_time,
'actual_end_time': contract.actual_end_time,
'is_contract': contract.is_contract,
'total_area': contract.total_area or 0.0,
})
# ========== 五、承租方/合同编号过滤 ==========
# 前端传入 lessee_id 或 contract_id 时,需要根据合同信息过滤
if lessee_id or contract_id:
allowed_property_ids = set()
for pid, contracts in contract_map.items():
for c in contracts:
# 承租方匹配
if lessee_id and c['lessee_id'] != int(lessee_id):
continue
# 合同编号匹配
if contract_id and c['contract_id'] != int(contract_id):
continue
allowed_property_ids.add(pid)
break
# 过滤 manage_lines,只保留符合条件的物业
manage_lines = manage_lines.filtered(
lambda l: l.yuthon_property_id.id in allowed_property_ids
)
# ========== 六、构建报表行 ==========
rows = []
total_rent_area = 0.0
for idx, line in enumerate(manage_lines, start=1):
property_rec = line.yuthon_property_id
if not property_rec:
continue
# --- 6.1 取合同信息(查询日期范围内生效的合同) ---
contract_code = ""
contract_record_id = False
lessee_name = ""
lessee_record_id = False
contracts = contract_map.get(property_rec.id, [])
if contracts:
# 优先取查询日期范围内生效的主合同
active_contracts = contracts
if query_date:
# 过滤:合同生效日期 <= 查询日期 且 合同失效日期 >= 查询日期
active_contracts = [
c for c in contracts
if c['actual_start_time'] and c['actual_start_time'] <= query_date
and (not c['actual_end_time'] or c['actual_end_time'] >= query_date)
]
# 优先取主合同(is_contract=True)
main_contracts = [c for c in active_contracts if c['is_contract']]
target = main_contracts[0] if main_contracts else (active_contracts[0] if active_contracts else None)
if target:
contract_code = target['contract_code']
contract_record_id = target['contract_id']
lessee_name = target['lessee_name']
lessee_record_id = target['lessee_id']
# --- 6.2 关键字过滤(物业名称、承租方、合同编号) ---
if keyword:
match = False
kw = keyword.lower()
# 物业名称
if property_rec.name and kw in property_rec.name.lower():
match = True
# 承租方
if lessee_name and kw in lessee_name.lower():
match = True
# 合同编号
if contract_code and kw in contract_code.lower():
match = True
if not match:
continue
# --- 6.3 取产权信息(产权证号、产权地址) ---
title_certificate = ""
property_address = ""
if property_rec.title_information_ids:
valid_titles = property_rec.title_information_ids
if query_date:
valid_titles = valid_titles.filtered(
lambda t: t.start_time and t.start_time <= query_date and
(not t.end_date or t.end_date >= query_date)
)
if valid_titles:
# 按生效日期降序取最新的
latest_title = valid_titles.sorted('start_time', reverse=True)[0]
title_certificate = latest_title.title_certificate or ""
if latest_title.property_message_id:
property_address = latest_title.property_message_id.name or ""
# --- 6.4 片区管理员 ---
admin_display = ""
if line.precinct_id:
admin_display = line.precinct_id.display_name or ""
# --- 6.5 累计统计 ---
rent_area = line.rent_area or 0.0
total_rent_area += rent_area
# --- 6.6 构建行数据(物业名称、合同编号、承租方支持穿透) ---
rows.append({
"序号": idx,
"物业id": property_rec.id,
"物业名称": {
'model': 'yuthon.property',
'id': property_rec.id,
'display_name': property_rec.name or "",
} if property_rec.name else "",
"合同编号": {
'model': 'property.lease.contract',
'id': contract_record_id,
'display_name': contract_code,
} if contract_record_id else contract_code or "",
"承租方": {
'model': 'property.tenant.information',
'id': lessee_record_id,
'display_name': lessee_name,
} if lessee_record_id else lessee_name or "",
"产权证号": title_certificate,
"产权地址": property_address,
"可租面积": round(rent_area, 4),
"片区管理员": admin_display,
})
# ========== 七、构建统计汇总数据 ==========
stats = [
{'name': '记录总数', 'amount': len(rows)},
{'name': '可租面积合计', 'amount': round(total_rent_area, 4)},
]
_logger.info("[RENT_AREA] query_date=%s, lines=%d, rows=%d, total_area=%.4f",
query_date, len(manage_lines), len(rows), total_rent_area)
return {"rows": rows, "stats": stats}
+249
View File
@@ -0,0 +1,249 @@
import datetime
import logging
from odoo import api, models
_logger = logging.getLogger(__name__)
class RentalRateReport(models.Model):
_name = 'rental.rate.report'
_description = '出租率报表'
_auto = False
@api.model
def get_rental_rate_report(self, params):
"""出租率报表数据源。
核心逻辑:
1. 数据源为 manage.information.line(管理信息明细行),取可租面积
2. 通过 property.lease.contract.line 反查执行中合同,取物业已租面积和合同已租面积
3. 物业已租面积:contract_line.property_area(compute 字段)
4. 合同已租面积:contract.total_area(合同总面积)
5. 出租率 = 物业已租面积 / 可租面积 * 100
6. 产权证号、产权地址从 title.information.line 取
7. 片区管理员从 precinct_id.display_name 取
8. 支持按物业名称(property_id)、承租方(lessee_id)、合同编号(contract_id)搜索
"""
# ========== 一、参数解析 ==========
date_start = params.get("date_start")
date_end = params.get("date_end")
# 单日期模式,优先取 date_start
query_date_str = date_start or date_end
keyword = params.get("keyword")
condition = params.get('condition')
partner_id = params.get('partner_id')
company_id = params.get('company_id')
# 前端侧边栏下拉选择器传入的 ID
property_id = params.get('property_id')
lessee_id = params.get('lessee_id')
contract_id = params.get('contract_id')
# ========== 二、查询日期解析 ==========
query_date = None
if query_date_str:
try:
# 截断时间后缀
query_date = datetime.datetime.fromisoformat(query_date_str.split(' ')[0]).date()
except Exception:
_logger.warning("[RENTAL_RATE] 日期解析失败: %s", query_date_str)
# ========== 三、查询管理信息明细行 ==========
domain = [('yuthon_property_id', '!=', False)]
# 日期过滤:生效日期 <= 查询日期,且(失效日期 >= 查询日期 或 失效日期为空)
if query_date:
domain += [
('start_time', '<=', query_date),
'|', ('end_date', '>=', query_date), ('end_date', '=', False),
]
# 其他筛选条件
if condition:
domain.append(('tenure_type', '=', condition))
if partner_id:
domain.append(('precinct_id', '=', partner_id))
if company_id:
domain.append(('company_id', '=', company_id))
# 物业名称下拉选择器
if property_id:
domain.append(('yuthon_property_id', '=', int(property_id)))
manage_lines = self.env['manage.information.line'].sudo().search(domain)
# ========== 四、批量预取合同信息(计算已租面积) ==========
property_ids = list({line.yuthon_property_id.id for line in manage_lines if line.yuthon_property_id})
# 映射:property_id → 物业已租面积总和(property_area 累加)
property_leased_map = {}
# 映射:property_id → 合同已租面积总和(total_area 累加)
contract_leased_map = {}
# 映射:property_id → 合同信息列表(用于搜索过滤)
contract_info_map = {}
if property_ids:
# 只取执行中的合同
contract_domain = [
('address_id', 'in', property_ids),
('lease_id.contact_state', '=', 'ing'), # 只取执行中的合同
]
contract_lines = self.env['property.lease.contract.line'].sudo().search(contract_domain)
for cl in contract_lines:
contract = cl.lease_id
if not contract:
continue
pid = cl.address_id.id
# 累加物业已租面积(property_area)
if pid not in property_leased_map:
property_leased_map[pid] = 0.0
property_leased_map[pid] += cl.property_area or 0.0
# 累加合同已租面积(total_area)
if pid not in contract_leased_map:
contract_leased_map[pid] = 0.0
contract_leased_map[pid] += contract.total_area or 0.0
# 合同信息(用于搜索过滤)
if pid not in contract_info_map:
contract_info_map[pid] = []
contract_info_map[pid].append({
'contract_id': contract.id,
'contract_code': contract.contract_code or "",
'lessee_id': contract.lessee_id.id if contract.lessee_id else False,
'lessee_name': contract.lessee_id.name if contract.lessee_id else "",
'is_contract': contract.is_contract,
'total_area': contract.total_area or 0.0,
'property_area': cl.property_area or 0.0,
})
# ========== 五、承租方/合同编号过滤 ==========
if lessee_id or contract_id:
allowed_property_ids = set()
for pid, contracts in contract_info_map.items():
for c in contracts:
if lessee_id and c['lessee_id'] != int(lessee_id):
continue
if contract_id and c['contract_id'] != int(contract_id):
continue
allowed_property_ids.add(pid)
break
manage_lines = manage_lines.filtered(
lambda l: l.yuthon_property_id.id in allowed_property_ids
)
# ========== 六、构建报表行 ==========
rows = []
total_rent_area = 0.0
total_property_leased = 0.0
total_contract_leased = 0.0
for idx, line in enumerate(manage_lines, start=1):
property_rec = line.yuthon_property_id
if not property_rec:
continue
# --- 6.1 可租面积 ---
rent_area = line.rent_area or 0.0
# --- 6.2 物业已租面积(从合同 property_area 累加) ---
property_leased = property_leased_map.get(property_rec.id, 0.0)
# --- 6.3 合同已租面积(从合同 total_area 累加) ---
contract_leased = contract_leased_map.get(property_rec.id, 0.0)
# --- 6.4 出租率 = 物业已租面积 / 可租面积 ---
rental_rate = round((property_leased / rent_area * 100) if rent_area > 0 else 0, 2)
# --- 6.5 取合同信息(关键字过滤用) ---
contract_code = ""
contract_record_id = False
lessee_name = ""
lessee_record_id = False
contracts = contract_info_map.get(property_rec.id, [])
if contracts:
# 优先取主合同
main_contracts = [c for c in contracts if c['is_contract']]
target = main_contracts[0] if main_contracts else (contracts[0] if contracts else None)
if target:
contract_code = target['contract_code']
contract_record_id = target['contract_id']
lessee_name = target['lessee_name']
lessee_record_id = target['lessee_id']
# --- 6.6 关键字过滤(物业名称、承租方、合同编号) ---
if keyword:
match = False
kw = keyword.lower()
if property_rec.name and kw in property_rec.name.lower():
match = True
if lessee_name and kw in lessee_name.lower():
match = True
if contract_code and kw in contract_code.lower():
match = True
if not match:
continue
# --- 6.7 取产权信息(产权证号、产权地址) ---
title_certificate = ""
property_address = ""
if property_rec.title_information_ids:
valid_titles = property_rec.title_information_ids
if query_date:
valid_titles = valid_titles.filtered(
lambda t: t.start_time and t.start_time <= query_date and
(not t.end_date or t.end_date >= query_date)
)
if valid_titles:
latest_title = valid_titles.sorted('start_time', reverse=True)[0]
title_certificate = latest_title.title_certificate or ""
if latest_title.property_message_id:
property_address = latest_title.property_message_id.name or ""
# --- 6.8 片区管理员 ---
admin_display = ""
if line.precinct_id:
admin_display = line.precinct_id.display_name or ""
# --- 6.9 累计统计 ---
total_rent_area += rent_area
total_property_leased += property_leased
total_contract_leased += contract_leased
# --- 6.10 构建行数据(物业名称、合同编号、承租方支持穿透) ---
rows.append({
"序号": idx,
"物业id": property_rec.id,
"物业名称": {
'model': 'yuthon.property',
'id': property_rec.id,
'display_name': property_rec.name or "",
} if property_rec.name else "",
"合同编号": {
'model': 'property.lease.contract',
'id': contract_record_id,
'display_name': contract_code,
} if contract_record_id else contract_code or "",
"承租方": {
'model': 'property.tenant.information',
'id': lessee_record_id,
'display_name': lessee_name,
} if lessee_record_id else lessee_name or "",
"产权证号": title_certificate,
"产权地址": property_address,
"可租面积": round(rent_area, 4),
"物业已租面积": round(property_leased, 4),
"合同已租面积": round(contract_leased, 4),
"出租率": rental_rate,
"片区管理员": admin_display,
})
# ========== 七、构建统计汇总数据 ==========
# 整体出租率 = 物业已租面积合计 / 可租面积合计
overall_rate = round((total_property_leased / total_rent_area * 100) if total_rent_area > 0 else 0, 2)
stats = [
{'name': '记录总数', 'amount': len(rows)},
{'name': '可租面积合计', 'amount': round(total_rent_area, 4)},
{'name': '物业已租面积合计', 'amount': round(total_property_leased, 4)},
{'name': '合同已租面积合计', 'amount': round(total_contract_leased, 4)},
{'name': '整体出租率', 'amount': overall_rate},
]
_logger.info("[RENTAL_RATE] query_date=%s, rows=%d, rent=%.4f, property_leased=%.4f, contract_leased=%.4f, rate=%.2f%%",
query_date, len(rows), total_rent_area, total_property_leased, total_contract_leased, overall_rate)
return {"rows": rows, "stats": stats}
+62 -36
View File
@@ -1,9 +1,12 @@
# -*- coding: utf-8 -*-
import datetime
import calendar
import logging
from odoo import api, models
_logger = logging.getLogger(__name__)
class YuthonDashboard(models.Model):
_name = 'yuthon.dashboard'
@@ -62,7 +65,8 @@ class YuthonDashboard(models.Model):
elif stats:
return stats[0].get('amount', 0.0)
return 0.0
except Exception:
except Exception as e:
_logger.error("[DASHBOARD_SAFE_GET_ERROR] model=%s method=%s error=%s", model_name, method_name, e, exc_info=True)
return 0.0
# 全月范围参数(用于 dateMode="range" 的报表)
@@ -98,10 +102,28 @@ class YuthonDashboard(models.Model):
cum_income = safe_get('receivable.modify.line', 'get_cum_income_report', 1)
# ==================== 二、其他收入板块 ====================
# 违约金
penalty_cum_arrears = safe_get('property.accounts.receivable', 'get_penalty_arrears_report', 1) # 违约金总额
penalty_month_received = safe_get('property.accounts.receivable', 'get_penalty_month_received_report', 2) # 总已收
penalty_year_cum = safe_get('property.accounts.receivable', 'get_penalty_year_cum_report', 1) # 违约金总额
# 违约金:调用三个独立报表方法
# 累计欠收:stats = [记录总数, 违约金总额, 违约金已收, 违约金未收] → 取 index 3(违约金未收)
# 时间过滤与看板一致,直接用 base_params(月初1号当天)
penalty_cum_arrears = safe_get(
'property.accounts.receivable', 'get_penalty_arrears_report', 3,
overrides=base_params
)
_logger.info("[DASHBOARD_PENALTY_ARREARS] base_params=%s, value=%s", base_params, penalty_cum_arrears)
# 本月实收:stats = [记录总数, 实收违约金] → 取 index 1
penalty_month_received = safe_get(
'property.accounts.receivable', 'get_penalty_month_received_report', 1,
overrides=range_params
)
# 本年累计:stats = [记录总数, 实收违约金] → 取 index 1,范围 1月1日 到 查询月末
penalty_year_cum_params = dict(base_params)
if has_date:
y = query_date.year
penalty_year_cum_params['date_start'] = f"{y}-01-01 00:00:00"
penalty_year_cum = safe_get(
'property.accounts.receivable', 'get_penalty_year_cum_report', 1,
overrides=penalty_year_cum_params
)
# 停车费
parking_month_receivable = safe_get('property.accounts.receivable', 'get_parking_fee_current_month_receivable', 0)
@@ -184,11 +206,14 @@ class YuthonDashboard(models.Model):
'title': '二、其他收入',
'cards': [
{'label': '违约金·累计欠收', 'value': penalty_cum_arrears, 'is_negative': True,
'action': 'yuthon_report.action_penalty_arrears_report', 'action_context': make_ctx()},
'action': 'yuthon_report.action_penalty_arrears_report',
'action_context': make_ctx({'date_end': base_params.get('date_end')})},
{'label': '违约金·本月实收', 'value': penalty_month_received, 'is_negative': False,
'action': 'yuthon_report.action_penalty_month_received_report', 'action_context': make_ctx()},
'action': 'yuthon_report.action_penalty_month_received_report',
'action_context': make_ctx({'date_start': range_params.get('date_start'), 'date_end': range_params.get('date_end')})},
{'label': '违约金·本年累计', 'value': penalty_year_cum, 'is_negative': False,
'action': 'yuthon_report.action_penalty_year_cum_report', 'action_context': make_ctx()},
'action': 'yuthon_report.action_penalty_year_cum_report',
'action_context': make_ctx({'date_start': penalty_year_cum_params.get('date_start'), 'date_end': penalty_year_cum_params.get('date_end')})},
{'label': '停车费·本月应收', 'value': parking_month_receivable, 'is_negative': False,
'action': 'yuthon_report.action_parking_fee_current_month_receivable', 'action_context': make_ctx()},
{'label': '停车费·本月实收', 'value': parking_month_received, 'is_negative': False,
@@ -349,49 +374,50 @@ class YuthonDashboard(models.Model):
return {'cards': [], 'row_groups': [], 'explanations': []}
def _get_area_data(self, base_params, query_date):
"""面积/比率板块"""
"""面积/比率板块
使用子报表方法获取汇总数据,确保看板卡片值与报表一致
已租面积拆为两个卡片:物业已租面积、合同已租面积
"""
try:
domain = [('yuthon_property_id', '!=', False)]
company_id = base_params.get('company_id')
partner_id = base_params.get('partner_id')
if company_id:
domain.append(('company_id', '=', int(company_id)))
if partner_id:
domain.append(('precinct_id', '=', int(partner_id)))
# 管理面积:调用管理面积报表
management_result = self.env['management.area.report'].get_management_area_report(base_params)
management_stats = management_result.get('stats', [])
total_management = management_stats[1].get('amount', 0.0) if len(management_stats) > 1 else 0.0
lines = self.env['manage.information.line'].sudo().search(domain)
# 可租面积:调用可租面积报表
rent_result = self.env['rent.area.report'].get_rent_area_report(base_params)
rent_stats = rent_result.get('stats', [])
total_rent = rent_stats[1].get('amount', 0.0) if len(rent_stats) > 1 else 0.0
total_management = sum((l.management_area or 0.0) for l in lines)
total_rent = sum((l.rent_area or 0.0) for l in lines)
total_leased = 0.0
total_contract_area = 0.0
# 已租面积:调用已租面积报表(返回物业已租面积和合同已租面积两个汇总)
leased_result = self.env['leased.area.report'].get_leased_area_report(base_params)
leased_stats = leased_result.get('stats', [])
# stats[1] = 物业已租面积合计, stats[2] = 合同已租面积合计
total_property_leased = leased_stats[1].get('amount', 0.0) if len(leased_stats) > 1 else 0.0
total_contract_leased = leased_stats[2].get('amount', 0.0) if len(leased_stats) > 2 else 0.0
for line in lines:
if line.yuthon_property_id:
contract_lines = self.env['property.contract.line'].sudo().search(
[('yuthon_property_id', '=', line.yuthon_property_id.id)], limit=1)
if contract_lines and contract_lines.lease_contract_id:
total_contract_area += contract_lines.lease_contract_id.total_area or 0.0
# 出租率:已租面积 / 可租面积
rental_rate = round((total_rent / total_management * 100) if total_management > 0 else 0, 2)
# 出租率:物业已租面积 / 可租面积
rental_rate = round((total_property_leased / total_rent * 100) if total_rent > 0 else 0, 2)
cards = [
{'label': '管理面积(㎡)', 'value': round(total_management, 4), 'is_negative': False,
'action': 'yuthon_report.action_management_area_report', 'action_context': {}, 'is_area': True},
{'label': '可租面积(㎡)', 'value': round(total_rent, 4), 'is_negative': False,
'action': 'yuthon_report.action_management_area_report', 'action_context': {}, 'is_area': True},
{'label': '已租面积(㎡)', 'value': round(total_leased, 4), 'is_negative': False,
'action': 'yuthon_report.action_management_area_report', 'action_context': {}, 'is_area': True},
'action': 'yuthon_report.action_rent_area_report', 'action_context': {}, 'is_area': True},
{'label': '物业已租面积(㎡)', 'value': round(total_property_leased, 4), 'is_negative': False,
'action': 'yuthon_report.action_leased_area_report', 'action_context': {}, 'is_area': True},
{'label': '合同已租面积(㎡)', 'value': round(total_contract_leased, 4), 'is_negative': False,
'action': 'yuthon_report.action_leased_area_report', 'action_context': {}, 'is_area': True},
{'label': '出租率', 'value': rental_rate, 'is_negative': False,
'action': 'yuthon_report.action_management_area_report', 'action_context': {}, 'is_percent': True},
'action': 'yuthon_report.action_rental_rate_report', 'action_context': {}, 'is_percent': True},
{'label': '收费率', 'value': 0.00, 'is_negative': False,
'action': '', 'action_context': {}, 'is_percent': True},
]
return {
'cards': cards,
'row_groups': [['管理面积(㎡)', '可租面积(㎡)', '已租面积(㎡)', '出租率', '收费率']],
'row_groups': [['管理面积(㎡)', '可租面积(㎡)', '物业已租面积(㎡)', '合同已租面积(㎡)', '出租率', '收费率']],
}
except Exception:
except Exception as e:
_logger.error("[DASHBOARD_AREA_ERROR] %s", e, exc_info=True)
return {'cards': [], 'row_groups': []}
@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<record id="action_leased_area_report" model="ir.actions.client">
<field name="name">已租面积报表</field>
<field name="tag">receivable_report_client</field>
<field name="context">
{
"sideBar": true,
"report_config": {
"model": "leased.area.report",
"method": "get_leased_area_report",
"dateMode": "single",
"columns": [
{"headerName": "序号", "field": "序号", "width": 45, "flex": 0, "pinned": "left"},
{"headerName": "物业id", "field": "物业id", "flex": 0.8},
{"headerName": "物业名称", "field": "物业名称", "flex": 1.8},
{"headerName": "合同编号", "field": "合同编号", "flex": 1.5},
{"headerName": "承租方", "field": "承租方", "flex": 1.2},
{"headerName": "产权证号", "field": "产权证号", "flex": 1.2},
{"headerName": "产权地址", "field": "产权地址", "flex": 1.8},
{"headerName": "物业已租面积", "field": "物业已租面积", "flex": 1.2, "type": "number"},
{"headerName": "合同已租面积", "field": "合同已租面积", "flex": 1.2, "type": "number"},
{"headerName": "片区管理员", "field": "片区管理员", "flex": 1.2}
]
}
}
</field>
</record>
<menuitem
id="menu_leased_area_report"
name="已租面积报表"
parent="yuthon_property_approval.property_approval_report_menu"
action="action_leased_area_report"
sequence="14"
/>
</data>
</odoo>
@@ -14,14 +14,13 @@
"columns": [
{"headerName": "序号", "field": "序号", "width": 45, "flex": 0, "pinned": "left"},
{"headerName": "物业id", "field": "物业id", "flex": 0.8},
{"headerName": "产权证号", "field": "产权证号", "flex": 1.2},
{"headerName": "物业名称", "field": "物业名称", "flex": 1.8},
{"headerName": "失效日期", "field": "失效日期", "flex": 1.2},
{"headerName": "管理类型", "field": "管理类型", "flex": 1},
{"headerName": "管理面积", "field": "管理面积", "flex": 1, "type": "number"},
{"headerName": "可租面积", "field": "可租面积", "flex": 1, "type": "number"},
{"headerName": "合同编号", "field": "合同编号", "flex": 1.5},
{"headerName": "总面积", "field": "总面积", "flex": 1, "type": "number"}
{"headerName": "承租方", "field": "承租方", "flex": 1.2},
{"headerName": "产权证号", "field": "产权证号", "flex": 1.2},
{"headerName": "产权地址", "field": "产权地址", "flex": 1.8},
{"headerName": "管理面积", "field": "管理面积", "flex": 1, "type": "number"},
{"headerName": "片区管理员", "field": "片区管理员", "flex": 1.2}
]
}
}
@@ -37,4 +36,3 @@
/>
</data>
</odoo>
+19 -18
View File
@@ -10,29 +10,30 @@
"report_config": {
"model": "property.accounts.receivable",
"method": "get_penalty_arrears_report",
"dateMode": "end",
"columns": [
{"headerName": "序号", "field": "序号", "width": 45, "flex": 0, "pinned": "left"},
{"headerName": "合同编号", "field": "合同编号", "flex": 1.2},
{"headerName": "物业名称", "field": "物业名称", "flex": 2},
{"headerName": "承租方名称", "field": "承租方名称", "flex": 1.4},
{"headerName": "金额", "field": "金额", "flex": 1, "type": "number"},
{"headerName": "应收金额", "field": "应收金额", "flex": 1, "type": "number"},
{"headerName": "违约计算时间(天)", "field": "违约计算时间(天)", "flex": 2.5, "wrapText": true},
{"headerName": "欠租金额", "field": "欠租金额", "flex": 1, "type": "number"},
{"headerName": "每天违约金(元/天)", "field": "每天违约金(元/天)", "flex": 1.2, "type": "number"},
{"headerName": "租金ID", "field": "租金ID", "width": 60, "flex": 0},
{"headerName": "租金应收日", "field": "租金应收日", "flex": 1},
{"headerName": "租金收款日期", "field": "租金收款日期", "flex": 1},
{"headerName": "租金金额", "field": "租金金额", "flex": 1, "type": "number"},
{"headerName": "租金已收", "field": "租金已收", "flex": 1, "type": "number"},
{"headerName": "租金未收", "field": "租金未收", "flex": 1, "type": "number"},
{"headerName": "租金状态", "field": "租金状态", "flex": 0.8},
{"headerName": "违约金金额", "field": "违约金金额", "flex": 1, "type": "number"},
{"headerName": "总已收金额", "field": "总已收金额", "flex": 1, "type": "number"},
{"headerName": "总未收", "field": "总未收", "flex": 1, "type": "number"},
{"headerName": "当月收款时间", "field": "当月收款时间", "flex": 1.2},
{"headerName": "当月收款金额", "field": "当月收款金额", "flex": 1, "type": "number"},
{"headerName": "所属公司", "field": "所属公司", "flex": 1},
{"headerName": "产权证号", "field": "产权证号", "flex": 1.5},
{"headerName": "管理员", "field": "管理员", "flex": 1}
{"headerName": "违约金已收", "field": "违约金已收", "flex": 1, "type": "number"},
{"headerName": "违约金未收", "field": "违约金未收", "flex": 1, "type": "number"},
{"headerName": "违约金状态", "field": "违约金状态", "flex": 0.8},
{"headerName": "违约金应收开始", "field": "违约金应收开始", "flex": 1},
{"headerName": "收费区间", "field": "收费区间", "flex": 1.5, "wrapText": true},
{"headerName": "备注", "field": "备注", "flex": 2, "wrapText": true},
{"headerName": "合同编号", "field": "合同编号", "flex": 1},
{"headerName": "物业名称", "field": "物业名称", "flex": 1.5},
{"headerName": "承租方名称", "field": "承租方名称", "flex": 1},
{"headerName": "所属公司", "field": "所属公司", "flex": 1}
]
}
}
</field>
</record>
</data>
</odoo>
</odoo>
@@ -10,29 +10,33 @@
"report_config": {
"model": "property.accounts.receivable",
"method": "get_penalty_month_received_report",
"dateMode": "range",
"columns": [
{"headerName": "序号", "field": "序号", "width": 45, "flex": 0, "pinned": "left"},
{"headerName": "合同编号", "field": "合同编号", "flex": 1.2},
{"headerName": "物业名称", "field": "物业名称", "flex": 2},
{"headerName": "承租方名称", "field": "承租方名称", "flex": 1.4},
{"headerName": "金额", "field": "金额", "flex": 1, "type": "number"},
{"headerName": "应收金额", "field": "应收金额", "flex": 1, "type": "number"},
{"headerName": "违约计算时间(天)", "field": "违约计算时间(天)", "flex": 1.8},
{"headerName": "欠租金额", "field": "欠租金额", "flex": 1, "type": "number"},
{"headerName": "每天违约金(元/天)", "field": "每天违约金(元/天)", "flex": 1.2, "type": "number"},
{"headerName": "租金ID", "field": "租金ID", "width": 70, "flex": 0, "pinned": "left"},
{"headerName": "租金应收日", "field": "租金应收日", "flex": 1},
{"headerName": "租金收款日期", "field": "租金收款日期", "flex": 1},
{"headerName": "租金金额", "field": "租金金额", "flex": 1, "type": "number"},
{"headerName": "租金已收", "field": "租金已收", "flex": 1, "type": "number"},
{"headerName": "租金未收", "field": "租金未收", "flex": 1, "type": "number"},
{"headerName": "租金状态", "field": "租金状态", "flex": 0.8},
{"headerName": "违约金金额", "field": "违约金金额", "flex": 1, "type": "number"},
{"headerName": "总已收金额", "field": "总已收金额", "flex": 1, "type": "number"},
{"headerName": "总未收", "field": "总未收", "flex": 1, "type": "number"},
{"headerName": "当月收款时间", "field": "当月收款时间", "flex": 1.2},
{"headerName": "当月收款金额", "field": "当月收款金额", "flex": 1, "type": "number"},
{"headerName": "所属公司", "field": "所属公司", "flex": 1},
{"headerName": "产权证号", "field": "产权证号", "flex": 1.5},
{"headerName": "管理员", "field": "管理员", "flex": 1}
{"headerName": "违约金已收", "field": "违约金已收", "flex": 1, "type": "number"},
{"headerName": "违约金未收", "field": "违约金未收", "flex": 1, "type": "number"},
{"headerName": "违约金状态", "field": "违约金状态", "flex": 0.8},
{"headerName": "违约金应收开始", "field": "违约金应收开始", "flex": 1},
{"headerName": "实收金额", "field": "实收金额", "flex": 1, "type": "number"},
{"headerName": "收款日期", "field": "收款日期", "flex": 1},
{"headerName": "收款备注", "field": "收款备注", "flex": 1.5, "wrapText": true},
{"headerName": "收费区间", "field": "收费区间", "flex": 1.5, "wrapText": true},
{"headerName": "备注", "field": "备注", "flex": 2, "wrapText": true},
{"headerName": "合同编号", "field": "合同编号", "flex": 1.2},
{"headerName": "物业名称", "field": "物业名称", "flex": 1.2},
{"headerName": "承租方名称", "field": "承租方名称", "flex": 1.2},
{"headerName": "所属公司", "field": "所属公司", "flex": 1}
]
}
}
</field>
</record>
</data>
</odoo>
@@ -10,20 +10,33 @@
"report_config": {
"model": "property.accounts.receivable",
"method": "get_penalty_year_cum_report",
"dateMode": "range",
"columns": [
{"headerName": "序号", "field": "序号", "width": 45, "flex": 0, "pinned": "left"},
{"headerName": "租金ID", "field": "租金ID", "width": 70, "flex": 0, "pinned": "left"},
{"headerName": "租金应收日", "field": "租金应收日", "flex": 1},
{"headerName": "租金收款日期", "field": "租金收款日期", "flex": 1},
{"headerName": "租金金额", "field": "租金金额", "flex": 1, "type": "number"},
{"headerName": "租金已收", "field": "租金已收", "flex": 1, "type": "number"},
{"headerName": "租金未收", "field": "租金未收", "flex": 1, "type": "number"},
{"headerName": "租金状态", "field": "租金状态", "flex": 0.8},
{"headerName": "违约金金额", "field": "违约金金额", "flex": 1, "type": "number"},
{"headerName": "违约金已收", "field": "违约金已收", "flex": 1, "type": "number"},
{"headerName": "违约金未收", "field": "违约金未收", "flex": 1, "type": "number"},
{"headerName": "违约金状态", "field": "违约金状态", "flex": 0.8},
{"headerName": "违约金应收开始", "field": "违约金应收开始", "flex": 1},
{"headerName": "实收金额", "field": "实收金额", "flex": 1, "type": "number"},
{"headerName": "收款日期", "field": "收款日期", "flex": 1},
{"headerName": "收款备注", "field": "收款备注", "flex": 1.5, "wrapText": true},
{"headerName": "收费区间", "field": "收费区间", "flex": 1.5, "wrapText": true},
{"headerName": "备注", "field": "备注", "flex": 2, "wrapText": true},
{"headerName": "合同编号", "field": "合同编号", "flex": 1.2},
{"headerName": "物业名称", "field": "物业名称", "flex": 2.5},
{"headerName": "承租方名称", "field": "承租方名称", "flex": 1.4},
{"headerName": "金额", "field": "金额", "flex": 1, "type": "number"},
{"headerName": "应收金额", "field": "应收金额", "flex": 1, "type": "number"},
{"headerName": "所属公司", "field": "所属公司", "flex": 1},
{"headerName": "管理员", "field": "管理员", "flex": 1}
{"headerName": "物业名称", "field": "物业名称", "flex": 1.2},
{"headerName": "承租方名称", "field": "承租方名称", "flex": 1.2},
{"headerName": "所属公司", "field": "所属公司", "flex": 1}
]
}
}
</field>
</record>
</data>
</odoo>
+38
View File
@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<record id="action_rent_area_report" model="ir.actions.client">
<field name="name">可租面积报表</field>
<field name="tag">receivable_report_client</field>
<field name="context">
{
"sideBar": true,
"report_config": {
"model": "rent.area.report",
"method": "get_rent_area_report",
"dateMode": "single",
"columns": [
{"headerName": "序号", "field": "序号", "width": 45, "flex": 0, "pinned": "left"},
{"headerName": "物业id", "field": "物业id", "flex": 0.8},
{"headerName": "物业名称", "field": "物业名称", "flex": 1.8},
{"headerName": "合同编号", "field": "合同编号", "flex": 1.5},
{"headerName": "承租方", "field": "承租方", "flex": 1.2},
{"headerName": "产权证号", "field": "产权证号", "flex": 1.2},
{"headerName": "产权地址", "field": "产权地址", "flex": 1.8},
{"headerName": "可租面积", "field": "可租面积", "flex": 1, "type": "number"},
{"headerName": "片区管理员", "field": "片区管理员", "flex": 1.2}
]
}
}
</field>
</record>
<menuitem
id="menu_rent_area_report"
name="可租面积报表"
parent="yuthon_property_approval.property_approval_report_menu"
action="action_rent_area_report"
sequence="13"
/>
</data>
</odoo>
@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<record id="action_rental_rate_report" model="ir.actions.client">
<field name="name">出租率报表</field>
<field name="tag">receivable_report_client</field>
<field name="context">
{
"sideBar": true,
"report_config": {
"model": "rental.rate.report",
"method": "get_rental_rate_report",
"dateMode": "single",
"columns": [
{"headerName": "序号", "field": "序号", "width": 45, "flex": 0, "pinned": "left"},
{"headerName": "物业id", "field": "物业id", "flex": 0.8},
{"headerName": "物业名称", "field": "物业名称", "flex": 1.8},
{"headerName": "合同编号", "field": "合同编号", "flex": 1.5},
{"headerName": "承租方", "field": "承租方", "flex": 1.2},
{"headerName": "产权证号", "field": "产权证号", "flex": 1.2},
{"headerName": "产权地址", "field": "产权地址", "flex": 1.8},
{"headerName": "可租面积", "field": "可租面积", "flex": 1, "type": "number"},
{"headerName": "物业已租面积", "field": "物业已租面积", "flex": 1.2, "type": "number"},
{"headerName": "合同已租面积", "field": "合同已租面积", "flex": 1.2, "type": "number"},
{"headerName": "出租率(%)", "field": "出租率", "flex": 1, "type": "number"},
{"headerName": "片区管理员", "field": "片区管理员", "flex": 1.2}
]
}
}
</field>
</record>
<menuitem
id="menu_rental_rate_report"
name="出租率报表"
parent="yuthon_property_approval.property_approval_report_menu"
action="action_rental_rate_report"
sequence="15"
/>
</data>
</odoo>