添加功能

This commit is contained in:
李鹏宇
2026-09-24 19:47:17 +08:00
parent f689a0f586
commit dcf9450ce5
5 changed files with 681 additions and 1 deletions
+12 -1
View File
@@ -981,12 +981,23 @@ class SoongWorkflowRecord(models.Model):
})
def open_record(self):
"""按「模型 + 记录 id」跳转到对应业务单据的表单"""
self.ensure_one()
if not self.res_model or not self.res_id:
raise UserError(_('该审批记录未关联业务单据,无法跳转'))
if self.res_model not in self.env:
raise UserError(_('系统中已不存在模型「%s」,无法跳转') % self.res_model)
if not self.env[self.res_model].sudo().browse(self.res_id).exists():
raise UserError(_('对应的业务单据已不存在(%s #%s),无法跳转')
% (self.res_model, self.res_id))
return {
"type": "ir.actions.act_window",
"name": self.name or self.res_model,
"res_model": self.res_model,
"res_id": self.res_id,
"name": self.name,
"view_mode": "form",
"views": [(False, "form")],
"target": "current",
}
@api.model
@@ -0,0 +1,300 @@
# -*- coding: utf-8 -*-
"""评估信息导入向导
把评估机构填写完成的「资产评估结果明细表」回写到物业的「评估信息」页:
- 表头 / 落款里的文档级信息(评估基准日、评估编号、评估方法、评估信息、评估机构)→ 写到表中每一行物业;
- 每行的:地址及位置 → 定位物业;分租单价/分租总价/整租单价/整租总价、备注 → 写到对应物业。
只有文件里填了值的单元格才会写入,空单元格不会覆盖物业上已有的值。
"""
import base64
import io
import logging
from datetime import datetime, date
from openpyxl import load_workbook
from openpyxl.utils.datetime import from_excel
from odoo import models, fields
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
# 文档级标签 -> yuthon.property 字段
HEADER_LABEL_FIELDS = {
'评估基准日': 'evaluation_date',
'评估编号': 'evaluation_code',
'评估方法': 'evaluation_method',
'评估信息': 'evaluation_info',
'评估机构': 'evaluation_agency',
}
# 每行(物业)列 -> 字段。A 列序号、B 列物业;列号从 1 开始
ROW_COLUMN_FIELDS = {
2: None, # 地址及位置:用于定位物业,不写入
21: 'evaluation_rent_price', # 首年月租金单价(分租)
22: 'evaluation_rent_total', # 首年月租金总价(分租)
23: 'evaluation_whole_rent_price', # 首年月租金单价(整租)
24: 'evaluation_whole_rent_total', # 首年月租金总价(整租)
25: 'evaluation_remark', # 备注
}
DATE_FORMATS = ('%Y-%m-%d', '%Y/%m/%d', '%Y.%m.%d', '%Y年%m月%d日', '%Y%m%d')
# 已经越过「已评估」、进入挂牌 / 签约流程的业务状态(导入时不再往回降级)
POST_ASSESS_STATES = ('to_list', 'listing', 'listed', 'to_sign', 'signing', 'signed')
# 需要评估机构在表里填写、导入时回写物业的字段(用于判断「文件到底填过没有」)
EVALUATOR_FIELDS = (
'evaluation_rent_price', 'evaluation_rent_total',
'evaluation_whole_rent_price', 'evaluation_whole_rent_total',
'evaluation_remark', 'evaluation_code', 'evaluation_method', 'evaluation_info',
)
class PropertyEvaluationImportWizard(models.TransientModel):
_name = 'property.evaluation.import.wizard'
_description = '评估信息导入向导'
excel_file = fields.Binary(string='Excel文件', required=True)
excel_name = fields.Char(string='文件名')
# ==================== 单元格解析 ====================
@staticmethod
def _text(value):
if value is None:
return ''
if isinstance(value, str):
return value.strip()
if isinstance(value, float) and value.is_integer():
return str(int(value))
return str(value).strip()
@staticmethod
def _to_number(value):
"""返回 (是否为有效数字, 数值);'-'/'—'// 等占位符视为无效但不算错误"""
if value is None or isinstance(value, bool):
return False, 0.0
if isinstance(value, (int, float)):
return True, float(value)
text = str(value).strip().replace(',', '')
if not text:
return False, 0.0
try:
return True, float(text)
except ValueError:
return False, 0.0
def _to_date(self, value):
if value is None:
return False
if isinstance(value, datetime):
return value.date()
if isinstance(value, date):
return value
if isinstance(value, (int, float)):
try:
converted = from_excel(value)
except Exception:
return False
return converted.date() if isinstance(converted, datetime) else converted
text = str(value).strip()
for fmt in DATE_FORMATS:
try:
return datetime.strptime(text, fmt).date()
except ValueError:
continue
return False
@staticmethod
def _placeholder(text):
"""'-'、'—'、'/'、'无' 之类的占位符,视为未填写"""
return text.strip() in ('', '-', '—', '-', '/', '无', 'None')
# ==================== 读取工作表 ====================
def _read_sheet(self):
"""返回 (sheet, 数据区起始行, 最大行)"""
raw = self.excel_file
if isinstance(raw, str):
raw = raw.encode()
try:
content = base64.b64decode(raw, validate=True)
except Exception:
content = raw
try:
workbook = load_workbook(filename=io.BytesIO(content), data_only=True)
except Exception as e:
raise UserError('文件解析失败:%s' % e)
sheet = workbook[workbook.sheetnames[0]]
# 数据区起始行:A 列是「序号」的是第一级表头,其下含「证载面积」的是第二级表头
first_header = None
for row in range(1, sheet.max_row + 1):
if self._text(sheet.cell(row, 1).value) == '序号':
first_header = row
break
if first_header is None:
raise UserError('文件格式不正确:没有找到「序号」表头行,请使用系统导出的表格填写后导入')
data_start = None
for row in range(first_header, sheet.max_row + 1):
if '证载面积' in self._text(sheet.cell(row, 5).value):
data_start = row + 1
break
if data_start is None:
data_start = first_header + 2
return sheet, data_start
def _read_header_values(self, sheet):
"""扫描整张表,取「标签:值」形式的文档级信息(不含数据行的备注列)"""
values = {}
for row in range(1, sheet.max_row + 1):
for col in range(1, sheet.max_column + 1):
text = self._text(sheet.cell(row, col).value)
if not text:
continue
for label, fname in HEADER_LABEL_FIELDS.items():
if not text.startswith(label):
continue
rest = text[len(label):].lstrip()
if rest[:1] not in (':', ':'):
continue
value = rest[1:].strip()
if value and not self._placeholder(value):
values[fname] = value
return values
# ==================== 主流程 ====================
def action_import(self):
if not self.excel_file:
raise UserError('请先上传 Excel 文件!')
sheet, data_start = self._read_sheet()
header_values = self._read_header_values(sheet)
errors = []
# 解析文档级信息
doc_vals = {}
for label, fname in HEADER_LABEL_FIELDS.items():
if fname not in header_values:
continue
raw_value = header_values[fname]
if fname == 'evaluation_date':
parsed = self._to_date(raw_value)
if not parsed:
errors.append('表头【%s】的值【%s】不是有效日期(如 2026-03-20 或 2026年03月20日)'
% (label, raw_value))
continue
doc_vals[fname] = parsed
else:
doc_vals[fname] = raw_value
# 逐行解析
row_data = []
seen_names = set()
total_row = None
for row in range(data_start, sheet.max_row + 1):
first = self._text(sheet.cell(row, 1).value)
if first == '合计':
total_row = row
break
name = self._text(sheet.cell(row, 2).value)
if not name:
continue
if name in seen_names:
errors.append('第%s行:物业【%s】在本次文件中重复' % (row, name))
continue
seen_names.add(name)
props = self.env['yuthon.property'].search([('name', '=', name)])
if not props:
errors.append('第%s行:物业名称【%s】在系统中不存在,请核对「地址及位置」列' % (row, name))
continue
if len(props) > 1:
errors.append('第%s行:物业名称【%s】在系统中存在 %s 条,无法唯一匹配,请先处理重名'
% (row, name, len(props)))
continue
vals = dict(doc_vals)
for col, fname in ROW_COLUMN_FIELDS.items():
if not fname:
continue
cell = sheet.cell(row, col)
text = self._text(cell.value)
if self._placeholder(text):
continue
if fname == 'evaluation_remark':
vals[fname] = text
continue
ok, number = self._to_number(cell.value)
if not ok:
errors.append('第%s行:物业【%s】的【%s】值【%s】不是有效数字'
% (row, name, self._column_label(col), text))
continue
vals[fname] = number
row_data.append((row, name, props[0], vals))
if not row_data and not errors:
errors.append('文件中没有可导入的物业数据行')
if errors:
raise UserError('导入失败,共 %s 处问题(未写入任何数据):\n\n%s'
% (len(errors), '\n'.join(errors[:50])))
# 只有导出时会自动带上的值(委托人 / 评估基准日 / 评估机构)不足以说明评估机构填过东西:
# 若评估机构该填的内容一个都没有,几乎可以肯定是「忘了保存」或「传错文件」,直接拦下。
evaluator_fields = set(EVALUATOR_FIELDS)
filled_fields = set()
for _row, _name, _prop, vals in row_data:
filled_fields |= (evaluator_fields & set(vals))
if not filled_fields:
raise UserError('文件里没有可导入的内容:评估编号 / 评估方法 / 评估信息、'
'分租单价 / 分租总价 / 整租单价 / 整租总价、备注 全部为空。')
updated = 0
updated_props = self.env['yuthon.property']
for row, name, prop, vals in row_data:
if not vals:
continue
prop.write(vals)
updated_props |= prop
updated += 1
_logger.info('评估信息导入:物业 %s (id=%s) 更新字段 %s', name, prop.id, sorted(vals))
# 评估信息导入即视为该物业评估完成:
# 物业「业务状态」→ 已评估(done),对应的评估方案「状态」→ 已评估(done)。
# 只向前推进:已进入挂牌 / 签约流程(待挂牌之后)的物业不往回降级。
if updated_props:
targets = updated_props.filtered(lambda prop: prop.business_state not in POST_ASSESS_STATES)
if targets:
targets.write({'business_state': 'done'})
plans = self.env['property.evaluation.plan'].search([
('property_id', 'in', updated_props.ids),
('property_state', '!=', 'done'),
])
if plans:
plans.write({'property_state': 'done'})
message = '已更新 %s 个物业的评估信息。' % updated
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': '导入成功',
'message': message,
'type': 'success',
'sticky': False,
'next': {'type': 'ir.actions.act_window_close'},
},
}
@staticmethod
def _column_label(col):
return {
21: '首年月租金单价(分租)',
22: '首年月租金总价(分租)',
23: '首年月租金单价(整租)',
24: '首年月租金总价(整租)',
}.get(col, '第%s列' % col)
@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="property_evaluation_import_wizard_form" model="ir.ui.view">
<field name="name">property.evaluation.import.wizard.form</field>
<field name="model">property.evaluation.import.wizard</field>
<field name="arch" type="xml">
<form string="评估信息导入">
<sheet>
<group>
<group>
<field name="excel_file" filename="excel_name"/>
<field name="excel_name" invisible="1"/>
</group>
<group/>
</group>
<div class="text-muted" style="margin-top:8px;">
<p>上传<b>评估机构填写完成后</b>的「资产评估结果明细表」(评估申请表单导出后发出去的那份)。</p>
<p>系统按「地址及位置」列匹配物业,把 评估基准日 / 评估编号 / 评估方法 / 评估信息 / 评估机构,
以及每行的 分租单价、分租总价、整租单价、整租总价、备注 写回物业的「评估信息」页。</p>
<p>只写入文件里<b>填了值</b>的单元格;空单元格不会覆盖物业上已有的值。物业名称在系统中不唯一时会报错,请先处理重名。</p>
</div>
</sheet>
<footer>
<button name="action_import" string="确认导入" type="object" class="btn-primary"/>
<button string="取消" class="btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
<record id="action_property_evaluation_import_wizard" model="ir.actions.act_window">
<field name="name">评估信息导入</field>
<field name="res_model">property.evaluation.import.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
</odoo>
@@ -0,0 +1,285 @@
# -*- coding: utf-8 -*-
import base64
import io
import logging
from datetime import datetime, date
from openpyxl import load_workbook
from openpyxl.utils.datetime import from_excel
from odoo import models, fields
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
SHEET_LABEL = '物业导入'
# 单工作表表头(顺序即模板列顺序):物业 3 列 + 管理信息明细 9 列 + 出租参考条件明细 9 列
HEADERS = [
'物业名称', '出租类型', '税率',
'管理类型', '所属片区', '权属类型', '管理面积', '可租面积', '管理员', '公司', '生效日期', '失效日期',
'开始日期', '结束日期', '租赁期限(<=)', '免租期(<=)', '第几年开始', '每几年递增',
'递增金额', '递增单价', '递增百分比',
]
# 权属类型 selection 中文 -> 值
TENURE_TYPE_MAP = {'自有': 'self', '代管': 'agency'}
DATE_FORMATS = ('%Y-%m-%d', '%Y/%m/%d', '%Y.%m.%d', '%Y%m%d', '%Y-%m-%d %H:%M:%S')
class PropertyImportWizard(models.TransientModel):
_name = 'property.import.wizard'
_description = '物业信息导入向导'
excel_file = fields.Binary(string='Excel文件', required=True)
excel_name = fields.Char(string='文件名')
# ==================== 单元格解析 ====================
@staticmethod
def _text(value):
if value is None:
return ''
if isinstance(value, str):
return value.strip()
if isinstance(value, float) and value.is_integer():
return str(int(value))
return str(value).strip()
@staticmethod
def _to_number(value):
if value is None or value == '' or isinstance(value, bool):
return False, 0.0
if isinstance(value, (int, float)):
return True, float(value)
try:
return True, float(str(value).strip().replace(',', ''))
except ValueError:
return False, 0.0
@staticmethod
def _to_date(value):
if value is None or value == '':
return False, False
if isinstance(value, datetime):
return True, value.date()
if isinstance(value, date):
return True, value
if isinstance(value, (int, float)):
try:
converted = from_excel(value)
except Exception:
return False, False
if isinstance(converted, datetime):
return True, converted.date()
if isinstance(converted, date):
return True, converted
return False, False
for fmt in DATE_FORMATS:
try:
return True, datetime.strptime(str(value).strip(), fmt).date()
except ValueError:
continue
return False, False
# ==================== 读取工作表 ====================
def _read_rows(self, workbook, errors):
"""读取第一个工作表,返回 [(excel行号, {表头: 原值})]"""
sheet = workbook[workbook.sheetnames[0]]
rows = list(sheet.iter_rows(values_only=True))
if not rows:
errors.append('模板中没有数据')
return []
headers = [self._text(h) for h in rows[0]]
missing = [h for h in HEADERS if h not in headers]
if missing:
errors.append('模板表头不正确,缺少列:%s(请使用最新模板)' % '、'.join(missing))
return []
index_map = {h: headers.index(h) for h in HEADERS}
result = []
for offset, row in enumerate(rows[1:], start=2):
if row is None or all(self._text(c) == '' for c in row):
continue
result.append((offset, {h: row[index_map[h]] if index_map[h] < len(row) else None
for h in HEADERS}))
return result
# ==================== 校验辅助 ====================
@staticmethod
def _where(row_no, prop_name, header):
prop_name = PropertyImportWizard._text(prop_name)
if prop_name and prop_name != '-':
return '第%s行:物业【%s】的【%s】' % (row_no, prop_name, header)
return '第%s行:【%s】' % (row_no, header)
def _get_m2o(self, model_name, value, row_no, prop_name, header, errors):
"""many2one 按名称查找;填错或不存在时提示联系管理员添加"""
name = self._text(value)
where = self._where(row_no, prop_name, header)
if not name:
errors.append('%s未填写' % where)
return False
record = self.env[model_name].search([('name', '=', name)], limit=1)
if not record:
errors.append('%s值【%s】不存在,请联系管理员添加' % (where, name))
return False
return record.id
def _get_selection(self, value, mapping, row_no, prop_name, header, errors):
"""selection 按中文标签匹配"""
text = self._text(value)
where = self._where(row_no, prop_name, header)
if not text:
errors.append('%s未填写' % where)
return False
if text not in mapping:
errors.append('%s值【%s】不存在,只能填写:%s' % (where, text, '、'.join(mapping.keys())))
return False
return mapping[text]
def _get_float(self, value, row_no, prop_name, header, errors):
where = self._where(row_no, prop_name, header)
if not self._text(value):
errors.append('%s未填写' % where)
return 0.0
ok, num = self._to_number(value)
if not ok:
errors.append('%s值【%s】不是有效数字' % (where, self._text(value)))
return 0.0
return num
def _get_int(self, value, row_no, prop_name, header, errors):
where = self._where(row_no, prop_name, header)
if not self._text(value):
errors.append('%s未填写' % where)
return 0
ok, num = self._to_number(value)
if not ok:
errors.append('%s值【%s】不是有效数字' % (where, self._text(value)))
return 0
return int(num)
def _get_date(self, value, row_no, prop_name, header, errors):
where = self._where(row_no, prop_name, header)
if not self._text(value):
errors.append('%s未填写' % where)
return False
ok, d = self._to_date(value)
if not ok:
errors.append('%s值【%s】不是有效日期(如 2026-01-01)' % (where, self._text(value)))
return False
return d
# ==================== 主流程 ====================
def action_import(self):
if not self.excel_file:
raise UserError('请先上传Excel文件!')
raw = self.excel_file
if isinstance(raw, str):
raw = raw.encode()
try:
content = base64.b64decode(raw, validate=True)
except Exception:
# 兼容直接传入原始字节的情况
content = raw
try:
workbook = load_workbook(filename=io.BytesIO(content), data_only=True)
except Exception as e:
raise UserError('文件解析失败:%s' % e)
errors = []
rows = self._read_rows(workbook, errors)
if errors:
self._raise_errors(errors)
records = [] # [(物业vals, 管理信息vals, 出租参考条件vals)]
seen_names = set()
for row_no, row in rows:
name = self._text(row['物业名称'])
if not name:
errors.append(self._where(row_no, '', '物业名称') + '未填写')
continue
if name in seen_names:
errors.append('第%s行:物业【%s】在本次导入中重复' % (row_no, name))
continue
if self.env['yuthon.property'].search_count([('name', '=', name)]):
errors.append('物业【%s】已存在,请勿重复导入' % name)
continue
seen_names.add(name)
# ---- 物业 ----
prop_vals = {
'name': name,
'asset_type_id': self._get_m2o('yuthon.resource.type', row['出租类型'],
row_no, name, '出租类型', errors),
'tax': self._get_float(row['税率'], row_no, name, '税率', errors),
}
# ---- 管理信息明细 ----
manage_vals = {
'purpose_id': self._get_m2o('yuthon.resource.purpose', row['管理类型'],
row_no, name, '管理类型', errors),
'precinct_id': self._get_m2o('yuthon.precinct.message', row['所属片区'],
row_no, name, '所属片区', errors),
'tenure_type': self._get_selection(row['权属类型'], TENURE_TYPE_MAP,
row_no, name, '权属类型', errors),
'management_area': self._get_float(row['管理面积'], row_no, name, '管理面积', errors),
'rent_area': self._get_float(row['可租面积'], row_no, name, '可租面积', errors),
'admin_id': self._get_m2o('res.users', row['管理员'],
row_no, name, '管理员', errors),
'company_id': self._get_m2o('yuthon.property.company', row['公司'],
row_no, name, '公司', errors),
'start_time': self._get_date(row['生效日期'], row_no, name, '生效日期', errors),
'end_date': self._get_date(row['失效日期'], row_no, name, '失效日期', errors),
}
# ---- 出租参考条件明细 ----
reference_vals = {
'start_date': self._get_date(row['开始日期'], row_no, name, '开始日期', errors),
'end_date': self._get_date(row['结束日期'], row_no, name, '结束日期', errors),
'lease_period': self._get_int(row['租赁期限(<=)'], row_no, name, '租赁期限(<=)', errors),
'rent_period': self._get_int(row['免租期(<=)'], row_no, name, '免租期(<=)', errors),
'first_years_begin': self._get_int(row['第几年开始'], row_no, name, '第几年开始', errors),
'increments_few_years': self._get_int(row['每几年递增'], row_no, name, '每几年递增', errors),
'annual_increase_amount': self._get_float(row['递增金额'], row_no, name, '递增金额', errors),
'rent_unit': self._get_float(row['递增单价'], row_no, name, '递增单价', errors),
'annual_increase_percentage': self._get_float(row['递增百分比'], row_no, name, '递增百分比', errors),
}
records.append((prop_vals, manage_vals, reference_vals))
if not records and not errors:
errors.append('模板中没有可导入的数据')
# ---------- 有错则整批失败,不写库 ----------
if errors:
self._raise_errors(errors)
# ---------- 写库 ----------
for prop_vals, manage_vals, reference_vals in records:
prop = self.env['yuthon.property'].create(prop_vals)
manage_vals['yuthon_property_id'] = prop.id
reference_vals['yuthon_property_id'] = prop.id
self.env['manage.information.line'].create(manage_vals)
self.env['yuthon.reference.conditions'].create(reference_vals)
_logger.info('物业导入:创建物业 %s (id=%s)', prop_vals['name'], prop.id)
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': '导入成功',
'message': '成功导入 %s 个物业(每个物业含 1 行管理信息明细、1 行出租参考条件明细)。'
% len(records),
'type': 'success',
'sticky': False,
'next': {'type': 'ir.actions.act_window_close'},
},
}
def _raise_errors(self, errors):
raise UserError('导入失败,共 %s 处问题(未写入任何数据):\n\n%s\n\n'
'※ 若提示「出租类型/管理类型/所属片区/管理员/公司」等值不存在,'
'请联系管理员先在系统中添加后再导入。'
% (len(errors), '\n'.join(errors[:50])))
@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<!-- 模板下载(模板放在 static/others 下,带初始示例数据) -->
<record id="property_import_template_downloads_action" model="ir.actions.act_url">
<field name="name">物业导入模板下载</field>
<field name="target">self</field>
<field name="url">/yuthon_property/static/others/property_import_temp.xlsx</field>
</record>
<record id="property_import_wizard_form" model="ir.ui.view">
<field name="name">property.import.wizard.form</field>
<field name="model">property.import.wizard</field>
<field name="arch" type="xml">
<form string="物业导入">
<header>
<button name="%(property_import_template_downloads_action)d" string="模板下载"
type="action" class="btn btn-primary"/>
</header>
<sheet>
<group>
<group>
<field name="excel_file" filename="excel_name"/>
<field name="excel_name" invisible="1"/>
</group>
<group/>
</group>
<div class="text-muted" style="margin-top:8px;">
<p>模板只有一个工作表,<b>一行一个物业</b>:前 3 列为物业信息,后面依次是「管理信息明细」和「出租参考条件明细」的列(各 1 行)。</p>
<p>所有列都必填;「权属类型」只能填 自有 / 代管。</p>
<p>若「出租类型 / 管理类型 / 所属片区 / 管理员 / 公司」等值不存在,导入会失败并提示,请联系管理员添加后再导入。</p>
</div>
</sheet>
<footer>
<button name="action_import" string="确认导入" type="object" class="btn-primary"/>
<button string="取消" class="btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
<record id="action_property_import_wizard" model="ir.actions.act_window">
<field name="name">物业导入</field>
<field name="res_model">property.import.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
</odoo>