diff --git a/soong_workflow/models/soong_workflow_record.py b/soong_workflow/models/soong_workflow_record.py index 789d093cf..017640877 100644 --- a/soong_workflow/models/soong_workflow_record.py +++ b/soong_workflow/models/soong_workflow_record.py @@ -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 diff --git a/yuthon_property/wizard/property_evaluation_import_wizard.py b/yuthon_property/wizard/property_evaluation_import_wizard.py new file mode 100644 index 000000000..1bc16fbce --- /dev/null +++ b/yuthon_property/wizard/property_evaluation_import_wizard.py @@ -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) diff --git a/yuthon_property/wizard/property_evaluation_import_wizard_views.xml b/yuthon_property/wizard/property_evaluation_import_wizard_views.xml new file mode 100644 index 000000000..acc60a38e --- /dev/null +++ b/yuthon_property/wizard/property_evaluation_import_wizard_views.xml @@ -0,0 +1,37 @@ + + + + property.evaluation.import.wizard.form + property.evaluation.import.wizard + + + + + + + + + + + + 上传评估机构填写完成后的「资产评估结果明细表」(评估申请表单导出后发出去的那份)。 + 系统按「地址及位置」列匹配物业,把 评估基准日 / 评估编号 / 评估方法 / 评估信息 / 评估机构, + 以及每行的 分租单价、分租总价、整租单价、整租总价、备注 写回物业的「评估信息」页。 + 只写入文件里填了值的单元格;空单元格不会覆盖物业上已有的值。物业名称在系统中不唯一时会报错,请先处理重名。 + + + + + + + + + 评估信息导入 + property.evaluation.import.wizard + form + new + + diff --git a/yuthon_property/wizard/property_import_wizard.py b/yuthon_property/wizard/property_import_wizard.py new file mode 100644 index 000000000..53d0f8541 --- /dev/null +++ b/yuthon_property/wizard/property_import_wizard.py @@ -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]))) diff --git a/yuthon_property/wizard/property_import_wizard_views.xml b/yuthon_property/wizard/property_import_wizard_views.xml new file mode 100644 index 000000000..fbb56780a --- /dev/null +++ b/yuthon_property/wizard/property_import_wizard_views.xml @@ -0,0 +1,47 @@ + + + + + 物业导入模板下载 + self + /yuthon_property/static/others/property_import_temp.xlsx + + + + property.import.wizard.form + property.import.wizard + + + + + + + + + + + + + + + 模板只有一个工作表,一行一个物业:前 3 列为物业信息,后面依次是「管理信息明细」和「出租参考条件明细」的列(各 1 行)。 + 所有列都必填;「权属类型」只能填 自有 / 代管。 + 若「出租类型 / 管理类型 / 所属片区 / 管理员 / 公司」等值不存在,导入会失败并提示,请联系管理员添加后再导入。 + + + + + + + + + 物业导入 + property.import.wizard + form + new + +
上传评估机构填写完成后的「资产评估结果明细表」(评估申请表单导出后发出去的那份)。
系统按「地址及位置」列匹配物业,把 评估基准日 / 评估编号 / 评估方法 / 评估信息 / 评估机构, + 以及每行的 分租单价、分租总价、整租单价、整租总价、备注 写回物业的「评估信息」页。
只写入文件里填了值的单元格;空单元格不会覆盖物业上已有的值。物业名称在系统中不唯一时会报错,请先处理重名。
模板只有一个工作表,一行一个物业:前 3 列为物业信息,后面依次是「管理信息明细」和「出租参考条件明细」的列(各 1 行)。
所有列都必填;「权属类型」只能填 自有 / 代管。
若「出租类型 / 管理类型 / 所属片区 / 管理员 / 公司」等值不存在,导入会失败并提示,请联系管理员添加后再导入。