Начальное наполнение

This commit is contained in:
parent 46dce11e57
commit b27b09a5da
144 changed files with 237603 additions and 1 deletions

View File

@ -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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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" /> -&gt; <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" /> -&gt; <t t-esc="float(qty_received)"/><br/>
</ul>
</div>
</template>
</data></odoo>

59
data/purchase_data.xml Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

3035
i18n/am.po Normal file

File diff suppressed because it is too large Load Diff

3342
i18n/ar.po Normal file

File diff suppressed because it is too large Load Diff

3049
i18n/az.po Normal file

File diff suppressed because it is too large Load Diff

3201
i18n/bg.po Normal file

File diff suppressed because it is too large Load Diff

3040
i18n/bs.po Normal file

File diff suppressed because it is too large Load Diff

3321
i18n/ca.po Normal file

File diff suppressed because it is too large Load Diff

3299
i18n/cs.po Normal file

File diff suppressed because it is too large Load Diff

3262
i18n/da.po Normal file

File diff suppressed because it is too large Load Diff

3386
i18n/de.po Normal file

File diff suppressed because it is too large Load Diff

3060
i18n/el.po Normal file

File diff suppressed because it is too large Load Diff

3120
i18n/en_AU.po Normal file

File diff suppressed because it is too large Load Diff

3045
i18n/en_GB.po Normal file

File diff suppressed because it is too large Load Diff

3377
i18n/es.po Normal file

File diff suppressed because it is too large Load Diff

3384
i18n/es_419.po Normal file

File diff suppressed because it is too large Load Diff

3037
i18n/es_BO.po Normal file

File diff suppressed because it is too large Load Diff

3037
i18n/es_CL.po Normal file

File diff suppressed because it is too large Load Diff

3048
i18n/es_CO.po Normal file

File diff suppressed because it is too large Load Diff

3037
i18n/es_CR.po Normal file

File diff suppressed because it is too large Load Diff

3053
i18n/es_DO.po Normal file

File diff suppressed because it is too large Load Diff

3046
i18n/es_EC.po Normal file

File diff suppressed because it is too large Load Diff

3039
i18n/es_PE.po Normal file

File diff suppressed because it is too large Load Diff

3037
i18n/es_PY.po Normal file

File diff suppressed because it is too large Load Diff

3037
i18n/es_VE.po Normal file

File diff suppressed because it is too large Load Diff

3372
i18n/et.po Normal file

File diff suppressed because it is too large Load Diff

3037
i18n/eu.po Normal file

File diff suppressed because it is too large Load Diff

3239
i18n/fa.po Normal file

File diff suppressed because it is too large Load Diff

3382
i18n/fi.po Normal file

File diff suppressed because it is too large Load Diff

3037
i18n/fo.po Normal file

File diff suppressed because it is too large Load Diff

3380
i18n/fr.po Normal file

File diff suppressed because it is too large Load Diff

3036
i18n/fr_BE.po Normal file

File diff suppressed because it is too large Load Diff

3037
i18n/fr_CA.po Normal file

File diff suppressed because it is too large Load Diff

3037
i18n/gl.po Normal file

File diff suppressed because it is too large Load Diff

3045
i18n/gu.po Normal file

File diff suppressed because it is too large Load Diff

3241
i18n/he.po Normal file

File diff suppressed because it is too large Load Diff

3041
i18n/hi.po Normal file

File diff suppressed because it is too large Load Diff

3085
i18n/hr.po Normal file

File diff suppressed because it is too large Load Diff

3216
i18n/hu.po Normal file

File diff suppressed because it is too large Load Diff

3364
i18n/id.po Normal file

File diff suppressed because it is too large Load Diff

3040
i18n/is.po Normal file

File diff suppressed because it is too large Load Diff

3372
i18n/it.po Normal file

File diff suppressed because it is too large Load Diff

3276
i18n/ja.po Normal file

File diff suppressed because it is too large Load Diff

3041
i18n/ka.po Normal file

File diff suppressed because it is too large Load Diff

3037
i18n/kab.po Normal file

File diff suppressed because it is too large Load Diff

3040
i18n/km.po Normal file

File diff suppressed because it is too large Load Diff

3290
i18n/ko.po Normal file

File diff suppressed because it is too large Load Diff

3039
i18n/lb.po Normal file

File diff suppressed because it is too large Load Diff

3037
i18n/lo.po Normal file

File diff suppressed because it is too large Load Diff

3252
i18n/lt.po Normal file

File diff suppressed because it is too large Load Diff

3210
i18n/lv.po Normal file

File diff suppressed because it is too large Load Diff

3042
i18n/mk.po Normal file

File diff suppressed because it is too large Load Diff

3083
i18n/mn.po Normal file

File diff suppressed because it is too large Load Diff

3055
i18n/nb.po Normal file

File diff suppressed because it is too large Load Diff

3372
i18n/nl.po Normal file

File diff suppressed because it is too large Load Diff

3289
i18n/pl.po Normal file

File diff suppressed because it is too large Load Diff

3311
i18n/pt.po Normal file

File diff suppressed because it is too large Load Diff

3371
i18n/pt_BR.po Normal file

File diff suppressed because it is too large Load Diff

3140
i18n/purchase.pot Normal file

File diff suppressed because it is too large Load Diff

3070
i18n/ro.po Normal file

File diff suppressed because it is too large Load Diff

3390
i18n/ru.po Normal file

File diff suppressed because it is too large Load Diff

3223
i18n/sk.po Normal file

File diff suppressed because it is too large Load Diff

3188
i18n/sl.po Normal file

File diff suppressed because it is too large Load Diff

3037
i18n/sq.po Normal file

File diff suppressed because it is too large Load Diff

3345
i18n/sr.po Normal file

File diff suppressed because it is too large Load Diff

3041
i18n/sr@latin.po Normal file

File diff suppressed because it is too large Load Diff

3318
i18n/sv.po Normal file

File diff suppressed because it is too large Load Diff

3339
i18n/th.po Normal file

File diff suppressed because it is too large Load Diff

3295
i18n/tr.po Normal file

File diff suppressed because it is too large Load Diff

3364
i18n/uk.po Normal file

File diff suppressed because it is too large Load Diff

3354
i18n/vi.po Normal file

File diff suppressed because it is too large Load Diff

3273
i18n/zh_CN.po Normal file

File diff suppressed because it is too large Load Diff

3278
i18n/zh_TW.po Normal file

File diff suppressed because it is too large Load Diff

View 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
View 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
View 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
View 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

View 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

View 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
View 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

File diff suppressed because it is too large Load Diff

View 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
View 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")

View 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
View 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
View 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
View 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
View 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
View 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

View 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