|
| 1 | +# Copyright 2026 Ecosoft Co., Ltd (https://ecosoft.co.th) |
| 2 | +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). |
| 3 | + |
| 4 | +from odoo import Command, api, fields, models |
| 5 | + |
| 6 | + |
| 7 | +class ProjectRevenueRecognition(models.Model): |
| 8 | + _name = "project.revenue.recognition" |
| 9 | + _inherit = ["mail.thread", "mail.activity.mixin"] |
| 10 | + _description = "Project Revenue Recognition" |
| 11 | + _order = "name desc" |
| 12 | + |
| 13 | + name = fields.Char(default="/", required=True, copy=False) |
| 14 | + project_id = fields.Many2one( |
| 15 | + comodel_name="project.project", |
| 16 | + required=True, |
| 17 | + ondelete="cascade", |
| 18 | + ) |
| 19 | + date = fields.Date( |
| 20 | + default=fields.Date.today, |
| 21 | + required=True, |
| 22 | + string="Accounting Date", |
| 23 | + ) |
| 24 | + company_id = fields.Many2one( |
| 25 | + comodel_name="res.company", |
| 26 | + required=True, |
| 27 | + default=lambda self: self.env.company, |
| 28 | + ) |
| 29 | + currency_id = fields.Many2one( |
| 30 | + comodel_name="res.currency", |
| 31 | + related="company_id.currency_id", |
| 32 | + ) |
| 33 | + journal_id = fields.Many2one( |
| 34 | + comodel_name="account.journal", |
| 35 | + default=lambda self: self.env.company.project_revenue_recognition_journal_id, |
| 36 | + required=True, |
| 37 | + ) |
| 38 | + move_ids = fields.One2many( |
| 39 | + comodel_name="account.move", |
| 40 | + inverse_name="project_revenue_recognition_id", |
| 41 | + ) |
| 42 | + move_count = fields.Integer( |
| 43 | + compute="_compute_move_count", |
| 44 | + ) |
| 45 | + project_percentage = fields.Float( |
| 46 | + compute="_compute_project_percentage", |
| 47 | + store=True, |
| 48 | + ) |
| 49 | + project_amount = fields.Monetary( |
| 50 | + compute="_compute_project_amount", |
| 51 | + store=True, |
| 52 | + ) |
| 53 | + invoice_percentage = fields.Float( |
| 54 | + compute="_compute_invoice_percentage", |
| 55 | + store=True, |
| 56 | + ) |
| 57 | + invoice_amount = fields.Monetary( |
| 58 | + compute="_compute_invoice_amount", |
| 59 | + store=True, |
| 60 | + ) |
| 61 | + diff_amount = fields.Monetary( |
| 62 | + compute="_compute_diff_amount", |
| 63 | + store=True, |
| 64 | + ) |
| 65 | + state = fields.Selection( |
| 66 | + selection=[ |
| 67 | + ("draft", "Draft"), |
| 68 | + ("confirm", "Confirm"), |
| 69 | + ("done", "Done"), |
| 70 | + ("reverse", "Reverse"), |
| 71 | + ("cancel", "Cancel"), |
| 72 | + ], |
| 73 | + default="draft", |
| 74 | + ) |
| 75 | + |
| 76 | + def _get_amount_sale_invoice(self): |
| 77 | + all_sale_orders = ( |
| 78 | + self.project_id._fetch_sale_order_items( |
| 79 | + {"project.task": [("is_closed", "=", False)]} |
| 80 | + ) |
| 81 | + .sudo() |
| 82 | + .order_id |
| 83 | + ) |
| 84 | + so_amount_total = sum(all_sale_orders.mapped("amount_total")) |
| 85 | + so_invoices = all_sale_orders.mapped("invoice_ids").filtered( |
| 86 | + lambda inv: inv.state == "posted" |
| 87 | + ) |
| 88 | + inv_amount_total = sum(so_invoices.mapped("amount_total")) |
| 89 | + return inv_amount_total, so_amount_total |
| 90 | + |
| 91 | + @api.depends("project_id.task_completion_percentage") |
| 92 | + def _compute_project_percentage(self): |
| 93 | + for rec in self: |
| 94 | + rec.project_percentage = rec.project_id.task_completion_percentage |
| 95 | + |
| 96 | + @api.depends("project_percentage") |
| 97 | + def _compute_project_amount(self): |
| 98 | + for rec in self: |
| 99 | + rec.project_amount = ( |
| 100 | + rec.project_percentage * rec._get_amount_sale_invoice()[1] |
| 101 | + ) |
| 102 | + |
| 103 | + @api.depends("project_id") |
| 104 | + def _compute_invoice_percentage(self): |
| 105 | + for rec in self: |
| 106 | + inv_amount_total, so_amount_total = rec._get_amount_sale_invoice() |
| 107 | + if so_amount_total: |
| 108 | + rec.invoice_percentage = inv_amount_total / so_amount_total |
| 109 | + |
| 110 | + @api.depends("project_id") |
| 111 | + def _compute_invoice_amount(self): |
| 112 | + for rec in self: |
| 113 | + inv_amount_total, so_amount_total = rec._get_amount_sale_invoice() |
| 114 | + rec.invoice_amount = inv_amount_total |
| 115 | + |
| 116 | + @api.depends("project_amount", "invoice_amount") |
| 117 | + def _compute_diff_amount(self): |
| 118 | + for rec in self: |
| 119 | + rec.diff_amount = rec.project_amount - rec.invoice_amount |
| 120 | + |
| 121 | + @api.depends("move_ids") |
| 122 | + def _compute_move_count(self): |
| 123 | + for rec in self: |
| 124 | + rec.move_count = len(rec.move_ids) |
| 125 | + |
| 126 | + def _get_move_dict(self): |
| 127 | + self.ensure_one() |
| 128 | + return { |
| 129 | + "move_type": "entry", |
| 130 | + "journal_id": self.journal_id.id, |
| 131 | + "date": self.date, |
| 132 | + "ref": self.name, |
| 133 | + "project_revenue_recognition_id": self.id, |
| 134 | + } |
| 135 | + |
| 136 | + def _get_move_line_dict(self): |
| 137 | + self.ensure_one() |
| 138 | + company = self.company_id |
| 139 | + return [ |
| 140 | + Command.create( |
| 141 | + { |
| 142 | + "account_id": company.project_revenue_account_id.id, |
| 143 | + "name": self.name, |
| 144 | + "debit": self.project_amount if self.diff_amount > 0 else 0, |
| 145 | + "credit": self.project_amount if self.diff_amount < 0 else 0, |
| 146 | + } |
| 147 | + ), |
| 148 | + Command.create( |
| 149 | + { |
| 150 | + "account_id": company.project_expense_account_id.id, |
| 151 | + "name": self.name, |
| 152 | + "debit": self.project_amount if self.diff_amount < 0 else 0, |
| 153 | + "credit": self.project_amount if self.diff_amount > 0 else 0, |
| 154 | + } |
| 155 | + ), |
| 156 | + ] |
| 157 | + |
| 158 | + def _create_moves(self): |
| 159 | + move_dict = [] |
| 160 | + for rec in self: |
| 161 | + move = rec._get_move_dict() |
| 162 | + move["line_ids"] = rec._get_move_line_dict() |
| 163 | + move_dict.append(move) |
| 164 | + moves = self.env["account.move"].create(move_dict) |
| 165 | + return moves |
| 166 | + |
| 167 | + @api.model_create_multi |
| 168 | + def create(self, vals_list): |
| 169 | + for vals in vals_list: |
| 170 | + if vals.get("name", "/") == "/": |
| 171 | + name = ( |
| 172 | + self.env["ir.sequence"].next_by_code("project.revenue.recognition") |
| 173 | + or "/" |
| 174 | + ) |
| 175 | + vals["name"] = name |
| 176 | + return super().create(vals_list) |
| 177 | + |
| 178 | + def action_confirm(self): |
| 179 | + return self.write({"state": "confirm"}) |
| 180 | + |
| 181 | + def action_adjust_cost(self): |
| 182 | + self._create_moves() |
| 183 | + return self.write({"state": "done"}) |
| 184 | + |
| 185 | + def action_reverse_cost(self): |
| 186 | + # NOTE: Reverse cost is not implemented |
| 187 | + return self.write({"state": "reverse"}) |
| 188 | + |
| 189 | + def action_cancel(self): |
| 190 | + # NOTE: Cancel is not implemented |
| 191 | + return self.write({"state": "cancel"}) |
| 192 | + |
| 193 | + def action_draft(self): |
| 194 | + return self.write({"state": "draft"}) |
| 195 | + |
| 196 | + def action_view_move(self): |
| 197 | + self.ensure_one() |
| 198 | + action = { |
| 199 | + "name": self.env._("Journal Entries"), |
| 200 | + "view_mode": "list,form", |
| 201 | + "res_model": "account.move", |
| 202 | + "type": "ir.actions.act_window", |
| 203 | + "domain": [("id", "in", self.move_ids.ids)], |
| 204 | + "views": [ |
| 205 | + [self.env.ref("account.view_move_tree").id, "list"], |
| 206 | + [self.env.ref("account.view_move_form").id, "form"], |
| 207 | + ], |
| 208 | + } |
| 209 | + return action |
0 commit comments