Начальное наполнение
This commit is contained in:
parent
46dce11e57
commit
b27b09a5da
63
README.md
63
README.md
@ -1,2 +1,63 @@
|
||||
# purchase
|
||||
Odoo Supply Chain
|
||||
-----------------
|
||||
|
||||
Automate requisition-to-pay, control invoicing with the Odoo
|
||||
<a href="https://www.odoo.com/app/purchase">Open Source Supply Chain</a>.
|
||||
|
||||
Automate procurement propositions, launch request for quotations, track
|
||||
purchase orders, manage vendors' information, control products reception and
|
||||
check vendors' invoices.
|
||||
|
||||
Automated Procurement Propositions
|
||||
----------------------------------
|
||||
|
||||
Reduce inventory level with procurement rules. Get the right purchase
|
||||
proposition at the right time to reduce your inventory level. Improve your
|
||||
purchase and inventory performance with procurement rules depending on stock
|
||||
levels, logistic rules, sales orders, forecasted manufacturing orders, etc.
|
||||
|
||||
Send requests for quotations or purchase orders to your vendor in one click.
|
||||
Get access to product receptions and invoices from your purchase order.
|
||||
|
||||
Purchase Tenders
|
||||
----------------
|
||||
|
||||
Launch purchase tenders, integrate vendor's answers in the process and
|
||||
compare propositions. Choose the best offer and send purchase orders easily.
|
||||
Use reporting to analyse the quality of your vendors afterwards.
|
||||
|
||||
|
||||
Email integrations
|
||||
------------------
|
||||
|
||||
Integrate all vendor's communications on the purchase orders (or RfQs) to get
|
||||
a strong traceability on the negotiation or after sales service issues. Use the
|
||||
claim management module to track issues related to vendors.
|
||||
|
||||
Standard Price, Average Price, FIFO
|
||||
-----------------------------------
|
||||
|
||||
Use the costing method that reflects your business: standard price, average
|
||||
price, fifo or lifo. Get your accounting entries and the right inventory
|
||||
valuation in real-time; Odoo manages everything for you, transparently.
|
||||
|
||||
Import Vendor Pricelists
|
||||
--------------------------
|
||||
|
||||
Take smart purchase decisions using the best prices. Easily import vendor's
|
||||
pricelists to make smarter purchase decisions based on promotions, prices
|
||||
depending on quantities and special contract conditions. You can even base your
|
||||
sale price depending on your vendor's prices.
|
||||
|
||||
Control Products and Invoices
|
||||
-----------------------------
|
||||
|
||||
No product or order is left behind, the inventory control allows you to manage
|
||||
back orders, refunds, product reception and quality control. Choose the right
|
||||
control method according to your need.
|
||||
|
||||
Control vendor bills with no effort. Choose the right method according to
|
||||
your need: pre-generate draft invoices based on purchase orders, on products
|
||||
receptions, create invoices manually and import lines from purchase orders,
|
||||
etc.
|
||||
|
||||
|
7
__init__.py
Normal file
7
__init__.py
Normal file
@ -0,0 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import controllers
|
||||
from . import models
|
||||
from . import report
|
||||
from . import populate
|
54
__manifest__.py
Normal file
54
__manifest__.py
Normal file
@ -0,0 +1,54 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
{
|
||||
'name': 'Purchase',
|
||||
'version': '1.2',
|
||||
'category': 'Inventory/Purchase',
|
||||
'sequence': 35,
|
||||
'summary': 'Purchase orders, tenders and agreements',
|
||||
'website': 'https://www.odoo.com/app/purchase',
|
||||
'depends': ['account'],
|
||||
'data': [
|
||||
'security/purchase_security.xml',
|
||||
'security/ir.model.access.csv',
|
||||
'data/digest_data.xml',
|
||||
'views/account_move_views.xml',
|
||||
'data/purchase_data.xml',
|
||||
'data/ir_cron_data.xml',
|
||||
'report/purchase_reports.xml',
|
||||
'views/purchase_views.xml',
|
||||
'views/res_config_settings_views.xml',
|
||||
'views/product_views.xml',
|
||||
'views/res_partner_views.xml',
|
||||
'report/purchase_bill_views.xml',
|
||||
'report/purchase_report_views.xml',
|
||||
'data/mail_templates.xml',
|
||||
'data/mail_template_data.xml',
|
||||
'views/portal_templates.xml',
|
||||
'report/purchase_order_templates.xml',
|
||||
'report/purchase_quotation_templates.xml',
|
||||
'views/product_packaging_views.xml',
|
||||
'views/analytic_account_views.xml',
|
||||
],
|
||||
'demo': [
|
||||
'data/purchase_demo.xml',
|
||||
],
|
||||
'installable': True,
|
||||
'application': True,
|
||||
'assets': {
|
||||
'web.assets_backend': [
|
||||
'purchase/static/src/product_catalog/**/*',
|
||||
'purchase/static/src/toaster_button/*',
|
||||
'purchase/static/src/views/*.js',
|
||||
'purchase/static/src/js/tours/purchase.js',
|
||||
'purchase/static/src/js/tours/purchase_steps.js',
|
||||
'purchase/static/src/**/*.xml',
|
||||
],
|
||||
'web.assets_frontend': [
|
||||
'purchase/static/src/js/purchase_datetimepicker.js',
|
||||
'purchase/static/src/js/purchase_portal_sidebar.js',
|
||||
],
|
||||
},
|
||||
'license': 'LGPL-3',
|
||||
}
|
4
controllers/__init__.py
Normal file
4
controllers/__init__.py
Normal file
@ -0,0 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import portal
|
198
controllers/portal.py
Normal file
198
controllers/portal.py
Normal file
@ -0,0 +1,198 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import base64
|
||||
from collections import OrderedDict
|
||||
from datetime import datetime
|
||||
|
||||
from odoo import http
|
||||
from odoo.exceptions import AccessError, MissingError
|
||||
from odoo.http import request, Response
|
||||
from odoo.tools import image_process
|
||||
from odoo.tools.translate import _
|
||||
from odoo.addons.portal.controllers import portal
|
||||
from odoo.addons.portal.controllers.portal import pager as portal_pager
|
||||
|
||||
|
||||
class CustomerPortal(portal.CustomerPortal):
|
||||
|
||||
def _prepare_home_portal_values(self, counters):
|
||||
values = super()._prepare_home_portal_values(counters)
|
||||
PurchaseOrder = request.env['purchase.order']
|
||||
if 'rfq_count' in counters:
|
||||
values['rfq_count'] = PurchaseOrder.search_count([
|
||||
('state', 'in', ['sent'])
|
||||
]) if PurchaseOrder.check_access_rights('read', raise_exception=False) else 0
|
||||
if 'purchase_count' in counters:
|
||||
values['purchase_count'] = PurchaseOrder.search_count([
|
||||
('state', 'in', ['purchase', 'done', 'cancel'])
|
||||
]) if PurchaseOrder.check_access_rights('read', raise_exception=False) else 0
|
||||
return values
|
||||
|
||||
def _get_purchase_searchbar_sortings(self):
|
||||
return {
|
||||
'date': {'label': _('Newest'), 'order': 'create_date desc, id desc'},
|
||||
'name': {'label': _('Name'), 'order': 'name asc, id asc'},
|
||||
'amount_total': {'label': _('Total'), 'order': 'amount_total desc, id desc'},
|
||||
}
|
||||
|
||||
def _render_portal(self, template, page, date_begin, date_end, sortby, filterby, domain, searchbar_filters, default_filter, url, history, page_name, key):
|
||||
values = self._prepare_portal_layout_values()
|
||||
PurchaseOrder = request.env['purchase.order']
|
||||
|
||||
if date_begin and date_end:
|
||||
domain += [('create_date', '>', date_begin), ('create_date', '<=', date_end)]
|
||||
|
||||
searchbar_sortings = self._get_purchase_searchbar_sortings()
|
||||
# default sort
|
||||
if not sortby:
|
||||
sortby = 'date'
|
||||
order = searchbar_sortings[sortby]['order']
|
||||
|
||||
if searchbar_filters:
|
||||
# default filter
|
||||
if not filterby:
|
||||
filterby = default_filter
|
||||
domain += searchbar_filters[filterby]['domain']
|
||||
|
||||
# count for pager
|
||||
count = PurchaseOrder.search_count(domain)
|
||||
|
||||
# make pager
|
||||
pager = portal_pager(
|
||||
url=url,
|
||||
url_args={'date_begin': date_begin, 'date_end': date_end, 'sortby': sortby, 'filterby': filterby},
|
||||
total=count,
|
||||
page=page,
|
||||
step=self._items_per_page
|
||||
)
|
||||
|
||||
# search the purchase orders to display, according to the pager data
|
||||
orders = PurchaseOrder.search(
|
||||
domain,
|
||||
order=order,
|
||||
limit=self._items_per_page,
|
||||
offset=pager['offset']
|
||||
)
|
||||
request.session[history] = orders.ids[:100]
|
||||
|
||||
values.update({
|
||||
'date': date_begin,
|
||||
key: orders,
|
||||
'page_name': page_name,
|
||||
'pager': pager,
|
||||
'searchbar_sortings': searchbar_sortings,
|
||||
'sortby': sortby,
|
||||
'searchbar_filters': OrderedDict(sorted(searchbar_filters.items())),
|
||||
'filterby': filterby,
|
||||
'default_url': url,
|
||||
})
|
||||
return request.render(template, values)
|
||||
|
||||
def _purchase_order_get_page_view_values(self, order, access_token, **kwargs):
|
||||
#
|
||||
def resize_to_48(source):
|
||||
if not source:
|
||||
source = request.env['ir.binary']._placeholder()
|
||||
else:
|
||||
source = base64.b64decode(source)
|
||||
return base64.b64encode(image_process(source, size=(48, 48)))
|
||||
|
||||
values = {
|
||||
'order': order,
|
||||
'resize_to_48': resize_to_48,
|
||||
'report_type': 'html',
|
||||
}
|
||||
if order.state in ('sent'):
|
||||
history = 'my_rfqs_history'
|
||||
else:
|
||||
history = 'my_purchases_history'
|
||||
return self._get_page_view_values(order, access_token, values, history, False, **kwargs)
|
||||
|
||||
@http.route(['/my/rfq', '/my/rfq/page/<int:page>'], type='http', auth="user", website=True)
|
||||
def portal_my_requests_for_quotation(self, page=1, date_begin=None, date_end=None, sortby=None, filterby=None, **kw):
|
||||
return self._render_portal(
|
||||
"purchase.portal_my_purchase_rfqs",
|
||||
page, date_begin, date_end, sortby, filterby,
|
||||
[('state', '=', 'sent')],
|
||||
{},
|
||||
None,
|
||||
"/my/rfq",
|
||||
'my_rfqs_history',
|
||||
'rfq',
|
||||
'rfqs'
|
||||
)
|
||||
|
||||
@http.route(['/my/purchase', '/my/purchase/page/<int:page>'], type='http', auth="user", website=True)
|
||||
def portal_my_purchase_orders(self, page=1, date_begin=None, date_end=None, sortby=None, filterby=None, **kw):
|
||||
return self._render_portal(
|
||||
"purchase.portal_my_purchase_orders",
|
||||
page, date_begin, date_end, sortby, filterby,
|
||||
[],
|
||||
{
|
||||
'all': {'label': _('All'), 'domain': [('state', 'in', ['purchase', 'done', 'cancel'])]},
|
||||
'purchase': {'label': _('Purchase Order'), 'domain': [('state', '=', 'purchase')]},
|
||||
'cancel': {'label': _('Cancelled'), 'domain': [('state', '=', 'cancel')]},
|
||||
'done': {'label': _('Locked'), 'domain': [('state', '=', 'done')]},
|
||||
},
|
||||
'all',
|
||||
"/my/purchase",
|
||||
'my_purchases_history',
|
||||
'purchase',
|
||||
'orders'
|
||||
)
|
||||
|
||||
@http.route(['/my/purchase/<int:order_id>'], type='http', auth="public", website=True)
|
||||
def portal_my_purchase_order(self, order_id=None, access_token=None, **kw):
|
||||
try:
|
||||
order_sudo = self._document_check_access('purchase.order', order_id, access_token=access_token)
|
||||
except (AccessError, MissingError):
|
||||
return request.redirect('/my')
|
||||
|
||||
report_type = kw.get('report_type')
|
||||
if report_type in ('html', 'pdf', 'text'):
|
||||
return self._show_report(model=order_sudo, report_type=report_type, report_ref='purchase.action_report_purchase_order', download=kw.get('download'))
|
||||
|
||||
confirm_type = kw.get('confirm')
|
||||
if confirm_type == 'reminder':
|
||||
order_sudo.confirm_reminder_mail(kw.get('confirmed_date'))
|
||||
if confirm_type == 'reception':
|
||||
order_sudo._confirm_reception_mail()
|
||||
|
||||
values = self._purchase_order_get_page_view_values(order_sudo, access_token, **kw)
|
||||
update_date = kw.get('update')
|
||||
if order_sudo.company_id:
|
||||
values['res_company'] = order_sudo.company_id
|
||||
if update_date == 'True':
|
||||
return request.render("purchase.portal_my_purchase_order_update_date", values)
|
||||
return request.render("purchase.portal_my_purchase_order", values)
|
||||
|
||||
@http.route(['/my/purchase/<int:order_id>/update'], type='json', auth="public", website=True)
|
||||
def portal_my_purchase_order_update_dates(self, order_id=None, access_token=None, **kw):
|
||||
"""User update scheduled date on purchase order line.
|
||||
"""
|
||||
try:
|
||||
order_sudo = self._document_check_access('purchase.order', order_id, access_token=access_token)
|
||||
except (AccessError, MissingError):
|
||||
return request.redirect('/my')
|
||||
|
||||
updated_dates = []
|
||||
for id_str, date_str in kw.items():
|
||||
try:
|
||||
line_id = int(id_str)
|
||||
except ValueError:
|
||||
return request.redirect(order_sudo.get_portal_url())
|
||||
line = order_sudo.order_line.filtered(lambda l: l.id == line_id)
|
||||
if not line:
|
||||
return request.redirect(order_sudo.get_portal_url())
|
||||
|
||||
try:
|
||||
updated_date = line._convert_to_middle_of_day(datetime.strptime(date_str, '%Y-%m-%d'))
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
updated_dates.append((line, updated_date))
|
||||
|
||||
if updated_dates:
|
||||
order_sudo._update_date_planned_for_lines(updated_dates)
|
||||
return Response(status=204)
|
28
data/digest_data.xml
Normal file
28
data/digest_data.xml
Normal file
@ -0,0 +1,28 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<odoo>
|
||||
<data>
|
||||
<record id="digest_tip_purchase_0" model="digest.tip">
|
||||
<field name="name">Tip: How to keep late receipts under control?</field>
|
||||
<field name="sequence">100</field>
|
||||
<field name="group_id" ref="purchase.group_purchase_user" />
|
||||
<field name="tip_description" type="html">
|
||||
<div>
|
||||
<p class="tip_title">Tip: How to keep late receipts under control?</p>
|
||||
<p class="tip_content">When creating a purchase order, have a look at the vendor's <i>On Time Delivery</i> rate: the percentage of products shipped on time. If it is too low, activate the <i>automated reminders</i>. A few days before the due shipment, Odoo will send the vendor an email to ask confirmation of shipment dates and keep you informed in case of any delays. To get the vendor's performance statistics, click on the OTD rate.</p>
|
||||
<img src="https://download.odoocdn.com/digests/purchase/static/src/img/OTDPurchase.gif" width="540" class="illustration_border" />
|
||||
</div>
|
||||
</field>
|
||||
</record>
|
||||
<record id="digest_tip_purchase_1" model="digest.tip">
|
||||
<field name="name">Tip: Never miss a purchase order</field>
|
||||
<field name="sequence">2000</field>
|
||||
<field name="group_id" ref="purchase.group_purchase_user" />
|
||||
<field name="tip_description" type="html">
|
||||
<div>
|
||||
<p class="tip_title">Tip: Never miss a purchase order</p>
|
||||
<p class="tip_content">When sending a purchase order by email, Odoo asks the vendor to acknowledge the reception of the order. When the vendor acknowledges the order by clicking on a button in the email, the information is added on the purchase order. Use filters to track orders that have not been acknowledged.</p>
|
||||
</div>
|
||||
</field>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
14
data/ir_cron_data.xml
Normal file
14
data/ir_cron_data.xml
Normal file
@ -0,0 +1,14 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<odoo noupdate="1">
|
||||
<record forcecreate="True" id="purchase_send_reminder_mail" model="ir.cron">
|
||||
<field name="name">Purchase reminder</field>
|
||||
<field name="user_id" ref="base.user_root" />
|
||||
<field name="interval_number">1</field>
|
||||
<field name="interval_type">days</field>
|
||||
<field name="numbercall">-1</field>
|
||||
<field name="doall">1</field>
|
||||
<field name="model_id" ref="model_purchase_order"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model._send_reminder_mail()</field>
|
||||
</record>
|
||||
</odoo>
|
116
data/mail_template_data.xml
Normal file
116
data/mail_template_data.xml
Normal file
@ -0,0 +1,116 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
<record id="email_template_edi_purchase" model="mail.template">
|
||||
<field name="name">Purchase: Request For Quotation</field>
|
||||
<field name="model_id" ref="purchase.model_purchase_order"/>
|
||||
<field name="subject">{{ object.company_id.name }} Order (Ref {{ object.name or 'n/a' }})</field>
|
||||
<field name="partner_to">{{ object.partner_id.id }}</field>
|
||||
<field name="description">Sent manually to vendor to request a quotation</field>
|
||||
<field name="body_html" type="html">
|
||||
<div style="margin: 0px; padding: 0px;">
|
||||
<p style="margin: 0px; padding: 0px; font-size: 13px;">
|
||||
Dear <t t-out="object.partner_id.name or ''">Brandon Freeman</t>
|
||||
<t t-if="object.partner_id.parent_id">
|
||||
(<t t-out="object.partner_id.parent_id.name or ''">Azure Interior</t>)
|
||||
</t>
|
||||
<br/><br/>
|
||||
Here is in attachment a request for quotation <span style="font-weight:bold;" t-out="object.name or ''">P00015</span>
|
||||
<t t-if="object.partner_ref">
|
||||
with reference: <t t-out="object.partner_ref or ''">REF_XXX</t>
|
||||
</t>
|
||||
from <t t-out="object.company_id.name or ''">YourCompany</t>.
|
||||
<br/><br/>
|
||||
If you have any questions, please do not hesitate to contact us.
|
||||
<br/><br/>
|
||||
Best regards,
|
||||
<t t-if="not is_html_empty(object.user_id.signature)">
|
||||
<br/><br/>
|
||||
<t t-out="object.user_id.signature or ''">--<br/>Mitchell Admin</t>
|
||||
</t>
|
||||
</p>
|
||||
</div></field>
|
||||
<field name="report_template_ids" eval="[(4, ref('purchase.report_purchase_quotation'))]"/>
|
||||
<field name="lang">{{ object.partner_id.lang }}</field>
|
||||
<field name="auto_delete" eval="True"/>
|
||||
</record>
|
||||
|
||||
<record id="email_template_edi_purchase_done" model="mail.template">
|
||||
<field name="name">Purchase: Purchase Order</field>
|
||||
<field name="model_id" ref="purchase.model_purchase_order"/>
|
||||
<field name="subject">{{ object.company_id.name }} Order (Ref {{ object.name or 'n/a' }})</field>
|
||||
<field name="partner_to">{{ object.partner_id.id }}</field>
|
||||
<field name="description">Sent to vendor with the purchase order in attachment</field>
|
||||
<field name="body_html" type="html">
|
||||
<div style="margin: 0px; padding: 0px;">
|
||||
<p style="margin: 0px; padding: 0px; font-size: 13px;">
|
||||
Dear <t t-out="object.partner_id.name or ''">Brandon Freeman</t>
|
||||
<t t-if="object.partner_id.parent_id">
|
||||
(<t t-out="object.partner_id.parent_id.name or ''">Azure Interior</t>)
|
||||
</t>
|
||||
<br/><br/>
|
||||
Here is in attachment a purchase order <span style="font-weight:bold;" t-out="object.name or ''">P00015</span>
|
||||
<t t-if="object.partner_ref">
|
||||
with reference: <t t-out="object.partner_ref or ''">REF_XXX</t>
|
||||
</t>
|
||||
amounting in <span style="font-weight:bold;" t-out="format_amount(object.amount_total, object.currency_id) or ''">$ 10.00</span>
|
||||
from <t t-out="object.company_id.name or ''">YourCompany</t>.
|
||||
<br/><br/>
|
||||
<t t-if="object.date_planned">
|
||||
The receipt is expected for <span style="font-weight:bold;" t-out="format_date(object.date_planned) or ''">05/05/2021</span>.
|
||||
<br/><br/>
|
||||
Could you please acknowledge the receipt of this order?
|
||||
</t>
|
||||
<t t-if="not is_html_empty(object.user_id.signature)">
|
||||
<br/><br/>
|
||||
<t t-out="object.user_id.signature or ''">--<br/>Mitchell Admin</t>
|
||||
</t>
|
||||
<br/><br/>
|
||||
</p>
|
||||
</div></field>
|
||||
<field name="report_template_ids" eval="[(4, ref('purchase.action_report_purchase_order'))]"/>
|
||||
<field name="lang">{{ object.partner_id.lang }}</field>
|
||||
<field name="auto_delete" eval="True"/>
|
||||
</record>
|
||||
|
||||
<record id="email_template_edi_purchase_reminder" model="mail.template">
|
||||
<field name="name">Purchase: Vendor Reminder</field>
|
||||
<field name="model_id" ref="purchase.model_purchase_order"/>
|
||||
<field name="email_from">{{ (object.user_id.email_formatted or user.email_formatted) }}</field>
|
||||
<field name="subject">{{ object.company_id.name }} Order (Ref {{ object.name or 'n/a' }})</field>
|
||||
<field name="partner_to">{{ object.partner_id.id }}</field>
|
||||
<field name="description">Sent to vendors before expected arrival, based on the purchase order setting</field>
|
||||
<field name="body_html" type="html">
|
||||
<div style="margin: 0px; padding: 0px;">
|
||||
<p style="margin: 0px; padding: 0px; font-size: 13px;">
|
||||
Dear <t t-out="object.partner_id.name or ''">Brandon Freeman</t>
|
||||
<t t-if="object.partner_id.parent_id">
|
||||
(<t t-out="object.partner_id.parent_id.name or ''">Azure Interior</t>)
|
||||
</t>
|
||||
<br/><br/>
|
||||
Here is a reminder that the delivery of the purchase order <span style="font-weight:bold;" t-out="object.name or ''">P00015</span>
|
||||
<t t-if="object.partner_ref">
|
||||
<span style="font-weight:bold;">(<t t-out="object.partner_ref or ''">REF_XXX</t>)</span>
|
||||
</t>
|
||||
is expected for
|
||||
<t t-if="object.date_planned">
|
||||
<span style="font-weight:bold;" t-out="format_date(object.date_planned) or ''">05/05/2021</span>.
|
||||
</t>
|
||||
<t t-else="">
|
||||
<span style="font-weight:bold;">undefined</span>.
|
||||
</t>
|
||||
Could you please confirm it will be delivered on time?
|
||||
<t t-if="not is_html_empty(object.user_id.signature)">
|
||||
<br/><br/>
|
||||
<t t-out="object.user_id.signature or ''">--<br/>Mitchell Admin</t>
|
||||
</t>
|
||||
<br/><br/>
|
||||
</p>
|
||||
</div></field>
|
||||
<field name="report_template_ids" eval="[(4, ref('purchase.action_report_purchase_order'))]"/>
|
||||
<field name="lang">{{ object.partner_id.lang }}</field>
|
||||
<field name="auto_delete" eval="True"/>
|
||||
</record>
|
||||
|
||||
</data>
|
||||
</odoo>
|
28
data/mail_templates.xml
Normal file
28
data/mail_templates.xml
Normal file
@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo><data noupdate="1">
|
||||
|
||||
<template id="track_po_line_template">
|
||||
<div>
|
||||
<strong>The ordered quantity has been updated.</strong>
|
||||
<ul>
|
||||
<li><t t-esc="line.product_id.display_name"/>:</li>
|
||||
Ordered Quantity: <t t-esc="line.product_qty" /> -> <t t-esc="float(product_qty)"/><br/>
|
||||
<t t-if='line.order_id.product_id.type in ("consu", "product")'>
|
||||
Received Quantity: <t t-esc="line.qty_received" /><br/>
|
||||
</t>
|
||||
Billed Quantity: <t t-esc="line.qty_invoiced"/>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template id="track_po_line_qty_received_template">
|
||||
<div>
|
||||
<strong>The received quantity has been updated.</strong>
|
||||
<ul>
|
||||
<li><t t-esc="line.product_id.display_name"/>:</li>
|
||||
Received Quantity: <t t-esc="line.qty_received" /> -> <t t-esc="float(qty_received)"/><br/>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
</data></odoo>
|
59
data/purchase_data.xml
Normal file
59
data/purchase_data.xml
Normal file
@ -0,0 +1,59 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
<!-- Purchase-related subtypes for messaging / Chatter -->
|
||||
<record id="mt_rfq_confirmed" model="mail.message.subtype">
|
||||
<field name="name">RFQ Confirmed</field>
|
||||
<field name="default" eval="False"/>
|
||||
<field name="res_model">purchase.order</field>
|
||||
</record>
|
||||
<record id="mt_rfq_approved" model="mail.message.subtype">
|
||||
<field name="name">RFQ Approved</field>
|
||||
<field name="default" eval="False"/>
|
||||
<field name="res_model">purchase.order</field>
|
||||
</record>
|
||||
<record id="mt_rfq_done" model="mail.message.subtype">
|
||||
<field name="name">RFQ Done</field>
|
||||
<field name="default" eval="False"/>
|
||||
<field name="res_model">purchase.order</field>
|
||||
</record>
|
||||
<record id="mt_rfq_sent" model="mail.message.subtype">
|
||||
<field name="name">RFQ Sent</field>
|
||||
<field name="default" eval="False"/>
|
||||
<field name="res_model">purchase.order</field>
|
||||
</record>
|
||||
|
||||
<!-- Sequences for purchase.order -->
|
||||
<record id="seq_purchase_order" model="ir.sequence">
|
||||
<field name="name">Purchase Order</field>
|
||||
<field name="code">purchase.order</field>
|
||||
<field name="prefix">P</field>
|
||||
<field name="padding">5</field>
|
||||
<field name="company_id" eval="False"/>
|
||||
</record>
|
||||
|
||||
<!-- Share Button in action menu -->
|
||||
<record id="model_purchase_order_action_share" model="ir.actions.server">
|
||||
<field name="name">Share</field>
|
||||
<field name="model_id" ref="purchase.model_purchase_order"/>
|
||||
<field name="binding_model_id" ref="purchase.model_purchase_order"/>
|
||||
<field name="binding_view_types">form</field>
|
||||
<field name="state">code</field>
|
||||
<field name="code">action = records.action_share()</field>
|
||||
</record>
|
||||
|
||||
<!-- Default value for company_dependant field -->
|
||||
<record forcecreate="True" id="receipt_reminder_email" model="ir.property">
|
||||
<field name="name">receipt_reminder_email</field>
|
||||
<field name="type" eval="'boolean'"/>
|
||||
<field name="fields_id" search="[('model','=','res.partner'),('name','=','receipt_reminder_email')]"/>
|
||||
<field eval="False" name="value"/>
|
||||
</record>
|
||||
<record forcecreate="True" id="reminder_date_before_receipt" model="ir.property">
|
||||
<field name="name">reminder_date_before_receipt</field>
|
||||
<field name="type" eval="'integer'"/>
|
||||
<field name="fields_id" search="[('model','=','res.partner'),('name','=','reminder_date_before_receipt')]"/>
|
||||
<field eval="1" name="value"/>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
300
data/purchase_demo.xml
Normal file
300
data/purchase_demo.xml
Normal file
@ -0,0 +1,300 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
|
||||
<record id="base.user_demo" model="res.users">
|
||||
<field name="groups_id" eval="[(3, ref('purchase.group_purchase_manager'))]"/>
|
||||
</record>
|
||||
|
||||
<record id="base.res_partner_1" model="res.partner">
|
||||
<field name="receipt_reminder_email">True</field>
|
||||
</record>
|
||||
|
||||
<record id="base.res_partner_2" model="res.partner">
|
||||
<field name="receipt_reminder_email">True</field>
|
||||
</record>
|
||||
|
||||
<record id="base.res_partner_12" model="res.partner">
|
||||
<field name="receipt_reminder_email">True</field>
|
||||
</record>
|
||||
|
||||
<record id="purchase_order_1" model="purchase.order">
|
||||
<field name="partner_id" ref="base.res_partner_1"/>
|
||||
<field name="user_id" ref="base.user_admin"/>
|
||||
<field name="state">draft</field>
|
||||
<field name="order_line" model="purchase.order.line" eval="[(5, 0, 0),
|
||||
(0, 0, {
|
||||
'product_id': ref('product.product_delivery_01'),
|
||||
'name': obj().env.ref('product.product_delivery_01').partner_ref,
|
||||
'price_unit': 79.80,
|
||||
'product_qty': 15.0,
|
||||
'product_uom': ref('uom.product_uom_unit'),
|
||||
'date_planned': DateTime.today() + relativedelta(days=3)}),
|
||||
(0, 0, {
|
||||
'product_id': ref('product.product_product_25'),
|
||||
'name': obj().env.ref('product.product_product_25').partner_ref,
|
||||
'price_unit': 286.70,
|
||||
'product_qty': 5.0,
|
||||
'product_uom': ref('uom.product_uom_unit'),
|
||||
'date_planned': DateTime.today() + relativedelta(days=3)}),
|
||||
(0, 0, {
|
||||
'product_id': ref('product.product_product_27'),
|
||||
'name': obj().env.ref('product.product_product_27').partner_ref,
|
||||
'price_unit': 99.00,
|
||||
'product_qty': 4.0,
|
||||
'product_uom': ref('uom.product_uom_unit'),
|
||||
'date_planned': DateTime.today() + relativedelta(days=3)})
|
||||
]"/>
|
||||
</record>
|
||||
|
||||
<record id="purchase_order_2" model="purchase.order">
|
||||
<field name="partner_id" ref="base.res_partner_3"/>
|
||||
<field name="user_id" ref="base.user_admin"/>
|
||||
<field name="state">draft</field>
|
||||
<field name="order_line" model="purchase.order.line" eval="[(5, 0, 0),
|
||||
(0, 0, {
|
||||
'product_id': ref('product.product_delivery_02'),
|
||||
'name': obj().env.ref('product.product_delivery_02').partner_ref,
|
||||
'price_unit': 132.50,
|
||||
'product_qty': 20.0,
|
||||
'product_uom': ref('uom.product_uom_unit'),
|
||||
'date_planned': DateTime.today() + relativedelta(days=1)}),
|
||||
(0, 0, {
|
||||
'product_id': ref('product.product_delivery_01'),
|
||||
'name': obj().env.ref('product.product_delivery_01').partner_ref,
|
||||
'price_unit': 89.0,
|
||||
'product_qty': 5.0,
|
||||
'product_uom': ref('uom.product_uom_unit'),
|
||||
'date_planned': DateTime.today() + relativedelta(days=1)}),
|
||||
]"/>
|
||||
</record>
|
||||
|
||||
<record id="purchase_order_3" model="purchase.order">
|
||||
<field name="partner_id" ref="base.res_partner_12"/>
|
||||
<field name="user_id" ref="base.user_admin"/>
|
||||
<field name="state">draft</field>
|
||||
<field name="order_line" model="purchase.order.line" eval="[(5, 0, 0),
|
||||
(0, 0, {
|
||||
'product_id': ref('product.product_product_2'),
|
||||
'name': obj().env.ref('product.product_product_2').partner_ref,
|
||||
'price_unit': 25.50,
|
||||
'product_qty': 10.0,
|
||||
'product_uom': ref('uom.product_uom_hour'),
|
||||
'date_planned': DateTime.today() + relativedelta(days=1)}),
|
||||
]"/>
|
||||
</record>
|
||||
|
||||
<record id="purchase_order_4" model="purchase.order">
|
||||
<field name="partner_id" ref="base.res_partner_4"/>
|
||||
<field name="user_id" ref="base.user_admin"/>
|
||||
<field name="state">draft</field>
|
||||
<field name="order_line" model="purchase.order.line" eval="[(5, 0, 0),
|
||||
(0, 0, {
|
||||
'product_id': ref('product.product_delivery_02'),
|
||||
'name': obj().env.ref('product.product_delivery_02').partner_ref,
|
||||
'price_unit': 85.50,
|
||||
'product_qty': 6.0,
|
||||
'product_uom': ref('uom.product_uom_unit'),
|
||||
'date_planned': DateTime.today() + relativedelta(days=5)}),
|
||||
(0, 0, {
|
||||
'product_id': ref('product.product_product_20'),
|
||||
'name': obj().env.ref('product.product_product_20').partner_ref,
|
||||
'price_unit': 1690.0,
|
||||
'product_qty': 5.0,
|
||||
'product_uom': ref('uom.product_uom_unit'),
|
||||
'date_planned': DateTime.today() + relativedelta(days=5)}),
|
||||
(0, 0, {
|
||||
'product_id': ref('product.product_product_6'),
|
||||
'name': obj().env.ref('product.product_product_6').partner_ref,
|
||||
'price_unit': 800.0,
|
||||
'product_qty': 7.0,
|
||||
'product_uom': ref('uom.product_uom_unit'),
|
||||
'date_planned': DateTime.today() + relativedelta(days=5)})
|
||||
]"/>
|
||||
</record>
|
||||
<function model="purchase.order" name="write">
|
||||
<value eval="[ref('purchase_order_4')]"/>
|
||||
<value eval="{'state': 'sent'}"/>
|
||||
</function>
|
||||
|
||||
<record id="purchase_order_5" model="purchase.order">
|
||||
<field name="partner_id" ref="base.res_partner_2"/>
|
||||
<field name="user_id" ref="base.user_admin"/>
|
||||
<field name="state">draft</field>
|
||||
<field name="order_line" model="purchase.order.line" eval="[(5, 0, 0),
|
||||
(0, 0, {
|
||||
'product_id': ref('product.product_product_22'),
|
||||
'name': obj().env.ref('product.product_product_22').partner_ref,
|
||||
'price_unit': 2010.0,
|
||||
'product_qty': 3.0,
|
||||
'product_uom': ref('uom.product_uom_unit'),
|
||||
'date_planned': DateTime.today()}),
|
||||
(0, 0, {
|
||||
'product_id': ref('product.product_product_24'),
|
||||
'name': obj().env.ref('product.product_product_24').partner_ref,
|
||||
'price_unit': 876.0,
|
||||
'product_qty': 3.0,
|
||||
'product_uom': ref('uom.product_uom_unit'),
|
||||
'date_planned': DateTime.today()}),
|
||||
]"/>
|
||||
</record>
|
||||
|
||||
<record id="purchase_order_6" model="purchase.order">
|
||||
<field name="partner_id" ref="base.res_partner_1"/>
|
||||
<field name="user_id" ref="base.user_admin"/>
|
||||
<field name="state">draft</field>
|
||||
<field name="order_line" model="purchase.order.line" eval="[(5, 0, 0),
|
||||
(0, 0, {
|
||||
'product_id': ref('product.product_delivery_02'),
|
||||
'name': obj().env.ref('product.product_delivery_02').partner_ref,
|
||||
'price_unit': 58.0,
|
||||
'product_qty': 9.0,
|
||||
'product_uom': ref('uom.product_uom_unit'),
|
||||
'date_planned': DateTime.today()}),
|
||||
(0, 0, {
|
||||
'product_id': ref('product.product_delivery_01'),
|
||||
'name': obj().env.ref('product.product_delivery_01').partner_ref,
|
||||
'price_unit': 65.0,
|
||||
'product_qty': 3.0,
|
||||
'product_uom': ref('uom.product_uom_unit'),
|
||||
'date_planned': DateTime.today()}),
|
||||
(0, 0, {
|
||||
'product_id': ref('product.consu_delivery_01'),
|
||||
'name': obj().env.ref('product.consu_delivery_01').partner_ref,
|
||||
'price_unit': 154.5,
|
||||
'product_qty': 4.0,
|
||||
'product_uom': ref('uom.product_uom_unit'),
|
||||
'date_planned': DateTime.today()}),
|
||||
]"/>
|
||||
</record>
|
||||
|
||||
<record id="purchase_order_7" model="purchase.order">
|
||||
<field name="partner_id" ref="base.res_partner_4"/>
|
||||
<field name="user_id" ref="base.user_admin"/>
|
||||
<field name="state">draft</field>
|
||||
<field name="order_line" model="purchase.order.line" eval="[(5, 0, 0),
|
||||
(0, 0, {
|
||||
'product_id': ref('product.product_product_12'),
|
||||
'name': obj().env.ref('product.product_product_12').partner_ref,
|
||||
'price_unit': 130.5,
|
||||
'product_qty': 5.0,
|
||||
'product_uom': ref('uom.product_uom_unit'),
|
||||
'date_planned': DateTime.today()}),
|
||||
(0, 0, {
|
||||
'product_id': ref('product.product_delivery_02'),
|
||||
'name': obj().env.ref('product.product_delivery_02').partner_ref,
|
||||
'price_unit': 38.0,
|
||||
'product_qty': 15.0,
|
||||
'product_uom': ref('uom.product_uom_unit'),
|
||||
'date_planned': DateTime.today()}),
|
||||
]"/>
|
||||
</record>
|
||||
|
||||
<record id="purchase_order_8" model="purchase.order">
|
||||
<field name="partner_id" ref="base.res_partner_1"/>
|
||||
<field name="user_id" ref="base.user_admin"/>
|
||||
<field name="state">purchase</field>
|
||||
<field name="create_date" eval="DateTime.today() - relativedelta(days=20)"/>
|
||||
<field name="date_order" eval="DateTime.today() - relativedelta(days=5)"/>
|
||||
<field name="date_approve" eval="DateTime.today() - relativedelta(days=9)"/>
|
||||
<field name="order_line" model="purchase.order.line" eval="[(5, 0, 0),
|
||||
(0, 0, {
|
||||
'product_id': ref('product.product_product_16'),
|
||||
'name': 'Drawer Black',
|
||||
'price_unit': 280.80,
|
||||
'product_qty': 15.0,
|
||||
'product_uom': ref('uom.product_uom_dozen'),
|
||||
'date_planned': time.strftime('%Y-%m-%d')}),
|
||||
(0, 0, {
|
||||
'product_id': ref('product.product_product_20'),
|
||||
'name': 'Flipover',
|
||||
'price_unit': 450.70,
|
||||
'product_qty': 5.0,
|
||||
'product_uom': ref('uom.product_uom_dozen'),
|
||||
'date_planned': time.strftime('%Y-%m-%d')})
|
||||
]"/>
|
||||
</record>
|
||||
|
||||
<record id="purchase_order_9" model="purchase.order">
|
||||
<field name="partner_id" ref="base.res_partner_3"/>
|
||||
<field name="user_id" ref="base.user_admin"/>
|
||||
<field name="state">purchase</field>
|
||||
<field name="create_date" eval="DateTime.today() - relativedelta(days=20)"/>
|
||||
<field name="date_order" eval="DateTime.today() - relativedelta(days=15)"/>
|
||||
<field name="date_approve" eval="DateTime.today() - relativedelta(days=5)"/>
|
||||
<field name="order_line" model="purchase.order.line" eval="[(5, 0, 0),
|
||||
(0, 0, {
|
||||
'product_id': ref('product.product_product_8'),
|
||||
'name': 'Large Desk',
|
||||
'price_unit': 500.00,
|
||||
'product_qty': 20.0,
|
||||
'product_uom': ref('uom.product_uom_dozen'),
|
||||
'date_planned': time.strftime('%Y-%m-%d')}),
|
||||
(0, 0, {
|
||||
'product_id': ref('product.product_product_5'),
|
||||
'name': 'Corner Desk Right Sit',
|
||||
'price_unit': 500.0,
|
||||
'product_qty': 5.0,
|
||||
'product_uom': ref('uom.product_uom_dozen'),
|
||||
'date_planned': time.strftime('%Y-%m-%d')}),
|
||||
]"/>
|
||||
</record>
|
||||
|
||||
<record id="purchase_order_10" model="purchase.order">
|
||||
<field name="partner_id" ref="base.res_partner_12"/>
|
||||
<field name="user_id" ref="base.user_admin"/>
|
||||
<field name="state">purchase</field>
|
||||
<field name="create_date" eval="DateTime.today() - relativedelta(days=20)"/>
|
||||
<field name="date_order" eval="DateTime.today() - relativedelta(days=15)"/>
|
||||
<field name="date_approve" eval="DateTime.today() - relativedelta(days=18)"/>
|
||||
<field name="order_line" model="purchase.order.line" eval="[(5, 0, 0),
|
||||
(0, 0, {
|
||||
'product_id': ref('product.product_product_12'),
|
||||
'name': 'Office Chair Black',
|
||||
'price_unit': 250.50,
|
||||
'product_qty': 10.0,
|
||||
'product_uom': ref('uom.product_uom_dozen'),
|
||||
'date_planned': time.strftime('%Y-%m-%d')}),
|
||||
]"/>
|
||||
</record>
|
||||
|
||||
<record id="purchase_activity_1" model="mail.activity">
|
||||
<field name="res_id" ref="purchase.purchase_order_2"/>
|
||||
<field name="res_model_id" ref="purchase.model_purchase_order"/>
|
||||
<field name="activity_type_id" ref="mail.mail_activity_data_email"/>
|
||||
<field name="date_deadline" eval="DateTime.today().strftime('%Y-%m-%d %H:%M')"/>
|
||||
<field name="summary">Send specifications</field>
|
||||
<field name="create_uid" ref="base.user_admin"/>
|
||||
<field name="user_id" ref="base.user_admin"/>
|
||||
</record>
|
||||
<record id="purchase_activity_2" model="mail.activity">
|
||||
<field name="res_id" ref="purchase.purchase_order_5"/>
|
||||
<field name="res_model_id" ref="purchase.model_purchase_order"/>
|
||||
<field name="activity_type_id" ref="mail.mail_activity_data_todo"/>
|
||||
<field name="date_deadline" eval="DateTime.today().strftime('%Y-%m-%d %H:%M')"/>
|
||||
<field name="summary">Get approval</field>
|
||||
<field name="create_uid" ref="base.user_admin"/>
|
||||
<field name="user_id" ref="base.user_admin"/>
|
||||
</record>
|
||||
<record id="purchase_activity_3" model="mail.activity">
|
||||
<field name="res_id" ref="purchase.purchase_order_6"/>
|
||||
<field name="res_model_id" ref="purchase.model_purchase_order"/>
|
||||
<field name="activity_type_id" ref="mail.mail_activity_data_todo"/>
|
||||
<field name="date_deadline" eval="(DateTime.today() - relativedelta(days=5)).strftime('%Y-%m-%d %H:%M')"/>
|
||||
<field name="summary">Check optional products</field>
|
||||
<field name="create_uid" ref="base.user_admin"/>
|
||||
<field name="user_id" ref="base.user_admin"/>
|
||||
</record>
|
||||
<record id="purchase_activity_4" model="mail.activity">
|
||||
<field name="res_id" ref="purchase.purchase_order_7"/>
|
||||
<field name="res_model_id" ref="purchase.model_purchase_order"/>
|
||||
<field name="activity_type_id" ref="mail.mail_activity_data_todo"/>
|
||||
<field name="date_deadline" eval="(DateTime.today() + relativedelta(days=5)).strftime('%Y-%m-%d %H:%M')"/>
|
||||
<field name="summary">Check competitors</field>
|
||||
<field name="create_uid" ref="base.user_admin"/>
|
||||
<field name="user_id" ref="base.user_admin"/>
|
||||
</record>
|
||||
|
||||
</data>
|
||||
</odoo>
|
||||
|
3039
i18n/af.po
Normal file
3039
i18n/af.po
Normal file
File diff suppressed because it is too large
Load Diff
3035
i18n/am.po
Normal file
3035
i18n/am.po
Normal file
File diff suppressed because it is too large
Load Diff
3342
i18n/ar.po
Normal file
3342
i18n/ar.po
Normal file
File diff suppressed because it is too large
Load Diff
3049
i18n/az.po
Normal file
3049
i18n/az.po
Normal file
File diff suppressed because it is too large
Load Diff
3201
i18n/bg.po
Normal file
3201
i18n/bg.po
Normal file
File diff suppressed because it is too large
Load Diff
3040
i18n/bs.po
Normal file
3040
i18n/bs.po
Normal file
File diff suppressed because it is too large
Load Diff
3321
i18n/ca.po
Normal file
3321
i18n/ca.po
Normal file
File diff suppressed because it is too large
Load Diff
3299
i18n/cs.po
Normal file
3299
i18n/cs.po
Normal file
File diff suppressed because it is too large
Load Diff
3262
i18n/da.po
Normal file
3262
i18n/da.po
Normal file
File diff suppressed because it is too large
Load Diff
3386
i18n/de.po
Normal file
3386
i18n/de.po
Normal file
File diff suppressed because it is too large
Load Diff
3060
i18n/el.po
Normal file
3060
i18n/el.po
Normal file
File diff suppressed because it is too large
Load Diff
3120
i18n/en_AU.po
Normal file
3120
i18n/en_AU.po
Normal file
File diff suppressed because it is too large
Load Diff
3045
i18n/en_GB.po
Normal file
3045
i18n/en_GB.po
Normal file
File diff suppressed because it is too large
Load Diff
3377
i18n/es.po
Normal file
3377
i18n/es.po
Normal file
File diff suppressed because it is too large
Load Diff
3384
i18n/es_419.po
Normal file
3384
i18n/es_419.po
Normal file
File diff suppressed because it is too large
Load Diff
3037
i18n/es_BO.po
Normal file
3037
i18n/es_BO.po
Normal file
File diff suppressed because it is too large
Load Diff
3037
i18n/es_CL.po
Normal file
3037
i18n/es_CL.po
Normal file
File diff suppressed because it is too large
Load Diff
3048
i18n/es_CO.po
Normal file
3048
i18n/es_CO.po
Normal file
File diff suppressed because it is too large
Load Diff
3037
i18n/es_CR.po
Normal file
3037
i18n/es_CR.po
Normal file
File diff suppressed because it is too large
Load Diff
3053
i18n/es_DO.po
Normal file
3053
i18n/es_DO.po
Normal file
File diff suppressed because it is too large
Load Diff
3046
i18n/es_EC.po
Normal file
3046
i18n/es_EC.po
Normal file
File diff suppressed because it is too large
Load Diff
3039
i18n/es_PE.po
Normal file
3039
i18n/es_PE.po
Normal file
File diff suppressed because it is too large
Load Diff
3037
i18n/es_PY.po
Normal file
3037
i18n/es_PY.po
Normal file
File diff suppressed because it is too large
Load Diff
3037
i18n/es_VE.po
Normal file
3037
i18n/es_VE.po
Normal file
File diff suppressed because it is too large
Load Diff
3372
i18n/et.po
Normal file
3372
i18n/et.po
Normal file
File diff suppressed because it is too large
Load Diff
3037
i18n/eu.po
Normal file
3037
i18n/eu.po
Normal file
File diff suppressed because it is too large
Load Diff
3239
i18n/fa.po
Normal file
3239
i18n/fa.po
Normal file
File diff suppressed because it is too large
Load Diff
3382
i18n/fi.po
Normal file
3382
i18n/fi.po
Normal file
File diff suppressed because it is too large
Load Diff
3037
i18n/fo.po
Normal file
3037
i18n/fo.po
Normal file
File diff suppressed because it is too large
Load Diff
3380
i18n/fr.po
Normal file
3380
i18n/fr.po
Normal file
File diff suppressed because it is too large
Load Diff
3036
i18n/fr_BE.po
Normal file
3036
i18n/fr_BE.po
Normal file
File diff suppressed because it is too large
Load Diff
3037
i18n/fr_CA.po
Normal file
3037
i18n/fr_CA.po
Normal file
File diff suppressed because it is too large
Load Diff
3037
i18n/gl.po
Normal file
3037
i18n/gl.po
Normal file
File diff suppressed because it is too large
Load Diff
3045
i18n/gu.po
Normal file
3045
i18n/gu.po
Normal file
File diff suppressed because it is too large
Load Diff
3241
i18n/he.po
Normal file
3241
i18n/he.po
Normal file
File diff suppressed because it is too large
Load Diff
3041
i18n/hi.po
Normal file
3041
i18n/hi.po
Normal file
File diff suppressed because it is too large
Load Diff
3085
i18n/hr.po
Normal file
3085
i18n/hr.po
Normal file
File diff suppressed because it is too large
Load Diff
3216
i18n/hu.po
Normal file
3216
i18n/hu.po
Normal file
File diff suppressed because it is too large
Load Diff
3364
i18n/id.po
Normal file
3364
i18n/id.po
Normal file
File diff suppressed because it is too large
Load Diff
3040
i18n/is.po
Normal file
3040
i18n/is.po
Normal file
File diff suppressed because it is too large
Load Diff
3372
i18n/it.po
Normal file
3372
i18n/it.po
Normal file
File diff suppressed because it is too large
Load Diff
3276
i18n/ja.po
Normal file
3276
i18n/ja.po
Normal file
File diff suppressed because it is too large
Load Diff
3041
i18n/ka.po
Normal file
3041
i18n/ka.po
Normal file
File diff suppressed because it is too large
Load Diff
3037
i18n/kab.po
Normal file
3037
i18n/kab.po
Normal file
File diff suppressed because it is too large
Load Diff
3040
i18n/km.po
Normal file
3040
i18n/km.po
Normal file
File diff suppressed because it is too large
Load Diff
3290
i18n/ko.po
Normal file
3290
i18n/ko.po
Normal file
File diff suppressed because it is too large
Load Diff
3039
i18n/lb.po
Normal file
3039
i18n/lb.po
Normal file
File diff suppressed because it is too large
Load Diff
3037
i18n/lo.po
Normal file
3037
i18n/lo.po
Normal file
File diff suppressed because it is too large
Load Diff
3252
i18n/lt.po
Normal file
3252
i18n/lt.po
Normal file
File diff suppressed because it is too large
Load Diff
3210
i18n/lv.po
Normal file
3210
i18n/lv.po
Normal file
File diff suppressed because it is too large
Load Diff
3042
i18n/mk.po
Normal file
3042
i18n/mk.po
Normal file
File diff suppressed because it is too large
Load Diff
3083
i18n/mn.po
Normal file
3083
i18n/mn.po
Normal file
File diff suppressed because it is too large
Load Diff
3055
i18n/nb.po
Normal file
3055
i18n/nb.po
Normal file
File diff suppressed because it is too large
Load Diff
3372
i18n/nl.po
Normal file
3372
i18n/nl.po
Normal file
File diff suppressed because it is too large
Load Diff
3289
i18n/pl.po
Normal file
3289
i18n/pl.po
Normal file
File diff suppressed because it is too large
Load Diff
3311
i18n/pt.po
Normal file
3311
i18n/pt.po
Normal file
File diff suppressed because it is too large
Load Diff
3371
i18n/pt_BR.po
Normal file
3371
i18n/pt_BR.po
Normal file
File diff suppressed because it is too large
Load Diff
3140
i18n/purchase.pot
Normal file
3140
i18n/purchase.pot
Normal file
File diff suppressed because it is too large
Load Diff
3070
i18n/ro.po
Normal file
3070
i18n/ro.po
Normal file
File diff suppressed because it is too large
Load Diff
3390
i18n/ru.po
Normal file
3390
i18n/ru.po
Normal file
File diff suppressed because it is too large
Load Diff
3223
i18n/sk.po
Normal file
3223
i18n/sk.po
Normal file
File diff suppressed because it is too large
Load Diff
3188
i18n/sl.po
Normal file
3188
i18n/sl.po
Normal file
File diff suppressed because it is too large
Load Diff
3037
i18n/sq.po
Normal file
3037
i18n/sq.po
Normal file
File diff suppressed because it is too large
Load Diff
3345
i18n/sr.po
Normal file
3345
i18n/sr.po
Normal file
File diff suppressed because it is too large
Load Diff
3041
i18n/sr@latin.po
Normal file
3041
i18n/sr@latin.po
Normal file
File diff suppressed because it is too large
Load Diff
3318
i18n/sv.po
Normal file
3318
i18n/sv.po
Normal file
File diff suppressed because it is too large
Load Diff
3339
i18n/th.po
Normal file
3339
i18n/th.po
Normal file
File diff suppressed because it is too large
Load Diff
3295
i18n/tr.po
Normal file
3295
i18n/tr.po
Normal file
File diff suppressed because it is too large
Load Diff
3364
i18n/uk.po
Normal file
3364
i18n/uk.po
Normal file
File diff suppressed because it is too large
Load Diff
3354
i18n/vi.po
Normal file
3354
i18n/vi.po
Normal file
File diff suppressed because it is too large
Load Diff
3273
i18n/zh_CN.po
Normal file
3273
i18n/zh_CN.po
Normal file
File diff suppressed because it is too large
Load Diff
3278
i18n/zh_TW.po
Normal file
3278
i18n/zh_TW.po
Normal file
File diff suppressed because it is too large
Load Diff
35
migrations/9.0.1.2/pre-create-properties.py
Normal file
35
migrations/9.0.1.2/pre-create-properties.py
Normal file
@ -0,0 +1,35 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
def convert_field(cr, model, field, target_model):
|
||||
table = model.replace('.', '_')
|
||||
|
||||
cr.execute("""SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = %s
|
||||
AND column_name = %s
|
||||
""", (table, field))
|
||||
if not cr.fetchone():
|
||||
return
|
||||
|
||||
cr.execute("SELECT id FROM ir_model_fields WHERE model=%s AND name=%s", (model, field))
|
||||
[fields_id] = cr.fetchone()
|
||||
|
||||
cr.execute("""
|
||||
INSERT INTO ir_property(name, type, fields_id, company_id, res_id, value_reference)
|
||||
SELECT %(field)s, 'many2one', %(fields_id)s, company_id, CONCAT('{model},', id),
|
||||
CONCAT('{target_model},', {field})
|
||||
FROM {table} t
|
||||
WHERE {field} IS NOT NULL
|
||||
AND NOT EXISTS(SELECT 1
|
||||
FROM ir_property
|
||||
WHERE fields_id=%(fields_id)s
|
||||
AND company_id=t.company_id
|
||||
AND res_id=CONCAT('{model},', t.id))
|
||||
""".format(**locals()), locals())
|
||||
|
||||
cr.execute('ALTER TABLE "{0}" DROP COLUMN "{1}" CASCADE'.format(table, field))
|
||||
|
||||
def migrate(cr, version):
|
||||
convert_field(cr, 'res.partner', 'property_purchase_currency_id', 'res.currency')
|
||||
convert_field(cr, 'product.template',
|
||||
'property_account_creditor_price_difference', 'account.account')
|
13
models/__init__.py
Normal file
13
models/__init__.py
Normal file
@ -0,0 +1,13 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import account_invoice
|
||||
from . import account_tax
|
||||
from . import analytic_account
|
||||
from . import analytic_applicability
|
||||
from . import purchase_order
|
||||
from . import purchase_order_line
|
||||
from . import product
|
||||
from . import res_company
|
||||
from . import res_config_settings
|
||||
from . import res_partner
|
477
models/account_invoice.py
Normal file
477
models/account_invoice.py
Normal file
@ -0,0 +1,477 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
import difflib
|
||||
import logging
|
||||
import time
|
||||
from markupsafe import Markup
|
||||
|
||||
from odoo import api, fields, models, Command, _
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
TOLERANCE = 0.02 # tolerance applied to the total when searching for a matching purchase order
|
||||
|
||||
|
||||
class AccountMove(models.Model):
|
||||
_inherit = 'account.move'
|
||||
|
||||
purchase_vendor_bill_id = fields.Many2one('purchase.bill.union', store=False, readonly=False,
|
||||
string='Auto-complete',
|
||||
help="Auto-complete from a past bill / purchase order.")
|
||||
purchase_id = fields.Many2one('purchase.order', store=False, readonly=False,
|
||||
string='Purchase Order',
|
||||
help="Auto-complete from a past purchase order.")
|
||||
purchase_order_count = fields.Integer(compute="_compute_origin_po_count", string='Purchase Order Count')
|
||||
|
||||
def _get_invoice_reference(self):
|
||||
self.ensure_one()
|
||||
vendor_refs = [ref for ref in set(self.invoice_line_ids.mapped('purchase_line_id.order_id.partner_ref')) if ref]
|
||||
if self.ref:
|
||||
return [ref for ref in self.ref.split(', ') if ref and ref not in vendor_refs] + vendor_refs
|
||||
return vendor_refs
|
||||
|
||||
@api.onchange('purchase_vendor_bill_id', 'purchase_id')
|
||||
def _onchange_purchase_auto_complete(self):
|
||||
''' Load from either an old purchase order, either an old vendor bill.
|
||||
|
||||
When setting a 'purchase.bill.union' in 'purchase_vendor_bill_id':
|
||||
* If it's a vendor bill, 'invoice_vendor_bill_id' is set and the loading is done by '_onchange_invoice_vendor_bill'.
|
||||
* If it's a purchase order, 'purchase_id' is set and this method will load lines.
|
||||
|
||||
/!\ All this not-stored fields must be empty at the end of this function.
|
||||
'''
|
||||
if self.purchase_vendor_bill_id.vendor_bill_id:
|
||||
self.invoice_vendor_bill_id = self.purchase_vendor_bill_id.vendor_bill_id
|
||||
self._onchange_invoice_vendor_bill()
|
||||
elif self.purchase_vendor_bill_id.purchase_order_id:
|
||||
self.purchase_id = self.purchase_vendor_bill_id.purchase_order_id
|
||||
self.purchase_vendor_bill_id = False
|
||||
|
||||
if not self.purchase_id:
|
||||
return
|
||||
|
||||
# Copy data from PO
|
||||
invoice_vals = self.purchase_id.with_company(self.purchase_id.company_id)._prepare_invoice()
|
||||
new_currency_id = self.invoice_line_ids and self.currency_id or invoice_vals.get('currency_id')
|
||||
del invoice_vals['ref'], invoice_vals['payment_reference']
|
||||
del invoice_vals['company_id'] # avoid recomputing the currency
|
||||
if self.move_type == invoice_vals['move_type']:
|
||||
del invoice_vals['move_type'] # no need to be updated if it's same value, to avoid recomputes
|
||||
self.update(invoice_vals)
|
||||
self.currency_id = new_currency_id
|
||||
|
||||
# Copy purchase lines.
|
||||
po_lines = self.purchase_id.order_line - self.invoice_line_ids.mapped('purchase_line_id')
|
||||
for line in po_lines.filtered(lambda l: not l.display_type):
|
||||
self.invoice_line_ids += self.env['account.move.line'].new(
|
||||
line._prepare_account_move_line(self)
|
||||
)
|
||||
|
||||
# Compute invoice_origin.
|
||||
origins = set(self.invoice_line_ids.mapped('purchase_line_id.order_id.name'))
|
||||
self.invoice_origin = ','.join(list(origins))
|
||||
|
||||
# Compute ref.
|
||||
refs = self._get_invoice_reference()
|
||||
self.ref = ', '.join(refs)
|
||||
|
||||
# Compute payment_reference.
|
||||
if not self.payment_reference:
|
||||
if len(refs) == 1:
|
||||
self.payment_reference = refs[0]
|
||||
elif len(refs) > 1:
|
||||
self.payment_reference = refs[-1]
|
||||
|
||||
self.purchase_id = False
|
||||
|
||||
@api.onchange('partner_id', 'company_id')
|
||||
def _onchange_partner_id(self):
|
||||
res = super(AccountMove, self)._onchange_partner_id()
|
||||
|
||||
currency_id = (
|
||||
self.partner_id.property_purchase_currency_id
|
||||
or self.env['res.currency'].browse(self.env.context.get("default_currency_id"))
|
||||
or self.currency_id
|
||||
)
|
||||
|
||||
if self.partner_id and self.move_type in ['in_invoice', 'in_refund'] and self.currency_id != currency_id:
|
||||
if not self.env.context.get('default_journal_id'):
|
||||
journal_domain = [
|
||||
*self.env['account.journal']._check_company_domain(self.company_id),
|
||||
('type', '=', 'purchase'),
|
||||
('currency_id', '=', currency_id.id),
|
||||
]
|
||||
default_journal_id = self.env['account.journal'].search(journal_domain, limit=1)
|
||||
if default_journal_id:
|
||||
self.journal_id = default_journal_id
|
||||
|
||||
self.currency_id = currency_id
|
||||
|
||||
return res
|
||||
|
||||
@api.depends('line_ids.purchase_line_id')
|
||||
def _compute_origin_po_count(self):
|
||||
for move in self:
|
||||
move.purchase_order_count = len(move.line_ids.purchase_line_id.order_id)
|
||||
|
||||
def action_view_source_purchase_orders(self):
|
||||
self.ensure_one()
|
||||
source_orders = self.line_ids.purchase_line_id.order_id
|
||||
result = self.env['ir.actions.act_window']._for_xml_id('purchase.purchase_form_action')
|
||||
if len(source_orders) > 1:
|
||||
result['domain'] = [('id', 'in', source_orders.ids)]
|
||||
elif len(source_orders) == 1:
|
||||
result['views'] = [(self.env.ref('purchase.purchase_order_form', False).id, 'form')]
|
||||
result['res_id'] = source_orders.id
|
||||
else:
|
||||
result = {'type': 'ir.actions.act_window_close'}
|
||||
return result
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
# OVERRIDE
|
||||
moves = super(AccountMove, self).create(vals_list)
|
||||
for move in moves:
|
||||
if move.reversed_entry_id:
|
||||
continue
|
||||
purchases = move.line_ids.purchase_line_id.order_id
|
||||
if not purchases:
|
||||
continue
|
||||
refs = [purchase._get_html_link() for purchase in purchases]
|
||||
message = _("This vendor bill has been created from: ") + Markup(',').join(refs)
|
||||
move.message_post(body=message)
|
||||
return moves
|
||||
|
||||
def write(self, vals):
|
||||
# OVERRIDE
|
||||
old_purchases = [move.mapped('line_ids.purchase_line_id.order_id') for move in self]
|
||||
res = super(AccountMove, self).write(vals)
|
||||
for i, move in enumerate(self):
|
||||
new_purchases = move.mapped('line_ids.purchase_line_id.order_id')
|
||||
if not new_purchases:
|
||||
continue
|
||||
diff_purchases = new_purchases - old_purchases[i]
|
||||
if diff_purchases:
|
||||
refs = [purchase._get_html_link() for purchase in diff_purchases]
|
||||
message = _("This vendor bill has been modified from: ") + Markup(',').join(refs)
|
||||
move.message_post(body=message)
|
||||
return res
|
||||
|
||||
def _find_matching_subset_po_lines(self, po_lines_with_amount, goal_total, timeout):
|
||||
"""Finds the purchase order lines adding up to the goal amount.
|
||||
|
||||
The problem of finding the subset of `po_lines_with_amount` which sums up to `goal_total` reduces to
|
||||
the 0-1 Knapsack problem. The dynamic programming approach to solve this problem is most of the time slower
|
||||
than this because identical sub-problems don't arise often enough. It returns the list of purchase order lines
|
||||
which sum up to `goal_total` or an empty list if multiple or no solutions were found.
|
||||
|
||||
:param po_lines_with_amount: a dict (str: float|recordset) containing:
|
||||
* line: an `purchase.order.line`
|
||||
* amount_to_invoice: the remaining amount to be invoiced of the line
|
||||
:param goal_total: the total amount to match with a subset of purchase order lines
|
||||
:param timeout: the max time the line matching algorithm can take before timing out
|
||||
:return: list of `purchase.order.line` whose remaining sum matches `goal_total`
|
||||
"""
|
||||
def find_matching_subset_po_lines(lines, goal):
|
||||
if time.time() - start_time > timeout:
|
||||
raise TimeoutError
|
||||
solutions = []
|
||||
for i, line in enumerate(lines):
|
||||
if line['amount_to_invoice'] < goal - TOLERANCE:
|
||||
# The amount to invoice of the current purchase order line is less than the amount we still need on
|
||||
# the vendor bill.
|
||||
# We try finding purchase order lines that match the remaining vendor bill amount minus the amount
|
||||
# to invoice of the current purchase order line. We only look in the purchase order lines that we
|
||||
# haven't passed yet.
|
||||
sub_solutions = find_matching_subset_po_lines(lines[i + 1:], goal - line['amount_to_invoice'])
|
||||
# We add all possible sub-solutions' purchase order lines in a tuple together with our current
|
||||
# purchase order line.
|
||||
solutions.extend((line['line'], *solution) for solution in sub_solutions)
|
||||
elif goal - TOLERANCE <= line['amount_to_invoice'] <= goal + TOLERANCE:
|
||||
# The amount to invoice of the current purchase order line matches the remaining vendor bill amount.
|
||||
# We add this purchase order line to our list of solutions.
|
||||
solutions.append([line['line']])
|
||||
if len(solutions) > 1:
|
||||
# More than one solution was found. We can't know for sure which is the correct one, so we don't
|
||||
# return any solution.
|
||||
return []
|
||||
return solutions
|
||||
start_time = time.time()
|
||||
try:
|
||||
subsets = find_matching_subset_po_lines(
|
||||
sorted(po_lines_with_amount, key=lambda line: line['amount_to_invoice'], reverse=True),
|
||||
goal_total
|
||||
)
|
||||
return subsets[0] if subsets else []
|
||||
except TimeoutError:
|
||||
_logger.warning("Timed out during search of a matching subset of purchase order lines")
|
||||
return []
|
||||
|
||||
def _find_matching_po_and_inv_lines(self, po_lines, inv_lines, timeout):
|
||||
"""Finds purchase order lines that match some of the invoice lines.
|
||||
|
||||
We try to find a purchase order line for every invoice line matching on the unit price and having at least
|
||||
the same quantity to invoice.
|
||||
|
||||
:param po_lines: list of purchase order lines that can be matched
|
||||
:param inv_lines: list of invoice lines to be matched
|
||||
:param timeout: how long this function can run before we consider it too long
|
||||
:return: a tuple (list, list) containing:
|
||||
* matched 'purchase.order.line'
|
||||
* tuple of purchase order line ids and their matched 'account.move.line'
|
||||
"""
|
||||
# Sort the invoice lines by unit price and quantity to speed up matching
|
||||
invoice_lines = sorted(inv_lines, key=lambda line: (line.price_unit, line.quantity), reverse=True)
|
||||
# Sort the purchase order lines by unit price and remaining quantity to speed up matching
|
||||
purchase_lines = sorted(
|
||||
po_lines,
|
||||
key=lambda line: (line.price_unit, line.product_qty - line.qty_invoiced),
|
||||
reverse=True
|
||||
)
|
||||
matched_po_lines = []
|
||||
matched_inv_lines = []
|
||||
try:
|
||||
start_time = time.time()
|
||||
for invoice_line in invoice_lines:
|
||||
# There are no purchase order lines left. We are done matching.
|
||||
if not purchase_lines:
|
||||
break
|
||||
# A dict of purchase lines mapping to a diff score for the name
|
||||
purchase_line_candidates = {}
|
||||
for purchase_line in purchase_lines:
|
||||
if time.time() - start_time > timeout:
|
||||
raise TimeoutError
|
||||
|
||||
# The lists are sorted by unit price descendingly.
|
||||
# When the unit price of the purchase line is lower than the unit price of the invoice line,
|
||||
# we cannot get a match anymore.
|
||||
if purchase_line.price_unit < invoice_line.price_unit:
|
||||
break
|
||||
|
||||
if (invoice_line.price_unit == purchase_line.price_unit
|
||||
and invoice_line.quantity <= purchase_line.product_qty - purchase_line.qty_invoiced):
|
||||
# The current purchase line is a possible match for the current invoice line.
|
||||
# We calculate the name match ratio and continue with other possible matches.
|
||||
#
|
||||
# We could match on more fields coming from an EDI invoice, but that requires extending the
|
||||
# account.move.line model with the extra matching fields and extending the EDI extraction
|
||||
# logic to fill these new fields.
|
||||
purchase_line_candidates[purchase_line] = difflib.SequenceMatcher(
|
||||
None, invoice_line.name, purchase_line.name).ratio()
|
||||
|
||||
if len(purchase_line_candidates) > 0:
|
||||
# We take the best match based on the name.
|
||||
purchase_line_match = max(purchase_line_candidates, key=purchase_line_candidates.get)
|
||||
if purchase_line_match:
|
||||
# We found a match. We remove the purchase order line so it does not get matched twice.
|
||||
purchase_lines.remove(purchase_line_match)
|
||||
matched_po_lines.append(purchase_line_match)
|
||||
matched_inv_lines.append((purchase_line_match.id, invoice_line))
|
||||
|
||||
return (matched_po_lines, matched_inv_lines)
|
||||
|
||||
except TimeoutError:
|
||||
_logger.warning('Timed out during search of matching purchase order lines')
|
||||
return ([], [])
|
||||
|
||||
def _set_purchase_orders(self, purchase_orders, force_write=True):
|
||||
"""Link the given purchase orders to this vendor bill and add their lines as invoice lines.
|
||||
|
||||
:param purchase_orders: a list of purchase orders to be linked to this vendor bill
|
||||
:param force_write: whether to delete all existing invoice lines before adding the vendor bill lines
|
||||
"""
|
||||
with self.env.cr.savepoint():
|
||||
with self._get_edi_creation() as invoice:
|
||||
if force_write and invoice.line_ids:
|
||||
invoice.invoice_line_ids = [Command.clear()]
|
||||
for purchase_order in purchase_orders:
|
||||
invoice.invoice_line_ids = [Command.create({
|
||||
'display_type': 'line_section',
|
||||
'name': _('From %s', purchase_order.name)
|
||||
})]
|
||||
invoice.purchase_id = purchase_order
|
||||
invoice._onchange_purchase_auto_complete()
|
||||
|
||||
def _match_purchase_orders(self, po_references, partner_id, amount_total, from_ocr, timeout):
|
||||
"""Tries to match open purchase order lines with this invoice given the information we have.
|
||||
|
||||
:param po_references: a list of potential purchase order references/names
|
||||
:param partner_id: the vendor id inferred from the vendor bill
|
||||
:param amount_total: the total amount of the vendor bill
|
||||
:param from_ocr: indicates whether this vendor bill was created from an OCR scan (less reliable)
|
||||
:param timeout: the max time the line matching algorithm can take before timing out
|
||||
:return: tuple (str, recordset, dict) containing:
|
||||
* the match method:
|
||||
* `total_match`: purchase order reference(s) and total amounts match perfectly
|
||||
* `subset_total_match`: a subset of the referenced purchase orders' lines matches the total amount of
|
||||
this invoice (OCR only)
|
||||
* `po_match`: only the purchase order reference matches (OCR only)
|
||||
* `subset_match`: a subset of the referenced purchase orders' lines matches a subset of the invoice
|
||||
lines based on unit prices (EDI only)
|
||||
* `no_match`: no result found
|
||||
* recordset of `purchase.order.line` containing purchase order lines matched with an invoice line
|
||||
* list of tuple containing every `purchase.order.line` id and its related `account.move.line`
|
||||
"""
|
||||
|
||||
common_domain = [
|
||||
('company_id', '=', self.company_id.id),
|
||||
('state', '=', 'purchase'),
|
||||
('invoice_status', 'in', ('to invoice', 'no'))
|
||||
]
|
||||
|
||||
matching_purchase_orders = self.env['purchase.order']
|
||||
|
||||
# We have purchase order references in our vendor bill and a total amount.
|
||||
if po_references and amount_total:
|
||||
# We first try looking for purchase orders whose names match one of the purchase order references in the
|
||||
# vendor bill.
|
||||
matching_purchase_orders |= self.env['purchase.order'].search(
|
||||
common_domain + [('name', 'in', po_references)])
|
||||
|
||||
if not matching_purchase_orders:
|
||||
# If not found, we try looking for purchase orders whose `partner_ref` field matches one of the
|
||||
# purchase order references in the vendor bill.
|
||||
matching_purchase_orders |= self.env['purchase.order'].search(
|
||||
common_domain + [('partner_ref', 'in', po_references)])
|
||||
|
||||
if matching_purchase_orders:
|
||||
# We found matching purchase orders and are extracting all purchase order lines together with their
|
||||
# amounts still to be invoiced.
|
||||
po_lines = [line for line in matching_purchase_orders.order_line if line.product_qty]
|
||||
po_lines_with_amount = [{
|
||||
'line': line,
|
||||
'amount_to_invoice': (1 - line.qty_invoiced / line.product_qty) * line.price_total,
|
||||
} for line in po_lines]
|
||||
|
||||
# If the sum of all remaining amounts to be invoiced for these purchase orders' lines is within a
|
||||
# tolerance from the vendor bill total, we have a total match. We return all purchase order lines
|
||||
# summing up to this vendor bill's total (could be from multiple purchase orders).
|
||||
if (amount_total - TOLERANCE
|
||||
< sum(line['amount_to_invoice'] for line in po_lines_with_amount)
|
||||
< amount_total + TOLERANCE):
|
||||
return 'total_match', matching_purchase_orders.order_line, None
|
||||
|
||||
elif from_ocr:
|
||||
# The invoice comes from an OCR scan.
|
||||
# We try to match the invoice total with purchase order lines.
|
||||
matching_po_lines = self._find_matching_subset_po_lines(
|
||||
po_lines_with_amount, amount_total, timeout)
|
||||
if matching_po_lines:
|
||||
return 'subset_total_match', self.env['purchase.order.line'].union(*matching_po_lines), None
|
||||
else:
|
||||
# We did not find a match for the invoice total.
|
||||
# We return all purchase order lines based only on the purchase order reference(s) in the
|
||||
# vendor bill.
|
||||
return 'po_match', matching_purchase_orders.order_line, None
|
||||
|
||||
else:
|
||||
# We have an invoice from an EDI document, so we try to match individual invoice lines with
|
||||
# individual purchase order lines from referenced purchase orders.
|
||||
matching_po_lines, matching_inv_lines = self._find_matching_po_and_inv_lines(
|
||||
po_lines, self.line_ids, timeout)
|
||||
|
||||
if matching_po_lines:
|
||||
# We found a subset of purchase order lines that match a subset of the vendor bill lines.
|
||||
# We return the matching purchase order lines and vendor bill lines.
|
||||
return ('subset_match',
|
||||
self.env['purchase.order.line'].union(*matching_po_lines),
|
||||
matching_inv_lines)
|
||||
|
||||
# As a last resort we try matching a purchase order by vendor and total amount.
|
||||
if partner_id and amount_total:
|
||||
purchase_id_domain = common_domain + [
|
||||
('partner_id', 'child_of', [partner_id]),
|
||||
('amount_total', '>=', amount_total - TOLERANCE),
|
||||
('amount_total', '<=', amount_total + TOLERANCE)
|
||||
]
|
||||
matching_purchase_orders = self.env['purchase.order'].search(purchase_id_domain)
|
||||
if len(matching_purchase_orders) == 1:
|
||||
# We found exactly one match on vendor and total amount (within tolerance).
|
||||
# We return all purchase order lines of the purchase order whose total amount matched our vendor bill.
|
||||
return 'total_match', matching_purchase_orders.order_line, None
|
||||
|
||||
# We couldn't find anything, so we return no lines.
|
||||
return ('no_match', matching_purchase_orders.order_line, None)
|
||||
|
||||
def _find_and_set_purchase_orders(self, po_references, partner_id, amount_total, from_ocr=False, timeout=10):
|
||||
"""Finds related purchase orders that (partially) match the vendor bill and links the matching lines on this
|
||||
vendor bill.
|
||||
|
||||
:param po_references: a list of potential purchase order references/names
|
||||
:param partner_id: the vendor id matched on the vendor bill
|
||||
:param amount_total: the total amount of the vendor bill
|
||||
:param from_ocr: indicates whether this vendor bill was created from an OCR scan (less reliable)
|
||||
:param timeout: the max time the line matching algorithm can take before timing out
|
||||
"""
|
||||
self.ensure_one()
|
||||
|
||||
method, matched_po_lines, matched_inv_lines = self._match_purchase_orders(
|
||||
po_references, partner_id, amount_total, from_ocr, timeout
|
||||
)
|
||||
|
||||
if method in ('total_match', 'po_match'):
|
||||
# The purchase order reference(s) and total amounts match perfectly or there is only one purchase order
|
||||
# reference that matches with an OCR invoice. We replace the invoice lines with the purchase order lines.
|
||||
self._set_purchase_orders(matched_po_lines.order_id, force_write=True)
|
||||
|
||||
elif method == 'subset_total_match':
|
||||
# A subset of the referenced purchase order lines matches the total amount of this invoice.
|
||||
# We keep the invoice lines, but add all the lines from the partially matched purchase orders:
|
||||
# * "naively" matched purchase order lines keep their quantity
|
||||
# * unmatched purchase order lines are added with their quantity set to 0
|
||||
self._set_purchase_orders(matched_po_lines.order_id, force_write=False)
|
||||
|
||||
with self._get_edi_creation() as invoice:
|
||||
unmatched_lines = invoice.invoice_line_ids.filtered(
|
||||
lambda l: l.purchase_line_id and l.purchase_line_id not in matched_po_lines)
|
||||
invoice.invoice_line_ids = [Command.update(line.id, {'quantity': 0}) for line in unmatched_lines]
|
||||
|
||||
elif method == 'subset_match':
|
||||
# A subset of the referenced purchase order lines matches a subset of the invoice lines.
|
||||
# We add the purchase order lines, but adjust the quantity to the quantities in the invoice.
|
||||
# The original invoice lines that correspond with a purchase order line are removed.
|
||||
self._set_purchase_orders(matched_po_lines.order_id, force_write=False)
|
||||
|
||||
with self._get_edi_creation() as invoice:
|
||||
unmatched_lines = invoice.invoice_line_ids.filtered(
|
||||
lambda l: l.purchase_line_id and l.purchase_line_id not in matched_po_lines)
|
||||
invoice.invoice_line_ids = [Command.delete(line.id) for line in unmatched_lines]
|
||||
|
||||
# We remove the original matched invoice lines and apply their quantities and taxes to the matched
|
||||
# purchase order lines.
|
||||
inv_and_po_lines = list(map(lambda line: (
|
||||
invoice.invoice_line_ids.filtered(
|
||||
lambda l: l.purchase_line_id and l.purchase_line_id.id == line[0]),
|
||||
invoice.invoice_line_ids.filtered(
|
||||
lambda l: l in line[1])
|
||||
),
|
||||
matched_inv_lines
|
||||
))
|
||||
invoice.invoice_line_ids = [
|
||||
Command.update(po_line.id, {'quantity': inv_line.quantity, 'tax_ids': inv_line.tax_ids})
|
||||
for po_line, inv_line in inv_and_po_lines
|
||||
]
|
||||
invoice.invoice_line_ids = [Command.delete(inv_line.id) for dummy, inv_line in inv_and_po_lines]
|
||||
|
||||
# If there are lines left not linked to a purchase order, we add a header
|
||||
unmatched_lines = invoice.invoice_line_ids.filtered(lambda l: not l.purchase_line_id)
|
||||
if len(unmatched_lines) > 0:
|
||||
invoice.invoice_line_ids = [Command.create({
|
||||
'display_type': 'line_section',
|
||||
'name': _('From Electronic Document'),
|
||||
'sequence': -1,
|
||||
})]
|
||||
|
||||
|
||||
|
||||
class AccountMoveLine(models.Model):
|
||||
""" Override AccountInvoice_line to add the link to the purchase order line it is related to"""
|
||||
_inherit = 'account.move.line'
|
||||
|
||||
purchase_line_id = fields.Many2one('purchase.order.line', 'Purchase Order Line', ondelete='set null', index='btree_not_null')
|
||||
purchase_order_id = fields.Many2one('purchase.order', 'Purchase Order', related='purchase_line_id.order_id', readonly=True)
|
||||
|
||||
def _copy_data_extend_business_fields(self, values):
|
||||
# OVERRIDE to copy the 'purchase_line_id' field as well.
|
||||
super(AccountMoveLine, self)._copy_data_extend_business_fields(values)
|
||||
values['purchase_line_id'] = self.purchase_line_id.id
|
30
models/account_tax.py
Normal file
30
models/account_tax.py
Normal file
@ -0,0 +1,30 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import models
|
||||
|
||||
|
||||
class AccountTax(models.Model):
|
||||
_inherit = "account.tax"
|
||||
|
||||
def _hook_compute_is_used(self, taxes_to_compute):
|
||||
# OVERRIDE in order to fetch taxes used in purchase
|
||||
|
||||
used_taxes = super()._hook_compute_is_used(taxes_to_compute)
|
||||
taxes_to_compute -= used_taxes
|
||||
|
||||
if taxes_to_compute:
|
||||
self.env['purchase.order.line'].flush_model(['taxes_id'])
|
||||
self.env.cr.execute("""
|
||||
SELECT id
|
||||
FROM account_tax
|
||||
WHERE EXISTS(
|
||||
SELECT 1
|
||||
FROM account_tax_purchase_order_line_rel AS pur
|
||||
WHERE account_tax_id IN %s
|
||||
AND account_tax.id = pur.account_tax_id
|
||||
)
|
||||
""", [tuple(taxes_to_compute)])
|
||||
|
||||
used_taxes.update([tax[0] for tax in self.env.cr.fetchall()])
|
||||
|
||||
return used_taxes
|
34
models/analytic_account.py
Normal file
34
models/analytic_account.py
Normal file
@ -0,0 +1,34 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
|
||||
|
||||
class AccountAnalyticAccount(models.Model):
|
||||
_inherit = 'account.analytic.account'
|
||||
|
||||
purchase_order_count = fields.Integer("Purchase Order Count", compute='_compute_purchase_order_count')
|
||||
|
||||
@api.depends('line_ids')
|
||||
def _compute_purchase_order_count(self):
|
||||
for account in self:
|
||||
account.purchase_order_count = self.env['purchase.order'].search_count([
|
||||
('order_line.invoice_lines.analytic_line_ids.account_id', '=', account.id)
|
||||
])
|
||||
|
||||
def action_view_purchase_orders(self):
|
||||
self.ensure_one()
|
||||
purchase_orders = self.env['purchase.order'].search([
|
||||
('order_line.invoice_lines.analytic_line_ids.account_id', '=', self.id)
|
||||
])
|
||||
result = {
|
||||
"type": "ir.actions.act_window",
|
||||
"res_model": "purchase.order",
|
||||
"domain": [['id', 'in', purchase_orders.ids]],
|
||||
"name": _("Purchase Orders"),
|
||||
'view_mode': 'tree,form',
|
||||
}
|
||||
if len(purchase_orders) == 1:
|
||||
result['view_mode'] = 'form'
|
||||
result['res_id'] = purchase_orders.id
|
||||
return result
|
15
models/analytic_applicability.py
Normal file
15
models/analytic_applicability.py
Normal file
@ -0,0 +1,15 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class AccountAnalyticApplicability(models.Model):
|
||||
_inherit = 'account.analytic.applicability'
|
||||
_description = "Analytic Plan's Applicabilities"
|
||||
|
||||
business_domain = fields.Selection(
|
||||
selection_add=[
|
||||
('purchase_order', 'Purchase Order'),
|
||||
],
|
||||
ondelete={'purchase_order': 'cascade'},
|
||||
)
|
94
models/product.py
Normal file
94
models/product.py
Normal file
@ -0,0 +1,94 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.addons.base.models.res_partner import WARNING_MESSAGE, WARNING_HELP
|
||||
from odoo.tools.float_utils import float_round
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
|
||||
class ProductTemplate(models.Model):
|
||||
_name = 'product.template'
|
||||
_inherit = 'product.template'
|
||||
|
||||
purchased_product_qty = fields.Float(compute='_compute_purchased_product_qty', string='Purchased', digits='Product Unit of Measure')
|
||||
purchase_method = fields.Selection([
|
||||
('purchase', 'On ordered quantities'),
|
||||
('receive', 'On received quantities'),
|
||||
], string="Control Policy", compute='_compute_purchase_method', default='receive', store=True, readonly=False,
|
||||
help="On ordered quantities: Control bills based on ordered quantities.\n"
|
||||
"On received quantities: Control bills based on received quantities.")
|
||||
purchase_line_warn = fields.Selection(WARNING_MESSAGE, 'Purchase Order Line Warning', help=WARNING_HELP, required=True, default="no-message")
|
||||
purchase_line_warn_msg = fields.Text('Message for Purchase Order Line')
|
||||
|
||||
@api.depends('detailed_type')
|
||||
def _compute_purchase_method(self):
|
||||
default_purchase_method = self.env['product.template'].default_get(['purchase_method']).get('purchase_method')
|
||||
for product in self:
|
||||
if product.detailed_type == 'service':
|
||||
product.purchase_method = 'purchase'
|
||||
else:
|
||||
product.purchase_method = default_purchase_method
|
||||
|
||||
def _compute_purchased_product_qty(self):
|
||||
for template in self:
|
||||
template.purchased_product_qty = float_round(sum([p.purchased_product_qty for p in template.product_variant_ids]), precision_rounding=template.uom_id.rounding)
|
||||
|
||||
@api.model
|
||||
def get_import_templates(self):
|
||||
res = super(ProductTemplate, self).get_import_templates()
|
||||
if self.env.context.get('purchase_product_template'):
|
||||
return [{
|
||||
'label': _('Import Template for Products'),
|
||||
'template': '/purchase/static/xls/product_purchase.xls'
|
||||
}]
|
||||
return res
|
||||
|
||||
def action_view_po(self):
|
||||
action = self.env["ir.actions.actions"]._for_xml_id("purchase.action_purchase_history")
|
||||
action['domain'] = ['&', ('state', 'in', ['purchase', 'done']), ('product_id', 'in', self.product_variant_ids.ids)]
|
||||
action['display_name'] = _("Purchase History for %s", self.display_name)
|
||||
return action
|
||||
|
||||
|
||||
class ProductProduct(models.Model):
|
||||
_name = 'product.product'
|
||||
_inherit = 'product.product'
|
||||
|
||||
purchased_product_qty = fields.Float(compute='_compute_purchased_product_qty', string='Purchased',
|
||||
digits='Product Unit of Measure')
|
||||
|
||||
def _compute_purchased_product_qty(self):
|
||||
date_from = fields.Datetime.to_string(fields.Date.context_today(self) - relativedelta(years=1))
|
||||
domain = [
|
||||
('order_id.state', 'in', ['purchase', 'done']),
|
||||
('product_id', 'in', self.ids),
|
||||
('order_id.date_approve', '>=', date_from)
|
||||
]
|
||||
order_lines = self.env['purchase.order.line']._read_group(domain, ['product_id'], ['product_uom_qty:sum'])
|
||||
purchased_data = {product.id: qty for product, qty in order_lines}
|
||||
for product in self:
|
||||
if not product.id:
|
||||
product.purchased_product_qty = 0.0
|
||||
continue
|
||||
product.purchased_product_qty = float_round(purchased_data.get(product.id, 0), precision_rounding=product.uom_id.rounding)
|
||||
|
||||
def action_view_po(self):
|
||||
action = self.env["ir.actions.actions"]._for_xml_id("purchase.action_purchase_history")
|
||||
action['domain'] = ['&', ('state', 'in', ['purchase', 'done']), ('product_id', 'in', self.ids)]
|
||||
action['display_name'] = _("Purchase History for %s", self.display_name)
|
||||
return action
|
||||
|
||||
|
||||
class ProductSupplierinfo(models.Model):
|
||||
_inherit = "product.supplierinfo"
|
||||
|
||||
@api.onchange('partner_id')
|
||||
def _onchange_partner_id(self):
|
||||
self.currency_id = self.partner_id.property_purchase_currency_id.id or self.env.company.currency_id.id
|
||||
|
||||
|
||||
class ProductPackaging(models.Model):
|
||||
_inherit = 'product.packaging'
|
||||
|
||||
purchase = fields.Boolean("Purchase", default=True, help="If true, the packaging can be used for purchase orders")
|
1067
models/purchase_order.py
Normal file
1067
models/purchase_order.py
Normal file
File diff suppressed because it is too large
Load Diff
654
models/purchase_order_line.py
Normal file
654
models/purchase_order_line.py
Normal file
@ -0,0 +1,654 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
from datetime import datetime, time
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from pytz import UTC
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.tools import DEFAULT_SERVER_DATETIME_FORMAT, get_lang
|
||||
from odoo.tools.float_utils import float_compare, float_round
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class PurchaseOrderLine(models.Model):
|
||||
_name = 'purchase.order.line'
|
||||
_inherit = 'analytic.mixin'
|
||||
_description = 'Purchase Order Line'
|
||||
_order = 'order_id, sequence, id'
|
||||
|
||||
name = fields.Text(
|
||||
string='Description', required=True, compute='_compute_price_unit_and_date_planned_and_name', store=True, readonly=False)
|
||||
sequence = fields.Integer(string='Sequence', default=10)
|
||||
product_qty = fields.Float(string='Quantity', digits='Product Unit of Measure', required=True,
|
||||
compute='_compute_product_qty', store=True, readonly=False)
|
||||
product_uom_qty = fields.Float(string='Total Quantity', compute='_compute_product_uom_qty', store=True)
|
||||
date_planned = fields.Datetime(
|
||||
string='Expected Arrival', index=True,
|
||||
compute="_compute_price_unit_and_date_planned_and_name", readonly=False, store=True,
|
||||
help="Delivery date expected from vendor. This date respectively defaults to vendor pricelist lead time then today's date.")
|
||||
discount = fields.Float(
|
||||
string="Discount (%)",
|
||||
compute='_compute_price_unit_and_date_planned_and_name',
|
||||
digits='Discount',
|
||||
store=True, readonly=False)
|
||||
taxes_id = fields.Many2many('account.tax', string='Taxes', context={'active_test': False})
|
||||
product_uom = fields.Many2one('uom.uom', string='Unit of Measure', domain="[('category_id', '=', product_uom_category_id)]")
|
||||
product_uom_category_id = fields.Many2one(related='product_id.uom_id.category_id')
|
||||
product_id = fields.Many2one('product.product', string='Product', domain=[('purchase_ok', '=', True)], change_default=True, index='btree_not_null')
|
||||
product_type = fields.Selection(related='product_id.detailed_type', readonly=True)
|
||||
price_unit = fields.Float(
|
||||
string='Unit Price', required=True, digits='Product Price',
|
||||
compute="_compute_price_unit_and_date_planned_and_name", readonly=False, store=True)
|
||||
price_unit_discounted = fields.Float('Unit Price (Discounted)', compute='_compute_price_unit_discounted')
|
||||
|
||||
price_subtotal = fields.Monetary(compute='_compute_amount', string='Subtotal', store=True)
|
||||
price_total = fields.Monetary(compute='_compute_amount', string='Total', store=True)
|
||||
price_tax = fields.Float(compute='_compute_amount', string='Tax', store=True)
|
||||
|
||||
order_id = fields.Many2one('purchase.order', string='Order Reference', index=True, required=True, ondelete='cascade')
|
||||
|
||||
company_id = fields.Many2one('res.company', related='order_id.company_id', string='Company', store=True, readonly=True)
|
||||
state = fields.Selection(related='order_id.state', store=True)
|
||||
|
||||
invoice_lines = fields.One2many('account.move.line', 'purchase_line_id', string="Bill Lines", readonly=True, copy=False)
|
||||
|
||||
# Replace by invoiced Qty
|
||||
qty_invoiced = fields.Float(compute='_compute_qty_invoiced', string="Billed Qty", digits='Product Unit of Measure', store=True)
|
||||
|
||||
qty_received_method = fields.Selection([('manual', 'Manual')], string="Received Qty Method", compute='_compute_qty_received_method', store=True,
|
||||
help="According to product configuration, the received quantity can be automatically computed by mechanism:\n"
|
||||
" - Manual: the quantity is set manually on the line\n"
|
||||
" - Stock Moves: the quantity comes from confirmed pickings\n")
|
||||
qty_received = fields.Float("Received Qty", compute='_compute_qty_received', inverse='_inverse_qty_received', compute_sudo=True, store=True, digits='Product Unit of Measure')
|
||||
qty_received_manual = fields.Float("Manual Received Qty", digits='Product Unit of Measure', copy=False)
|
||||
qty_to_invoice = fields.Float(compute='_compute_qty_invoiced', string='To Invoice Quantity', store=True, readonly=True,
|
||||
digits='Product Unit of Measure')
|
||||
|
||||
partner_id = fields.Many2one('res.partner', related='order_id.partner_id', string='Partner', readonly=True, store=True)
|
||||
currency_id = fields.Many2one(related='order_id.currency_id', store=True, string='Currency', readonly=True)
|
||||
date_order = fields.Datetime(related='order_id.date_order', string='Order Date', readonly=True)
|
||||
date_approve = fields.Datetime(related="order_id.date_approve", string='Confirmation Date', readonly=True)
|
||||
product_packaging_id = fields.Many2one('product.packaging', string='Packaging', domain="[('purchase', '=', True), ('product_id', '=', product_id)]", check_company=True,
|
||||
compute="_compute_product_packaging_id", store=True, readonly=False)
|
||||
product_packaging_qty = fields.Float('Packaging Quantity', compute="_compute_product_packaging_qty", store=True, readonly=False)
|
||||
tax_calculation_rounding_method = fields.Selection(
|
||||
related='company_id.tax_calculation_rounding_method',
|
||||
string='Tax calculation rounding method', readonly=True)
|
||||
display_type = fields.Selection([
|
||||
('line_section', "Section"),
|
||||
('line_note', "Note")], default=False, help="Technical field for UX purpose.")
|
||||
|
||||
_sql_constraints = [
|
||||
('accountable_required_fields',
|
||||
"CHECK(display_type IS NOT NULL OR (product_id IS NOT NULL AND product_uom IS NOT NULL AND date_planned IS NOT NULL))",
|
||||
"Missing required fields on accountable purchase order line."),
|
||||
('non_accountable_null_fields',
|
||||
"CHECK(display_type IS NULL OR (product_id IS NULL AND price_unit = 0 AND product_uom_qty = 0 AND product_uom IS NULL AND date_planned is NULL))",
|
||||
"Forbidden values on non-accountable purchase order line"),
|
||||
]
|
||||
|
||||
@api.depends('product_qty', 'price_unit', 'taxes_id', 'discount')
|
||||
def _compute_amount(self):
|
||||
for line in self:
|
||||
tax_results = self.env['account.tax']._compute_taxes([line._convert_to_tax_base_line_dict()])
|
||||
totals = next(iter(tax_results['totals'].values()))
|
||||
amount_untaxed = totals['amount_untaxed']
|
||||
amount_tax = totals['amount_tax']
|
||||
|
||||
line.update({
|
||||
'price_subtotal': amount_untaxed,
|
||||
'price_tax': amount_tax,
|
||||
'price_total': amount_untaxed + amount_tax,
|
||||
})
|
||||
|
||||
def _convert_to_tax_base_line_dict(self):
|
||||
""" Convert the current record to a dictionary in order to use the generic taxes computation method
|
||||
defined on account.tax.
|
||||
|
||||
:return: A python dictionary.
|
||||
"""
|
||||
self.ensure_one()
|
||||
return self.env['account.tax']._convert_to_tax_base_line_dict(
|
||||
self,
|
||||
partner=self.order_id.partner_id,
|
||||
currency=self.order_id.currency_id,
|
||||
product=self.product_id,
|
||||
taxes=self.taxes_id,
|
||||
price_unit=self.price_unit,
|
||||
quantity=self.product_qty,
|
||||
discount=self.discount,
|
||||
price_subtotal=self.price_subtotal,
|
||||
)
|
||||
|
||||
def _compute_tax_id(self):
|
||||
for line in self:
|
||||
line = line.with_company(line.company_id)
|
||||
fpos = line.order_id.fiscal_position_id or line.order_id.fiscal_position_id._get_fiscal_position(line.order_id.partner_id)
|
||||
# filter taxes by company
|
||||
taxes = line.product_id.supplier_taxes_id.filtered_domain(self.env['account.tax']._check_company_domain(line.company_id))
|
||||
line.taxes_id = fpos.map_tax(taxes)
|
||||
|
||||
@api.depends('discount', 'price_unit')
|
||||
def _compute_price_unit_discounted(self):
|
||||
for line in self:
|
||||
line.price_unit_discounted = line.price_unit * (1 - line.discount / 100)
|
||||
|
||||
@api.depends('invoice_lines.move_id.state', 'invoice_lines.quantity', 'qty_received', 'product_uom_qty', 'order_id.state')
|
||||
def _compute_qty_invoiced(self):
|
||||
for line in self:
|
||||
# compute qty_invoiced
|
||||
qty = 0.0
|
||||
for inv_line in line._get_invoice_lines():
|
||||
if inv_line.move_id.state not in ['cancel'] or inv_line.move_id.payment_state == 'invoicing_legacy':
|
||||
if inv_line.move_id.move_type == 'in_invoice':
|
||||
qty += inv_line.product_uom_id._compute_quantity(inv_line.quantity, line.product_uom)
|
||||
elif inv_line.move_id.move_type == 'in_refund':
|
||||
qty -= inv_line.product_uom_id._compute_quantity(inv_line.quantity, line.product_uom)
|
||||
line.qty_invoiced = qty
|
||||
|
||||
# compute qty_to_invoice
|
||||
if line.order_id.state in ['purchase', 'done']:
|
||||
if line.product_id.purchase_method == 'purchase':
|
||||
line.qty_to_invoice = line.product_qty - line.qty_invoiced
|
||||
else:
|
||||
line.qty_to_invoice = line.qty_received - line.qty_invoiced
|
||||
else:
|
||||
line.qty_to_invoice = 0
|
||||
|
||||
def _get_invoice_lines(self):
|
||||
self.ensure_one()
|
||||
if self._context.get('accrual_entry_date'):
|
||||
return self.invoice_lines.filtered(
|
||||
lambda l: l.move_id.invoice_date and l.move_id.invoice_date <= self._context['accrual_entry_date']
|
||||
)
|
||||
else:
|
||||
return self.invoice_lines
|
||||
|
||||
@api.depends('product_id', 'product_id.type')
|
||||
def _compute_qty_received_method(self):
|
||||
for line in self:
|
||||
if line.product_id and line.product_id.type in ['consu', 'service']:
|
||||
line.qty_received_method = 'manual'
|
||||
else:
|
||||
line.qty_received_method = False
|
||||
|
||||
@api.depends('qty_received_method', 'qty_received_manual')
|
||||
def _compute_qty_received(self):
|
||||
for line in self:
|
||||
if line.qty_received_method == 'manual':
|
||||
line.qty_received = line.qty_received_manual or 0.0
|
||||
else:
|
||||
line.qty_received = 0.0
|
||||
|
||||
@api.onchange('qty_received')
|
||||
def _inverse_qty_received(self):
|
||||
""" When writing on qty_received, if the value should be modify manually (`qty_received_method` = 'manual' only),
|
||||
then we put the value in `qty_received_manual`. Otherwise, `qty_received_manual` should be False since the
|
||||
received qty is automatically compute by other mecanisms.
|
||||
"""
|
||||
for line in self:
|
||||
if line.qty_received_method == 'manual':
|
||||
line.qty_received_manual = line.qty_received
|
||||
else:
|
||||
line.qty_received_manual = 0.0
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
for values in vals_list:
|
||||
if values.get('display_type', self.default_get(['display_type'])['display_type']):
|
||||
values.update(product_id=False, price_unit=0, product_uom_qty=0, product_uom=False, date_planned=False)
|
||||
else:
|
||||
values.update(self._prepare_add_missing_fields(values))
|
||||
|
||||
lines = super().create(vals_list)
|
||||
for line in lines:
|
||||
if line.product_id and line.order_id.state == 'purchase':
|
||||
msg = _("Extra line with %s ", line.product_id.display_name)
|
||||
line.order_id.message_post(body=msg)
|
||||
return lines
|
||||
|
||||
def write(self, values):
|
||||
if 'display_type' in values and self.filtered(lambda line: line.display_type != values.get('display_type')):
|
||||
raise UserError(_("You cannot change the type of a purchase order line. Instead you should delete the current line and create a new line of the proper type."))
|
||||
|
||||
if 'product_qty' in values:
|
||||
precision = self.env['decimal.precision'].precision_get('Product Unit of Measure')
|
||||
for line in self:
|
||||
if (
|
||||
line.order_id.state == "purchase"
|
||||
and float_compare(line.product_qty, values["product_qty"], precision_digits=precision) != 0
|
||||
):
|
||||
line.order_id.message_post_with_source(
|
||||
'purchase.track_po_line_template',
|
||||
render_values={'line': line, 'product_qty': values['product_qty']},
|
||||
subtype_xmlid='mail.mt_note',
|
||||
)
|
||||
|
||||
if 'qty_received' in values:
|
||||
for line in self:
|
||||
line._track_qty_received(values['qty_received'])
|
||||
return super(PurchaseOrderLine, self).write(values)
|
||||
|
||||
@api.ondelete(at_uninstall=False)
|
||||
def _unlink_except_purchase_or_done(self):
|
||||
for line in self:
|
||||
if line.order_id.state in ['purchase', 'done']:
|
||||
state_description = {state_desc[0]: state_desc[1] for state_desc in self._fields['state']._description_selection(self.env)}
|
||||
raise UserError(_('Cannot delete a purchase order line which is in state %r.', state_description.get(line.state)))
|
||||
|
||||
@api.model
|
||||
def _get_date_planned(self, seller, po=False):
|
||||
"""Return the datetime value to use as Schedule Date (``date_planned``) for
|
||||
PO Lines that correspond to the given product.seller_ids,
|
||||
when ordered at `date_order_str`.
|
||||
|
||||
:param Model seller: used to fetch the delivery delay (if no seller
|
||||
is provided, the delay is 0)
|
||||
:param Model po: purchase.order, necessary only if the PO line is
|
||||
not yet attached to a PO.
|
||||
:rtype: datetime
|
||||
:return: desired Schedule Date for the PO line
|
||||
"""
|
||||
date_order = po.date_order if po else self.order_id.date_order
|
||||
if date_order:
|
||||
return date_order + relativedelta(days=seller.delay if seller else 0)
|
||||
else:
|
||||
return datetime.today() + relativedelta(days=seller.delay if seller else 0)
|
||||
|
||||
@api.depends('product_id', 'order_id.partner_id')
|
||||
def _compute_analytic_distribution(self):
|
||||
for line in self:
|
||||
if not line.display_type:
|
||||
distribution = self.env['account.analytic.distribution.model']._get_distribution({
|
||||
"product_id": line.product_id.id,
|
||||
"product_categ_id": line.product_id.categ_id.id,
|
||||
"partner_id": line.order_id.partner_id.id,
|
||||
"partner_category_id": line.order_id.partner_id.category_id.ids,
|
||||
"company_id": line.company_id.id,
|
||||
})
|
||||
line.analytic_distribution = distribution or line.analytic_distribution
|
||||
|
||||
@api.onchange('product_id')
|
||||
def onchange_product_id(self):
|
||||
# TODO: Remove when onchanges are replaced with computes
|
||||
if not self.product_id or (self.env.context.get('origin_po_id') and self.product_qty):
|
||||
return
|
||||
|
||||
# Reset date, price and quantity since _onchange_quantity will provide default values
|
||||
self.price_unit = self.product_qty = 0.0
|
||||
|
||||
self._product_id_change()
|
||||
|
||||
self._suggest_quantity()
|
||||
|
||||
def _product_id_change(self):
|
||||
if not self.product_id:
|
||||
return
|
||||
|
||||
self.product_uom = self.product_id.uom_po_id or self.product_id.uom_id
|
||||
product_lang = self.product_id.with_context(
|
||||
lang=get_lang(self.env, self.partner_id.lang).code,
|
||||
partner_id=self.partner_id.id,
|
||||
company_id=self.company_id.id,
|
||||
)
|
||||
self.name = self._get_product_purchase_description(product_lang)
|
||||
|
||||
self._compute_tax_id()
|
||||
|
||||
@api.onchange('product_id')
|
||||
def onchange_product_id_warning(self):
|
||||
if not self.product_id or not self.env.user.has_group('purchase.group_warning_purchase'):
|
||||
return
|
||||
warning = {}
|
||||
title = False
|
||||
message = False
|
||||
|
||||
product_info = self.product_id
|
||||
|
||||
if product_info.purchase_line_warn != 'no-message':
|
||||
title = _("Warning for %s", product_info.name)
|
||||
message = product_info.purchase_line_warn_msg
|
||||
warning['title'] = title
|
||||
warning['message'] = message
|
||||
if product_info.purchase_line_warn == 'block':
|
||||
self.product_id = False
|
||||
return {'warning': warning}
|
||||
return {}
|
||||
|
||||
@api.depends('product_qty', 'product_uom', 'company_id')
|
||||
def _compute_price_unit_and_date_planned_and_name(self):
|
||||
for line in self:
|
||||
if not line.product_id or line.invoice_lines or not line.company_id:
|
||||
continue
|
||||
params = {'order_id': line.order_id}
|
||||
seller = line.product_id._select_seller(
|
||||
partner_id=line.partner_id,
|
||||
quantity=line.product_qty,
|
||||
date=line.order_id.date_order and line.order_id.date_order.date() or fields.Date.context_today(line),
|
||||
uom_id=line.product_uom,
|
||||
params=params)
|
||||
|
||||
if seller or not line.date_planned:
|
||||
line.date_planned = line._get_date_planned(seller).strftime(DEFAULT_SERVER_DATETIME_FORMAT)
|
||||
|
||||
# If not seller, use the standard price. It needs a proper currency conversion.
|
||||
if not seller:
|
||||
unavailable_seller = line.product_id.seller_ids.filtered(
|
||||
lambda s: s.partner_id == line.order_id.partner_id)
|
||||
if not unavailable_seller and line.price_unit and line.product_uom == line._origin.product_uom:
|
||||
# Avoid to modify the price unit if there is no price list for this partner and
|
||||
# the line has already one to avoid to override unit price set manually.
|
||||
continue
|
||||
po_line_uom = line.product_uom or line.product_id.uom_po_id
|
||||
price_unit = line.env['account.tax']._fix_tax_included_price_company(
|
||||
line.product_id.uom_id._compute_price(line.product_id.standard_price, po_line_uom),
|
||||
line.product_id.supplier_taxes_id,
|
||||
line.taxes_id,
|
||||
line.company_id,
|
||||
)
|
||||
price_unit = line.product_id.cost_currency_id._convert(
|
||||
price_unit,
|
||||
line.currency_id,
|
||||
line.company_id,
|
||||
line.date_order or fields.Date.context_today(line),
|
||||
False
|
||||
)
|
||||
line.price_unit = float_round(price_unit, precision_digits=max(line.currency_id.decimal_places, self.env['decimal.precision'].precision_get('Product Price')))
|
||||
continue
|
||||
|
||||
price_unit = line.env['account.tax']._fix_tax_included_price_company(seller.price, line.product_id.supplier_taxes_id, line.taxes_id, line.company_id) if seller else 0.0
|
||||
price_unit = seller.currency_id._convert(price_unit, line.currency_id, line.company_id, line.date_order or fields.Date.context_today(line), False)
|
||||
price_unit = float_round(price_unit, precision_digits=max(line.currency_id.decimal_places, self.env['decimal.precision'].precision_get('Product Price')))
|
||||
line.price_unit = seller.product_uom._compute_price(price_unit, line.product_uom)
|
||||
line.discount = seller.discount or 0.0
|
||||
|
||||
# record product names to avoid resetting custom descriptions
|
||||
default_names = []
|
||||
vendors = line.product_id._prepare_sellers({})
|
||||
for vendor in vendors:
|
||||
product_ctx = {'seller_id': vendor.id, 'lang': get_lang(line.env, line.partner_id.lang).code}
|
||||
default_names.append(line._get_product_purchase_description(line.product_id.with_context(product_ctx)))
|
||||
if not line.name or line.name in default_names:
|
||||
product_ctx = {'seller_id': seller.id, 'lang': get_lang(line.env, line.partner_id.lang).code}
|
||||
line.name = line._get_product_purchase_description(line.product_id.with_context(product_ctx))
|
||||
|
||||
@api.depends('product_id', 'product_qty', 'product_uom')
|
||||
def _compute_product_packaging_id(self):
|
||||
for line in self:
|
||||
# remove packaging if not match the product
|
||||
if line.product_packaging_id.product_id != line.product_id:
|
||||
line.product_packaging_id = False
|
||||
# suggest biggest suitable packaging matching the PO's company
|
||||
if line.product_id and line.product_qty and line.product_uom:
|
||||
suggested_packaging = line.product_id.packaging_ids\
|
||||
.filtered(lambda p: p.purchase and (p.product_id.company_id <= p.company_id <= line.company_id))\
|
||||
._find_suitable_product_packaging(line.product_qty, line.product_uom)
|
||||
line.product_packaging_id = suggested_packaging or line.product_packaging_id
|
||||
|
||||
@api.onchange('product_packaging_id')
|
||||
def _onchange_product_packaging_id(self):
|
||||
if self.product_packaging_id and self.product_qty:
|
||||
newqty = self.product_packaging_id._check_qty(self.product_qty, self.product_uom, "UP")
|
||||
if float_compare(newqty, self.product_qty, precision_rounding=self.product_uom.rounding) != 0:
|
||||
return {
|
||||
'warning': {
|
||||
'title': _('Warning'),
|
||||
'message': _(
|
||||
"This product is packaged by %(pack_size).2f %(pack_name)s. You should purchase %(quantity).2f %(unit)s.",
|
||||
pack_size=self.product_packaging_id.qty,
|
||||
pack_name=self.product_id.uom_id.name,
|
||||
quantity=newqty,
|
||||
unit=self.product_uom.name
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
@api.depends('product_packaging_id', 'product_uom', 'product_qty')
|
||||
def _compute_product_packaging_qty(self):
|
||||
self.product_packaging_qty = 0
|
||||
for line in self:
|
||||
if not line.product_packaging_id:
|
||||
continue
|
||||
line.product_packaging_qty = line.product_packaging_id._compute_qty(line.product_qty, line.product_uom)
|
||||
|
||||
@api.depends('product_packaging_qty')
|
||||
def _compute_product_qty(self):
|
||||
for line in self:
|
||||
if line.product_packaging_id:
|
||||
packaging_uom = line.product_packaging_id.product_uom_id
|
||||
qty_per_packaging = line.product_packaging_id.qty
|
||||
product_qty = packaging_uom._compute_quantity(line.product_packaging_qty * qty_per_packaging, line.product_uom)
|
||||
if float_compare(product_qty, line.product_qty, precision_rounding=line.product_uom.rounding) != 0:
|
||||
line.product_qty = product_qty
|
||||
|
||||
@api.depends('product_uom', 'product_qty', 'product_id.uom_id')
|
||||
def _compute_product_uom_qty(self):
|
||||
for line in self:
|
||||
if line.product_id and line.product_id.uom_id != line.product_uom:
|
||||
line.product_uom_qty = line.product_uom._compute_quantity(line.product_qty, line.product_id.uom_id)
|
||||
else:
|
||||
line.product_uom_qty = line.product_qty
|
||||
|
||||
def _get_gross_price_unit(self):
|
||||
self.ensure_one()
|
||||
price_unit = self.price_unit
|
||||
if self.discount:
|
||||
price_unit = price_unit * (1 - self.discount / 100)
|
||||
if self.taxes_id:
|
||||
qty = self.product_qty or 1
|
||||
price_unit_prec = self.env['decimal.precision'].precision_get('Product Price')
|
||||
price_unit = self.taxes_id.with_context(round=False).compute_all(price_unit, currency=self.order_id.currency_id, quantity=qty)['total_void']
|
||||
price_unit = float_round(price_unit / qty, precision_digits=price_unit_prec)
|
||||
if self.product_uom.id != self.product_id.uom_id.id:
|
||||
price_unit *= self.product_uom.factor / self.product_id.uom_id.factor
|
||||
return price_unit
|
||||
|
||||
def action_add_from_catalog(self):
|
||||
order = self.env['purchase.order'].browse(self.env.context.get('order_id'))
|
||||
return order.action_add_from_catalog()
|
||||
|
||||
def action_purchase_history(self):
|
||||
self.ensure_one()
|
||||
action = self.env["ir.actions.actions"]._for_xml_id("purchase.action_purchase_history")
|
||||
action['domain'] = [('state', 'in', ['purchase', 'done']), ('product_id', '=', self.product_id.id)]
|
||||
action['display_name'] = _("Purchase History for %s", self.product_id.display_name)
|
||||
action['context'] = {
|
||||
'search_default_partner_id': self.partner_id.id
|
||||
}
|
||||
|
||||
return action
|
||||
|
||||
def _suggest_quantity(self):
|
||||
'''
|
||||
Suggest a minimal quantity based on the seller
|
||||
'''
|
||||
if not self.product_id:
|
||||
return
|
||||
seller_min_qty = self.product_id.seller_ids\
|
||||
.filtered(lambda r: r.partner_id == self.order_id.partner_id and (not r.product_id or r.product_id == self.product_id))\
|
||||
.sorted(key=lambda r: r.min_qty)
|
||||
if seller_min_qty:
|
||||
self.product_qty = seller_min_qty[0].min_qty or 1.0
|
||||
self.product_uom = seller_min_qty[0].product_uom
|
||||
else:
|
||||
self.product_qty = 1.0
|
||||
|
||||
def _get_product_catalog_lines_data(self):
|
||||
""" Return information about purchase order lines in `self`.
|
||||
|
||||
If `self` is empty, this method returns only the default value(s) needed for the product
|
||||
catalog. In this case, the quantity that equals 0.
|
||||
|
||||
Otherwise, it returns a quantity and a price based on the product of the POL(s) and whether
|
||||
the product is read-only or not.
|
||||
|
||||
A product is considered read-only if the order is considered read-only (see
|
||||
``PurchaseOrder._is_readonly`` for more details) or if `self` contains multiple records.
|
||||
|
||||
Note: This method cannot be called with multiple records that have different products linked.
|
||||
|
||||
:raise odoo.exceptions.ValueError: ``len(self.product_id) != 1``
|
||||
:rtype: dict
|
||||
:return: A dict with the following structure:
|
||||
{
|
||||
'quantity': float,
|
||||
'price': float,
|
||||
'readOnly': bool,
|
||||
'uom': dict,
|
||||
'purchase_uom': dict,
|
||||
'packaging': dict,
|
||||
}
|
||||
"""
|
||||
if len(self) == 1:
|
||||
catalog_info = self.order_id._get_product_price_and_data(self.product_id)
|
||||
uom = {
|
||||
'display_name': self.product_id.uom_id.display_name,
|
||||
'id': self.product_id.uom_id.id,
|
||||
}
|
||||
catalog_info.update(
|
||||
quantity=self.product_qty,
|
||||
price=self.price_unit * (1 - self.discount / 100),
|
||||
readOnly=self.order_id._is_readonly(),
|
||||
uom=uom,
|
||||
)
|
||||
if self.product_id.uom_id != self.product_uom:
|
||||
catalog_info['purchase_uom'] = {
|
||||
'display_name': self.product_uom.display_name,
|
||||
'id': self.product_uom.id,
|
||||
}
|
||||
if self.product_packaging_id:
|
||||
packaging = self.product_packaging_id
|
||||
catalog_info['packaging'] = {
|
||||
'id': packaging.id,
|
||||
'name': packaging.display_name,
|
||||
'qty': packaging.product_uom_id._compute_quantity(packaging.qty, self.product_uom),
|
||||
}
|
||||
return catalog_info
|
||||
elif self:
|
||||
self.product_id.ensure_one()
|
||||
order_line = self[0]
|
||||
catalog_info = order_line.order_id._get_product_price_and_data(order_line.product_id)
|
||||
catalog_info['quantity'] = sum(self.mapped(
|
||||
lambda line: line.product_uom._compute_quantity(
|
||||
qty=line.product_qty,
|
||||
to_unit=line.product_id.uom_id,
|
||||
)))
|
||||
catalog_info['readOnly'] = True
|
||||
return catalog_info
|
||||
return {'quantity': 0}
|
||||
|
||||
def _get_product_purchase_description(self, product_lang):
|
||||
self.ensure_one()
|
||||
name = product_lang.display_name
|
||||
if product_lang.description_purchase:
|
||||
name += '\n' + product_lang.description_purchase
|
||||
|
||||
return name
|
||||
|
||||
def _prepare_account_move_line(self, move=False):
|
||||
self.ensure_one()
|
||||
aml_currency = move and move.currency_id or self.currency_id
|
||||
date = move and move.date or fields.Date.today()
|
||||
res = {
|
||||
'display_type': self.display_type or 'product',
|
||||
'name': '%s: %s' % (self.order_id.name, self.name),
|
||||
'product_id': self.product_id.id,
|
||||
'product_uom_id': self.product_uom.id,
|
||||
'quantity': self.qty_to_invoice,
|
||||
'discount': self.discount,
|
||||
'price_unit': self.currency_id._convert(self.price_unit, aml_currency, self.company_id, date, round=False),
|
||||
'tax_ids': [(6, 0, self.taxes_id.ids)],
|
||||
'purchase_line_id': self.id,
|
||||
}
|
||||
if self.analytic_distribution and not self.display_type:
|
||||
res['analytic_distribution'] = self.analytic_distribution
|
||||
return res
|
||||
|
||||
@api.model
|
||||
def _prepare_add_missing_fields(self, values):
|
||||
""" Deduce missing required fields from the onchange """
|
||||
res = {}
|
||||
onchange_fields = ['name', 'price_unit', 'product_qty', 'product_uom', 'taxes_id', 'date_planned']
|
||||
if values.get('order_id') and values.get('product_id') and any(f not in values for f in onchange_fields):
|
||||
line = self.new(values)
|
||||
line.onchange_product_id()
|
||||
for field in onchange_fields:
|
||||
if field not in values:
|
||||
res[field] = line._fields[field].convert_to_write(line[field], line)
|
||||
return res
|
||||
|
||||
@api.model
|
||||
def _prepare_purchase_order_line(self, product_id, product_qty, product_uom, company_id, supplier, po):
|
||||
partner = supplier.partner_id
|
||||
uom_po_qty = product_uom._compute_quantity(product_qty, product_id.uom_po_id, rounding_method='HALF-UP')
|
||||
# _select_seller is used if the supplier have different price depending
|
||||
# the quantities ordered.
|
||||
today = fields.Date.today()
|
||||
seller = product_id.with_company(company_id)._select_seller(
|
||||
partner_id=partner,
|
||||
quantity=uom_po_qty,
|
||||
date=po.date_order and max(po.date_order.date(), today) or today,
|
||||
uom_id=product_id.uom_po_id)
|
||||
|
||||
product_taxes = product_id.supplier_taxes_id.filtered(lambda x: x.company_id.id == company_id.id)
|
||||
taxes = po.fiscal_position_id.map_tax(product_taxes)
|
||||
|
||||
price_unit = self.env['account.tax']._fix_tax_included_price_company(
|
||||
seller.price, product_taxes, taxes, company_id) if seller else 0.0
|
||||
if price_unit and seller and po.currency_id and seller.currency_id != po.currency_id:
|
||||
price_unit = seller.currency_id._convert(
|
||||
price_unit, po.currency_id, po.company_id, po.date_order or fields.Date.today())
|
||||
|
||||
product_lang = product_id.with_prefetch().with_context(
|
||||
lang=partner.lang,
|
||||
partner_id=partner.id,
|
||||
)
|
||||
name = product_lang.with_context(seller_id=seller.id).display_name
|
||||
if product_lang.description_purchase:
|
||||
name += '\n' + product_lang.description_purchase
|
||||
|
||||
date_planned = self.order_id.date_planned or self._get_date_planned(seller, po=po)
|
||||
discount = seller.discount or 0.0
|
||||
|
||||
return {
|
||||
'name': name,
|
||||
'product_qty': uom_po_qty,
|
||||
'product_id': product_id.id,
|
||||
'product_uom': product_id.uom_po_id.id,
|
||||
'price_unit': price_unit,
|
||||
'date_planned': date_planned,
|
||||
'taxes_id': [(6, 0, taxes.ids)],
|
||||
'order_id': po.id,
|
||||
'discount': discount,
|
||||
}
|
||||
|
||||
def _convert_to_middle_of_day(self, date):
|
||||
"""Return a datetime which is the noon of the input date(time) according
|
||||
to order user's time zone, convert to UTC time.
|
||||
"""
|
||||
return self.order_id.get_order_timezone().localize(datetime.combine(date, time(12))).astimezone(UTC).replace(tzinfo=None)
|
||||
|
||||
def _update_date_planned(self, updated_date):
|
||||
self.date_planned = updated_date
|
||||
|
||||
def _track_qty_received(self, new_qty):
|
||||
self.ensure_one()
|
||||
# don't track anything when coming from the accrued expense entry wizard, as it is only computing fields at a past date to get relevant amounts
|
||||
# and doesn't actually change anything to the current record
|
||||
if self.env.context.get('accrual_entry_date'):
|
||||
return
|
||||
if new_qty != self.qty_received and self.order_id.state == 'purchase':
|
||||
self.order_id.message_post_with_source(
|
||||
'purchase.track_po_line_qty_received_template',
|
||||
render_values={'line': self, 'qty_received': new_qty},
|
||||
subtype_xmlid='mail.mt_note',
|
||||
)
|
||||
|
||||
def _validate_analytic_distribution(self):
|
||||
for line in self:
|
||||
if line.display_type:
|
||||
continue
|
||||
line._validate_distribution(
|
||||
product=line.product_id.id,
|
||||
business_domain='purchase_order',
|
||||
company_id=line.company_id.id,
|
||||
)
|
28
models/res_company.py
Normal file
28
models/res_company.py
Normal file
@ -0,0 +1,28 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
class Company(models.Model):
|
||||
_inherit = 'res.company'
|
||||
|
||||
po_lead = fields.Float(string='Purchase Lead Time', required=True,
|
||||
help="Margin of error for vendor lead times. When the system "
|
||||
"generates Purchase Orders for procuring products, "
|
||||
"they will be scheduled that many days earlier "
|
||||
"to cope with unexpected vendor delays.", default=0.0)
|
||||
|
||||
po_lock = fields.Selection([
|
||||
('edit', 'Allow to edit purchase orders'),
|
||||
('lock', 'Confirmed purchase orders are not editable')
|
||||
], string="Purchase Order Modification", default="edit",
|
||||
help='Purchase Order Modification used when you want to purchase order editable after confirm')
|
||||
|
||||
po_double_validation = fields.Selection([
|
||||
('one_step', 'Confirm purchase orders in one step'),
|
||||
('two_step', 'Get 2 levels of approvals to confirm a purchase order')
|
||||
], string="Levels of Approvals", default='one_step',
|
||||
help="Provide a double validation mechanism for purchases")
|
||||
|
||||
po_double_validation_amount = fields.Monetary(string='Double validation amount', default=5000,
|
||||
help="Minimum amount for which a double validation is required")
|
47
models/res_config_settings.py
Normal file
47
models/res_config_settings.py
Normal file
@ -0,0 +1,47 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class ResConfigSettings(models.TransientModel):
|
||||
_inherit = 'res.config.settings'
|
||||
|
||||
lock_confirmed_po = fields.Boolean("Lock Confirmed Orders", default=lambda self: self.env.company.po_lock == 'lock')
|
||||
po_lock = fields.Selection(related='company_id.po_lock', string="Purchase Order Modification *", readonly=False)
|
||||
po_order_approval = fields.Boolean("Purchase Order Approval", default=lambda self: self.env.company.po_double_validation == 'two_step')
|
||||
po_double_validation = fields.Selection(related='company_id.po_double_validation', string="Levels of Approvals *", readonly=False)
|
||||
po_double_validation_amount = fields.Monetary(related='company_id.po_double_validation_amount', string="Minimum Amount", currency_field='company_currency_id', readonly=False)
|
||||
company_currency_id = fields.Many2one('res.currency', related='company_id.currency_id', string="Company Currency", readonly=True)
|
||||
default_purchase_method = fields.Selection([
|
||||
('purchase', 'Ordered quantities'),
|
||||
('receive', 'Received quantities'),
|
||||
], string="Bill Control", default_model="product.template",
|
||||
help="This default value is applied to any new product created. "
|
||||
"This can be changed in the product detail form.", default="receive")
|
||||
group_warning_purchase = fields.Boolean("Purchase Warnings", implied_group='purchase.group_warning_purchase')
|
||||
module_account_3way_match = fields.Boolean("3-way matching: purchases, receptions and bills")
|
||||
module_purchase_requisition = fields.Boolean("Purchase Agreements")
|
||||
module_purchase_product_matrix = fields.Boolean("Purchase Grid Entry")
|
||||
po_lead = fields.Float(related='company_id.po_lead', readonly=False)
|
||||
use_po_lead = fields.Boolean(
|
||||
string="Security Lead Time for Purchase",
|
||||
config_parameter='purchase.use_po_lead',
|
||||
help="Margin of error for vendor lead times. When the system generates Purchase Orders for reordering products,they will be scheduled that many days earlier to cope with unexpected vendor delays.")
|
||||
|
||||
group_send_reminder = fields.Boolean("Receipt Reminder", implied_group='purchase.group_send_reminder', default=True,
|
||||
help="Allow automatically send email to remind your vendor the receipt date")
|
||||
|
||||
@api.onchange('use_po_lead')
|
||||
def _onchange_use_po_lead(self):
|
||||
if not self.use_po_lead:
|
||||
self.po_lead = 0.0
|
||||
|
||||
def set_values(self):
|
||||
super().set_values()
|
||||
po_lock = 'lock' if self.lock_confirmed_po else 'edit'
|
||||
po_double_validation = 'two_step' if self.po_order_approval else 'one_step'
|
||||
if self.po_lock != po_lock:
|
||||
self.po_lock = po_lock
|
||||
if self.po_double_validation != po_double_validation:
|
||||
self.po_double_validation = po_double_validation
|
67
models/res_partner.py
Normal file
67
models/res_partner.py
Normal file
@ -0,0 +1,67 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, fields, models
|
||||
from odoo.addons.base.models.res_partner import WARNING_MESSAGE, WARNING_HELP
|
||||
|
||||
|
||||
class res_partner(models.Model):
|
||||
_name = 'res.partner'
|
||||
_inherit = 'res.partner'
|
||||
|
||||
def _compute_purchase_order_count(self):
|
||||
# retrieve all children partners and prefetch 'parent_id' on them
|
||||
all_partners = self.with_context(active_test=False).search_fetch(
|
||||
[('id', 'child_of', self.ids)],
|
||||
['parent_id'],
|
||||
)
|
||||
purchase_order_groups = self.env['purchase.order']._read_group(
|
||||
domain=[('partner_id', 'in', all_partners.ids)],
|
||||
groupby=['partner_id'], aggregates=['__count'],
|
||||
)
|
||||
self_ids = set(self._ids)
|
||||
|
||||
self.purchase_order_count = 0
|
||||
for partner, count in purchase_order_groups:
|
||||
while partner:
|
||||
if partner.id in self_ids:
|
||||
partner.purchase_order_count += count
|
||||
partner = partner.parent_id
|
||||
|
||||
def _compute_supplier_invoice_count(self):
|
||||
# retrieve all children partners and prefetch 'parent_id' on them
|
||||
all_partners = self.with_context(active_test=False).search_fetch(
|
||||
[('id', 'child_of', self.ids)],
|
||||
['parent_id'],
|
||||
)
|
||||
supplier_invoice_groups = self.env['account.move']._read_group(
|
||||
domain=[('partner_id', 'in', all_partners.ids),
|
||||
('move_type', 'in', ('in_invoice', 'in_refund'))],
|
||||
groupby=['partner_id'], aggregates=['__count']
|
||||
)
|
||||
self_ids = set(self._ids)
|
||||
|
||||
self.supplier_invoice_count = 0
|
||||
for partner, count in supplier_invoice_groups:
|
||||
while partner:
|
||||
if partner.id in self_ids:
|
||||
partner.supplier_invoice_count += count
|
||||
partner = partner.parent_id
|
||||
|
||||
@api.model
|
||||
def _commercial_fields(self):
|
||||
return super(res_partner, self)._commercial_fields()
|
||||
|
||||
property_purchase_currency_id = fields.Many2one(
|
||||
'res.currency', string="Supplier Currency", company_dependent=True,
|
||||
help="This currency will be used, instead of the default one, for purchases from the current partner")
|
||||
purchase_order_count = fields.Integer(compute='_compute_purchase_order_count', string='Purchase Order Count')
|
||||
supplier_invoice_count = fields.Integer(compute='_compute_supplier_invoice_count', string='# Vendor Bills')
|
||||
purchase_warn = fields.Selection(WARNING_MESSAGE, 'Purchase Order', help=WARNING_HELP, default="no-message")
|
||||
purchase_warn_msg = fields.Text('Message for Purchase Order')
|
||||
|
||||
receipt_reminder_email = fields.Boolean('Receipt Reminder', default=False, company_dependent=True,
|
||||
help="Automatically send a confirmation email to the vendor X days before the expected receipt date, asking him to confirm the exact date.")
|
||||
reminder_date_before_receipt = fields.Integer('Days Before Receipt', default=1, company_dependent=True,
|
||||
help="Number of days to send reminder email before the promised receipt date")
|
||||
buyer_id = fields.Many2one('res.users', string='Buyer')
|
4
populate/__init__.py
Normal file
4
populate/__init__.py
Normal file
@ -0,0 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import purchase
|
92
populate/purchase.py
Normal file
92
populate/purchase.py
Normal file
@ -0,0 +1,92 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from odoo import models
|
||||
from odoo.tools import populate, groupby
|
||||
from odoo.addons.stock.populate.stock import COMPANY_NB_WITH_STOCK
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ResCompany(models.Model):
|
||||
_inherit = 'res.company'
|
||||
|
||||
def _populate_factories(self):
|
||||
return super()._populate_factories() + [
|
||||
('po_lead', populate.randint(0, 2))
|
||||
]
|
||||
|
||||
|
||||
class PurchaseOrder(models.Model):
|
||||
_inherit = 'purchase.order'
|
||||
|
||||
_populate_sizes = {'small': 100, 'medium': 1_500, 'large': 25_000}
|
||||
_populate_dependencies = ['res.partner']
|
||||
|
||||
def _populate_factories(self):
|
||||
now = datetime.now()
|
||||
|
||||
company_ids = self.env.registry.populated_models['res.company'][:COMPANY_NB_WITH_STOCK]
|
||||
all_partners = self.env['res.partner'].browse(self.env.registry.populated_models['res.partner'])
|
||||
partners_by_company = dict(groupby(all_partners, key=lambda par: par.company_id.id))
|
||||
partners_inter_company = self.env['res.partner'].concat(*partners_by_company.get(False, []))
|
||||
partners_by_company = {com: self.env['res.partner'].concat(*partners) | partners_inter_company for com, partners in partners_by_company.items() if com}
|
||||
|
||||
def get_date_order(values, counter, random):
|
||||
# 95.45 % of picking scheduled between (-5, 10) days and follow a gauss distribution (only +-15% PO is late)
|
||||
delta = random.gauss(5, 5)
|
||||
return now + timedelta(days=delta)
|
||||
|
||||
def get_date_planned(values, counter, random):
|
||||
# 95 % of PO Receipt Date between (1, 16) days after the order deadline and follow a exponential distribution
|
||||
delta = random.expovariate(5) + 1
|
||||
return values['date_order'] + timedelta(days=delta)
|
||||
|
||||
def get_partner_id(values, counter, random):
|
||||
return random.choice(partners_by_company[values['company_id']]).id
|
||||
|
||||
def get_currency_id(values, counter, random):
|
||||
company = self.env['res.company'].browse(values['company_id'])
|
||||
return company.currency_id.id
|
||||
|
||||
return [
|
||||
('company_id', populate.randomize(company_ids)),
|
||||
('date_order', populate.compute(get_date_order)),
|
||||
('date_planned', populate.compute(get_date_planned)),
|
||||
('partner_id', populate.compute(get_partner_id)),
|
||||
('currency_id', populate.compute(get_currency_id)),
|
||||
]
|
||||
|
||||
|
||||
class PurchaseOrderLine(models.Model):
|
||||
_inherit = 'purchase.order.line'
|
||||
|
||||
_populate_sizes = {'small': 500, 'medium': 7_500, 'large': 125_000}
|
||||
_populate_dependencies = ['purchase.order', 'product.product']
|
||||
|
||||
def _populate_factories(self):
|
||||
|
||||
purchase_order_ids = self.env.registry.populated_models['purchase.order']
|
||||
product_ids = self.env.registry.populated_models['product.product']
|
||||
|
||||
def get_product_uom(values, counter, random):
|
||||
product = self.env['product.product'].browse(values['product_id'])
|
||||
return product.uom_id.id
|
||||
|
||||
def get_date_planned(values, counter, random):
|
||||
po = self.env['purchase.order'].browse(values['order_id'])
|
||||
return po.date_planned
|
||||
|
||||
return [
|
||||
('order_id', populate.iterate(purchase_order_ids)),
|
||||
('name', populate.constant("PO-line-{counter}")),
|
||||
('product_id', populate.randomize(product_ids)),
|
||||
('product_uom', populate.compute(get_product_uom)),
|
||||
('taxes_id', populate.constant(False)), # to avoid slow _prepare_add_missing_fields
|
||||
('date_planned', populate.compute(get_date_planned)),
|
||||
('product_qty', populate.randint(1, 10)),
|
||||
('price_unit', populate.randint(10, 100)),
|
||||
]
|
5
report/__init__.py
Normal file
5
report/__init__.py
Normal file
@ -0,0 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import purchase_report
|
||||
from . import purchase_bill
|
55
report/purchase_bill.py
Normal file
55
report/purchase_bill.py
Normal file
@ -0,0 +1,55 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, fields, models, tools
|
||||
from odoo.tools import formatLang
|
||||
|
||||
class PurchaseBillUnion(models.Model):
|
||||
_name = 'purchase.bill.union'
|
||||
_auto = False
|
||||
_description = 'Purchases & Bills Union'
|
||||
_order = "date desc, name desc"
|
||||
_rec_names_search = ['name', 'reference']
|
||||
|
||||
name = fields.Char(string='Reference', readonly=True)
|
||||
reference = fields.Char(string='Source', readonly=True)
|
||||
partner_id = fields.Many2one('res.partner', string='Vendor', readonly=True)
|
||||
date = fields.Date(string='Date', readonly=True)
|
||||
amount = fields.Float(string='Amount', readonly=True)
|
||||
currency_id = fields.Many2one('res.currency', string='Currency', readonly=True)
|
||||
company_id = fields.Many2one('res.company', 'Company', readonly=True)
|
||||
vendor_bill_id = fields.Many2one('account.move', string='Vendor Bill', readonly=True)
|
||||
purchase_order_id = fields.Many2one('purchase.order', string='Purchase Order', readonly=True)
|
||||
|
||||
def init(self):
|
||||
tools.drop_view_if_exists(self.env.cr, 'purchase_bill_union')
|
||||
self.env.cr.execute("""
|
||||
CREATE OR REPLACE VIEW purchase_bill_union AS (
|
||||
SELECT
|
||||
id, name, ref as reference, partner_id, date, amount_untaxed as amount, currency_id, company_id,
|
||||
id as vendor_bill_id, NULL as purchase_order_id
|
||||
FROM account_move
|
||||
WHERE
|
||||
move_type='in_invoice' and state = 'posted'
|
||||
UNION
|
||||
SELECT
|
||||
-id, name, partner_ref as reference, partner_id, date_order::date as date, amount_untaxed as amount, currency_id, company_id,
|
||||
NULL as vendor_bill_id, id as purchase_order_id
|
||||
FROM purchase_order
|
||||
WHERE
|
||||
state in ('purchase', 'done') AND
|
||||
invoice_status in ('to invoice', 'no')
|
||||
)""")
|
||||
|
||||
@api.depends('currency_id', 'reference', 'amount', 'purchase_order_id')
|
||||
@api.depends_context('show_total_amount')
|
||||
def _compute_display_name(self):
|
||||
for doc in self:
|
||||
name = doc.name or ''
|
||||
if doc.reference:
|
||||
name += ' - ' + doc.reference
|
||||
amount = doc.amount
|
||||
if doc.purchase_order_id and doc.purchase_order_id.invoice_status == 'no':
|
||||
amount = 0.0
|
||||
name += ': ' + formatLang(self.env, amount, monetary=True, currency_obj=doc.currency_id)
|
||||
doc.display_name = name
|
36
report/purchase_bill_views.xml
Normal file
36
report/purchase_bill_views.xml
Normal file
@ -0,0 +1,36 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="view_purchase_bill_union_filter" model="ir.ui.view">
|
||||
<field name="name">purchase.bill.union.select</field>
|
||||
<field name="model">purchase.bill.union</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Search Reference Document">
|
||||
<field name="name" string="Reference" filter_domain="['|', ('name','ilike',self), ('reference','=like',self+'%')]"/>
|
||||
<field name="amount"/>
|
||||
<separator/>
|
||||
<field name="partner_id" operator="child_of"/>
|
||||
<separator/>
|
||||
<filter name="purchase_orders" string="Purchase Orders" domain="[('purchase_order_id', '!=', False)]"/>
|
||||
<filter name="vendor_bills" string="Vendor Bills" domain="[('vendor_bill_id', '!=', False)]"/>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_purchase_bill_union_tree" model="ir.ui.view">
|
||||
<field name="name">purchase.bill.union.tree</field>
|
||||
<field name="model">purchase.bill.union</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Reference Document">
|
||||
<field name="name"/>
|
||||
<field name="reference"/>
|
||||
<field name="partner_id"/>
|
||||
<field name="date"/>
|
||||
<field name="amount"/>
|
||||
<field name="currency_id" column_invisible="True"/>
|
||||
<field name="company_id" groups="base.group_multi_company" options="{'no_create': True}"/>
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user