# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. from odoo import models, fields, api, exceptions, _ from datetime import datetime, timedelta, date from dateutil.relativedelta import relativedelta from odoo.exceptions import UserError import calendar class PropertyLeaseRenewal(models.Model): _name = 'property.lease.renewal' _description = '租赁变更审批' _inherit = ['mail.thread.main.attachment', 'mail.activity.mixin', 'soong.workflow.mixin'] _rec_name = 'name' name = fields.Char(string='标题') user_id = fields.Many2one('res.users', string="申请人", default=lambda self: self.env.user) department_id = fields.Many2one(related='user_id.department_id', string="部门") company_id = fields.Many2one(related='user_id.company_id', string="公司") code = fields.Char(string="编号", default=lambda self: self.env['ir.sequence'].next_by_code('property_lease_renewal_code')) renewal_type = fields.Selection([('early_checkout', '合同终止'), ('lease', '承租方变更'), ('extend', '延长租期')], string='变更类型') urgency_level = fields.Selection([('normal', '普件'), ('urgent', '急件'), ('emergency', '特急件')], string='紧急度', default='normal') is_chapter = fields.Selection([('yes', '是'), ('no', '否')], string="是否用章") type_stamp_ids = fields.Many2many('seals.type.setting', string='用章类型', domain="['&', ('start_date', '<=', context_today()), '|', ('end_date', '=', False), ('end_date', '>=', context_today())]") 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])]) # 原合同信息 lessee_id = fields.Many2one(related="contract_id.lessee_id", string='原承租方') hide = fields.Boolean(string="隐藏") display_name = fields.Char(string='原承租方') contract_id = fields.Many2one('property.lease.contract', string='租赁合同') monthly_rent = fields.Float(related="contract_id.rent", string='租金') cycle_dict = fields.Selection(related="contract_id.cycle_dict", string='租金支付周期') original_start_date = fields.Date(related="contract_id.term_start_time", string='原租赁期开始时间') original_end_date = fields.Date(related="contract_id.term_end_time", string='原租赁期结束时间') property_name = fields.Char(related="contract_id.property_name", string='物业信息') # 变更信息 new_lessee_id = fields.Many2one("property.tenant.information", string="承租方", store=True) contact = fields.Char(string='联系人') phone = fields.Char(string='联系电话') card = fields.Char(string='身份证号') effective_date = fields.Date(string='生效日期', required=True) entity_type = fields.Selection([('person', '个人'), ('company', '企业')], string='承租方类型') reason = fields.Text(string='变更原因') fee = fields.Float(string='变更费用') document_ids = fields.Many2many('ir.attachment', string="变更附件") early_explain = fields.Text(string="退租说明") early_reason = fields.Selection([('normal', '正常退租'), ('agree', '协商一致'), ('settle', '违约结清'), ('other', '其他')], string="退租原因") early_quitting_date = fields.Date(string="退租日期") active = fields.Boolean('Active', default=True, tracking=True) work_end = fields.Boolean(string="结束") deposit = fields.Float(string="原押金") refund_amount = fields.Float(string="退款金额") deposit_treatment = fields.Selection([('refund', '全额退押'), ('part_refund', '部分退租'), ('no_refund', '全额没收')], string="押金处理方式", default='refund') total_receivables = fields.Float(string="应收金额", compute="_compute_total_receivables") forfeit = fields.Float(string="没收金额") receivables_ids = fields.One2many('early.receivables.line', 'renewal_id', string='应收明细') # 延长租期 actual_end_date = fields.Date(string='实际结束日期') breakdown_ids = fields.One2many('property.rent.breakdown.line', 'renewal_id', string='租金明细') def _get_tenant_by_contact(self, contact): """按联系人或姓名获取承租方信息""" if not contact: return False return self.env['property.tenant.information'].search([ '|', ('contact', '=', contact), ('name', '=', contact) ], limit=1) def _get_employee_by_contact(self, contact): """按联系人姓名匹配员工,拿身份证号等信息""" if not contact: return False return self.env['hr.employee'].search([('name', '=', contact)], limit=1) @api.onchange('new_lessee_id') def _onchange_new_lessee_id_fill_contact(self): """选择新承租方时,不再自动带出联系人/电话/身份证号,由联系人字段驱动。""" # 需求调整:不做自动填充,保留用户手动输入的联系人 return @api.onchange('refund_amount') def _onchange_forfeit(self): for rec in self: rec.forfeit = rec.deposit - rec.refund_amount @api.onchange('contact') def _onchange_contact_fill_info(self): """填写联系人后,按员工信息带出身份证号与联系电话;不再从承租方带出。""" for rec in self: if not rec.contact: continue employee = rec._get_employee_by_contact(rec.contact) if employee: rec.phone = employee.mobile_phone or False rec.card = employee.identification_id or False else: # 未找到员工时,清空以避免沿用旧值,由用户手填 rec.phone = False rec.card = False @api.depends('receivables_ids', 'receivables_ids.original_amount', 'receivables_ids.reduction_amount', 'receivables_ids.void') def _compute_total_receivables(self): for renewal in self: valid_receivable_lines = renewal.receivables_ids.filtered(lambda line: not line.void and line.is_reconciliation == False) total = sum(line.original_amount - line.reduction_amount for line in valid_receivable_lines) renewal.total_receivables = total def action_print_lease_renewal(self): """打印租赁变更审批""" self.ensure_one() report = self.env.ref('yuthon_property_approval.action_property_lease_renewal_report') return report.report_action(self) def end_approval(self): for rec in self: if rec.renewal_type == 'extend': # 延长租期:修改原合同实际结束日期,并将租金明细写入原合同 if rec.actual_end_date: rec.contract_id.write({ 'actual_end_time': rec.actual_end_date }) if rec.breakdown_ids: for line in rec.breakdown_ids: self.env['property.rent.breakdown.line'].create({ 'breakdown_id': rec.contract_id.id, 'unit_price': line.unit_price, 'monthly_amount': line.monthly_amount, 'start_data': line.start_data, 'end_data': line.end_data, 'month_number': line.month_number, 'period_rent': line.period_rent, }) elif rec.renewal_type == 'lease': origin_contract = rec.contract_id contract_actual_end = origin_contract.actual_end_time if origin_contract else None effective_date = fields.Date.to_date(rec.effective_date) last_day = calendar.monthrange(effective_date.year, effective_date.month)[1] month_end_date = date(effective_date.year, effective_date.month, last_day) contract_actual_end_date = fields.Date.to_date(contract_actual_end) if contract_actual_end else None if not contract_actual_end_date or contract_actual_end_date >= month_end_date: rec.hide = True origin_contract = rec.contract_id original_lessee_id = origin_contract.lessee_id.id if origin_contract.lessee_id else False version_str = origin_contract.version.lstrip('V') main_version = version_str.split('.')[0] new_main_version = int(main_version) + 1 new_version = f"V{new_main_version}.0" # 删除原承租方的物业合同信息 contract_to_unlink = origin_contract.lessee_id.property_contract_ids.filtered( lambda line: line.lease_contract_id.id == rec.contract_id.id ) if contract_to_unlink: contract_to_unlink.unlink() # 新增新承租方的物业合同信息 property_contract_list = [] origin_contract.write({ 'version': new_version, 'lessee_id': rec.new_lessee_id.id }) origin_contract.is_lessee = True self.env['lessee.alteration.line'].create({ 'contract_id': origin_contract.id, 'lessee_id': original_lessee_id, 'start_date': origin_contract.actual_start_time, 'end_date': rec.effective_date - relativedelta(days=1), }) origin_contract._compute_contract_count() property_contract_list.append([0, 0, { 'tenant_id': rec.new_lessee_id.id, 'lease_contract_id': rec.contract_id.id, 'rent': rec.contract_id.rent, }]) rec.new_lessee_id.write({'property_contract_ids': property_contract_list}) # 新增一条更名应收账单 management_type_id = rec.env["yuthon.management.type"].search([('name', '=', '更名费')]) if not management_type_id: management_type_id = rec.env["yuthon.management.type"].create({'name': '更名费'}) if rec.fee > 0: self.env['property.accounts.receivable'].create({ 'contract_id': origin_contract.id, 'original_amount': rec.fee, 'management_type_id': management_type_id.id, 'due_date': rec.effective_date, }) elif rec.renewal_type == 'early_checkout': workflow_record_id = self.env["soong.workflow.record"].search( [("res_model", "=", self._name), ("res_id", "=", self.id)]) lessor_id = rec.contract_id.lessor_id company_id = self.env['res.company'].search([('name', '=', '集团本部')], limit=1) if lessor_id.referred in ['天河投资', '天投城建']: company_id = self.env['res.company'].search([('name', '=', '集团本部')], limit=1) elif lessor_id.referred in ['天河物管']: company_id = self.env['res.company'].browse(3) elif lessor_id.referred in ['天河粮储']: company_id = self.env['res.company'].search([('name', '=', '天河粮储')], limit=1) # 取本审批最后一条流程日志的操作时间,作为账单 due_date operation_log = self.env['soong.workflow.log'].search([ ('model', '=', self._name), ('res_id', '=', rec.id), ], order='lase_click_date DESC, id DESC', limit=1) operation_date = operation_log.lase_click_date.date() if operation_log.lase_click_date else fields.Date.today() # 非押金退款明细(除押金外的退款,原逻辑保留) if rec.receivables_ids: refund_lines = rec.receivables_ids.filtered(lambda l: l.refund_amount > 0 and l.management_type_id.name != '押金') bills_line_list = [] if refund_lines: for refund_line in refund_lines: bills_line_list.append({ 'lessor_id': rec.contract_id.lessor_id.id, 'due_date': operation_date, 'management_type_id': refund_line.management_type_id.id, 'amount': refund_line.refund_amount, 'contract_id': rec.contract_id.id, 'receivables_id': refund_line.id, 'remake': rec.early_explain }) if bills_line_list: self.env['property.bills.list'].create(bills_line_list) # 押金处理(根据押金处理方式分三种,先判断去重再创建) deposit_management_type = self.env['yuthon.management.type'].search( [('name', '=', '押金')], limit=1) if rec.deposit_treatment == 'refund': # 全额退押金:仅创建 bills.list,金额 = 原押金 deposit if rec.deposit > 0: exist_bill = self.env['property.bills.list'].search([ ('contract_id', '=', rec.contract_id.id), ('management_type_id', '=', deposit_management_type.id), ('amount', '=', rec.deposit), ('workflow_record_id', '=', workflow_record_id.id), ], limit=1) if not exist_bill: self.env['property.bills.list'].create({ 'lessor_id': rec.contract_id.lessor_id.id, 'due_date': operation_date, 'management_type_id': deposit_management_type.id, 'amount': rec.deposit, 'contract_id': rec.contract_id.id, 'remake': rec.early_explain, 'workflow_record_id': workflow_record_id.id, }) elif rec.deposit_treatment == 'part_refund': # 部分退租:bills.list 金额 = refund_amount;bank.statements 和 receivable 金额 = forfeit if rec.refund_amount > 0: exist_bill = self.env['property.bills.list'].search([ ('contract_id', '=', rec.contract_id.id), ('management_type_id', '=', deposit_management_type.id), ('amount', '=', rec.refund_amount), ('workflow_record_id', '=', workflow_record_id.id), ], limit=1) if not exist_bill: self.env['property.bills.list'].create({ 'lessor_id': rec.contract_id.lessor_id.id, 'due_date': operation_date, 'management_type_id': deposit_management_type.id, 'amount': rec.refund_amount, 'contract_id': rec.contract_id.id, 'remake': rec.early_explain, 'workflow_record_id': workflow_record_id.id, }) if rec.forfeit > 0: start_time = datetime.combine(rec.early_quitting_date, datetime.min.time().replace(hour=8, minute=00)) existing_bank_statement = self.env['property.bank.statements'].sudo().search([ ('start_time', '=', start_time), ('original_amount', '=', rec.forfeit), ('name_account', '=', rec.lessee_id.name), ('remake', '=', rec.early_explain), ('workflow_record_id', '=', workflow_record_id.id), ], limit=1) if not existing_bank_statement: bank_accounts = self.env['property.bank.line'].search([ ('tenant_id', '=', rec.lessee_id.id) ]).mapped('bank') bank_accounts = [acc for acc in bank_accounts if acc][0] rec.contract_id.write({ 'actual_end_time': rec.early_quitting_date }) bank_id = self.env['property.bank.statements'].sudo().create({ 'start_time': start_time, 'name': bank_accounts, 'original_amount': rec.forfeit, 'name_account': rec.lessee_id.name, 'remake': rec.early_explain, 'lending_direction': 'loan', 'workflow_record_id': workflow_record_id.id, 'company_id': company_id.id, 'your_own_unit': lessor_id.referred, 'your_own_account': lessor_id.rent_account, }) bank_id._compute_property_employee_id() management_type_id = self.env['yuthon.management.type'].search([ ('name', '=', '违约金') ], limit=1) exist_receivable = self.env['property.accounts.receivable'].search([ ('contract_id', '=', rec.contract_id.id), ('management_type_id', '=', management_type_id.id), ('due_date', '=', rec.early_quitting_date), ('original_amount', '=', rec.forfeit), ], limit=1) if not exist_receivable: self.env['property.accounts.receivable'].create({ 'contract_id': rec.contract_id.id, 'management_type_id': management_type_id.id, 'due_date': rec.early_quitting_date, 'original_amount': rec.forfeit, 'remark': rec.early_explain, }) deposit_id = self.env['property.accounts.receivable'].search([ ('contract_id', '=', rec.contract_id.id), ('management_type_id.name', '=', '押金'), ], limit=1) if deposit_id: deposit_id.write({ 'workflow_record_id': workflow_record_id.id }) elif rec.deposit_treatment == 'no_refund': # 全额没收:bills.list、bank.statements、receivable 金额均为 deposit if rec.deposit > 0: exist_bill = self.env['property.bills.list'].search([ ('contract_id', '=', rec.contract_id.id), ('management_type_id', '=', deposit_management_type.id), ('amount', '=', rec.deposit), ('workflow_record_id', '=', workflow_record_id.id), ], limit=1) if not exist_bill: bill = self.env['property.bills.list'].create({ 'lessor_id': rec.contract_id.lessor_id.id, 'due_date': operation_date, 'management_type_id': deposit_management_type.id, 'amount': rec.deposit, 'contract_id': rec.contract_id.id, 'remake': rec.early_explain, 'workflow_record_id': workflow_record_id.id, }) else: bill = exist_bill start_time = datetime.combine(rec.early_quitting_date, datetime.min.time().replace(hour=8, minute=00)) existing_bank_statement = self.env['property.bank.statements'].sudo().search([ ('start_time', '=', start_time), ('original_amount', '=', rec.deposit), ('name_account', '=', rec.lessee_id.name), ('remake', '=', rec.early_explain), ('workflow_record_id', '=', workflow_record_id.id), ], limit=1) if not existing_bank_statement: bank_accounts = self.env['property.bank.line'].search([ ('tenant_id', '=', rec.lessee_id.id) ]).mapped('bank') bank_accounts = [acc for acc in bank_accounts if acc][0] rec.contract_id.write({ 'actual_end_time': rec.early_quitting_date }) bank_id = self.env['property.bank.statements'].sudo().create({ 'start_time': start_time, 'name': bank_accounts, 'original_amount': rec.deposit, 'name_account': rec.lessee_id.name, 'remake': rec.early_explain, 'lending_direction': 'loan', 'workflow_record_id': workflow_record_id.id, 'company_id': company_id.id, 'your_own_unit': lessor_id.referred, 'your_own_account': lessor_id.rent_account, }) bank_id._compute_property_employee_id() else: bank_id = existing_bank_statement management_type_id = self.env['yuthon.management.type'].search([ ('name', '=', '违约金') ], limit=1) exist_receivable = self.env['property.accounts.receivable'].search([ ('contract_id', '=', rec.contract_id.id), ('management_type_id', '=', management_type_id.id), ('due_date', '=', rec.early_quitting_date), ('original_amount', '=', rec.deposit), ], limit=1) if not exist_receivable: self.env['property.accounts.receivable'].create({ 'contract_id': rec.contract_id.id, 'management_type_id': management_type_id.id, 'due_date': rec.early_quitting_date, 'original_amount': rec.deposit, 'remark': rec.early_explain, }) # ===== 触发 action_pay + button_reconciliation 的真实逻辑 ===== # 说明:action_pay / button_reconciliation 按钮本身只是返回向导弹窗的方法, # 真正的业务在各自 wizard 的 confirm / 对账逻辑里。这里不再手工 write 子集字段, # 而是直接调用 property.bills.wizard 的 _do_pay()(与弹窗 confirm 共用同一份支付逻辑), # 以及复现银行流水对账的核心效果,保证与人工点“核销/对账”完全一致。 deposit_receivable = self.env['property.accounts.receivable'].search([ ('contract_id', '=', rec.contract_id.id), ('management_type_id', '=', deposit_management_type.id), ], limit=1) # 1) 支付效果(action_pay):调用支付弹窗的真实逻辑 —— 实付日期=应付日期、标记已付、 # 回写押金应收单实付日期与支付状态、结清合同。全额没收场景下跳过 confirm 的未对账校验。 if bill and not bill.actual_date: bill.amount_actual = bill.amount pay_wizard = self.env['property.bills.wizard'].create({ 'bills_list_id': bill.id, 'actual_date': bill.due_date, 'receivables_id': deposit_receivable.id if deposit_receivable else False, }) pay_wizard._do_pay() # 2) 对账效果(button_reconciliation):bank.statements 与押金应收对账 if bank_id and bank_id.bank_state != 'reconciled': bank_id.bank_state = 'reconciled' bank_id.yes_amount = bank_id.original_amount bank_id.reconciliation_time = fields.Datetime.now() - timedelta(hours=8) # 自动核销违约金 if exist_receivable: exist_receivable.write({ 'has_date': bill.due_date if bill else operation_date, 'is_reconciliation': True, 'payout_date': operation_date, 'delivered1': rec.deposit, 'deposit_state': 'paid', 'reconciliation_time': fields.Datetime.now(), 'bank_id': bank_id.id, }) self.env['receivable.modify.line'].create({ 'bank_id': bank_id.id, 'not_collected': rec.deposit, 'reconciliation_date': operation_date, 'receivable_id': exist_receivable.id, 'is_write': True, 'operation_date': fields.Date.today(), 'is_penalty': True, 'remake1': rec.early_explain, 'amount': rec.deposit, }) rec.contract_id.contact_state = 'settle' rec.contract_id.compute_is_has() # 原有作废/减免逻辑保留 for receivable_id in rec.receivables_ids: if receivable_id.void: receivable_id.receivable_id.write({ 'is_invalid': True, 'void_date': operation_date }) if receivable_id.reduction_amount: receivable_id.receivable_id.discount_amount = receivable_id.reduction_amount receivable_id.receivable_id.discount_date = operation_date @api.onchange('early_quitting_date', 'contract_id') def _onchange_early_quitting_date(self): self.receivables_ids = [(5, 0, 0)] receivable_ids = self.env['property.accounts.receivable'].search([ ('due_date', '<=', self.early_quitting_date), ('contract_id', '=', self.contract_id.id), ]) deposit_receivables = self.env['property.accounts.receivable'] normal_receivables = self.env['property.accounts.receivable'] for rec in receivable_ids: if rec.management_type_id and rec.management_type_id.name == '押金': deposit_receivables |= rec else: normal_receivables |= rec if deposit_receivables: self.deposit = sum(rec.original_amount for rec in deposit_receivables) receivable_list = [] for rec in normal_receivables: receivable_list.append([0, 0, { 'receivable_id': rec.id, 'is_reconciliation': rec.is_reconciliation, }]) self.receivables_ids = receivable_list @api.onchange('contract_id', 'renewal_type') def _onchange_name(self): if self.contract_id and self.renewal_type: renewal_type_text = dict(self._fields['renewal_type'].selection).get(self.renewal_type, '') self.name = f"{self.contract_id.name} + {renewal_type_text}" elif self.contract_id: self.name = self.contract_id.property_name else: self.name = False class PropertyEarlyQuitting(models.Model): _name = 'property.early.quitting' class EarlyReceivablesLine(models.Model): _name = 'early.receivables.line' _description = '应收明细' renewal_id = fields.Many2one('property.lease.renewal', string='关联续租审批') receivable_id = fields.Many2one('property.accounts.receivable', string='应收账单') management_type_id = fields.Many2one(related='receivable_id.management_type_id', string='类型') is_reconciliation = fields.Boolean(string='对账') due_date = fields.Date(related='receivable_id.due_date', string='应收日期') original_amount = fields.Float(string='金额', compute='_compute_original_amount') reduction_amount = fields.Float(string='减免金额') refund_amount = fields.Float(string='退款金额') void = fields.Boolean(string='作废', default=False) @api.depends('receivable_id') def _compute_original_amount(self): for line in self: line.original_amount = line.receivable_id.original_amount - line.receivable_id.discount_amount