|
| 1 | +from collections import defaultdict |
| 2 | + |
| 3 | +from odoo import models |
| 4 | +from odoo.fields import Domain |
| 5 | + |
| 6 | + |
| 7 | +class ResCompany(models.Model): |
| 8 | + _inherit = "res.company" |
| 9 | + |
| 10 | + def stock_value_in_currency(self, accounts_by_product=None, at_date=None): |
| 11 | + """Inventory value per ``(valuation account, secondary currency)``. |
| 12 | +
|
| 13 | + Twin of ``stock_value``, which answers the same in company currency. Keyed by |
| 14 | + currency as well because one valuation account can gather products valued in |
| 15 | + different secondary currencies; products without one contribute nothing. |
| 16 | + """ |
| 17 | + self.ensure_one() |
| 18 | + value_by_account = defaultdict(float) |
| 19 | + if not accounts_by_product: |
| 20 | + accounts_by_product = self.with_context(prefetch_fields=False)._get_accounts_by_product() |
| 21 | + for product, accounts in accounts_by_product.items(): |
| 22 | + scoped = product.with_company(self) |
| 23 | + currency = scoped.valuation_currency_id |
| 24 | + if not currency: |
| 25 | + continue |
| 26 | + value_by_account[accounts["valuation"], currency] += scoped.with_context( |
| 27 | + to_date=at_date |
| 28 | + ).total_value_in_currency |
| 29 | + return value_by_account |
| 30 | + |
| 31 | + def stock_accounting_value_in_currency(self, accounts_by_product=None, at_date=None): |
| 32 | + """Booked value per ``(valuation account, secondary currency)``, off the |
| 33 | + ``amount_currency`` of the posted journal items. |
| 34 | +
|
| 35 | + Twin of ``stock_accounting_value``. It only sees what was booked WITH a secondary |
| 36 | + amount, so entries posted before this module did carry none and contribute zero — |
| 37 | + the column is not retroactive, and that is documented rather than estimated. |
| 38 | +
|
| 39 | + Lines expressed in the company currency are left out on purpose: their |
| 40 | + ``amount_currency`` mirrors the balance and adding it would double the company |
| 41 | + figure into a secondary total. |
| 42 | + """ |
| 43 | + self.ensure_one() |
| 44 | + if not accounts_by_product: |
| 45 | + accounts_by_product = self._get_accounts_by_product() |
| 46 | + account_data = defaultdict(float) |
| 47 | + currencies = self.env["res.currency"] |
| 48 | + accounts = self.env["account.account"] |
| 49 | + for product, product_accounts in accounts_by_product.items(): |
| 50 | + currency = product.with_company(self).valuation_currency_id |
| 51 | + if not currency: |
| 52 | + continue |
| 53 | + currencies |= currency |
| 54 | + accounts |= product_accounts["valuation"] |
| 55 | + if not (currencies and accounts): |
| 56 | + return account_data |
| 57 | + domain = Domain( |
| 58 | + [ |
| 59 | + ("account_id", "in", accounts.ids), |
| 60 | + ("company_id", "=", self.id), |
| 61 | + ("parent_state", "=", "posted"), |
| 62 | + ("currency_id", "in", currencies.ids), |
| 63 | + ] |
| 64 | + ) |
| 65 | + if at_date: |
| 66 | + domain &= Domain([("date", "<=", at_date)]) |
| 67 | + grouped = self.env["account.move.line"]._read_group( |
| 68 | + domain, ["account_id", "currency_id"], ["amount_currency:sum"] |
| 69 | + ) |
| 70 | + for account, currency, amount in grouped: |
| 71 | + account_data[account, currency] += amount |
| 72 | + return account_data |
| 73 | + |
| 74 | + def _get_valuation_currency_by_account(self, accounts_by_product): |
| 75 | + """The single secondary currency of each valuation account, or nothing. |
| 76 | +
|
| 77 | + A journal item carries ONE currency, so an account gathering products valued in |
| 78 | + different secondary currencies cannot state them all. Rather than pick one and be |
| 79 | + wrong, such an account is left out and its closing line stays in company currency |
| 80 | + — the same "one or none" rule the valuation wizard applies to its draft. |
| 81 | +
|
| 82 | + Products with NO secondary currency disqualify the account just the same, and that |
| 83 | + is the common case rather than the exotic one: a category is valued in a second |
| 84 | + currency while the rest of the catalogue, on the same default valuation account, |
| 85 | + is not. The closing line of an account is split into one line per product and the |
| 86 | + secondary amount is shared out across ALL of them, so those products take a slice |
| 87 | + of an amount that is not theirs and the one actually valued in that currency is |
| 88 | + left with a fraction of its own value. Measured on a database with demo data: of |
| 89 | + 100 in secondary currency belonging to a single product, that product kept 14,49 |
| 90 | + and the rest went to a dozen furniture products valued in no second currency at |
| 91 | + all. An account that mixes them cannot be stated in one currency, so it stays in |
| 92 | + company currency until it holds only products valued in the same one — in |
| 93 | + practice, giving the category its own valuation account. |
| 94 | + """ |
| 95 | + currencies_by_account = defaultdict(lambda: self.env["res.currency"]) |
| 96 | + mixed_accounts = self.env["account.account"] |
| 97 | + for product, accounts in accounts_by_product.items(): |
| 98 | + currency = product.with_company(self).valuation_currency_id |
| 99 | + if currency: |
| 100 | + currencies_by_account[accounts["valuation"]] |= currency |
| 101 | + else: |
| 102 | + mixed_accounts |= accounts["valuation"] |
| 103 | + return { |
| 104 | + account: currencies |
| 105 | + for account, currencies in currencies_by_account.items() |
| 106 | + if len(currencies) == 1 and account not in mixed_accounts |
| 107 | + } |
| 108 | + |
| 109 | + def _annotate_valuation_vals(self, vals_list, accounts_by_product, at_date=None): |
| 110 | + """Put the secondary amount on the closing vals, before they are split per product. |
| 111 | +
|
| 112 | + The variation being booked is inventory value minus what is already booked, the |
| 113 | + same shape ``_get_stock_valuation_account_vals`` uses in company currency. The |
| 114 | + location-reclassification ``extra_balance`` is NOT netted here: it is a |
| 115 | + company-currency notion today, with no secondary twin, so netting it would mix |
| 116 | + units. |
| 117 | +
|
| 118 | + Each pair of vals carries the amount with the sign of its own leg, so the entry |
| 119 | + adds up to zero in the secondary currency too. ``_get_valuation_val_extra_vals`` |
| 120 | + prorates it afterwards when the line is split per product. |
| 121 | + """ |
| 122 | + vals_list = super()._annotate_valuation_vals(vals_list, accounts_by_product, at_date=at_date) |
| 123 | + if not vals_list: |
| 124 | + return vals_list |
| 125 | + currency_by_account = self._get_valuation_currency_by_account(accounts_by_product) |
| 126 | + if not currency_by_account: |
| 127 | + return vals_list |
| 128 | + inventory = self.stock_value_in_currency(accounts_by_product, at_date) |
| 129 | + booked = self.stock_accounting_value_in_currency(accounts_by_product, at_date) |
| 130 | + Account = self.env["account.account"] |
| 131 | + # Walked TWO BY TWO: the vals come as pairs —valuation leg plus counterpart, as |
| 132 | + # ``_prepare_inventory_aml_vals`` returns them— and BOTH have to be annotated. |
| 133 | + # Annotating only the valuation leg moves it to the secondary currency and leaves |
| 134 | + # its counterpart alone in the company one, so neither group adds up to zero. |
| 135 | + for first, second in zip(vals_list[0::2], vals_list[1::2]): |
| 136 | + legs = {} |
| 137 | + for vals in (first, second): |
| 138 | + account = Account.browse(vals["account_id"]) |
| 139 | + if account in currency_by_account: |
| 140 | + legs[account] = vals |
| 141 | + # A pair that cannot be told apart is left alone rather than annotated wrongly: |
| 142 | + # an odd tail, or both legs being valuation accounts (one account's counterpart |
| 143 | + # is another product's valuation account). |
| 144 | + if len(legs) != 1: |
| 145 | + continue |
| 146 | + account = next(iter(legs)) |
| 147 | + currency = currency_by_account[account] |
| 148 | + balance_in_currency = inventory.get((account, currency), 0.0) - booked.get((account, currency), 0.0) |
| 149 | + if currency.is_zero(balance_in_currency): |
| 150 | + continue |
| 151 | + amount = abs(balance_in_currency) |
| 152 | + for vals in (first, second): |
| 153 | + # Each leg carries the amount with the sign of its own balance, so the pair |
| 154 | + # nets to zero in the secondary currency exactly as it does in the company |
| 155 | + # one. |
| 156 | + vals["currency_id"] = currency.id |
| 157 | + vals["amount_currency"] = amount if vals["debit"] else -amount |
| 158 | + return vals_list |
| 159 | + |
| 160 | + def _get_valuation_val_extra_vals(self, vals, balance, net): |
| 161 | + """Prorate the secondary amount with the SAME denominator as the balance. |
| 162 | +
|
| 163 | + Only ``debit`` / ``credit`` are re-split by the base, and every other key is copied |
| 164 | + verbatim onto every product line — right for the account or the label, wrong for an |
| 165 | + amount: N lines would each carry the full secondary amount, the entry would still |
| 166 | + add up in company currency, and would not in the other one. |
| 167 | +
|
| 168 | + Left unrounded on purpose: ``_balance_valuation_extra_vals`` rounds the whole split |
| 169 | + at once, which is the only place the cent lost between the shares can be seen. |
| 170 | + """ |
| 171 | + res = super()._get_valuation_val_extra_vals(vals, balance, net) |
| 172 | + if vals.get("amount_currency") and net: |
| 173 | + res["amount_currency"] = vals["amount_currency"] * balance / net |
| 174 | + return res |
| 175 | + |
| 176 | + def _balance_valuation_extra_vals(self, vals, product_vals): |
| 177 | + """Round every share and give the leftover to the largest one, so the split adds up |
| 178 | + to the secondary amount of the line it came from, to the cent. |
| 179 | +
|
| 180 | + Rounding each share on its own leaves the entry off by a cent in the secondary |
| 181 | + currency —three products sharing 100 take 33,33 each and 0,01 goes missing— and |
| 182 | + nothing downstream catches it: the entry balances in company currency, so it posts. |
| 183 | + The leftover goes to the largest share because that is where it is worth least in |
| 184 | + relative terms. |
| 185 | + """ |
| 186 | + product_vals = super()._balance_valuation_extra_vals(vals, product_vals) |
| 187 | + amount_currency = vals.get("amount_currency") |
| 188 | + currency = self.env["res.currency"].browse(vals.get("currency_id")) |
| 189 | + if not (amount_currency and currency): |
| 190 | + return product_vals |
| 191 | + shares = [share for share in product_vals if share.get("amount_currency")] |
| 192 | + if not shares: |
| 193 | + return product_vals |
| 194 | + for share in shares: |
| 195 | + share["amount_currency"] = currency.round(share["amount_currency"]) |
| 196 | + leftover = currency.round(amount_currency - sum(share["amount_currency"] for share in shares)) |
| 197 | + if not currency.is_zero(leftover): |
| 198 | + largest = max(shares, key=lambda share: abs(share["amount_currency"])) |
| 199 | + largest["amount_currency"] = currency.round(largest["amount_currency"] + leftover) |
| 200 | + return product_vals |
0 commit comments