@@ -981,12 +981,23 @@ class SoongWorkflowRecord(models.Model):
|
|||||||
})
|
})
|
||||||
|
|
||||||
def open_record(self):
|
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 {
|
return {
|
||||||
"type": "ir.actions.act_window",
|
"type": "ir.actions.act_window",
|
||||||
|
"name": self.name or self.res_model,
|
||||||
"res_model": self.res_model,
|
"res_model": self.res_model,
|
||||||
"res_id": self.res_id,
|
"res_id": self.res_id,
|
||||||
"name": self.name,
|
|
||||||
"view_mode": "form",
|
"view_mode": "form",
|
||||||
|
"views": [(False, "form")],
|
||||||
|
"target": "current",
|
||||||
}
|
}
|
||||||
|
|
||||||
@api.model
|
@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>
|
||||||
Reference in New Issue
Block a user