372 lines
20 KiB
Python
372 lines
20 KiB
Python
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' |