12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789 |
- import ast
- from collections import defaultdict
- from contextlib import contextmanager
- from datetime import date, timedelta
- from functools import lru_cache
- from odoo import api, fields, models, Command, _
- from odoo.exceptions import ValidationError, UserError
- from odoo.tools import frozendict, formatLang, format_date, float_compare, Query
- from odoo.tools.sql import create_index
- from odoo.addons.web.controllers.utils import clean_action
- from odoo.addons.account.models.account_move import MAX_HASH_VERSION
- class AccountMoveLine(models.Model):
- _name = "account.move.line"
- _inherit = "analytic.mixin"
- _description = "Journal Item"
- _order = "date desc, move_name desc, id"
- _check_company_auto = True
- _rec_names_search = ['name', 'move_id', 'product_id']
- # ==============================================================================================
- # JOURNAL ENTRY
- # ==============================================================================================
- # === Parent fields === #
- move_id = fields.Many2one(
- comodel_name='account.move',
- string='Journal Entry',
- required=True,
- readonly=True,
- index=True,
- auto_join=True,
- ondelete="cascade",
- check_company=True,
- )
- journal_id = fields.Many2one(
- related='move_id.journal_id', store=True, precompute=True,
- index=True,
- copy=False,
- )
- company_id = fields.Many2one(
- related='move_id.company_id', store=True, readonly=True, precompute=True,
- index=True,
- )
- company_currency_id = fields.Many2one(
- string='Company Currency',
- related='move_id.company_currency_id', readonly=True, store=True, precompute=True,
- )
- move_name = fields.Char(
- string='Number',
- related='move_id.name', store=True,
- index='btree',
- )
- parent_state = fields.Selection(related='move_id.state', store=True)
- date = fields.Date(
- related='move_id.date', store=True,
- copy=False,
- group_operator='min',
- )
- ref = fields.Char(
- related='move_id.ref', store=True,
- copy=False,
- index='trigram',
- )
- is_storno = fields.Boolean(
- string="Company Storno Accounting",
- related='move_id.is_storno',
- help="Utility field to express whether the journal item is subject to storno accounting",
- )
- sequence = fields.Integer(compute='_compute_sequence', store=True, readonly=False, precompute=True)
- move_type = fields.Selection(related='move_id.move_type')
- # === Accountable fields === #
- account_id = fields.Many2one(
- comodel_name='account.account',
- string='Account',
- compute='_compute_account_id', store=True, readonly=False, precompute=True,
- inverse='_inverse_account_id',
- index=True,
- auto_join=True,
- ondelete="cascade",
- domain="[('deprecated', '=', False), ('company_id', '=', company_id), ('is_off_balance', '=', False)]",
- check_company=True,
- tracking=True,
- )
- name = fields.Char(
- string='Label',
- compute='_compute_name', store=True, readonly=False, precompute=True,
- tracking=True,
- )
- debit = fields.Monetary(
- string='Debit',
- compute='_compute_debit_credit', inverse='_inverse_debit', store=True, precompute=True,
- currency_field='company_currency_id',
- )
- credit = fields.Monetary(
- string='Credit',
- compute='_compute_debit_credit', inverse='_inverse_credit', store=True, precompute=True,
- currency_field='company_currency_id',
- )
- balance = fields.Monetary(
- string='Balance',
- compute='_compute_balance', store=True, readonly=False, precompute=True,
- currency_field='company_currency_id',
- )
- cumulated_balance = fields.Monetary(
- string='Cumulated Balance',
- compute='_compute_cumulated_balance',
- currency_field='company_currency_id',
- exportable=False,
- help="Cumulated balance depending on the domain and the order chosen in the view.")
- currency_rate = fields.Float(
- compute='_compute_currency_rate',
- help="Currency rate from company currency to document currency.",
- )
- amount_currency = fields.Monetary(
- string='Amount in Currency',
- group_operator=None,
- compute='_compute_amount_currency', inverse='_inverse_amount_currency', store=True, readonly=False, precompute=True,
- help="The amount expressed in an optional other currency if it is a multi-currency entry.")
- currency_id = fields.Many2one(
- comodel_name='res.currency',
- string='Currency',
- compute='_compute_currency_id', store=True, readonly=False, precompute=True,
- required=True,
- )
- is_same_currency = fields.Boolean(compute='_compute_same_currency')
- partner_id = fields.Many2one(
- comodel_name='res.partner',
- string='Partner',
- compute='_compute_partner_id', inverse='_inverse_partner_id', store=True, readonly=False, precompute=True,
- ondelete='restrict',
- )
- # === Origin fields === #
- reconcile_model_id = fields.Many2one(
- comodel_name='account.reconcile.model',
- string="Reconciliation Model",
- copy=False,
- readonly=True,
- check_company=True,
- )
- payment_id = fields.Many2one(
- comodel_name='account.payment',
- string="Originator Payment",
- related='move_id.payment_id', store=True,
- auto_join=True,
- index='btree_not_null',
- help="The payment that created this entry")
- statement_line_id = fields.Many2one(
- comodel_name='account.bank.statement.line',
- string="Originator Statement Line",
- related='move_id.statement_line_id', store=True,
- auto_join=True,
- index='btree_not_null',
- help="The statement line that created this entry")
- statement_id = fields.Many2one(
- related='statement_line_id.statement_id', store=True,
- auto_join=True,
- index='btree_not_null',
- copy=False,
- help="The bank statement used for bank reconciliation")
- # === Tax fields === #
- tax_ids = fields.Many2many(
- comodel_name='account.tax',
- string="Taxes",
- compute='_compute_tax_ids', store=True, readonly=False, precompute=True,
- context={'active_test': False},
- check_company=True,
- )
- group_tax_id = fields.Many2one(
- comodel_name='account.tax',
- string="Originator Group of Taxes",
- index='btree_not_null',
- )
- tax_line_id = fields.Many2one(
- comodel_name='account.tax',
- string='Originator Tax',
- related='tax_repartition_line_id.tax_id', store=True, precompute=True,
- ondelete='restrict',
- help="Indicates that this journal item is a tax line")
- tax_group_id = fields.Many2one( # used in the widget tax-group-custom-field
- string='Originator tax group',
- related='tax_line_id.tax_group_id', store=True, precompute=True,
- )
- tax_base_amount = fields.Monetary(
- string="Base Amount",
- readonly=True,
- currency_field='company_currency_id',
- )
- tax_repartition_line_id = fields.Many2one(
- comodel_name='account.tax.repartition.line',
- string="Originator Tax Distribution Line",
- ondelete='restrict',
- readonly=True,
- check_company=True,
- help="Tax distribution line that caused the creation of this move line, if any")
- tax_tag_ids = fields.Many2many(
- string="Tags",
- comodel_name='account.account.tag',
- ondelete='restrict',
- context={'active_test': False},
- tracking=True,
- help="Tags assigned to this line by the tax creating it, if any. It determines its impact on financial reports.",
- )
- tax_audit = fields.Char(
- string="Tax Audit String",
- compute="_compute_tax_audit", store=True,
- help="Computed field, listing the tax grids impacted by this line, and the amount it applies to each of them.")
- # Technical field. True if the balance of this move line needs to be
- # inverted when computing its total for each tag (for sales invoices, for # example)
- tax_tag_invert = fields.Boolean(
- string="Invert Tags",
- compute='_compute_tax_tag_invert', store=True, readonly=False, copy=False,
- )
- # === Reconciliation fields === #
- amount_residual = fields.Monetary(
- string='Residual Amount',
- compute='_compute_amount_residual', store=True,
- currency_field='company_currency_id',
- help="The residual amount on a journal item expressed in the company currency.",
- )
- amount_residual_currency = fields.Monetary(
- string='Residual Amount in Currency',
- compute='_compute_amount_residual', store=True,
- help="The residual amount on a journal item expressed in its currency (possibly not the "
- "company currency).",
- )
- reconciled = fields.Boolean(compute='_compute_amount_residual', store=True)
- full_reconcile_id = fields.Many2one(
- comodel_name='account.full.reconcile',
- string="Matching",
- copy=False,
- index='btree_not_null',
- readonly=True,
- )
- matched_debit_ids = fields.One2many(
- comodel_name='account.partial.reconcile', inverse_name='credit_move_id',
- string='Matched Debits',
- readonly=True,
- help='Debit journal items that are matched with this journal item.',
- )
- matched_credit_ids = fields.One2many(
- comodel_name='account.partial.reconcile', inverse_name='debit_move_id',
- string='Matched Credits',
- readonly=True,
- help='Credit journal items that are matched with this journal item.',
- )
- matching_number = fields.Char(
- string="Matching #",
- compute='_compute_matching_number', store=True,
- help="Matching number for this line, 'P' if it is only partially reconcile, or the name of "
- "the full reconcile if it exists.",
- )
- is_account_reconcile = fields.Boolean(
- string='Account Reconcile',
- related='account_id.reconcile',
- )
- # === Related fields ===
- account_type = fields.Selection(
- related='account_id.account_type',
- string="Internal Type",
- )
- account_internal_group = fields.Selection(related='account_id.internal_group')
- account_root_id = fields.Many2one(
- related='account_id.root_id',
- string="Account Root",
- store=True,
- )
- # ==============================================================================================
- # INVOICE
- # ==============================================================================================
- display_type = fields.Selection(
- selection=[
- ('product', 'Product'),
- ('cogs', 'Cost of Goods Sold'),
- ('tax', 'Tax'),
- ('rounding', "Rounding"),
- ('payment_term', 'Payment Term'),
- ('line_section', 'Section'),
- ('line_note', 'Note'),
- ('epd', 'Early Payment Discount')
- ],
- compute='_compute_display_type', store=True, readonly=False, precompute=True,
- required=True,
- )
- product_id = fields.Many2one(
- comodel_name='product.product',
- string='Product',
- inverse='_inverse_product_id',
- ondelete='restrict',
- )
- product_uom_id = fields.Many2one(
- comodel_name='uom.uom',
- string='Unit of Measure',
- compute='_compute_product_uom_id', store=True, readonly=False, precompute=True,
- domain="[('category_id', '=', product_uom_category_id)]",
- ondelete="restrict",
- )
- product_uom_category_id = fields.Many2one(
- comodel_name='uom.category',
- related='product_id.uom_id.category_id',
- )
- quantity = fields.Float(
- string='Quantity',
- compute='_compute_quantity', store=True, readonly=False, precompute=True,
- digits='Product Unit of Measure',
- help="The optional quantity expressed by this line, eg: number of product sold. "
- "The quantity is not a legal requirement but is very useful for some reports.",
- )
- date_maturity = fields.Date(
- string='Due Date',
- index=True,
- tracking=True,
- help="This field is used for payable and receivable journal entries. "
- "You can put the limit date for the payment of this line.",
- )
- # === Price fields === #
- price_unit = fields.Float(
- string='Unit Price',
- compute="_compute_price_unit", store=True, readonly=False, precompute=True,
- digits='Product Price',
- )
- price_subtotal = fields.Monetary(
- string='Subtotal',
- compute='_compute_totals', store=True,
- currency_field='currency_id',
- )
- price_total = fields.Monetary(
- string='Total',
- compute='_compute_totals', store=True,
- currency_field='currency_id',
- )
- discount = fields.Float(
- string='Discount (%)',
- digits='Discount',
- default=0.0,
- )
- # === Invoice sync fields === #
- term_key = fields.Binary(compute='_compute_term_key', exportable=False)
- tax_key = fields.Binary(compute='_compute_tax_key', exportable=False)
- compute_all_tax = fields.Binary(compute='_compute_all_tax', exportable=False)
- compute_all_tax_dirty = fields.Boolean(compute='_compute_all_tax')
- epd_key = fields.Binary(compute='_compute_epd_key', exportable=False)
- epd_needed = fields.Binary(compute='_compute_epd_needed', exportable=False)
- epd_dirty = fields.Boolean(compute='_compute_epd_needed')
- # === Analytic fields === #
- analytic_line_ids = fields.One2many(
- comodel_name='account.analytic.line', inverse_name='move_line_id',
- string='Analytic lines',
- )
- analytic_distribution = fields.Json(
- inverse="_inverse_analytic_distribution",
- ) # add the inverse function used to trigger the creation/update of the analytic lines accordingly (field originally defined in the analytic mixin)
- # === Early Pay fields === #
- discount_date = fields.Date(
- string='Discount Date',
- store=True,
- help='Last date at which the discounted amount must be paid in order for the Early Payment Discount to be granted'
- )
- # Discounted amount to pay when the early payment discount is applied
- discount_amount_currency = fields.Monetary(
- string='Discount amount in Currency',
- store=True,
- currency_field='currency_id',
- )
- # Discounted balance when the early payment discount is applied
- discount_balance = fields.Monetary(
- string='Discount Balance',
- store=True,
- currency_field='company_currency_id',
- )
- discount_percentage = fields.Float(store=True,)
- # === Misc Information === #
- blocked = fields.Boolean(
- string='No Follow-up',
- default=False,
- help="You can check this box to mark this journal item as a litigation with the "
- "associated partner",
- )
- is_refund = fields.Boolean(compute='_compute_is_refund')
- _sql_constraints = [
- (
- "check_credit_debit",
- "CHECK(display_type IN ('line_section', 'line_note') OR credit * debit=0)",
- "Wrong credit or debit value in accounting entry !"
- ),
- (
- "check_amount_currency_balance_sign",
- """CHECK(
- display_type IN ('line_section', 'line_note')
- OR (
- (balance <= 0 AND amount_currency <= 0)
- OR
- (balance >= 0 AND amount_currency >= 0)
- )
- )""",
- "The amount expressed in the secondary currency must be positive when account is debited and negative when "
- "account is credited. If the currency is the same as the one from the company, this amount must strictly "
- "be equal to the balance."
- ),
- (
- "check_accountable_required_fields",
- "CHECK(display_type IN ('line_section', 'line_note') OR account_id IS NOT NULL)",
- "Missing required account on accountable line."
- ),
- (
- "check_non_accountable_fields_null",
- "CHECK(display_type NOT IN ('line_section', 'line_note') OR (amount_currency = 0 AND debit = 0 AND credit = 0 AND account_id IS NULL))",
- "Forbidden balance or account on non-accountable line"
- ),
- ]
- # -------------------------------------------------------------------------
- # COMPUTE METHODS
- # -------------------------------------------------------------------------
- @api.depends('move_id')
- def _compute_display_type(self):
- for line in self.filtered(lambda l: not l.display_type):
- # avoid cyclic dependencies with _compute_account_id
- account_set = self.env.cache.contains(line, line._fields['account_id'])
- tax_set = self.env.cache.contains(line, line._fields['tax_line_id'])
- line.display_type = (
- 'tax' if tax_set and line.tax_line_id else
- 'payment_term' if account_set and line.account_id.account_type in ['asset_receivable', 'liability_payable'] else
- 'product'
- ) if line.move_id.is_invoice() else 'product'
- # Do not depend on `move_id.partner_id`, the inverse is taking care of that
- def _compute_partner_id(self):
- for line in self:
- line.partner_id = line.move_id.partner_id.commercial_partner_id
- @api.depends('move_id.currency_id')
- def _compute_currency_id(self):
- for line in self:
- if line.display_type == 'cogs':
- line.currency_id = line.company_currency_id
- elif line.move_id.is_invoice(include_receipts=True):
- line.currency_id = line.move_id.currency_id
- else:
- line.currency_id = line.currency_id or line.company_id.currency_id
- @api.depends('product_id')
- def _compute_name(self):
- for line in self:
- if line.display_type == 'payment_term':
- if line.move_id.payment_reference:
- line.name = line.move_id.payment_reference
- elif not line.name:
- line.name = ''
- continue
- if not line.product_id or line.display_type in ('line_section', 'line_note'):
- continue
- if line.partner_id.lang:
- product = line.product_id.with_context(lang=line.partner_id.lang)
- else:
- product = line.product_id
- values = []
- if product.partner_ref:
- values.append(product.partner_ref)
- if line.journal_id.type == 'sale':
- if product.description_sale:
- values.append(product.description_sale)
- elif line.journal_id.type == 'purchase':
- if product.description_purchase:
- values.append(product.description_purchase)
- line.name = '\n'.join(values)
- def _compute_account_id(self):
- term_lines = self.filtered(lambda line: line.display_type == 'payment_term')
- if term_lines:
- moves = term_lines.move_id
- self.env.cr.execute("""
- WITH previous AS (
- SELECT DISTINCT ON (line.move_id)
- 'account.move' AS model,
- line.move_id AS id,
- NULL AS account_type,
- line.account_id AS account_id
- FROM account_move_line line
- WHERE line.move_id = ANY(%(move_ids)s)
- AND line.display_type = 'payment_term'
- AND line.id != ANY(%(current_ids)s)
- ),
- properties AS(
- SELECT DISTINCT ON (property.company_id, property.name, property.res_id)
- 'res.partner' AS model,
- SPLIT_PART(property.res_id, ',', 2)::integer AS id,
- CASE
- WHEN property.name = 'property_account_receivable_id' THEN 'asset_receivable'
- ELSE 'liability_payable'
- END AS account_type,
- SPLIT_PART(property.value_reference, ',', 2)::integer AS account_id
- FROM ir_property property
- JOIN res_company company ON property.company_id = company.id
- WHERE property.name IN ('property_account_receivable_id', 'property_account_payable_id')
- AND property.company_id = ANY(%(company_ids)s)
- AND property.res_id = ANY(%(partners)s)
- ORDER BY property.company_id, property.name, property.res_id, account_id
- ),
- default_properties AS(
- SELECT DISTINCT ON (property.company_id, property.name)
- 'res.partner' AS model,
- company.partner_id AS id,
- CASE
- WHEN property.name = 'property_account_receivable_id' THEN 'asset_receivable'
- ELSE 'liability_payable'
- END AS account_type,
- SPLIT_PART(property.value_reference, ',', 2)::integer AS account_id
- FROM ir_property property
- JOIN res_company company ON property.company_id = company.id
- WHERE property.name IN ('property_account_receivable_id', 'property_account_payable_id')
- AND property.company_id = ANY(%(company_ids)s)
- AND property.res_id IS NULL
- ORDER BY property.company_id, property.name, account_id
- ),
- fallback AS (
- SELECT DISTINCT ON (account.company_id, account.account_type)
- 'res.company' AS model,
- account.company_id AS id,
- account.account_type AS account_type,
- account.id AS account_id
- FROM account_account account
- WHERE account.company_id = ANY(%(company_ids)s)
- AND account.account_type IN ('asset_receivable', 'liability_payable')
- AND account.deprecated = 'f'
- )
- SELECT * FROM previous
- UNION ALL
- SELECT * FROM properties
- UNION ALL
- SELECT * FROM default_properties
- UNION ALL
- SELECT * FROM fallback
- """, {
- 'company_ids': moves.company_id.ids,
- 'move_ids': moves.ids,
- 'partners': [f'res.partner,{pid}' for pid in moves.commercial_partner_id.ids],
- 'current_ids': term_lines.ids
- })
- accounts = {
- (model, id, account_type): account_id
- for model, id, account_type, account_id in self.env.cr.fetchall()
- }
- for line in term_lines:
- account_type = 'asset_receivable' if line.move_id.is_sale_document(include_receipts=True) else 'liability_payable'
- move = line.move_id
- account_id = (
- accounts.get(('account.move', move.id, None))
- or accounts.get(('res.partner', move.commercial_partner_id.id, account_type))
- or accounts.get(('res.partner', move.company_id.partner_id.id, account_type))
- or accounts.get(('res.company', move.company_id.id, account_type))
- )
- if line.move_id.fiscal_position_id:
- account_id = self.move_id.fiscal_position_id.map_account(self.env['account.account'].browse(account_id))
- line.account_id = account_id
- product_lines = self.filtered(lambda line: line.display_type == 'product' and line.move_id.is_invoice(True))
- for line in product_lines:
- if line.product_id:
- fiscal_position = line.move_id.fiscal_position_id
- accounts = line.with_company(line.company_id).product_id\
- .product_tmpl_id.get_product_accounts(fiscal_pos=fiscal_position)
- if line.move_id.is_sale_document(include_receipts=True):
- line.account_id = accounts['income'] or line.account_id
- elif line.move_id.is_purchase_document(include_receipts=True):
- line.account_id = accounts['expense'] or line.account_id
- elif line.partner_id:
- line.account_id = self.env['account.account']._get_most_frequent_account_for_partner(
- company_id=line.company_id.id,
- partner_id=line.partner_id.id,
- move_type=line.move_id.move_type,
- )
- for line in self:
- if not line.account_id and line.display_type not in ('line_section', 'line_note'):
- previous_two_accounts = line.move_id.line_ids.filtered(
- lambda l: l.account_id and l.display_type == line.display_type
- )[-2:].account_id
- if len(previous_two_accounts) == 1 and len(line.move_id.line_ids) > 2:
- line.account_id = previous_two_accounts
- else:
- line.account_id = line.move_id.journal_id.default_account_id
- @api.depends('move_id')
- def _compute_balance(self):
- for line in self:
- if line.display_type in ('line_section', 'line_note'):
- line.balance = False
- elif not line.move_id.is_invoice(include_receipts=True):
- # Only act as a default value when none of balance/debit/credit is specified
- # balance is always the written field because of `_sanitize_vals`
- line.balance = -sum((line.move_id.line_ids - line).mapped('balance'))
- else:
- line.balance = 0
- @api.depends('balance', 'move_id.is_storno')
- def _compute_debit_credit(self):
- for line in self:
- if not line.is_storno:
- line.debit = line.balance if line.balance > 0.0 else 0.0
- line.credit = -line.balance if line.balance < 0.0 else 0.0
- else:
- line.debit = line.balance if line.balance < 0.0 else 0.0
- line.credit = -line.balance if line.balance > 0.0 else 0.0
- @api.depends('currency_id', 'company_id', 'move_id.date')
- def _compute_currency_rate(self):
- @lru_cache()
- def get_rate(from_currency, to_currency, company, date):
- return self.env['res.currency']._get_conversion_rate(
- from_currency=from_currency,
- to_currency=to_currency,
- company=company,
- date=date,
- )
- for line in self:
- if line.currency_id:
- line.currency_rate = get_rate(
- from_currency=line.company_currency_id,
- to_currency=line.currency_id,
- company=line.company_id,
- date=line.move_id.invoice_date or line.move_id.date or fields.Date.context_today(line),
- )
- else:
- line.currency_rate = 1
- @api.depends('currency_id', 'company_currency_id')
- def _compute_same_currency(self):
- for record in self:
- record.is_same_currency = record.currency_id == record.company_currency_id
- @api.depends('currency_rate', 'balance')
- def _compute_amount_currency(self):
- for line in self:
- if line.amount_currency is False:
- line.amount_currency = line.currency_id.round(line.balance * line.currency_rate)
- if line.currency_id == line.company_id.currency_id:
- line.amount_currency = line.balance
- @api.depends('full_reconcile_id.name', 'matched_debit_ids', 'matched_credit_ids')
- def _compute_matching_number(self):
- for record in self:
- if record.full_reconcile_id:
- record.matching_number = record.full_reconcile_id.name
- elif record.matched_debit_ids or record.matched_credit_ids:
- record.matching_number = 'P'
- else:
- record.matching_number = None
- @api.depends_context('order_cumulated_balance', 'domain_cumulated_balance')
- def _compute_cumulated_balance(self):
- if not self.env.context.get('order_cumulated_balance'):
- # We do not come from search_read, so we are not in a list view, so it doesn't make any sense to compute the cumulated balance
- self.cumulated_balance = 0
- return
- # get the where clause
- query = self._where_calc(list(self.env.context.get('domain_cumulated_balance') or []))
- order_string = ", ".join(self._generate_order_by_inner(self._table, self.env.context.get('order_cumulated_balance'), query, reverse_direction=True))
- from_clause, where_clause, where_clause_params = query.get_sql()
- sql = """
- SELECT account_move_line.id, SUM(account_move_line.balance) OVER (
- ORDER BY %(order_by)s
- ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
- )
- FROM %(from)s
- WHERE %(where)s
- """ % {'from': from_clause, 'where': where_clause or 'TRUE', 'order_by': order_string}
- self.env.cr.execute(sql, where_clause_params)
- result = {r[0]: r[1] for r in self.env.cr.fetchall()}
- for record in self:
- record.cumulated_balance = result[record.id]
- @api.depends('debit', 'credit', 'amount_currency', 'account_id', 'currency_id', 'company_id',
- 'matched_debit_ids', 'matched_credit_ids')
- def _compute_amount_residual(self):
- """ Computes the residual amount of a move line from a reconcilable account in the company currency and the line's currency.
- This amount will be 0 for fully reconciled lines or lines from a non-reconcilable account, the original line amount
- for unreconciled lines, and something in-between for partially reconciled lines.
- """
- need_residual_lines = self.filtered(lambda x: x.account_id.reconcile or x.account_id.account_type in ('asset_cash', 'liability_credit_card'))
- # Run the residual amount computation on all lines stored in the db. By
- # using _origin, new records (with a NewId) are excluded and the
- # computation works automagically for virtual onchange records as well.
- stored_lines = need_residual_lines._origin
- if stored_lines:
- self.env['account.partial.reconcile'].flush_model()
- self.env['res.currency'].flush_model(['decimal_places'])
- aml_ids = tuple(stored_lines.ids)
- self._cr.execute('''
- SELECT
- part.debit_move_id AS line_id,
- 'debit' AS flag,
- COALESCE(SUM(part.amount), 0.0) AS amount,
- ROUND(SUM(part.debit_amount_currency), curr.decimal_places) AS amount_currency
- FROM account_partial_reconcile part
- JOIN res_currency curr ON curr.id = part.debit_currency_id
- WHERE part.debit_move_id IN %s
- GROUP BY part.debit_move_id, curr.decimal_places
- UNION ALL
- SELECT
- part.credit_move_id AS line_id,
- 'credit' AS flag,
- COALESCE(SUM(part.amount), 0.0) AS amount,
- ROUND(SUM(part.credit_amount_currency), curr.decimal_places) AS amount_currency
- FROM account_partial_reconcile part
- JOIN res_currency curr ON curr.id = part.credit_currency_id
- WHERE part.credit_move_id IN %s
- GROUP BY part.credit_move_id, curr.decimal_places
- ''', [aml_ids, aml_ids])
- amounts_map = {
- (line_id, flag): (amount, amount_currency)
- for line_id, flag, amount, amount_currency in self.env.cr.fetchall()
- }
- else:
- amounts_map = {}
- # Lines that can't be reconciled with anything since the account doesn't allow that.
- for line in self - need_residual_lines:
- line.amount_residual = 0.0
- line.amount_residual_currency = 0.0
- line.reconciled = False
- for line in need_residual_lines:
- # Since this part could be call on 'new' records, 'company_currency_id'/'currency_id' could be not set.
- comp_curr = line.company_currency_id or self.env.company.currency_id
- foreign_curr = line.currency_id or comp_curr
- # Retrieve the amounts in both foreign/company currencies. If the record is 'new', the amounts_map is empty.
- debit_amount, debit_amount_currency = amounts_map.get((line._origin.id, 'debit'), (0.0, 0.0))
- credit_amount, credit_amount_currency = amounts_map.get((line._origin.id, 'credit'), (0.0, 0.0))
- # Subtract the values from the account.partial.reconcile to compute the residual amounts.
- line.amount_residual = comp_curr.round(line.balance - debit_amount + credit_amount)
- line.amount_residual_currency = foreign_curr.round(line.amount_currency - debit_amount_currency + credit_amount_currency)
- line.reconciled = (
- comp_curr.is_zero(line.amount_residual)
- and foreign_curr.is_zero(line.amount_residual_currency)
- )
- @api.depends('move_id.move_type', 'tax_ids', 'tax_repartition_line_id', 'debit', 'credit', 'tax_tag_ids', 'is_refund')
- def _compute_tax_tag_invert(self):
- for record in self:
- if not record.tax_repartition_line_id and not record.tax_ids:
- # Invoices imported from other softwares might only have kept the tags, not the taxes.
- record.tax_tag_invert = record.tax_tag_ids and record.move_id.is_inbound()
- elif record.move_id.move_type == 'entry':
- # For misc operations, cash basis entries and write-offs from the bank reconciliation widget
- tax = record.tax_repartition_line_id.tax_id or record.tax_ids[:1]
- is_refund = record.is_refund
- tax_type = tax.type_tax_use
- record.tax_tag_invert = (tax_type == 'purchase' and is_refund) or (tax_type == 'sale' and not is_refund)
- else:
- # For invoices with taxes
- record.tax_tag_invert = record.move_id.is_inbound()
- @api.depends('tax_tag_ids', 'debit', 'credit', 'journal_id', 'tax_tag_invert')
- def _compute_tax_audit(self):
- separator = ' '
- for record in self:
- currency = record.company_id.currency_id
- audit_str = ''
- for tag in record.tax_tag_ids:
- tag_amount = (record.tax_tag_invert and -1 or 1) * (tag.tax_negate and -1 or 1) * record.balance
- if tag.applicability == 'taxes' and tag.name[0] in {'+', '-'}:
- # Then, the tag comes from a report expression, and hence has a + or - sign (also in its name)
- tag_name = tag.name[1:]
- else:
- # Then, it's a financial tag (sign is always +, and never shown in tag name)
- tag_name = tag.name
- audit_str += separator if audit_str else ''
- audit_str += tag_name + ': ' + formatLang(self.env, tag_amount, currency_obj=currency)
- record.tax_audit = audit_str
- @api.depends('product_id')
- def _compute_product_uom_id(self):
- for line in self:
- line.product_uom_id = line.product_id.uom_id
- @api.depends('display_type')
- def _compute_quantity(self):
- for line in self:
- line.quantity = 1 if line.display_type == 'product' else False
- @api.depends('display_type')
- def _compute_sequence(self):
- seq_map = {
- 'tax': 10000,
- 'rounding': 11000,
- 'payment_term': 12000,
- }
- for line in self:
- line.sequence = seq_map.get(line.display_type, 100)
- @api.depends('quantity', 'discount', 'price_unit', 'tax_ids', 'currency_id')
- def _compute_totals(self):
- for line in self:
- if line.display_type != 'product':
- line.price_total = line.price_subtotal = False
- # Compute 'price_subtotal'.
- line_discount_price_unit = line.price_unit * (1 - (line.discount / 100.0))
- subtotal = line.quantity * line_discount_price_unit
- # Compute 'price_total'.
- if line.tax_ids:
- taxes_res = line.tax_ids.compute_all(
- line_discount_price_unit,
- quantity=line.quantity,
- currency=line.currency_id,
- product=line.product_id,
- partner=line.partner_id,
- is_refund=line.is_refund,
- )
- line.price_subtotal = taxes_res['total_excluded']
- line.price_total = taxes_res['total_included']
- else:
- line.price_total = line.price_subtotal = subtotal
- @api.depends('product_id', 'product_uom_id')
- def _compute_price_unit(self):
- for line in self:
- if not line.product_id or line.display_type in ('line_section', 'line_note'):
- continue
- if line.move_id.is_sale_document(include_receipts=True):
- document_type = 'sale'
- elif line.move_id.is_purchase_document(include_receipts=True):
- document_type = 'purchase'
- else:
- document_type = 'other'
- line.price_unit = line.product_id._get_tax_included_unit_price(
- line.move_id.company_id,
- line.move_id.currency_id,
- line.move_id.date,
- document_type,
- fiscal_position=line.move_id.fiscal_position_id,
- product_uom=line.product_uom_id,
- )
- @api.depends('product_id', 'product_uom_id')
- def _compute_tax_ids(self):
- for line in self:
- if line.display_type in ('line_section', 'line_note', 'payment_term'):
- continue
- # /!\ Don't remove existing taxes if there is no explicit taxes set on the account.
- if line.product_id or line.account_id.tax_ids or not line.tax_ids:
- line.tax_ids = line._get_computed_taxes()
- def _get_computed_taxes(self):
- self.ensure_one()
- if self.move_id.is_sale_document(include_receipts=True):
- # Out invoice.
- if self.product_id.taxes_id:
- tax_ids = self.product_id.taxes_id.filtered(lambda tax: tax.company_id == self.move_id.company_id)
- else:
- tax_ids = self.account_id.tax_ids.filtered(lambda tax: tax.type_tax_use == 'sale')
- if not tax_ids and self.display_type == 'product':
- tax_ids = self.move_id.company_id.account_sale_tax_id
- elif self.move_id.is_purchase_document(include_receipts=True):
- # In invoice.
- if self.product_id.supplier_taxes_id:
- tax_ids = self.product_id.supplier_taxes_id.filtered(lambda tax: tax.company_id == self.move_id.company_id)
- else:
- tax_ids = self.account_id.tax_ids.filtered(lambda tax: tax.type_tax_use == 'purchase')
- if not tax_ids and self.display_type == 'product':
- tax_ids = self.move_id.company_id.account_purchase_tax_id
- else:
- # Miscellaneous operation.
- tax_ids = self.account_id.tax_ids
- if self.company_id and tax_ids:
- tax_ids = tax_ids.filtered(lambda tax: tax.company_id == self.company_id)
- if tax_ids and self.move_id.fiscal_position_id:
- tax_ids = self.move_id.fiscal_position_id.map_tax(tax_ids)
- return tax_ids
- @api.depends('tax_ids', 'currency_id', 'partner_id', 'account_id', 'group_tax_id', 'analytic_distribution')
- def _compute_tax_key(self):
- for line in self:
- if line.tax_repartition_line_id:
- line.tax_key = frozendict({
- 'tax_repartition_line_id': line.tax_repartition_line_id.id,
- 'group_tax_id': line.group_tax_id.id,
- 'account_id': line.account_id.id,
- 'currency_id': line.currency_id.id,
- 'analytic_distribution': line.analytic_distribution,
- 'tax_ids': [(6, 0, line.tax_ids.ids)],
- 'tax_tag_ids': [(6, 0, line.tax_tag_ids.ids)],
- 'partner_id': line.partner_id.id,
- 'move_id': line.move_id.id,
- 'display_type': 'epd' if line.name and _('(Discount)') in line.name else line.display_type,
- })
- else:
- line.tax_key = frozendict({'id': line.id})
- @api.depends('tax_ids', 'currency_id', 'partner_id', 'analytic_distribution', 'balance', 'partner_id', 'move_id.partner_id', 'price_unit', 'quantity')
- def _compute_all_tax(self):
- for line in self:
- sign = line.move_id.direction_sign
- if line.display_type == 'tax':
- line.compute_all_tax = {}
- line.compute_all_tax_dirty = False
- continue
- if line.display_type == 'product' and line.move_id.is_invoice(True):
- amount_currency = sign * line.price_unit * (1 - line.discount / 100)
- handle_price_include = True
- quantity = line.quantity
- else:
- amount_currency = line.amount_currency
- handle_price_include = False
- quantity = 1
- compute_all_currency = line.tax_ids.compute_all(
- amount_currency,
- currency=line.currency_id,
- quantity=quantity,
- product=line.product_id,
- partner=line.move_id.partner_id or line.partner_id,
- is_refund=line.is_refund,
- handle_price_include=handle_price_include,
- include_caba_tags=line.move_id.always_tax_exigible,
- fixed_multiplicator=sign,
- )
- rate = line.amount_currency / line.balance if line.balance else 1
- line.compute_all_tax_dirty = True
- line.compute_all_tax = {
- frozendict({
- 'tax_repartition_line_id': tax['tax_repartition_line_id'],
- 'group_tax_id': tax['group'] and tax['group'].id or False,
- 'account_id': tax['account_id'] or line.account_id.id,
- 'currency_id': line.currency_id.id,
- 'analytic_distribution': (tax['analytic'] or not tax['use_in_tax_closing']) and line.analytic_distribution,
- 'tax_ids': [(6, 0, tax['tax_ids'])],
- 'tax_tag_ids': [(6, 0, tax['tag_ids'])],
- 'partner_id': line.move_id.partner_id.id or line.partner_id.id,
- 'move_id': line.move_id.id,
- 'display_type': line.display_type,
- }): {
- 'name': tax['name'] + (' ' + _('(Discount)') if line.display_type == 'epd' else ''),
- 'balance': tax['amount'] / rate,
- 'amount_currency': tax['amount'],
- 'tax_base_amount': tax['base'] / rate * (-1 if line.tax_tag_invert else 1),
- }
- for tax in compute_all_currency['taxes']
- if tax['amount']
- }
- if not line.tax_repartition_line_id:
- line.compute_all_tax[frozendict({'id': line.id})] = {
- 'tax_tag_ids': [(6, 0, compute_all_currency['base_tags'])],
- }
- @api.depends('tax_ids', 'account_id', 'company_id')
- def _compute_epd_key(self):
- for line in self:
- if line.display_type == 'epd' and line.company_id.early_pay_discount_computation == 'mixed':
- line.epd_key = frozendict({
- 'account_id': line.account_id.id,
- 'analytic_distribution': line.analytic_distribution,
- 'tax_ids': [Command.set(line.tax_ids.ids)],
- 'tax_tag_ids': [Command.set(line.tax_tag_ids.ids)],
- 'move_id': line.move_id.id,
- })
- else:
- line.epd_key = False
- @api.depends('move_id.needed_terms', 'account_id', 'analytic_distribution', 'tax_ids', 'tax_tag_ids', 'company_id')
- def _compute_epd_needed(self):
- for line in self:
- needed_terms = line.move_id.needed_terms
- line.epd_dirty = True
- line.epd_needed = False
- if line.display_type != 'product' or not line.tax_ids.ids or line.company_id.early_pay_discount_computation != 'mixed':
- continue
- amount_total = abs(sum(x['amount_currency'] for x in needed_terms.values()))
- percentages_to_apply = []
- names = []
- for term in needed_terms.values():
- if term.get('discount_percentage'):
- percentages_to_apply.append({
- 'discount_percentage': term['discount_percentage'],
- 'term_percentage': abs(term['amount_currency'] / amount_total) if amount_total else 0
- })
- names.append(f"{term['discount_percentage']}%")
- discount_percentage_name = ', '.join(names)
- epd_needed = {}
- for percentages in percentages_to_apply:
- percentage = percentages['discount_percentage'] / 100
- line_percentage = percentages['term_percentage']
- taxes = line.tax_ids.filtered(lambda t: t.amount_type != 'fixed')
- epd_needed_vals = epd_needed.setdefault(
- frozendict({
- 'move_id': line.move_id.id,
- 'account_id': line.account_id.id,
- 'analytic_distribution': line.analytic_distribution,
- 'tax_ids': [Command.set(taxes.ids)],
- 'tax_tag_ids': line.compute_all_tax[frozendict({'id': line.id})]['tax_tag_ids'],
- 'display_type': 'epd',
- }),
- {
- 'name': _("Early Payment Discount (%s)", discount_percentage_name),
- 'amount_currency': 0.0,
- 'balance': 0.0,
- 'price_subtotal': 0.0,
- },
- )
- epd_needed_vals['amount_currency'] -= line.currency_id.round(line.amount_currency * percentage * line_percentage)
- epd_needed_vals['balance'] -= line.currency_id.round(line.balance * percentage * line_percentage)
- epd_needed_vals['price_subtotal'] -= line.currency_id.round(line.price_subtotal * percentage * line_percentage)
- epd_needed_vals = epd_needed.setdefault(
- frozendict({
- 'move_id': line.move_id.id,
- 'account_id': line.account_id.id,
- 'display_type': 'epd',
- }),
- {
- 'name': _("Early Payment Discount (%s)", discount_percentage_name),
- 'amount_currency': 0.0,
- 'balance': 0.0,
- 'price_subtotal': 0.0,
- 'tax_ids': [Command.clear()],
- },
- )
- epd_needed_vals['amount_currency'] += line.currency_id.round(line.amount_currency * percentage * line_percentage)
- epd_needed_vals['balance'] += line.currency_id.round(line.balance * percentage * line_percentage)
- epd_needed_vals['price_subtotal'] += line.currency_id.round(line.price_subtotal * percentage * line_percentage)
- line.epd_needed = {k: frozendict(v) for k, v in epd_needed.items()}
- @api.depends('move_id.move_type', 'balance', 'tax_repartition_line_id', 'tax_ids')
- def _compute_is_refund(self):
- for line in self:
- is_refund = False
- if line.move_id.move_type in ('out_refund', 'in_refund'):
- is_refund = True
- elif line.move_id.move_type == 'entry':
- if line.tax_repartition_line_id:
- is_refund = bool(line.tax_repartition_line_id.refund_tax_id)
- else:
- tax_type = line.tax_ids[:1].type_tax_use
- if tax_type == 'sale' and line.credit == 0:
- is_refund = True
- elif tax_type == 'purchase' and line.debit == 0:
- is_refund = True
- if line.tax_ids and line.move_id.reversed_entry_id:
- is_refund = not is_refund
- line.is_refund = is_refund
- @api.depends('date_maturity')
- def _compute_term_key(self):
- for line in self:
- if line.display_type == 'payment_term':
- line.term_key = frozendict({
- 'move_id': line.move_id.id,
- 'date_maturity': fields.Date.to_date(line.date_maturity),
- 'discount_date': line.discount_date,
- 'discount_percentage': line.discount_percentage
- })
- else:
- line.term_key = False
- @api.depends('account_id', 'partner_id', 'product_id')
- def _compute_analytic_distribution(self):
- for line in self:
- if line.display_type == 'product' or not line.move_id.is_invoice(include_receipts=True):
- distribution = self.env['account.analytic.distribution.model']._get_distribution({
- "product_id": line.product_id.id,
- "product_categ_id": line.product_id.categ_id.id,
- "partner_id": line.partner_id.id,
- "partner_category_id": line.partner_id.category_id.ids,
- "account_prefix": line.account_id.code,
- "company_id": line.company_id.id,
- })
- line.analytic_distribution = distribution or line.analytic_distribution
- # -------------------------------------------------------------------------
- # INVERSE METHODS
- # -------------------------------------------------------------------------
- @api.onchange('partner_id')
- def _inverse_partner_id(self):
- self._conditional_add_to_compute('account_id', lambda line: (
- line.display_type == 'payment_term' # recompute based on settings
- ))
- @api.onchange('product_id')
- def _inverse_product_id(self):
- self._conditional_add_to_compute('account_id', lambda line: (
- line.display_type == 'product' and line.move_id.is_invoice(True)
- ))
- @api.onchange('amount_currency', 'currency_id')
- def _inverse_amount_currency(self):
- for line in self:
- if line.currency_id == line.company_id.currency_id and line.balance != line.amount_currency:
- line.balance = line.amount_currency
- elif (
- line.currency_id != line.company_id.currency_id
- and not line.move_id.is_invoice(True)
- and not self.env.is_protected(self._fields['balance'], line)
- ):
- line.balance = line.company_id.currency_id.round(line.amount_currency / line.currency_rate)
- @api.onchange('debit')
- def _inverse_debit(self):
- for line in self:
- if line.debit:
- line.credit = 0
- line.balance = line.debit - line.credit
- @api.onchange('credit')
- def _inverse_credit(self):
- for line in self:
- if line.credit:
- line.debit = 0
- line.balance = line.debit - line.credit
- @api.onchange('analytic_distribution')
- def _inverse_analytic_distribution(self):
- """ Unlink and recreate analytic_lines when modifying the distribution."""
- lines_to_modify = self.env['account.move.line'].browse([
- line.id for line in self if line.parent_state == "posted"
- ])
- lines_to_modify.analytic_line_ids.unlink()
- lines_to_modify._create_analytic_lines()
- @api.onchange('account_id')
- def _inverse_account_id(self):
- self._inverse_analytic_distribution()
- self._conditional_add_to_compute('tax_ids', lambda line: (
- line.account_id.tax_ids
- and not line.product_id.taxes_id.filtered(lambda tax: tax.company_id == line.company_id)
- ))
- # -------------------------------------------------------------------------
- # CONSTRAINT METHODS
- # -------------------------------------------------------------------------
- def _check_constrains_account_id_journal_id(self):
- # Avoid using api.constrains here as in case of a write on
- # account move and account move line in the same operation, the check would be done
- # before all write are complete, causing a false positive
- self.flush_recordset()
- for line in self.filtered(lambda x: x.display_type not in ('line_section', 'line_note')):
- account = line.account_id
- journal = line.move_id.journal_id
- if account.deprecated:
- raise UserError(_('The account %s (%s) is deprecated.') % (account.name, account.code))
- account_currency = account.currency_id
- if account_currency and account_currency != line.company_currency_id and account_currency != line.currency_id:
- raise UserError(_('The account selected on your journal entry forces to provide a secondary currency. You should remove the secondary currency on the account.'))
- if account.allowed_journal_ids and journal not in account.allowed_journal_ids:
- raise UserError(_('You cannot use this account (%s) in this journal, check the field \'Allowed Journals\' on the related account.', account.display_name))
- if account in (journal.default_account_id, journal.suspense_account_id):
- continue
- is_account_control_ok = not journal.account_control_ids or account in journal.account_control_ids
- if not is_account_control_ok:
- raise UserError(_("You cannot use this account (%s) in this journal, check the section 'Control-Access' under "
- "tab 'Advanced Settings' on the related journal.", account.display_name))
- @api.constrains('account_id', 'tax_ids', 'tax_line_id', 'reconciled')
- def _check_off_balance(self):
- for line in self:
- if line.account_id.internal_group == 'off_balance':
- if any(a.internal_group != line.account_id.internal_group for a in line.move_id.line_ids.account_id):
- raise UserError(_('If you want to use "Off-Balance Sheet" accounts, all the accounts of the journal entry must be of this type'))
- if line.tax_ids or line.tax_line_id:
- raise UserError(_('You cannot use taxes on lines with an Off-Balance account'))
- if line.reconciled:
- raise UserError(_('Lines from "Off-Balance Sheet" accounts cannot be reconciled'))
- @api.constrains('account_id', 'display_type')
- def _check_payable_receivable(self):
- for line in self:
- account_type = line.account_id.account_type
- if line.move_id.is_sale_document(include_receipts=True):
- if (line.display_type == 'payment_term') ^ (account_type == 'asset_receivable'):
- raise UserError(_("Any journal item on a receivable account must have a due date and vice versa."))
- if line.move_id.is_purchase_document(include_receipts=True):
- if (line.display_type == 'payment_term') ^ (account_type == 'liability_payable'):
- raise UserError(_("Any journal item on a payable account must have a due date and vice versa."))
- @api.constrains('product_uom_id')
- def _check_product_uom_category_id(self):
- for line in self:
- if line.product_uom_id and line.product_id and line.product_uom_id.category_id != line.product_id.product_tmpl_id.uom_id.category_id:
- raise UserError(_(
- "The Unit of Measure (UoM) '%s' you have selected for product '%s', "
- "is incompatible with its category : %s.",
- line.product_uom_id.name,
- line.product_id.name,
- line.product_id.product_tmpl_id.uom_id.category_id.name
- ))
- def _affect_tax_report(self):
- self.ensure_one()
- return self.tax_ids or self.tax_line_id or self.tax_tag_ids.filtered(lambda x: x.applicability == "taxes")
- def _check_tax_lock_date(self):
- for line in self.filtered(lambda l: l.move_id.state == 'posted'):
- move = line.move_id
- if move.company_id.tax_lock_date and move.date <= move.company_id.tax_lock_date and line._affect_tax_report():
- raise UserError(_("The operation is refused as it would impact an already issued tax statement. "
- "Please change the journal entry date or the tax lock date set in the settings (%s) to proceed.")
- % format_date(self.env, move.company_id.tax_lock_date))
- def _check_reconciliation(self):
- for line in self:
- if line.matched_debit_ids or line.matched_credit_ids:
- raise UserError(_("You cannot do this modification on a reconciled journal entry. "
- "You can just change some non legal fields or you must unreconcile first.\n"
- "Journal Entry (id): %s (%s)") % (line.move_id.name, line.move_id.id))
- @api.constrains('tax_ids', 'tax_repartition_line_id')
- def _check_caba_non_caba_shared_tags(self):
- """ When mixing cash basis and non cash basis taxes, it is important
- that those taxes don't share tags on the repartition creating
- a single account.move.line.
- Shared tags in this context cannot work, as the tags would need to
- be present on both the invoice and cash basis move, leading to the same
- base amount to be taken into account twice; which is wrong.This is
- why we don't support that. A workaround may be provided by the use of
- a group of taxes, whose children are type_tax_use=None, and only one
- of them uses the common tag.
- Note that taxes of the same exigibility are allowed to share tags.
- """
- def get_base_repartition(base_aml, taxes):
- if not taxes:
- return self.env['account.tax.repartition.line']
- is_refund = base_aml.is_refund
- repartition_field = is_refund and 'refund_repartition_line_ids' or 'invoice_repartition_line_ids'
- return taxes.mapped(repartition_field)
- for aml in self:
- caba_taxes = aml.tax_ids.filtered(lambda x: x.tax_exigibility == 'on_payment')
- non_caba_taxes = aml.tax_ids - caba_taxes
- caba_base_tags = get_base_repartition(aml, caba_taxes).filtered(lambda x: x.repartition_type == 'base').tag_ids
- non_caba_base_tags = get_base_repartition(aml, non_caba_taxes).filtered(lambda x: x.repartition_type == 'base').tag_ids
- common_tags = caba_base_tags & non_caba_base_tags
- if not common_tags:
- # When a tax is affecting another one with different tax exigibility, tags cannot be shared either.
- tax_tags = aml.tax_repartition_line_id.tag_ids
- comparison_tags = non_caba_base_tags if aml.tax_repartition_line_id.tax_id.tax_exigibility == 'on_payment' else caba_base_tags
- common_tags = tax_tags & comparison_tags
- if common_tags:
- raise ValidationError(_("Taxes exigible on payment and on invoice cannot be mixed on the same journal item if they share some tag."))
- # -------------------------------------------------------------------------
- # CRUD/ORM
- # -------------------------------------------------------------------------
- def check_field_access_rights(self, operation, field_names):
- result = super().check_field_access_rights(operation, field_names)
- if not field_names:
- weirdos = ['term_key', 'tax_key', 'compute_all_tax', 'epd_key', 'epd_needed']
- result = [fname for fname in result if fname not in weirdos]
- return result
- def invalidate_model(self, fnames=None, flush=True):
- # Invalidate cache of related moves
- if fnames is None or 'move_id' in fnames:
- field = self._fields['move_id']
- lines = self.env.cache.get_records(self, field)
- move_ids = {id_ for id_ in self.env.cache.get_values(lines, field) if id_}
- if move_ids:
- self.env['account.move'].browse(move_ids).invalidate_recordset()
- return super().invalidate_model(fnames=fnames, flush=flush)
- def invalidate_recordset(self, fnames=None, flush=True):
- # Invalidate cache of related moves
- if fnames is None or 'move_id' in fnames:
- field = self._fields['move_id']
- move_ids = {id_ for id_ in self.env.cache.get_values(self, field) if id_}
- if move_ids:
- self.env['account.move'].browse(move_ids).invalidate_recordset()
- return super().invalidate_recordset(fnames=fnames, flush=flush)
- @api.model
- def search_read(self, domain=None, fields=None, offset=0, limit=None, order=None):
- def to_tuple(t):
- return tuple(map(to_tuple, t)) if isinstance(t, (list, tuple)) else t
- # Make an explicit order because we will need to reverse it
- order = (order or self._order) + ', id'
- # Add the domain and order by in order to compute the cumulated balance in _compute_cumulated_balance
- contextualized = self.with_context(
- domain_cumulated_balance=to_tuple(domain or []),
- order_cumulated_balance=order,
- )
- return super(AccountMoveLine, contextualized).search_read(domain, fields, offset, limit, order)
- def init(self):
- """ change index on partner_id to a multi-column index on (partner_id, ref), the new index will behave in the
- same way when we search on partner_id, with the addition of being optimal when having a query that will
- search on partner_id and ref at the same time (which is the case when we open the bank reconciliation widget)
- """
- create_index(self._cr, 'account_move_line_partner_id_ref_idx', 'account_move_line', ["partner_id", "ref"])
- create_index(self._cr, 'account_move_line_date_name_id_idx', 'account_move_line', ["date desc", "move_name desc", "id"])
- super().init()
- def default_get(self, fields_list):
- defaults = super().default_get(fields_list)
- quick_encode_suggestion = self.env.context.get('quick_encoding_vals')
- if quick_encode_suggestion:
- defaults['account_id'] = quick_encode_suggestion['account_id']
- defaults['price_unit'] = quick_encode_suggestion['price_unit']
- defaults['tax_ids'] = [Command.set(quick_encode_suggestion['tax_ids'])]
- return defaults
- def _sanitize_vals(self, vals):
- if 'debit' in vals or 'credit' in vals:
- vals = vals.copy()
- if 'balance' in vals:
- vals.pop('debit', None)
- vals.pop('credit', None)
- else:
- vals['balance'] = vals.pop('debit', 0) - vals.pop('credit', 0)
- return vals
- def _prepare_create_values(self, vals_list):
- result_vals_list = super()._prepare_create_values(vals_list)
- for init_vals, res_vals in zip(vals_list, result_vals_list):
- # Allow computing the balance based on the amount_currency if it wasn't specified in the create vals.
- if (
- 'amount_currency' in init_vals
- and 'balance' not in init_vals
- and 'debit' not in init_vals
- and 'credit' not in init_vals
- ):
- res_vals.pop('balance', 0)
- res_vals.pop('debit', 0)
- res_vals.pop('credit', 0)
- return result_vals_list
- @contextmanager
- def _sync_invoice(self, container):
- if container['records'].env.context.get('skip_invoice_line_sync'):
- yield
- return # avoid infinite recursion
- def existing():
- return {
- line: {
- 'amount_currency': line.currency_id.round(line.amount_currency),
- 'balance': line.company_id.currency_id.round(line.balance),
- 'currency_rate': line.currency_rate,
- 'price_subtotal': line.currency_id.round(line.price_subtotal),
- 'move_type': line.move_id.move_type,
- } for line in container['records'].with_context(
- skip_invoice_line_sync=True,
- ).filtered(lambda l: l.move_id.is_invoice(True))
- }
- def changed(fname):
- return line not in before or before[line][fname] != after[line][fname]
- before = existing()
- yield
- after = existing()
- for line in after:
- if (
- line.display_type == 'product'
- and (not changed('amount_currency') or line not in before)
- ):
- amount_currency = line.move_id.direction_sign * line.currency_id.round(line.price_subtotal)
- if line.amount_currency != amount_currency or line not in before:
- line.amount_currency = amount_currency
- if line.currency_id == line.company_id.currency_id:
- line.balance = amount_currency
- after = existing()
- for line in after:
- if (
- (changed('amount_currency') or changed('currency_rate') or changed('move_type'))
- and (not changed('balance') or (line not in before and not line.balance))
- ):
- balance = line.company_id.currency_id.round(line.amount_currency / line.currency_rate)
- line.balance = balance
- # Since this method is called during the sync, inside of `create`/`write`, these fields
- # already have been computed and marked as so. But this method should re-trigger it since
- # it changes the dependencies.
- self.env.add_to_compute(self._fields['debit'], container['records'])
- self.env.add_to_compute(self._fields['credit'], container['records'])
- @api.model_create_multi
- def create(self, vals_list):
- moves = self.env['account.move'].browse({vals['move_id'] for vals in vals_list})
- container = {'records': self}
- move_container = {'records': moves}
- with moves._check_balanced(move_container),\
- moves._sync_dynamic_lines(move_container),\
- self._sync_invoice(container):
- lines = super().create([self._sanitize_vals(vals) for vals in vals_list])
- container['records'] = lines
- for line in lines:
- if line.move_id.state == 'posted':
- line._check_tax_lock_date()
- lines.move_id._synchronize_business_models(['line_ids'])
- lines._check_constrains_account_id_journal_id()
- return lines
- def write(self, vals):
- if not vals:
- return True
- protected_fields = self._get_lock_date_protected_fields()
- account_to_write = self.env['account.account'].browse(vals['account_id']) if 'account_id' in vals else None
- # Check writing a deprecated account.
- if account_to_write and account_to_write.deprecated:
- raise UserError(_('You cannot use a deprecated account.'))
- inalterable_fields = set(self._get_integrity_hash_fields()).union({'inalterable_hash', 'secure_sequence_number'})
- hashed_moves = self.move_id.filtered('inalterable_hash')
- violated_fields = set(vals) & inalterable_fields
- if hashed_moves and violated_fields:
- raise UserError(_(
- "You cannot edit the following fields: %s.\n"
- "The following entries are already hashed:\n%s",
- ', '.join(f['string'] for f in self.fields_get(violated_fields).values()),
- '\n'.join(hashed_moves.mapped('name')),
- ))
- line_to_write = self
- vals = self._sanitize_vals(vals)
- for line in self:
- if not any(self.env['account.move']._field_will_change(line, vals, field_name) for field_name in vals):
- line_to_write -= line
- continue
- if line.parent_state == 'posted':
- if any(key in vals for key in ('tax_ids', 'tax_line_id')):
- raise UserError(_('You cannot modify the taxes related to a posted journal item, you should reset the journal entry to draft to do so.'))
- # Check the lock date.
- if line.parent_state == 'posted' and any(self.env['account.move']._field_will_change(line, vals, field_name) for field_name in protected_fields['fiscal']):
- line.move_id._check_fiscalyear_lock_date()
- # Check the tax lock date.
- if line.parent_state == 'posted' and any(self.env['account.move']._field_will_change(line, vals, field_name) for field_name in protected_fields['tax']):
- line._check_tax_lock_date()
- # Check the reconciliation.
- if any(self.env['account.move']._field_will_change(line, vals, field_name) for field_name in protected_fields['reconciliation']):
- line._check_reconciliation()
- move_container = {'records': self.move_id}
- with self.move_id._check_balanced(move_container),\
- self.move_id._sync_dynamic_lines(move_container),\
- self._sync_invoice({'records': self}):
- self = line_to_write
- if not self:
- return True
- # Tracking stuff can be skipped for perfs using tracking_disable context key
- if not self.env.context.get('tracking_disable', False):
- # Get all tracked fields (without related fields because these fields must be manage on their own model)
- tracking_fields = []
- for value in vals:
- field = self._fields[value]
- if hasattr(field, 'related') and field.related:
- continue # We don't want to track related field.
- if hasattr(field, 'tracking') and field.tracking:
- tracking_fields.append(value)
- ref_fields = self.env['account.move.line'].fields_get(tracking_fields)
- # Get initial values for each line
- move_initial_values = {}
- for line in self.filtered(lambda l: l.move_id.posted_before): # Only lines with posted once move.
- for field in tracking_fields:
- # Group initial values by move_id
- if line.move_id.id not in move_initial_values:
- move_initial_values[line.move_id.id] = {}
- move_initial_values[line.move_id.id].update({field: line[field]})
- result = super().write(vals)
- self.move_id._synchronize_business_models(['line_ids'])
- if any(field in vals for field in ['account_id', 'currency_id']):
- self._check_constrains_account_id_journal_id()
- if not self.env.context.get('tracking_disable', False):
- # Log changes to move lines on each move
- for move_id, modified_lines in move_initial_values.items():
- for line in self.filtered(lambda l: l.move_id.id == move_id):
- tracking_value_ids = line._mail_track(ref_fields, modified_lines)[1]
- if tracking_value_ids:
- msg = _(
- "Journal Item %s updated",
- line._get_html_link(title=f"#{line.id}")
- )
- line.move_id._message_log(
- body=msg,
- tracking_value_ids=tracking_value_ids
- )
- return result
- def _valid_field_parameter(self, field, name):
- # EXTENDS models
- return name == 'tracking' or super()._valid_field_parameter(field, name)
- @api.ondelete(at_uninstall=False)
- def _unlink_except_posted(self):
- # Prevent deleting lines on posted entries
- if not self._context.get('force_delete') and any(m.state == 'posted' for m in self.move_id):
- raise UserError(_('You cannot delete an item linked to a posted entry.'))
- @api.ondelete(at_uninstall=False)
- def _prevent_automatic_line_deletion(self):
- if not self.env.context.get('dynamic_unlink'):
- for line in self:
- if line.display_type == 'tax' and line.move_id.line_ids.tax_ids:
- raise ValidationError(_(
- "You cannot delete a tax line as it would impact the tax report"
- ))
- elif line.display_type == 'payment_term':
- raise ValidationError(_(
- "You cannot delete a payable/receivable line as it would not be consistent "
- "with the payment terms"
- ))
- def unlink(self):
- if not self:
- return
- # Check the lines are not reconciled (partially or not).
- self._check_reconciliation()
- # Check the lock date. (Only relevant if the move is posted)
- self.move_id.filtered(lambda m: m.state == 'posted')._check_fiscalyear_lock_date()
- # Check the tax lock date.
- self._check_tax_lock_date()
- move_container = {'records': self.move_id}
- with self.move_id._check_balanced(move_container),\
- self.move_id._sync_dynamic_lines(move_container):
- res = super().unlink()
- return res
- def name_get(self):
- return [(line.id, " ".join(
- element for element in (
- line.move_id.name,
- line.ref and f"({line.ref})",
- line.name or line.product_id.display_name,
- ) if element
- )) for line in self]
- def copy_data(self, default=None):
- data_list = super().copy_data(default=default)
- for line, values in zip(self, data_list):
- # Don't copy the name of a payment term line.
- if line.display_type == 'payment_term' and line.move_id.is_invoice(True):
- del values['name']
- # Don't copy restricted fields of notes
- if line.display_type in ('line_section', 'line_note'):
- del values['balance']
- del values['account_id']
- # Will be recomputed from the price_unit
- if line.display_type == 'product' and line.move_id.is_invoice(True):
- del values['balance']
- if self._context.get('include_business_fields'):
- line._copy_data_extend_business_fields(values)
- return data_list
- def _search_panel_domain_image(self, field_name, domain, set_count=False, limit=False):
- if field_name != 'account_root_id' or set_count:
- return super()._search_panel_domain_image(field_name, domain, set_count, limit)
- # Override in order to not read the complete move line table and use the index instead
- query = self._search(domain, limit=1)
- # if domain is logically equivalent to false
- if not isinstance(query, Query):
- return {}
- query.order = None
- query.add_where('account.id = account_move_line.account_id')
- query_str, query_param = query.select()
- self.env.cr.execute(f"""
- SELECT account.root_id
- FROM account_account account,
- LATERAL ({query_str}) line
- WHERE account.company_id IN %s
- """, query_param + [tuple(self.env.companies.ids)])
- return {
- root.id: {'id': root.id, 'display_name': root.display_name}
- for root in self.env['account.root'].browse(id for [id] in self.env.cr.fetchall())
- }
- # -------------------------------------------------------------------------
- # TRACKING METHODS
- # -------------------------------------------------------------------------
- def _mail_track(self, tracked_fields, initial):
- changes, tracking_value_ids = super()._mail_track(tracked_fields, initial)
- if len(changes) > len(tracking_value_ids):
- for i, changed_field in enumerate(changes):
- if tracked_fields[changed_field]['type'] in ['one2many', 'many2many']:
- field = self.env['ir.model.fields']._get(self._name, changed_field)
- vals = {
- 'field': field.id,
- 'field_desc': field.field_description,
- 'field_type': field.ttype,
- 'tracking_sequence': field.tracking,
- 'old_value_char': ', '.join(initial[changed_field].mapped('name')),
- 'new_value_char': ', '.join(self[changed_field].mapped('name')),
- }
- tracking_value_ids.insert(i, Command.create(vals))
- return changes, tracking_value_ids
- # -------------------------------------------------------------------------
- # RECONCILIATION
- # -------------------------------------------------------------------------
- @api.model
- def _prepare_reconciliation_single_partial(self, debit_vals, credit_vals):
- """ Prepare the values to create an account.partial.reconcile later when reconciling the dictionaries passed
- as parameters, each one representing an account.move.line.
- :param debit_vals: The values of account.move.line to consider for a debit line.
- :param credit_vals: The values of account.move.line to consider for a credit line.
- :return: A dictionary:
- * debit_vals: None if the line has nothing left to reconcile.
- * credit_vals: None if the line has nothing left to reconcile.
- * partial_vals: The newly computed values for the partial.
- """
- def is_payment(vals):
- return vals.get('is_payment') or (
- vals.get('record')
- and bool(vals['record'].move_id.payment_id or vals['record'].move_id.statement_line_id)
- )
- def get_odoo_rate(vals, other_line=None):
- if vals.get('record') and vals['record'].move_id.is_invoice(include_receipts=True):
- exchange_rate_date = vals['record'].move_id.invoice_date
- else:
- exchange_rate_date = vals['date']
- if not is_payment(vals) and other_line and is_payment(other_line):
- exchange_rate_date = other_line['date']
- return recon_currency._get_conversion_rate(company_currency, recon_currency, vals['company'], exchange_rate_date)
- def get_accounting_rate(vals):
- if company_currency.is_zero(vals['balance']) or vals['currency'].is_zero(vals['amount_currency']):
- return None
- else:
- return abs(vals['amount_currency']) / abs(vals['balance'])
- # ==== Determine the currency in which the reconciliation will be done ====
- # In this part, we retrieve the residual amounts, check if they are zero or not and determine in which
- # currency and at which rate the reconciliation will be done.
- res = {
- 'debit_vals': debit_vals,
- 'credit_vals': credit_vals,
- }
- remaining_debit_amount_curr = debit_vals['amount_residual_currency']
- remaining_credit_amount_curr = credit_vals['amount_residual_currency']
- remaining_debit_amount = debit_vals['amount_residual']
- remaining_credit_amount = credit_vals['amount_residual']
- company_currency = debit_vals['company'].currency_id
- has_debit_zero_residual = company_currency.is_zero(remaining_debit_amount)
- has_credit_zero_residual = company_currency.is_zero(remaining_credit_amount)
- has_debit_zero_residual_currency = debit_vals['currency'].is_zero(remaining_debit_amount_curr)
- has_credit_zero_residual_currency = credit_vals['currency'].is_zero(remaining_credit_amount_curr)
- is_rec_pay_account = debit_vals.get('record') \
- and debit_vals['record'].account_type in ('asset_receivable', 'liability_payable')
- if debit_vals['currency'] == credit_vals['currency'] == company_currency \
- and not has_debit_zero_residual \
- and not has_credit_zero_residual:
- # Everything is expressed in company's currency and there is something left to reconcile.
- recon_currency = company_currency
- debit_rate = credit_rate = 1.0
- recon_debit_amount = remaining_debit_amount
- recon_credit_amount = -remaining_credit_amount
- elif debit_vals['currency'] == company_currency \
- and is_rec_pay_account \
- and not has_debit_zero_residual \
- and credit_vals['currency'] != company_currency \
- and not has_credit_zero_residual_currency:
- # The credit line is using a foreign currency but not the opposite line.
- # In that case, convert the amount in company currency to the foreign currency one.
- recon_currency = credit_vals['currency']
- debit_rate = get_odoo_rate(debit_vals, other_line=credit_vals)
- credit_rate = get_accounting_rate(credit_vals)
- recon_debit_amount = recon_currency.round(remaining_debit_amount * debit_rate)
- recon_credit_amount = -remaining_credit_amount_curr
- # If there is nothing left after applying the rate to reconcile in foreign currency,
- # try to fallback on the company currency instead.
- if recon_currency.is_zero(recon_debit_amount) or recon_currency.is_zero(recon_credit_amount):
- recon_currency = company_currency
- debit_rate = 1
- recon_debit_amount = remaining_debit_amount
- recon_credit_amount = -remaining_credit_amount
- elif debit_vals['currency'] != company_currency \
- and is_rec_pay_account \
- and not has_debit_zero_residual_currency \
- and credit_vals['currency'] == company_currency \
- and not has_credit_zero_residual:
- # The debit line is using a foreign currency but not the opposite line.
- # In that case, convert the amount in company currency to the foreign currency one.
- recon_currency = debit_vals['currency']
- debit_rate = get_accounting_rate(debit_vals)
- credit_rate = get_odoo_rate(credit_vals, other_line=debit_vals)
- recon_debit_amount = remaining_debit_amount_curr
- recon_credit_amount = recon_currency.round(-remaining_credit_amount * credit_rate)
- # If there is nothing left after applying the rate to reconcile in foreign currency,
- # try to fallback on the company currency instead.
- if recon_currency.is_zero(recon_debit_amount) or recon_currency.is_zero(recon_credit_amount):
- recon_currency = company_currency
- credit_rate = 1
- recon_debit_amount = remaining_debit_amount
- recon_credit_amount = -remaining_credit_amount
- elif debit_vals['currency'] == credit_vals['currency'] \
- and debit_vals['currency'] != company_currency \
- and not has_debit_zero_residual_currency \
- and not has_credit_zero_residual_currency:
- # Both lines are sharing the same foreign currency.
- recon_currency = debit_vals['currency']
- debit_rate = get_accounting_rate(debit_vals)
- credit_rate = get_accounting_rate(credit_vals)
- recon_debit_amount = remaining_debit_amount_curr
- recon_credit_amount = -remaining_credit_amount_curr
- elif debit_vals['currency'] == credit_vals['currency'] \
- and debit_vals['currency'] != company_currency \
- and (has_debit_zero_residual_currency or has_credit_zero_residual_currency):
- # Special case for exchange difference lines. In that case, both lines are sharing the same foreign
- # currency but at least one has no amount in foreign currency.
- # In that case, we don't want a rate for the opposite line because the exchange difference is supposed
- # to reduce only the amount in company currency but not the foreign one.
- recon_currency = company_currency
- debit_rate = None
- credit_rate = None
- recon_debit_amount = remaining_debit_amount
- recon_credit_amount = -remaining_credit_amount
- else:
- # Multiple involved foreign currencies. The reconciliation is done using the currency of the company.
- recon_currency = company_currency
- debit_rate = get_accounting_rate(debit_vals)
- credit_rate = get_accounting_rate(credit_vals)
- recon_debit_amount = remaining_debit_amount
- recon_credit_amount = -remaining_credit_amount
- # Check if there is something left to reconcile. Move to the next loop iteration if not.
- skip_reconciliation = False
- if recon_currency.is_zero(recon_debit_amount):
- res['debit_vals'] = None
- skip_reconciliation = True
- if recon_currency.is_zero(recon_credit_amount):
- res['credit_vals'] = None
- skip_reconciliation = True
- if skip_reconciliation:
- return res
- # ==== Match both lines together and compute amounts to reconcile ====
- # Determine which line is fully matched by the other.
- compare_amounts = recon_currency.compare_amounts(recon_debit_amount, recon_credit_amount)
- min_recon_amount = min(recon_debit_amount, recon_credit_amount)
- debit_fully_matched = compare_amounts <= 0
- credit_fully_matched = compare_amounts >= 0
- # ==== Computation of partial amounts ====
- if recon_currency == company_currency:
- # Compute the partial amount expressed in company currency.
- partial_amount = min_recon_amount
- # Compute the partial amount expressed in foreign currency.
- if debit_rate:
- partial_debit_amount_currency = debit_vals['currency'].round(debit_rate * min_recon_amount)
- partial_debit_amount_currency = min(partial_debit_amount_currency, remaining_debit_amount_curr)
- else:
- partial_debit_amount_currency = 0.0
- if credit_rate:
- partial_credit_amount_currency = credit_vals['currency'].round(credit_rate * min_recon_amount)
- partial_credit_amount_currency = min(partial_credit_amount_currency, -remaining_credit_amount_curr)
- else:
- partial_credit_amount_currency = 0.0
- else:
- # recon_currency != company_currency
- # Compute the partial amount expressed in company currency.
- if debit_rate:
- partial_debit_amount = company_currency.round(min_recon_amount / debit_rate)
- partial_debit_amount = min(partial_debit_amount, remaining_debit_amount)
- else:
- partial_debit_amount = 0.0
- if credit_rate:
- partial_credit_amount = company_currency.round(min_recon_amount / credit_rate)
- partial_credit_amount = min(partial_credit_amount, -remaining_credit_amount)
- else:
- partial_credit_amount = 0.0
- partial_amount = min(partial_debit_amount, partial_credit_amount)
- # Compute the partial amount expressed in foreign currency.
- # Take care to handle the case when a line expressed in company currency is mimicking the foreign
- # currency of the opposite line.
- if debit_vals['currency'] == company_currency:
- partial_debit_amount_currency = partial_amount
- else:
- partial_debit_amount_currency = min_recon_amount
- if credit_vals['currency'] == company_currency:
- partial_credit_amount_currency = partial_amount
- else:
- partial_credit_amount_currency = min_recon_amount
- # Computation of the partial exchange difference. You can skip this part using the
- # `no_exchange_difference` context key (when reconciling an exchange difference for example).
- if not self._context.get('no_exchange_difference'):
- exchange_lines_to_fix = self.env['account.move.line']
- amounts_list = []
- if recon_currency == company_currency:
- if debit_fully_matched:
- debit_exchange_amount = remaining_debit_amount_curr - partial_debit_amount_currency
- if not debit_vals['currency'].is_zero(debit_exchange_amount):
- if debit_vals.get('record'):
- exchange_lines_to_fix += debit_vals['record']
- amounts_list.append({'amount_residual_currency': debit_exchange_amount})
- remaining_debit_amount_curr -= debit_exchange_amount
- if credit_fully_matched:
- credit_exchange_amount = remaining_credit_amount_curr + partial_credit_amount_currency
- if not credit_vals['currency'].is_zero(credit_exchange_amount):
- if credit_vals.get('record'):
- exchange_lines_to_fix += credit_vals['record']
- amounts_list.append({'amount_residual_currency': credit_exchange_amount})
- remaining_credit_amount_curr += credit_exchange_amount
- else:
- if debit_fully_matched:
- # Create an exchange difference on the remaining amount expressed in company's currency.
- debit_exchange_amount = remaining_debit_amount - partial_amount
- if not company_currency.is_zero(debit_exchange_amount):
- if debit_vals.get('record'):
- exchange_lines_to_fix += debit_vals['record']
- amounts_list.append({'amount_residual': debit_exchange_amount})
- remaining_debit_amount -= debit_exchange_amount
- if debit_vals['currency'] == company_currency:
- remaining_debit_amount_curr -= debit_exchange_amount
- else:
- # Create an exchange difference ensuring the rate between the residual amounts expressed in
- # both foreign and company's currency is still consistent regarding the rate between
- # 'amount_currency' & 'balance'.
- debit_exchange_amount = partial_debit_amount - partial_amount
- if company_currency.compare_amounts(debit_exchange_amount, 0.0) > 0:
- if debit_vals.get('record'):
- exchange_lines_to_fix += debit_vals['record']
- amounts_list.append({'amount_residual': debit_exchange_amount})
- remaining_debit_amount -= debit_exchange_amount
- if debit_vals['currency'] == company_currency:
- remaining_debit_amount_curr -= debit_exchange_amount
- if credit_fully_matched:
- # Create an exchange difference on the remaining amount expressed in company's currency.
- credit_exchange_amount = remaining_credit_amount + partial_amount
- if not company_currency.is_zero(credit_exchange_amount):
- if credit_vals.get('record'):
- exchange_lines_to_fix += credit_vals['record']
- amounts_list.append({'amount_residual': credit_exchange_amount})
- remaining_credit_amount -= credit_exchange_amount
- if credit_vals['currency'] == company_currency:
- remaining_credit_amount_curr -= credit_exchange_amount
- else:
- # Create an exchange difference ensuring the rate between the residual amounts expressed in
- # both foreign and company's currency is still consistent regarding the rate between
- # 'amount_currency' & 'balance'.
- credit_exchange_amount = partial_amount - partial_credit_amount
- if company_currency.compare_amounts(credit_exchange_amount, 0.0) < 0:
- if credit_vals.get('record'):
- exchange_lines_to_fix += credit_vals['record']
- amounts_list.append({'amount_residual': credit_exchange_amount})
- remaining_credit_amount -= credit_exchange_amount
- if credit_vals['currency'] == company_currency:
- remaining_credit_amount_curr -= credit_exchange_amount
- if exchange_lines_to_fix:
- res['exchange_vals'] = exchange_lines_to_fix._prepare_exchange_difference_move_vals(
- amounts_list,
- exchange_date=max(debit_vals['date'], credit_vals['date']),
- )
- # ==== Create partials ====
- remaining_debit_amount -= partial_amount
- remaining_credit_amount += partial_amount
- remaining_debit_amount_curr -= partial_debit_amount_currency
- remaining_credit_amount_curr += partial_credit_amount_currency
- res['partial_vals'] = {
- 'amount': partial_amount,
- 'debit_amount_currency': partial_debit_amount_currency,
- 'credit_amount_currency': partial_credit_amount_currency,
- 'debit_move_id': debit_vals.get('record') and debit_vals['record'].id,
- 'credit_move_id': credit_vals.get('record') and credit_vals['record'].id,
- }
- debit_vals['amount_residual'] = remaining_debit_amount
- debit_vals['amount_residual_currency'] = remaining_debit_amount_curr
- credit_vals['amount_residual'] = remaining_credit_amount
- credit_vals['amount_residual_currency'] = remaining_credit_amount_curr
- if debit_fully_matched:
- res['debit_vals'] = None
- if credit_fully_matched:
- res['credit_vals'] = None
- return res
- @api.model
- def _prepare_reconciliation_partials(self, vals_list):
- ''' Prepare the partials on the current journal items to perform the reconciliation.
- Note: The order of records in self is important because the journal items will be reconciled using this order.
- :return: a tuple of 1) list of vals for partial reconciliation creation, 2) the list of vals for the exchange difference entries to be created
- '''
- debit_vals_list = iter([x for x in vals_list if x['balance'] > 0.0 or x['amount_currency'] > 0.0])
- credit_vals_list = iter([x for x in vals_list if x['balance'] < 0.0 or x['amount_currency'] < 0.0])
- debit_vals = None
- credit_vals = None
- partials_vals_list = []
- exchange_data = {}
- while True:
- # ==== Find the next available lines ====
- # For performance reasons, the partials are created all at once meaning the residual amounts can't be
- # trusted from one iteration to another. That's the reason why all residual amounts are kept as variables
- # and reduced "manually" every time we append a dictionary to 'partials_vals_list'.
- # Move to the next available debit line.
- if not debit_vals:
- debit_vals = next(debit_vals_list, None)
- if not debit_vals:
- break
- # Move to the next available credit line.
- if not credit_vals:
- credit_vals = next(credit_vals_list, None)
- if not credit_vals:
- break
- # ==== Compute the amounts to reconcile ====
- res = self._prepare_reconciliation_single_partial(debit_vals, credit_vals)
- if res.get('partial_vals'):
- if res.get('exchange_vals'):
- exchange_data[len(partials_vals_list)] = res['exchange_vals']
- partials_vals_list.append(res['partial_vals'])
- if res['debit_vals'] is None:
- debit_vals = None
- if res['credit_vals'] is None:
- credit_vals = None
- return partials_vals_list, exchange_data
- def _create_reconciliation_partials(self):
- '''create the partial reconciliation between all the records in self
- :return: A recordset of account.partial.reconcile.
- '''
- partials_vals_list, exchange_data = self._prepare_reconciliation_partials([
- {
- 'record': line,
- 'balance': line.balance,
- 'amount_currency': line.amount_currency,
- 'amount_residual': line.amount_residual,
- 'amount_residual_currency': line.amount_residual_currency,
- 'company': line.company_id,
- 'currency': line.currency_id,
- 'date': line.date,
- }
- for line in self
- ])
- partials = self.env['account.partial.reconcile'].create(partials_vals_list)
- # ==== Create exchange difference moves ====
- for index, exchange_vals in exchange_data.items():
- partials[index].exchange_move_id = self._create_exchange_difference_move(exchange_vals)
- return partials
- def _prepare_exchange_difference_move_vals(self, amounts_list, company=None, exchange_date=None):
- """ Prepare values to create later the exchange difference journal entry.
- The exchange difference journal entry is there to fix the debit/credit of lines when the journal items are
- fully reconciled in foreign currency.
- :param amounts_list: A list of dict, one for each aml.
- :param company: The company in case there is no aml in self.
- :param exchange_date: Optional date object providing the date to consider for the exchange difference.
- :return: A python dictionary containing:
- * move_vals: A dictionary to be passed to the account.move.create method.
- * to_reconcile: A list of tuple <move_line, sequence> in order to perform the reconciliation after the move
- creation.
- """
- company = self.company_id or company
- if not company:
- return
- journal = company.currency_exchange_journal_id
- expense_exchange_account = company.expense_currency_exchange_account_id
- income_exchange_account = company.income_currency_exchange_account_id
- move_vals = {
- 'move_type': 'entry',
- 'date': max(exchange_date or date.min, company._get_user_fiscal_lock_date() + timedelta(days=1)),
- 'journal_id': journal.id,
- 'line_ids': [],
- 'always_tax_exigible': True,
- }
- to_reconcile = []
- for line, amounts in zip(self, amounts_list):
- move_vals['date'] = max(move_vals['date'], line.date)
- if 'amount_residual' in amounts:
- amount_residual = amounts['amount_residual']
- amount_residual_currency = 0.0
- if line.currency_id == line.company_id.currency_id:
- amount_residual_currency = amount_residual
- amount_residual_to_fix = amount_residual
- if line.company_currency_id.is_zero(amount_residual):
- continue
- elif 'amount_residual_currency' in amounts:
- amount_residual = 0.0
- amount_residual_currency = amounts['amount_residual_currency']
- amount_residual_to_fix = amount_residual_currency
- if line.currency_id.is_zero(amount_residual_currency):
- continue
- else:
- continue
- if amount_residual_to_fix > 0.0:
- exchange_line_account = expense_exchange_account
- else:
- exchange_line_account = income_exchange_account
- sequence = len(move_vals['line_ids'])
- move_vals['line_ids'] += [
- Command.create({
- 'name': _('Currency exchange rate difference'),
- 'debit': -amount_residual if amount_residual < 0.0 else 0.0,
- 'credit': amount_residual if amount_residual > 0.0 else 0.0,
- 'amount_currency': -amount_residual_currency,
- 'account_id': line.account_id.id,
- 'currency_id': line.currency_id.id,
- 'partner_id': line.partner_id.id,
- 'sequence': sequence,
- }),
- Command.create({
- 'name': _('Currency exchange rate difference'),
- 'debit': amount_residual if amount_residual > 0.0 else 0.0,
- 'credit': -amount_residual if amount_residual < 0.0 else 0.0,
- 'amount_currency': amount_residual_currency,
- 'account_id': exchange_line_account.id,
- 'currency_id': line.currency_id.id,
- 'partner_id': line.partner_id.id,
- 'sequence': sequence + 1,
- }),
- ]
- to_reconcile.append((line, sequence))
- return {'move_vals': move_vals, 'to_reconcile': to_reconcile}
- @api.model
- def _create_exchange_difference_move(self, exchange_diff_vals):
- """ Create the exchange difference journal entry on the current journal items.
- :param exchange_diff_vals: The current vals of the exchange difference journal entry created by the
- '_prepare_exchange_difference_move_vals' method.
- :return: An account.move record.
- """
- move_vals = exchange_diff_vals['move_vals']
- if not move_vals['line_ids']:
- return
- # Check the configuration of the exchange difference journal.
- journal = self.env['account.journal'].browse(move_vals['journal_id'])
- if not journal:
- raise UserError(_(
- "You should configure the 'Exchange Gain or Loss Journal' in your company settings, to manage"
- " automatically the booking of accounting entries related to differences between exchange rates."
- ))
- if not journal.company_id.expense_currency_exchange_account_id:
- raise UserError(_(
- "You should configure the 'Loss Exchange Rate Account' in your company settings, to manage"
- " automatically the booking of accounting entries related to differences between exchange rates."
- ))
- if not journal.company_id.income_currency_exchange_account_id.id:
- raise UserError(_(
- "You should configure the 'Gain Exchange Rate Account' in your company settings, to manage"
- " automatically the booking of accounting entries related to differences between exchange rates."
- ))
- # Create the move.
- exchange_move = self.env['account.move'].with_context(skip_invoice_sync=True).create(move_vals)
- exchange_move._post(soft=False)
- # Reconcile lines to the newly created exchange difference journal entry by creating more partials.
- for source_line, sequence in exchange_diff_vals['to_reconcile']:
- exchange_diff_line = exchange_move.line_ids[sequence]
- (exchange_diff_line + source_line).with_context(no_exchange_difference=True).reconcile()
- return exchange_move
- def _add_exchange_difference_cash_basis_vals(self, exchange_diff_vals):
- """ Generate the exchange difference values used to create the journal items
- in order to fix the cash basis lines using the transfer account in a multi-currencies
- environment when this account is not a reconcile one.
- When the tax cash basis journal entries are generated and all involved
- transfer account set on taxes are all reconcilable, the account balance
- will be reset to zero by the exchange difference journal items generated
- above. However, this mechanism will not work if there is any transfer
- accounts that are not reconcile and we are generating the cash basis
- journal items in a foreign currency. In that specific case, we need to
- generate extra journal items at the generation of the exchange difference
- journal entry to ensure this balance is reset to zero and then, will not
- appear on the tax report leading to erroneous tax base amount / tax amount.
- :param exchange_diff_vals: The current vals of the exchange difference journal entry created by the
- '_prepare_exchange_difference_move_vals' method.
- """
- caba_lines_to_reconcile = defaultdict(lambda: self.env['account.move.line']) # in the form {(move, account, repartition_line): move_lines}
- move_vals = exchange_diff_vals['move_vals']
- for move in self.move_id:
- account_vals_to_fix = {}
- move_values = move._collect_tax_cash_basis_values()
- # The cash basis doesn't need to be handled for this move because there is another payment term
- # line that is not yet fully paid.
- if not move_values or not move_values['is_fully_paid']:
- continue
- # ==========================================================================
- # Add the balance of all tax lines of the current move in order in order
- # to compute the residual amount for each of them.
- # ==========================================================================
- caba_rounding_diff_label = _("Cash basis rounding difference")
- move_vals['date'] = max(move_vals['date'], move.date)
- for caba_treatment, line in move_values['to_process_lines']:
- vals = {
- 'name': caba_rounding_diff_label,
- 'currency_id': line.currency_id.id,
- 'partner_id': line.partner_id.id,
- 'tax_ids': [Command.set(line.tax_ids.ids)],
- 'tax_tag_ids': [Command.set(line.tax_tag_ids.ids)],
- 'debit': line.debit,
- 'credit': line.credit,
- 'amount_currency': line.amount_currency,
- }
- if caba_treatment == 'tax':
- # Tax line.
- grouping_key = self.env['account.partial.reconcile']._get_cash_basis_tax_line_grouping_key_from_record(line)
- if grouping_key in account_vals_to_fix:
- debit = account_vals_to_fix[grouping_key]['debit'] + vals['debit']
- credit = account_vals_to_fix[grouping_key]['credit'] + vals['credit']
- balance = debit - credit
- account_vals_to_fix[grouping_key].update({
- 'debit': balance if balance > 0 else 0,
- 'credit': -balance if balance < 0 else 0,
- 'tax_base_amount': account_vals_to_fix[grouping_key]['tax_base_amount'] + line.tax_base_amount,
- 'amount_currency': account_vals_to_fix[grouping_key]['amount_currency'] + line.amount_currency,
- })
- else:
- account_vals_to_fix[grouping_key] = {
- **vals,
- 'account_id': line.account_id.id,
- 'tax_base_amount': line.tax_base_amount,
- 'tax_repartition_line_id': line.tax_repartition_line_id.id,
- }
- if line.account_id.reconcile:
- caba_lines_to_reconcile[(move, line.account_id, line.tax_repartition_line_id)] |= line
- elif caba_treatment == 'base':
- # Base line.
- account_to_fix = line.company_id.account_cash_basis_base_account_id
- if not account_to_fix:
- continue
- grouping_key = self.env['account.partial.reconcile']._get_cash_basis_base_line_grouping_key_from_record(line, account=account_to_fix)
- if grouping_key not in account_vals_to_fix:
- account_vals_to_fix[grouping_key] = {
- **vals,
- 'account_id': account_to_fix.id,
- }
- else:
- # Multiple base lines could share the same key, if the same
- # cash basis tax is used alone on several lines of the invoices
- account_vals_to_fix[grouping_key]['debit'] += vals['debit']
- account_vals_to_fix[grouping_key]['credit'] += vals['credit']
- account_vals_to_fix[grouping_key]['amount_currency'] += vals['amount_currency']
- # ==========================================================================
- # Subtract the balance of all previously generated cash basis journal entries
- # in order to retrieve the residual balance of each involved transfer account.
- # ==========================================================================
- cash_basis_moves = self.env['account.move'].search([('tax_cash_basis_origin_move_id', '=', move.id)])
- caba_transition_accounts = self.env['account.account']
- for line in cash_basis_moves.line_ids:
- grouping_key = None
- if line.tax_repartition_line_id:
- # Tax line.
- transition_account = line.tax_line_id.cash_basis_transition_account_id
- grouping_key = self.env['account.partial.reconcile']._get_cash_basis_tax_line_grouping_key_from_record(
- line,
- account=transition_account,
- )
- caba_transition_accounts |= transition_account
- elif line.tax_ids:
- # Base line.
- grouping_key = self.env['account.partial.reconcile']._get_cash_basis_base_line_grouping_key_from_record(
- line,
- account=line.company_id.account_cash_basis_base_account_id,
- )
- if grouping_key not in account_vals_to_fix:
- continue
- account_vals_to_fix[grouping_key]['debit'] -= line.debit
- account_vals_to_fix[grouping_key]['credit'] -= line.credit
- account_vals_to_fix[grouping_key]['amount_currency'] -= line.amount_currency
- # Collect the caba lines affecting the transition account.
- for transition_line in filter(lambda x: x.account_id in caba_transition_accounts, cash_basis_moves.line_ids):
- caba_reconcile_key = (transition_line.move_id, transition_line.account_id, transition_line.tax_repartition_line_id)
- caba_lines_to_reconcile[caba_reconcile_key] |= transition_line
- # ==========================================================================
- # Generate the exchange difference journal items:
- # - to reset the balance of all transfer account to zero.
- # - fix rounding issues on the tax account/base tax account.
- # ==========================================================================
- currency = move_values['currency']
- # To know which rate to use for the adjustment, get the rate used by the most recent cash basis move
- last_caba_move = max(cash_basis_moves, key=lambda m: m.date) if cash_basis_moves else self.env['account.move']
- currency_line = last_caba_move.line_ids.filtered(lambda x: x.currency_id == currency)[:1]
- currency_rate = currency_line.balance / currency_line.amount_currency if currency_line.amount_currency else 1.0
- existing_line_vals_list = move_vals['line_ids']
- next_sequence = len(existing_line_vals_list)
- for grouping_key, values in account_vals_to_fix.items():
- if currency.is_zero(values['amount_currency']):
- continue
- # There is a rounding error due to multiple payments on the foreign currency amount
- balance = currency.round(currency_rate * values['amount_currency'])
- if values.get('tax_repartition_line_id'):
- # Tax line
- tax_repartition_line = self.env['account.tax.repartition.line'].browse(values['tax_repartition_line_id'])
- account = tax_repartition_line.account_id or self.env['account.account'].browse(values['account_id'])
- existing_line_vals_list.extend([
- Command.create({
- **values,
- 'debit': balance if balance > 0.0 else 0.0,
- 'credit': -balance if balance < 0.0 else 0.0,
- 'amount_currency': values['amount_currency'],
- 'account_id': account.id,
- 'sequence': next_sequence,
- }),
- Command.create({
- **values,
- 'debit': -balance if balance < 0.0 else 0.0,
- 'credit': balance if balance > 0.0 else 0.0,
- 'amount_currency': -values['amount_currency'],
- 'account_id': values['account_id'],
- 'tax_ids': [],
- 'tax_tag_ids': [],
- 'tax_base_amount': 0,
- 'tax_repartition_line_id': False,
- 'sequence': next_sequence + 1,
- }),
- ])
- else:
- # Base line
- existing_line_vals_list.extend([
- Command.create({
- **values,
- 'debit': balance if balance > 0.0 else 0.0,
- 'credit': -balance if balance < 0.0 else 0.0,
- 'amount_currency': values['amount_currency'],
- 'sequence': next_sequence,
- }),
- Command.create({
- **values,
- 'debit': -balance if balance < 0.0 else 0.0,
- 'credit': balance if balance > 0.0 else 0.0,
- 'amount_currency': -values['amount_currency'],
- 'tax_ids': [],
- 'tax_tag_ids': [],
- 'sequence': next_sequence + 1,
- }),
- ])
- next_sequence += 2
- return caba_lines_to_reconcile
- def reconcile(self):
- ''' Reconcile the current move lines all together.
- :return: A dictionary representing a summary of what has been done during the reconciliation:
- * partials: A recorset of all account.partial.reconcile created during the reconciliation.
- * exchange_partials: A recorset of all account.partial.reconcile created during the reconciliation
- with the exchange difference journal entries.
- * full_reconcile: An account.full.reconcile record created when there is nothing left to reconcile
- in the involved lines.
- * tax_cash_basis_moves: An account.move recordset representing the tax cash basis journal entries.
- '''
- results = {'exchange_partials': self.env['account.partial.reconcile']}
- if not self:
- return results
- not_paid_invoices = self.move_id.filtered(lambda move:
- move.is_invoice(include_receipts=True)
- and move.payment_state not in ('paid', 'in_payment')
- )
- # ==== Check the lines can be reconciled together ====
- company = None
- account = None
- for line in self:
- if line.reconciled:
- raise UserError(_("You are trying to reconcile some entries that are already reconciled."))
- if not line.account_id.reconcile and line.account_id.account_type not in ('asset_cash', 'liability_credit_card'):
- raise UserError(_("Account %s does not allow reconciliation. First change the configuration of this account to allow it.")
- % line.account_id.display_name)
- if line.move_id.state != 'posted':
- raise UserError(_('You can only reconcile posted entries.'))
- if company is None:
- company = line.company_id
- elif line.company_id != company:
- raise UserError(_("Entries doesn't belong to the same company: %s != %s")
- % (company.display_name, line.company_id.display_name))
- if account is None:
- account = line.account_id
- elif line.account_id != account:
- raise UserError(_("Entries are not from the same account: %s != %s")
- % (account.display_name, line.account_id.display_name))
- if self._context.get('reduced_line_sorting'):
- sorting_f = lambda line: (line.date_maturity or line.date, line.currency_id)
- else:
- sorting_f = lambda line: (line.date_maturity or line.date, line.currency_id, line.amount_currency)
- sorted_lines = self.sorted(key=sorting_f)
- # ==== Collect all involved lines through the existing reconciliation ====
- involved_lines = sorted_lines._all_reconciled_lines()
- involved_partials = involved_lines.matched_credit_ids | involved_lines.matched_debit_ids
- # ==== Create partials ====
- partial_no_exch_diff = bool(self.env['ir.config_parameter'].sudo().get_param('account.disable_partial_exchange_diff'))
- sorted_lines_ctx = sorted_lines.with_context(no_exchange_difference=self._context.get('no_exchange_difference') or partial_no_exch_diff)
- partials = sorted_lines_ctx._create_reconciliation_partials()
- results['partials'] = partials
- involved_partials += partials
- exchange_move_lines = partials.exchange_move_id.line_ids.filtered(lambda line: line.account_id == account)
- involved_lines += exchange_move_lines
- exchange_diff_partials = exchange_move_lines.matched_debit_ids + exchange_move_lines.matched_credit_ids
- involved_partials += exchange_diff_partials
- results['exchange_partials'] += exchange_diff_partials
- # ==== Create entries for cash basis taxes ====
- is_cash_basis_needed = account.company_id.tax_exigibility and account.account_type in ('asset_receivable', 'liability_payable')
- if is_cash_basis_needed and not self._context.get('move_reverse_cancel') and not self._context.get('no_cash_basis'):
- tax_cash_basis_moves = partials._create_tax_cash_basis_moves()
- results['tax_cash_basis_moves'] = tax_cash_basis_moves
- # ==== Check if a full reconcile is needed ====
- def is_line_reconciled(line, has_multiple_currencies):
- # Check if the journal item passed as parameter is now fully reconciled.
- return line.reconciled \
- or (line.company_currency_id.is_zero(line.amount_residual)
- if has_multiple_currencies
- else line.currency_id.is_zero(line.amount_residual_currency)
- )
- has_multiple_currencies = len(involved_lines.currency_id) > 1
- if all(is_line_reconciled(line, has_multiple_currencies) for line in involved_lines):
- # ==== Create the exchange difference move ====
- # This part could be bypassed using the 'no_exchange_difference' key inside the context. This is useful
- # when importing a full accounting including the reconciliation like Winbooks.
- exchange_move = self.env['account.move']
- caba_lines_to_reconcile = None
- if not self._context.get('no_exchange_difference'):
- # In normal cases, the exchange differences are already generated by the partial at this point meaning
- # there is no journal item left with a zero amount residual in one currency but not in the other.
- # However, after a migration coming from an older version with an older partial reconciliation or due to
- # some rounding issues (when dealing with different decimal places for example), we could need an extra
- # exchange difference journal entry to handle them.
- exchange_lines_to_fix = self.env['account.move.line']
- amounts_list = []
- exchange_max_date = date.min
- for line in involved_lines:
- if not line.company_currency_id.is_zero(line.amount_residual):
- exchange_lines_to_fix += line
- amounts_list.append({'amount_residual': line.amount_residual})
- elif not line.currency_id.is_zero(line.amount_residual_currency):
- exchange_lines_to_fix += line
- amounts_list.append({'amount_residual_currency': line.amount_residual_currency})
- exchange_max_date = max(exchange_max_date, line.date)
- exchange_diff_vals = exchange_lines_to_fix._prepare_exchange_difference_move_vals(
- amounts_list,
- company=involved_lines[0].company_id,
- exchange_date=exchange_max_date,
- )
- # Exchange difference for cash basis entries.
- # If we are fully reversing the entry, no need to fix anything since the journal entry
- # is exactly the mirror of the source journal entry.
- if is_cash_basis_needed and not self._context.get('move_reverse_cancel'):
- caba_lines_to_reconcile = involved_lines._add_exchange_difference_cash_basis_vals(exchange_diff_vals)
- # Create the exchange difference.
- if exchange_diff_vals['move_vals']['line_ids']:
- exchange_move = involved_lines._create_exchange_difference_move(exchange_diff_vals)
- if exchange_move:
- exchange_move_lines = exchange_move.line_ids.filtered(lambda line: line.account_id == account)
- # Track newly created lines.
- involved_lines += exchange_move_lines
- # Track newly created partials.
- exchange_diff_partials = exchange_move_lines.matched_debit_ids \
- + exchange_move_lines.matched_credit_ids
- involved_partials += exchange_diff_partials
- results['exchange_partials'] += exchange_diff_partials
- # ==== Create the full reconcile ====
- results['full_reconcile'] = self.env['account.full.reconcile'] \
- .with_context(
- skip_invoice_sync=True,
- skip_invoice_line_sync=True,
- skip_account_move_synchronization=True,
- check_move_validity=False,
- ) \
- .create({
- 'exchange_move_id': exchange_move and exchange_move.id,
- 'partial_reconcile_ids': [Command.set(involved_partials.ids)],
- 'reconciled_line_ids': [Command.set(involved_lines.ids)],
- })
- # === Cash basis rounding autoreconciliation ===
- # In case a cash basis rounding difference line got created for the transition account, we reconcile it with the corresponding lines
- # on the cash basis moves (so that it reaches full reconciliation and creates an exchange difference entry for this account as well)
- if caba_lines_to_reconcile:
- for (dummy, account, repartition_line), amls_to_reconcile in caba_lines_to_reconcile.items():
- if not account.reconcile:
- continue
- exchange_line = exchange_move.line_ids.filtered(
- lambda l: l.account_id == account and l.tax_repartition_line_id == repartition_line
- )
- (exchange_line + amls_to_reconcile).filtered(lambda l: not l.reconciled).reconcile()
- not_paid_invoices.filtered(lambda move:
- move.payment_state in ('paid', 'in_payment')
- )._invoice_paid_hook()
- return results
- def remove_move_reconcile(self):
- """ Undo a reconciliation """
- (self.matched_debit_ids + self.matched_credit_ids).unlink()
- # -------------------------------------------------------------------------
- # ANALYTIC
- # -------------------------------------------------------------------------
- def _validate_analytic_distribution(self):
- for line in self.filtered(lambda line: line.display_type == 'product'):
- line._validate_distribution(**{
- 'product': line.product_id.id,
- 'account': line.account_id.id,
- 'business_domain': line.move_id.move_type in ['out_invoice', 'out_refund', 'out_receipt'] and 'invoice'
- or line.move_id.move_type in ['in_invoice', 'in_refund', 'in_receipt'] and 'bill'
- or 'general',
- 'company_id': line.company_id.id,
- })
- def _create_analytic_lines(self):
- """ Create analytic items upon validation of an account.move.line having an analytic distribution.
- """
- self._validate_analytic_distribution()
- analytic_line_vals = []
- for line in self:
- analytic_line_vals.extend(line._prepare_analytic_lines())
- self.env['account.analytic.line'].create(analytic_line_vals)
- def _prepare_analytic_lines(self):
- self.ensure_one()
- analytic_line_vals = []
- if self.analytic_distribution:
- # distribution_on_each_plan corresponds to the proportion that is distributed to each plan to be able to
- # give the real amount when we achieve a 100% distribution
- distribution_on_each_plan = {}
- for account_id, distribution in self.analytic_distribution.items():
- line_values = self._prepare_analytic_distribution_line(float(distribution), account_id, distribution_on_each_plan)
- if not self.currency_id.is_zero(line_values.get('amount')):
- analytic_line_vals.append(line_values)
- return analytic_line_vals
- def _prepare_analytic_distribution_line(self, distribution, account_id, distribution_on_each_plan):
- """ Prepare the values used to create() an account.analytic.line upon validation of an account.move.line having
- analytic tags with analytic distribution.
- """
- self.ensure_one()
- account_id = int(account_id)
- account = self.env['account.analytic.account'].browse(account_id)
- distribution_plan = distribution_on_each_plan.get(account.root_plan_id, 0) + distribution
- decimal_precision = self.env['decimal.precision'].precision_get('Percentage Analytic')
- if float_compare(distribution_plan, 100, precision_digits=decimal_precision) == 0:
- amount = -self.balance * (100 - distribution_on_each_plan.get(account.root_plan_id, 0)) / 100.0
- else:
- amount = -self.balance * distribution / 100.0
- distribution_on_each_plan[account.root_plan_id] = distribution_plan
- default_name = self.name or (self.ref or '/' + ' -- ' + (self.partner_id and self.partner_id.name or '/'))
- return {
- 'name': default_name,
- 'date': self.date,
- 'account_id': account_id,
- 'partner_id': self.partner_id.id,
- 'unit_amount': self.quantity,
- 'product_id': self.product_id and self.product_id.id or False,
- 'product_uom_id': self.product_uom_id and self.product_uom_id.id or False,
- 'amount': amount,
- 'general_account_id': self.account_id.id,
- 'ref': self.ref,
- 'move_line_id': self.id,
- 'user_id': self.move_id.invoice_user_id.id or self._uid,
- 'company_id': account.company_id.id or self.company_id.id or self.env.company.id,
- 'category': 'invoice' if self.move_id.is_sale_document() else 'vendor_bill' if self.move_id.is_purchase_document() else 'other',
- }
- # -------------------------------------------------------------------------
- # MISC
- # -------------------------------------------------------------------------
- def _get_integrity_hash_fields(self):
- # Use the new hash version by default, but keep the old one for backward compatibility when generating the integrity report.
- hash_version = self._context.get('hash_version', MAX_HASH_VERSION)
- if hash_version == 1:
- return ['debit', 'credit', 'account_id', 'partner_id']
- elif hash_version in (2, 3):
- return ['name', 'debit', 'credit', 'account_id', 'partner_id']
- raise NotImplementedError(f"hash_version={hash_version} doesn't exist")
- def _reconciled_lines(self):
- ids = []
- for aml in self.filtered('reconciled'):
- ids.extend([r.debit_move_id.id for r in aml.matched_debit_ids] if aml.credit > 0 else [r.credit_move_id.id for r in aml.matched_credit_ids])
- ids.append(aml.id)
- return ids
- def _all_reconciled_lines(self):
- reconciliation_lines = self.filtered(lambda x: x.account_id.reconcile or x.account_id.account_type in ('asset_cash', 'liability_credit_card'))
- current_lines = reconciliation_lines
- current_partials = self.env['account.partial.reconcile']
- while current_lines:
- current_partials = (current_lines.matched_debit_ids + current_lines.matched_credit_ids) - current_partials
- current_lines = (current_partials.debit_move_id + current_partials.credit_move_id) - current_lines
- reconciliation_lines += current_lines
- return reconciliation_lines
- def _get_attachment_domains(self):
- self.ensure_one()
- domains = [[('res_model', '=', 'account.move'), ('res_id', '=', self.move_id.id)]]
- if self.statement_id:
- domains.append([('res_model', '=', 'account.bank.statement'), ('res_id', '=', self.statement_id.id)])
- if self.payment_id:
- domains.append([('res_model', '=', 'account.payment'), ('res_id', '=', self.payment_id.id)])
- return domains
- @api.model
- def _get_tax_exigible_domain(self):
- """ Returns a domain to be used to identify the move lines that are allowed
- to be taken into account in the tax report.
- """
- return [
- # Lines on moves without any payable or receivable line are always exigible
- '|', ('move_id.always_tax_exigible', '=', True),
- # Lines with only tags are always exigible
- '|', '&', ('tax_line_id', '=', False), ('tax_ids', '=', False),
- # Lines from CABA entries are always exigible
- '|', ('move_id.tax_cash_basis_rec_id', '!=', False),
- # Lines from non-CABA taxes are always exigible
- '|', ('tax_line_id.tax_exigibility', '!=', 'on_payment'),
- ('tax_ids.tax_exigibility', '!=', 'on_payment'), # So: exigible if at least one tax from tax_ids isn't on_payment
- ]
- def _convert_to_tax_base_line_dict(self):
- """ Convert the current record to a dictionary in order to use the generic taxes computation method
- defined on account.tax.
- :return: A python dictionary.
- """
- self.ensure_one()
- is_invoice = self.move_id.is_invoice(include_receipts=True)
- sign = -1 if self.move_id.is_inbound(include_receipts=True) else 1
- return self.env['account.tax']._convert_to_tax_base_line_dict(
- self,
- partner=self.partner_id,
- currency=self.currency_id,
- product=self.product_id,
- taxes=self.tax_ids,
- price_unit=self.price_unit if is_invoice else self.amount_currency,
- quantity=self.quantity if is_invoice else 1.0,
- discount=self.discount if is_invoice else 0.0,
- account=self.account_id,
- analytic_distribution=self.analytic_distribution,
- price_subtotal=sign * self.amount_currency,
- is_refund=self.is_refund,
- rate=(abs(self.amount_currency) / abs(self.balance)) if self.balance else 1.0
- )
- def _convert_to_tax_line_dict(self):
- """ Convert the current record to a dictionary in order to use the generic taxes computation method
- defined on account.tax.
- :return: A python dictionary.
- """
- self.ensure_one()
- sign = -1 if self.move_id.is_inbound(include_receipts=True) else 1
- return self.env['account.tax']._convert_to_tax_line_dict(
- self,
- partner=self.partner_id,
- currency=self.currency_id,
- taxes=self.tax_ids,
- tax_tags=self.tax_tag_ids,
- tax_repartition_line=self.tax_repartition_line_id,
- group_tax=self.group_tax_id,
- account=self.account_id,
- analytic_distribution=self.analytic_distribution,
- tax_amount=sign * self.amount_currency,
- )
- def _get_invoiced_qty_per_product(self):
- qties = defaultdict(float)
- for aml in self:
- qty = aml.product_uom_id._compute_quantity(aml.quantity, aml.product_id.uom_id)
- if aml.move_id.move_type == 'out_invoice':
- qties[aml.product_id] += qty
- elif aml.move_id.move_type == 'out_refund':
- qties[aml.product_id] -= qty
- return qties
- def _get_lock_date_protected_fields(self):
- """ Returns the names of the fields that should be protected by the accounting fiscal year and tax lock dates
- """
- tax_fnames = ['balance', 'tax_line_id', 'tax_ids', 'tax_tag_ids']
- fiscal_fnames = tax_fnames + ['account_id', 'journal_id', 'amount_currency', 'currency_id', 'partner_id']
- reconciliation_fnames = ['account_id', 'date', 'balance', 'amount_currency', 'currency_id', 'partner_id']
- return {
- 'tax': tax_fnames,
- 'fiscal': fiscal_fnames,
- 'reconciliation': reconciliation_fnames,
- }
- @api.model
- def get_import_templates(self):
- return [{
- 'label': _('Import Template for Journal Items'),
- 'template': '/account/static/xls/aml_import_template.xlsx'
- }]
- def _is_eligible_for_early_payment_discount(self, currency, reference_date):
- self.ensure_one()
- return self.display_type == 'payment_term' \
- and self.currency_id == currency \
- and self.move_id.move_type in ('out_invoice', 'out_receipt', 'in_invoice', 'in_receipt') \
- and not self.matched_debit_ids \
- and not self.matched_credit_ids \
- and self.discount_date \
- and reference_date <= self.discount_date
- # -------------------------------------------------------------------------
- # PUBLIC ACTIONS
- # -------------------------------------------------------------------------
- def open_reconcile_view(self):
- action = self.env['ir.actions.act_window']._for_xml_id('account.action_account_moves_all_grouped_matching')
- ids = self._all_reconciled_lines().filtered(lambda l: l.matched_debit_ids or l.matched_credit_ids).ids
- action['domain'] = [('id', 'in', ids)]
- return clean_action(action, self.env)
- def action_open_business_doc(self):
- return self.move_id.action_open_business_doc()
- def action_automatic_entry(self):
- action = self.env['ir.actions.act_window']._for_xml_id('account.account_automatic_entry_wizard_action')
- # Force the values of the move line in the context to avoid issues
- ctx = dict(self.env.context)
- ctx.pop('active_id', None)
- ctx.pop('default_journal_id', None)
- ctx['active_ids'] = self.ids
- ctx['active_model'] = 'account.move.line'
- action['context'] = ctx
- return action
- # -------------------------------------------------------------------------
- # TOOLING
- # -------------------------------------------------------------------------
- def _conditional_add_to_compute(self, fname, condition):
- field = self._fields[fname]
- to_reset = self.filtered(lambda line:
- condition(line)
- and not self.env.is_protected(field, line)
- )
- to_reset.invalidate_recordset([fname])
- self.env.add_to_compute(field, to_reset)
- # -------------------------------------------------------------------------
- # HOOKS
- # -------------------------------------------------------------------------
- def _copy_data_extend_business_fields(self, values):
- self.ensure_one()
- def _get_downpayment_lines(self):
- ''' Return the downpayment move lines associated with the move line.
- This method is overridden in the sale order module.
- '''
- return self.env['account.move.line']
|