123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273327432753276327732783279328032813282328332843285328632873288328932903291329232933294329532963297329832993300330133023303330433053306330733083309331033113312331333143315331633173318331933203321332233233324332533263327332833293330333133323333333433353336333733383339334033413342334333443345334633473348334933503351335233533354335533563357335833593360336133623363336433653366336733683369337033713372337333743375337633773378337933803381338233833384338533863387338833893390339133923393339433953396339733983399340034013402340334043405340634073408340934103411341234133414341534163417341834193420342134223423342434253426342734283429343034313432343334343435343634373438343934403441344234433444344534463447344834493450345134523453345434553456345734583459346034613462346334643465346634673468346934703471347234733474347534763477347834793480348134823483348434853486348734883489349034913492349334943495349634973498349935003501350235033504350535063507350835093510351135123513351435153516351735183519352035213522352335243525352635273528352935303531353235333534353535363537353835393540354135423543354435453546354735483549355035513552355335543555355635573558355935603561356235633564356535663567356835693570357135723573357435753576357735783579358035813582358335843585358635873588358935903591359235933594359535963597359835993600360136023603360436053606360736083609361036113612361336143615361636173618361936203621362236233624362536263627362836293630363136323633363436353636363736383639364036413642364336443645364636473648364936503651365236533654365536563657365836593660366136623663366436653666366736683669367036713672367336743675367636773678367936803681368236833684368536863687368836893690369136923693369436953696369736983699370037013702370337043705370637073708370937103711371237133714371537163717371837193720372137223723372437253726372737283729373037313732373337343735373637373738373937403741374237433744374537463747374837493750375137523753375437553756375737583759376037613762376337643765376637673768376937703771377237733774377537763777377837793780378137823783378437853786378737883789379037913792379337943795379637973798379938003801380238033804380538063807380838093810381138123813381438153816381738183819382038213822382338243825382638273828382938303831383238333834383538363837383838393840384138423843384438453846384738483849385038513852385338543855385638573858385938603861386238633864386538663867386838693870387138723873387438753876387738783879388038813882388338843885388638873888388938903891389238933894389538963897389838993900390139023903390439053906390739083909391039113912391339143915391639173918391939203921392239233924392539263927392839293930393139323933393439353936393739383939394039413942394339443945394639473948394939503951395239533954395539563957395839593960396139623963396439653966396739683969397039713972397339743975397639773978397939803981398239833984398539863987398839893990399139923993399439953996399739983999400040014002400340044005400640074008400940104011401240134014401540164017401840194020402140224023402440254026402740284029403040314032403340344035403640374038403940404041404240434044404540464047404840494050405140524053405440554056405740584059406040614062406340644065406640674068406940704071407240734074407540764077407840794080408140824083408440854086408740884089409040914092409340944095409640974098409941004101410241034104410541064107410841094110411141124113411441154116411741184119412041214122412341244125412641274128412941304131413241334134413541364137413841394140414141424143414441454146414741484149415041514152415341544155415641574158415941604161416241634164416541664167416841694170417141724173417441754176417741784179418041814182418341844185418641874188418941904191419241934194419541964197419841994200420142024203420442054206420742084209421042114212421342144215421642174218421942204221422242234224422542264227422842294230423142324233423442354236423742384239424042414242424342444245424642474248424942504251425242534254425542564257425842594260426142624263426442654266426742684269427042714272427342744275427642774278427942804281428242834284428542864287428842894290429142924293429442954296429742984299430043014302430343044305430643074308430943104311431243134314431543164317431843194320432143224323432443254326432743284329433043314332433343344335433643374338433943404341434243434344434543464347434843494350435143524353435443554356435743584359436043614362436343644365436643674368436943704371437243734374437543764377437843794380438143824383438443854386438743884389 |
- # -*- coding: utf-8 -*-
- from collections import defaultdict
- from contextlib import ExitStack, contextmanager
- from datetime import date, timedelta
- from dateutil.relativedelta import relativedelta
- from hashlib import sha256
- from json import dumps
- import re
- from textwrap import shorten
- from unittest.mock import patch
- from odoo import api, fields, models, _, Command
- from odoo.addons.base.models.decimal_precision import DecimalPrecision
- from odoo.addons.account.tools import format_rf_reference
- from odoo.exceptions import UserError, ValidationError, AccessError, RedirectWarning
- from odoo.tools import (
- date_utils,
- email_re,
- email_split,
- float_compare,
- float_is_zero,
- float_repr,
- format_amount,
- format_date,
- formatLang,
- frozendict,
- get_lang,
- is_html_empty,
- sql
- )
- MAX_HASH_VERSION = 3
- PAYMENT_STATE_SELECTION = [
- ('not_paid', 'Not Paid'),
- ('in_payment', 'In Payment'),
- ('paid', 'Paid'),
- ('partial', 'Partially Paid'),
- ('reversed', 'Reversed'),
- ('invoicing_legacy', 'Invoicing App Legacy'),
- ]
- TYPE_REVERSE_MAP = {
- 'entry': 'entry',
- 'out_invoice': 'out_refund',
- 'out_refund': 'entry',
- 'in_invoice': 'in_refund',
- 'in_refund': 'entry',
- 'out_receipt': 'out_refund',
- 'in_receipt': 'in_refund',
- }
- EMPTY = object()
- class AccountMove(models.Model):
- _name = "account.move"
- _inherit = ['portal.mixin', 'mail.thread', 'mail.activity.mixin', 'sequence.mixin']
- _description = "Journal Entry"
- _order = 'date desc, name desc, id desc'
- _mail_post_access = 'read'
- _check_company_auto = True
- _sequence_index = "journal_id"
- _rec_names_search = ['name', 'partner_id.name', 'ref']
- @property
- def _sequence_monthly_regex(self):
- return self.journal_id.sequence_override_regex or super()._sequence_monthly_regex
- @property
- def _sequence_yearly_regex(self):
- return self.journal_id.sequence_override_regex or super()._sequence_yearly_regex
- @property
- def _sequence_fixed_regex(self):
- return self.journal_id.sequence_override_regex or super()._sequence_fixed_regex
- # ==============================================================================================
- # JOURNAL ENTRY
- # ==============================================================================================
- # === Accounting fields === #
- name = fields.Char(
- string='Number',
- compute='_compute_name', inverse='_inverse_name', readonly=False, store=True,
- copy=False,
- tracking=True,
- index='trigram',
- )
- ref = fields.Char(string='Reference', copy=False, tracking=True)
- date = fields.Date(
- string='Date',
- index=True,
- compute='_compute_date', store=True, required=True, readonly=False, precompute=True,
- states={'posted': [('readonly', True)], 'cancel': [('readonly', True)]},
- copy=False,
- tracking=True,
- )
- state = fields.Selection(
- selection=[
- ('draft', 'Draft'),
- ('posted', 'Posted'),
- ('cancel', 'Cancelled'),
- ],
- string='Status',
- required=True,
- readonly=True,
- copy=False,
- tracking=True,
- default='draft',
- )
- move_type = fields.Selection(
- selection=[
- ('entry', 'Journal Entry'),
- ('out_invoice', 'Customer Invoice'),
- ('out_refund', 'Customer Credit Note'),
- ('in_invoice', 'Vendor Bill'),
- ('in_refund', 'Vendor Credit Note'),
- ('out_receipt', 'Sales Receipt'),
- ('in_receipt', 'Purchase Receipt'),
- ],
- string='Type',
- required=True,
- readonly=True,
- tracking=True,
- change_default=True,
- index=True,
- default="entry",
- )
- is_storno = fields.Boolean(
- compute='_compute_is_storno', store=True, readonly=False,
- copy=False,
- )
- journal_id = fields.Many2one(
- 'account.journal',
- string='Journal',
- compute='_compute_journal_id', inverse='_inverse_journal_id', store=True, readonly=False, precompute=True,
- required=True,
- states={'draft': [('readonly', False)]},
- check_company=True,
- domain="[('id', 'in', suitable_journal_ids)]",
- )
- company_id = fields.Many2one(
- comodel_name='res.company',
- string='Company',
- compute='_compute_company_id', inverse='_inverse_company_id', store=True, readonly=False, precompute=True,
- index=True,
- )
- line_ids = fields.One2many(
- 'account.move.line',
- 'move_id',
- string='Journal Items',
- copy=True,
- readonly=True,
- states={'draft': [('readonly', False)]},
- )
- # === Payment fields === #
- payment_id = fields.Many2one(
- comodel_name='account.payment',
- string="Payment",
- index='btree_not_null',
- copy=False,
- check_company=True,
- )
- # === Statement fields === #
- statement_line_id = fields.Many2one(
- comodel_name='account.bank.statement.line',
- string="Statement Line",
- copy=False,
- check_company=True,
- )
- # === Cash basis feature fields === #
- # used to keep track of the tax cash basis reconciliation. This is needed
- # when cancelling the source: it will post the inverse journal entry to
- # cancel that part too.
- tax_cash_basis_rec_id = fields.Many2one(
- comodel_name='account.partial.reconcile',
- string='Tax Cash Basis Entry of',
- )
- tax_cash_basis_origin_move_id = fields.Many2one(
- comodel_name='account.move',
- index='btree_not_null',
- string="Cash Basis Origin",
- readonly=True,
- help="The journal entry from which this tax cash basis journal entry has been created.",
- )
- tax_cash_basis_created_move_ids = fields.One2many(
- string="Cash Basis Entries",
- comodel_name='account.move',
- inverse_name='tax_cash_basis_origin_move_id',
- help="The cash basis entries created from the taxes on this entry, when reconciling its lines.",
- )
- # used by cash basis taxes, telling the lines of the move are always
- # exigible. This happens if the move contains no payable or receivable line.
- always_tax_exigible = fields.Boolean(compute='_compute_always_tax_exigible', store=True, readonly=False)
- # === Misc fields === #
- auto_post = fields.Selection(
- string='Auto-post',
- selection=[
- ('no', 'No'),
- ('at_date', 'At Date'),
- ('monthly', 'Monthly'),
- ('quarterly', 'Quarterly'),
- ('yearly', 'Yearly'),
- ],
- default='no', required=True, copy=False,
- help='Specify whether this entry is posted automatically on its accounting date, and any similar recurring invoices.')
- auto_post_until = fields.Date(
- string='Auto-post until',
- copy=False,
- compute='_compute_auto_post_until', store=True, readonly=False,
- help='This recurring move will be posted up to and including this date.')
- auto_post_origin_id = fields.Many2one(
- comodel_name='account.move',
- string='First recurring entry',
- readonly=True, copy=False,
- )
- hide_post_button = fields.Boolean(compute='_compute_hide_post_button', readonly=True)
- to_check = fields.Boolean(
- string='To Check',
- tracking=True,
- help="If this checkbox is ticked, it means that the user was not sure of all the related "
- "information at the time of the creation of the move and that the move needs to be "
- "checked again.",
- )
- posted_before = fields.Boolean(copy=False)
- suitable_journal_ids = fields.Many2many(
- 'account.journal',
- compute='_compute_suitable_journal_ids',
- )
- highest_name = fields.Char(compute='_compute_highest_name')
- made_sequence_hole = fields.Boolean(compute='_compute_made_sequence_hole')
- show_name_warning = fields.Boolean(store=False)
- type_name = fields.Char('Type Name', compute='_compute_type_name')
- country_code = fields.Char(related='company_id.account_fiscal_country_id.code', readonly=True)
- attachment_ids = fields.One2many('ir.attachment', 'res_id', domain=[('res_model', '=', 'account.move')], string='Attachments')
- # === Hash Fields === #
- restrict_mode_hash_table = fields.Boolean(related='journal_id.restrict_mode_hash_table')
- secure_sequence_number = fields.Integer(string="Inalteralbility No Gap Sequence #", readonly=True, copy=False, index=True)
- inalterable_hash = fields.Char(string="Inalterability Hash", readonly=True, copy=False)
- string_to_hash = fields.Char(compute='_compute_string_to_hash', readonly=True)
- # ==============================================================================================
- # INVOICE
- # ==============================================================================================
- invoice_line_ids = fields.One2many( # /!\ invoice_line_ids is just a subset of line_ids.
- 'account.move.line',
- 'move_id',
- string='Invoice lines',
- copy=False,
- readonly=True,
- domain=[('display_type', 'in', ('product', 'line_section', 'line_note'))],
- states={'draft': [('readonly', False)]},
- )
- # === Date fields === #
- invoice_date = fields.Date(
- string='Invoice/Bill Date',
- readonly=True,
- states={'draft': [('readonly', False)]},
- index=True,
- copy=False,
- )
- invoice_date_due = fields.Date(
- string='Due Date',
- compute='_compute_invoice_date_due', store=True, readonly=False,
- states={'draft': [('readonly', False)]},
- index=True,
- copy=False,
- )
- invoice_payment_term_id = fields.Many2one(
- comodel_name='account.payment.term',
- string='Payment Terms',
- compute='_compute_invoice_payment_term_id', store=True, readonly=False, precompute=True,
- states={'posted': [('readonly', True)], 'cancel': [('readonly', True)]},
- check_company=True,
- )
- needed_terms = fields.Binary(compute='_compute_needed_terms', exportable=False)
- needed_terms_dirty = fields.Boolean(compute='_compute_needed_terms')
- # === Partner fields === #
- partner_id = fields.Many2one(
- 'res.partner',
- string='Partner',
- readonly=True,
- tracking=True,
- states={'draft': [('readonly', False)]},
- inverse='_inverse_partner_id',
- check_company=True,
- change_default=True,
- ondelete='restrict',
- )
- commercial_partner_id = fields.Many2one(
- 'res.partner',
- string='Commercial Entity',
- compute='_compute_commercial_partner_id', store=True, readonly=True,
- ondelete='restrict',
- )
- partner_shipping_id = fields.Many2one(
- comodel_name='res.partner',
- string='Delivery Address',
- compute='_compute_partner_shipping_id', store=True, readonly=False, precompute=True,
- domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]",
- help="Delivery address for current invoice.",
- )
- partner_bank_id = fields.Many2one(
- 'res.partner.bank',
- string='Recipient Bank',
- compute='_compute_partner_bank_id', store=True, readonly=False,
- help="Bank Account Number to which the invoice will be paid. "
- "A Company bank account if this is a Customer Invoice or Vendor Credit Note, "
- "otherwise a Partner bank account number.",
- check_company=True,
- tracking=True,
- )
- fiscal_position_id = fields.Many2one(
- 'account.fiscal.position',
- string='Fiscal Position',
- check_company=True,
- compute='_compute_fiscal_position_id', store=True, readonly=False, precompute=True,
- states={'posted': [('readonly', True)], 'cancel': [('readonly', True)]},
- domain="[('company_id', '=', company_id)]",
- ondelete="restrict",
- help="Fiscal positions are used to adapt taxes and accounts for particular "
- "customers or sales orders/invoices. The default value comes from the customer.",
- )
- # === Payment fields === #
- payment_reference = fields.Char(
- string='Payment Reference',
- index='trigram',
- copy=False,
- help="The payment reference to set on journal items.",
- tracking=True,
- compute='_compute_payment_reference', inverse='_inverse_payment_reference', store=True, readonly=False,
- )
- display_qr_code = fields.Boolean(
- string="Display QR-code",
- compute='_compute_display_qr_code',
- )
- qr_code_method = fields.Selection(
- string="Payment QR-code", copy=False,
- selection=lambda self: self.env['res.partner.bank'].get_available_qr_methods_in_sequence(),
- help="Type of QR-code to be generated for the payment of this invoice, "
- "when printing it. If left blank, the first available and usable method "
- "will be used.",
- )
- # === Payment widget fields === #
- invoice_outstanding_credits_debits_widget = fields.Binary(
- groups="account.group_account_invoice,account.group_account_readonly",
- compute='_compute_payments_widget_to_reconcile_info',
- exportable=False,
- )
- invoice_has_outstanding = fields.Boolean(
- groups="account.group_account_invoice,account.group_account_readonly",
- compute='_compute_payments_widget_to_reconcile_info',
- )
- invoice_payments_widget = fields.Binary(
- groups="account.group_account_invoice,account.group_account_readonly",
- compute='_compute_payments_widget_reconciled_info',
- exportable=False,
- )
- # === Currency fields === #
- company_currency_id = fields.Many2one(
- string='Company Currency',
- related='company_id.currency_id', readonly=True,
- )
- currency_id = fields.Many2one(
- 'res.currency',
- string='Currency',
- tracking=True,
- required=True,
- compute='_compute_currency_id', inverse='_inverse_currency_id', store=True, readonly=False, precompute=True,
- states={'posted': [('readonly', True)], 'cancel': [('readonly', True)]},
- )
- # === Amount fields === #
- direction_sign = fields.Integer(
- compute='_compute_direction_sign',
- help="Multiplicator depending on the document type, to convert a price into a balance",
- )
- amount_untaxed = fields.Monetary(
- string='Untaxed Amount',
- compute='_compute_amount', store=True, readonly=True,
- tracking=True,
- )
- amount_tax = fields.Monetary(
- string='Tax',
- compute='_compute_amount', store=True, readonly=True,
- )
- amount_total = fields.Monetary(
- string='Total',
- compute='_compute_amount', store=True, readonly=True,
- inverse='_inverse_amount_total',
- )
- amount_residual = fields.Monetary(
- string='Amount Due',
- compute='_compute_amount', store=True,
- )
- amount_untaxed_signed = fields.Monetary(
- string='Untaxed Amount Signed',
- compute='_compute_amount', store=True, readonly=True,
- currency_field='company_currency_id',
- )
- amount_tax_signed = fields.Monetary(
- string='Tax Signed',
- compute='_compute_amount', store=True, readonly=True,
- currency_field='company_currency_id',
- )
- amount_total_signed = fields.Monetary(
- string='Total Signed',
- compute='_compute_amount', store=True, readonly=True,
- currency_field='company_currency_id',
- )
- amount_total_in_currency_signed = fields.Monetary(
- string='Total in Currency Signed',
- compute='_compute_amount', store=True, readonly=True,
- currency_field='currency_id',
- )
- amount_residual_signed = fields.Monetary(
- string='Amount Due Signed',
- compute='_compute_amount', store=True,
- currency_field='company_currency_id',
- )
- tax_totals = fields.Binary(
- string="Invoice Totals",
- compute='_compute_tax_totals',
- inverse='_inverse_tax_totals',
- help='Edit Tax amounts if you encounter rounding issues.',
- exportable=False,
- )
- payment_state = fields.Selection(
- selection=PAYMENT_STATE_SELECTION,
- string="Payment Status",
- compute='_compute_payment_state', store=True, readonly=True,
- copy=False,
- tracking=True,
- )
- # === Reverse feature fields === #
- reversed_entry_id = fields.Many2one(
- comodel_name='account.move',
- string="Reversal of",
- index='btree_not_null',
- readonly=True,
- copy=False,
- check_company=True,
- )
- reversal_move_id = fields.One2many('account.move', 'reversed_entry_id')
- # === Vendor bill fields === #
- invoice_vendor_bill_id = fields.Many2one(
- 'account.move',
- store=False,
- check_company=True,
- string='Vendor Bill',
- help="Auto-complete from a past bill.",
- )
- invoice_source_email = fields.Char(string='Source Email', tracking=True)
- invoice_partner_display_name = fields.Char(compute='_compute_invoice_partner_display_info', store=True)
- # === Fiduciary mode fields === #
- quick_edit_mode = fields.Boolean(compute='_compute_quick_edit_mode')
- quick_edit_total_amount = fields.Monetary(
- string='Total (Tax inc.)',
- help='Use this field to encode the total amount of the invoice.\n'
- 'Odoo will automatically create one invoice line with default values to match it.',
- )
- quick_encoding_vals = fields.Binary(compute='_compute_quick_encoding_vals', exportable=False)
- # === Misc Information === #
- narration = fields.Html(
- string='Terms and Conditions',
- compute='_compute_narration', store=True, readonly=False,
- )
- is_move_sent = fields.Boolean(
- readonly=True,
- default=False,
- copy=False,
- tracking=True,
- help="It indicates that the invoice/payment has been sent.",
- )
- invoice_user_id = fields.Many2one(
- string='Salesperson',
- comodel_name='res.users',
- copy=False,
- tracking=True,
- default=lambda self: self.env.user,
- )
- # Technical field used to fit the generic behavior in mail templates.
- user_id = fields.Many2one(string='User', related='invoice_user_id')
- invoice_origin = fields.Char(
- string='Origin',
- readonly=True,
- tracking=True,
- help="The document(s) that generated the invoice.",
- )
- invoice_incoterm_id = fields.Many2one(
- comodel_name='account.incoterms',
- string='Incoterm',
- default=lambda self: self.env.company.incoterm_id,
- help='International Commercial Terms are a series of predefined commercial '
- 'terms used in international transactions.',
- )
- invoice_cash_rounding_id = fields.Many2one(
- comodel_name='account.cash.rounding',
- string='Cash Rounding Method',
- readonly=True,
- states={'draft': [('readonly', False)]},
- help='Defines the smallest coinage of the currency that can be used to pay by cash.',
- )
- # === Display purpose fields === #
- # used to have a dynamic domain on journal / taxes in the form view.
- invoice_filter_type_domain = fields.Char(compute='_compute_invoice_filter_type_domain')
- bank_partner_id = fields.Many2one(
- comodel_name='res.partner',
- compute='_compute_bank_partner_id',
- help='Technical field to get the domain on the bank',
- )
- # used to display a message when the invoice's accounting date is prior of the tax lock date
- tax_lock_date_message = fields.Char(compute='_compute_tax_lock_date_message')
- # used for tracking the status of the currency
- display_inactive_currency_warning = fields.Boolean(compute="_compute_display_inactive_currency_warning")
- tax_country_id = fields.Many2one( # used to filter the available taxes depending on the fiscal country and fiscal position.
- comodel_name='res.country',
- compute='_compute_tax_country_id',
- )
- tax_country_code = fields.Char(compute="_compute_tax_country_code")
- has_reconciled_entries = fields.Boolean(compute="_compute_has_reconciled_entries")
- show_reset_to_draft_button = fields.Boolean(compute='_compute_show_reset_to_draft_button')
- partner_credit_warning = fields.Text(
- compute='_compute_partner_credit_warning',
- groups="account.group_account_invoice,account.group_account_readonly",
- )
- duplicated_ref_ids = fields.Many2many(comodel_name='account.move', compute='_compute_duplicated_ref_ids')
- # used to display the various dates and amount dues on the invoice's PDF
- payment_term_details = fields.Binary(compute="_compute_payment_term_details", exportable=False)
- show_payment_term_details = fields.Boolean(compute="_compute_show_payment_term_details")
- show_discount_details = fields.Boolean(compute="_compute_show_payment_term_details")
- def _auto_init(self):
- super()._auto_init()
- self.env.cr.execute("""
- CREATE INDEX IF NOT EXISTS account_move_to_check_idx
- ON account_move(journal_id) WHERE to_check = true;
- CREATE INDEX IF NOT EXISTS account_move_payment_idx
- ON account_move(journal_id, state, payment_state, move_type, date);
- -- Used for gap detection in list views
- CREATE INDEX IF NOT EXISTS account_move_sequence_index3
- ON account_move (journal_id, sequence_prefix desc, (sequence_number+1) desc);
- """)
- # -------------------------------------------------------------------------
- # COMPUTE METHODS
- # -------------------------------------------------------------------------
- def _compute_payment_reference(self):
- for move in self.filtered(lambda m: (
- m.state == 'posted'
- and m.move_type == 'out_invoice'
- and not m.payment_reference
- )):
- move.payment_reference = move._get_invoice_computed_reference()
- self._inverse_payment_reference()
- @api.depends('invoice_date', 'company_id')
- def _compute_date(self):
- for move in self:
- if not move.invoice_date:
- if not move.date:
- move.date = fields.Date.context_today(self)
- continue
- accounting_date = move.invoice_date
- if not move.is_sale_document(include_receipts=True):
- accounting_date = move._get_accounting_date(move.invoice_date, move._affect_tax_report())
- if accounting_date and accounting_date != move.date:
- move.date = accounting_date
- # might be protected because `_get_accounting_date` requires the `name`
- self.env.add_to_compute(self._fields['name'], move)
- @api.depends('auto_post')
- def _compute_auto_post_until(self):
- for record in self:
- if record.auto_post in ('no', 'at_date'):
- record.auto_post_until = False
- @api.depends('date', 'auto_post')
- def _compute_hide_post_button(self):
- for record in self:
- record.hide_post_button = record.state != 'draft' \
- or record.auto_post != 'no' and record.date > fields.Date.today()
- @api.depends('journal_id')
- def _compute_company_id(self):
- for move in self:
- company_id = move.journal_id.company_id or self.env.company
- if company_id != move.company_id:
- move.company_id = company_id
- @api.depends('move_type')
- def _compute_journal_id(self):
- for record in self.filtered(lambda r: r.journal_id.type not in r._get_valid_journal_types()):
- record.journal_id = record._search_default_journal()
- def _get_valid_journal_types(self):
- if self.is_sale_document(include_receipts=True):
- return ['sale']
- elif self.is_purchase_document(include_receipts=True):
- return ['purchase']
- elif self.payment_id or self.env.context.get('is_payment'):
- return ['bank', 'cash']
- return ['general']
- def _search_default_journal(self):
- if self.payment_id and self.payment_id.journal_id:
- return self.payment_id.journal_id
- if self.statement_line_id and self.statement_line_id.journal_id:
- return self.statement_line_id.journal_id
- if self.statement_line_ids.statement_id.journal_id:
- return self.statement_line_ids.statement_id.journal_id[:1]
- journal_types = self._get_valid_journal_types()
- company_id = (self.company_id or self.env.company).id
- domain = [('company_id', '=', company_id), ('type', 'in', journal_types)]
- journal = None
- # the currency is not a hard dependence, it triggers via manual add_to_compute
- # avoid computing the currency before all it's dependences are set (like the journal...)
- if self.env.cache.contains(self, self._fields['currency_id']):
- currency_id = self.currency_id.id or self._context.get('default_currency_id')
- if currency_id and currency_id != self.company_id.currency_id.id:
- currency_domain = domain + [('currency_id', '=', currency_id)]
- journal = self.env['account.journal'].search(currency_domain, limit=1)
- if not journal:
- journal = self.env['account.journal'].search(domain, limit=1)
- if not journal:
- company = self.env['res.company'].browse(company_id)
- error_msg = _(
- "No journal could be found in company %(company_name)s for any of those types: %(journal_types)s",
- company_name=company.display_name,
- journal_types=', '.join(journal_types),
- )
- raise UserError(error_msg)
- return journal
- @api.depends('move_type')
- def _compute_is_storno(self):
- for move in self:
- move.is_storno = move.is_storno or (move.move_type in ('out_refund', 'in_refund') and move.company_id.account_storno)
- @api.depends('company_id', 'invoice_filter_type_domain')
- def _compute_suitable_journal_ids(self):
- for m in self:
- journal_type = m.invoice_filter_type_domain or 'general'
- company_id = m.company_id.id or self.env.company.id
- domain = [('company_id', '=', company_id), ('type', '=', journal_type)]
- m.suitable_journal_ids = self.env['account.journal'].search(domain)
- @api.depends('posted_before', 'state', 'journal_id', 'date')
- def _compute_name(self):
- self = self.sorted(lambda m: (m.date, m.ref or '', m.id))
- for move in self:
- move_has_name = move.name and move.name != '/'
- if move_has_name or move.state != 'posted':
- if not move.posted_before and not move._sequence_matches_date():
- if move._get_last_sequence(lock=False):
- # The name does not match the date and the move is not the first in the period:
- # Reset to draft
- move.name = False
- continue
- else:
- if move_has_name and move.posted_before or not move_has_name and move._get_last_sequence(lock=False):
- # The move either
- # - has a name and was posted before, or
- # - doesn't have a name, but is not the first in the period
- # so we don't recompute the name
- continue
- if move.date and (not move_has_name or not move._sequence_matches_date()):
- move._set_next_sequence()
- self.filtered(lambda m: not m.name and not move.quick_edit_mode).name = '/'
- self._inverse_name()
- @api.depends('journal_id', 'date')
- def _compute_highest_name(self):
- for record in self:
- record.highest_name = record._get_last_sequence(lock=False)
- @api.depends('name', 'journal_id')
- def _compute_made_sequence_hole(self):
- self.env.cr.execute("""
- SELECT this.id
- FROM account_move this
- JOIN res_company company ON company.id = this.company_id
- LEFT JOIN account_move other ON this.journal_id = other.journal_id
- AND this.sequence_prefix = other.sequence_prefix
- AND this.sequence_number = other.sequence_number + 1
- WHERE other.id IS NULL
- AND this.sequence_number != 1
- AND this.name != '/'
- AND this.id = ANY(%(move_ids)s)
- """, {
- 'move_ids': self.ids,
- })
- made_sequence_hole = set(r[0] for r in self.env.cr.fetchall())
- for move in self:
- move.made_sequence_hole = move.id in made_sequence_hole
- @api.depends('move_type')
- def _compute_type_name(self):
- type_name_mapping = dict(
- self._fields['move_type']._description_selection(self.env),
- out_invoice=_('Invoice'),
- out_refund=_('Credit Note'),
- )
- for record in self:
- record.type_name = type_name_mapping[record.move_type]
- @api.depends('line_ids.account_id.account_type')
- def _compute_always_tax_exigible(self):
- for record in self:
- # We need to check is_invoice as well because always_tax_exigible is used to
- # set the tags as well, during the encoding. So, if no receivable/payable
- # line has been created yet, the invoice would be detected as always exigible,
- # and set the tags on some lines ; which would be wrong.
- record.always_tax_exigible = not record.is_invoice(True) \
- and not record._collect_tax_cash_basis_values()
- @api.depends('partner_id')
- def _compute_commercial_partner_id(self):
- for move in self:
- move.commercial_partner_id = move.partner_id.commercial_partner_id
- @api.depends('partner_id')
- def _compute_partner_shipping_id(self):
- for move in self:
- if move.is_invoice(include_receipts=True):
- addr = move.partner_id.address_get(['delivery'])
- move.partner_shipping_id = addr and addr.get('delivery')
- else:
- move.partner_shipping_id = False
- @api.depends('partner_id', 'partner_shipping_id', 'company_id')
- def _compute_fiscal_position_id(self):
- for move in self:
- delivery_partner = self.env['res.partner'].browse(
- move.partner_shipping_id.id
- or move.partner_id.address_get(['delivery'])['delivery']
- )
- move.fiscal_position_id = self.env['account.fiscal.position'].with_company(move.company_id)._get_fiscal_position(
- move.partner_id, delivery=delivery_partner)
- @api.depends('bank_partner_id')
- def _compute_partner_bank_id(self):
- for move in self:
- bank_ids = move.bank_partner_id.bank_ids.filtered(
- lambda bank: not bank.company_id or bank.company_id == move.company_id)
- move.partner_bank_id = bank_ids[0] if bank_ids else False
- @api.depends('partner_id')
- def _compute_invoice_payment_term_id(self):
- for move in self:
- if move.is_sale_document(include_receipts=True) and move.partner_id.property_payment_term_id:
- move.invoice_payment_term_id = move.partner_id.property_payment_term_id
- elif move.is_purchase_document(include_receipts=True) and move.partner_id.property_supplier_payment_term_id:
- move.invoice_payment_term_id = move.partner_id.property_supplier_payment_term_id
- else:
- move.invoice_payment_term_id = False
- @api.depends('needed_terms')
- def _compute_invoice_date_due(self):
- today = fields.Date.context_today(self)
- for move in self:
- move.invoice_date_due = move.needed_terms and max(
- (k['date_maturity'] for k in move.needed_terms.keys() if k),
- default=False,
- ) or move.invoice_date_due or today
- @api.depends('journal_id', 'statement_line_id')
- def _compute_currency_id(self):
- for invoice in self:
- currency = (
- invoice.statement_line_id.foreign_currency_id
- or invoice.journal_id.currency_id
- or invoice.currency_id
- or invoice.journal_id.company_id.currency_id
- )
- invoice.currency_id = currency
- @api.depends('move_type')
- def _compute_direction_sign(self):
- for invoice in self:
- if invoice.move_type == 'entry' or invoice.is_outbound():
- invoice.direction_sign = 1
- else:
- invoice.direction_sign = -1
- @api.depends(
- 'line_ids.matched_debit_ids.debit_move_id.move_id.payment_id.is_matched',
- 'line_ids.matched_debit_ids.debit_move_id.move_id.line_ids.amount_residual',
- 'line_ids.matched_debit_ids.debit_move_id.move_id.line_ids.amount_residual_currency',
- 'line_ids.matched_credit_ids.credit_move_id.move_id.payment_id.is_matched',
- 'line_ids.matched_credit_ids.credit_move_id.move_id.line_ids.amount_residual',
- 'line_ids.matched_credit_ids.credit_move_id.move_id.line_ids.amount_residual_currency',
- 'line_ids.balance',
- 'line_ids.currency_id',
- 'line_ids.amount_currency',
- 'line_ids.amount_residual',
- 'line_ids.amount_residual_currency',
- 'line_ids.payment_id.state',
- 'line_ids.full_reconcile_id',
- 'state')
- def _compute_amount(self):
- for move in self:
- total_untaxed, total_untaxed_currency = 0.0, 0.0
- total_tax, total_tax_currency = 0.0, 0.0
- total_residual, total_residual_currency = 0.0, 0.0
- total, total_currency = 0.0, 0.0
- for line in move.line_ids:
- if move.is_invoice(True):
- # === Invoices ===
- if line.display_type == 'tax' or (line.display_type == 'rounding' and line.tax_repartition_line_id):
- # Tax amount.
- total_tax += line.balance
- total_tax_currency += line.amount_currency
- total += line.balance
- total_currency += line.amount_currency
- elif line.display_type in ('product', 'rounding'):
- # Untaxed amount.
- total_untaxed += line.balance
- total_untaxed_currency += line.amount_currency
- total += line.balance
- total_currency += line.amount_currency
- elif line.display_type == 'payment_term':
- # Residual amount.
- total_residual += line.amount_residual
- total_residual_currency += line.amount_residual_currency
- else:
- # === Miscellaneous journal entry ===
- if line.debit:
- total += line.balance
- total_currency += line.amount_currency
- sign = move.direction_sign
- move.amount_untaxed = sign * total_untaxed_currency
- move.amount_tax = sign * total_tax_currency
- move.amount_total = sign * total_currency
- move.amount_residual = -sign * total_residual_currency
- move.amount_untaxed_signed = -total_untaxed
- move.amount_tax_signed = -total_tax
- move.amount_total_signed = abs(total) if move.move_type == 'entry' else -total
- move.amount_residual_signed = total_residual
- move.amount_total_in_currency_signed = abs(move.amount_total) if move.move_type == 'entry' else -(sign * move.amount_total)
- @api.depends('amount_residual', 'move_type', 'state', 'company_id')
- def _compute_payment_state(self):
- stored_ids = tuple(self.ids)
- if stored_ids:
- self.env['account.partial.reconcile'].flush_model()
- self.env['account.payment'].flush_model(['is_matched'])
- queries = []
- for source_field, counterpart_field in (('debit', 'credit'), ('credit', 'debit')):
- queries.append(f'''
- SELECT
- source_line.id AS source_line_id,
- source_line.move_id AS source_move_id,
- account.account_type AS source_line_account_type,
- ARRAY_AGG(counterpart_move.move_type) AS counterpart_move_types,
- COALESCE(BOOL_AND(COALESCE(pay.is_matched, FALSE))
- FILTER (WHERE counterpart_move.payment_id IS NOT NULL), TRUE) AS all_payments_matched,
- BOOL_OR(COALESCE(BOOL(pay.id), FALSE)) as has_payment,
- BOOL_OR(COALESCE(BOOL(counterpart_move.statement_line_id), FALSE)) as has_st_line
- FROM account_partial_reconcile part
- JOIN account_move_line source_line ON source_line.id = part.{source_field}_move_id
- JOIN account_account account ON account.id = source_line.account_id
- JOIN account_move_line counterpart_line ON counterpart_line.id = part.{counterpart_field}_move_id
- JOIN account_move counterpart_move ON counterpart_move.id = counterpart_line.move_id
- LEFT JOIN account_payment pay ON pay.id = counterpart_move.payment_id
- WHERE source_line.move_id IN %s AND counterpart_line.move_id != source_line.move_id
- GROUP BY source_line_id, source_move_id, source_line_account_type
- ''')
- self._cr.execute(' UNION ALL '.join(queries), [stored_ids, stored_ids])
- payment_data = defaultdict(lambda: [])
- for row in self._cr.dictfetchall():
- payment_data[row['source_move_id']].append(row)
- else:
- payment_data = {}
- for invoice in self:
- if invoice.payment_state == 'invoicing_legacy':
- # invoicing_legacy state is set via SQL when setting setting field
- # invoicing_switch_threshold (defined in account_accountant).
- # The only way of going out of this state is through this setting,
- # so we don't recompute it here.
- continue
- currencies = invoice._get_lines_onchange_currency().currency_id
- currency = currencies if len(currencies) == 1 else invoice.company_id.currency_id
- reconciliation_vals = payment_data.get(invoice.id, [])
- payment_state_matters = invoice.is_invoice(True)
- # Restrict on 'receivable'/'payable' lines for invoices/expense entries.
- if payment_state_matters:
- reconciliation_vals = [x for x in reconciliation_vals if x['source_line_account_type'] in ('asset_receivable', 'liability_payable')]
- new_pmt_state = 'not_paid'
- if invoice.state == 'posted':
- # Posted invoice/expense entry.
- if payment_state_matters:
- if currency.is_zero(invoice.amount_residual):
- if any(x['has_payment'] or x['has_st_line'] for x in reconciliation_vals):
- # Check if the invoice/expense entry is fully paid or 'in_payment'.
- if all(x['all_payments_matched'] for x in reconciliation_vals):
- new_pmt_state = 'paid'
- else:
- new_pmt_state = invoice._get_invoice_in_payment_state()
- else:
- new_pmt_state = 'paid'
- reverse_move_types = set()
- for x in reconciliation_vals:
- for move_type in x['counterpart_move_types']:
- reverse_move_types.add(move_type)
- in_reverse = (invoice.move_type in ('in_invoice', 'in_receipt')
- and (reverse_move_types == {'in_refund'} or reverse_move_types == {'in_refund', 'entry'}))
- out_reverse = (invoice.move_type in ('out_invoice', 'out_receipt')
- and (reverse_move_types == {'out_refund'} or reverse_move_types == {'out_refund', 'entry'}))
- misc_reverse = (invoice.move_type in ('entry', 'out_refund', 'in_refund')
- and reverse_move_types == {'entry'})
- if in_reverse or out_reverse or misc_reverse:
- new_pmt_state = 'reversed'
- elif reconciliation_vals:
- new_pmt_state = 'partial'
- invoice.payment_state = new_pmt_state
- @api.depends('invoice_payment_term_id', 'invoice_date', 'currency_id', 'amount_total_in_currency_signed', 'invoice_date_due')
- def _compute_needed_terms(self):
- for invoice in self:
- is_draft = invoice.id != invoice._origin.id
- invoice.needed_terms = {}
- invoice.needed_terms_dirty = True
- sign = 1 if invoice.is_inbound(include_receipts=True) else -1
- if invoice.is_invoice(True) and invoice.invoice_line_ids:
- if invoice.invoice_payment_term_id:
- if is_draft:
- tax_amount_currency = 0.0
- untaxed_amount_currency = 0.0
- for line in invoice.invoice_line_ids:
- untaxed_amount_currency += line.price_subtotal
- for tax_result in (line.compute_all_tax or {}).values():
- tax_amount_currency += -sign * tax_result.get('amount_currency', 0.0)
- untaxed_amount = untaxed_amount_currency
- tax_amount = tax_amount_currency
- else:
- tax_amount_currency = invoice.amount_tax * sign
- tax_amount = invoice.amount_tax_signed
- untaxed_amount_currency = invoice.amount_untaxed * sign
- untaxed_amount = invoice.amount_untaxed_signed
- invoice_payment_terms = invoice.invoice_payment_term_id._compute_terms(
- date_ref=invoice.invoice_date or invoice.date or fields.Date.today(),
- currency=invoice.currency_id,
- tax_amount_currency=tax_amount_currency,
- tax_amount=tax_amount,
- untaxed_amount_currency=untaxed_amount_currency,
- untaxed_amount=untaxed_amount,
- company=invoice.company_id,
- sign=sign
- )
- for term in invoice_payment_terms:
- key = frozendict({
- 'move_id': invoice.id,
- 'date_maturity': fields.Date.to_date(term.get('date')),
- 'discount_date': term.get('discount_date'),
- 'discount_percentage': term.get('discount_percentage'),
- })
- values = {
- 'balance': term['company_amount'],
- 'amount_currency': term['foreign_amount'],
- 'discount_amount_currency': term['discount_amount_currency'] or 0.0,
- 'discount_balance': term['discount_balance'] or 0.0,
- 'discount_date': term['discount_date'],
- 'discount_percentage': term['discount_percentage'],
- }
- if key not in invoice.needed_terms:
- invoice.needed_terms[key] = values
- else:
- invoice.needed_terms[key]['balance'] += values['balance']
- invoice.needed_terms[key]['amount_currency'] += values['amount_currency']
- else:
- invoice.needed_terms[frozendict({
- 'move_id': invoice.id,
- 'date_maturity': fields.Date.to_date(invoice.invoice_date_due),
- 'discount_date': False,
- 'discount_percentage': 0
- })] = {
- 'balance': invoice.amount_total_signed,
- 'amount_currency': invoice.amount_total_in_currency_signed,
- }
- def _compute_payments_widget_to_reconcile_info(self):
- for move in self:
- move.invoice_outstanding_credits_debits_widget = False
- move.invoice_has_outstanding = False
- if move.state != 'posted' \
- or move.payment_state not in ('not_paid', 'partial') \
- or not move.is_invoice(include_receipts=True):
- continue
- pay_term_lines = move.line_ids\
- .filtered(lambda line: line.account_id.account_type in ('asset_receivable', 'liability_payable'))
- domain = [
- ('account_id', 'in', pay_term_lines.account_id.ids),
- ('parent_state', '=', 'posted'),
- ('partner_id', '=', move.commercial_partner_id.id),
- ('reconciled', '=', False),
- '|', ('amount_residual', '!=', 0.0), ('amount_residual_currency', '!=', 0.0),
- ]
- payments_widget_vals = {'outstanding': True, 'content': [], 'move_id': move.id}
- if move.is_inbound():
- domain.append(('balance', '<', 0.0))
- payments_widget_vals['title'] = _('Outstanding credits')
- else:
- domain.append(('balance', '>', 0.0))
- payments_widget_vals['title'] = _('Outstanding debits')
- for line in self.env['account.move.line'].search(domain):
- if line.currency_id == move.currency_id:
- # Same foreign currency.
- amount = abs(line.amount_residual_currency)
- else:
- # Different foreign currencies.
- amount = line.company_currency_id._convert(
- abs(line.amount_residual),
- move.currency_id,
- move.company_id,
- line.date,
- )
- if move.currency_id.is_zero(amount):
- continue
- payments_widget_vals['content'].append({
- 'journal_name': line.ref or line.move_id.name,
- 'amount': amount,
- 'currency_id': move.currency_id.id,
- 'id': line.id,
- 'move_id': line.move_id.id,
- 'date': fields.Date.to_string(line.date),
- 'account_payment_id': line.payment_id.id,
- })
- if not payments_widget_vals['content']:
- continue
- move.invoice_outstanding_credits_debits_widget = payments_widget_vals
- move.invoice_has_outstanding = True
- @api.depends('move_type', 'line_ids.amount_residual')
- def _compute_payments_widget_reconciled_info(self):
- for move in self:
- payments_widget_vals = {'title': _('Less Payment'), 'outstanding': False, 'content': []}
- if move.state == 'posted' and move.is_invoice(include_receipts=True):
- reconciled_vals = []
- reconciled_partials = move._get_all_reconciled_invoice_partials()
- for reconciled_partial in reconciled_partials:
- counterpart_line = reconciled_partial['aml']
- if counterpart_line.move_id.ref:
- reconciliation_ref = '%s (%s)' % (counterpart_line.move_id.name, counterpart_line.move_id.ref)
- else:
- reconciliation_ref = counterpart_line.move_id.name
- if counterpart_line.amount_currency and counterpart_line.currency_id != counterpart_line.company_id.currency_id:
- foreign_currency = counterpart_line.currency_id
- else:
- foreign_currency = False
- reconciled_vals.append({
- 'name': counterpart_line.name,
- 'journal_name': counterpart_line.journal_id.name,
- 'amount': reconciled_partial['amount'],
- 'currency_id': move.company_id.currency_id.id if reconciled_partial['is_exchange'] else reconciled_partial['currency'].id,
- 'date': counterpart_line.date,
- 'partial_id': reconciled_partial['partial_id'],
- 'account_payment_id': counterpart_line.payment_id.id,
- 'payment_method_name': counterpart_line.payment_id.payment_method_line_id.name,
- 'move_id': counterpart_line.move_id.id,
- 'ref': reconciliation_ref,
- # these are necessary for the views to change depending on the values
- 'is_exchange': reconciled_partial['is_exchange'],
- 'amount_company_currency': formatLang(self.env, abs(counterpart_line.balance), currency_obj=counterpart_line.company_id.currency_id),
- 'amount_foreign_currency': foreign_currency and formatLang(self.env, abs(counterpart_line.amount_currency), currency_obj=foreign_currency)
- })
- payments_widget_vals['content'] = reconciled_vals
- if payments_widget_vals['content']:
- move.invoice_payments_widget = payments_widget_vals
- else:
- move.invoice_payments_widget = False
- @api.depends(
- 'invoice_line_ids.currency_rate',
- 'invoice_line_ids.tax_base_amount',
- 'invoice_line_ids.tax_line_id',
- 'invoice_line_ids.price_total',
- 'invoice_line_ids.price_subtotal',
- 'invoice_payment_term_id',
- 'partner_id',
- 'currency_id',
- )
- def _compute_tax_totals(self):
- """ Computed field used for custom widget's rendering.
- Only set on invoices.
- """
- for move in self:
- if move.is_invoice(include_receipts=True):
- base_lines = move.invoice_line_ids.filtered(lambda line: line.display_type == 'product')
- base_line_values_list = [line._convert_to_tax_base_line_dict() for line in base_lines]
- sign = move.direction_sign
- if move.id:
- # The invoice is stored so we can add the early payment discount lines directly to reduce the
- # tax amount without touching the untaxed amount.
- base_line_values_list += [
- {
- **line._convert_to_tax_base_line_dict(),
- 'handle_price_include': False,
- 'quantity': 1.0,
- 'price_unit': sign * line.amount_currency,
- }
- for line in move.line_ids.filtered(lambda line: line.display_type == 'epd')
- ]
- kwargs = {
- 'base_lines': base_line_values_list,
- 'currency': move.currency_id or move.journal_id.currency_id or move.company_id.currency_id,
- }
- if move.id:
- kwargs['tax_lines'] = [
- line._convert_to_tax_line_dict()
- for line in move.line_ids.filtered(lambda line: line.display_type == 'tax')
- ]
- else:
- # In case the invoice isn't yet stored, the early payment discount lines are not there. Then,
- # we need to simulate them.
- epd_aggregated_values = {}
- for base_line in base_lines:
- if not base_line.epd_needed:
- continue
- for grouping_dict, values in base_line.epd_needed.items():
- epd_values = epd_aggregated_values.setdefault(grouping_dict, {'price_subtotal': 0.0})
- epd_values['price_subtotal'] += values['price_subtotal']
- for grouping_dict, values in epd_aggregated_values.items():
- taxes = None
- if grouping_dict.get('tax_ids'):
- taxes = self.env['account.tax'].browse(grouping_dict['tax_ids'][0][2])
- kwargs['base_lines'].append(self.env['account.tax']._convert_to_tax_base_line_dict(
- None,
- partner=move.partner_id,
- currency=move.currency_id,
- taxes=taxes,
- price_unit=values['price_subtotal'],
- quantity=1.0,
- account=self.env['account.account'].browse(grouping_dict['account_id']),
- analytic_distribution=values.get('analytic_distribution'),
- price_subtotal=values['price_subtotal'],
- is_refund=move.move_type in ('out_refund', 'in_refund'),
- handle_price_include=False,
- ))
- move.tax_totals = self.env['account.tax']._prepare_tax_totals(**kwargs)
- if move.invoice_cash_rounding_id:
- rounding_amount = move.invoice_cash_rounding_id.compute_difference(move.currency_id, move.tax_totals['amount_total'])
- totals = move.tax_totals
- totals['display_rounding'] = True
- if rounding_amount:
- if move.invoice_cash_rounding_id.strategy == 'add_invoice_line':
- totals['rounding_amount'] = rounding_amount
- totals['formatted_rounding_amount'] = formatLang(self.env, totals['rounding_amount'], currency_obj=move.currency_id)
- totals['amount_total_rounded'] = totals['amount_total'] + rounding_amount
- totals['formatted_amount_total_rounded'] = formatLang(self.env, totals['amount_total_rounded'], currency_obj=move.currency_id)
- elif move.invoice_cash_rounding_id.strategy == 'biggest_tax':
- if totals['subtotals_order']:
- max_tax_group = max((
- tax_group
- for tax_groups in totals['groups_by_subtotal'].values()
- for tax_group in tax_groups
- ), key=lambda tax_group: tax_group['tax_group_amount'])
- max_tax_group['tax_group_amount'] += rounding_amount
- max_tax_group['formatted_tax_group_amount'] = formatLang(self.env, max_tax_group['tax_group_amount'], currency_obj=move.currency_id)
- totals['amount_total'] += rounding_amount
- totals['formatted_amount_total'] = formatLang(self.env, totals['amount_total'], currency_obj=move.currency_id)
- else:
- # Non-invoice moves don't support that field (because of multicurrency: all lines of the invoice share the same currency)
- move.tax_totals = None
- @api.depends('show_payment_term_details')
- def _compute_payment_term_details(self):
- '''
- Returns an [] containing the payment term's information to be displayed on the invoice's PDF.
- '''
- for invoice in self:
- invoice.payment_term_details = False
- if invoice.show_payment_term_details:
- sign = 1 if invoice.is_inbound(include_receipts=True) else -1
- payment_term_details = []
- for line in invoice.line_ids.filtered(lambda l: l.display_type == 'payment_term').sorted('date_maturity'):
- payment_term_details.append({
- 'date': format_date(self.env, line.date_maturity),
- 'amount': sign * line.amount_currency,
- 'discount_date': format_date(self.env, line.discount_date),
- 'discount_amount_currency': sign * line.discount_amount_currency,
- })
- invoice.payment_term_details = payment_term_details
- @api.depends('move_type', 'payment_state', 'invoice_payment_term_id')
- def _compute_show_payment_term_details(self):
- '''
- Determines :
- - whether or not an additional table should be added at the end of the invoice to display the various
- - whether or not there is an early pay discount in this invoice that should be displayed
- '''
- for invoice in self:
- if invoice.move_type in ('out_invoice', 'out_receipt', 'in_invoice', 'in_receipt') and invoice.payment_state in ('not_paid', 'partial'):
- payment_term_lines = invoice.line_ids.filtered(lambda l: l.display_type == 'payment_term')
- invoice.show_discount_details = any(line.discount_date for line in payment_term_lines)
- invoice.show_payment_term_details = len(payment_term_lines) > 1 or invoice.show_discount_details
- else:
- invoice.show_discount_details = False
- invoice.show_payment_term_details = False
- @api.depends('partner_id', 'invoice_source_email', 'partner_id.name')
- def _compute_invoice_partner_display_info(self):
- for move in self:
- vendor_display_name = move.partner_id.display_name
- if not vendor_display_name:
- if move.invoice_source_email:
- vendor_display_name = _('@From: %(email)s', email=move.invoice_source_email)
- else:
- vendor_display_name = _('#Created by: %s', move.sudo().create_uid.name or self.env.user.name)
- move.invoice_partner_display_name = vendor_display_name
- @api.depends('move_type')
- def _compute_invoice_filter_type_domain(self):
- for move in self:
- if move.is_sale_document(include_receipts=True):
- move.invoice_filter_type_domain = 'sale'
- elif move.is_purchase_document(include_receipts=True):
- move.invoice_filter_type_domain = 'purchase'
- else:
- move.invoice_filter_type_domain = False
- @api.depends('commercial_partner_id')
- def _compute_bank_partner_id(self):
- for move in self:
- if move.is_inbound():
- move.bank_partner_id = move.company_id.partner_id
- else:
- move.bank_partner_id = move.commercial_partner_id
- @api.depends('date', 'line_ids.debit', 'line_ids.credit', 'line_ids.tax_line_id', 'line_ids.tax_ids', 'line_ids.tax_tag_ids',
- 'invoice_line_ids.debit', 'invoice_line_ids.credit', 'invoice_line_ids.tax_line_id', 'invoice_line_ids.tax_ids', 'invoice_line_ids.tax_tag_ids')
- def _compute_tax_lock_date_message(self):
- for move in self:
- accounting_date = move.date or fields.Date.context_today(move)
- affects_tax_report = move._affect_tax_report()
- move.tax_lock_date_message = move._get_lock_date_message(accounting_date, affects_tax_report)
- @api.depends('currency_id')
- def _compute_display_inactive_currency_warning(self):
- for move in self.with_context(active_test=False):
- move.display_inactive_currency_warning = move.currency_id and not move.currency_id.active
- @api.depends('company_id.account_fiscal_country_id', 'fiscal_position_id', 'fiscal_position_id.country_id', 'fiscal_position_id.foreign_vat')
- def _compute_tax_country_id(self):
- for record in self:
- if record.fiscal_position_id.foreign_vat:
- record.tax_country_id = record.fiscal_position_id.country_id
- else:
- record.tax_country_id = record.company_id.account_fiscal_country_id
- @api.depends('tax_country_id')
- def _compute_tax_country_code(self):
- for record in self:
- record.tax_country_code = record.tax_country_id.code
- @api.depends('line_ids')
- def _compute_has_reconciled_entries(self):
- for move in self:
- move.has_reconciled_entries = len(move.line_ids._reconciled_lines()) > 1
- @api.depends('restrict_mode_hash_table', 'state')
- def _compute_show_reset_to_draft_button(self):
- for move in self:
- move.show_reset_to_draft_button = not move.restrict_mode_hash_table and move.state in ('posted', 'cancel')
- # EXTENDS portal portal.mixin
- def _compute_access_url(self):
- super()._compute_access_url()
- for move in self.filtered(lambda move: move.is_invoice()):
- move.access_url = '/my/invoices/%s' % (move.id)
- @api.depends('move_type', 'partner_id', 'company_id')
- def _compute_narration(self):
- use_invoice_terms = self.env['ir.config_parameter'].sudo().get_param('account.use_invoice_terms')
- for move in self:
- if not move.is_sale_document(include_receipts=True):
- continue
- if not use_invoice_terms:
- move.narration = False
- else:
- lang = move.partner_id.lang or self.env.user.lang
- if not move.company_id.terms_type == 'html':
- narration = move.company_id.with_context(lang=lang).invoice_terms if not is_html_empty(move.company_id.invoice_terms) else ''
- else:
- baseurl = self.env.company.get_base_url() + '/terms'
- context = {'lang': lang}
- narration = _('Terms & Conditions: %s', baseurl)
- del context
- move.narration = narration or False
- @api.depends('company_id', 'partner_id', 'tax_totals', 'currency_id')
- def _compute_partner_credit_warning(self):
- for move in self:
- move.with_company(move.company_id)
- move.partner_credit_warning = ''
- show_warning = move.state == 'draft' and \
- move.move_type == 'out_invoice' and \
- move.company_id.account_use_credit_limit
- if show_warning:
- amount_total_currency = move.currency_id._convert(move.tax_totals['amount_total'], move.company_currency_id, move.company_id, move.date)
- updated_credit = move.partner_id.commercial_partner_id.credit + amount_total_currency
- move.partner_credit_warning = self._build_credit_warning_message(move, updated_credit)
- def _build_credit_warning_message(self, record, updated_credit):
- ''' Build the warning message that will be displayed in a yellow banner on top of the current record
- if the partner exceeds a credit limit (set on the company or the partner itself).
- :param record: The record where the warning will appear (Invoice, Sales Order...).
- :param updated_credit (float): The partner's updated credit limit including the current record.
- :return (str): The warning message to be showed.
- '''
- partner_id = record.partner_id.commercial_partner_id
- if not partner_id.credit_limit or updated_credit <= partner_id.credit_limit:
- return ''
- msg = _('%s has reached its Credit Limit of : %s\nTotal amount due ',
- partner_id.name,
- formatLang(self.env, partner_id.credit_limit, currency_obj=record.company_id.currency_id))
- if updated_credit > partner_id.credit:
- msg += _('(including this document) ')
- msg += ': %s' % formatLang(self.env, updated_credit, currency_obj=record.company_id.currency_id)
- return msg
- @api.depends('journal_id.type', 'company_id')
- def _compute_quick_edit_mode(self):
- for move in self:
- quick_edit_mode = move.company_id.quick_edit_mode
- if move.journal_id.type == 'sale':
- move.quick_edit_mode = quick_edit_mode in ('out_invoices', 'out_and_in_invoices')
- elif move.journal_id.type == 'purchase':
- move.quick_edit_mode = quick_edit_mode in ('in_invoices', 'out_and_in_invoices')
- else:
- move.quick_edit_mode = False
- @api.depends('quick_edit_total_amount', 'invoice_line_ids.price_total', 'tax_totals')
- def _compute_quick_encoding_vals(self):
- for move in self:
- move.quick_encoding_vals = move._get_quick_edit_suggestions()
- @api.depends('ref', 'move_type', 'partner_id', 'invoice_date')
- def _compute_duplicated_ref_ids(self):
- move_to_duplicate_move = self._fetch_duplicate_supplier_reference()
- for move in self:
- move.duplicated_ref_ids = move_to_duplicate_move.get(move, self.env['account.move'])
- def _fetch_duplicate_supplier_reference(self, only_posted=False):
- moves = self.filtered(lambda m: m.is_purchase_document() and m.ref)
- if not moves:
- return {}
- used_fields = ("company_id", "partner_id", "commercial_partner_id", "ref", "move_type", "invoice_date", "state")
- self.env["account.move"].flush_model(used_fields)
- move_table_and_alias = "account_move AS move"
- place_holders = {}
- if not moves.ids:
- # This handles the special case of a record creation in the UI which isn't searchable in the DB
- place_holders = {
- "id": 0,
- **{
- field_name: moves._fields[field_name].convert_to_write(moves[field_name], moves) or None
- for field_name in used_fields
- },
- }
- casted_values = ", ".join([f"%({field_name})s::{moves._fields[field_name].column_type[0]}" for field_name in place_holders])
- move_table_and_alias = f'(VALUES ({casted_values})) AS move({", ".join(place_holders)})'
- self.env.cr.execute(f"""
- SELECT
- move.id AS move_id,
- array_agg(duplicate_move.id) AS duplicate_ids
- FROM {move_table_and_alias}
- JOIN account_move AS duplicate_move ON
- move.company_id = duplicate_move.company_id
- AND move.commercial_partner_id = duplicate_move.commercial_partner_id
- AND move.ref = duplicate_move.ref
- AND move.move_type = duplicate_move.move_type
- AND move.id != duplicate_move.id
- AND (move.invoice_date = duplicate_move.invoice_date OR NOT %(only_posted)s)
- AND duplicate_move.state != 'cancel'
- AND (duplicate_move.state = 'posted' OR NOT %(only_posted)s)
- WHERE move.id IN %(moves)s
- GROUP BY move.id
- """, {
- "only_posted": only_posted,
- "moves": tuple(moves.ids or [0]),
- **place_holders
- })
- return {
- self.env['account.move'].browse(res['move_id']): self.env['account.move'].browse(res['duplicate_ids'])
- for res in self.env.cr.dictfetchall()
- }
- @api.depends('company_id')
- def _compute_display_qr_code(self):
- for record in self:
- record.display_qr_code = (
- record.move_type in ('out_invoice', 'out_receipt', 'in_invoice', 'in_receipt')
- and record.company_id.qr_code
- )
- # -------------------------------------------------------------------------
- # INVERSE METHODS
- # -------------------------------------------------------------------------
- def _inverse_tax_totals(self):
- if self.env.context.get('skip_invoice_sync'):
- return
- with self._sync_dynamic_line(
- existing_key_fname='term_key',
- needed_vals_fname='needed_terms',
- needed_dirty_fname='needed_terms_dirty',
- line_type='payment_term',
- container={'records': self},
- ):
- for move in self:
- if not move.is_invoice(include_receipts=True):
- continue
- invoice_totals = move.tax_totals
- for amount_by_group_list in invoice_totals['groups_by_subtotal'].values():
- for amount_by_group in amount_by_group_list:
- tax_lines = move.line_ids.filtered(lambda line: line.tax_group_id.id == amount_by_group['tax_group_id'])
- if tax_lines:
- first_tax_line = tax_lines[0]
- tax_group_old_amount = sum(tax_lines.mapped('amount_currency'))
- sign = -1 if move.is_inbound() else 1
- delta_amount = tax_group_old_amount * sign - amount_by_group['tax_group_amount']
- if not move.currency_id.is_zero(delta_amount):
- first_tax_line.amount_currency -= delta_amount * sign
- self._compute_amount()
- def _inverse_amount_total(self):
- for move in self:
- if len(move.line_ids) != 2 or move.is_invoice(include_receipts=True):
- continue
- to_write = []
- amount_currency = abs(move.amount_total)
- balance = move.currency_id._convert(amount_currency, move.company_currency_id, move.company_id, move.invoice_date or move.date)
- for line in move.line_ids:
- if not line.currency_id.is_zero(balance - abs(line.balance)):
- to_write.append((1, line.id, {
- 'debit': line.balance > 0.0 and balance or 0.0,
- 'credit': line.balance < 0.0 and balance or 0.0,
- 'amount_currency': line.balance > 0.0 and amount_currency or -amount_currency,
- }))
- move.write({'line_ids': to_write})
- @api.onchange('partner_id')
- def _inverse_partner_id(self):
- for invoice in self:
- if invoice.is_invoice(True):
- for line in invoice.line_ids + invoice.invoice_line_ids:
- if line.partner_id != invoice.commercial_partner_id:
- line.partner_id = invoice.commercial_partner_id
- line._inverse_partner_id()
- @api.onchange('company_id')
- def _inverse_company_id(self):
- self._conditional_add_to_compute('journal_id', lambda m: (
- m.journal_id.company_id != m.company_id
- ))
- @api.onchange('currency_id')
- def _inverse_currency_id(self):
- self._conditional_add_to_compute('journal_id', lambda m: (
- m.journal_id.currency_id
- and m.journal_id.currency_id != m.currency_id
- ))
- (self.line_ids | self.invoice_line_ids)._conditional_add_to_compute('currency_id', lambda l: (
- l.move_id.is_invoice(True)
- and l.move_id.currency_id != l.currency_id
- ))
- @api.onchange('journal_id')
- def _inverse_journal_id(self):
- self._conditional_add_to_compute('company_id', lambda m: (
- not m.company_id
- or m.company_id != m.journal_id.company_id
- ))
- self._conditional_add_to_compute('currency_id', lambda m: (
- not m.currency_id
- or m.journal_id.currency_id and m.currency_id != m.journal_id.currency_id
- ))
- def _inverse_payment_reference(self):
- self.line_ids._conditional_add_to_compute('name', lambda line: (
- line.display_type == 'payment_term'
- ))
- def _inverse_name(self):
- self._conditional_add_to_compute('payment_reference', lambda move: (
- move.name and move.name != '/'
- ))
- # -------------------------------------------------------------------------
- # ONCHANGE METHODS
- # -------------------------------------------------------------------------
- @api.onchange('date')
- def _onchange_date(self):
- if not self.is_invoice(True):
- self.line_ids._inverse_amount_currency()
- @api.onchange('invoice_vendor_bill_id')
- def _onchange_invoice_vendor_bill(self):
- if self.invoice_vendor_bill_id:
- # Copy invoice lines.
- for line in self.invoice_vendor_bill_id.invoice_line_ids:
- copied_vals = line.copy_data()[0]
- self.invoice_line_ids += self.env['account.move.line'].new(copied_vals)
- self.currency_id = self.invoice_vendor_bill_id.currency_id
- self.fiscal_position_id = self.invoice_vendor_bill_id.fiscal_position_id
- # Reset
- self.invoice_vendor_bill_id = False
- @api.onchange('partner_id')
- def _onchange_partner_id(self):
- self = self.with_company(self.journal_id.company_id)
- warning = {}
- if self.partner_id:
- rec_account = self.partner_id.property_account_receivable_id
- pay_account = self.partner_id.property_account_payable_id
- if not rec_account and not pay_account:
- action = self.env.ref('account.action_account_config')
- msg = _('Cannot find a chart of accounts for this company, You should configure it. \nPlease go to Account Configuration.')
- raise RedirectWarning(msg, action.id, _('Go to the configuration panel'))
- p = self.partner_id
- if p.invoice_warn == 'no-message' and p.parent_id:
- p = p.parent_id
- if p.invoice_warn and p.invoice_warn != 'no-message':
- # Block if partner only has warning but parent company is blocked
- if p.invoice_warn != 'block' and p.parent_id and p.parent_id.invoice_warn == 'block':
- p = p.parent_id
- warning = {
- 'title': _("Warning for %s", p.name),
- 'message': p.invoice_warn_msg
- }
- if p.invoice_warn == 'block':
- self.partner_id = False
- return {'warning': warning}
- @api.onchange('name', 'highest_name')
- def _onchange_name_warning(self):
- if self.name and self.name != '/' and self.name <= (self.highest_name or '') and not self.quick_edit_mode:
- self.show_name_warning = True
- else:
- self.show_name_warning = False
- origin_name = self._origin.name
- if not origin_name or origin_name == '/':
- origin_name = self.highest_name
- if (
- self.name and self.name != '/'
- and origin_name and origin_name != '/'
- and self.date == self._origin.date
- and self.journal_id == self._origin.journal_id
- ):
- new_format, new_format_values = self._get_sequence_format_param(self.name)
- origin_format, origin_format_values = self._get_sequence_format_param(origin_name)
- if (
- new_format != origin_format
- or dict(new_format_values, year=0, month=0, seq=0) != dict(origin_format_values, year=0, month=0, seq=0)
- ):
- changed = _(
- "It was previously '%(previous)s' and it is now '%(current)s'.",
- previous=origin_name,
- current=self.name,
- )
- reset = self._deduce_sequence_number_reset(self.name)
- if reset == 'month':
- detected = _(
- "The sequence will restart at 1 at the start of every month.\n"
- "The year detected here is '%(year)s' and the month is '%(month)s'.\n"
- "The incrementing number in this case is '%(formatted_seq)s'."
- )
- elif reset == 'year':
- detected = _(
- "The sequence will restart at 1 at the start of every year.\n"
- "The year detected here is '%(year)s'.\n"
- "The incrementing number in this case is '%(formatted_seq)s'."
- )
- else:
- detected = _(
- "The sequence will never restart.\n"
- "The incrementing number in this case is '%(formatted_seq)s'."
- )
- new_format_values['formatted_seq'] = "{seq:0{seq_length}d}".format(**new_format_values)
- detected = detected % new_format_values
- return {'warning': {
- 'title': _("The sequence format has changed."),
- 'message': "%s\n\n%s" % (changed, detected)
- }}
- @api.onchange('journal_id')
- def _onchange_journal_id(self):
- if not self.quick_edit_mode:
- self.name = '/'
- self._compute_name()
- @api.onchange('invoice_cash_rounding_id')
- def _onchange_invoice_cash_rounding_id(self):
- for move in self:
- if move.invoice_cash_rounding_id.strategy == 'add_invoice_line' and not move.invoice_cash_rounding_id.profit_account_id:
- return {'warning': {
- 'title': _("Warning for Cash Rounding Method: %s", move.invoice_cash_rounding_id.name),
- 'message': _("You must specify the Profit Account (company dependent)")
- }}
- # -------------------------------------------------------------------------
- # CONSTRAINT METHODS
- # -------------------------------------------------------------------------
- @api.constrains('name', 'journal_id', 'state')
- def _check_unique_sequence_number(self):
- moves = self.filtered(lambda move: move.state == 'posted')
- if not moves:
- return
- self.flush_model(['name', 'journal_id', 'move_type', 'state'])
- # /!\ Computed stored fields are not yet inside the database.
- self._cr.execute('''
- SELECT move2.id, move2.name
- FROM account_move move
- INNER JOIN account_move move2 ON
- move2.name = move.name
- AND move2.journal_id = move.journal_id
- AND move2.move_type = move.move_type
- AND move2.id != move.id
- WHERE move.id IN %s AND move2.state = 'posted'
- ''', [tuple(moves.ids)])
- res = self._cr.fetchall()
- if res:
- raise ValidationError(_('Posted journal entry must have an unique sequence number per company.\n'
- 'Problematic numbers: %s\n') % ', '.join(r[1] for r in res))
- @contextmanager
- def _check_balanced(self, container):
- ''' Assert the move is fully balanced debit = credit.
- An error is raised if it's not the case.
- '''
- with self._disable_recursion(container, 'check_move_validity', default=True, target=False) as disabled:
- yield
- if disabled:
- return
- unbalanced_moves = self._get_unbalanced_moves(container)
- if unbalanced_moves:
- error_msg = _("An error has occurred.")
- for move_id, sum_debit, sum_credit in unbalanced_moves:
- move = self.browse(move_id)
- error_msg += _(
- "\n\n"
- "The move (%s) is not balanced.\n"
- "The total of debits equals %s and the total of credits equals %s.\n"
- "You might want to specify a default account on journal \"%s\" to automatically balance each move.",
- move.display_name,
- format_amount(self.env, sum_debit, move.company_id.currency_id),
- format_amount(self.env, sum_credit, move.company_id.currency_id),
- move.journal_id.name)
- raise UserError(error_msg)
- def _get_unbalanced_moves(self, container):
- moves = container['records'].filtered(lambda move: move.line_ids)
- if not moves:
- return
- # /!\ As this method is called in create / write, we can't make the assumption the computed stored fields
- # are already done. Then, this query MUST NOT depend on computed stored fields.
- # It happens as the ORM calls create() with the 'no_recompute' statement.
- self.env['account.move.line'].flush_model(['debit', 'credit', 'balance', 'currency_id', 'move_id'])
- self._cr.execute('''
- SELECT line.move_id,
- ROUND(SUM(line.debit), currency.decimal_places) debit,
- ROUND(SUM(line.credit), currency.decimal_places) credit
- FROM account_move_line line
- JOIN account_move move ON move.id = line.move_id
- JOIN res_company company ON company.id = move.company_id
- JOIN res_currency currency ON currency.id = company.currency_id
- WHERE line.move_id IN %s
- GROUP BY line.move_id, currency.decimal_places
- HAVING ROUND(SUM(line.balance), currency.decimal_places) != 0
- ''', [tuple(moves.ids)])
- return self._cr.fetchall()
- def _check_fiscalyear_lock_date(self):
- for move in self:
- lock_date = move.company_id._get_user_fiscal_lock_date()
- if move.date <= lock_date:
- if self.user_has_groups('account.group_account_manager'):
- message = _("You cannot add/modify entries prior to and inclusive of the lock date %s.", format_date(self.env, lock_date))
- else:
- message = _("You cannot add/modify entries prior to and inclusive of the lock date %s. Check the company settings or ask someone with the 'Adviser' role", format_date(self.env, lock_date))
- raise UserError(message)
- return True
- @api.constrains('auto_post', 'invoice_date')
- def _require_bill_date_for_autopost(self):
- """Vendor bills must have an invoice date set to be posted. Require it for auto-posted bills."""
- for record in self:
- if record.auto_post != 'no' and record.is_purchase_document() and not record.invoice_date:
- raise ValidationError(_("For this entry to be automatically posted, it required a bill date."))
- @api.constrains('journal_id', 'move_type')
- def _check_journal_move_type(self):
- for move in self:
- if move.is_purchase_document(include_receipts=True) and move.journal_id.type != 'purchase':
- raise ValidationError(_("Cannot create a purchase document in a non purchase journal"))
- if move.is_sale_document(include_receipts=True) and move.journal_id.type != 'sale':
- raise ValidationError(_("Cannot create a sale document in a non sale journal"))
- @api.constrains('ref', 'move_type', 'partner_id', 'journal_id', 'invoice_date', 'state')
- def _check_duplicate_supplier_reference(self):
- """ Assert the move which is about to be posted isn't a duplicated move from another posted entry"""
- move_to_duplicate_moves = self.filtered(lambda m: m.state == 'posted')._fetch_duplicate_supplier_reference(only_posted=True)
- if any(duplicate_move for duplicate_move in move_to_duplicate_moves.values()):
- duplicate_move_ids = list(set(
- move_id
- for move_ids in (move.ids + duplicate.ids for move, duplicate in move_to_duplicate_moves.items() if duplicate)
- for move_id in move_ids
- ))
- action = self.env['ir.actions.actions']._for_xml_id('account.action_move_line_form')
- action['domain'] = [('id', 'in', duplicate_move_ids)]
- action['views'] = [((view_id, 'list') if view_type == 'tree' else (view_id, view_type)) for view_id, view_type in action['views']]
- raise RedirectWarning(
- message=_("Duplicated vendor reference detected. You probably encoded twice the same vendor bill/credit note."),
- action=action,
- button_text=_("Open list"),
- )
- @api.constrains('line_ids', 'fiscal_position_id', 'company_id')
- def _validate_taxes_country(self):
- """ By playing with the fiscal position in the form view, it is possible to keep taxes on the invoices from
- a different country than the one allowed by the fiscal country or the fiscal position.
- This contrains ensure such account.move cannot be kept, as they could generate inconsistencies in the reports.
- """
- self._compute_tax_country_id() # We need to ensure this field has been computed, as we use it in our check
- for record in self:
- amls = record.line_ids
- impacted_countries = amls.tax_ids.country_id | amls.tax_line_id.country_id
- if impacted_countries and impacted_countries != record.tax_country_id:
- if record.fiscal_position_id and impacted_countries != record.fiscal_position_id.country_id:
- raise ValidationError(_("This entry contains taxes that are not compatible with your fiscal position. Check the country set in fiscal position and in your tax configuration."))
- raise ValidationError(_("This entry contains one or more taxes that are incompatible with your fiscal country. Check company fiscal country in the settings and tax country in taxes configuration."))
- # -------------------------------------------------------------------------
- # BUSINESS MODELS SYNCHRONIZATION
- # -------------------------------------------------------------------------
- def _synchronize_business_models(self, changed_fields):
- ''' Ensure the consistency between:
- account.payment & account.move
- account.bank.statement.line & account.move
- The idea is to call the method performing the synchronization of the business
- models regarding their related journal entries. To avoid cycling, the
- 'skip_account_move_synchronization' key is used through the context.
- :param changed_fields: A set containing all modified fields on account.move.
- '''
- if self._context.get('skip_account_move_synchronization'):
- return
- self_sudo = self.sudo()
- self_sudo.payment_id._synchronize_from_moves(changed_fields)
- self_sudo.statement_line_id._synchronize_from_moves(changed_fields)
- # -------------------------------------------------------------------------
- # DYNAMIC LINES
- # -------------------------------------------------------------------------
- def _recompute_cash_rounding_lines(self):
- ''' Handle the cash rounding feature on invoices.
- In some countries, the smallest coins do not exist. For example, in Switzerland, there is no coin for 0.01 CHF.
- For this reason, if invoices are paid in cash, you have to round their total amount to the smallest coin that
- exists in the currency. For the CHF, the smallest coin is 0.05 CHF.
- There are two strategies for the rounding:
- 1) Add a line on the invoice for the rounding: The cash rounding line is added as a new invoice line.
- 2) Add the rounding in the biggest tax amount: The cash rounding line is added as a new tax line on the tax
- having the biggest balance.
- '''
- self.ensure_one()
- def _compute_cash_rounding(self, total_amount_currency):
- ''' Compute the amount differences due to the cash rounding.
- :param self: The current account.move record.
- :param total_amount_currency: The invoice's total in invoice's currency.
- :return: The amount differences both in company's currency & invoice's currency.
- '''
- difference = self.invoice_cash_rounding_id.compute_difference(self.currency_id, total_amount_currency)
- if self.currency_id == self.company_id.currency_id:
- diff_amount_currency = diff_balance = difference
- else:
- diff_amount_currency = difference
- diff_balance = self.currency_id._convert(diff_amount_currency, self.company_id.currency_id, self.company_id, self.invoice_date or self.date)
- return diff_balance, diff_amount_currency
- def _apply_cash_rounding(self, diff_balance, diff_amount_currency, cash_rounding_line):
- ''' Apply the cash rounding.
- :param self: The current account.move record.
- :param diff_balance: The computed balance to set on the new rounding line.
- :param diff_amount_currency: The computed amount in invoice's currency to set on the new rounding line.
- :param cash_rounding_line: The existing cash rounding line.
- :return: The newly created rounding line.
- '''
- rounding_line_vals = {
- 'balance': diff_balance,
- 'amount_currency': diff_amount_currency,
- 'partner_id': self.partner_id.id,
- 'move_id': self.id,
- 'currency_id': self.currency_id.id,
- 'company_id': self.company_id.id,
- 'company_currency_id': self.company_id.currency_id.id,
- 'display_type': 'rounding',
- }
- if self.invoice_cash_rounding_id.strategy == 'biggest_tax':
- biggest_tax_line = None
- for tax_line in self.line_ids.filtered('tax_repartition_line_id'):
- if not biggest_tax_line or abs(tax_line.balance) > abs(biggest_tax_line.balance):
- biggest_tax_line = tax_line
- # No tax found.
- if not biggest_tax_line:
- return
- rounding_line_vals.update({
- 'name': _('%s (rounding)', biggest_tax_line.name),
- 'account_id': biggest_tax_line.account_id.id,
- 'tax_repartition_line_id': biggest_tax_line.tax_repartition_line_id.id,
- 'tax_tag_ids': [(6, 0, biggest_tax_line.tax_tag_ids.ids)],
- 'tax_ids': [Command.set(biggest_tax_line.tax_ids.ids)]
- })
- elif self.invoice_cash_rounding_id.strategy == 'add_invoice_line':
- if diff_balance > 0.0 and self.invoice_cash_rounding_id.loss_account_id:
- account_id = self.invoice_cash_rounding_id.loss_account_id.id
- else:
- account_id = self.invoice_cash_rounding_id.profit_account_id.id
- rounding_line_vals.update({
- 'name': self.invoice_cash_rounding_id.name,
- 'account_id': account_id,
- 'tax_ids': [Command.clear()]
- })
- # Create or update the cash rounding line.
- if cash_rounding_line:
- cash_rounding_line.write(rounding_line_vals)
- else:
- cash_rounding_line = self.env['account.move.line'].create(rounding_line_vals)
- existing_cash_rounding_line = self.line_ids.filtered(lambda line: line.display_type == 'rounding')
- # The cash rounding has been removed.
- if not self.invoice_cash_rounding_id:
- existing_cash_rounding_line.unlink()
- # self.line_ids -= existing_cash_rounding_line
- return
- # The cash rounding strategy has changed.
- if self.invoice_cash_rounding_id and existing_cash_rounding_line:
- strategy = self.invoice_cash_rounding_id.strategy
- old_strategy = 'biggest_tax' if existing_cash_rounding_line.tax_line_id else 'add_invoice_line'
- if strategy != old_strategy:
- # self.line_ids -= existing_cash_rounding_line
- existing_cash_rounding_line.unlink()
- existing_cash_rounding_line = self.env['account.move.line']
- others_lines = self.line_ids.filtered(lambda line: line.account_id.account_type not in ('asset_receivable', 'liability_payable'))
- others_lines -= existing_cash_rounding_line
- total_amount_currency = sum(others_lines.mapped('amount_currency'))
- diff_balance, diff_amount_currency = _compute_cash_rounding(self, total_amount_currency)
- # The invoice is already rounded.
- if self.currency_id.is_zero(diff_balance) and self.currency_id.is_zero(diff_amount_currency):
- existing_cash_rounding_line.unlink()
- # self.line_ids -= existing_cash_rounding_line
- return
- # No update needed
- if existing_cash_rounding_line \
- and float_compare(existing_cash_rounding_line.balance, diff_balance, precision_rounding=self.currency_id.rounding) == 0 \
- and float_compare(existing_cash_rounding_line.amount_currency, diff_amount_currency, precision_rounding=self.currency_id.rounding) == 0:
- return
- _apply_cash_rounding(self, diff_balance, diff_amount_currency, existing_cash_rounding_line)
- @contextmanager
- def _sync_unbalanced_lines(self, container):
- yield
- # Skip posted moves.
- for invoice in (x for x in container['records'] if x.state != 'posted'):
- # Unlink tax lines if all taxes have been removed.
- if not invoice.line_ids.tax_ids:
- # if there isn't any tax but there remains a tax_line_id, it means we are currently in the process of
- # removing the taxes from the entry. Thus, we want the automatic balancing to happen in order to have
- # a smooth process for tax deletion
- if not invoice.line_ids.filtered('tax_line_id'):
- continue
- invoice.line_ids.filtered('tax_line_id').unlink()
- # Set the balancing line's balance and amount_currency to zero,
- # so that it does not interfere with _get_unbalanced_moves() below.
- balance_name = _('Automatic Balancing Line')
- existing_balancing_line = invoice.line_ids.filtered(lambda line: line.name == balance_name)
- if existing_balancing_line:
- existing_balancing_line.balance = existing_balancing_line.amount_currency = 0.0
- # Create an automatic balancing line to make sure the entry can be saved/posted.
- # If such a line already exists, we simply update its amounts.
- unbalanced_moves = self._get_unbalanced_moves({'records': invoice})
- if isinstance(unbalanced_moves, list) and len(unbalanced_moves) == 1:
- dummy, debit, credit = unbalanced_moves[0]
- vals = {'balance': credit - debit}
- if existing_balancing_line:
- existing_balancing_line.write(vals)
- else:
- vals.update({
- 'name': balance_name,
- 'move_id': invoice.id,
- 'account_id': invoice.company_id.account_journal_suspense_account_id.id,
- 'currency_id': invoice.currency_id.id,
- })
- self.env['account.move.line'].create(vals)
- @contextmanager
- def _sync_rounding_lines(self, container):
- yield
- for invoice in container['records']:
- invoice._recompute_cash_rounding_lines()
- @contextmanager
- def _sync_dynamic_line(self, existing_key_fname, needed_vals_fname, needed_dirty_fname, line_type, container):
- def existing():
- return {
- line[existing_key_fname]: line
- for line in container['records'].line_ids
- if line[existing_key_fname]
- }
- def needed():
- res = {}
- for computed_needed in container['records'].mapped(needed_vals_fname):
- if computed_needed is False:
- continue # there was an invalidation, let's hope nothing needed to be changed...
- for key, values in computed_needed.items():
- if key not in res:
- res[key] = dict(values)
- else:
- ignore = True
- for fname in res[key]:
- if self.env['account.move.line']._fields[fname].type == 'monetary':
- res[key][fname] += values[fname]
- if res[key][fname]:
- ignore = False
- if ignore:
- del res[key]
- # Convert float values to their "ORM cache" one to prevent different rounding calculations
- for dict_key in res:
- move_id = dict_key.get('move_id')
- if not move_id:
- continue
- record = self.env['account.move'].browse(move_id)
- for fname, current_value in res[dict_key].items():
- field = self.env['account.move.line']._fields[fname]
- if isinstance(current_value, float):
- new_value = field.convert_to_cache(current_value, record)
- res[dict_key][fname] = new_value
- return res
- def dirty():
- *path, dirty_fname = needed_dirty_fname.split('.')
- eligible_recs = container['records'].mapped('.'.join(path))
- if eligible_recs._name == 'account.move.line':
- eligible_recs = eligible_recs.filtered(lambda l: l.display_type != 'cogs')
- dirty_recs = eligible_recs.filtered(dirty_fname)
- return dirty_recs, dirty_fname
- existing_before = existing()
- needed_before = needed()
- dirty_recs_before, dirty_fname = dirty()
- dirty_recs_before[dirty_fname] = False
- yield
- dirty_recs_after, dirty_fname = dirty()
- if dirty_recs_before and not dirty_recs_after: # TODO improve filter
- return
- existing_after = existing()
- needed_after = needed()
- # Filter out deleted lines from `needed_before` to not recompute lines if not necessary or wanted
- line_ids = set(self.env['account.move.line'].browse(k['id'] for k in needed_before if 'id' in k).exists().ids)
- needed_before = {k: v for k, v in needed_before.items() if 'id' not in k or k['id'] in line_ids}
- # old key to new key for the same line
- inv_existing_before = {v: k for k, v in existing_before.items()}
- inv_existing_after = {v: k for k, v in existing_after.items()}
- before2after = {
- before: inv_existing_after[bline]
- for bline, before in inv_existing_before.items()
- if bline in inv_existing_after
- }
- if needed_after == needed_before:
- return
- to_delete = [
- line.id
- for key, line in existing_before.items()
- if key not in needed_after
- and key in existing_after
- and before2after[key] not in needed_after
- ]
- to_delete_set = set(to_delete)
- to_delete.extend(line.id
- for key, line in existing_after.items()
- if key not in needed_after and line.id not in to_delete_set
- )
- to_create = {
- key: values
- for key, values in needed_after.items()
- if key not in existing_after
- }
- to_write = {
- existing_after[key]: values
- for key, values in needed_after.items()
- if key in existing_after
- and any(
- self.env['account.move.line']._fields[fname].convert_to_write(existing_after[key][fname], self)
- != values[fname]
- for fname in values
- )
- }
- while to_delete and to_create:
- key, values = to_create.popitem()
- line_id = to_delete.pop()
- self.env['account.move.line'].browse(line_id).write(
- {**key, **values, 'display_type': line_type}
- )
- if to_delete:
- self.env['account.move.line'].browse(to_delete).with_context(dynamic_unlink=True).unlink()
- if to_create:
- self.env['account.move.line'].create([
- {**key, **values, 'display_type': line_type}
- for key, values in to_create.items()
- ])
- if to_write:
- for line, values in to_write.items():
- line.write(values)
- @contextmanager
- def _sync_invoice(self, container):
- def existing():
- return {
- move: {
- 'commercial_partner_id': move.commercial_partner_id,
- }
- for move in container['records'].filtered(lambda m: m.is_invoice(True))
- }
- def changed(fname):
- return move not in before or before[move][fname] != after[move][fname]
- before = existing()
- yield
- after = existing()
- for move in after:
- if changed('commercial_partner_id'):
- move.line_ids.partner_id = after[move]['commercial_partner_id']
- @contextmanager
- def _sync_dynamic_lines(self, container):
- with self._disable_recursion(container, 'skip_invoice_sync') as disabled:
- if disabled:
- yield
- return
- def update_containers():
- # Only invoice-like and journal entries in "auto tax mode" are synced
- tax_container['records'] = container['records'].filtered(lambda m: (m.is_invoice(True) or m.line_ids.tax_ids and not m.tax_cash_basis_origin_move_id))
- invoice_container['records'] = container['records'].filtered(lambda m: m.is_invoice(True))
- misc_container['records'] = container['records'].filtered(lambda m: m.move_type == 'entry' and not m.tax_cash_basis_origin_move_id)
- tax_container, invoice_container, misc_container = ({} for __ in range(3))
- update_containers()
- with ExitStack() as stack:
- stack.enter_context(self._sync_dynamic_line(
- existing_key_fname='term_key',
- needed_vals_fname='needed_terms',
- needed_dirty_fname='needed_terms_dirty',
- line_type='payment_term',
- container=invoice_container,
- ))
- stack.enter_context(self._sync_unbalanced_lines(misc_container))
- stack.enter_context(self._sync_rounding_lines(invoice_container))
- stack.enter_context(self._sync_dynamic_line(
- existing_key_fname='tax_key',
- needed_vals_fname='line_ids.compute_all_tax',
- needed_dirty_fname='line_ids.compute_all_tax_dirty',
- line_type='tax',
- container=tax_container,
- ))
- stack.enter_context(self._sync_dynamic_line(
- existing_key_fname='epd_key',
- needed_vals_fname='line_ids.epd_needed',
- needed_dirty_fname='line_ids.epd_dirty',
- line_type='epd',
- container=invoice_container,
- ))
- stack.enter_context(self._sync_invoice(invoice_container))
- line_container = {'records': self.line_ids}
- with self.line_ids._sync_invoice(line_container):
- yield
- line_container['records'] = self.line_ids
- update_containers()
- # -------------------------------------------------------------------------
- # LOW-LEVEL METHODS
- # -------------------------------------------------------------------------
- def check_field_access_rights(self, operation, field_names):
- result = super().check_field_access_rights(operation, field_names)
- if not field_names:
- weirdos = ['needed_terms', 'quick_encoding_vals', 'payment_term_details']
- result = [fname for fname in result if fname not in weirdos]
- return result
- def copy_data(self, default=None):
- data_list = super().copy_data(default)
- for move, data in zip(self, data_list):
- if move.move_type in ('out_invoice', 'in_invoice'):
- data['line_ids'] = [
- (command, _id, line_vals)
- for command, _id, line_vals in data['line_ids']
- if command == Command.CREATE
- ]
- elif move.move_type == 'entry':
- if 'partner_id' not in data:
- data['partner_id'] = False
- if not self.journal_id.active and 'journal_id' in data_list:
- del default['journal_id']
- return data_list
- @api.returns('self', lambda value: value.id)
- def copy(self, default=None):
- default = dict(default or {})
- if (fields.Date.to_date(default.get('date')) or self.date) <= self.company_id._get_user_fiscal_lock_date():
- default['date'] = self.company_id._get_user_fiscal_lock_date() + timedelta(days=1)
- copied_am = super().copy(default)
- message_origin = '' if not copied_am.auto_post_origin_id else \
- '<br/>' + _('This recurring entry originated from %s', copied_am.auto_post_origin_id._get_html_link())
- copied_am._message_log(body=_(
- 'This entry has been duplicated from %s%s',
- self._get_html_link(),
- message_origin,
- ))
- return copied_am
- def _sanitize_vals(self, vals):
- if vals.get('invoice_line_ids') and vals.get('line_ids'):
- # values can sometimes be in only one of the two fields, sometimes in
- # both fields, sometimes one field can be explicitely empty while the other
- # one is not, sometimes not...
- update_vals = {
- line_id: line_vals
- for command, line_id, line_vals in vals['invoice_line_ids']
- if command == Command.UPDATE
- }
- for command, line_id, line_vals in vals['line_ids']:
- if command == Command.UPDATE and line_id in update_vals:
- line_vals.update(update_vals.pop(line_id))
- for line_id, line_vals in update_vals.items():
- vals['line_ids'] += [Command.update(line_id, line_vals)]
- for command, line_id, line_vals in vals['invoice_line_ids']:
- assert command not in (Command.SET, Command.CLEAR)
- if [command, line_id, line_vals] not in vals['line_ids']:
- vals['line_ids'] += [(command, line_id, line_vals)]
- del vals['invoice_line_ids']
- return vals
- @api.model_create_multi
- def create(self, vals_list):
- if any('state' in vals and vals.get('state') == 'posted' for vals in vals_list):
- raise UserError(_('You cannot create a move already in the posted state. Please create a draft move and post it after.'))
- container = {'records': self}
- with self._check_balanced(container):
- with self._sync_dynamic_lines(container):
- moves = super().create([self._sanitize_vals(vals) for vals in vals_list])
- container['records'] = moves
- for move, vals in zip(moves, vals_list):
- if 'tax_totals' in vals:
- move.tax_totals = vals['tax_totals']
- return moves
- def write(self, vals):
- if not vals:
- return True
- self._sanitize_vals(vals)
- for move in self:
- if (move.restrict_mode_hash_table and move.state == "posted" and set(vals).intersection(move._get_integrity_hash_fields())):
- raise UserError(_("You cannot edit the following fields due to restrict mode being activated on the journal: %s.") % ', '.join(move._get_integrity_hash_fields()))
- if (move.restrict_mode_hash_table and move.inalterable_hash and 'inalterable_hash' in vals) or (move.secure_sequence_number and 'secure_sequence_number' in vals):
- raise UserError(_('You cannot overwrite the values ensuring the inalterability of the accounting.'))
- if (move.posted_before and 'journal_id' in vals and move.journal_id.id != vals['journal_id']):
- raise UserError(_('You cannot edit the journal of an account move if it has been posted once.'))
- if (move.name and move.name != '/' and move.sequence_number not in (0, 1) and 'journal_id' in vals and move.journal_id.id != vals['journal_id']):
- raise UserError(_('You cannot edit the journal of an account move if it already has a sequence number assigned.'))
- # You can't change the date or name of a move being inside a locked period.
- if move.state == "posted" and (
- ('name' in vals and move.name != vals['name'])
- or ('date' in vals and move.date != vals['date'])
- ):
- move._check_fiscalyear_lock_date()
- move.line_ids._check_tax_lock_date()
- # You can't post subtract a move to a locked period.
- if 'state' in vals and move.state == 'posted' and vals['state'] != 'posted':
- move._check_fiscalyear_lock_date()
- move.line_ids._check_tax_lock_date()
- if move.journal_id.sequence_override_regex and vals.get('name') and vals['name'] != '/' and not re.match(move.journal_id.sequence_override_regex, vals['name']):
- if not self.env.user.has_group('account.group_account_manager'):
- raise UserError(_('The Journal Entry sequence is not conform to the current format. Only the Accountant can change it.'))
- move.journal_id.sequence_override_regex = False
- to_protect = []
- for fname in vals:
- field = self._fields[fname]
- if field.compute and not field.readonly:
- to_protect.append(field)
- container = {'records': self}
- with self.env.protecting(to_protect, self), self._check_balanced(container):
- with self._sync_dynamic_lines(container):
- res = super(AccountMove, self.with_context(
- skip_account_move_synchronization=True,
- )).write(vals)
- # Reset the name of draft moves when changing the journal.
- # Protected against holes in the pre-validation checks.
- if 'journal_id' in vals and 'name' not in vals:
- self.name = False
- self._compute_name()
- # You can't change the date of a not-locked move to a locked period.
- # You can't post a new journal entry inside a locked period.
- if 'date' in vals or 'state' in vals:
- posted_move = self.filtered(lambda m: m.state == 'posted')
- posted_move._check_fiscalyear_lock_date()
- posted_move.line_ids._check_tax_lock_date()
- # Hash the move
- if vals.get('state') == 'posted':
- self.flush_recordset() # Ensure that the name is correctly computed before it is used to generate the hash
- for move in self.filtered(lambda m: m.restrict_mode_hash_table and not(m.secure_sequence_number or m.inalterable_hash)).sorted(lambda m: (m.date, m.ref or '', m.id)):
- new_number = move.journal_id.secure_sequence_id.next_by_id()
- res |= super(AccountMove, move).write({
- 'secure_sequence_number': new_number,
- 'inalterable_hash': move._get_new_hash(new_number),
- })
- self._synchronize_business_models(set(vals.keys()))
- # Apply the rounding on the Quick Edit mode only when adding a new line
- for move in self:
- if 'tax_totals' in vals:
- super(AccountMove, move).write({'tax_totals': vals['tax_totals']})
- if 'journal_id' in vals:
- self.line_ids._check_constrains_account_id_journal_id()
- return res
- def check_move_sequence_chain(self):
- return self.filtered(lambda move: move.name != '/')._is_end_of_seq_chain()
- @api.ondelete(at_uninstall=False)
- def _unlink_forbid_parts_of_chain(self):
- """ For a user with Billing/Bookkeeper rights, when the fidu mode is deactivated,
- moves with a sequence number can only be deleted if they are the last element of a chain of sequence.
- If they are not, deleting them would create a gap. If the user really wants to do this, he still can
- explicitly empty the 'name' field of the move; but we discourage that practice.
- If a user is a Billing Administrator/Accountant or if fidu mode is activated, we show a warning,
- but they can delete the moves even if it creates a sequence gap.
- """
- if not (
- self.user_has_groups('account.group_account_manager')
- or self.company_id.quick_edit_mode
- or self._context.get('force_delete')
- or self.check_move_sequence_chain()
- ):
- raise UserError(_(
- "You cannot delete this entry, as it has already consumed a sequence number and is not the last one in the chain. "
- "You should probably revert it instead."
- ))
- def unlink(self):
- self = self.with_context(skip_invoice_sync=True, dynamic_unlink=True) # no need to sync to delete everything
- self.line_ids.unlink()
- return super().unlink()
- def name_get(self):
- result = []
- for move in self:
- result.append((move.id, move._get_move_display_name(show_ref=True)))
- return result
- def onchange(self, values, field_name, field_onchange):
- if field_name in ('line_ids', 'invoice_line_ids'):
- # Since only one field can be changed at the same time (the record is saved when changing tabs)
- # we can avoid building the snapshots for the other field
- to_del = 'invoice_line_ids' if field_name == 'line_ids' else 'line_ids'
- for key in list(field_onchange):
- if key == to_del or key.startswith(f"{to_del}."):
- del field_onchange[key]
- # test_01_account_tour
- # File "/data/build/odoo/addons/account/models/account_move.py", line 2127, in onchange
- # del values[to_del]
- # KeyError: 'line_ids'
- values.pop(to_del, None)
- if field_name and not isinstance(field_name, list):
- field_name = [field_name]
- with self.env.protecting([self._fields[fname] for fname in field_name or []], self):
- return super().onchange(values, field_name, field_onchange)
- # -------------------------------------------------------------------------
- # RECONCILIATION METHODS
- # -------------------------------------------------------------------------
- def _collect_tax_cash_basis_values(self):
- ''' Collect all information needed to create the tax cash basis journal entries:
- - Determine if a tax cash basis journal entry is needed.
- - Compute the lines to be processed and the amounts needed to compute a percentage.
- :return: A dictionary:
- * move: The current account.move record passed as parameter.
- * to_process_lines: A tuple (caba_treatment, line) where:
- - caba_treatment is either 'tax' or 'base', depending on what should
- be considered on the line when generating the caba entry.
- For example, a line with tax_ids=caba and tax_line_id=non_caba
- will have a 'base' caba treatment, as we only want to treat its base
- part in the caba entry (the tax part is already exigible on the invoice)
- - line is an account.move.line record being not exigible on the tax report.
- * currency: The currency on which the percentage has been computed.
- * total_balance: sum(payment_term_lines.mapped('balance').
- * total_residual: sum(payment_term_lines.mapped('amount_residual').
- * total_amount_currency: sum(payment_term_lines.mapped('amount_currency').
- * total_residual_currency: sum(payment_term_lines.mapped('amount_residual_currency').
- * is_fully_paid: A flag indicating the current move is now fully paid.
- '''
- self.ensure_one()
- values = {
- 'move': self,
- 'to_process_lines': [],
- 'total_balance': 0.0,
- 'total_residual': 0.0,
- 'total_amount_currency': 0.0,
- 'total_residual_currency': 0.0,
- }
- currencies = set()
- has_term_lines = False
- for line in self.line_ids:
- if line.account_type in ('asset_receivable', 'liability_payable'):
- sign = 1 if line.balance > 0.0 else -1
- currencies.add(line.currency_id)
- has_term_lines = True
- values['total_balance'] += sign * line.balance
- values['total_residual'] += sign * line.amount_residual
- values['total_amount_currency'] += sign * line.amount_currency
- values['total_residual_currency'] += sign * line.amount_residual_currency
- elif line.tax_line_id.tax_exigibility == 'on_payment':
- values['to_process_lines'].append(('tax', line))
- currencies.add(line.currency_id)
- elif 'on_payment' in line.tax_ids.flatten_taxes_hierarchy().mapped('tax_exigibility'):
- values['to_process_lines'].append(('base', line))
- currencies.add(line.currency_id)
- if not values['to_process_lines'] or not has_term_lines:
- return None
- # Compute the currency on which made the percentage.
- if len(currencies) == 1:
- values['currency'] = list(currencies)[0]
- else:
- # Don't support the case where there is multiple involved currencies.
- return None
- # Determine whether the move is now fully paid.
- values['is_fully_paid'] = self.company_id.currency_id.is_zero(values['total_residual']) \
- or values['currency'].is_zero(values['total_residual_currency'])
- return values
- # -------------------------------------------------------------------------
- # SEQUENCE MIXIN
- # -------------------------------------------------------------------------
- def _must_check_constrains_date_sequence(self):
- # OVERRIDES sequence.mixin
- return not self.quick_edit_mode
- def _get_last_sequence_domain(self, relaxed=False):
- # EXTENDS account sequence.mixin
- self.ensure_one()
- if not self.date or not self.journal_id:
- return "WHERE FALSE", {}
- where_string = "WHERE journal_id = %(journal_id)s AND name != '/'"
- param = {'journal_id': self.journal_id.id}
- is_payment = self.payment_id or self._context.get('is_payment')
- if not relaxed:
- domain = [('journal_id', '=', self.journal_id.id), ('id', '!=', self.id or self._origin.id), ('name', 'not in', ('/', '', False))]
- if self.journal_id.refund_sequence:
- refund_types = ('out_refund', 'in_refund')
- domain += [('move_type', 'in' if self.move_type in refund_types else 'not in', refund_types)]
- if self.journal_id.payment_sequence:
- domain += [('payment_id', '!=' if is_payment else '=', False)]
- reference_move_name = self.search(domain + [('date', '<=', self.date)], order='date desc', limit=1).name
- if not reference_move_name:
- reference_move_name = self.search(domain, order='date asc', limit=1).name
- sequence_number_reset = self._deduce_sequence_number_reset(reference_move_name)
- if sequence_number_reset == 'year':
- where_string += " AND date_trunc('year', date::timestamp without time zone) = date_trunc('year', %(date)s) "
- param['date'] = self.date
- param['anti_regex'] = re.sub(r"\?P<\w+>", "?:", self._sequence_monthly_regex.split('(?P<seq>')[0]) + '$'
- elif sequence_number_reset == 'month':
- where_string += " AND date_trunc('month', date::timestamp without time zone) = date_trunc('month', %(date)s) "
- param['date'] = self.date
- else:
- param['anti_regex'] = re.sub(r"\?P<\w+>", "?:", self._sequence_yearly_regex.split('(?P<seq>')[0]) + '$'
- if param.get('anti_regex') and not self.journal_id.sequence_override_regex:
- where_string += " AND sequence_prefix !~ %(anti_regex)s "
- if self.journal_id.refund_sequence:
- if self.move_type in ('out_refund', 'in_refund'):
- where_string += " AND move_type IN ('out_refund', 'in_refund') "
- else:
- where_string += " AND move_type NOT IN ('out_refund', 'in_refund') "
- elif self.journal_id.payment_sequence:
- if is_payment:
- where_string += " AND payment_id IS NOT NULL "
- else:
- where_string += " AND payment_id IS NULL "
- return where_string, param
- def _get_starting_sequence(self):
- # EXTENDS account sequence.mixin
- self.ensure_one()
- is_payment = self.payment_id or self._context.get('is_payment')
- if self.journal_id.type in ['sale', 'bank', 'cash']:
- starting_sequence = "%s/%04d/00000" % (self.journal_id.code, self.date.year)
- else:
- starting_sequence = "%s/%04d/%02d/0000" % (self.journal_id.code, self.date.year, self.date.month)
- if self.journal_id.refund_sequence and self.move_type in ('out_refund', 'in_refund'):
- starting_sequence = "R" + starting_sequence
- if self.journal_id.payment_sequence and is_payment:
- starting_sequence = "P" + starting_sequence
- return starting_sequence
- # -------------------------------------------------------------------------
- # PAYMENT REFERENCE
- # -------------------------------------------------------------------------
- def _get_invoice_reference_euro_invoice(self):
- """ This computes the reference based on the RF Creditor Reference.
- The data of the reference is the database id number of the invoice.
- For instance, if an invoice is issued with id 43, the check number
- is 07 so the reference will be 'RF07 43'.
- """
- self.ensure_one()
- return format_rf_reference(self.id)
- def _get_invoice_reference_euro_partner(self):
- """ This computes the reference based on the RF Creditor Reference.
- The data of the reference is the user defined reference of the
- partner or the database id number of the parter.
- For instance, if an invoice is issued for the partner with internal
- reference 'food buyer 654', the digits will be extracted and used as
- the data. This will lead to a check number equal to 00 and the
- reference will be 'RF00 654'.
- If no reference is set for the partner, its id in the database will
- be used.
- """
- self.ensure_one()
- partner_ref = self.partner_id.ref
- partner_ref_nr = re.sub(r'\D', '', partner_ref or '')[-21:] or str(self.partner_id.id)[-21:]
- partner_ref_nr = partner_ref_nr[-21:]
- return format_rf_reference(partner_ref_nr)
- def _get_invoice_reference_odoo_invoice(self):
- """ This computes the reference based on the Odoo format.
- We simply return the number of the invoice, defined on the journal
- sequence.
- """
- self.ensure_one()
- return self.name
- def _get_invoice_reference_odoo_partner(self):
- """ This computes the reference based on the Odoo format.
- The data used is the reference set on the partner or its database
- id otherwise. For instance if the reference of the customer is
- 'dumb customer 97', the reference will be 'CUST/dumb customer 97'.
- """
- ref = self.partner_id.ref or str(self.partner_id.id)
- prefix = _('CUST')
- return '%s/%s' % (prefix, ref)
- def _get_invoice_computed_reference(self):
- self.ensure_one()
- if self.journal_id.invoice_reference_type == 'none':
- return ''
- ref_function = getattr(self, f'_get_invoice_reference_{self.journal_id.invoice_reference_model}_{self.journal_id.invoice_reference_type}', None)
- if ref_function is None:
- raise UserError(_("The combination of reference model and reference type on the journal is not implemented"))
- return ref_function()
- # -------------------------------------------------------------------------
- # QUICK ENCODING
- # -------------------------------------------------------------------------
- @api.model
- def _get_frequent_account_and_taxes(self, company_id, partner_id, move_type):
- """
- Returns the most used accounts and taxes for a given partner and company,
- eventually filtered according to the move type.
- """
- if not partner_id:
- return 0, False, False
- where_internal_group = ""
- if move_type in self.env['account.move'].get_inbound_types(include_receipts=True):
- where_internal_group = "AND account.internal_group = 'income'"
- elif move_type in self.env['account.move'].get_outbound_types(include_receipts=True):
- where_internal_group = "AND account.internal_group = 'expense'"
- self._cr.execute(f"""
- SELECT
- COUNT(foo.id), foo.account_id, foo.taxes
- FROM
- (
- SELECT
- account.id AS account_id,
- account.code,
- aml.id,
- ARRAY_AGG(tax_rel.account_tax_id) AS taxes
- FROM account_account account
- LEFT JOIN account_move_line aml
- ON (account.id = aml.account_id
- AND aml.partner_id = %s
- AND aml.date >= now() - interval '2 years')
- LEFT JOIN account_move_line_account_tax_rel tax_rel ON (aml.id = tax_rel.account_move_line_id)
- WHERE
- account.company_id = %s
- AND account.deprecated = FALSE
- {where_internal_group}
- GROUP BY account.id, account.code, aml.id
- ) AS foo
- GROUP BY foo.account_id, foo.code, foo.taxes
- ORDER BY COUNT(foo.id) DESC, foo.code
- LIMIT 1
- """, [partner_id, company_id])
- return self._cr.fetchone()
- def _get_quick_edit_suggestions(self):
- """
- Returns a dictionnary containing the suggested values when creating a new
- line with the quick_edit_total_amount set. We will compute the price_unit
- that has to be set with the correct that in order to match this total amount.
- If the vendor/customer is set, we will suggest the most frequently used account
- for that partner as the default one, otherwise the default of the journal.
- """
- self.ensure_one()
- if not self.quick_edit_mode or not self.quick_edit_total_amount:
- return False
- count, account_id, tax_ids = self._get_frequent_account_and_taxes(
- self.company_id.id,
- self.partner_id.id,
- self.move_type,
- )
- if count:
- taxes = self.env['account.tax'].browse(tax_ids)
- else:
- account_id = self.journal_id.default_account_id.id
- if self.is_sale_document(include_receipts=True):
- taxes = self.journal_id.default_account_id.tax_ids.filtered(lambda tax: tax.type_tax_use == 'sale')
- else:
- taxes = self.journal_id.default_account_id.tax_ids.filtered(lambda tax: tax.type_tax_use == 'purchase')
- if not taxes:
- taxes = (
- self.journal_id.company_id.account_sale_tax_id
- if self.journal_id.type == 'sale' else
- self.journal_id.company_id.account_purchase_tax_id
- )
- taxes = self.fiscal_position_id.map_tax(taxes)
- # When a payment term has an early payment discount and the company's epd computation is set to 'mixed', recomputing
- # the untaxed amount should take in consideration the discount percentage otherwise we'd get a wrong value.
- # Since in a payment term we can have multiple lines with multiple discounts, handling all cases can get
- # complicated. For this we check that we have only one line with one discount and handle only this case.
- # We also check that we have one percentage tax for the same reason.
- # In one example: let's say: base = 100, discount = 2%, tax = 21%
- # the total will be calculated as: total = base + (base * (1 - discount)) * tax
- # If we manipulate the equation to get the base from the total, we'll have base = total / ((1 - discount) * tax + 1)
- term_lines = self.invoice_payment_term_id.line_ids
- discount_percentage = term_lines.discount_percentage if len(term_lines) == 1 else 0
- remaining_amount = self.quick_edit_total_amount - self.tax_totals['amount_total']
- if (
- discount_percentage
- and self.company_id.early_pay_discount_computation == 'mixed'
- and len(taxes) == 1
- and taxes.amount_type == 'percent'
- ):
- price_untaxed = self.currency_id.round(
- remaining_amount / (((1.0 - discount_percentage / 100.0) * (taxes.amount / 100.0)) + 1.0))
- else:
- price_untaxed = taxes.with_context(force_price_include=True).compute_all(remaining_amount)['total_excluded']
- return {'account_id': account_id, 'tax_ids': taxes.ids, 'price_unit': price_untaxed}
- @api.onchange('quick_edit_mode', 'journal_id', 'company_id')
- def _quick_edit_mode_suggest_invoice_date(self):
- """Suggest the Customer Invoice/Vendor Bill date based on previous invoice and lock dates"""
- for record in self:
- if record.quick_edit_mode and not record.invoice_date:
- invoice_date = fields.Date.context_today(self)
- prev_move = self.search([('state', '=', 'posted'),
- ('journal_id', '=', record.journal_id.id),
- ('company_id', '=', record.company_id.id),
- ('invoice_date', '!=', False)],
- limit=1)
- if prev_move:
- invoice_date = self._get_accounting_date(prev_move.invoice_date, False)
- record.invoice_date = invoice_date
- @api.onchange('quick_edit_total_amount', 'partner_id')
- def _onchange_quick_edit_total_amount(self):
- """
- Creates a new line with the suggested values (for the account, the price_unit,
- and the tax) such that the total amount matches the quick total amount.
- """
- if (
- not self.quick_edit_total_amount
- or not self.quick_edit_mode
- or len(self.invoice_line_ids) > 0
- ):
- return
- suggestions = self.quick_encoding_vals
- self.invoice_line_ids = [Command.clear()]
- self.invoice_line_ids += self.env['account.move.line'].new({
- 'partner_id': self.partner_id,
- 'account_id': suggestions['account_id'],
- 'currency_id': self.currency_id.id,
- 'price_unit': suggestions['price_unit'],
- 'tax_ids': [Command.set(suggestions['tax_ids'])],
- })
- self._check_total_amount(self.quick_edit_total_amount)
- @api.onchange('invoice_line_ids')
- def _onchange_quick_edit_line_ids(self):
- quick_encode_suggestion = self.env.context.get('quick_encoding_vals')
- if (
- not self.quick_edit_total_amount
- or not self.quick_edit_mode
- or not self.invoice_line_ids
- or not quick_encode_suggestion
- or not quick_encode_suggestion['price_unit'] == self.invoice_line_ids[-1].price_unit
- ):
- return
- self._check_total_amount(self.quick_edit_total_amount)
- def _check_total_amount(self, amount_total):
- """
- Verifies that the total amount corresponds to the quick total amount chosen as some
- rounding errors may appear. In such a case, we round up the tax such that the total
- is equal to the quick total amount set
- E.g.: 100€ including 21% tax: base = 82.64, tax = 17.35, total = 99.99
- The tax will be set to 17.36 in order to have a total of 100.00
- """
- if not self.tax_totals or not amount_total:
- return
- totals = self.tax_totals
- tax_amount_rounding_error = amount_total - totals['amount_total']
- if not float_is_zero(tax_amount_rounding_error, precision_rounding=self.currency_id.rounding):
- if _('Untaxed Amount') in totals['groups_by_subtotal']:
- totals['groups_by_subtotal'][_('Untaxed Amount')][0]['tax_group_amount'] += tax_amount_rounding_error
- totals['amount_total'] = amount_total
- self.tax_totals = totals
- # -------------------------------------------------------------------------
- # HASH
- # -------------------------------------------------------------------------
- def _get_integrity_hash_fields(self):
- # Use the latest 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 ['date', 'journal_id', 'company_id']
- elif hash_version in (2, 3):
- return ['name', 'date', 'journal_id', 'company_id']
- raise NotImplementedError(f"hash_version={hash_version} doesn't exist")
- def _get_integrity_hash_fields_and_subfields(self):
- return self._get_integrity_hash_fields() + [f'line_ids.{subfield}' for subfield in self.line_ids._get_integrity_hash_fields()]
- def _get_new_hash(self, secure_seq_number):
- """ Returns the hash to write on journal entries when they get posted"""
- self.ensure_one()
- #get the only one exact previous move in the securisation sequence
- prev_move = self.sudo().search([('state', '=', 'posted'),
- ('company_id', '=', self.company_id.id),
- ('journal_id', '=', self.journal_id.id),
- ('secure_sequence_number', '!=', 0),
- ('secure_sequence_number', '=', int(secure_seq_number) - 1)])
- if prev_move and len(prev_move) != 1:
- raise UserError(
- _('An error occurred when computing the inalterability. Impossible to get the unique previous posted journal entry.'))
- #build and return the hash
- return self._compute_hash(prev_move.inalterable_hash if prev_move else u'')
- def _compute_hash(self, previous_hash):
- """ Computes the hash of the browse_record given as self, based on the hash
- of the previous record in the company's securisation sequence given as parameter"""
- self.ensure_one()
- hash_string = sha256((previous_hash + self.string_to_hash).encode('utf-8'))
- return hash_string.hexdigest()
- @api.depends(lambda self: self._get_integrity_hash_fields_and_subfields())
- @api.depends_context('hash_version')
- def _compute_string_to_hash(self):
- def _getattrstring(obj, field_str):
- hash_version = self._context.get('hash_version', MAX_HASH_VERSION)
- field_value = obj[field_str]
- if obj._fields[field_str].type == 'many2one':
- field_value = field_value.id
- if obj._fields[field_str].type == 'monetary' and hash_version >= 3:
- return float_repr(field_value, obj.currency_id.decimal_places)
- return str(field_value)
- for move in self:
- values = {}
- for field in move._get_integrity_hash_fields():
- values[field] = _getattrstring(move, field)
- for line in move.line_ids:
- for field in line._get_integrity_hash_fields():
- k = 'line_%d_%s' % (line.id, field)
- values[k] = _getattrstring(line, field)
- #make the json serialization canonical
- # (https://tools.ietf.org/html/draft-staykov-hu-json-canonical-form-00)
- move.string_to_hash = dumps(values, sort_keys=True,
- ensure_ascii=True, indent=None,
- separators=(',', ':'))
- # -------------------------------------------------------------------------
- # RECURRING ENTRIES
- # -------------------------------------------------------------------------
- @api.model
- def _apply_delta_recurring_entries(self, date, date_origin, period):
- '''Advances date by `period` months, maintaining original day of the month if possible.'''
- deltas = {'monthly': 1, 'quarterly': 3, 'yearly': 12}
- prev_months = (date.year - date_origin.year) * 12 + date.month - date_origin.month
- return date_origin + relativedelta(months=deltas[period] + prev_months)
- def _copy_recurring_entries(self):
- ''' Creates a copy of a recurring (periodic) entry and adjusts its dates for the next period.
- Meant to be called right after posting a periodic entry.
- Copies extra fields as defined by _get_fields_to_copy_recurring_entries().
- '''
- for record in self:
- record.auto_post_origin_id = record.auto_post_origin_id or record # original entry references itself
- next_date = self._apply_delta_recurring_entries(record.date, record.auto_post_origin_id.date, record.auto_post)
- if not record.auto_post_until or next_date <= record.auto_post_until: # recurrence continues
- record.copy(default=record._get_fields_to_copy_recurring_entries({'date': next_date}))
- def _get_fields_to_copy_recurring_entries(self, values):
- ''' Determines which extra fields to copy when copying a recurring entry.
- To be extended by modules that add fields with copy=False (implicit or explicit)
- whenever the opposite behavior is expected for recurring invoices.
- '''
- values.update({
- 'auto_post': self.auto_post, # copy=False to avoid mistakes but should be the same in recurring copies
- 'auto_post_until': self.auto_post_until, # same as above
- 'auto_post_origin_id': self.auto_post_origin_id.id, # same as above
- 'invoice_user_id': self.invoice_user_id.id, # otherwise user would be OdooBot
- })
- if self.invoice_date:
- values.update({'invoice_date': self._apply_delta_recurring_entries(self.invoice_date, self.auto_post_origin_id.invoice_date, self.auto_post)})
- if not self.invoice_payment_term_id and self.invoice_date_due:
- # no payment terms: maintain timedelta between due date and accounting date
- values.update({'invoice_date_due': values['date'] + (self.invoice_date_due - self.date)})
- return values
- # -------------------------------------------------------------------------
- # BUSINESS METHODS
- # -------------------------------------------------------------------------
- def _prepare_invoice_aggregated_taxes(self, filter_invl_to_apply=None, filter_tax_values_to_apply=None, grouping_key_generator=None):
- self.ensure_one()
- base_lines = [
- x._convert_to_tax_base_line_dict()
- for x in self.line_ids.filtered(lambda x: x.display_type == 'product' and (not filter_invl_to_apply or filter_invl_to_apply(x)))
- ]
- to_process = []
- for base_line in base_lines:
- to_update_vals, tax_values_list = self.env['account.tax']._compute_taxes_for_single_line(base_line)
- to_process.append((base_line, to_update_vals, tax_values_list))
- return self.env['account.tax']._aggregate_taxes(
- to_process,
- filter_tax_values_to_apply=filter_tax_values_to_apply,
- grouping_key_generator=grouping_key_generator,
- )
- def _get_invoice_counterpart_amls_for_early_payment_discount_per_payment_term_line(self):
- """ Helper to get the values to create the counterpart journal items on the register payment wizard and the
- bank reconciliation widget in case of an early payment discount. When the early payment discount computation
- is included, we need to compute the base amounts / tax amounts for each receivable / payable but we need to
- take care about the rounding issues. For others computations, we need to balance the discount you get.
- :return: A list of values to create the counterpart journal items split in 3 categories:
- * term_lines: The journal items containing the discount amounts for each receivable line when the
- discount computation is excluded / mixed.
- * tax_lines: The journal items acting as tax lines when the discount computation is included.
- * base_lines: The journal items acting as base for tax lines when the discount computation is included.
- """
- self.ensure_one()
- def grouping_key_generator(base_line, tax_values):
- return self.env['account.tax']._get_generation_dict_from_base_line(base_line, tax_values)
- def inverse_tax_rep(tax_rep):
- tax = tax_rep.tax_id
- index = list(tax.invoice_repartition_line_ids).index(tax_rep)
- return tax.refund_repartition_line_ids[index]
- # Get the current tax amounts in the current invoice.
- tax_amounts = {
- inverse_tax_rep(line.tax_repartition_line_id).id: {
- 'amount_currency': line.amount_currency,
- 'balance': line.balance,
- }
- for line in self.line_ids.filtered(lambda x: x.display_type == 'tax')
- }
- product_lines = self.line_ids.filtered(lambda x: x.display_type == 'product')
- base_lines = [
- {
- **x._convert_to_tax_base_line_dict(),
- 'is_refund': True,
- }
- for x in product_lines
- ]
- for base_line in base_lines:
- base_line['taxes'] = base_line['taxes'].filtered(lambda t: t.amount_type != 'fixed')
- if self.is_inbound(include_receipts=True):
- cash_discount_account = self.company_id.account_journal_early_pay_discount_loss_account_id
- else:
- cash_discount_account = self.company_id.account_journal_early_pay_discount_gain_account_id
- res = {
- 'term_lines': defaultdict(lambda: {}),
- 'tax_lines': defaultdict(lambda: {}),
- 'base_lines': defaultdict(lambda: {}),
- }
- early_pay_discount_computation = self.company_id.early_pay_discount_computation
- base_per_percentage = {}
- tax_computation_per_percentage = {}
- for aml in self.line_ids.filtered(lambda x: x.display_type == 'payment_term'):
- if not aml.discount_percentage:
- continue
- term_amount_currency = aml.amount_currency - aml.discount_amount_currency
- term_balance = aml.balance - aml.discount_balance
- if early_pay_discount_computation == 'included' and product_lines.tax_ids:
- # Compute the amounts for each percentage.
- if aml.discount_percentage not in tax_computation_per_percentage:
- # Compute the base amounts.
- base_per_percentage[aml.discount_percentage] = resulting_delta_base_details = {}
- to_process = []
- for base_line in base_lines:
- invoice_line = base_line['record']
- to_update_vals, tax_values_list = self.env['account.tax']._compute_taxes_for_single_line(
- base_line,
- early_pay_discount_computation=early_pay_discount_computation,
- early_pay_discount_percentage=aml.discount_percentage,
- )
- to_process.append((base_line, to_update_vals, tax_values_list))
- grouping_dict = {
- 'tax_ids': [Command.set(base_line['taxes'].ids)],
- 'tax_tag_ids': to_update_vals['tax_tag_ids'],
- 'partner_id': base_line['partner'].id,
- 'currency_id': base_line['currency'].id,
- 'account_id': cash_discount_account.id,
- 'analytic_distribution': base_line['analytic_distribution'],
- }
- base_detail = resulting_delta_base_details.setdefault(frozendict(grouping_dict), {
- 'balance': 0.0,
- 'amount_currency': 0.0,
- })
- amount_currency = self.currency_id\
- .round(self.direction_sign * to_update_vals['price_subtotal'] - invoice_line.amount_currency)
- balance = self.company_currency_id\
- .round(amount_currency / base_line['rate'])
- base_detail['balance'] += balance
- base_detail['amount_currency'] += amount_currency
- # Compute the tax amounts.
- tax_details_with_epd = self.env['account.tax']._aggregate_taxes(
- to_process,
- grouping_key_generator=grouping_key_generator,
- )
- tax_computation_per_percentage[aml.discount_percentage] = resulting_delta_tax_details = {}
- for tax_detail in tax_details_with_epd['tax_details'].values():
- tax_amount_without_epd = tax_amounts.get(tax_detail['tax_repartition_line_id'])
- if not tax_amount_without_epd:
- continue
- tax_amount_currency = self.currency_id\
- .round(self.direction_sign * tax_detail['tax_amount_currency'] - tax_amount_without_epd['amount_currency'])
- tax_amount = self.company_currency_id\
- .round(self.direction_sign * tax_detail['tax_amount'] - tax_amount_without_epd['balance'])
- if self.currency_id.is_zero(tax_amount_currency) and self.company_currency_id.is_zero(tax_amount):
- continue
- resulting_delta_tax_details[tax_detail['tax_repartition_line_id']] = {
- **tax_detail,
- 'amount_currency': tax_amount_currency,
- 'balance': tax_amount,
- }
- # Multiply each amount by the percentage paid by the current payment term line.
- percentage_paid = abs(aml.amount_residual_currency / self.amount_total)
- for tax_detail in tax_computation_per_percentage[aml.discount_percentage].values():
- tax_rep = self.env['account.tax.repartition.line'].browse(tax_detail['tax_repartition_line_id'])
- tax = tax_rep.tax_id
- grouping_dict = {
- 'account_id': tax_detail['account_id'],
- 'partner_id': tax_detail['partner_id'],
- 'currency_id': tax_detail['currency_id'],
- 'analytic_distribution': tax_detail['analytic_distribution'],
- 'tax_repartition_line_id': tax_rep.id,
- 'tax_ids': tax_detail['tax_ids'],
- 'tax_tag_ids': tax_detail['tax_tag_ids'],
- 'group_tax_id': tax_detail['tax_id'] if tax_detail['tax_id'] != tax.id else None,
- }
- res['tax_lines'][aml][frozendict(grouping_dict)] = {
- 'name': _("Early Payment Discount (%s)", tax.name),
- 'amount_currency': aml.currency_id.round(tax_detail['amount_currency'] * percentage_paid),
- 'balance': aml.company_currency_id.round(tax_detail['balance'] * percentage_paid),
- 'tax_tag_invert': True,
- }
- for grouping_dict, base_detail in base_per_percentage[aml.discount_percentage].items():
- res['base_lines'][aml][grouping_dict] = {
- 'name': _("Early Payment Discount"),
- 'amount_currency': aml.currency_id.round(base_detail['amount_currency'] * percentage_paid),
- 'balance': aml.company_currency_id.round(base_detail['balance'] * percentage_paid),
- }
- # Fix the rounding issue if any.
- delta_amount_currency = term_amount_currency \
- - sum(x['amount_currency'] for x in res['base_lines'][aml].values()) \
- - sum(x['amount_currency'] for x in res['tax_lines'][aml].values())
- delta_balance = term_balance \
- - sum(x['balance'] for x in res['base_lines'][aml].values()) \
- - sum(x['balance'] for x in res['tax_lines'][aml].values())
- last_tax_line = (list(res['tax_lines'][aml].values()) or list(res['base_lines'][aml].values()))[-1]
- last_tax_line['amount_currency'] += delta_amount_currency
- last_tax_line['balance'] += delta_balance
- else:
- grouping_dict = {'account_id': cash_discount_account.id}
- res['term_lines'][aml][frozendict(grouping_dict)] = {
- 'name': _("Early Payment Discount"),
- 'partner_id': aml.partner_id.id,
- 'currency_id': aml.currency_id.id,
- 'amount_currency': term_amount_currency,
- 'balance': term_balance,
- }
- return res
- @api.model
- def _get_invoice_counterpart_amls_for_early_payment_discount(self, aml_values_list, open_balance):
- """ Helper to get the values to create the counterpart journal items on the register payment wizard and the
- bank reconciliation widget in case of an early payment discount by taking care of the payment term lines we
- are matching and the exchange difference in case of multi-currencies.
- :param aml_values_list: A list of dictionaries containing:
- * aml: The payment term line we match.
- * amount_currency: The matched amount_currency for this line.
- * balance: The matched balance for this line (could be different in case of multi-currencies).
- :param open_balance: The current open balance to be covered by the early payment discount.
- :return: A list of values to create the counterpart journal items split in 3 categories:
- * term_lines: The journal items containing the discount amounts for each receivable line when the
- discount computation is excluded / mixed.
- * tax_lines: The journal items acting as tax lines when the discount computation is included.
- * base_lines: The journal items acting as base for tax lines when the discount computation is included.
- * exchange_lines: The journal items representing the exchange differences in case of multi-currencies.
- """
- res = {
- 'base_lines': {},
- 'tax_lines': {},
- 'term_lines': {},
- 'exchange_lines': {},
- }
- res_per_invoice = {}
- for aml_values in aml_values_list:
- aml = aml_values['aml']
- invoice = aml.move_id
- if invoice not in res_per_invoice:
- res_per_invoice[invoice] = invoice._get_invoice_counterpart_amls_for_early_payment_discount_per_payment_term_line()
- for key in ('base_lines', 'tax_lines', 'term_lines'):
- for grouping_dict, vals in res_per_invoice[invoice][key][aml].items():
- line_vals = res[key].setdefault(grouping_dict, {
- **vals,
- 'amount_currency': 0.0,
- 'balance': 0.0,
- })
- line_vals['amount_currency'] += vals['amount_currency']
- line_vals['balance'] += vals['balance']
- # Track the balance to handle the exchange difference.
- open_balance -= vals['balance']
- exchange_diff_sign = aml.company_currency_id.compare_amounts(open_balance, 0.0)
- if exchange_diff_sign != 0.0:
- if exchange_diff_sign > 0.0:
- exchange_line_account = aml.company_id.expense_currency_exchange_account_id
- else:
- exchange_line_account = aml.company_id.income_currency_exchange_account_id
- grouping_dict = {
- 'account_id': exchange_line_account.id,
- 'currency_id': aml.currency_id.id,
- 'partner_id': aml.partner_id.id,
- }
- line_vals = res['exchange_lines'].setdefault(frozendict(grouping_dict), {
- **grouping_dict,
- 'name': _("Early Payment Discount (Exchange Difference)"),
- 'amount_currency': 0.0,
- 'balance': 0.0,
- })
- line_vals['balance'] += open_balance
- return {
- key: [
- {
- **grouping_dict,
- **vals,
- }
- for grouping_dict, vals in mapping.items()
- ]
- for key, mapping in res.items()
- }
- def _affect_tax_report(self):
- return any(line._affect_tax_report() for line in (self.line_ids | self.invoice_line_ids))
- def _get_move_display_name(self, show_ref=False):
- ''' Helper to get the display name of an invoice depending of its type.
- :param show_ref: A flag indicating of the display name must include or not the journal entry reference.
- :return: A string representing the invoice.
- '''
- self.ensure_one()
- name = ''
- if self.state == 'draft':
- name += {
- 'out_invoice': _('Draft Invoice'),
- 'out_refund': _('Draft Credit Note'),
- 'in_invoice': _('Draft Bill'),
- 'in_refund': _('Draft Vendor Credit Note'),
- 'out_receipt': _('Draft Sales Receipt'),
- 'in_receipt': _('Draft Purchase Receipt'),
- 'entry': _('Draft Entry'),
- }[self.move_type]
- name += ' '
- if not self.name or self.name == '/':
- name += '(* %s)' % str(self.id)
- else:
- name += self.name
- if self.env.context.get('input_full_display_name'):
- if self.partner_id:
- name += f', {self.partner_id.name}'
- if self.date:
- name += f', {format_date(self.env, self.date)}'
- return name + (f" ({shorten(self.ref, width=50)})" if show_ref and self.ref else '')
- def _get_reconciled_amls(self):
- """Helper used to retrieve the reconciled move lines on this journal entry"""
- reconciled_lines = self.line_ids.filtered(lambda line: line.account_id.account_type in ('asset_receivable', 'liability_payable'))
- return reconciled_lines.mapped('matched_debit_ids.debit_move_id') + reconciled_lines.mapped('matched_credit_ids.credit_move_id')
- def _get_reconciled_payments(self):
- """Helper used to retrieve the reconciled payments on this journal entry"""
- return self._get_reconciled_amls().move_id.payment_id
- def _get_reconciled_statement_lines(self):
- """Helper used to retrieve the reconciled statement lines on this journal entry"""
- return self._get_reconciled_amls().move_id.statement_line_id
- def _get_reconciled_invoices(self):
- """Helper used to retrieve the reconciled invoices on this journal entry"""
- return self._get_reconciled_amls().move_id.filtered(lambda move: move.is_invoice(include_receipts=True))
- def _get_all_reconciled_invoice_partials(self):
- self.ensure_one()
- reconciled_lines = self.line_ids.filtered(lambda line: line.account_id.account_type in ('asset_receivable', 'liability_payable'))
- if not reconciled_lines:
- return {}
- self.env['account.partial.reconcile'].flush_model([
- 'credit_amount_currency', 'credit_move_id', 'debit_amount_currency',
- 'debit_move_id', 'exchange_move_id',
- ])
- query = '''
- SELECT
- part.id,
- part.exchange_move_id,
- part.debit_amount_currency AS amount,
- part.credit_move_id AS counterpart_line_id
- FROM account_partial_reconcile part
- WHERE part.debit_move_id IN %s
- UNION ALL
- SELECT
- part.id,
- part.exchange_move_id,
- part.credit_amount_currency AS amount,
- part.debit_move_id AS counterpart_line_id
- FROM account_partial_reconcile part
- WHERE part.credit_move_id IN %s
- '''
- self._cr.execute(query, [tuple(reconciled_lines.ids)] * 2)
- partial_values_list = []
- counterpart_line_ids = set()
- exchange_move_ids = set()
- for values in self._cr.dictfetchall():
- partial_values_list.append({
- 'aml_id': values['counterpart_line_id'],
- 'partial_id': values['id'],
- 'amount': values['amount'],
- 'currency': self.currency_id,
- })
- counterpart_line_ids.add(values['counterpart_line_id'])
- if values['exchange_move_id']:
- exchange_move_ids.add(values['exchange_move_id'])
- if exchange_move_ids:
- self.env['account.move.line'].flush_model(['move_id'])
- query = '''
- SELECT
- part.id,
- part.credit_move_id AS counterpart_line_id
- FROM account_partial_reconcile part
- JOIN account_move_line credit_line ON credit_line.id = part.credit_move_id
- WHERE credit_line.move_id IN %s AND part.debit_move_id IN %s
- UNION ALL
- SELECT
- part.id,
- part.debit_move_id AS counterpart_line_id
- FROM account_partial_reconcile part
- JOIN account_move_line debit_line ON debit_line.id = part.debit_move_id
- WHERE debit_line.move_id IN %s AND part.credit_move_id IN %s
- '''
- self._cr.execute(query, [tuple(exchange_move_ids), tuple(counterpart_line_ids)] * 2)
- for values in self._cr.dictfetchall():
- counterpart_line_ids.add(values['counterpart_line_id'])
- partial_values_list.append({
- 'aml_id': values['counterpart_line_id'],
- 'partial_id': values['id'],
- 'currency': self.company_id.currency_id,
- })
- counterpart_lines = {x.id: x for x in self.env['account.move.line'].browse(counterpart_line_ids)}
- for partial_values in partial_values_list:
- partial_values['aml'] = counterpart_lines[partial_values['aml_id']]
- partial_values['is_exchange'] = partial_values['aml'].move_id.id in exchange_move_ids
- if partial_values['is_exchange']:
- partial_values['amount'] = abs(partial_values['aml'].balance)
- return partial_values_list
- def _get_reconciled_invoices_partials(self):
- ''' Helper to retrieve the details about reconciled invoices.
- :return A list of tuple (partial, amount, invoice_line).
- '''
- self.ensure_one()
- pay_term_lines = self.line_ids\
- .filtered(lambda line: line.account_type in ('asset_receivable', 'liability_payable'))
- invoice_partials = []
- exchange_diff_moves = []
- for partial in pay_term_lines.matched_debit_ids:
- invoice_partials.append((partial, partial.credit_amount_currency, partial.debit_move_id))
- if partial.exchange_move_id:
- exchange_diff_moves.append(partial.exchange_move_id.id)
- for partial in pay_term_lines.matched_credit_ids:
- invoice_partials.append((partial, partial.debit_amount_currency, partial.credit_move_id))
- if partial.exchange_move_id:
- exchange_diff_moves.append(partial.exchange_move_id.id)
- return invoice_partials, exchange_diff_moves
- def _reverse_moves(self, default_values_list=None, cancel=False):
- ''' Reverse a recordset of account.move.
- If cancel parameter is true, the reconcilable or liquidity lines
- of each original move will be reconciled with its reverse's.
- :param default_values_list: A list of default values to consider per move.
- ('type' & 'reversed_entry_id' are computed in the method).
- :return: An account.move recordset, reverse of the current self.
- '''
- if not default_values_list:
- default_values_list = [{} for move in self]
- if cancel:
- lines = self.mapped('line_ids')
- # Avoid maximum recursion depth.
- if lines:
- lines.remove_move_reconcile()
- reverse_moves = self.env['account.move']
- for move, default_values in zip(self, default_values_list):
- default_values.update({
- 'move_type': TYPE_REVERSE_MAP[move.move_type],
- 'reversed_entry_id': move.id,
- 'partner_id': move.partner_id.id,
- })
- reverse_moves += move.with_context(
- move_reverse_cancel=cancel,
- include_business_fields=True,
- skip_invoice_sync=move.move_type == 'entry',
- ).copy(default_values)
- reverse_moves.with_context(skip_invoice_sync=cancel).write({'line_ids': [
- Command.update(line.id, {
- 'balance': -line.balance,
- 'amount_currency': -line.amount_currency,
- })
- for line in reverse_moves.line_ids
- if line.move_id.move_type == 'entry' or line.display_type == 'cogs'
- ]})
- # Reconcile moves together to cancel the previous one.
- if cancel:
- reverse_moves.with_context(move_reverse_cancel=cancel)._post(soft=False)
- for move, reverse_move in zip(self, reverse_moves):
- group = defaultdict(list)
- for line in (move.line_ids + reverse_move.line_ids).filtered(lambda l: not l.reconciled):
- group[(line.account_id, line.currency_id)].append(line.id)
- for (account, dummy), line_ids in group.items():
- if account.reconcile or account.account_type in ('asset_cash', 'liability_credit_card'):
- self.env['account.move.line'].browse(line_ids).with_context(move_reverse_cancel=cancel).reconcile()
- return reverse_moves
- def _post(self, soft=True):
- """Post/Validate the documents.
- Posting the documents will give it a number, and check that the document is
- complete (some fields might not be required if not posted but are required
- otherwise).
- If the journal is locked with a hash table, it will be impossible to change
- some fields afterwards.
- :param soft (bool): if True, future documents are not immediately posted,
- but are set to be auto posted automatically at the set accounting date.
- Nothing will be performed on those documents before the accounting date.
- :return Model<account.move>: the documents that have been posted
- """
- if not self.env.su and not self.env.user.has_group('account.group_account_invoice'):
- raise AccessError(_("You don't have the access rights to post an invoice."))
- for invoice in self.filtered(lambda move: move.is_invoice(include_receipts=True)):
- if invoice.quick_edit_mode and invoice.quick_edit_total_amount and invoice.quick_edit_total_amount != invoice.amount_total:
- raise UserError(_(
- "The current total is %s but the expected total is %s. In order to post the invoice/bill, "
- "you can adjust its lines or the expected Total (tax inc.).",
- formatLang(self.env, invoice.amount_total, currency_obj=invoice.currency_id),
- formatLang(self.env, invoice.quick_edit_total_amount, currency_obj=invoice.currency_id),
- ))
- if invoice.partner_bank_id and not invoice.partner_bank_id.active:
- raise UserError(_(
- "The recipient bank account linked to this invoice is archived.\n"
- "So you cannot confirm the invoice."
- ))
- if float_compare(invoice.amount_total, 0.0, precision_rounding=invoice.currency_id.rounding) < 0:
- raise UserError(_(
- "You cannot validate an invoice with a negative total amount. "
- "You should create a credit note instead. "
- "Use the action menu to transform it into a credit note or refund."
- ))
- if not invoice.partner_id:
- if invoice.is_sale_document():
- raise UserError(_("The field 'Customer' is required, please complete it to validate the Customer Invoice."))
- elif invoice.is_purchase_document():
- raise UserError(_("The field 'Vendor' is required, please complete it to validate the Vendor Bill."))
- # Handle case when the invoice_date is not set. In that case, the invoice_date is set at today and then,
- # lines are recomputed accordingly.
- if not invoice.invoice_date:
- if invoice.is_sale_document(include_receipts=True):
- invoice.invoice_date = fields.Date.context_today(self)
- elif invoice.is_purchase_document(include_receipts=True):
- raise UserError(_("The Bill/Refund date is required to validate this document."))
- for move in self:
- if move.state == 'posted':
- raise UserError(_('The entry %s (id %s) is already posted.') % (move.name, move.id))
- if not move.line_ids.filtered(lambda line: line.display_type not in ('line_section', 'line_note')):
- raise UserError(_('You need to add a line before posting.'))
- if not soft and move.auto_post != 'no' and move.date > fields.Date.context_today(self):
- date_msg = move.date.strftime(get_lang(self.env).date_format)
- raise UserError(_("This move is configured to be auto-posted on %s", date_msg))
- if not move.journal_id.active:
- raise UserError(_(
- "You cannot post an entry in an archived journal (%(journal)s)",
- journal=move.journal_id.display_name,
- ))
- if move.display_inactive_currency_warning:
- raise UserError(_(
- "You cannot validate a document with an inactive currency: %s",
- move.currency_id.name
- ))
- if move.line_ids.account_id.filtered(lambda account: account.deprecated):
- raise UserError(_("A line of this move is using a deprecated account, you cannot post it."))
- if soft:
- future_moves = self.filtered(lambda move: move.date > fields.Date.context_today(self))
- for move in future_moves:
- if move.auto_post == 'no':
- move.auto_post = 'at_date'
- msg = _('This move will be posted at the accounting date: %(date)s', date=format_date(self.env, move.date))
- move.message_post(body=msg)
- to_post = self - future_moves
- else:
- to_post = self
- for move in to_post:
- affects_tax_report = move._affect_tax_report()
- lock_dates = move._get_violated_lock_dates(move.date, affects_tax_report)
- if lock_dates:
- move.date = move._get_accounting_date(move.invoice_date or move.date, affects_tax_report)
- # Create the analytic lines in batch is faster as it leads to less cache invalidation.
- to_post.line_ids._create_analytic_lines()
- # Trigger copying for recurring invoices
- to_post.filtered(lambda m: m.auto_post not in ('no', 'at_date'))._copy_recurring_entries()
- for invoice in to_post:
- # Fix inconsistencies that may occure if the OCR has been editing the invoice at the same time of a user. We force the
- # partner on the lines to be the same as the one on the move, because that's the only one the user can see/edit.
- wrong_lines = invoice.is_invoice() and invoice.line_ids.filtered(lambda aml:
- aml.partner_id != invoice.commercial_partner_id
- and aml.display_type not in ('line_note', 'line_section')
- )
- if wrong_lines:
- wrong_lines.write({'partner_id': invoice.commercial_partner_id.id})
- to_post.write({
- 'state': 'posted',
- 'posted_before': True,
- })
- for invoice in to_post:
- invoice.message_subscribe([
- p.id
- for p in [invoice.partner_id]
- if p not in invoice.sudo().message_partner_ids
- ])
- if (
- invoice.is_sale_document()
- and invoice.journal_id.sale_activity_type_id
- and (invoice.journal_id.sale_activity_user_id or invoice.invoice_user_id).id not in (self.env.ref('base.user_root').id, False)
- ):
- invoice.activity_schedule(
- date_deadline=min((date for date in invoice.line_ids.mapped('date_maturity') if date), default=invoice.date),
- activity_type_id=invoice.journal_id.sale_activity_type_id.id,
- summary=invoice.journal_id.sale_activity_note,
- user_id=invoice.journal_id.sale_activity_user_id.id or invoice.invoice_user_id.id,
- )
- customer_count, supplier_count = defaultdict(int), defaultdict(int)
- for invoice in to_post:
- if invoice.is_sale_document():
- customer_count[invoice.partner_id] += 1
- elif invoice.is_purchase_document():
- supplier_count[invoice.partner_id] += 1
- elif invoice.move_type == 'entry':
- sale_amls = invoice.line_ids.filtered(lambda line: line.partner_id and line.account_id.account_type == 'asset_receivable')
- for partner in sale_amls.mapped('partner_id'):
- customer_count[partner] += 1
- purchase_amls = invoice.line_ids.filtered(lambda line: line.partner_id and line.account_id.account_type == 'liability_payable')
- for partner in purchase_amls.mapped('partner_id'):
- supplier_count[partner] += 1
- for partner, count in customer_count.items():
- (partner | partner.commercial_partner_id)._increase_rank('customer_rank', count)
- for partner, count in supplier_count.items():
- (partner | partner.commercial_partner_id)._increase_rank('supplier_rank', count)
- # Trigger action for paid invoices if amount is zero
- to_post.filtered(
- lambda m: m.is_invoice(include_receipts=True) and m.currency_id.is_zero(m.amount_total)
- )._invoice_paid_hook()
- return to_post
- def _find_and_set_purchase_orders(self, po_references, partner_id, amount_total, prefer_purchase_line=False, timeout=10):
- # hook to be used with purchase, so that vendor bills are sync/autocompleted with purchase orders
- self.ensure_one()
- def _link_invoice_origin_to_purchase_orders(self, timeout=10):
- for move in self.filtered(lambda m: m.move_type in self.get_purchase_types()):
- references = [move.invoice_origin] if move.invoice_origin else []
- move._find_and_set_purchase_orders(references, move.partner_id.id, move.amount_total, timeout)
- return self
- # -------------------------------------------------------------------------
- # PUBLIC ACTIONS
- # -------------------------------------------------------------------------
- def open_reconcile_view(self):
- return self.line_ids.open_reconcile_view()
- def action_open_business_doc(self):
- self.ensure_one()
- if self.payment_id:
- name = _("Payment")
- res_model = 'account.payment'
- res_id = self.payment_id.id
- elif self.statement_line_id:
- name = _("Bank Transaction")
- res_model = 'account.bank.statement.line'
- res_id = self.statement_line_id.id
- else:
- name = _("Journal Entry")
- res_model = 'account.move'
- res_id = self.id
- return {
- 'name': name,
- 'type': 'ir.actions.act_window',
- 'view_mode': 'form',
- 'views': [(False, 'form')],
- 'res_model': res_model,
- 'res_id': res_id,
- 'target': 'current',
- }
- def open_created_caba_entries(self):
- self.ensure_one()
- return {
- 'type': 'ir.actions.act_window',
- 'name': _("Cash Basis Entries"),
- 'res_model': 'account.move',
- 'view_mode': 'form',
- 'domain': [('id', 'in', self.tax_cash_basis_created_move_ids.ids)],
- 'views': [(self.env.ref('account.view_move_tree').id, 'tree'), (False, 'form')],
- }
- def open_duplicated_ref_bill_view(self):
- moves = self + self.duplicated_ref_ids
- action = self.env["ir.actions.actions"]._for_xml_id("account.action_move_line_form")
- action['domain'] = [('id', 'in', moves.ids)]
- return action
- def action_switch_invoice_into_refund_credit_note(self):
- for move in self:
- if move.posted_before:
- raise ValidationError(_("You cannot switch the type of a posted document."))
- if move.move_type == 'entry':
- raise ValidationError(_("This action isn't available for this document."))
- in_out, old_move_type = move.move_type.split('_')
- new_move_type = f"{in_out}_{'invoice' if old_move_type == 'refund' else 'refund'}"
- move.name = False
- move.write({
- 'move_type': new_move_type,
- 'partner_bank_id': False,
- 'currency_id': move.currency_id.id,
- })
- if move.amount_total < 0:
- move.write({
- 'line_ids': [
- Command.update(line.id, {'quantity': -line.quantity})
- for line in move.line_ids
- if line.display_type == 'product'
- ]
- })
- def action_register_payment(self):
- ''' Open the account.payment.register wizard to pay the selected journal entries.
- :return: An action opening the account.payment.register wizard.
- '''
- return {
- 'name': _('Register Payment'),
- 'res_model': 'account.payment.register',
- 'view_mode': 'form',
- 'context': {
- 'active_model': 'account.move',
- 'active_ids': self.ids,
- },
- 'target': 'new',
- 'type': 'ir.actions.act_window',
- }
- def action_invoice_print(self):
- """ Print the invoice and mark it as sent, so that we can see more
- easily the next step of the workflow
- """
- if any(not move.is_invoice(include_receipts=True) for move in self):
- raise UserError(_("Only invoices could be printed."))
- self.filtered(lambda inv: not inv.is_move_sent).write({'is_move_sent': True})
- if self.user_has_groups('account.group_account_invoice'):
- return self.env.ref('account.account_invoices').report_action(self)
- else:
- return self.env.ref('account.account_invoices_without_payment').report_action(self)
- def action_duplicate(self):
- # offer the possibility to duplicate thanks to a button instead of a hidden menu, which is more visible
- self.ensure_one()
- action = self.env["ir.actions.actions"]._for_xml_id("account.action_move_journal_line")
- action['context'] = dict(self.env.context)
- action['context']['form_view_initial_mode'] = 'edit'
- action['context']['view_no_maturity'] = False
- action['views'] = [(self.env.ref('account.view_move_form').id, 'form')]
- action['res_id'] = self.copy().id
- return action
- def action_send_and_print(self):
- return {
- 'name': _('Send Invoice'),
- 'res_model': 'account.invoice.send',
- 'view_mode': 'form',
- 'context': {
- 'default_email_layout_xmlid': 'mail.mail_notification_layout_with_responsible_signature',
- 'default_template_id': self.env.ref(self._get_mail_template()).id,
- 'mark_invoice_as_sent': True,
- 'active_model': 'account.move',
- # Setting both active_id and active_ids is required, mimicking how direct call to
- # ir.actions.act_window works
- 'active_id': self.ids[0],
- 'active_ids': self.ids,
- },
- 'target': 'new',
- 'type': 'ir.actions.act_window',
- }
- def action_invoice_sent(self):
- """ Open a window to compose an email, with the edi invoice template
- message loaded by default
- """
- self.ensure_one()
- template = self.env.ref(self._get_mail_template(), raise_if_not_found=False)
- lang = False
- if template:
- lang = template._render_lang(self.ids)[self.id]
- if not lang:
- lang = get_lang(self.env).code
- compose_form = self.env.ref('account.account_invoice_send_wizard_form', raise_if_not_found=False)
- ctx = dict(
- default_model='account.move',
- default_res_id=self.id,
- # For the sake of consistency we need a default_res_model if
- # default_res_id is set. Not renaming default_model as it can
- # create many side-effects.
- default_res_model='account.move',
- default_use_template=bool(template),
- default_template_id=template and template.id or False,
- default_composition_mode='comment',
- mark_invoice_as_sent=True,
- default_email_layout_xmlid="mail.mail_notification_layout_with_responsible_signature",
- model_description=self.with_context(lang=lang).type_name,
- force_email=True,
- active_ids=self.ids,
- )
- report_action = {
- 'name': _('Send Invoice'),
- 'type': 'ir.actions.act_window',
- 'view_type': 'form',
- 'view_mode': 'form',
- 'res_model': 'account.invoice.send',
- 'views': [(compose_form.id, 'form')],
- 'view_id': compose_form.id,
- 'target': 'new',
- 'context': ctx,
- }
- if self.env.is_admin() and not self.env.company.external_report_layout_id and not self.env.context.get('discard_logo_check'):
- return self.env['ir.actions.report']._action_configure_external_report_layout(report_action)
- return report_action
- def preview_invoice(self):
- self.ensure_one()
- return {
- 'type': 'ir.actions.act_url',
- 'target': 'self',
- 'url': self.get_portal_url(),
- }
- def action_reverse(self):
- action = self.env["ir.actions.actions"]._for_xml_id("account.action_view_account_move_reversal")
- if self.is_invoice():
- action['name'] = _('Credit Note')
- return action
- def action_post(self):
- moves_with_payments = self.filtered('payment_id')
- other_moves = self - moves_with_payments
- if moves_with_payments:
- moves_with_payments.payment_id.action_post()
- if other_moves:
- other_moves._post(soft=False)
- return False
- def js_assign_outstanding_line(self, line_id):
- ''' Called by the 'payment' widget to reconcile a suggested journal item to the present
- invoice.
- :param line_id: The id of the line to reconcile with the current invoice.
- '''
- self.ensure_one()
- lines = self.env['account.move.line'].browse(line_id)
- lines += self.line_ids.filtered(lambda line: line.account_id == lines[0].account_id and not line.reconciled)
- return lines.reconcile()
- def js_remove_outstanding_partial(self, partial_id):
- ''' Called by the 'payment' widget to remove a reconciled entry to the present invoice.
- :param partial_id: The id of an existing partial reconciled with the current invoice.
- '''
- self.ensure_one()
- partial = self.env['account.partial.reconcile'].browse(partial_id)
- return partial.unlink()
- def button_set_checked(self):
- for move in self:
- move.to_check = False
- def button_draft(self):
- exchange_move_ids = set()
- if self:
- self.env['account.full.reconcile'].flush_model(['exchange_move_id'])
- self.env['account.partial.reconcile'].flush_model(['exchange_move_id'])
- self._cr.execute(
- """
- SELECT DISTINCT sub.exchange_move_id
- FROM (
- SELECT exchange_move_id
- FROM account_full_reconcile
- WHERE exchange_move_id IN %s
- UNION ALL
- SELECT exchange_move_id
- FROM account_partial_reconcile
- WHERE exchange_move_id IN %s
- ) AS sub
- """,
- [tuple(self.ids), tuple(self.ids)],
- )
- exchange_move_ids = set([row[0] for row in self._cr.fetchall()])
- for move in self:
- if move.id in exchange_move_ids:
- raise UserError(_('You cannot reset to draft an exchange difference journal entry.'))
- if move.tax_cash_basis_rec_id or move.tax_cash_basis_origin_move_id:
- # If the reconciliation was undone, move.tax_cash_basis_rec_id will be empty;
- # but we still don't want to allow setting the caba entry to draft
- # (it'll have been reversed automatically, so no manual intervention is required),
- # so we also check tax_cash_basis_origin_move_id, which stays unchanged
- # (we need both, as tax_cash_basis_origin_move_id did not exist in older versions).
- raise UserError(_('You cannot reset to draft a tax cash basis journal entry.'))
- if move.restrict_mode_hash_table and move.state == 'posted':
- raise UserError(_('You cannot modify a posted entry of this journal because it is in strict mode.'))
- # We remove all the analytics entries for this journal
- move.mapped('line_ids.analytic_line_ids').unlink()
- self.mapped('line_ids').remove_move_reconcile()
- self.write({'state': 'draft', 'is_move_sent': False})
- def button_cancel(self):
- self.write({'auto_post': 'no', 'state': 'cancel'})
- def action_activate_currency(self):
- self.currency_id.filtered(lambda currency: not currency.active).write({'active': True})
- def _get_mail_template(self):
- """
- :return: the correct mail template based on the current move type
- """
- return (
- 'account.email_template_edi_credit_note'
- if all(move.move_type == 'out_refund' for move in self)
- else 'account.email_template_edi_invoice'
- )
- def _notify_get_recipients_groups(self, msg_vals=None):
- groups = super()._notify_get_recipients_groups(msg_vals)
- local_msg_vals = dict(msg_vals or {})
- if self.move_type != 'entry':
- # This allows partners added to the email list in the sending wizard to access this document.
- for group_name, _group_method, group_data in groups:
- if group_name == 'customer' and self._portal_ensure_token():
- access_link = self._notify_get_action_link(
- 'view', **local_msg_vals, access_token=self.access_token)
- group_data.update({
- 'has_button_access': True,
- 'button_access': {
- 'url': access_link,
- },
- })
- return groups
- def _get_report_base_filename(self):
- return self._get_move_display_name()
- # -------------------------------------------------------------------------
- # CRON
- # -------------------------------------------------------------------------
- def _autopost_draft_entries(self):
- ''' This method is called from a cron job.
- It is used to post entries such as those created by the module
- account_asset and recurring entries created in _post().
- '''
- moves = self.search([
- ('state', '=', 'draft'),
- ('date', '<=', fields.Date.context_today(self)),
- ('auto_post', '!=', 'no'),
- ('to_check', '=', False),
- ], limit=100)
- try: # try posting in batch
- with self.env.cr.savepoint():
- moves._post()
- except UserError: # if at least one move cannot be posted, handle moves one by one
- for move in moves:
- try:
- with self.env.cr.savepoint():
- move._post()
- except UserError as e:
- move.to_check = True
- msg = _('The move could not be posted for the following reason: %(error_message)s', error_message=e)
- move.message_post(body=msg, message_type='comment')
- if len(moves) == 100: # assumes there are more whenever search hits limit
- self.env.ref('account.ir_cron_auto_post_draft_entry')._trigger()
- # -------------------------------------------------------------------------
- # HELPER METHODS
- # -------------------------------------------------------------------------
- @api.model
- def get_invoice_types(self, include_receipts=False):
- return self.get_sale_types(include_receipts) + self.get_purchase_types(include_receipts)
- def is_invoice(self, include_receipts=False):
- return self.is_sale_document(include_receipts) or self.is_purchase_document(include_receipts)
- @api.model
- def get_sale_types(self, include_receipts=False):
- return ['out_invoice', 'out_refund'] + (include_receipts and ['out_receipt'] or [])
- def is_sale_document(self, include_receipts=False):
- return self.move_type in self.get_sale_types(include_receipts)
- @api.model
- def get_purchase_types(self, include_receipts=False):
- return ['in_invoice', 'in_refund'] + (include_receipts and ['in_receipt'] or [])
- def is_purchase_document(self, include_receipts=False):
- return self.move_type in self.get_purchase_types(include_receipts)
- @api.model
- def get_inbound_types(self, include_receipts=True):
- return ['out_invoice', 'in_refund'] + (include_receipts and ['out_receipt'] or [])
- def is_inbound(self, include_receipts=True):
- return self.move_type in self.get_inbound_types(include_receipts)
- @api.model
- def get_outbound_types(self, include_receipts=True):
- return ['in_invoice', 'out_refund'] + (include_receipts and ['in_receipt'] or [])
- def is_outbound(self, include_receipts=True):
- return self.move_type in self.get_outbound_types(include_receipts)
- def _get_accounting_date(self, invoice_date, has_tax):
- """Get correct accounting date for previous periods, taking tax lock date into account.
- When registering an invoice in the past, we still want the sequence to be increasing.
- We then take the last day of the period, depending on the sequence format.
- If there is a tax lock date and there are taxes involved, we register the invoice at the
- last date of the first open period.
- :param invoice_date (datetime.date): The invoice date
- :param has_tax (bool): Iff any taxes are involved in the lines of the invoice
- :return (datetime.date):
- """
- lock_dates = self._get_violated_lock_dates(invoice_date, has_tax)
- today = fields.Date.today()
- highest_name = self.highest_name or self._get_last_sequence(relaxed=True, lock=False)
- number_reset = self._deduce_sequence_number_reset(highest_name)
- if lock_dates:
- invoice_date = lock_dates[-1][0] + timedelta(days=1)
- if self.is_sale_document(include_receipts=True):
- if lock_dates:
- if not highest_name or number_reset == 'month':
- return min(today, date_utils.get_month(invoice_date)[1])
- elif number_reset == 'year':
- return min(today, date_utils.end_of(invoice_date, 'year'))
- else:
- if not highest_name or number_reset == 'month':
- if (today.year, today.month) > (invoice_date.year, invoice_date.month):
- return date_utils.get_month(invoice_date)[1]
- else:
- return max(invoice_date, today)
- elif number_reset == 'year':
- if today.year > invoice_date.year:
- return date(invoice_date.year, 12, 31)
- else:
- return max(invoice_date, today)
- return invoice_date
- def _get_violated_lock_dates(self, invoice_date, has_tax):
- """Get all the lock dates affecting the current invoice_date.
- :param invoice_date: The invoice date
- :param has_tax: If any taxes are involved in the lines of the invoice
- :return: a list of tuples containing the lock dates affecting this move, ordered chronologically.
- """
- locks = []
- user_lock_date = self.company_id._get_user_fiscal_lock_date()
- if invoice_date and user_lock_date and invoice_date <= user_lock_date:
- locks.append((user_lock_date, _('user')))
- tax_lock_date = self.company_id.tax_lock_date
- if invoice_date and tax_lock_date and has_tax and invoice_date <= tax_lock_date:
- locks.append((tax_lock_date, _('tax')))
- locks.sort()
- return locks
- def _get_lock_date_message(self, invoice_date, has_tax):
- """Get a message describing the latest lock date affecting the specified date.
- :param invoice_date: The date to be checked
- :param has_tax: If any taxes are involved in the lines of the invoice
- :return: a message describing the latest lock date affecting this move and the date it will be
- accounted on if posted, or False if no lock dates affect this move.
- """
- lock_dates = self._get_violated_lock_dates(invoice_date, has_tax)
- if lock_dates:
- invoice_date = self._get_accounting_date(invoice_date, has_tax)
- lock_date, lock_type = lock_dates[-1]
- tax_lock_date_message = _(
- "The date is being set prior to the %(lock_type)s lock date %(lock_date)s. "
- "The Journal Entry will be accounted on %(invoice_date)s upon posting.",
- lock_type=lock_type,
- lock_date=format_date(self.env, lock_date),
- invoice_date=format_date(self.env, invoice_date))
- return tax_lock_date_message
- return False
- @api.model
- def _move_dict_to_preview_vals(self, move_vals, currency_id=None):
- preview_vals = {
- 'group_name': "%s, %s" % (format_date(self.env, move_vals['date']) or _('[Not set]'), move_vals['ref']),
- 'items_vals': move_vals['line_ids'],
- }
- for line in preview_vals['items_vals']:
- if 'partner_id' in line[2]:
- # sudo is needed to compute display_name in a multi companies environment
- line[2]['partner_id'] = self.env['res.partner'].browse(line[2]['partner_id']).sudo().display_name
- line[2]['account_id'] = self.env['account.account'].browse(line[2]['account_id']).display_name or _('Destination Account')
- line[2]['debit'] = currency_id and formatLang(self.env, line[2]['debit'], currency_obj=currency_id) or line[2]['debit']
- line[2]['credit'] = currency_id and formatLang(self.env, line[2]['credit'], currency_obj=currency_id) or line[2]['debit']
- return preview_vals
- def _generate_qr_code(self, silent_errors=False):
- """ Generates and returns a QR-code generation URL for this invoice,
- raising an error message if something is misconfigured.
- The chosen QR generation method is the one set in qr_method field if there is one,
- or the first eligible one found. If this search had to be performed and
- and eligible method was found, qr_method field is set to this method before
- returning the URL. If no eligible QR method could be found, we return None.
- """
- self.ensure_one()
- if not self.display_qr_code:
- return None
- qr_code_method = self.qr_code_method
- if qr_code_method:
- # If the user set a qr code generator manually, we check that we can use it
- if not self.partner_bank_id._eligible_for_qr_code(self.qr_code_method, self.partner_id, self.currency_id):
- raise UserError(_("The chosen QR-code type is not eligible for this invoice."))
- else:
- # Else we find one that's eligible and assign it to the invoice
- for candidate_method, _candidate_name in self.env['res.partner.bank'].get_available_qr_methods_in_sequence():
- if self.partner_bank_id._eligible_for_qr_code(candidate_method, self.partner_id, self.currency_id, raises_error=False):
- qr_code_method = candidate_method
- break
- if not qr_code_method:
- # No eligible method could be found; we can't generate the QR-code
- return None
- unstruct_ref = self.ref if self.ref else self.name
- rslt = self.partner_bank_id.build_qr_code_base64(self.amount_residual, unstruct_ref, self.payment_reference, self.currency_id, self.partner_id, qr_code_method, silent_errors=silent_errors)
- # We only set qr_code_method after generating the url; otherwise, it
- # could be set even in case of a failure in the QR code generation
- # (which would change the field, but not refresh UI, making the displayed data inconsistent with db)
- self.qr_code_method = qr_code_method
- return rslt
- @contextmanager
- def _get_edi_creation(self):
- """Get an environment to import documents from other sources.
- Allow to edit the current move or create a new one.
- This will prevent computing the dynamic lines at each invoice line added and only
- compute everything at the end.
- """
- container = {'records': self}
- with self._check_balanced(container),\
- self._disable_discount_precision(),\
- self._sync_dynamic_lines(container):
- move = self or self.create({})
- yield move
- container['records'] = move
- @contextmanager
- def _disable_discount_precision(self):
- """Disable the user defined precision for discounts.
- This is useful for importing documents coming from other softwares and providers.
- The reasonning is that if the document that we are importing has a discount, it
- shouldn't be rounded to the local settings.
- """
- original_precision_get = DecimalPrecision.precision_get
- def precision_get(self, application):
- if application == 'Discount':
- return 100
- return original_precision_get(self, application)
- with patch('odoo.addons.base.models.decimal_precision.DecimalPrecision.precision_get', new=precision_get):
- yield
- # -------------------------------------------------------------------------
- # TOOLING
- # -------------------------------------------------------------------------
- @api.model
- def _field_will_change(self, record, vals, field_name):
- if field_name not in vals:
- return False
- field = record._fields[field_name]
- if field.type == 'many2one':
- return record[field_name].id != vals[field_name]
- if field.type == 'many2many':
- current_ids = set(record[field_name].ids)
- after_write_ids = set(record.new({field_name: vals[field_name]})[field_name].ids)
- return current_ids != after_write_ids
- if field.type == 'one2many':
- return True
- if field.type == 'monetary' and record[field.get_currency_field(record)]:
- return not record[field.get_currency_field(record)].is_zero(record[field_name] - vals[field_name])
- if field.type == 'float':
- record_value = field.convert_to_cache(record[field_name], record)
- to_write_value = field.convert_to_cache(vals[field_name], record)
- return record_value != to_write_value
- return record[field_name] != vals[field_name]
- @api.model
- def _cleanup_write_orm_values(self, record, vals):
- cleaned_vals = dict(vals)
- for field_name in vals.keys():
- if not self._field_will_change(record, vals, field_name):
- del cleaned_vals[field_name]
- return cleaned_vals
- @contextmanager
- def _disable_recursion(self, container, key, default=None, target=True):
- """Apply the context key to all environments inside this context manager.
- If this context key is already set on the recordsets, yield `True`.
- The recordsets modified are the one in the container, as well as all the
- `self` recordsets of the calling stack.
- This more or less gives the wanted context to all records inside of the
- context manager.
- :param container: A mutable dict that needs to at least contain the key
- `records`. Can contain other items if changing the env
- is needed.
- :param key: The context key to apply to the recordsets.
- :param default: the default value of the context key, if it isn't defined
- yet in the context
- :param target: the value of the context key meaning that we shouldn't
- recurse
- :return: True iff we should just exit the context manager
- """
- disabled = container['records'].env.context.get(key, default) == target
- previous_values = {}
- if not disabled: # it wasn't disabled yet, disable it now
- for env in self.env.transaction.envs:
- previous_values[env] = env.context.get(key, EMPTY)
- env.context = frozendict({**env.context, key: target})
- try:
- yield disabled
- finally:
- for env, val in previous_values.items():
- if val != EMPTY:
- env.context = frozendict({**env.context, key: val})
- else:
- env.context = frozendict({k: v for k, v in env.context.items() if k != key})
- # ------------------------------------------------------------
- # MAIL.THREAD
- # ------------------------------------------------------------
- @api.model
- def message_new(self, msg_dict, custom_values=None):
- # EXTENDS mail mail.thread
- # Add custom behavior when receiving a new invoice through the mail's gateway.
- if (custom_values or {}).get('move_type', 'entry') not in ('out_invoice', 'in_invoice'):
- return super().message_new(msg_dict, custom_values=custom_values)
- company = self.env['res.company'].browse(custom_values['company_id']) if custom_values.get('company_id') else self.env.company
- def is_internal_partner(partner):
- # Helper to know if the partner is an internal one.
- return partner == company.partner_id or (partner.user_ids and all(user._is_internal() for user in partner.user_ids))
- extra_domain = False
- if custom_values.get('company_id'):
- extra_domain = ['|', ('company_id', '=', custom_values['company_id']), ('company_id', '=', False)]
- # Search for partners in copy.
- cc_mail_addresses = email_split(msg_dict.get('cc', ''))
- followers = [partner for partner in self._mail_find_partner_from_emails(cc_mail_addresses, extra_domain) if partner]
- # Search for partner that sent the mail.
- from_mail_addresses = email_split(msg_dict.get('from', ''))
- senders = partners = [partner for partner in self._mail_find_partner_from_emails(from_mail_addresses, extra_domain) if partner]
- # Search for partners using the user.
- if not senders:
- senders = partners = list(self._mail_search_on_user(from_mail_addresses))
- if partners:
- # Check we are not in the case when an internal user forwarded the mail manually.
- if is_internal_partner(partners[0]):
- # Search for partners in the mail's body.
- body_mail_addresses = set(email_re.findall(msg_dict.get('body')))
- partners = [
- partner
- for partner in self._mail_find_partner_from_emails(body_mail_addresses, extra_domain)
- if not is_internal_partner(partner) and partner.company_id.id in (False, company.id)
- ]
- # Little hack: Inject the mail's subject in the body.
- if msg_dict.get('subject') and msg_dict.get('body'):
- msg_dict['body'] = '<div><div><h3>%s</h3></div>%s</div>' % (msg_dict['subject'], msg_dict['body'])
- # Create the invoice.
- values = {
- 'name': '/', # we have to give the name otherwise it will be set to the mail's subject
- 'invoice_source_email': from_mail_addresses[0],
- 'partner_id': partners and partners[0].id or False,
- }
- move_ctx = self.with_context(default_move_type=custom_values['move_type'], default_journal_id=custom_values['journal_id'])
- move = super(AccountMove, move_ctx).message_new(msg_dict, custom_values=values)
- move._compute_name() # because the name is given, we need to recompute in case it is the first invoice of the journal
- # Assign followers.
- all_followers_ids = set(partner.id for partner in followers + senders + partners if is_internal_partner(partner))
- move.message_subscribe(list(all_followers_ids))
- return move
- def _message_post_after_hook(self, new_message, message_values):
- # EXTENDS mail mail.thread
- # When posting a message, check the attachment to see if it's an invoice and update with the imported data.
- res = super()._message_post_after_hook(new_message, message_values)
- attachments = new_message.attachment_ids
- if len(self) != 1 or not attachments or self.env.context.get('no_new_invoice') or not self.is_invoice(include_receipts=True):
- return res
- odoobot = self.env.ref('base.partner_root')
- if attachments and self.state != 'draft':
- self.message_post(body=_('The invoice is not a draft, it was not updated from the attachment.'),
- message_type='comment',
- subtype_xmlid='mail.mt_note',
- author_id=odoobot.id)
- return res
- if attachments and self.invoice_line_ids:
- self.message_post(body=_('The invoice already contains lines, it was not updated from the attachment.'),
- message_type='comment',
- subtype_xmlid='mail.mt_note',
- author_id=odoobot.id)
- return res
- decoders = self.env['account.move']._get_update_invoice_from_attachment_decoders(self)
- with self._disable_discount_precision():
- for decoder in sorted(decoders, key=lambda d: d[0]):
- # start with message_main_attachment_id, that way if OCR is installed, only that one will be parsed.
- # this is based on the fact that the ocr will be the last decoder.
- for attachment in attachments.sorted(lambda x: x != self.message_main_attachment_id):
- invoice = decoder[1](attachment, self)
- if invoice:
- return res
- return res
- def _creation_subtype(self):
- # EXTENDS mail mail.thread
- if self.move_type in ('out_invoice', 'out_receipt'):
- return self.env.ref('account.mt_invoice_created')
- else:
- return super()._creation_subtype()
- def _track_subtype(self, init_values):
- # EXTENDS mail mail.thread
- # add custom subtype depending of the state.
- self.ensure_one()
- if not self.is_invoice(include_receipts=True):
- if self.payment_id and 'state' in init_values:
- self.payment_id._message_track(['state'], {self.payment_id.id: init_values})
- return super()._track_subtype(init_values)
- if 'payment_state' in init_values and self.payment_state == 'paid':
- return self.env.ref('account.mt_invoice_paid')
- elif 'state' in init_values and self.state == 'posted' and self.is_sale_document(include_receipts=True):
- return self.env.ref('account.mt_invoice_validated')
- return super()._track_subtype(init_values)
- def _creation_message(self):
- # EXTENDS mail mail.thread
- if not self.is_invoice(include_receipts=True):
- return super()._creation_message()
- return {
- 'out_invoice': _('Invoice Created'),
- 'out_refund': _('Credit Note Created'),
- 'in_invoice': _('Vendor Bill Created'),
- 'in_refund': _('Refund Created'),
- 'out_receipt': _('Sales Receipt Created'),
- 'in_receipt': _('Purchase Receipt Created'),
- }[self.move_type]
- def _notify_by_email_prepare_rendering_context(self, message, msg_vals, model_description=False,
- force_email_company=False, force_email_lang=False):
- # EXTENDS mail mail.thread
- render_context = super()._notify_by_email_prepare_rendering_context(
- message, msg_vals, model_description=model_description,
- force_email_company=force_email_company, force_email_lang=force_email_lang
- )
- subtitles = [render_context['record'].name]
- if self.invoice_date_due and self.payment_state not in ('in_payment', 'paid'):
- subtitles.append(_('%(amount)s due\N{NO-BREAK SPACE}%(date)s',
- amount=format_amount(self.env, self.amount_total, self.currency_id, lang_code=render_context.get('lang')),
- date=format_date(self.env, self.invoice_date_due, date_format='short', lang_code=render_context.get('lang'))
- ))
- else:
- subtitles.append(format_amount(self.env, self.amount_total, self.currency_id, lang_code=render_context.get('lang')))
- render_context['subtitles'] = subtitles
- return render_context
- # -------------------------------------------------------------------------
- # TOOLING
- # -------------------------------------------------------------------------
- def _conditional_add_to_compute(self, fname, condition):
- field = self._fields[fname]
- to_reset = self.filtered(lambda move:
- condition(move)
- and not self.env.is_protected(field, move._origin)
- and (move._origin or not move[fname])
- )
- to_reset.invalidate_recordset([fname])
- self.env.add_to_compute(field, to_reset)
- # -------------------------------------------------------------------------
- # HOOKS
- # -------------------------------------------------------------------------
- def _action_invoice_ready_to_be_sent(self):
- """ Hook allowing custom code when an invoice becomes ready to be sent by mail to the customer.
- For example, when an EDI document must be sent to the government and be signed by it.
- """
- def _is_ready_to_be_sent(self):
- """ Helper telling if a journal entry is ready to be sent by mail to the customer.
- :return: True if the invoice is ready, False otherwise.
- """
- self.ensure_one()
- return True
- @contextmanager
- def _send_only_when_ready(self):
- moves_not_ready = self.filtered(lambda x: not x._is_ready_to_be_sent())
- try:
- yield
- finally:
- moves_now_ready = moves_not_ready.filtered(lambda x: x._is_ready_to_be_sent())
- if moves_now_ready:
- moves_now_ready._action_invoice_ready_to_be_sent()
- def _invoice_paid_hook(self):
- ''' Hook to be overrided called when the invoice moves to the paid state. '''
- def _get_lines_onchange_currency(self):
- # Override needed for COGS
- return self.line_ids
- @api.model
- def _get_invoice_in_payment_state(self):
- ''' Hook to give the state when the invoice becomes fully paid. This is necessary because the users working
- with only invoicing don't want to see the 'in_payment' state. Then, this method will be overridden in the
- accountant module to enable the 'in_payment' state. '''
- return 'paid'
- def _get_name_invoice_report(self):
- """ This method need to be inherit by the localizations if they want to print a custom invoice report instead of
- the default one. For example please review the l10n_ar module """
- self.ensure_one()
- return 'account.report_invoice_document'
- def _get_create_document_from_attachment_decoders(self):
- """ Returns a list of method that are able to create an invoice from an attachment and a priority.
- :returns: A list of tuples (priority, method) where method takes an attachment as parameter.
- """
- return []
- def _get_update_invoice_from_attachment_decoders(self, invoice):
- """ Returns a list of method that are able to create an invoice from an attachment and a priority.
- :param invoice: The invoice on which to update the data.
- :returns: A list of tuples (priority, method) where method takes an attachment as parameter.
- """
- return []
- def _is_downpayment(self):
- ''' Return true if the invoice is a downpayment.
- Down-payments can be created from a sale order. This method is overridden in the sale order module.
- '''
- return False
- @api.model
- def get_invoice_localisation_fields_required_to_invoice(self, country_id):
- """ Returns the list of fields that needs to be filled when creating an invoice for the selected country.
- This is required for some flows that would allow a user to request an invoice from the portal.
- Using these, we can get their information and dynamically create form inputs based for the fields required legally for the company country_id.
- The returned fields must be of type ir.model.fields in order to handle translations
- :param country_id: The country for which we want the fields.
- :return: an array of ir.model.fields for which the user should provide values.
- """
- return []
|