Files
gzth/yuthon_property_approval/models/property_evaluate_application.py
T
2026-09-24 19:38:13 +08:00

372 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from email.policy import default
import base64
import io
from copy import copy
from openpyxl import load_workbook
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
from odoo.tools.misc import file_path
from odoo import models, fields, api, _
from odoo.exceptions import UserError, ValidationError
class PropertyEvaluateApplication(models.Model):
_name = 'property.evaluate.application'
_description = '物业评估申请'
_inherit = ['soong.workflow.mixin', 'mail.thread.main.attachment', 'mail.activity.mixin']
name = fields.Char(string='标题', default='物业评估申请')
code = fields.Char(string='编号', required=True, copy=False,
default=lambda self: self.env['ir.sequence'].next_by_code('yuthon_property_code'))
main_text = fields.Html(string='正文')
users_id = fields.Many2one('res.users', string="申请人", default=lambda self: self.env.user.id)
department_id = fields.Many2one(related='users_id.employee_id.department_id', string="部门")
is_issues = fields.Boolean(string='是否上会')
issues_result = fields.Html(string="会议结果")
related_issues_id = fields.Many2one('issue.library', string="关联议题")
is_experts = fields.Boolean(string="是否需要专家评审")
documents_ids = fields.Many2many('ir.attachment', string='议题附件')
property_ids = fields.One2many('yuthon.property.line', 'property_message_id' ,string='物业信息明细行')
urgency_level = fields.Selection([('normal', '普件'), ('urgent', '急件'), ('emergency', '特急件')], string='紧急度',
default='normal')
active = fields.Boolean('Active', default=True, tracking=True)
date_assessment = fields.Date(string='评估基准日')
evaluation_company_id = fields.Many2one('evaluation.company', string='评估公司')
service_money = fields.Float(string='评估服务费总额')
pay_time = fields.Date(string='支付时间')
pay_way = fields.Char(string='支付方式')
work_end = fields.Boolean(string="结束")
def _get_default_name_title(self):
return self._description if self._description else self._name
name_title = fields.Char(default=_get_default_name_title)
self_users_ids = fields.Many2many('res.users', string="当前经办人", default=lambda self: [(6, 0, [self.env.user.id])])
def put_property_record(self):
form_id = self.env.ref('yuthon_property_approval.views_property_record_form').id
property_list = []
for record in self.evaluate_application_ids:
property_list.append([0, 0, {'name': record.name,
'owner_id': record.owner_id.id,
'assessment_category':record.assessment_category,
'evaluation_area': record.evaluation_area,
'registered_use': record.registered_use,
'current_status': record.current_status,
'last_hand_rent_price': record.last_hand_rent_price,
'rental_conditions': record.rental_conditions,
'remarks': record.remarks,}])
return {
'name': '提交物业备案审批',
'view_mode': 'form',
'res_model': 'property.record',
'type': 'ir.actions.act_window',
'views': [(form_id, 'form')],
"context": {
'default_evaluate_application_ids':property_list
}
}
# ================= 导出「资产评估结果明细表」 =================
# 模板:static/others/assessment_result_template.xlsx(单工作表「租金-结果明细表」,25 列)
# 结构:第 1~3 行 标题/评估基准日/委托人;第 4 行 评估编号·评估方法·评估信息(评估机构填写);
# 第 5~6 行 两级表头;其后为数据区,再依次是「合计」行、备注说明、调整表、落款(评估人员/评估机构)。
# 行号不写死:运行时按「A 列为『序号』的第一行」定位表头、「A 列为『合计』」定位合计行。
EXPORT_TEMPLATE_PATH = 'yuthon_property_approval/static/others/assessment_result_template.xlsx'
EXPORT_COL_COUNT = 25
@staticmethod
def _find_assessment_data_start(sheet, max_row):
"""定位数据区起始行:A 列为「序号」的是第一级表头,其下含「证载面积」的是第二级表头"""
first_header = None
for row in range(1, max_row + 1):
if str(sheet.cell(row, 1).value or '').strip() == '序号':
first_header = row
break
if first_header is None:
raise UserError('导出模板格式异常:未找到表头行')
for row in range(first_header, max_row + 1):
if '证载面积' in str(sheet.cell(row, 5).value or ''):
return row + 1
return first_header + 2
@staticmethod
def _fmt_num(value):
"""整数去掉小数点,其余按 %g 输出"""
if value in (False, None, ''):
return ''
try:
number = float(value)
except (TypeError, ValueError):
return str(value)
return str(int(number)) if number == int(number) else ('%g' % number)
@api.model
def _selection_label(self, model_name, field_name, value):
if not value:
return ''
field = self.env[model_name]._fields.get(field_name)
if not field:
return value
return dict(field._description_selection(self.env)).get(value, value)
@api.model
def _increase_text(self, ref_line):
"""租金递增文案,取自物业「出租参考条件」中覆盖当天的那行。
递增金额 / 递增单价 / 递增百分比 **只会填其中一个**,按填了哪个拼单位;
若历史数据里同时填了多个,按 百分比 → 金额 → 单价 的优先级取第一个。
每几年递增 > 1 时写「每N年递增」,否则写「每年递增」。
"""
if not ref_line:
return ''
if ref_line.annual_increase_percentage:
value, unit = ref_line.annual_increase_percentage, '%'
elif ref_line.annual_increase_amount:
value, unit = ref_line.annual_increase_amount, '元'
elif ref_line.rent_unit:
value, unit = ref_line.rent_unit, '元/㎡'
else:
return ''
few_years = ref_line.increments_few_years or 0
every = '每年' if few_years <= 1 else '每%s年' % few_years
text = '%s递增%s%s' % (every, self._fmt_num(value), unit)
if ref_line.first_years_begin:
text = '第%s年起%s' % (ref_line.first_years_begin, text)
return text
def _build_assessment_export_rows(self):
"""按物业明细行生成 25 列的数据行;最后 5 列(分租/整租单价总价、备注)留空"""
rows = []
for index, line in enumerate(self.property_ids, start=1):
prop = line.property_id
ref_line = prop._get_valid_reference_line() if prop else prop
title_line = prop._get_valid_title_line() if prop else prop
certificate = ''
if title_line and title_line.property_message_id:
certificate = title_line.property_message_id.title_certificate or ''
rows.append([
index, # A 序号
prop.name or '', # B 地址及位置
title_line.owner_id.name if title_line and title_line.owner_id else '', # C 权属人/产权持有者
certificate, # D 权证编号
title_line.equity_area if title_line and title_line.equity_area else None, # E 证载面积(㎡)
title_line.registered_id.name if title_line and title_line.registered_id else '', # F 证载用途
prop.building_structure or '', # G 建筑结构
prop.rental_floor or '', # H 出租楼层
prop.property_floor_id.show_name or '' if prop.property_floor_id else '', # I 总楼层
prop.construction_year or '', # J 建筑年代
prop.shared_land_area or None, # K 共用地面积(㎡)
prop.land_nature or '', # L 土地性质
self._selection_label('yuthon.property', 'land_acquisition', prop.land_acquisition), # M 取得方式
self._selection_label('yuthon.property', 'property_type', prop.property_type), # N 用途
prop.land_end_date or None, # O 土地终止日期
line.evaluation_area or None, # P 出租/评估面积(㎡)
prop.asset_type_id.name or '' if prop.asset_type_id else '', # Q 出租用途
'不超过%s年' % self._fmt_num(ref_line.lease_period) if ref_line and ref_line.lease_period else '', # R 租期
'不超过%s天' % self._fmt_num(ref_line.rent_period) if ref_line and ref_line.rent_period else '', # S 免租期
self._increase_text(ref_line), # T 租金递增
None, None, None, None, None, # U~Y 留空(评估结果 + 备注)
])
return rows
def action_export_assessment_result(self):
"""导出资产评估结果明细表(按模板生成 xlsx 并下载)"""
self.ensure_one()
if not self.property_ids:
raise UserError('该评估申请没有物业明细行,无法导出')
try:
template_path = file_path(self.EXPORT_TEMPLATE_PATH)
except FileNotFoundError:
raise UserError('未找到导出模板文件,请检查模块文件是否完整')
workbook = load_workbook(template_path)
sheet = workbook[workbook.sheetnames[0]]
col_count = self.EXPORT_COL_COUNT
max_row = sheet.max_row
# ① 定位表头(A 列为「序号」的是第一级表头行,其下含「证载面积」的是第二级表头行),
# 数据区紧随表头之后;再定位「合计」行,其后的内容(备注说明 / 调整表 / 落款)整体搬移
data_start = self._find_assessment_data_start(sheet, max_row)
total_row = None
for row in range(data_start, max_row + 1):
if str(sheet.cell(row, 1).value or '').strip() == '合计':
total_row = row
break
if total_row is None:
raise UserError('导出模板格式异常:未找到「合计」行')
data_styles = {col: copy(sheet.cell(data_start, col)._style) for col in range(1, col_count + 1)}
total_styles = {col: copy(sheet.cell(total_row, col)._style) for col in range(1, col_count + 1)}
# 以「合计」行为原点 0,把它和它之后的内容(备注说明 / 调整表 / 落款)整体记录、稍后搬移
tail = {} # (相对行, 列) -> (值, 样式)
tail_heights = {} # 相对行 -> 行高
tail_last_offset = 0
for offset, row in enumerate(range(total_row, max_row + 1)):
tail_heights[offset] = sheet.row_dimensions[row].height
for col in range(1, col_count + 1):
cell = sheet.cell(row, col)
tail[(offset, col)] = (cell.value, copy(cell._style))
if cell.value is not None:
tail_last_offset = offset
tail_merges = [
(r.min_row - total_row, r.min_col, r.max_row - total_row, r.max_col)
for r in sheet.merged_cells.ranges
if r.min_row >= total_row
]
# ② 解除数据区以后的合并,并彻底清空数据区到表尾
for name in [str(r) for r in sheet.merged_cells.ranges if r.min_row >= data_start]:
sheet.unmerge_cells(name)
for row in range(data_start, max_row + 1):
for col in range(1, col_count + 1):
cell = sheet.cell(row, col)
cell.value = None
cell.border = Border()
cell.fill = PatternFill()
cell.font = Font()
cell.alignment = Alignment()
# ③ 写入数据行(每行一个物业,样式沿用模板数据行,边框统一补齐)
data_rows = self._build_assessment_export_rows()
side = Side(style='thin')
full_border = Border(left=side, right=side, top=side, bottom=side)
for offset, values in enumerate(data_rows):
row = data_start + offset
for col in range(1, col_count + 1):
cell = sheet.cell(row, col)
cell._style = copy(data_styles[col])
cell.value = values[col - 1]
cell.border = full_border
if not sheet.row_dimensions[row].height or sheet.row_dimensions[row].height < 20:
sheet.row_dimensions[row].height = 30
# ④ 把「合计」行及其后的固定内容整体下移到数据行之后
total_target = data_start + len(data_rows)
for (offset, col), (value, style) in tail.items():
cell = sheet.cell(total_target + offset, col)
cell._style = copy(style)
cell.value = value
for offset, height in tail_heights.items():
if height:
sheet.row_dimensions[total_target + offset].height = height
for min_row, min_col, max_row_, max_col in tail_merges:
sheet.merge_cells(
start_row=total_target + min_row, start_column=min_col,
end_row=total_target + max_row_, end_column=max_col)
# ⑤ 合计行的面积两列按实际行数求和(金额列留空)
last_data_row = data_start + len(data_rows) - 1
for col in range(1, col_count + 1):
sheet.cell(total_target, col)._style = copy(total_styles[col])
sheet.cell(total_target, 1, value='合计')
sheet.cell(total_target, 5, value='=SUM(E%s:E%s)' % (data_start, last_data_row))
sheet.cell(total_target, 16, value='=SUM(P%s:P%s)' % (data_start, last_data_row))
# ⑥ 删掉模板残留的空行,避免表格下方拖出一大片空白
tail_len = tail_last_offset + 1
first_empty_row = total_target + tail_len
if sheet.max_row >= first_empty_row:
sheet.delete_rows(first_empty_row, sheet.max_row - first_empty_row + 1)
# ⑦ 文档级信息:评估基准日 / 委托人(取表单上的,模板里的示例值一律覆盖)
base_date = self.date_assessment
sheet.cell(2, 1, value='评估基准日:%s' % (base_date.strftime('%Y年%m月%d日') if base_date else ''))
sheet.cell(3, 1, value='委托人:%s' % (self.users_id.name or ''))
# ⑧ 落款:评估人员留空(由评估机构填),评估机构取表单上的「评估公司」
company_name = self.evaluation_company_id.name or ''
for row in range(total_target, sheet.max_row + 1):
for col in range(1, col_count + 1):
value = sheet.cell(row, col).value
if not isinstance(value, str):
continue
if value.startswith('评估人员:'):
sheet.cell(row, col, value='评估人员:')
elif value.startswith('评估机构:'):
sheet.cell(row, col, value='评估机构:%s' % company_name)
output = io.BytesIO()
workbook.save(output)
output.seek(0)
file_name = '资产评估结果明细表-%s.xlsx' % (self.code or self.name or self.id)
attachment = self.env['ir.attachment'].create({
'name': file_name,
'type': 'binary',
'datas': base64.b64encode(output.read()),
'mimetype': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
})
return {
'type': 'ir.actions.act_url',
'url': '/web/content/%s?download=true' % attachment.id,
'target': 'self',
}
def _on_workflow_transfer(self):
"""「转交」(含非退回的加签)成功后的业务钩子。
与挂牌申报表 / 成交确认书同一口径:**保存只置「待X」,转交才置「X中」**。
评估申请真正流转时才把关联的评估方案与物业置为「评估中」,单纯保存不算。
触发点见 soong_workflow/wizard/soong_workflow_comment_wizard.py。
"""
for application in self:
plans = application.property_ids.mapped('property_evaluation_id')
waiting_plans = plans.filtered(lambda plan: plan.property_state == 'to')
if waiting_plans:
waiting_plans.write({'property_state': 'ing'})
# 物业自己的业务状态同步为「评估中」,不动已是「评估中/已评估及之后」的物业
waiting_props = plans.mapped('property_id').filtered(
lambda prop: prop.business_state in (False, '', 'to'))
if waiting_props:
waiting_props.write({'business_state': 'ing'})
@api.onchange('is_issues')
def _onchange_is_issues(self):
if self.is_issues:
issue_id = self.env['issue.library'].create({'name': self.name, 'is_invisible_workflow': True})
self.related_issues_id = issue_id.id
self.env['issue.library.approval.line'].create({
'issue_library_id': issue_id.id,
'workflow_name': self.name,
'approval_type': self.workflow_id.name,
'res_model': self._name,
'res_id': self.id,
'code': self.code,
'approval_date': fields.Datetime.now(),
})
class YuhtonPropertyLine(models.Model):
_name = 'yuthon.property.line'
_description = '物业信息评估明细'
property_message_id = fields.Many2one('property.evaluate.application', string='关联评估申请')
property_evaluation_id = fields.Many2one('property.evaluation.plan', string="关联物业评估方案")
property_id = fields.Many2one(related='property_evaluation_id.property_id', string="物业信息")
owner_id = fields.Many2one('yuthon.owner.message', related='property_evaluation_id.owner_id', string="权属人")
assessment_category = fields.Selection(related='property_evaluation_id.assessment_category', string="评估类别")
lease_period = fields.Integer(related='property_evaluation_id.lease_period', string='租期期限(<=)')
registered_id = fields.Many2one(related='property_evaluation_id.registered_id', string='证载用途')
registered_id2 = fields.Many2one(related='property_evaluation_id.registered_id2', string='设定用途')
annual_increase_percentage = fields.Float(related='property_evaluation_id.annual_increase_percentage', string='每年递增(%)', igits=(6, 2))
purpose_id = fields.Many2one('yuthon.resource.purpose', string="管理类型")
evaluation_area = fields.Float(related='property_evaluation_id.evaluation_area', string='评估面积(m²)')
rent_id = fields.Many2one('rent.free.setting', string="办法")
remarks = fields.Text(related='property_evaluation_id.remake', string='备注')
def application_detail(self):
return {
"name": "物业",
"type": "ir.actions.act_window",
"view_mode": "tree",
"res_model": "yuthon.property",
"target": "new",
"domain": [('id', '=', self.property_id.id)],
}
class EvaluateApplicationLine(models.Model):
_name = 'evaluate.application.line'