Files
gzth/yuthon_property_approval/models/property_tenant_checkout.py
T
2026-09-20 20:25:00 +08:00

467 lines
27 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from datetime import datetime, timedelta, date
import calendar
from odoo import models, fields, api
from odoo.exceptions import UserError
class PropertyTenantCheckout(models.Model):
"""租户退租表:物业客户服务中心检查、租户签字、财务结算的多节点审批单。
各节点可见内容由工作流 state 字段驱动(form 视图按 state 显隐分组),
节点与按钮在工作流里配置,本模型只管字段与数据。"""
_name = 'property.tenant.checkout'
_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_tenant_checkout_code')) # 流水号
urgency_level = fields.Selection([('normal', '普件'), ('urgent', '急件'), ('emergency', '特急件')], string='紧急度',
default='normal') # 紧急程度
name_title = fields.Char(default=lambda self: self._description or self._name) # 表单标题展示(取模型描述)
self_users_ids = fields.Many2many('res.users', string="当前经办人", default=lambda self: [(6, 0, [self.env.user.id])]) # 当前经办人
active = fields.Boolean('Active', default=True, tracking=True) # 归档标记
work_end = fields.Boolean(string="结束") # 流程是否结束
document_ids = fields.Many2many('ir.attachment', 'property_tenant_checkout_document_rel',
'checkout_id', 'attachment_id', string="附件") # 附件
# ===== 关联租赁合同 =====
contract_id = fields.Many2one('property.lease.contract', string='租赁合同') # 从合同列表「退租」按钮发起时自动带入
# ===== 合同信息(关联后自动带出,只读参考)=====
tenant_unit = fields.Char(string='租户单元') # 如:4F东边01单元(关联合同后自动带出,可改)
lessee_id = fields.Many2one(related='contract_id.lessee_id', string='承租方', readonly=True) # 承租方(关联合同自动带出)
contract_code = fields.Char(related='contract_id.contract_code', string='合同编号', readonly=True) # 合同编号
lessor_id = fields.Many2one(related='contract_id.lessor_id', string='出租方公司', readonly=True) # 出租方
contract_term_start = fields.Date(related='contract_id.term_start_time', string='合同开始', readonly=True) # 合同期限起
contract_term_end = fields.Date(related='contract_id.term_end_time', string='合同结束', readonly=True) # 合同期限止
deposit_amount = fields.Float(related='contract_id.deposit', string='合同保证金', readonly=True) # 合同保证金
rent_address = fields.Char(string='租赁地址') # 租赁地址(关联合同后自动带出,可改)
# ===== 节点一:发起申请 =====
# 退租原因(同时兼任「合同办理方式」):驱动终止逻辑与表单显隐;early_quitting_date 为终止统一日期(退租截止日期与退租日期统一使用此字段)
early_reason = fields.Selection([
('normal', '正常退租'), ('agree', '协商一致'), ('settle', '违约结清'), ('other', '其他')
], string='退租原因', default='normal')
early_quitting_date = fields.Date(string='退租日期') # 实际退租日期(终止逻辑统一使用此字段)
early_explain = fields.Text(string='退租说明') # 退租说明(进入终止/押金处理逻辑)
other_note = fields.Text(string='其它(补充说明)') # 其它补充说明
# ===== 节点二:工程组检查与确认 =====
inspection_ids = fields.One2many('property.tenant.checkout.inspection', 'checkout_id', string='物业检查项') # 物业检查项明细
site_photo = fields.Many2many('ir.attachment', 'property_tenant_checkout_site_photo_rel',
'checkout_id', 'attachment_id', string='现场照片') # 现场照片
engineering_sign = fields.Binary(string='工程组检查确认签字') # 工程组检查确认签字(PNG)
handler = fields.Char(string='经办人') # 经办人(工程组)
# ===== 节点三:租户签字确认 =====
result_summary = fields.Text(string='结果摘要') # 退租结果摘要
tenant_signature = fields.Binary(string='租户电子签名') # 租户电子签名(PNG:手绘/打字/上传)
signer_name = fields.Char(string='签字人姓名') # 签字人姓名
tenant_sign_date = fields.Date(string='签字确认日期') # 签字确认日期
# ===== 节点四:费用结算与复核 =====
fee_ids = fields.One2many('property.tenant.checkout.fee', 'checkout_id', string='应收费用明细') # 应收费用明细
total_fee = fields.Float(string='应收合计', compute='_compute_total_fee', store=True) # 应收费用合计(自动求和)
deposit_treatment = fields.Selection([
('refund', '全额退押'), ('part_refund', '部分退租'), ('no_refund', '全额没收')
], string='保证金处理方式') # 保证金处理方式(与租赁变更审批·合同终止逻辑一致)
settlement_sign = fields.Binary(string='结算复核签字') # 结算复核签字(PNG)
# ===== 节点五:财务处理 =====
finance_handler = fields.Char(string='财务部经办人') # 财务部经办人
finance_reviewer = fields.Char(string='复核人') # 复核人
finance_person_in_charge = fields.Char(string='财务部负责人') # 财务部负责人
finance_date = fields.Date(string='财务日期') # 财务部日期
# ===== 合同终止相关(从租赁变更审批·合同终止迁移)=====
deposit = fields.Float(string='原押金') # 原押金(关联合同押金类应收汇总,onchange 带出)
refund_amount = fields.Float(string='退款金额') # 退款金额(部分退押时填写)
forfeit = fields.Float(string='没收金额') # 没收金额(= 原押金 - 退款金额)
total_receivables = fields.Float(string='应收金额', compute='_compute_total_receivables', store=True) # 应收金额(部分退押时汇总)
receivables_ids = fields.One2many('early.receivables.line', 'checkout_id', string='应收明细') # 截至退租日的应收账单(可作废/减免/退款)
@api.depends('fee_ids', 'fee_ids.amount')
def _compute_total_fee(self):
"""应收合计 = 所有费用明细金额之和"""
for rec in self:
rec.total_fee = sum(line.amount for line in rec.fee_ids)
@api.onchange('tenant_signature')
def _onchange_tenant_signature(self):
"""签名后自动填写签字日期;清除签名则清空日期(仅记录、不强制)"""
if self.tenant_signature:
self.tenant_sign_date = fields.Date.today() # 有签名则写入当天日期
else:
self.tenant_sign_date = False # 无签名则清空日期
@api.onchange('contract_id')
def _onchange_contract_id(self):
"""关联合同后自动带出公司名称/物业信息/租赁地址(可再手工修改);其余信息以 related 只读展示。
正常退租时,自动带出合同结束日期作为退租日期。"""
if self.contract_id:
self.tenant_unit = self.contract_id.property_name # 租户单元=合同物业信息
# 租赁地址=合同物业明细首行对应的物业地址名称(无物业明细则为空)
self.rent_address = self.contract_id.lease_ids[:1].address_id.name
# 正常退租:退租日期默认取合同结束日期(可再手工修改)
if self.early_reason == 'normal' and self.contract_id.term_end_time:
self.early_quitting_date = self.contract_id.term_end_time
@api.depends('receivables_ids', 'receivables_ids.original_amount', 'receivables_ids.reduction_amount',
'receivables_ids.void')
def _compute_total_receivables(self):
"""应收金额 = 未被作废且未对账的应收明细(原金额 - 减免金额)之和"""
for rec in self:
valid_lines = rec.receivables_ids.filtered(lambda line: not line.void and not line.is_reconciliation)
rec.total_receivables = sum(line.original_amount - line.reduction_amount for line in valid_lines)
@api.onchange('refund_amount')
def _onchange_forfeit(self):
"""部分退押时:没收金额 = 原押金 - 退款金额"""
for rec in self:
rec.forfeit = rec.deposit - rec.refund_amount
@api.onchange('early_quitting_date', 'contract_id')
def _onchange_early_quitting_date(self):
"""退租日期/合同变化:拉取截至退租日的应收账单生成应收明细,并汇总原押金"""
self.receivables_ids = [(5, 0, 0)]
if not self.early_quitting_date or not self.contract_id:
self.deposit = 0
return
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
def end_approval(self):
"""合同终止终审逻辑(从租赁变更审批·合同终止 early_checkout 分支迁移)。
统一以 early_quitting_date 作为终止日期;按 early_reason 区分退租方式由前端/配置决定,
此处对所有退租场景统一处理:押金处理 + 应收作废/减免 + 银行流水与对账。"""
for rec in self:
contract = rec.contract_id
if not contract:
continue
workflow_record_id = self.env["soong.workflow.record"].search(
[("res_model", "=", self._name), ("res_id", "=", self.id)])
lessor_id = contract.lessor_id
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)
else:
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 and 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': contract.lessor_id.id,
'due_date': operation_date,
'management_type_id': refund_line.management_type_id.id,
'amount': refund_line.refund_amount,
'contract_id': contract.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', '=', contract.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': contract.lessor_id.id,
'due_date': operation_date,
'management_type_id': deposit_management_type.id,
'amount': rec.deposit,
'contract_id': contract.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
forfeit = rec.deposit - rec.refund_amount
if rec.refund_amount > 0:
exist_bill = self.env['property.bills.list'].search([
('contract_id', '=', contract.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': contract.lessor_id.id,
'due_date': operation_date,
'management_type_id': deposit_management_type.id,
'amount': rec.refund_amount,
'contract_id': contract.id,
'remake': rec.early_explain,
'workflow_record_id': workflow_record_id.id,
})
if 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', '=', 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')
filtered_accounts = [acc for acc in bank_accounts if acc]
bank_accounts = filtered_accounts[0] if filtered_accounts else None
contract.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': 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', '=', contract.id),
('management_type_id', '=', management_type_id.id),
('due_date', '=', rec.early_quitting_date),
('original_amount', '=', forfeit),
], limit=1)
if not exist_receivable:
self.env['property.accounts.receivable'].create({
'contract_id': contract.id,
'management_type_id': management_type_id.id,
'due_date': rec.early_quitting_date,
'original_amount': forfeit,
'remark': rec.early_explain,
})
deposit_id = self.env['property.accounts.receivable'].search([
('contract_id', '=', contract.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', '=', contract.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': contract.lessor_id.id,
'due_date': operation_date,
'management_type_id': deposit_management_type.id,
'amount': rec.deposit,
'contract_id': contract.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')
filtered_accounts = [acc for acc in bank_accounts if acc]
bank_accounts = filtered_accounts[0] if filtered_accounts else None
contract.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', '=', contract.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': contract.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 的真实逻辑 =====
deposit_receivable = self.env['property.accounts.receivable'].search([
('contract_id', '=', contract.id),
('management_type_id', '=', deposit_management_type.id),
], limit=1)
# 1) 支付效果(action_pay):调用支付弹窗的真实逻辑
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,
})
contract.contact_state = 'settle'
contract.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
def action_print_tenant_checkout(self):
"""打印租户退租表"""
self.ensure_one()
report = self.env.ref('yuthon_property_approval.action_property_tenant_checkout_report')
return report.report_action(self)
class PropertyLeaseContract(models.Model):
"""在租赁合同上增加「退租」入口:从合同列表直接发起租户退租表"""
_inherit = 'property.lease.contract'
def action_tenant_checkout(self):
"""合同列表「退租」按钮:打开租户退租表并自动关联当前合同"""
self.ensure_one()
return {
'name': '租户退租表',
'type': 'ir.actions.act_window',
'res_model': 'property.tenant.checkout',
'view_mode': 'form',
'target': 'current',
'context': {
'default_contract_id': self.id, # 关联当前合同
'default_tenant_unit': self.property_name, # 带出租户单元(物业信息)
'default_rent_address': self.lease_ids[:1].address_id.name, # 带出租赁地址(物业地址名称)
},
}