mirror of
https://gitlab.com/hibou-io/hibou-odoo/suite.git
synced 2025-01-20 12:37:31 +02:00
Merge branch '11.0' into 11.0-test
This commit is contained in:
3
.gitmodules
vendored
3
.gitmodules
vendored
@@ -26,3 +26,6 @@
|
||||
[submodule "external/hibou-oca/bank-payment"]
|
||||
path = external/hibou-oca/bank-payment
|
||||
url = https://github.com/hibou-io/oca-bank-payment.git
|
||||
[submodule "external/hibou-shipbox"]
|
||||
path = external/hibou-shipbox
|
||||
url = https://github.com/hibou-io/shipbox.git
|
||||
|
||||
@@ -27,11 +27,6 @@ Main Features
|
||||
:width: 988
|
||||
:align: left
|
||||
|
||||
=============
|
||||
Known Issues
|
||||
=============
|
||||
|
||||
* It is technically possible, but *not* recommended, to change which invoices are in the wizard. You've been warned.
|
||||
|
||||
=======
|
||||
License
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
<tree editable="bottom" create="false">
|
||||
<field name="wizard_id" invisible="1"/>
|
||||
<field name="partner_id" readonly="1"/>
|
||||
<field name="invoice_id"/>
|
||||
<field name="invoice_id" readonly="1" force_save="1"/>
|
||||
<field name="residual" readonly="1" sum="Total Residual"/>
|
||||
<field name="residual_due" readonly="1" sum="Total Due"/>
|
||||
<field name="amount" sum="Total Amount"/>
|
||||
|
||||
2
external/hibou-oca/account-analytic
vendored
2
external/hibou-oca/account-analytic
vendored
Submodule external/hibou-oca/account-analytic updated: 7e06779e23...496db65e9d
2
external/hibou-oca/bank-payment
vendored
2
external/hibou-oca/bank-payment
vendored
Submodule external/hibou-oca/bank-payment updated: c7630c7f85...634492de89
2
external/hibou-oca/connector
vendored
2
external/hibou-oca/connector
vendored
Submodule external/hibou-oca/connector updated: 5c3a125d6a...601f0dbda3
2
external/hibou-oca/connector-ecommerce
vendored
2
external/hibou-oca/connector-ecommerce
vendored
Submodule external/hibou-oca/connector-ecommerce updated: dbc8fbf136...de8c1911b1
2
external/hibou-oca/connector-magento
vendored
2
external/hibou-oca/connector-magento
vendored
Submodule external/hibou-oca/connector-magento updated: d1b991671d...8cce7538e6
2
external/hibou-oca/product-attribute
vendored
2
external/hibou-oca/product-attribute
vendored
Submodule external/hibou-oca/product-attribute updated: 6d906de3bd...b89f3a7199
2
external/hibou-oca/queue
vendored
2
external/hibou-oca/queue
vendored
Submodule external/hibou-oca/queue updated: 9d3c193e62...56e8abbd24
2
external/hibou-oca/sale-workflow
vendored
2
external/hibou-oca/sale-workflow
vendored
Submodule external/hibou-oca/sale-workflow updated: df8c964e79...4e3a5a0f90
2
external/hibou-oca/server-tools
vendored
2
external/hibou-oca/server-tools
vendored
Submodule external/hibou-oca/server-tools updated: 4bd10c5946...dab39ca53c
1
external/hibou-shipbox
vendored
Submodule
1
external/hibou-shipbox
vendored
Submodule
Submodule external/hibou-shipbox added at 61ec9b25ab
@@ -3,7 +3,10 @@
|
||||
'author': 'Hibou Corp. <hello@hibou.io>',
|
||||
'license': 'AGPL-3',
|
||||
'category': 'Human Resources',
|
||||
'depends': ['hr_contract'],
|
||||
'depends': [
|
||||
'hr_payroll',
|
||||
'hr_workers_comp',
|
||||
],
|
||||
'version': '11.0.0.0.0',
|
||||
'description': """
|
||||
Workers' Compensation Class - Payroll
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
'license': 'AGPL-3',
|
||||
'category': 'Localization',
|
||||
'depends': ['hr_payroll'],
|
||||
'version': '11.0.2017.0.0',
|
||||
'version': '11.0.2018.1.0',
|
||||
'description': """
|
||||
USA Payroll Rules.
|
||||
==================
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<field name="name">FICA Employee Social Security Wages (2018)</field>
|
||||
<field name="code">FICA_EMP_SS_WAGES_2018</field>
|
||||
<field name="condition_select">python</field>
|
||||
<field name="condition_python">result = (payslip.date_to[:4] == '2018')</field>
|
||||
<field name="condition_python">result = (payslip.date_to[:4] == '2018') and not contract.fica_exempt</field>
|
||||
<field name="amount_select">code</field>
|
||||
<field name="amount_python_compute">
|
||||
###
|
||||
@@ -32,7 +32,7 @@ else:
|
||||
<field name="name">FICA Employee Medicare Wages (2018)</field>
|
||||
<field name="code">FICA_EMP_M_WAGES_2018</field>
|
||||
<field name="condition_select">python</field>
|
||||
<field name="condition_python">result = (payslip.date_to[:4] == '2018')</field>
|
||||
<field name="condition_python">result = (payslip.date_to[:4] == '2018') and not contract.fica_exempt</field>
|
||||
<field name="amount_select">code</field>
|
||||
<field name="amount_python_compute">result = categories.GROSS</field>
|
||||
<field name="appears_on_payslip" eval="False"/>
|
||||
@@ -43,7 +43,7 @@ else:
|
||||
<field name="name">FICA Employee Medicare Additional Wages (2018)</field>
|
||||
<field name="code">FICA_EMP_M_ADD_WAGES_2018</field>
|
||||
<field name="condition_select">python</field>
|
||||
<field name="condition_python">result = (payslip.date_to[:4] == '2018')</field>
|
||||
<field name="condition_python">result = (payslip.date_to[:4] == '2018') and not contract.fica_exempt</field>
|
||||
<field name="amount_select">code</field>
|
||||
<field name="amount_python_compute">
|
||||
###
|
||||
|
||||
@@ -26,6 +26,8 @@ class USHrContract(models.Model):
|
||||
|
||||
external_wages = fields.Float(string='External Existing Wages', default=0.0)
|
||||
|
||||
fica_exempt = fields.Boolean(string='FICA Exempt', help="Exempt from Social Security and "
|
||||
"Medicare e.g. F1 Student Visa")
|
||||
futa_type = fields.Selection([
|
||||
(FUTA_TYPE_EXEMPT, 'Exempt (0%)'),
|
||||
(FUTA_TYPE_NORMAL, 'Normal Net Rate (0.6%)'),
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
<group string="Other" name="other">
|
||||
<field name="external_wages" string="External YTD Wages"/>
|
||||
<field name="futa_type" string="Unemployment Tax Type (FUTA)"/>
|
||||
<field name="fica_exempt"/>
|
||||
</group>
|
||||
</group>
|
||||
</page>
|
||||
|
||||
@@ -342,3 +342,27 @@ class TestUsPayslip2018(TestUsPayslip):
|
||||
if 'FED_INC_WITHHOLD' in cats:
|
||||
fed_inc_withhold = cats['FED_INC_WITHHOLD']
|
||||
self.assertPayrollEqual(fed_inc_withhold, 0.0)
|
||||
|
||||
def test_2018_taxes_with_fica_exempt(self):
|
||||
salary = 6000.0
|
||||
schedule_pay = 'bi-weekly'
|
||||
w4_allowances = 2
|
||||
employee = self._createEmployee()
|
||||
contract = self._createContract(employee, salary, schedule_pay, w4_allowances)
|
||||
contract.fica_exempt = True
|
||||
|
||||
self._log('2018 tax w4 exempt payslip:')
|
||||
payslip = self._createPayslip(employee, '2018-01-01', '2018-01-31')
|
||||
|
||||
payslip.compute_sheet()
|
||||
|
||||
cats = self._getCategories(payslip)
|
||||
|
||||
ss_wages = cats.get('FICA_EMP_SS_WAGES', 0.0)
|
||||
med_wages = cats.get('FICA_EMP_M_WAGES', 0.0)
|
||||
ss = cats.get('FICA_EMP_SS', 0.0)
|
||||
med = cats.get('FICA_EMP_M', 0.0)
|
||||
self.assertPayrollEqual(ss_wages, 0.0)
|
||||
self.assertPayrollEqual(med_wages, 0.0)
|
||||
self.assertPayrollEqual(ss, 0.0)
|
||||
self.assertPayrollEqual(med, 0.0)
|
||||
|
||||
1
l10n_us_partner_zipcode/__init__.py
Normal file
1
l10n_us_partner_zipcode/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import res_partner
|
||||
37
l10n_us_partner_zipcode/__manifest__.py
Normal file
37
l10n_us_partner_zipcode/__manifest__.py
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
'name': 'US ZIP Code to City/State',
|
||||
'summary': 'Determines the City and State from a provided ZIP code.',
|
||||
'version': '11.0.1.0.0',
|
||||
'author': "Hibou Corp.",
|
||||
'category': 'Localization',
|
||||
'license': 'AGPL-3',
|
||||
'complexity': 'easy',
|
||||
'images': [],
|
||||
'website': "https://hibou.io",
|
||||
'description': """
|
||||
US ZIP Code to City/State
|
||||
=========================
|
||||
|
||||
Determines the City and State from a provided ZIP code. Requires the `uszipcode` python package.
|
||||
|
||||
Does not require `base_geolocalize`, but will fill the related fields if possible.
|
||||
|
||||
|
||||
Contributors
|
||||
------------
|
||||
|
||||
* Jared Kipe <jared@hibou.io>
|
||||
|
||||
""",
|
||||
'depends': [
|
||||
'base',
|
||||
],
|
||||
'external_dependencies': {
|
||||
'python': ['uszipcode']
|
||||
},
|
||||
'demo': [],
|
||||
'data': [
|
||||
],
|
||||
'auto_install': False,
|
||||
'installable': True,
|
||||
}
|
||||
39
l10n_us_partner_zipcode/res_partner.py
Normal file
39
l10n_us_partner_zipcode/res_partner.py
Normal file
@@ -0,0 +1,39 @@
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
from uszipcode import ZipcodeSearchEngine
|
||||
except ImportError:
|
||||
_logger.warn('module "uszipcode" cannot be loaded, you will be unable to detect Cities and States by ZIP')
|
||||
ZipcodeSearchEngine = None
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class Partner(models.Model):
|
||||
_inherit = 'res.partner'
|
||||
|
||||
@api.onchange('zip')
|
||||
def _zip_to_city_state(self):
|
||||
if ZipcodeSearchEngine and self.zip and not self.city:
|
||||
country_us = self.env['res.country'].search([('code', '=', 'US')], limit=1)
|
||||
state_obj = self.env['res.country.state']
|
||||
if not self.country_id or self.country_id.id == country_us.id:
|
||||
with ZipcodeSearchEngine() as search:
|
||||
zipcode = search.by_zipcode(self.zip)
|
||||
if zipcode:
|
||||
if not self.country_id:
|
||||
self.country_id = country_us
|
||||
|
||||
self.city = zipcode['City']
|
||||
self.state_id = state_obj.search([
|
||||
('code', '=', zipcode['State']),
|
||||
('country_id', '=', country_us.id),
|
||||
], limit=1)
|
||||
|
||||
if hasattr(self, 'partner_latitude') and not self.partner_latitude:
|
||||
self.partner_latitude = zipcode['Latitude']
|
||||
self.partner_longitude = zipcode['Longitude']
|
||||
self.date_localization = fields.Date.context_today(self)
|
||||
return {}
|
||||
1
purchase_by_sale_history/__init__.py
Executable file
1
purchase_by_sale_history/__init__.py
Executable file
@@ -0,0 +1 @@
|
||||
from . import wizard
|
||||
21
purchase_by_sale_history/__manifest__.py
Executable file
21
purchase_by_sale_history/__manifest__.py
Executable file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
'name': 'Purchase by Sale History',
|
||||
'author': 'Hibou Corp. <hello@hibou.io>',
|
||||
'version': '11.0.1.0.0',
|
||||
'category': 'Purchases',
|
||||
'sequence': 95,
|
||||
'summary': 'Fill Purchase Orders by Sales History',
|
||||
'description': """
|
||||
Adds wizard to Purchase Orders that will fill the purchase order with products based on sales history.
|
||||
""",
|
||||
'website': 'https://hibou.io/',
|
||||
'depends': [
|
||||
'sale_stock',
|
||||
'purchase',
|
||||
],
|
||||
'data': [
|
||||
'wizard/purchase_by_sale_history_views.xml',
|
||||
],
|
||||
'installable': True,
|
||||
'application': False,
|
||||
}
|
||||
1
purchase_by_sale_history/tests/__init__.py
Normal file
1
purchase_by_sale_history/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import test_purchase_by_sale_history
|
||||
133
purchase_by_sale_history/tests/test_purchase_by_sale_history.py
Normal file
133
purchase_by_sale_history/tests/test_purchase_by_sale_history.py
Normal file
@@ -0,0 +1,133 @@
|
||||
from odoo import fields
|
||||
from odoo.tests import common
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
|
||||
class TestPurchaseBySaleHistory(common.TransactionCase):
|
||||
|
||||
def test_00_wizard(self):
|
||||
sale_partner = self.env.ref('base.res_partner_2')
|
||||
purchase_partner = self.env['res.partner'].create({
|
||||
'name': 'Purchase Partner',
|
||||
})
|
||||
|
||||
product11 = self.env['product.product'].create({
|
||||
'name': 'Product 1',
|
||||
'type': 'product',
|
||||
})
|
||||
product12 = self.env['product.product'].create({
|
||||
'name': 'Product 1.1',
|
||||
'type': 'product',
|
||||
'product_tmpl_id': product11.product_tmpl_id.id,
|
||||
})
|
||||
product2 = self.env['product.product'].create({
|
||||
'name': 'Product 2',
|
||||
'type': 'product',
|
||||
})
|
||||
|
||||
po1 = self.env['purchase.order'].create({
|
||||
'partner_id': purchase_partner.id,
|
||||
})
|
||||
|
||||
# Create initial wizard, it won't apply to any products because the PO is empty, and the vendor
|
||||
# doesn't supply any products yet.
|
||||
wiz = self.env['purchase.sale.history.make'].create({
|
||||
'purchase_id': po1.id,
|
||||
})
|
||||
|
||||
self.assertEqual(wiz.product_count, 0.0, 'There shouldn\'t be any products for this vendor yet.')
|
||||
|
||||
# Assign vendor to products created earlier.
|
||||
self.env['product.supplierinfo'].create({
|
||||
'name': purchase_partner.id,
|
||||
'product_tmpl_id': product11.product_tmpl_id.id,
|
||||
'product_id': product11.id,
|
||||
})
|
||||
self.env['product.supplierinfo'].create({
|
||||
'name': purchase_partner.id,
|
||||
'product_tmpl_id': product2.product_tmpl_id.id,
|
||||
})
|
||||
# New wizard picks up the correct number of products supplied by this vendor.
|
||||
wiz = self.env['purchase.sale.history.make'].create({
|
||||
'purchase_id': po1.id,
|
||||
})
|
||||
self.assertEqual(wiz.product_count, 2)
|
||||
|
||||
# Make some sales history...
|
||||
sale_date = fields.Datetime.to_string(datetime.now() - timedelta(days=30))
|
||||
self.env['sale.order'].create({
|
||||
'partner_id': sale_partner.id,
|
||||
'date_order': sale_date,
|
||||
'confirmation_date': sale_date,
|
||||
'picking_policy': 'direct',
|
||||
'order_line': [
|
||||
(0, 0, {'product_id': product11.id, 'product_uom_qty': 3.0}),
|
||||
(0, 0, {'product_id': product12.id, 'product_uom_qty': 3.0}),
|
||||
(0, 0, {'product_id': product2.id, 'product_uom_qty': 3.0}),
|
||||
],
|
||||
}).action_confirm()
|
||||
|
||||
days = 60
|
||||
history_start = fields.Date.to_string(datetime.now() - timedelta(days=days))
|
||||
history_end = fields.Date.today()
|
||||
wiz.write({
|
||||
'history_start': history_start,
|
||||
'history_end': history_end,
|
||||
'procure_days': days,
|
||||
})
|
||||
self.assertEqual(wiz.history_days, days)
|
||||
wiz.action_confirm()
|
||||
self.assertEqual(po1.order_line.filtered(lambda l: l.product_id == product11).product_qty, 3.0)
|
||||
self.assertEqual(po1.order_line.filtered(lambda l: l.product_id == product12).product_qty, 0.0)
|
||||
self.assertEqual(po1.order_line.filtered(lambda l: l.product_id == product2).product_qty, 3.0)
|
||||
|
||||
# Make additional sales history...
|
||||
sale_date = fields.Datetime.to_string(datetime.now() - timedelta(days=15))
|
||||
self.env['sale.order'].create({
|
||||
'partner_id': sale_partner.id,
|
||||
'date_order': sale_date,
|
||||
'confirmation_date': sale_date,
|
||||
'picking_policy': 'direct',
|
||||
'order_line': [
|
||||
(0, 0, {'product_id': product11.id, 'product_uom_qty': 3.0}),
|
||||
(0, 0, {'product_id': product12.id, 'product_uom_qty': 3.0}),
|
||||
(0, 0, {'product_id': product2.id, 'product_uom_qty': 3.0}),
|
||||
],
|
||||
}).action_confirm()
|
||||
|
||||
wiz.action_confirm()
|
||||
self.assertEqual(po1.order_line.filtered(lambda l: l.product_id == product11).product_qty, 6.0)
|
||||
self.assertEqual(po1.order_line.filtered(lambda l: l.product_id == product12).product_qty, 0.0)
|
||||
self.assertEqual(po1.order_line.filtered(lambda l: l.product_id == product2).product_qty, 6.0)
|
||||
|
||||
# Make additional sales history that should NOT be counted...
|
||||
sale_date = fields.Datetime.to_string(datetime.now() - timedelta(days=61))
|
||||
self.env['sale.order'].create({
|
||||
'partner_id': sale_partner.id,
|
||||
'date_order': sale_date,
|
||||
'confirmation_date': sale_date,
|
||||
'picking_policy': 'direct',
|
||||
'order_line': [
|
||||
(0, 0, {'product_id': product11.id, 'product_uom_qty': 3.0}),
|
||||
(0, 0, {'product_id': product12.id, 'product_uom_qty': 3.0}),
|
||||
(0, 0, {'product_id': product2.id, 'product_uom_qty': 3.0}),
|
||||
],
|
||||
}).action_confirm()
|
||||
|
||||
wiz.action_confirm()
|
||||
self.assertEqual(po1.order_line.filtered(lambda l: l.product_id == product11).product_qty, 6.0)
|
||||
self.assertEqual(po1.order_line.filtered(lambda l: l.product_id == product12).product_qty, 0.0)
|
||||
self.assertEqual(po1.order_line.filtered(lambda l: l.product_id == product2).product_qty, 6.0)
|
||||
|
||||
# Test that the wizard will only use the existing PO line products now that we have lines.
|
||||
po1.order_line.filtered(lambda l: l.product_id == product2).unlink()
|
||||
wiz.action_confirm()
|
||||
self.assertEqual(po1.order_line.filtered(lambda l: l.product_id == product11).product_qty, 6.0)
|
||||
self.assertEqual(po1.order_line.filtered(lambda l: l.product_id == product12).product_qty, 0.0)
|
||||
self.assertEqual(po1.order_line.filtered(lambda l: l.product_id == product2).product_qty, 0.0)
|
||||
|
||||
# Plan for 1/2 the days of inventory
|
||||
wiz.procure_days = days / 2.0
|
||||
wiz.action_confirm()
|
||||
self.assertEqual(po1.order_line.filtered(lambda l: l.product_id == product11).product_qty, 6.0 / 2.0)
|
||||
|
||||
1
purchase_by_sale_history/wizard/__init__.py
Normal file
1
purchase_by_sale_history/wizard/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import purchase_by_sale_history
|
||||
100
purchase_by_sale_history/wizard/purchase_by_sale_history.py
Normal file
100
purchase_by_sale_history/wizard/purchase_by_sale_history.py
Normal file
@@ -0,0 +1,100 @@
|
||||
from odoo import api, fields, models
|
||||
from math import ceil
|
||||
from datetime import timedelta
|
||||
|
||||
|
||||
class PurchaseBySaleHistory(models.TransientModel):
|
||||
_name = 'purchase.sale.history.make'
|
||||
|
||||
purchase_id = fields.Many2one('purchase.order', string='Purchase Order')
|
||||
history_start = fields.Date(string='Sales History Start', default=lambda o: fields.Date.from_string(fields.Date.today()) - timedelta(days=30))
|
||||
history_end = fields.Date(string='Sales History End', default=fields.Date.today)
|
||||
history_days = fields.Integer(string='Sales History Days', compute='_compute_history_days')
|
||||
procure_days = fields.Integer(string='Days to Procure',
|
||||
default=30,
|
||||
help='History will be computed as an average per day, '
|
||||
'and then multiplied by the days you wish to procure for.')
|
||||
product_count = fields.Integer(string='Product Count', compute='_compute_product_count',
|
||||
help='Products on the PO or that the Vendor provides.')
|
||||
|
||||
@api.multi
|
||||
@api.depends('history_start', 'history_end')
|
||||
def _compute_history_days(self):
|
||||
for wiz in self:
|
||||
if not all((wiz.history_end, wiz.history_start)):
|
||||
wiz.history_days = 0
|
||||
else:
|
||||
delta = fields.Date.from_string(wiz.history_end) - fields.Date.from_string(wiz.history_start)
|
||||
wiz.history_days = delta.days
|
||||
|
||||
@api.multi
|
||||
@api.depends('purchase_id', 'purchase_id.order_line', 'purchase_id.partner_id')
|
||||
def _compute_product_count(self):
|
||||
for wiz in self:
|
||||
if wiz.purchase_id.order_line:
|
||||
wiz.product_count = len(set(wiz.purchase_id.order_line.mapped('product_id.id')))
|
||||
elif wiz.purchase_id.partner_id:
|
||||
self.env.cr.execute("""SELECT COUNT(DISTINCT(psi.product_id)) + COUNT(DISTINCT(p.id))
|
||||
FROM product_supplierinfo psi
|
||||
LEFT JOIN product_product p ON p.product_tmpl_id = psi.product_tmpl_id AND psi.product_id IS NULL
|
||||
WHERE psi.name = %d;"""
|
||||
% (wiz.purchase_id.partner_id.id, ))
|
||||
wiz.product_count = self.env.cr.fetchall()[0][0]
|
||||
|
||||
def _history_product_ids(self):
|
||||
if self.purchase_id.order_line:
|
||||
return self.purchase_id.order_line.mapped('product_id.id')
|
||||
|
||||
self.env.cr.execute("""SELECT DISTINCT(COALESCE(psi.product_id, p.id))
|
||||
FROM product_supplierinfo psi
|
||||
LEFT JOIN product_product p ON p.product_tmpl_id = psi.product_tmpl_id AND psi.product_id IS NULL
|
||||
WHERE psi.name = %d;"""
|
||||
% (self.purchase_id.partner_id.id, ))
|
||||
rows = self.env.cr.fetchall()
|
||||
return [r[0] for r in rows if r[0]]
|
||||
|
||||
def _sale_history(self, product_ids):
|
||||
self.env.cr.execute("""SELECT product_id, sum(product_uom_qty)
|
||||
FROM sale_report
|
||||
WHERE date BETWEEN %s AND %s AND product_id IN %s
|
||||
GROUP BY 1""", (self.history_start, self.history_end, tuple(product_ids)))
|
||||
return self.env.cr.fetchall()
|
||||
|
||||
def _apply_history(self, history):
|
||||
line_model = self.env['purchase.order.line']
|
||||
updated_lines = line_model.browse()
|
||||
for pid, sold_qty in history:
|
||||
# TODO: Should convert from Sale UOM to Purchase UOM
|
||||
qty = ceil(sold_qty * self.procure_days / self.history_days)
|
||||
# Find line that already exists on PO
|
||||
line = self.purchase_id.order_line.filtered(lambda l: l.product_id.id == pid)
|
||||
if line:
|
||||
line.write({'product_qty': qty})
|
||||
line._onchange_quantity()
|
||||
else:
|
||||
# Create new PO line
|
||||
line = line_model.new({
|
||||
'order_id': self.purchase_id.id,
|
||||
'product_id': pid,
|
||||
'product_qty': qty,
|
||||
})
|
||||
line.onchange_product_id()
|
||||
line_vals = line._convert_to_write(line._cache)
|
||||
line_vals['product_qty'] = qty
|
||||
line = line_model.create(line_vals)
|
||||
updated_lines += line
|
||||
|
||||
# Lines not touched should now not be ordered.
|
||||
other_lines = self.purchase_id.order_line - updated_lines
|
||||
other_lines.write({'product_qty': 0.0})
|
||||
for line in other_lines:
|
||||
line._onchange_quantity()
|
||||
|
||||
@api.multi
|
||||
def action_confirm(self):
|
||||
self.ensure_one()
|
||||
history_product_ids = self._history_product_ids()
|
||||
history = self._sale_history(history_product_ids)
|
||||
self._apply_history(history)
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<odoo>
|
||||
<record id="purchase_sale_history_make_form" model="ir.ui.view">
|
||||
<field name="name">purchase.sale.history.make.form</field>
|
||||
<field name="model">purchase.sale.history.make</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Fill PO From Sales History">
|
||||
<sheet>
|
||||
<field name="id" invisible="1"/>
|
||||
<field name="purchase_id" invisible="1"/>
|
||||
<group>
|
||||
<group>
|
||||
<field name="history_start"/>
|
||||
<field name="history_end"/>
|
||||
<field name="history_days"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="procure_days"/>
|
||||
<field name="product_count"/>
|
||||
</group>
|
||||
</group>
|
||||
</sheet>
|
||||
<footer>
|
||||
<button string='Run' name="action_confirm" type="object" class="btn-primary"/>
|
||||
<button string="Cancel" class="btn-default" special="cancel"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="purchase_sale_history_make_action" model="ir.actions.act_window">
|
||||
<field name="name">Fill PO From Sales History</field>
|
||||
<field name="res_model">purchase.sale.history.make</field>
|
||||
<field name="view_type">form</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="view_id" ref="purchase_sale_history_make_form"/>
|
||||
<field name="target">new</field>
|
||||
<field name="context">{'default_purchase_id': active_id}</field>
|
||||
</record>
|
||||
|
||||
<!-- Button on Purchase Order to launch wizard -->
|
||||
<record id="purchase_order_form_inherit" model="ir.ui.view">
|
||||
<field name="name">purchase.order.form.inherit</field>
|
||||
<field name="model">purchase.order</field>
|
||||
<field name="inherit_id" ref="purchase.purchase_order_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//button[@name='button_confirm']" position="after">
|
||||
<button name="%(purchase_by_sale_history.purchase_sale_history_make_action)d" type="action" string="Fill by Sales" attrs="{'invisible': ['|', ('state', 'in', ('purchase', 'done', 'cancel')), ('partner_id', '=', False)]}"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
3
rma/__init__.py
Normal file
3
rma/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from . import controllers
|
||||
from . import models
|
||||
from . import wizard
|
||||
27
rma/__manifest__.py
Normal file
27
rma/__manifest__.py
Normal file
@@ -0,0 +1,27 @@
|
||||
# © 2018 Hibou Corp.
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
{
|
||||
'name': 'Hibou RMAs',
|
||||
'version': '11.0.1.0.0',
|
||||
'category': 'Warehouse',
|
||||
'author': "Hibou Corp.",
|
||||
'license': 'AGPL-3',
|
||||
'website': 'https://hibou.io/',
|
||||
'depends': [
|
||||
'stock',
|
||||
'delivery',
|
||||
],
|
||||
'data': [
|
||||
'data/ir_sequence_data.xml',
|
||||
'security/ir.model.access.csv',
|
||||
'views/rma_views.xml',
|
||||
'views/stock_picking_views.xml',
|
||||
'wizard/rma_lines_views.xml',
|
||||
],
|
||||
'demo': [
|
||||
'data/rma_demo.xml',
|
||||
],
|
||||
'installable': True,
|
||||
'application': True,
|
||||
}
|
||||
1
rma/controllers/__init__.py
Normal file
1
rma/controllers/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import main
|
||||
49
rma/controllers/main.py
Normal file
49
rma/controllers/main.py
Normal file
@@ -0,0 +1,49 @@
|
||||
from odoo import http, exceptions
|
||||
from base64 import b64decode
|
||||
import hmac
|
||||
from hashlib import sha256
|
||||
from datetime import datetime
|
||||
from time import mktime
|
||||
|
||||
|
||||
def create_hmac(secret, a_attchment_id, e_expires):
|
||||
return hmac.new(secret.encode(), str(str(a_attchment_id) + str(e_expires)).encode(), sha256).hexdigest()
|
||||
|
||||
|
||||
def check_hmac(secret, hash_, a_attachment_id, e_expires):
|
||||
myh = hmac.new(secret.encode(), str(str(a_attachment_id) + str(e_expires)).encode(), sha256)
|
||||
return hmac.compare_digest(str(hash_), myh.hexdigest())
|
||||
|
||||
|
||||
class RMAController(http.Controller):
|
||||
|
||||
@http.route(['/rma_label'], type='http', auth='public', website=True)
|
||||
def index(self, *args, **request):
|
||||
a_attachment_id = request.get('a')
|
||||
e_expires = request.get('e')
|
||||
hash = request.get('h')
|
||||
|
||||
if not all([a_attachment_id, e_expires, hash]):
|
||||
return http.Response('Invalid Request', status=400)
|
||||
|
||||
now = datetime.utcnow()
|
||||
now = int(mktime(now.timetuple()))
|
||||
|
||||
config = http.request.env['ir.config_parameter'].sudo()
|
||||
secret = str(config.search([('key', '=', 'database.secret')], limit=1).value)
|
||||
|
||||
if not check_hmac(secret, hash, a_attachment_id, e_expires):
|
||||
return http.Response('Invalid Request', status=400)
|
||||
|
||||
if now > int(e_expires):
|
||||
return http.Response('Expired', status=404)
|
||||
|
||||
attachment = http.request.env['ir.attachment'].sudo().search([('id', '=', int(a_attachment_id))], limit=1)
|
||||
if attachment:
|
||||
data = attachment.datas
|
||||
filename = attachment.name
|
||||
mimetype = attachment.mimetype
|
||||
return http.request.make_response(b64decode(data), [
|
||||
('Content-Type', mimetype),
|
||||
('Content-Disposition', 'attachment; filename="' + filename + '"')])
|
||||
return http.Response('Invalid Attachment', status=404)
|
||||
14
rma/data/ir_sequence_data.xml
Normal file
14
rma/data/ir_sequence_data.xml
Normal file
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
|
||||
<record id="seq_rma" model="ir.sequence">
|
||||
<field name="name">RMA</field>
|
||||
<field name="code">rma.rma</field>
|
||||
<field name="prefix">RMA</field>
|
||||
<field name="padding">3</field>
|
||||
<field name="company_id" eval="False"/>
|
||||
</record>
|
||||
|
||||
</data>
|
||||
</odoo>
|
||||
44
rma/data/rma_demo.xml
Normal file
44
rma/data/rma_demo.xml
Normal file
@@ -0,0 +1,44 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<odoo>
|
||||
<record id="template_missing_item" model="rma.template">
|
||||
<field name="name">Missing Item</field>
|
||||
<field name="valid_days" eval="10"/>
|
||||
<field name="create_out_picking" eval="True"/>
|
||||
<field name="out_type_id" ref="stock.picking_type_out"/>
|
||||
<field name="out_location_id" ref="stock.stock_location_stock"/>
|
||||
<field name="out_location_dest_id" ref="stock.stock_location_customers"/>
|
||||
<field name="out_procure_method">make_to_stock</field>
|
||||
</record>
|
||||
|
||||
<record id="rma_return_sequence" model="ir.sequence">
|
||||
<field name="name">RMA Returns</field>
|
||||
<field name="implementation">standard</field>
|
||||
<field name="prefix">WH/RMA/</field>
|
||||
<field name="padding" eval="5"/>
|
||||
<field name="number_increment" eval="1"/>
|
||||
</record>
|
||||
|
||||
<record id="picking_type_rma_return" model="stock.picking.type">
|
||||
<field name="name">RMA Receipts</field>
|
||||
<field name="sequence_id" ref="rma_return_sequence"/>
|
||||
<field name="warehouse_id" ref="stock.warehouse0"/>
|
||||
<field name="code">incoming</field>
|
||||
<field name="show_operations" eval="False"/>
|
||||
<field name="show_reserved" eval="True"/>
|
||||
<field name="use_create_lots" eval="False"/>
|
||||
<field name="use_existing_lots" eval="True"/>
|
||||
<field name="default_location_dest_id" ref="stock.stock_location_stock"/>
|
||||
</record>
|
||||
|
||||
<record id="template_picking_return" model="rma.template">
|
||||
<field name="name">Picking Return</field>
|
||||
<field name="usage">stock_picking</field>
|
||||
<field name="valid_days" eval="10"/>
|
||||
<field name="create_in_picking" eval="True"/>
|
||||
<field name="in_type_id" ref="picking_type_rma_return"/>
|
||||
<field name="in_location_id" ref="stock.stock_location_customers"/>
|
||||
<field name="in_location_dest_id" ref="stock.stock_location_stock"/>
|
||||
<field name="in_procure_method">make_to_stock</field>
|
||||
<field name="in_require_return" eval="True"/>
|
||||
</record>
|
||||
</odoo>
|
||||
2
rma/models/__init__.py
Normal file
2
rma/models/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from . import rma
|
||||
from . import stock_picking
|
||||
457
rma/models/rma.py
Normal file
457
rma/models/rma.py
Normal file
@@ -0,0 +1,457 @@
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import UserError
|
||||
from ..controllers.main import create_hmac
|
||||
from datetime import timedelta, datetime
|
||||
from time import mktime
|
||||
|
||||
|
||||
class RMATemplate(models.Model):
|
||||
_name = 'rma.template'
|
||||
|
||||
name = fields.Char(string='Name')
|
||||
usage = fields.Selection([
|
||||
('stock_picking', 'Stock Picking'),
|
||||
], string='Applies To')
|
||||
description = fields.Html(string='Internal Instructions')
|
||||
customer_description = fields.Html(string='Customer Instructions')
|
||||
valid_days = fields.Integer(string='Expire in Days')
|
||||
|
||||
create_in_picking = fields.Boolean(string='Create Inbound Picking')
|
||||
create_out_picking = fields.Boolean(string='Create Outbound Picking')
|
||||
|
||||
in_type_id = fields.Many2one('stock.picking.type', string='Inbound Picking Type')
|
||||
out_type_id = fields.Many2one('stock.picking.type', string='Outbound Picking Type')
|
||||
|
||||
in_location_id = fields.Many2one('stock.location', string='Inbound Source Location')
|
||||
in_location_dest_id = fields.Many2one('stock.location', string='Inbound Destination Location')
|
||||
in_carrier_id = fields.Many2one('delivery.carrier', string='Inbound Carrier')
|
||||
in_require_return = fields.Boolean(string='Inbound Require return of picking')
|
||||
in_procure_method = fields.Selection([
|
||||
('make_to_stock', 'Take from Stock'),
|
||||
('make_to_order', 'Apply Procurements')
|
||||
], string="Inbound Procurement Method", default='make_to_stock')
|
||||
in_to_refund = fields.Boolean(string='Inbound Mark Refund', oldname='in_to_refund_so')
|
||||
|
||||
out_location_id = fields.Many2one('stock.location', string='Outbound Source Location')
|
||||
out_location_dest_id = fields.Many2one('stock.location', string='Outbound Destination Location')
|
||||
out_carrier_id = fields.Many2one('delivery.carrier', string='Outbound Carrier')
|
||||
out_require_return = fields.Boolean(string='Outbound Require picking to duplicate')
|
||||
out_procure_method = fields.Selection([
|
||||
('make_to_stock', 'Take from Stock'),
|
||||
('make_to_order', 'Apply Procurements')
|
||||
], string="Outbound Procurement Method", default='make_to_stock')
|
||||
out_to_refund = fields.Boolean(string='Outbound Mark Refund')
|
||||
|
||||
def _values_for_in_picking(self, rma):
|
||||
return {
|
||||
'origin': rma.name,
|
||||
'partner_id': rma.partner_shipping_id.id,
|
||||
'picking_type_id': self.in_type_id.id,
|
||||
'location_id': self.in_location_id.id,
|
||||
'location_dest_id': self.in_location_dest_id.id,
|
||||
'carrier_id': self.in_carrier_id.id if self.in_carrier_id else False,
|
||||
'move_lines': [(0, None, {
|
||||
'name': rma.name + ' IN: ' + l.product_id.name_get()[0][1],
|
||||
'product_id': l.product_id.id,
|
||||
'product_uom_qty': l.product_uom_qty,
|
||||
'product_uom': l.product_uom_id.id,
|
||||
'procure_method': self.in_procure_method,
|
||||
'to_refund': self.in_to_refund,
|
||||
}) for l in rma.lines],
|
||||
}
|
||||
|
||||
def _values_for_out_picking(self, rma):
|
||||
return {
|
||||
'origin': rma.name,
|
||||
'partner_id': rma.partner_shipping_id.id,
|
||||
'picking_type_id': self.out_type_id.id,
|
||||
'location_id': self.out_location_id.id,
|
||||
'location_dest_id': self.out_location_dest_id.id,
|
||||
'carrier_id': self.out_carrier_id.id if self.out_carrier_id else False,
|
||||
'move_lines': [(0, None, {
|
||||
'name': rma.name + ' OUT: ' + l.product_id.name_get()[0][1],
|
||||
'product_id': l.product_id.id,
|
||||
'product_uom_qty': l.product_uom_qty,
|
||||
'product_uom': l.product_uom_id.id,
|
||||
'procure_method': self.out_procure_method,
|
||||
'to_refund': self.out_to_refund,
|
||||
}) for l in rma.lines],
|
||||
}
|
||||
|
||||
|
||||
class RMATag(models.Model):
|
||||
_name = "rma.tag"
|
||||
_description = "RMA Tag"
|
||||
|
||||
name = fields.Char('Tag Name', required=True)
|
||||
color = fields.Integer('Color Index')
|
||||
|
||||
_sql_constraints = [
|
||||
('name_uniq', 'unique (name)', "Tag name already exists !"),
|
||||
]
|
||||
|
||||
|
||||
class RMA(models.Model):
|
||||
_name = 'rma.rma'
|
||||
_inherit = ['mail.thread', 'mail.activity.mixin']
|
||||
_description = 'RMA'
|
||||
_order = 'id desc'
|
||||
|
||||
name = fields.Char(string='Number', copy=False)
|
||||
state = fields.Selection([
|
||||
('draft', 'New'),
|
||||
('confirmed', 'Confirmed'),
|
||||
('done', 'Done'),
|
||||
('cancel', 'Cancelled'),
|
||||
], string='State', default='draft', copy=False)
|
||||
company_id = fields.Many2one('res.company', 'Company')
|
||||
template_id = fields.Many2one('rma.template', string='Type', required=True)
|
||||
stock_picking_id = fields.Many2one('stock.picking', string='Stock Picking')
|
||||
stock_picking_rma_count = fields.Integer('Number of RMAs for this Picking', compute='_compute_stock_picking_rma_count')
|
||||
partner_id = fields.Many2one('res.partner', string='Partner')
|
||||
partner_shipping_id = fields.Many2one('res.partner', string='Shipping')
|
||||
lines = fields.One2many('rma.line', 'rma_id', string='Lines')
|
||||
tag_ids = fields.Many2many('rma.tag', 'rma_tags_rel', 'rma_id', 'tag_id', string='Tags')
|
||||
description = fields.Html(string='Internal Instructions', related='template_id.description')
|
||||
customer_description = fields.Html(string='Customer Instructions', related='template_id.customer_description')
|
||||
template_usage = fields.Selection(string='Template Usage', related='template_id.usage')
|
||||
validity_date = fields.Datetime(string='Expiration Date')
|
||||
|
||||
in_picking_id = fields.Many2one('stock.picking', string='Inbound Picking', copy=False)
|
||||
out_picking_id = fields.Many2one('stock.picking', string='Outbound Picking', copy=False)
|
||||
|
||||
in_picking_state = fields.Selection(string='In Picking State', related='in_picking_id.state')
|
||||
out_picking_state = fields.Selection(string='Out Picking State', related='out_picking_id.state')
|
||||
|
||||
in_picking_carrier_id = fields.Many2one('delivery.carrier', related='in_picking_id.carrier_id')
|
||||
out_picking_carrier_id = fields.Many2one('delivery.carrier', related='out_picking_id.carrier_id')
|
||||
|
||||
in_carrier_tracking_ref = fields.Char(related='in_picking_id.carrier_tracking_ref')
|
||||
in_label_url = fields.Char(compute='_compute_in_label_url')
|
||||
out_carrier_tracking_ref = fields.Char(related='out_picking_id.carrier_tracking_ref')
|
||||
|
||||
|
||||
@api.onchange('template_usage')
|
||||
@api.multi
|
||||
def _onchange_template_usage(self):
|
||||
now = datetime.now()
|
||||
for rma in self:
|
||||
if rma.template_id.valid_days:
|
||||
rma.validity_date = now + timedelta(days=rma.template_id.valid_days)
|
||||
if rma.template_usage != 'stock_picking':
|
||||
rma.stock_picking_id = False
|
||||
|
||||
@api.onchange('stock_picking_id')
|
||||
@api.multi
|
||||
def _onchange_stock_picking_id(self):
|
||||
for rma in self.filtered(lambda rma: rma.stock_picking_id):
|
||||
rma.partner_id = rma.stock_picking_id.partner_id
|
||||
rma.partner_shipping_id = rma.stock_picking_id.partner_id
|
||||
|
||||
@api.onchange('in_carrier_tracking_ref', 'validity_date')
|
||||
@api.multi
|
||||
def _compute_in_label_url(self):
|
||||
config = self.env['ir.config_parameter'].sudo()
|
||||
secret = config.search([('key', '=', 'database.secret')], limit=1)
|
||||
secret = str(secret.value) if secret else ''
|
||||
base_url = config.search([('key', '=', 'web.base.url')], limit=1)
|
||||
base_url = str(base_url.value) if base_url else ''
|
||||
for rma in self:
|
||||
if not rma.in_picking_id:
|
||||
rma.in_label_url = ''
|
||||
continue
|
||||
if rma.validity_date:
|
||||
e_expires = int(mktime(fields.Datetime.from_string(rma.validity_date).timetuple()))
|
||||
else:
|
||||
year = datetime.now() + timedelta(days=365)
|
||||
e_expires = int(mktime(year.timetuple()))
|
||||
attachment = self.env['ir.attachment'].search([
|
||||
('res_model', '=', 'stock.picking'),
|
||||
('res_id', '=', rma.in_picking_id.id),
|
||||
('name', 'like', 'Label%')], limit=1)
|
||||
if not attachment:
|
||||
rma.in_label_url = ''
|
||||
continue
|
||||
rma.in_label_url = base_url + '/rma_label?a=' + \
|
||||
str(attachment.id) + '&e=' + str(e_expires) + \
|
||||
'&h=' + create_hmac(secret, attachment.id, e_expires)
|
||||
|
||||
@api.multi
|
||||
@api.depends('stock_picking_id')
|
||||
def _compute_stock_picking_rma_count(self):
|
||||
for rma in self:
|
||||
if rma.stock_picking_id:
|
||||
rma_data = self.read_group([('stock_picking_id', '=', rma.stock_picking_id.id), ('state', '!=', 'cancel')],
|
||||
['stock_picking_id'], ['stock_picking_id'])
|
||||
if rma_data:
|
||||
rma.stock_picking_rma_count = rma_data[0]['stock_picking_id_count']
|
||||
else:
|
||||
rma.stock_picking_rma_count = 0.0
|
||||
|
||||
|
||||
@api.multi
|
||||
def open_stock_picking_rmas(self):
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Picking RMAs'),
|
||||
'res_model': 'rma.rma',
|
||||
'view_mode': 'tree,form',
|
||||
'context': {'search_default_stock_picking_id': self[0].stock_picking_id.id}
|
||||
}
|
||||
|
||||
@api.model
|
||||
def create(self, vals):
|
||||
if vals.get('name', _('New')) == _('New'):
|
||||
if 'company_id' in vals:
|
||||
vals['name'] = self.env['ir.sequence'].with_context(force_company=vals['company_id']).next_by_code('rma.rma') or _('New')
|
||||
else:
|
||||
vals['name'] = self.env['ir.sequence'].next_by_code('rma.rma') or _('New')
|
||||
|
||||
result = super(RMA, self).create(vals)
|
||||
return result
|
||||
|
||||
@api.multi
|
||||
def action_confirm(self):
|
||||
for rma in self:
|
||||
in_picking_id = False
|
||||
out_picking_id = False
|
||||
if any((not rma.template_id, not rma.lines, not rma.partner_id, not rma.partner_shipping_id)):
|
||||
raise UserError(_('You can only confirm RMAs with lines, and partner information.'))
|
||||
if rma.template_id.create_in_picking:
|
||||
in_picking_id = rma._create_in_picking()
|
||||
if in_picking_id:
|
||||
in_picking_id.action_confirm()
|
||||
in_picking_id.action_assign()
|
||||
if rma.template_id.create_out_picking:
|
||||
out_picking_id = rma._create_out_picking()
|
||||
if out_picking_id:
|
||||
out_picking_id.action_confirm()
|
||||
out_picking_id.action_assign()
|
||||
rma.write({'state': 'confirmed',
|
||||
'in_picking_id': in_picking_id.id if in_picking_id else False,
|
||||
'out_picking_id': out_picking_id.id if out_picking_id else False})
|
||||
|
||||
@api.multi
|
||||
def action_done(self):
|
||||
for rma in self:
|
||||
if rma.in_picking_id and rma.in_picking_id.state not in ('done', 'cancel'):
|
||||
raise UserError(_('Inbound picking not complete or cancelled.'))
|
||||
if rma.out_picking_id and rma.out_picking_id.state not in ('done', 'cancel'):
|
||||
raise UserError(_('Outbound picking not complete or cancelled.'))
|
||||
self.write({'state': 'done'})
|
||||
|
||||
@api.multi
|
||||
def action_cancel(self):
|
||||
for rma in self:
|
||||
rma.in_picking_id.action_cancel()
|
||||
rma.out_picking_id.action_cancel()
|
||||
self.write({'state': 'cancel'})
|
||||
|
||||
@api.multi
|
||||
def action_draft(self):
|
||||
self.filtered(lambda l: l.state == 'cancel').write({
|
||||
'state': 'draft', 'in_picking_id': False, 'out_picking_id': False})
|
||||
|
||||
@api.multi
|
||||
def _create_in_picking(self):
|
||||
if self.template_usage and hasattr(self, '_create_in_picking_' + self.template_usage):
|
||||
return getattr(self, '_create_in_picking_' + self.template_usage)()
|
||||
values = self.template_id._values_for_in_picking(self)
|
||||
return self.env['stock.picking'].sudo().create(values)
|
||||
|
||||
def _create_out_picking(self):
|
||||
if self.template_usage and hasattr(self, '_create_out_picking_' + self.template_usage):
|
||||
return getattr(self, '_create_out_picking_' + self.template_usage)()
|
||||
values = self.template_id._values_for_out_picking(self)
|
||||
return self.env['stock.picking'].sudo().create(values)
|
||||
|
||||
def _find_candidate_return_picking(self, product_ids, pickings, location_id):
|
||||
done_pickings = pickings.filtered(lambda p: p.state == 'done' and p.location_dest_id.id == location_id)
|
||||
for p in done_pickings:
|
||||
p_product_ids = p.move_lines.filtered(lambda l: l.state == 'done').mapped('product_id.id')
|
||||
if set(product_ids) & set(p_product_ids) == set(product_ids):
|
||||
return p
|
||||
return None
|
||||
|
||||
@api.multi
|
||||
def action_in_picking_send_to_shipper(self):
|
||||
for rma in self:
|
||||
if rma.in_picking_id and rma.in_picking_carrier_id:
|
||||
rma.in_picking_id.send_to_shipper()
|
||||
|
||||
@api.multi
|
||||
def action_add_picking_lines(self):
|
||||
make_line_obj = self.env['rma.picking.make.lines']
|
||||
for rma in self:
|
||||
lines = make_line_obj.create({
|
||||
'rma_id': rma.id,
|
||||
})
|
||||
action = self.env.ref('rma.action_rma_add_lines').read()[0]
|
||||
action['res_id'] = lines.id
|
||||
return action
|
||||
|
||||
@api.multi
|
||||
def unlink(self):
|
||||
for rma in self:
|
||||
if rma.state not in ('draft'):
|
||||
raise UserError(_('You can not delete a non-draft RMA.'))
|
||||
return super(RMA, self).unlink()
|
||||
|
||||
def _picking_from_values(self, values, values_update, move_line_values_update):
|
||||
values.update(values_update)
|
||||
move_lines = []
|
||||
for l1, l2, vals in values['move_lines']:
|
||||
vals.update(move_line_values_update)
|
||||
move_lines.append((l1, l2, vals))
|
||||
values['move_lines'] = move_lines
|
||||
return self.env['stock.picking'].sudo().create(values)
|
||||
|
||||
def _new_in_picking(self, old_picking):
|
||||
new_picking = old_picking.copy({
|
||||
'move_lines': [],
|
||||
'picking_type_id': self.template_id.in_type_id.id,
|
||||
'state': 'draft',
|
||||
'origin': old_picking.name + ' ' + self.name,
|
||||
'location_id': self.template_id.in_location_id.id,
|
||||
'location_dest_id': self.template_id.in_location_dest_id.id,
|
||||
'carrier_id': self.template_id.in_carrier_id.id if self.template_id.in_carrier_id else 0,
|
||||
'carrier_tracking_ref': False,
|
||||
'carrier_price': False
|
||||
})
|
||||
new_picking.message_post_with_view('mail.message_origin_link',
|
||||
values={'self': new_picking, 'origin': self},
|
||||
subtype_id=self.env.ref('mail.mt_note').id)
|
||||
return new_picking
|
||||
|
||||
def _new_in_move_vals(self, rma_line, new_picking, old_move):
|
||||
return {
|
||||
'name': self.name + ' IN: ' + rma_line.product_id.name_get()[0][1],
|
||||
'product_id': rma_line.product_id.id,
|
||||
'product_uom_qty': rma_line.product_uom_qty,
|
||||
'product_uom': rma_line.product_uom_id.id,
|
||||
'picking_id': new_picking.id,
|
||||
'state': 'draft',
|
||||
'location_id': old_move.location_dest_id.id,
|
||||
'location_dest_id': self.template_id.in_location_dest_id.id,
|
||||
'picking_type_id': new_picking.picking_type_id.id,
|
||||
'warehouse_id': new_picking.picking_type_id.warehouse_id.id,
|
||||
'origin_returned_move_id': old_move.id,
|
||||
'procure_method': self.template_id.in_procure_method,
|
||||
'to_refund': self.template_id.in_to_refund,
|
||||
}
|
||||
|
||||
def _new_in_moves(self, old_picking, new_picking, move_update):
|
||||
lines = self.lines.filtered(lambda l: l.product_uom_qty >= 1)
|
||||
if not lines:
|
||||
raise UserError(_('You have no lines with positive quantity.'))
|
||||
|
||||
moves = self.env['stock.move']
|
||||
for l in lines:
|
||||
return_move = old_picking.move_lines.filtered(lambda ol: ol.state == 'done' and ol.product_id.id == l.product_id.id)[0]
|
||||
copy_vals = self._new_in_move_vals(l, new_picking, return_move)
|
||||
copy_vals.update(move_update)
|
||||
r = return_move.copy(copy_vals)
|
||||
vals = {}
|
||||
# +--------------------------------------------------------------------------------------------------------+
|
||||
# | picking_pick <--Move Orig-- picking_pack --Move Dest--> picking_ship
|
||||
# | | returned_move_ids ↑ | returned_move_ids
|
||||
# | ↓ | return_line.move_id ↓
|
||||
# | return pick(Add as dest) return toLink return ship(Add as orig)
|
||||
# +--------------------------------------------------------------------------------------------------------+
|
||||
move_orig_to_link = return_move.move_dest_ids.mapped('returned_move_ids')
|
||||
move_dest_to_link = return_move.move_orig_ids.mapped('returned_move_ids')
|
||||
vals['move_orig_ids'] = [(4, m.id) for m in move_orig_to_link | return_move]
|
||||
vals['move_dest_ids'] = [(4, m.id) for m in move_dest_to_link]
|
||||
r.write(vals)
|
||||
moves += r
|
||||
return moves
|
||||
|
||||
def _new_out_picking(self, old_picking):
|
||||
new_picking = old_picking.copy({
|
||||
'move_lines': [],
|
||||
'picking_type_id': self.template_id.out_type_id.id,
|
||||
'state': 'draft',
|
||||
'origin': old_picking.name + ' ' + self.name,
|
||||
'location_id': self.template_id.out_location_id.id,
|
||||
'location_dest_id': self.template_id.out_location_dest_id.id,
|
||||
'carrier_id': self.template_id.out_carrier_id.id if self.template_id.out_carrier_id else 0,
|
||||
'carrier_tracking_ref': False,
|
||||
'carrier_price': False
|
||||
})
|
||||
new_picking.message_post_with_view('mail.message_origin_link',
|
||||
values={'self': new_picking, 'origin': self},
|
||||
subtype_id=self.env.ref('mail.mt_note').id)
|
||||
return new_picking
|
||||
|
||||
def _new_out_move_vals(self, rma_line, new_picking, old_move):
|
||||
return {
|
||||
'name': self.name + ' OUT: ' + rma_line.product_id.name_get()[0][1],
|
||||
'product_id': rma_line.product_id.id,
|
||||
'product_uom_qty': rma_line.product_uom_qty,
|
||||
'picking_id': new_picking.id,
|
||||
'state': 'draft',
|
||||
'location_id': self.template_id.out_location_id.id,
|
||||
'location_dest_id': self.template_id.out_location_dest_id.id,
|
||||
'picking_type_id': new_picking.picking_type_id.id,
|
||||
'warehouse_id': new_picking.picking_type_id.warehouse_id.id,
|
||||
'origin_returned_move_id': False,
|
||||
'procure_method': self.template_id.out_procure_method,
|
||||
'to_refund': self.template_id.out_to_refund,
|
||||
}
|
||||
|
||||
def _new_out_moves(self, old_picking, new_picking, move_update):
|
||||
lines = self.lines.filtered(lambda l: l.product_uom_qty >= 1)
|
||||
if not lines:
|
||||
raise UserError(_('You have no lines with positive quantity.'))
|
||||
moves = self.env['stock.move']
|
||||
for l in lines:
|
||||
return_move = old_picking.move_lines.filtered(lambda ol: ol.state == 'done' and ol.product_id.id == l.product_id.id)[0]
|
||||
copy_vals = self._new_out_move_vals(l, new_picking, return_move)
|
||||
copy_vals.update(move_update)
|
||||
moves += return_move.copy(copy_vals)
|
||||
return moves
|
||||
|
||||
def _create_in_picking_stock_picking(self):
|
||||
if not self.stock_picking_id or self.stock_picking_id.state != 'done':
|
||||
raise UserError(_('You must have a completed stock picking for this RMA.'))
|
||||
if not self.template_id.in_require_return:
|
||||
group_id = self.stock_picking_id.group_id.id if self.stock_picking_id.group_id else 0
|
||||
values = self.template_id._values_for_in_picking(self)
|
||||
update = {'group_id': group_id}
|
||||
return self._picking_from_values(values, update, update)
|
||||
|
||||
old_picking = self.stock_picking_id
|
||||
|
||||
new_picking = self._new_in_picking(old_picking)
|
||||
self._new_in_moves(old_picking, new_picking, {})
|
||||
return new_picking
|
||||
|
||||
def _create_out_picking_stock_picking(self):
|
||||
if not self.stock_picking_id or self.stock_picking_id.state != 'done':
|
||||
raise UserError(_('You must have a completed stock picking for this RMA.'))
|
||||
if not self.template_id.out_require_return:
|
||||
group_id = self.stock_picking_id.group_id.id if self.stock_picking_id.group_id else 0
|
||||
values = self.template_id._values_for_out_picking(self)
|
||||
update = {'group_id': group_id}
|
||||
return self._picking_from_values(values, update, update)
|
||||
|
||||
old_picking = self.stock_picking_id
|
||||
new_picking = self._new_out_picking(old_picking)
|
||||
self._new_out_moves(old_picking, new_picking, {})
|
||||
return new_picking
|
||||
|
||||
|
||||
class RMALine(models.Model):
|
||||
_name = 'rma.line'
|
||||
|
||||
rma_id = fields.Many2one('rma.rma', string='RMA')
|
||||
product_id = fields.Many2one('product.product', 'Product')
|
||||
product_uom_id = fields.Many2one('product.uom', 'UOM')
|
||||
product_uom_qty = fields.Float(string='QTY')
|
||||
rma_template_usage = fields.Selection(related='rma_id.template_usage')
|
||||
|
||||
@api.onchange('product_id')
|
||||
@api.multi
|
||||
def _onchange_product_id(self):
|
||||
for line in self:
|
||||
line.product_uom_id = line.product_id.uom_id
|
||||
13
rma/models/stock_picking.py
Normal file
13
rma/models/stock_picking.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from odoo import api, models, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class StockPicking(models.Model):
|
||||
_inherit = 'stock.picking'
|
||||
|
||||
@api.multi
|
||||
def send_to_shipper(self):
|
||||
res = False
|
||||
for pick in self.filtered(lambda p: not p.carrier_tracking_ref):
|
||||
res = super(StockPicking, pick).send_to_shipper()
|
||||
return res
|
||||
7
rma/security/ir.model.access.csv
Normal file
7
rma/security/ir.model.access.csv
Normal file
@@ -0,0 +1,7 @@
|
||||
"id","name","model_id:id","group_id:id","perm_read","perm_write","perm_create","perm_unlink"
|
||||
"manage_rma stock","manage rma","model_rma_rma","stock.group_stock_user",1,1,1,1
|
||||
"manage_rma_line stock","manage rma line","model_rma_line","stock.group_stock_user",1,1,1,1
|
||||
"manage_rma_template stock","manage rma template","model_rma_template","stock.group_stock_manager",1,1,1,1
|
||||
"manage_rma_tag stock","manage rma tag","model_rma_tag","stock.group_stock_manager",1,1,1,1
|
||||
"access_rma_template stock","access rma template","model_rma_template","stock.group_stock_user",1,1,0,0
|
||||
"access_rma_tag stock","access rma tag","model_rma_tag","stock.group_stock_user",1,0,0,0
|
||||
|
1
rma/tests/__init__.py
Normal file
1
rma/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import test_rma
|
||||
222
rma/tests/test_rma.py
Normal file
222
rma/tests/test_rma.py
Normal file
@@ -0,0 +1,222 @@
|
||||
from odoo.tests import common
|
||||
from odoo.exceptions import UserError, ValidationError
|
||||
import logging
|
||||
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TestRMA(common.TransactionCase):
|
||||
def setUp(self):
|
||||
super(TestRMA, self).setUp()
|
||||
self.product1 = self.env.ref('product.product_product_24')
|
||||
self.template_missing = self.env.ref('rma.template_missing_item')
|
||||
self.template_return = self.env.ref('rma.template_picking_return')
|
||||
self.partner1 = self.env.ref('base.res_partner_2')
|
||||
|
||||
def test_00_basic_rma(self):
|
||||
self.template_missing.usage = False
|
||||
rma = self.env['rma.rma'].create({
|
||||
'template_id': self.template_missing.id,
|
||||
'partner_id': self.partner1.id,
|
||||
'partner_shipping_id': self.partner1.id,
|
||||
})
|
||||
self.assertEqual(rma.state, 'draft')
|
||||
rma_line = self.env['rma.line'].create({
|
||||
'rma_id': rma.id,
|
||||
'product_id': self.product1.id,
|
||||
'product_uom_id': self.product1.uom_id.id,
|
||||
'product_uom_qty': 2.0,
|
||||
})
|
||||
rma.action_confirm()
|
||||
# Should have made pickings
|
||||
self.assertEqual(rma.state, 'confirmed')
|
||||
# No inbound picking
|
||||
self.assertFalse(rma.in_picking_id)
|
||||
# Good outbound picking
|
||||
self.assertTrue(rma.out_picking_id)
|
||||
self.assertEqual(rma_line.product_id, rma.out_picking_id.move_lines.product_id)
|
||||
self.assertEqual(rma_line.product_uom_qty, rma.out_picking_id.move_lines.product_uom_qty)
|
||||
|
||||
with self.assertRaises(UserError):
|
||||
rma.action_done()
|
||||
|
||||
rma.out_picking_id.move_lines.quantity_done = 2.0
|
||||
rma.out_picking_id.action_done()
|
||||
rma.action_done()
|
||||
self.assertEqual(rma.state, 'done')
|
||||
|
||||
def test_10_rma_cancel(self):
|
||||
self.template_missing.usage = False
|
||||
rma = self.env['rma.rma'].create({
|
||||
'template_id': self.template_missing.id,
|
||||
'partner_id': self.partner1.id,
|
||||
'partner_shipping_id': self.partner1.id,
|
||||
})
|
||||
self.assertEqual(rma.state, 'draft')
|
||||
rma_line = self.env['rma.line'].create({
|
||||
'rma_id': rma.id,
|
||||
'product_id': self.product1.id,
|
||||
'product_uom_id': self.product1.uom_id.id,
|
||||
'product_uom_qty': 2.0,
|
||||
})
|
||||
rma.action_confirm()
|
||||
# Good outbound picking
|
||||
self.assertEqual(rma.out_picking_id.move_lines.state, 'assigned')
|
||||
rma.action_cancel()
|
||||
self.assertEqual(rma.out_picking_id.move_lines.state, 'cancel')
|
||||
|
||||
def test_20_picking_rma(self):
|
||||
type_out = self.env.ref('stock.picking_type_out')
|
||||
location = self.env.ref('stock.stock_location_stock')
|
||||
location_customer = self.env.ref('stock.stock_location_customers')
|
||||
self.product1.tracking = 'serial'
|
||||
|
||||
# Need to ensure this is the only quant that can be reserved for this move.
|
||||
adj = self.env['stock.inventory'].create({
|
||||
'name': 'Test',
|
||||
'location_id': location.id,
|
||||
'filter': 'product',
|
||||
'product_id': self.product1.id,
|
||||
})
|
||||
lot = self.env['stock.production.lot'].create({
|
||||
'product_id': self.product1.id,
|
||||
'name': 'X100',
|
||||
'product_uom_id': self.product1.uom_id.id,
|
||||
})
|
||||
self.assertFalse(lot.quant_ids)
|
||||
self.assertEqual(lot.product_qty, 0.0)
|
||||
|
||||
adj.action_start()
|
||||
self.assertTrue(adj.line_ids)
|
||||
adj.line_ids.write({
|
||||
'product_qty': 0.0,
|
||||
})
|
||||
|
||||
self.env['stock.inventory.line'].create({
|
||||
'inventory_id': adj.id,
|
||||
'location_id': location.id,
|
||||
'product_id': self.product1.id,
|
||||
'product_uom_id': self.product1.uom_id.id,
|
||||
'prod_lot_id': lot.id,
|
||||
'product_qty': 1.0,
|
||||
})
|
||||
|
||||
adj.action_done()
|
||||
self.assertEqual(self.product1.qty_available, 1.0)
|
||||
self.assertTrue(lot.quant_ids)
|
||||
self.assertEqual(lot.product_qty, 1.0)
|
||||
|
||||
# Create initial picking that will be returned by RMA
|
||||
picking_out = self.env['stock.picking'].create({
|
||||
'partner_id': self.partner1.id,
|
||||
'name': 'testpicking',
|
||||
'picking_type_id': type_out.id,
|
||||
'location_id': location.id,
|
||||
'location_dest_id': location_customer.id,
|
||||
})
|
||||
self.env['stock.move'].create({
|
||||
'name': self.product1.name,
|
||||
'product_id': self.product1.id,
|
||||
'product_uom_qty': 1.0,
|
||||
'product_uom': self.product1.uom_id.id,
|
||||
'picking_id': picking_out.id,
|
||||
'location_id': location.id,
|
||||
'location_dest_id': location_customer.id,
|
||||
})
|
||||
picking_out.with_context(planned_picking=True).action_confirm()
|
||||
|
||||
# Try to RMA item not delivered yet
|
||||
rma = self.env['rma.rma'].create({
|
||||
'template_id': self.template_return.id,
|
||||
'partner_id': self.partner1.id,
|
||||
'partner_shipping_id': self.partner1.id,
|
||||
'stock_picking_id': picking_out.id,
|
||||
})
|
||||
self.assertEqual(rma.state, 'draft')
|
||||
wizard = self.env['rma.picking.make.lines'].create({
|
||||
'rma_id': rma.id,
|
||||
})
|
||||
wizard.line_ids.product_uom_qty = 1.0
|
||||
wizard.add_lines()
|
||||
self.assertEqual(len(rma.lines), 1)
|
||||
|
||||
# Make sure that we cannot 'return' if we cannot 'reverse' a stock move
|
||||
# (this is what `in_require_return` and `out_require_return` do on `rma.template`)
|
||||
with self.assertRaises(UserError):
|
||||
rma.action_confirm()
|
||||
|
||||
# Finish our original picking
|
||||
picking_out.action_assign()
|
||||
self.assertEqual(picking_out.state, 'assigned')
|
||||
|
||||
# The only lot should be reserved, so we shouldn't get an exception finishing the transfer.
|
||||
picking_out.move_line_ids.write({
|
||||
'qty_done': 1.0,
|
||||
})
|
||||
picking_out.do_transfer()
|
||||
self.assertEqual(picking_out.state, 'done')
|
||||
|
||||
# Now we can 'return' that picking
|
||||
rma.action_confirm()
|
||||
self.assertEqual(rma.in_picking_id.state, 'assigned')
|
||||
pack_opt = rma.in_picking_id.move_line_ids[0]
|
||||
self.assertTrue(pack_opt)
|
||||
|
||||
# We cannot check this directly anymore. Instead just try to return the same lot and make sure you can.
|
||||
# self.assertEqual(pack_opt.lot_id, lot)
|
||||
|
||||
with self.assertRaises(UserError):
|
||||
rma.action_done()
|
||||
|
||||
pack_opt.qty_done = 1.0
|
||||
with self.assertRaises(UserError):
|
||||
# require a lot
|
||||
rma.in_picking_id.do_transfer()
|
||||
|
||||
pack_opt.lot_id = lot
|
||||
rma.in_picking_id.do_transfer()
|
||||
rma.action_done()
|
||||
|
||||
# Ensure that the same lot was in fact returned into our destination inventory
|
||||
quant = self.env['stock.quant'].search([('product_id', '=', self.product1.id), ('location_id', '=', location.id)])
|
||||
self.assertEqual(len(quant), 1)
|
||||
self.assertEqual(quant.lot_id, lot)
|
||||
|
||||
# Make another RMA for the same picking
|
||||
rma2 = self.env['rma.rma'].create({
|
||||
'template_id': self.template_return.id,
|
||||
'partner_id': self.partner1.id,
|
||||
'partner_shipping_id': self.partner1.id,
|
||||
'stock_picking_id': picking_out.id,
|
||||
})
|
||||
wizard = self.env['rma.picking.make.lines'].create({
|
||||
'rma_id': rma2.id,
|
||||
})
|
||||
wizard.line_ids.product_uom_qty = 1.0
|
||||
wizard.add_lines()
|
||||
self.assertEqual(len(rma2.lines), 1)
|
||||
|
||||
rma2.action_confirm()
|
||||
|
||||
# In Odoo 10, this would not have been able to reserve.
|
||||
# In Odoo 11, reservation can still happen, but at least we can't move the same lot twice!
|
||||
# self.assertEqual(rma2.in_picking_id.state, 'confirmed')
|
||||
|
||||
# Requires Lot
|
||||
with self.assertRaises(UserError):
|
||||
rma2.in_picking_id.move_line_ids.write({'qty_done': 1.0})
|
||||
rma2.in_picking_id.do_transfer()
|
||||
|
||||
# Assign existing lot
|
||||
rma2.in_picking_id.move_line_ids.write({
|
||||
'lot_id': lot.id
|
||||
})
|
||||
|
||||
# Existing lot cannot be re-used.
|
||||
with self.assertRaises(ValidationError):
|
||||
rma2.in_picking_id.action_done()
|
||||
|
||||
# RMA cannot be completed because the inbound picking state is confirmed
|
||||
with self.assertRaises(UserError):
|
||||
rma2.action_done()
|
||||
275
rma/views/rma_views.xml
Normal file
275
rma/views/rma_views.xml
Normal file
@@ -0,0 +1,275 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_rma_rma_form" model="ir.ui.view">
|
||||
<field name="name">rma.rma.form</field>
|
||||
<field name="model">rma.rma</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="RMA" class="oe_form_nomargin">
|
||||
<header>
|
||||
<button name="action_confirm" string="Confirm" class="btn-primary" type="object" attrs="{'invisible': [('state', '!=', 'draft')]}"/>
|
||||
<button name="action_done" string="Done" class="btn-primary" type="object" attrs="{'invisible': [('state', '!=', 'confirmed')]}"/>
|
||||
<button name="action_draft" string="Set Draft" class="btn-default" type="object" attrs="{'invisible': [('state', '!=', 'cancel')]}"/>
|
||||
<button name="action_cancel" string="Cancel" class="btn-default" type="object" attrs="{'invisible': [('state', 'in', ('draft', 'done'))]}"/>
|
||||
<field name="state" widget="statusbar" on_change="1" modifiers="{'readonly': true}"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<div class="oe_button_box" name="button_box">
|
||||
<button class="oe_stat_button" name="open_stock_picking_rmas" icon="fa-cubes"
|
||||
type="object" attrs="{'invisible': ['|', ('stock_picking_id', '=', False), ('stock_picking_rma_count', '<=', 1)]}">
|
||||
<field name="stock_picking_rma_count" string="Pick RMAs" widget="statinfo" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="oe_title">
|
||||
<h1>
|
||||
<field name="name" readonly="1" modifiers="{'readonly': true, 'required': true}"/>
|
||||
</h1>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<field name="template_usage" invisible="1"/>
|
||||
<field name="template_id" options="{'no_create': True}" attrs="{'readonly': [('state', 'in', ('confirmed', 'done', 'cancel'))]}"/>
|
||||
<field name="stock_picking_id" options="{'no_create': True}" attrs="{'invisible': [('template_usage', '!=', 'stock_picking')], 'required': [('template_usage', '=', 'stock_picking')], 'readonly': [('state', 'in', ('confirmed', 'done', 'cancel'))]}"/>
|
||||
<br/>
|
||||
<button string="Add lines" type="object" name="action_add_picking_lines" attrs="{'invisible': ['|', ('stock_picking_id', '=', False), ('state', '!=', 'draft')]}"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="validity_date"/>
|
||||
<field name="tag_ids" widget="many2many_tags" placeholder="Tags" options="{'no_create': True}"/>
|
||||
<field name="partner_id" options="{'no_create_edit': True}" attrs="{'readonly': [('state', '!=', 'draft')]}"/>
|
||||
<field name="partner_shipping_id" attrs="{'readonly': [('state', '!=', 'draft')]}"/>
|
||||
<field name="company_id" invisible="1" options="{'no_create': True}" can_create="true" can_write="true" modifiers="{}"/>
|
||||
</group>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Internal Instructions">
|
||||
<field name="description" readonly="1"/>
|
||||
</page>
|
||||
<page string="Customer Instructions">
|
||||
<field name="customer_description" readonly="1"/>
|
||||
</page>
|
||||
</notebook>
|
||||
<notebook>
|
||||
<page string="RMA Lines">
|
||||
<field name="lines" attrs="{'readonly': [('state', '!=', 'draft')]}">
|
||||
<tree editable="bottom">
|
||||
<field name="rma_template_usage" invisible="1"/>
|
||||
<field name="product_id" attrs="{'readonly': [('rma_template_usage', '!=', False)]}"/>
|
||||
<field name="product_uom_qty" attrs="{'readonly': [('rma_template_usage', '!=', False)]}"/>
|
||||
<field name="product_uom_id" attrs="{'readonly': [('rma_template_usage', '!=', False)]}"/>
|
||||
</tree>
|
||||
</field>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
<sheet attrs="{'invisible': [('in_picking_id', '=', False)]}">
|
||||
<header>
|
||||
<field name="in_picking_state" widget="statusbar" on_change="1" modifiers="{'readonly': true}"/>
|
||||
</header>
|
||||
<div class="oe_title">
|
||||
<h2>Inbound Picking:</h2>
|
||||
<h1>
|
||||
<field name="in_picking_id" readonly="1" modifiers="{'readonly': true}"/>
|
||||
</h1>
|
||||
<p>
|
||||
<field name="in_label_url" attrs="{'invisible': [('in_label_url', '=', False)]}"/>
|
||||
</p>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<field name="in_picking_carrier_id" string="Carrier"/>
|
||||
<field name="in_carrier_tracking_ref" string="Tracking"/>
|
||||
<button string="Generate Label" type="object" name="action_in_picking_send_to_shipper" attrs="{'invisible': ['|', ('in_carrier_tracking_ref', '!=', False), ('in_picking_carrier_id', '=', False)]}"/>
|
||||
</group>
|
||||
</group>
|
||||
</sheet>
|
||||
<sheet attrs="{'invisible': [('out_picking_id', '=', False)]}">
|
||||
<header>
|
||||
<field name="out_picking_state" widget="statusbar" on_change="1" modifiers="{'readonly': true}"/>
|
||||
</header>
|
||||
<div class="oe_title">
|
||||
<h2>Outbound Picking:</h2>
|
||||
<h1>
|
||||
<field name="out_picking_id" readonly="1" modifiers="{'readonly': true}"/>
|
||||
</h1>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<field name="out_picking_carrier_id" string="Carrier"/>
|
||||
<field name="out_carrier_tracking_ref" string="Tracking"/>
|
||||
</group>
|
||||
</group>
|
||||
</sheet>
|
||||
<div class="oe_chatter">
|
||||
<field name="message_follower_ids" widget="mail_followers"/>
|
||||
<field name="activity_ids" widget="mail_activity"/>
|
||||
<field name="message_ids" widget="mail_thread"/>
|
||||
</div>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_rma_rma_tree" model="ir.ui.view">
|
||||
<field name="name">rma.rma.tree</field>
|
||||
<field name="model">rma.rma</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree colors="blue:state == 'draft';gray:state in ('cancel', 'done');orange:validity_date and validity_date < current_date;">
|
||||
<field name="name"/>
|
||||
<field name="template_id"/>
|
||||
<field name="stock_picking_id"/>
|
||||
<field name="partner_id"/>
|
||||
<field name="create_date"/>
|
||||
<field name="validity_date"/>
|
||||
<field name="state"/>
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_rma_rma_search" model="ir.ui.view">
|
||||
<field name="name">rma.rma.tree</field>
|
||||
<field name="model">rma.rma</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Search RMA">
|
||||
<field name="name"/>
|
||||
<field name="partner_id"/>
|
||||
<field name="template_id"/>
|
||||
<field name="stock_picking_id"/>
|
||||
<separator/>
|
||||
<filter string="New" name="new" domain="[('state', '=', 'draft')]"/>
|
||||
<filter string="Confirmed" name="confirmed" domain="[('state', '=', 'confirmed')]"/>
|
||||
<filter string="Expired" name="expired" domain="[('validity_date', '!=', False),('validity_date', '<', datetime.datetime.now())]"/>
|
||||
<group expand="0" name="group_by" string="Group By">
|
||||
<filter string="State" domain="[]" context="{'group_by': 'state'}"/>
|
||||
<filter string="Template" domain="[]" context="{'group_by': 'Template'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_rma_template_form" model="ir.ui.view">
|
||||
<field name="name">rma.template.form</field>
|
||||
<field name="model">rma.template</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="RMA Template" class="oe_form_nomargin">
|
||||
<sheet>
|
||||
<div class="oe_title">
|
||||
<h1>
|
||||
<field name="name"/>
|
||||
</h1>
|
||||
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<field name="usage"/>
|
||||
<field name="valid_days"/>
|
||||
</group>
|
||||
</group>
|
||||
<group>
|
||||
<group>
|
||||
<field name="create_in_picking"/>
|
||||
<field name="in_type_id" attrs="{'invisible': [('create_in_picking', '=', False)]}"/>
|
||||
<field name="in_location_id" attrs="{'invisible': [('create_in_picking', '=', False)]}"/>
|
||||
<field name="in_location_dest_id" attrs="{'invisible': [('create_in_picking', '=', False)]}"/>
|
||||
<field name="in_carrier_id" attrs="{'invisible': [('create_in_picking', '=', False)]}"/>
|
||||
<field name="in_require_return" attrs="{'invisible': [('create_in_picking', '=', False)]}"/>
|
||||
<field name="in_procure_method" attrs="{'invisible': [('create_in_picking', '=', False)]}"/>
|
||||
<field name="in_to_refund" attrs="{'invisible': [('create_in_picking', '=', False)]}"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="create_out_picking"/>
|
||||
<field name="out_type_id" attrs="{'invisible': [('create_out_picking', '=', False)]}"/>
|
||||
<field name="out_location_id" attrs="{'invisible': [('create_out_picking', '=', False)]}"/>
|
||||
<field name="out_location_dest_id" attrs="{'invisible': [('create_out_picking', '=', False)]}"/>
|
||||
<field name="out_carrier_id" attrs="{'invisible': [('create_out_picking', '=', False)]}"/>
|
||||
<field name="out_require_return" attrs="{'invisible': [('create_out_picking', '=', False)]}"/>
|
||||
<field name="out_procure_method" attrs="{'invisible': [('create_out_picking', '=', False)]}"/>
|
||||
<field name="out_to_refund" attrs="{'invisible': [('create_out_picking', '=', False)]}"/>
|
||||
</group>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Internal Instructions">
|
||||
<field name="description"/>
|
||||
</page>
|
||||
<page string="Customer Instructions">
|
||||
<field name="customer_description"/>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_rma_template_tree" model="ir.ui.view">
|
||||
<field name="name">rma.template.tree</field>
|
||||
<field name="model">rma.template</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree>
|
||||
<field name="name"/>
|
||||
<field name="usage"/>
|
||||
<field name="create_in_picking"/>
|
||||
<field name="create_out_picking"/>
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
|
||||
<record id="action_rma_rma" model="ir.actions.act_window">
|
||||
<field name="name">RMA</field>
|
||||
<field name="res_model">rma.rma</field>
|
||||
<field name="view_type">form</field>
|
||||
<field name="view_mode">tree,form</field>
|
||||
</record>
|
||||
|
||||
<menuitem
|
||||
action="action_rma_rma"
|
||||
name="RMA"
|
||||
id="menu_rma"
|
||||
web_icon="fa fa-cubes,#FFFFFF,#EB5A46"
|
||||
sequence="10"
|
||||
/>
|
||||
<menuitem
|
||||
action="action_rma_rma"
|
||||
id="menu_rma_rmas"
|
||||
parent="menu_rma"
|
||||
sequence="10"
|
||||
/>
|
||||
<menuitem
|
||||
id="menu_rma_configuration"
|
||||
name="Configuration"
|
||||
parent="menu_rma"
|
||||
sequence="100"
|
||||
/>
|
||||
|
||||
|
||||
<record id="action_rma_tag_form" model="ir.actions.act_window">
|
||||
<field name="name">RMA Tag</field>
|
||||
<field name="res_model">rma.tag</field>
|
||||
<field name="view_type">form</field>
|
||||
<field name="view_mode">tree,form</field>
|
||||
</record>
|
||||
|
||||
<record id="action_rma_template_form" model="ir.actions.act_window">
|
||||
<field name="name">RMA Templates</field>
|
||||
<field name="res_model">rma.template</field>
|
||||
<field name="view_type">form</field>
|
||||
<field name="view_mode">tree,form</field>
|
||||
</record>
|
||||
|
||||
<menuitem
|
||||
id="menu_rma_configuation_rma_template_form"
|
||||
name="Templates"
|
||||
action="action_rma_template_form"
|
||||
parent="menu_rma_configuration"
|
||||
sequence="21"
|
||||
/>
|
||||
<menuitem
|
||||
id="menu_rma_configuation_rma_tag_form"
|
||||
name="Tags"
|
||||
action="action_rma_tag_form"
|
||||
parent="menu_rma_configuration"
|
||||
sequence="25"
|
||||
/>
|
||||
|
||||
|
||||
|
||||
|
||||
</odoo>
|
||||
13
rma/views/stock_picking_views.xml
Normal file
13
rma/views/stock_picking_views.xml
Normal file
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_picking_withcarrier_out_form" model="ir.ui.view">
|
||||
<field name="name">delivery.stock.picking_withcarrier.form.view</field>
|
||||
<field name="model">stock.picking</field>
|
||||
<field name="inherit_id" ref="delivery.view_picking_withcarrier_out_form" />
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//button[@name='send_to_shipper']" position="attributes">
|
||||
<attribute name="attrs">{'invisible':['|','|','|',('carrier_tracking_ref','!=',False),('delivery_type','in', ['fixed', 'base_on_rule']),('delivery_type','=',False)]}</attribute>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
1
rma/wizard/__init__.py
Normal file
1
rma/wizard/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import rma_lines
|
||||
57
rma/wizard/rma_lines.py
Normal file
57
rma/wizard/rma_lines.py
Normal file
@@ -0,0 +1,57 @@
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class RMAPickingMakeLines(models.TransientModel):
|
||||
_name = 'rma.picking.make.lines'
|
||||
_description = 'Add Picking Lines'
|
||||
|
||||
rma_id = fields.Many2one('rma.rma', string='RMA')
|
||||
line_ids = fields.One2many('rma.picking.make.lines.line', 'rma_make_lines_id', string='Lines')
|
||||
|
||||
|
||||
@api.model
|
||||
def create(self, vals):
|
||||
maker = super(RMAPickingMakeLines, self).create(vals)
|
||||
maker._create_lines()
|
||||
return maker
|
||||
|
||||
def _line_values(self, move):
|
||||
return {
|
||||
'rma_make_lines_id': self.id,
|
||||
'product_id': move.product_id.id,
|
||||
'qty_ordered': move.ordered_qty,
|
||||
'qty_delivered': move.product_uom_qty,
|
||||
'product_uom_qty': 0.0,
|
||||
'product_uom_id': move.product_uom.id,
|
||||
}
|
||||
|
||||
def _create_lines(self):
|
||||
make_lines_obj = self.env['rma.picking.make.lines.line']
|
||||
|
||||
if self.rma_id.template_usage == 'stock_picking' and self.rma_id.stock_picking_id:
|
||||
for l in self.rma_id.stock_picking_id.move_lines:
|
||||
self.line_ids |= make_lines_obj.create(self._line_values(l))
|
||||
|
||||
@api.multi
|
||||
def add_lines(self):
|
||||
rma_line_obj = self.env['rma.line']
|
||||
for o in self:
|
||||
lines = o.line_ids.filtered(lambda l: l.product_uom_qty > 0.0)
|
||||
for l in lines:
|
||||
rma_line_obj.create({
|
||||
'rma_id': o.rma_id.id,
|
||||
'product_id': l.product_id.id,
|
||||
'product_uom_id': l.product_uom_id.id,
|
||||
'product_uom_qty': l.product_uom_qty,
|
||||
})
|
||||
|
||||
|
||||
class RMAPickingMakeLinesLine(models.TransientModel):
|
||||
_name = 'rma.picking.make.lines.line'
|
||||
|
||||
rma_make_lines_id = fields.Many2one('rma.picking.make.lines')
|
||||
product_id = fields.Many2one('product.product', string="Product")
|
||||
qty_ordered = fields.Float(string='Ordered')
|
||||
qty_delivered = fields.Float(string='Delivered')
|
||||
product_uom_qty = fields.Float(string='QTY')
|
||||
product_uom_id = fields.Many2one('product.uom', 'UOM')
|
||||
38
rma/wizard/rma_lines_views.xml
Normal file
38
rma/wizard/rma_lines_views.xml
Normal file
@@ -0,0 +1,38 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<odoo>
|
||||
<record id="view_rma_add_lines_form" model="ir.ui.view">
|
||||
<field name="name">view.rma.add.lines.form</field>
|
||||
<field name="model">rma.picking.make.lines</field>
|
||||
<field name="type">form</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<field name="line_ids">
|
||||
<tree editable="top">
|
||||
<field name="product_id" readonly="1"/>
|
||||
<field name="qty_ordered" readonly="1"/>
|
||||
<field name="qty_delivered" readonly="1"/>
|
||||
<field name="product_uom_qty"/>
|
||||
<field name="product_uom_id" readonly="1"/>
|
||||
</tree>
|
||||
</field>
|
||||
<footer>
|
||||
<button class="oe_highlight"
|
||||
name="add_lines"
|
||||
type="object"
|
||||
string="Add" />
|
||||
<button class="oe_link"
|
||||
special="cancel"
|
||||
string="Cancel" />
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
<record id="action_rma_add_lines" model="ir.actions.act_window">
|
||||
<field name="name">Add RMA Lines</field>
|
||||
<field name="res_model">rma.picking.make.lines</field>
|
||||
<field name="view_type">form</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="view_id" ref="view_rma_add_lines_form" />
|
||||
<field name="target">new</field>
|
||||
</record>
|
||||
</odoo>
|
||||
2
rma_sale/__init__.py
Normal file
2
rma_sale/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from . import models
|
||||
from . import wizard
|
||||
26
rma_sale/__manifest__.py
Normal file
26
rma_sale/__manifest__.py
Normal file
@@ -0,0 +1,26 @@
|
||||
# © 2018 Hibou Corp.
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
{
|
||||
'name': 'Hibou RMAs for Sale Orders',
|
||||
'version': '11.0.1.0.0',
|
||||
'category': 'Sale',
|
||||
'author': "Hibou Corp.",
|
||||
'license': 'AGPL-3',
|
||||
'website': 'https://hibou.io/',
|
||||
'depends': [
|
||||
'rma',
|
||||
'sale',
|
||||
'sales_team',
|
||||
],
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
'views/rma_views.xml',
|
||||
'wizard/rma_lines_views.xml',
|
||||
],
|
||||
'demo': [
|
||||
'data/rma_demo.xml',
|
||||
],
|
||||
'installable': True,
|
||||
'application': False,
|
||||
}
|
||||
15
rma_sale/data/rma_demo.xml
Normal file
15
rma_sale/data/rma_demo.xml
Normal file
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<odoo>
|
||||
<record id="template_sale_return" model="rma.template">
|
||||
<field name="name">Sale Return</field>
|
||||
<field name="usage">sale_order</field>
|
||||
<field name="valid_days" eval="10"/>
|
||||
<field name="create_in_picking" eval="True"/>
|
||||
<field name="in_type_id" ref="stock.picking_type_in"/>
|
||||
<field name="in_location_id" ref="stock.stock_location_customers"/>
|
||||
<field name="in_location_dest_id" ref="stock.stock_location_stock"/>
|
||||
<field name="in_procure_method">make_to_stock</field>
|
||||
<field name="in_to_refund" eval="True"/>
|
||||
<field name="in_require_return" eval="True"/>
|
||||
</record>
|
||||
</odoo>
|
||||
1
rma_sale/models/__init__.py
Normal file
1
rma_sale/models/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import rma
|
||||
118
rma_sale/models/rma.py
Normal file
118
rma_sale/models/rma.py
Normal file
@@ -0,0 +1,118 @@
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class RMATemplate(models.Model):
|
||||
_inherit = 'rma.template'
|
||||
|
||||
usage = fields.Selection(selection_add=[('sale_order', 'Sale Order')])
|
||||
|
||||
|
||||
class RMA(models.Model):
|
||||
_inherit = 'rma.rma'
|
||||
|
||||
sale_order_id = fields.Many2one('sale.order', string='Sale Order')
|
||||
sale_order_rma_count = fields.Integer('Number of RMAs for this Sale Order', compute='_compute_sale_order_rma_count')
|
||||
company_id = fields.Many2one('res.company', 'Company',
|
||||
default=lambda self: self.env['res.company']._company_default_get('sale.order'))
|
||||
|
||||
@api.multi
|
||||
@api.depends('sale_order_id')
|
||||
def _compute_sale_order_rma_count(self):
|
||||
for rma in self:
|
||||
if rma.sale_order_id:
|
||||
rma_data = self.read_group([('sale_order_id', '=', rma.sale_order_id.id), ('state', '!=', 'cancel')],
|
||||
['sale_order_id'], ['sale_order_id'])
|
||||
if rma_data:
|
||||
rma.sale_order_rma_count = rma_data[0]['sale_order_id_count']
|
||||
else:
|
||||
rma.sale_order_rma_count = 0.0
|
||||
|
||||
|
||||
@api.multi
|
||||
def open_sale_order_rmas(self):
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Sale Order RMAs'),
|
||||
'res_model': 'rma.rma',
|
||||
'view_mode': 'tree,form',
|
||||
'context': {'search_default_sale_order_id': self[0].sale_order_id.id}
|
||||
}
|
||||
|
||||
@api.onchange('template_usage')
|
||||
@api.multi
|
||||
def _onchange_template_usage(self):
|
||||
res = super(RMA, self)._onchange_template_usage()
|
||||
for rma in self.filtered(lambda rma: rma.template_usage != 'sale_order'):
|
||||
rma.sale_order_id = False
|
||||
return res
|
||||
|
||||
@api.onchange('sale_order_id')
|
||||
@api.multi
|
||||
def _onchange_sale_order_id(self):
|
||||
for rma in self.filtered(lambda rma: rma.sale_order_id):
|
||||
rma.partner_id = rma.sale_order_id.partner_id
|
||||
rma.partner_shipping_id = rma.sale_order_id.partner_shipping_id
|
||||
|
||||
|
||||
@api.multi
|
||||
def action_add_so_lines(self):
|
||||
make_line_obj = self.env['rma.sale.make.lines']
|
||||
for rma in self:
|
||||
lines = make_line_obj.create({
|
||||
'rma_id': rma.id,
|
||||
})
|
||||
action = self.env.ref('rma_sale.action_rma_add_lines').read()[0]
|
||||
action['res_id'] = lines.id
|
||||
return action
|
||||
|
||||
def _create_in_picking_sale_order(self):
|
||||
if not self.sale_order_id:
|
||||
raise UserError(_('You must have a sale order for this RMA.'))
|
||||
if not self.template_id.in_require_return:
|
||||
group_id = self.sale_order_id.procurement_group_id.id if self.sale_order_id.procurement_group_id else 0
|
||||
sale_id = self.sale_order_id
|
||||
values = self.template_id._values_for_in_picking(self)
|
||||
update = {'sale_id': sale_id, 'group_id': group_id}
|
||||
update_lines = {'group_id': group_id}
|
||||
return self._picking_from_values(values, update, update_lines)
|
||||
|
||||
lines = self.lines.filtered(lambda l: l.product_uom_qty >= 1)
|
||||
if not lines:
|
||||
raise UserError(_('You have no lines with positive quantity.'))
|
||||
product_ids = lines.mapped('product_id.id')
|
||||
|
||||
old_picking = self._find_candidate_return_picking(product_ids, self.sale_order_id.picking_ids, self.template_id.in_location_id.id)
|
||||
if not old_picking:
|
||||
raise UserError('No eligible pickings were found to return (you can only return products from the same initial picking).')
|
||||
|
||||
new_picking = self._new_in_picking(old_picking)
|
||||
self._new_in_moves(old_picking, new_picking, {})
|
||||
return new_picking
|
||||
|
||||
def _create_out_picking_sale_order(self):
|
||||
if not self.sale_order_id:
|
||||
raise UserError(_('You must have a sale order for this RMA.'))
|
||||
if not self.template_id.out_require_return:
|
||||
group_id = self.sale_order_id.procurement_group_id.id if self.sale_order_id.procurement_group_id else 0
|
||||
sale_id = self.sale_order_id
|
||||
values = self.template_id._values_for_out_picking(self)
|
||||
update = {'sale_id': sale_id, 'group_id': group_id}
|
||||
update_lines = {'to_refund_so': self.template_id.in_to_refund_so, 'group_id': group_id}
|
||||
return self._picking_from_values(values, update, update_lines)
|
||||
|
||||
lines = self.lines.filtered(lambda l: l.product_uom_qty >= 1)
|
||||
if not lines:
|
||||
raise UserError(_('You have no lines with positive quantity.'))
|
||||
product_ids = lines.mapped('product_id.id')
|
||||
|
||||
old_picking = self._find_candidate_return_picking(product_ids, self.sale_order_id.picking_ids, self.template_id.out_location_dest_id.id)
|
||||
if not old_picking:
|
||||
raise UserError(
|
||||
'No eligible pickings were found to duplicate (you can only return products from the same initial picking).')
|
||||
|
||||
new_picking = self._new_out_picking(old_picking)
|
||||
self._new_out_moves(old_picking, new_picking, {})
|
||||
return new_picking
|
||||
|
||||
|
||||
7
rma_sale/security/ir.model.access.csv
Normal file
7
rma_sale/security/ir.model.access.csv
Normal file
@@ -0,0 +1,7 @@
|
||||
"id","name","model_id:id","group_id:id","perm_read","perm_write","perm_create","perm_unlink"
|
||||
"manage_rma sale","manage rma","rma.model_rma_rma","sales_team.group_sale_salesman",1,1,1,1
|
||||
"manage_rma_line sale","manage rma line","rma.model_rma_line","sales_team.group_sale_salesman",1,1,1,1
|
||||
"manage_rma_template sale","manage rma template","rma.model_rma_template","sales_team.group_sale_manager",1,1,1,1
|
||||
"manage_rma_tag sale","manage rma tag","rma.model_rma_tag","sales_team.group_sale_manager",1,1,1,1
|
||||
"access_rma_template sale","access rma template","rma.model_rma_template","sales_team.group_sale_salesman",1,1,0,0
|
||||
"access_rma_tag sale","access rma tag","rma.model_rma_tag","sales_team.group_sale_salesman",1,0,0,0
|
||||
|
1
rma_sale/tests/__init__.py
Normal file
1
rma_sale/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import test_rma
|
||||
113
rma_sale/tests/test_rma.py
Normal file
113
rma_sale/tests/test_rma.py
Normal file
@@ -0,0 +1,113 @@
|
||||
from odoo.addons.rma.tests.test_rma import TestRMA
|
||||
from odoo.exceptions import UserError, ValidationError
|
||||
|
||||
|
||||
class TestRMASale(TestRMA):
|
||||
|
||||
def setUp(self):
|
||||
super(TestRMASale, self).setUp()
|
||||
self.template_sale_return = self.env.ref('rma_sale.template_sale_return')
|
||||
|
||||
def test_20_sale_return(self):
|
||||
self.product1.tracking = 'serial'
|
||||
order = self.env['sale.order'].create({
|
||||
'partner_id': self.partner1.id,
|
||||
'partner_invoice_id': self.partner1.id,
|
||||
'partner_shipping_id': self.partner1.id,
|
||||
'order_line': [(0, 0, {
|
||||
'product_id': self.product1.id,
|
||||
'product_uom_qty': 1.0,
|
||||
'product_uom': self.product1.uom_id.id,
|
||||
'price_unit': 10.0,
|
||||
})]
|
||||
})
|
||||
order.action_confirm()
|
||||
self.assertTrue(order.state in ('sale', 'done'))
|
||||
self.assertEqual(len(order.picking_ids), 1, 'Tests only run with single stage delivery.')
|
||||
|
||||
# Try to RMA item not delivered yet
|
||||
rma = self.env['rma.rma'].create({
|
||||
'template_id': self.template_sale_return.id,
|
||||
'partner_id': self.partner1.id,
|
||||
'partner_shipping_id': self.partner1.id,
|
||||
'sale_order_id': order.id,
|
||||
})
|
||||
self.assertEqual(rma.state, 'draft')
|
||||
wizard = self.env['rma.sale.make.lines'].create({
|
||||
'rma_id': rma.id,
|
||||
})
|
||||
self.assertEqual(wizard.line_ids.qty_delivered, 0.0)
|
||||
wizard.line_ids.product_uom_qty = 1.0
|
||||
wizard.add_lines()
|
||||
self.assertEqual(len(rma.lines), 1)
|
||||
with self.assertRaises(UserError):
|
||||
rma.action_confirm()
|
||||
|
||||
order.picking_ids.force_assign()
|
||||
pack_opt = order.picking_ids.move_line_ids[0]
|
||||
lot = self.env['stock.production.lot'].create({
|
||||
'product_id': self.product1.id,
|
||||
'name': 'X100',
|
||||
'product_uom_id': self.product1.uom_id.id,
|
||||
})
|
||||
pack_opt.qty_done = 1.0
|
||||
pack_opt.lot_id = lot
|
||||
order.picking_ids.do_transfer()
|
||||
self.assertEqual(order.picking_ids.state, 'done')
|
||||
wizard = self.env['rma.sale.make.lines'].create({
|
||||
'rma_id': rma.id,
|
||||
})
|
||||
self.assertEqual(wizard.line_ids.qty_delivered, 1.0)
|
||||
|
||||
# Confirm RMA
|
||||
rma.action_confirm()
|
||||
self.assertEqual(rma.in_picking_id.state, 'assigned')
|
||||
pack_opt = rma.in_picking_id.move_line_ids[0]
|
||||
|
||||
with self.assertRaises(UserError):
|
||||
rma.action_done()
|
||||
|
||||
pack_opt.lot_id = lot
|
||||
pack_opt.qty_done = 1.0
|
||||
rma.in_picking_id.do_transfer()
|
||||
rma.action_done()
|
||||
|
||||
# Make another RMA for the same sale order
|
||||
rma2 = self.env['rma.rma'].create({
|
||||
'template_id': self.template_sale_return.id,
|
||||
'partner_id': self.partner1.id,
|
||||
'partner_shipping_id': self.partner1.id,
|
||||
'sale_order_id': order.id,
|
||||
})
|
||||
wizard = self.env['rma.sale.make.lines'].create({
|
||||
'rma_id': rma2.id,
|
||||
})
|
||||
# The First completed RMA will have "un-delivered" it for invoicing purposes.
|
||||
self.assertEqual(wizard.line_ids.qty_delivered, 0.0)
|
||||
wizard.line_ids.product_uom_qty = 1.0
|
||||
wizard.add_lines()
|
||||
self.assertEqual(len(rma2.lines), 1)
|
||||
|
||||
rma2.action_confirm()
|
||||
|
||||
# In Odoo 10, this would not have been able to reserve.
|
||||
# In Odoo 11, reservation can still happen, but at least we can't move the same lot twice!
|
||||
# self.assertEqual(rma2.in_picking_id.state, 'confirmed')
|
||||
|
||||
# Requires Lot
|
||||
with self.assertRaises(UserError):
|
||||
rma2.in_picking_id.move_line_ids.write({'qty_done': 1.0})
|
||||
rma2.in_picking_id.do_transfer()
|
||||
|
||||
# Assign existing lot
|
||||
rma2.in_picking_id.move_line_ids.write({
|
||||
'lot_id': lot.id
|
||||
})
|
||||
|
||||
# Existing lot cannot be re-used.
|
||||
with self.assertRaises(ValidationError):
|
||||
rma2.in_picking_id.action_done()
|
||||
|
||||
# RMA cannot be completed because the inbound picking state is confirmed
|
||||
with self.assertRaises(UserError):
|
||||
rma2.action_done()
|
||||
65
rma_sale/views/rma_views.xml
Normal file
65
rma_sale/views/rma_views.xml
Normal file
@@ -0,0 +1,65 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- RMA -->
|
||||
<record id="view_rma_rma_form_sale" model="ir.ui.view">
|
||||
<field name="name">rma.rma.form.sale</field>
|
||||
<field name="model">rma.rma</field>
|
||||
<field name="inherit_id" ref="rma.view_rma_rma_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//div[@name='button_box']" position="inside">
|
||||
<button class="oe_stat_button" name="open_sale_order_rmas" icon="fa-cubes"
|
||||
type="object" attrs="{'invisible': ['|', ('sale_order_id', '=', False), ('sale_order_rma_count', '<=', 1)]}">
|
||||
<field name="sale_order_rma_count" string="SO RMAs" widget="statinfo" />
|
||||
</button>
|
||||
</xpath>
|
||||
<xpath expr="//field[@name='template_id']" position="after">
|
||||
<field name="sale_order_id" options="{'no_create': True}" attrs="{'invisible': [('template_usage', '!=', 'sale_order')], 'required': [('template_usage', '=', 'sale_order')], 'readonly': [('state', 'in', ('confirmed', 'done', 'cancel'))]}"/>
|
||||
<br/>
|
||||
<button string="Add lines" type="object" name="action_add_so_lines" attrs="{'invisible': ['|', ('sale_order_id', '=', False), ('state', '!=', 'draft')]}"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_rma_rma_tree_sale" model="ir.ui.view">
|
||||
<field name="name">rma.rma.tree.sale</field>
|
||||
<field name="model">rma.rma</field>
|
||||
<field name="inherit_id" ref="rma.view_rma_rma_tree"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='template_id']" position="after">
|
||||
<field name="sale_order_id"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_rma_rma_search_sale" model="ir.ui.view">
|
||||
<field name="name">rma.rma.tree.sale</field>
|
||||
<field name="model">rma.rma</field>
|
||||
<field name="inherit_id" ref="rma.view_rma_rma_search"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='template_id']" position="after">
|
||||
<field name="sale_order_id"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<menuitem
|
||||
action="rma.action_rma_rma"
|
||||
id="menu_action_sales_rma_form"
|
||||
parent="sale.sale_order_menu"
|
||||
sequence="12"
|
||||
/>
|
||||
|
||||
<menuitem
|
||||
action="rma.action_rma_template_form"
|
||||
id="menu_action_sales_rma_template_form"
|
||||
parent="sale.menu_sale_config"
|
||||
sequence="12"
|
||||
/>
|
||||
<menuitem
|
||||
action="rma.action_rma_tag_form"
|
||||
id="menu_action_sales_rma_tag_form"
|
||||
parent="sale.menu_sale_config"
|
||||
sequence="12"
|
||||
/>
|
||||
</odoo>
|
||||
2
rma_sale/wizard/__init__.py
Normal file
2
rma_sale/wizard/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import rma_lines
|
||||
61
rma_sale/wizard/rma_lines.py
Normal file
61
rma_sale/wizard/rma_lines.py
Normal file
@@ -0,0 +1,61 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class RMASaleMakeLines(models.TransientModel):
|
||||
_name = 'rma.sale.make.lines'
|
||||
_description = 'Add SO Lines'
|
||||
|
||||
rma_id = fields.Many2one('rma.rma', string='RMA')
|
||||
line_ids = fields.One2many('rma.sale.make.lines.line', 'rma_make_lines_id', string='Lines')
|
||||
|
||||
|
||||
@api.model
|
||||
def create(self, vals):
|
||||
maker = super(RMASaleMakeLines, self).create(vals)
|
||||
maker._create_lines()
|
||||
return maker
|
||||
|
||||
def _line_values(self, so_line):
|
||||
return {
|
||||
'rma_make_lines_id': self.id,
|
||||
'product_id': so_line.product_id.id,
|
||||
'qty_ordered': so_line.product_uom_qty,
|
||||
'qty_delivered': so_line.qty_delivered,
|
||||
'qty_invoiced': so_line.qty_invoiced,
|
||||
'product_uom_qty': 0.0,
|
||||
'product_uom_id': so_line.product_uom.id,
|
||||
}
|
||||
|
||||
def _create_lines(self):
|
||||
make_lines_obj = self.env['rma.sale.make.lines.line']
|
||||
|
||||
if self.rma_id.template_usage == 'sale_order' and self.rma_id.sale_order_id:
|
||||
for l in self.rma_id.sale_order_id.order_line:
|
||||
self.line_ids |= make_lines_obj.create(self._line_values(l))
|
||||
|
||||
@api.multi
|
||||
def add_lines(self):
|
||||
rma_line_obj = self.env['rma.line']
|
||||
for o in self:
|
||||
lines = o.line_ids.filtered(lambda l: l.product_uom_qty > 0.0)
|
||||
for l in lines:
|
||||
rma_line_obj.create({
|
||||
'rma_id': o.rma_id.id,
|
||||
'product_id': l.product_id.id,
|
||||
'product_uom_id': l.product_uom_id.id,
|
||||
'product_uom_qty': l.product_uom_qty,
|
||||
})
|
||||
|
||||
|
||||
class RMASOMakeLinesLine(models.TransientModel):
|
||||
_name = 'rma.sale.make.lines.line'
|
||||
|
||||
rma_make_lines_id = fields.Many2one('rma.sale.make.lines')
|
||||
product_id = fields.Many2one('product.product', string="Product")
|
||||
qty_ordered = fields.Float(string='Ordered')
|
||||
qty_invoiced = fields.Float(string='Invoiced')
|
||||
qty_delivered = fields.Float(string='Delivered')
|
||||
product_uom_qty = fields.Float(string='QTY')
|
||||
product_uom_id = fields.Many2one('product.uom', 'UOM')
|
||||
39
rma_sale/wizard/rma_lines_views.xml
Normal file
39
rma_sale/wizard/rma_lines_views.xml
Normal file
@@ -0,0 +1,39 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<odoo>
|
||||
<record id="view_rma_add_lines_form" model="ir.ui.view">
|
||||
<field name="name">view.rma.add.lines.form</field>
|
||||
<field name="model">rma.sale.make.lines</field>
|
||||
<field name="type">form</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<field name="line_ids">
|
||||
<tree editable="top">
|
||||
<field name="product_id" readonly="1"/>
|
||||
<field name="qty_ordered" readonly="1"/>
|
||||
<field name="qty_delivered" readonly="1"/>
|
||||
<field name="qty_invoiced" readonly="1"/>
|
||||
<field name="product_uom_qty"/>
|
||||
<field name="product_uom_id" readonly="1"/>
|
||||
</tree>
|
||||
</field>
|
||||
<footer>
|
||||
<button class="oe_highlight"
|
||||
name="add_lines"
|
||||
type="object"
|
||||
string="Add" />
|
||||
<button class="oe_link"
|
||||
special="cancel"
|
||||
string="Cancel" />
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
<record id="action_rma_add_lines" model="ir.actions.act_window">
|
||||
<field name="name">Add RMA Lines</field>
|
||||
<field name="res_model">rma.sale.make.lines</field>
|
||||
<field name="view_type">form</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="view_id" ref="view_rma_add_lines_form" />
|
||||
<field name="target">new</field>
|
||||
</record>
|
||||
</odoo>
|
||||
2
stock_warehouse_procurement/__init__.py
Normal file
2
stock_warehouse_procurement/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from . import wizard
|
||||
from . import models
|
||||
20
stock_warehouse_procurement/__manifest__.py
Normal file
20
stock_warehouse_procurement/__manifest__.py
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
'name': 'Reorder Rules per Warehouse',
|
||||
'version': '11.0.1.0.0',
|
||||
'category': 'Warehouse',
|
||||
'depends': [
|
||||
'stock',
|
||||
],
|
||||
'description': """
|
||||
Extends `stock.scheduler.compute` wizard to allow running on demand per-warehouse.
|
||||
|
||||
""",
|
||||
'author': "Hibou Corp.",
|
||||
'license': 'AGPL-3',
|
||||
'website': 'https://hibou.io/',
|
||||
'data': [
|
||||
'wizard/stock_scheduler_compute_views.xml',
|
||||
],
|
||||
'installable': True,
|
||||
'application': False,
|
||||
}
|
||||
1
stock_warehouse_procurement/models/__init__.py
Normal file
1
stock_warehouse_procurement/models/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import procurement
|
||||
12
stock_warehouse_procurement/models/procurement.py
Normal file
12
stock_warehouse_procurement/models/procurement.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from odoo import models
|
||||
|
||||
|
||||
class ProcurementGroup(models.Model):
|
||||
_inherit = 'procurement.group'
|
||||
|
||||
def _get_orderpoint_domain(self, company_id=False):
|
||||
domain = super(ProcurementGroup, self)._get_orderpoint_domain(company_id)
|
||||
warehouse_id = self.env.context.get('warehouse_id', None)
|
||||
if warehouse_id:
|
||||
domain.append(('warehouse_id', '=', warehouse_id))
|
||||
return domain
|
||||
1
stock_warehouse_procurement/wizard/__init__.py
Normal file
1
stock_warehouse_procurement/wizard/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import stock_scheduler_compute
|
||||
@@ -0,0 +1,14 @@
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class StockSchedulerCompute(models.TransientModel):
|
||||
_inherit = 'stock.scheduler.compute'
|
||||
|
||||
warehouse_id = fields.Many2one('stock.warehouse', string='Warehouse')
|
||||
|
||||
@api.multi
|
||||
def procure_calculation(self):
|
||||
self.ensure_one()
|
||||
if self.warehouse_id:
|
||||
self = self.with_context(warehouse_id=self.warehouse_id.id)
|
||||
return super(StockSchedulerCompute, self).procure_calculation()
|
||||
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<odoo>
|
||||
<record id="view_procurement_compute_wizard" model="ir.ui.view">
|
||||
<field name="name">Run Schedulers Manually</field>
|
||||
<field name="model">stock.scheduler.compute</field>
|
||||
<field name="inherit_id" ref="stock.view_procurement_compute_wizard"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//p" position="after">
|
||||
<group>
|
||||
<field name="warehouse_id"/>
|
||||
</group>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
Reference in New Issue
Block a user